From 56413b0847c3b0ad59b5677ab2f1e0f6cc12400d Mon Sep 17 00:00:00 2001 From: Javi Fontan Date: Sat, 17 Jul 2021 17:56:32 +0200 Subject: [PATCH] Protect admin page with login --- README.md | 4 +- go.mod | 2 + go.sum | 2 + server/assets/login.html | 12 +++ server/auth.go | 158 +++++++++++++++++++++++++++++++++ server/cmd/glslsandbox/main.go | 43 ++++++++- server/server.go | 42 ++++++++- server/store/users.go | 4 +- server/store/users_test.go | 4 +- 9 files changed, 260 insertions(+), 11 deletions(-) create mode 100644 server/assets/login.html create mode 100644 server/auth.go diff --git a/README.md b/README.md index e5ce31b..969e513 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ $ go build ./server/cmd/glslsandbox $ ./glslsandbox ``` +* The first time it starts it creates an admin user and the credentials are printed. + * The server should be accessible on http://localhost:8888 * Admin interface is on http://localhost:8888/admin @@ -105,4 +107,4 @@ By default the data files are read from `./data`. This path can be hanged with t $ DATA_PATH=/my/data/directory ./glslsandbox ``` -The data directory contains the sqlite database (`glslsandbox.db`) and the thumbnails (`thumbs` directory). \ No newline at end of file +The data directory contains the sqlite database (`glslsandbox.db`) and the thumbnails (`thumbs` directory). diff --git a/go.mod b/go.mod index 53e4761..f8cca6e 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.16 require ( github.com/davecgh/go-spew v1.1.1 github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8 + github.com/golang-jwt/jwt v3.2.1+incompatible github.com/jmoiron/sqlx v1.3.4 github.com/kelseyhightower/envconfig v1.4.0 github.com/kr/text v0.2.0 // indirect @@ -14,6 +15,7 @@ require ( github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect github.com/sirupsen/logrus v1.8.1 github.com/stretchr/testify v1.7.0 + golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect modernc.org/sqlite v1.11.2 ) diff --git a/go.sum b/go.sum index b55b7ca..8dac4ec 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8 h1:DujepqpGd1hyOd7a github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c= +github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/google/go-cmp v0.5.3 h1:x95R7cp+rSeeqAMI2knLtQ0DKlaBhv2NrtrOvafPHRo= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/jmoiron/sqlx v1.3.4 h1:wv+0IJZfL5z0uZoUjlpKgHkgaFSYD+r9CfrXjEXsO7w= diff --git a/server/assets/login.html b/server/assets/login.html new file mode 100644 index 0000000..d3b0cda --- /dev/null +++ b/server/assets/login.html @@ -0,0 +1,12 @@ + + + +
+ +
+ +
+ +
+ + \ No newline at end of file diff --git a/server/auth.go b/server/auth.go new file mode 100644 index 0000000..5fda370 --- /dev/null +++ b/server/auth.go @@ -0,0 +1,158 @@ +package server + +import ( + "fmt" + "net/http" + "time" + + "github.com/golang-jwt/jwt" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + "github.com/mrdoob/glsl-sandbox/server/store" + "golang.org/x/crypto/bcrypt" +) + +const ( + accessTokenCookieName = "access-token" + tokenDuration = time.Hour * 24 * 30 + bcryptCost = 8 +) + +var ( + ErrNotAuthorized = fmt.Errorf("user not authorized") +) + +type Claims struct { + jwt.StandardClaims + + Name string `json:"name"` + Role store.Role `json:"role"` +} + +type Auth struct { + users *store.Users + secret string +} + +func NewAuth(users *store.Users, secret string) *Auth { + return &Auth{ + users: users, + secret: secret, + } +} + +func (a *Auth) GenerateToken(c echo.Context, u store.User) error { + if u.Name == "" { + return fmt.Errorf("invalid name") + } + if u.Role == "" { + return fmt.Errorf("invalid role") + } + + expirationTime := time.Now().Add(tokenDuration) + claims := Claims{ + Name: u.Name, + Role: u.Role, + StandardClaims: jwt.StandardClaims{ + ExpiresAt: expirationTime.Unix(), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, &claims) + + tokenString, err := token.SignedString([]byte(a.secret)) + if err != nil { + return fmt.Errorf("could not generate token: %w", err) + } + + cookie := http.Cookie{ + Name: accessTokenCookieName, + Value: tokenString, + Expires: expirationTime, + Path: "/", + HttpOnly: true, + } + c.SetCookie(&cookie) + + return nil +} + +func (a *Auth) Add( + name string, + password string, + email string, + role store.Role, +) error { + hashedPassword, err := bcrypt.GenerateFromPassword( + []byte(password), bcryptCost) + if err != nil { + return fmt.Errorf("could not hash password: %w", err) + } + u := store.User{ + Name: name, + Password: hashedPassword, + Email: email, + Role: role, + Active: true, + CreatedAt: time.Now(), + } + return a.users.Add(u) +} + +func (a *Auth) Login(c echo.Context, name, password string) error { + u, err := a.users.User(name) + if err != nil { + return err + } + + err = bcrypt.CompareHashAndPassword(u.Password, []byte(password)) + if err != nil { + return fmt.Errorf("invalid password: %w", err) + } + + err = a.GenerateToken(c, u) + if err != nil { + return fmt.Errorf("could not generate cookie: %w", err) + } + + return nil +} + +func (a *Auth) CheckPermissions(c echo.Context) error { + user := c.Get("user") + if user == nil { + return fmt.Errorf("token not set") + } + + u, ok := user.(*jwt.Token) + if !ok { + return fmt.Errorf("malformed token") + } + + err := u.Claims.Valid() + if err != nil { + return fmt.Errorf("invalid claims: %w", err) + } + + claims, ok := u.Claims.(*Claims) + if !ok { + return fmt.Errorf("invalid claims") + } + + if claims.Role != store.RoleAdmin && claims.Role != store.RoleModerator { + return fmt.Errorf("not enough permissions: %s", claims.Role) + } + + return nil +} + +func (a *Auth) Middleware( + f func(error, echo.Context) error, +) echo.MiddlewareFunc { + return middleware.JWTWithConfig(middleware.JWTConfig{ + Claims: new(Claims), + SigningKey: []byte(a.secret), + TokenLookup: "cookie:" + accessTokenCookieName, + ErrorHandlerWithContext: f, + }) +} diff --git a/server/cmd/glslsandbox/main.go b/server/cmd/glslsandbox/main.go index fba4d67..d195c1c 100644 --- a/server/cmd/glslsandbox/main.go +++ b/server/cmd/glslsandbox/main.go @@ -1,9 +1,13 @@ package main import ( + "crypto/md5" + "encoding/hex" "fmt" + "math/rand" "os" "path/filepath" + "time" "github.com/jmoiron/sqlx" "github.com/kelseyhightower/envconfig" @@ -14,8 +18,9 @@ import ( const dbName = "glslsandbox.db" type Config struct { - DataPath string `envconfig:"DATA_PATH" default:"./data"` - Import string `envconfig:"IMPORT"` + DataPath string `envconfig:"DATA_PATH" default:"./data"` + Import string `envconfig:"IMPORT"` + AuthSecret string `envconfig:"AUTH_SECRET" default:"secret"` } func main() { @@ -46,6 +51,18 @@ func start() error { return fmt.Errorf("could not initialize effects database: %w", err) } + users, err := store.NewUsers(db) + if err != nil { + return fmt.Errorf("could not initialize users database: %w", err) + } + + auth := server.NewAuth(users, cfg.AuthSecret) + + err = createUser(auth, users) + if err != nil { + return err + } + if cfg.Import != "" { err = importDatabase(effects, cfg.Import) if err != nil { @@ -53,7 +70,7 @@ func start() error { } } - s := server.New(effects, cfg.DataPath) + s := server.New(effects, auth, cfg.DataPath) return s.Start() } @@ -77,3 +94,23 @@ func importDatabase(effects *store.Effects, file string) error { return nil } + +func createUser(auth *server.Auth, users *store.Users) error { + _, err := users.User("admin") + if err == nil { + return nil + } + + b := make([]byte, 16) + rand.Seed(time.Now().UnixNano()) + rand.Read(b) + m := md5.Sum(b) + password := hex.EncodeToString(m[:]) + err = auth.Add("admin", password, "", store.RoleAdmin) + if err != nil { + return fmt.Errorf("could not create admin user: %w", err) + } + + fmt.Printf("created user 'admin' with password '%s'", password) + return nil +} diff --git a/server/server.go b/server/server.go index 0926952..af46316 100644 --- a/server/server.go +++ b/server/server.go @@ -61,10 +61,11 @@ type Server struct { echo *echo.Echo template *Template effects *store.Effects + auth *Auth dataPath string } -func New(e *store.Effects, dataPath string) *Server { +func New(e *store.Effects, auth *Auth, dataPath string) *Server { t := template.New("") t = t.Funcs(template.FuncMap{ "checkboxID": func(id int) string { @@ -85,6 +86,7 @@ func New(e *store.Effects, dataPath string) *Server { templates: t, }, effects: e, + auth: auth, dataPath: dataPath, } } @@ -106,13 +108,23 @@ func (s *Server) routes() { s.echo.GET("/e", s.effectHandler) s.echo.POST("/e", s.saveHandler) s.echo.GET("/item/:id", s.itemHandler) - s.echo.GET("/admin", s.adminHandler) - s.echo.POST("/admin", s.adminPostHandler) s.echo.Static("/thumbs", filepath.Join(s.dataPath, "thumbs")) s.echo.Static("/css", "./server/assets/css") s.echo.Static("/js", "./server/assets/js") s.echo.File("/diff", "./server/assets/diff.html") + + s.echo.File("/login", "./server/assets/login.html") + s.echo.POST("/login", s.loginHandler) + + admin := s.echo.Group("/admin") + admin.Use(s.auth.Middleware(func(err error, c echo.Context) error { + c.Logger().Errorf("not authorized: %s", err.Error()) + return c.Redirect(http.StatusSeeOther, "/login") + })) + + admin.GET("", s.adminHandler) + admin.POST("", s.adminPostHandler) } func (s *Server) indexHandler(c echo.Context) error { @@ -369,6 +381,30 @@ func (s *Server) adminPostHandler(c echo.Context) error { return c.Redirect(http.StatusSeeOther, url) } +type loginData struct { + Name string `form:"name"` + Password string `form:"password"` +} + +func (s *Server) loginHandler(c echo.Context) error { + log := c.Logger() + + var l loginData + err := c.Bind(&l) + if err != nil { + log.Errorf("malformed form: %s", err.Error()) + return c.Redirect(http.StatusSeeOther, "/login") + } + + err = s.auth.Login(c, l.Name, l.Password) + if err != nil { + log.Errorf("could not authenticate: %s", err.Error()) + return c.Redirect(http.StatusSeeOther, "/login") + } + + return c.Redirect(http.StatusSeeOther, "/admin") +} + func thumbPath(dataPath string, id int) string { return filepath.Join(dataPath, "thumbs", fmt.Sprintf("%d.png", id)) } diff --git a/server/store/users.go b/server/store/users.go index bc13131..d701afa 100644 --- a/server/store/users.go +++ b/server/store/users.go @@ -18,7 +18,7 @@ const ( type User struct { ID int `db:"id"` Name string `db:"name"` - Password string `db:"password"` + Password []byte `db:"password"` Email string `db:"email"` Role Role `db:"role"` Active bool `db:"active"` @@ -30,7 +30,7 @@ const ( CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, - password TEXT, + password BLOB, email TEXT, role TEXT, active INTEGER, diff --git a/server/store/users_test.go b/server/store/users_test.go index 92371e2..28c5f26 100644 --- a/server/store/users_test.go +++ b/server/store/users_test.go @@ -15,7 +15,7 @@ var ( testTime = time.Date(2021, time.January, 1, 0, 0, 0, 0, time.UTC) testUser = User{ Name: "test", - Password: "password", + Password: []byte("password"), Email: "email", Role: RoleAdmin, Active: true, @@ -57,7 +57,7 @@ func TestUserUpdate(t *testing.T) { expected := User{ Name: "test", - Password: "newpassword", + Password: []byte("newpassword"), Email: "newemail", Role: RoleModerator, Active: false,