Authentication
+This page is reserved admin users only. If you're not an admin, + please go back. +
+ +From 3a64c14c46181a5169dc142df900d7c5f39c51e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Freitas?= <1160907@isep.ipp.pt> Date: Sat, 7 Dec 2024 16:16:53 +0000 Subject: [PATCH] feat: protect sensitive routes with role based access (#3) * feat: add authentication management using bolt db * feat: check if an admin user already exists * feat: add service for authenticating users and registering admins * chore: add colors to console logger prefixes * feat: add authorization service * feat: guard sensitive routes with middleware that checks if request has origin from an admin user * feat: add authentication and create admin user pages --- cmd/master/master.go | 21 +++++ internal/data/db/auth.go | 21 +++++ internal/data/db/db-bolt/db.go | 4 +- internal/data/db/db-bolt/util.go | 2 + internal/data/db/table.go | 4 +- internal/data/db/user.go | 20 +++++ internal/data/models/role.go | 8 ++ internal/data/models/user.go | 25 ++++++ internal/data/models/user_test.go | 33 +++++++ internal/data/repositories/auth+db.go | 70 +++++++++++++++ internal/data/repositories/auth.go | 8 ++ internal/data/repositories/user+db.go | 38 ++++++++ internal/data/repositories/user.go | 5 ++ internal/http/cookie.go | 30 +++++++ internal/http/ctx.go | 2 + internal/http/handler+user.go | 63 ++++++++++++++ internal/http/handler.go | 31 +++++-- internal/http/middleware.go | 21 +++++ internal/http/route.go | 2 + internal/http/static.go | 2 + internal/logging/console.go | 18 ++-- internal/service/authentication.go | 121 ++++++++++++++++++++++++++ internal/service/authorization.go | 62 +++++++++++++ public/index.css | 4 + public/tpl/user/index.gohtml | 106 ++++++++++++++++++++++ public/tpl/user/new/index.gohtml | 109 +++++++++++++++++++++++ 26 files changed, 816 insertions(+), 14 deletions(-) create mode 100644 internal/data/db/auth.go create mode 100644 internal/data/db/user.go create mode 100644 internal/data/models/role.go create mode 100644 internal/data/models/user.go create mode 100644 internal/data/models/user_test.go create mode 100644 internal/data/repositories/auth+db.go create mode 100644 internal/data/repositories/auth.go create mode 100644 internal/data/repositories/user+db.go create mode 100644 internal/data/repositories/user.go create mode 100644 internal/http/cookie.go create mode 100644 internal/http/handler+user.go create mode 100644 internal/service/authentication.go create mode 100644 internal/service/authorization.go create mode 100644 public/tpl/user/index.gohtml create mode 100644 public/tpl/user/new/index.gohtml diff --git a/cmd/master/master.go b/cmd/master/master.go index 458d037..11dba32 100644 --- a/cmd/master/master.go +++ b/cmd/master/master.go @@ -64,6 +64,8 @@ func main() { e := initializeHttpServer(ctx, env.ServerHost, env.ServerPort, env.ServerTLSCert, env.ServerTLSKey, env.ServerVirtualHost) defer e.Close() + go logIfAdminNeedsRegistration(sc.Authentication) + // 6. Await termination... c := make(chan os.Signal, 1) signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) @@ -249,15 +251,34 @@ func updateServiceContainer( ) http.ServiceContainer { zps := event.NewZeroMQEventPubSub(s) stt, ok := db.Table(dbb.TableSpeedtest) + crt, _ := db.Table(dbb.TableCredentials) + ust, _ := db.Table(dbb.TableUser) + if !ok { logging.LogFatal("table %s hasn't been initialized", dbb.TableSpeedtest) } sps := repositories.NewDatabaseSpeedtestStoreRepository(stt.(dbb.SpeedtestTable)) + authRepo := repositories.NewDatabaseAuthenticationRepository(crt.(dbb.CredentialsTable), ust.(dbb.UserTable)) + userRepo := repositories.NewDatabaseUserRepository(ust.(dbb.UserTable)) + + tokens := service.TokenBucket{} sc.NodeCommander = service.NewNodeCommanderService(zps, zps) sc.NodeSpeedtest = service.NewNodeSpeedtestService(zps, zps, sps) sc.Network = service.NewNetworkService(zps) sc.Networking = service.NewNetworkingService() + sc.Authentication = service.NewAuthenticationService(authRepo, userRepo, &tokens) + sc.Authorization = service.NewAuthorizationService(&tokens) return sc } + +func logIfAdminNeedsRegistration( + as *service.AuthenticationService, +) { + if !as.NeedsAdminRegistration() { + return + } + + logging.LogWarning("no admin account has been registered yet! register one now at /dashboard") +} diff --git a/internal/data/db/auth.go b/internal/data/db/auth.go new file mode 100644 index 0000000..4c08567 --- /dev/null +++ b/internal/data/db/auth.go @@ -0,0 +1,21 @@ +package db + +type CredentialsTable CrudTable[CredentialsEntity, string] +type CredentialsEntity struct { + Username string + Password string +} + +func NewCredentialsEntity( + username string, + password string, +) CredentialsEntity { + return CredentialsEntity{ + Username: username, + Password: password, + } +} + +func (e CredentialsEntity) PK() string { + return e.Username +} diff --git a/internal/data/db/db-bolt/db.go b/internal/data/db/db-bolt/db.go index 52c2a3f..8ecdb54 100644 --- a/internal/data/db/db-bolt/db.go +++ b/internal/data/db/db-bolt/db.go @@ -27,9 +27,11 @@ func (bdb *BoltDatabase) Open() error { } stt := NewBoltCrudTable[db.SpeedtestEntity](db.TableSpeedtest, bbdb) + crt := NewBoltCrudTable[db.CredentialsEntity](db.TableCredentials, bbdb) + ust := NewBoltCrudTable[db.UserEntity](db.TableUser, bbdb) bdb.DB = bbdb - bdb.tables = []db.Table{stt} + bdb.tables = []db.Table{stt, crt, ust} return nil } diff --git a/internal/data/db/db-bolt/util.go b/internal/data/db/db-bolt/util.go index 5ad81a6..c6a5e25 100644 --- a/internal/data/db/db-bolt/util.go +++ b/internal/data/db/db-bolt/util.go @@ -9,4 +9,6 @@ import ( // Register here all entities that will be persisted in a bolt database. func init() { gob.Register(db.SpeedtestEntity{}) + gob.Register(db.UserEntity{}) + gob.Register(db.CredentialsEntity{}) } diff --git a/internal/data/db/table.go b/internal/data/db/table.go index 64c9cb9..060f977 100644 --- a/internal/data/db/table.go +++ b/internal/data/db/table.go @@ -2,7 +2,9 @@ package db // All database tables managed by the master node. const ( - TableSpeedtest = "node.speedtests" + TableSpeedtest = "node.speedtests" + TableCredentials = "auth.credentials" + TableUser = "auth.user" ) type Table interface { diff --git a/internal/data/db/user.go b/internal/data/db/user.go new file mode 100644 index 0000000..3b3c76b --- /dev/null +++ b/internal/data/db/user.go @@ -0,0 +1,20 @@ +package db + +import "github.com/guackamolly/zero-monitor/internal/data/models" + +type UserTable CrudTable[UserEntity, string] +type UserEntity struct { + models.User +} + +func NewUserEntity( + user models.User, +) UserEntity { + return UserEntity{ + User: user, + } +} + +func (e UserEntity) PK() string { + return e.ID() +} diff --git a/internal/data/models/role.go b/internal/data/models/role.go new file mode 100644 index 0000000..0aa915d --- /dev/null +++ b/internal/data/models/role.go @@ -0,0 +1,8 @@ +package models + +const ( + AdminRole Role = iota + 1 + GuestRole +) + +type Role int diff --git a/internal/data/models/user.go b/internal/data/models/user.go new file mode 100644 index 0000000..d8528cd --- /dev/null +++ b/internal/data/models/user.go @@ -0,0 +1,25 @@ +package models + +import "strings" + +type User struct { + Role + Username string +} + +func NewAdminUser( + username string, +) User { + return User{ + Username: username, + Role: AdminRole, + } +} + +func (u User) ID() string { + return strings.ToLower(u.Username) +} + +func (u User) IsAdmin() bool { + return u.Role == AdminRole +} diff --git a/internal/data/models/user_test.go b/internal/data/models/user_test.go new file mode 100644 index 0000000..9c3b795 --- /dev/null +++ b/internal/data/models/user_test.go @@ -0,0 +1,33 @@ +package models_test + +import ( + "testing" + + "github.com/guackamolly/zero-monitor/internal/data/models" +) + +func TestUserIsAdmin(t *testing.T) { + testCases := []struct { + desc string + input models.User + output bool + }{ + { + desc: "returns true if user has admin role", + input: models.User{Role: models.AdminRole}, + output: true, + }, + { + desc: "returns false if user has guest role", + input: models.User{Role: models.GuestRole}, + output: false, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + if output := tC.input.IsAdmin(); output != tC.output { + t.Errorf("expected %v but got %v", tC.output, output) + } + }) + } +} diff --git a/internal/data/repositories/auth+db.go b/internal/data/repositories/auth+db.go new file mode 100644 index 0000000..6d9a088 --- /dev/null +++ b/internal/data/repositories/auth+db.go @@ -0,0 +1,70 @@ +package repositories + +import ( + "fmt" + + "github.com/guackamolly/zero-monitor/internal/data/db" + "github.com/guackamolly/zero-monitor/internal/data/models" + "github.com/guackamolly/zero-monitor/internal/logging" +) + +type DatabaseAuthenticationRepository struct { + authTable db.CredentialsTable + userTable db.UserTable +} + +func NewDatabaseAuthenticationRepository( + authTable db.CredentialsTable, + userTable db.UserTable, +) *DatabaseAuthenticationRepository { + return &DatabaseAuthenticationRepository{ + authTable: authTable, + userTable: userTable, + } +} + +func (r DatabaseAuthenticationRepository) SignIn(username string, password string) (models.User, error) { + credsEntity, ok, err := r.authTable.Lookup(username) + if !ok || err != nil { + return models.User{}, fmt.Errorf("user credentials not found") + } + + if credsEntity.Password != password { + return models.User{}, fmt.Errorf("user credentials don't match") + } + + userEntity, ok, err := r.userTable.Lookup(username) + if !ok || err != nil { + return models.User{}, fmt.Errorf("user not found") + } + + return userEntity.User, nil +} + +func (r DatabaseAuthenticationRepository) RegisterAdmin(username string, password string) (models.User, error) { + if _, ok, _ := r.authTable.Lookup(username); ok { + return models.User{}, fmt.Errorf("username already exists") + } + + if _, ok, _ := r.userTable.Lookup(username); ok { + return models.User{}, fmt.Errorf("username already exists") + } + + user := models.NewAdminUser(username) + err := r.userTable.Insert(db.NewUserEntity(user)) + if err != nil { + return models.User{}, fmt.Errorf("user table insert failed, %v", err) + } + + err = r.authTable.Insert(db.NewCredentialsEntity(username, password)) + if err == nil { + return user, nil + } + + delErr := r.userTable.Delete(db.NewUserEntity(user)) + if delErr != nil { + logging.LogWarning("failed to insert user credentials, and now can't delete user from user table!") + } + + return models.User{}, err +} diff --git a/internal/data/repositories/auth.go b/internal/data/repositories/auth.go new file mode 100644 index 0000000..7f1098f --- /dev/null +++ b/internal/data/repositories/auth.go @@ -0,0 +1,8 @@ +package repositories + +import "github.com/guackamolly/zero-monitor/internal/data/models" + +type AuthenticationRepository interface { + SignIn(username string, password string) (models.User, error) + RegisterAdmin(username string, password string) (models.User, error) +} diff --git a/internal/data/repositories/user+db.go b/internal/data/repositories/user+db.go new file mode 100644 index 0000000..5e30824 --- /dev/null +++ b/internal/data/repositories/user+db.go @@ -0,0 +1,38 @@ +package repositories + +import ( + "strings" + + "github.com/guackamolly/zero-monitor/internal/data/db" +) + +type DatabaseUserRepository struct { + userTable db.UserTable +} + +func NewDatabaseUserRepository( + userTable db.UserTable, +) *DatabaseUserRepository { + return &DatabaseUserRepository{ + userTable: userTable, + } +} + +func (r DatabaseUserRepository) AdminExists() (bool, error) { + users, err := r.userTable.All() + if err != nil && !strings.HasSuffix(err.Error(), "does not exist") { + return false, err + } + + if len(users) == 0 { + return false, nil + } + + for _, u := range users { + if u.IsAdmin() { + return true, nil + } + } + + return false, nil +} diff --git a/internal/data/repositories/user.go b/internal/data/repositories/user.go new file mode 100644 index 0000000..d2cf19e --- /dev/null +++ b/internal/data/repositories/user.go @@ -0,0 +1,5 @@ +package repositories + +type UserRepository interface { + AdminExists() (bool, error) +} diff --git a/internal/http/cookie.go b/internal/http/cookie.go new file mode 100644 index 0000000..a6d842f --- /dev/null +++ b/internal/http/cookie.go @@ -0,0 +1,30 @@ +package http + +import ( + "net/http" + "time" + + "github.com/labstack/echo/v4" +) + +const ( + tokenCookie = "token" +) + +func NewCookie( + ectx echo.Context, + name string, + value string, + path string, + expiry time.Time, +) *http.Cookie { + c := new(http.Cookie) + c.Name = name + c.Value = value + c.Path = path + c.Expires = expiry + c.SameSite = http.SameSiteStrictMode + c.Secure = ectx.IsTLS() + + return c +} diff --git a/internal/http/ctx.go b/internal/http/ctx.go index e3b49e6..f1aded3 100644 --- a/internal/http/ctx.go +++ b/internal/http/ctx.go @@ -21,6 +21,8 @@ type ServiceContainer struct { MasterConfiguration *service.MasterConfigurationService Network *service.NetworkService Networking *service.NetworkingService + Authentication *service.AuthenticationService + Authorization *service.AuthorizationService } func InjectServiceContainer( diff --git a/internal/http/handler+user.go b/internal/http/handler+user.go new file mode 100644 index 0000000..5531ad1 --- /dev/null +++ b/internal/http/handler+user.go @@ -0,0 +1,63 @@ +package http + +import ( + "github.com/labstack/echo/v4" +) + +// GET /user +func userHandler(ectx echo.Context) error { + return withServiceContainer(ectx, func(sc *ServiceContainer) error { + if sc.Authentication.NeedsAdminRegistration() { + return ectx.Redirect(301, userNewRoute) + } + + return ectx.Render(200, "user", nil) + }) +} + +// POST /user +func userFormHandler(ectx echo.Context) error { + return withServiceContainer(ectx, func(sc *ServiceContainer) error { + username := ectx.FormValue("username") + password := ectx.FormValue("password") + + t, err := sc.Authentication.Authenticate(username, password) + if err != nil { + return ectx.Redirect(301, userRoute) + } + + ectx.SetCookie(NewCookie(ectx, tokenCookie, t.Value, WithVirtualHost(rootRoute), t.Expiry)) + return ectx.Redirect(301, dashboardRoute) + }) +} + +// GET /user/new +func userNewHandler(ectx echo.Context) error { + return withServiceContainer(ectx, func(sc *ServiceContainer) error { + if !sc.Authentication.NeedsAdminRegistration() { + return ectx.Redirect(301, rootRoute) + } + + return ectx.Render(200, "user/new", nil) + }) +} + +// POST /user/new +func userNewFormHandler(ectx echo.Context) error { + return withServiceContainer(ectx, func(sc *ServiceContainer) error { + if !sc.Authentication.NeedsAdminRegistration() { + return ectx.Redirect(301, rootRoute) + } + + username := ectx.FormValue("username") + password := ectx.FormValue("password") + + t, err := sc.Authentication.RegisterAdmin(username, password) + if err != nil { + return ectx.Redirect(301, userNewRoute) + } + + ectx.SetCookie(NewCookie(ectx, tokenCookie, t.Value, WithVirtualHost(rootRoute), t.Expiry)) + return ectx.Redirect(301, userNewRoute) + }) +} diff --git a/internal/http/handler.go b/internal/http/handler.go index 225c0a5..5e11e97 100644 --- a/internal/http/handler.go +++ b/internal/http/handler.go @@ -6,28 +6,45 @@ import ( ) func RegisterHandlers(e *echo.Echo) { + // / (public) e.GET(rootRoute, rootHandler) - e.GET(dashboardRoute, dashboardHandler) - e.POST(dashboardRoute, dashboardFormHandler) + // /dashboard (admin only) + e.GET(dashboardRoute, dashboardHandler, adminRouteMiddleware) + e.POST(dashboardRoute, dashboardFormHandler, adminRouteMiddleware) + // /network (public) e.GET(networkRoute, networkHandler) e.GET(networkPublicKeyRoute, networkPublicKeyHandler) e.GET(networkConnectionEndpointRoute, networkConnectionEndpointHandler) + // /network/:id/connections | packages (public) e.GET(networkIdRoute, networkIdHandler) e.GET(networkIdConnectionsRoute, networkIdConnectionsHandler) e.GET(networkIdPackagesRoute, networkIdPackagesHandler) - e.GET(networkIdProcessesRoute, networkIdProcessesHandler) - e.POST(networkIdProcessesRoute, networkIdProcessesFormHandler) + + // /network/:id/processes (admin only) + e.GET(networkIdProcessesRoute, networkIdProcessesHandler, adminRouteMiddleware) + e.POST(networkIdProcessesRoute, networkIdProcessesFormHandler, adminRouteMiddleware) + + // /network/:id/speedtest (POST admin only) e.GET(networkIdSpeedtestRoute, networkIdSpeedtestHandler) - e.POST(networkIdSpeedtestRoute, networkIdSpeedtestFormHandler) + e.POST(networkIdSpeedtestRoute, networkIdSpeedtestFormHandler, adminRouteMiddleware) + + // /network/:id/processes (public) e.GET(networkIdSpeedtestHistoryRoute, networkIdSpeedtestHistoryHandler) e.GET(networkIdSpeedtestHistoryChartRoute, networkIdSpeedtestHistoryChartHandler) e.GET(networkIdSpeedtestIdRoute, networkIdSpeedtestIdHandler) - e.GET(settingsRoute, getSettingsHandler) - e.POST(settingsRoute, updateSettingsHandler) + // /settings (admin only) + e.GET(settingsRoute, getSettingsHandler, adminRouteMiddleware) + e.POST(settingsRoute, updateSettingsHandler, adminRouteMiddleware) + + // user (public if no admin has been registered yet) + e.GET(userRoute, userHandler) + e.POST(userRoute, userFormHandler) + e.GET(userNewRoute, userNewHandler) + e.POST(userNewRoute, userNewFormHandler) e.HTTPErrorHandler = httpErrorHandler() } diff --git a/internal/http/middleware.go b/internal/http/middleware.go index cf26f96..4c5dbfd 100644 --- a/internal/http/middleware.go +++ b/internal/http/middleware.go @@ -2,6 +2,7 @@ package http import ( "context" + "net/http" "github.com/guackamolly/zero-monitor/internal/data/models" "github.com/guackamolly/zero-monitor/internal/logging" @@ -37,6 +38,26 @@ func contextMiddleware(ctx context.Context) echo.MiddlewareFunc { } } +// Use this middleware to guard routes that can only be accessed by admin users. +var adminRouteMiddleware = func(next echo.HandlerFunc) echo.HandlerFunc { + return func(ectx echo.Context) error { + return withServiceContainer(ectx, func(sc *ServiceContainer) error { + var cookie *http.Cookie + var err error + + if cookie, err = ectx.Cookie(tokenCookie); err != nil || cookie == nil { + return ectx.Redirect(301, userRoute) + } + + if !sc.Authorization.HasAdminRights(cookie.Value) { + return ectx.Redirect(301, userRoute) + } + + return next(ectx) + }) + } +} + func withServiceContainer(ectx echo.Context, with func(*ServiceContainer) error) error { ctx, ok := ectx.Get(ctxKey).(context.Context) diff --git a/internal/http/route.go b/internal/http/route.go index e95d3f5..3701b6a 100644 --- a/internal/http/route.go +++ b/internal/http/route.go @@ -15,4 +15,6 @@ var ( networkIdSpeedtestIdRoute = WithVirtualHost("/network/:id/speedtest/:id2") networkPublicKeyRoute = WithVirtualHost("/network/public-key") networkConnectionEndpointRoute = WithVirtualHost("/network/connection-endpoint") + userRoute = WithVirtualHost("/user") + userNewRoute = WithVirtualHost("/user/new") ) diff --git a/internal/http/static.go b/internal/http/static.go index d3223fe..4ae6915 100644 --- a/internal/http/static.go +++ b/internal/http/static.go @@ -28,6 +28,8 @@ var ( "network/:id/speedtest/history": "tpl/network/id/speedtest/history/*.gohtml", "network/:id/speedtest/:id": "tpl/network/id/speedtest/id/*.gohtml", "settings": "tpl/settings/*.gohtml", + "user": "tpl/user/*.gohtml", + "user/new": "tpl/user/new/*.gohtml", } httpErrors = map[int]string{ diff --git a/internal/logging/console.go b/internal/logging/console.go index c6e7fbe..6ccc040 100644 --- a/internal/logging/console.go +++ b/internal/logging/console.go @@ -3,29 +3,37 @@ package logging import ( "fmt" console "log" + + "github.com/labstack/gommon/color" ) +var debugPrefix = color.Green("(debug): ") +var infoPrefix = color.Blue("(info): ") +var warningPrefix = color.Yellow("(warning): ") +var errorPrefix = color.Red("(error): ") +var fatalPrefix = color.RedBg("(fatal): ") + type consoleLogger struct{} func (l consoleLogger) Info(fmt string, s ...any) { - console.Println("(info): " + l.format(fmt, s...)) + console.Println(infoPrefix + l.format(fmt, s...)) } func (l consoleLogger) Warning(fmt string, s ...any) { - console.Println("(warn): " + l.format(fmt, s...)) + console.Println(warningPrefix + l.format(fmt, s...)) } func (l consoleLogger) Error(fmt string, s ...any) { - console.Println("(error): " + l.format(fmt, s...)) + console.Println(errorPrefix + l.format(fmt, s...)) } func (l consoleLogger) Fatal(fmt string, s ...any) { f := l.format(fmt, s...) - console.Fatalln("(fatal): " + f) + console.Fatalln(fatalPrefix + f) } func (l consoleLogger) Debug(fmt string, s ...any) { - console.Println("(debug): " + l.format(fmt, s...)) + console.Println(debugPrefix + l.format(fmt, s...)) } func (l consoleLogger) format(fmts string, s ...any) string { diff --git a/internal/service/authentication.go b/internal/service/authentication.go new file mode 100644 index 0000000..e7aa0f8 --- /dev/null +++ b/internal/service/authentication.go @@ -0,0 +1,121 @@ +package service + +import ( + "crypto/sha512" + "encoding/hex" + "fmt" + "regexp" + "time" + + "github.com/guackamolly/zero-monitor/internal/data/repositories" + "github.com/guackamolly/zero-monitor/internal/logging" +) + +var usernameRegex = regexp.MustCompile(`^[a-zA-Z0-9-_.@]{3,25}$`) +var passwordRegex = regexp.MustCompile(`^[^\s]{5,20}$`) + +// Service for managing authentication requests. +type AuthenticationService struct { + authRepo repositories.AuthenticationRepository + userRepo repositories.UserRepository + tokens *TokenBucket + + cacheNeedsAdminRegistration *bool +} + +func NewAuthenticationService( + authRepo repositories.AuthenticationRepository, + userRepo repositories.UserRepository, + tokenks *TokenBucket, +) *AuthenticationService { + s := &AuthenticationService{ + authRepo: authRepo, + userRepo: userRepo, + tokens: tokenks, + } + + return s +} + +func (s *AuthenticationService) Authenticate( + username string, + password string, +) (Token, error) { + if err := s.validateCredentials(username, password); err != nil { + return Token{}, err + } + + u, err := s.authRepo.SignIn(username, s.hash(password)) + if err != nil { + return Token{}, err + } + + return s.tokens.New(u), nil +} + +func (s *AuthenticationService) RegisterAdmin( + username string, + password string, +) (Token, error) { + if !s.NeedsAdminRegistration() { + return Token{}, fmt.Errorf("one admin account is already registered") + } + + if err := s.validateCredentials(username, password); err != nil { + return Token{}, err + } + + password = s.hash(password) + u, err := s.authRepo.RegisterAdmin(username, password) + if err != nil { + return Token{}, err + } + + *s.cacheNeedsAdminRegistration = false + return s.tokens.New(u), nil +} + +func (s *AuthenticationService) NeedsAdminRegistration() bool { + if s.cacheNeedsAdminRegistration != nil { + return *s.cacheNeedsAdminRegistration + } + + // retry at most 5 times if repo call fails + for i := 0; i < 5; i++ { + exists, err := s.userRepo.AdminExists() + if err != nil { + time.Sleep(150 * time.Millisecond) + continue + } + + needsAdminRegistration := !exists + s.cacheNeedsAdminRegistration = &needsAdminRegistration + return *s.cacheNeedsAdminRegistration + } + + logging.LogWarning("couldn't guess if admin is registered or not. allowing admin registration") + return true +} + +func (s *AuthenticationService) hash(pt string) string { + hash := sha512.New() + hash.Write([]byte(pt)) + bs := hash.Sum(nil) + + return hex.EncodeToString(bs) +} + +func (s *AuthenticationService) validateCredentials( + username string, + password string, +) error { + if !usernameRegex.MatchString(username) { + return fmt.Errorf("username does not match pattern") + } + + if !passwordRegex.MatchString(password) { + return fmt.Errorf("password does not match pattern") + } + + return nil +} diff --git a/internal/service/authorization.go b/internal/service/authorization.go new file mode 100644 index 0000000..f15f198 --- /dev/null +++ b/internal/service/authorization.go @@ -0,0 +1,62 @@ +package service + +import ( + "time" + + "github.com/guackamolly/zero-monitor/internal/data/models" +) + +type Token struct { + Value string + User models.User + Expiry time.Time +} + +type TokenBucket map[string]Token + +func (b TokenBucket) New(user models.User) Token { + // clear existing tokens before adding a new one + for t, tk := range b { + if tk.User == user { + delete(b, t) + } + } + + token := Token{ + Value: models.UUID(), + User: user, + Expiry: time.Now().Add(24 * time.Hour), + } + + b[token.Value] = token + return token +} + +func (b TokenBucket) Token(token string) (Token, bool) { + if t, ok := b[token]; ok { + return t, t.Expiry.After(time.Now()) + } + + return Token{}, false +} + +// Service for managing authorization requests. +type AuthorizationService struct { + tokens *TokenBucket +} + +func NewAuthorizationService( + tokens *TokenBucket, +) *AuthorizationService { + return &AuthorizationService{ + tokens: tokens, + } +} + +func (s *AuthorizationService) HasAdminRights(token string) bool { + if t, ok := s.tokens.Token(token); ok { + return t.User.IsAdmin() + } + + return false +} diff --git a/public/index.css b/public/index.css index 7a913fe..c1a2b97 100644 --- a/public/index.css +++ b/public/index.css @@ -269,6 +269,10 @@ button { width: min-content; } +.fit { + width: fit-content; +} + .speedtest>span { display: flex; flex-direction: row; diff --git a/public/tpl/user/index.gohtml b/public/tpl/user/index.gohtml new file mode 100644 index 0000000..cdca1c0 --- /dev/null +++ b/public/tpl/user/index.gohtml @@ -0,0 +1,106 @@ +{{ define "user" }} + + +
+This page is reserved admin users only. If you're not an admin, + please go back. +
+ +
+ Fill the form below to create a new admin user.
+
+ WARNING: You must create an admin user
+ before exposing the server in the wild!
+