Skip to content

Commit

Permalink
Protect admin page with login
Browse files Browse the repository at this point in the history
  • Loading branch information
jfontan committed Jul 17, 2021
1 parent d82c158 commit 56413b0
Show file tree
Hide file tree
Showing 9 changed files with 260 additions and 11 deletions.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
The data directory contains the sqlite database (`glslsandbox.db`) and the thumbnails (`thumbs` directory).
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
12 changes: 12 additions & 0 deletions server/assets/login.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<body>
<form action="/login" method="POST">
<label for="name">Username</label>
<input type="text" id="name" name="name"/><br/>
<label for="password">Password</label>
<input type="password" id="password" name="password"/><br/>
<input type="submit" value="Submit"/>
</form>
</body>
</html>
158 changes: 158 additions & 0 deletions server/auth.go
Original file line number Diff line number Diff line change
@@ -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,
})
}
43 changes: 40 additions & 3 deletions server/cmd/glslsandbox/main.go
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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() {
Expand Down Expand Up @@ -46,14 +51,26 @@ 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 {
return fmt.Errorf("could not import database: %w", err)
}
}

s := server.New(effects, cfg.DataPath)
s := server.New(effects, auth, cfg.DataPath)
return s.Start()
}

Expand All @@ -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
}
42 changes: 39 additions & 3 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -85,6 +86,7 @@ func New(e *store.Effects, dataPath string) *Server {
templates: t,
},
effects: e,
auth: auth,
dataPath: dataPath,
}
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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))
}
Expand Down
4 changes: 2 additions & 2 deletions server/store/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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,
Expand Down
Loading

0 comments on commit 56413b0

Please sign in to comment.