From 6b45213649d034459f63d6f8b56756370e6bc7dc Mon Sep 17 00:00:00 2001 From: surtur Date: Thu, 24 Aug 2023 18:43:24 +0200 Subject: [PATCH] go: add user onboarding, HIBP search functionality * add user onboarding workflow * fix user editing (no edits of passwords of regular users after onboarding) * refresh HIBP breach cache in DB on app start-up * display HIBP breach details * fix request scheduling to prevent panics (this still needs some love..) * fix middleware auth * add TODOs * update head.tmpl * reword some error messages --- app/routes.go | 8 ++ ent/hibp/hibp.go | 3 + ent/hibp_create.go | 12 ++ ent/runtime.go | 6 +- ent/schema/hibp.go | 1 + ent/schema/user.go | 4 +- ent/user/user.go | 2 - ent/user_update.go | 24 ++-- handlers/error.go | 25 ++-- handlers/handlers.go | 2 + handlers/home.go | 33 ++++- handlers/logout.go | 4 +- handlers/manage-user.go | 109 +++++++++++--- handlers/middleware.go | 50 ++++--- handlers/search-hibp.go | 174 +++++++++++++++++++++++ handlers/signin.go | 5 +- handlers/type.go | 18 ++- handlers/user-init-password-change.go | 129 +++++++++++++++++ handlers/view-hibp.go | 81 +++++++++++ justfile | 2 +- modules/db/db.go | 1 + modules/funcmap/funcmap.go | 14 ++ modules/hibp/hibp.go | 97 +++++++++++-- modules/hibp/schedule.go | 4 +- modules/user/user.go | 141 ++++++++++++++++++ run.go | 18 ++- templates/head.tmpl | 6 +- templates/home.tmpl | 5 + templates/manage/apikeys.tmpl | 6 +- templates/manage/user-details.tmpl | 34 ++++- templates/manage/user-edit.tmpl | 6 +- templates/manage/user.tmpl | 10 ++ templates/navbar.tmpl | 14 +- templates/user/hibp-details.tmpl | 158 ++++++++++++++++++++ templates/user/hibp-search.tmpl | 78 ++++++++++ templates/user/init-password-change.tmpl | 47 ++++++ 36 files changed, 1221 insertions(+), 110 deletions(-) create mode 100644 handlers/search-hibp.go create mode 100644 handlers/user-init-password-change.go create mode 100644 handlers/view-hibp.go create mode 100644 templates/user/hibp-details.tmpl create mode 100644 templates/user/hibp-search.tmpl create mode 100644 templates/user/init-password-change.tmpl diff --git a/app/routes.go b/app/routes.go index 2940397..21069f7 100644 --- a/app/routes.go +++ b/app/routes.go @@ -72,6 +72,14 @@ func (a *App) SetupRoutes() error { return c.NoContent(http.StatusNotFound) }) + user := e.Group("/user", handlers.MiddlewareSession, xsrf) + + user.GET("/initial-password-change", handlers.InitialPasswordChange()) + user.POST("/initial-password-change", handlers.InitialPasswordChange()) + user.GET("/hibp-search", handlers.GetSearchHIBP()) + user.POST("/hibp-search", handlers.SearchHIBP()) + user.GET("/hibp-breach-details/:name", handlers.ViewHIBP()) + manage := e.Group("/manage", handlers.MiddlewareSession, xsrf) manage.GET("/api-keys", handlers.ManageAPIKeys(), compress) diff --git a/ent/hibp/hibp.go b/ent/hibp/hibp.go index 4ac08d9..8f12128 100644 --- a/ent/hibp/hibp.go +++ b/ent/hibp/hibp.go @@ -5,6 +5,7 @@ package hibp import ( "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" + "github.com/google/uuid" ) const ( @@ -114,6 +115,8 @@ var ( DefaultIsSpamList bool // DefaultIsMalware holds the default value on creation for the "is_malware" field. DefaultIsMalware bool + // DefaultID holds the default value on creation for the "id" field. + DefaultID func() uuid.UUID ) // OrderOption defines the ordering options for the HIBP queries. diff --git a/ent/hibp_create.go b/ent/hibp_create.go index ee32e22..9271f4a 100644 --- a/ent/hibp_create.go +++ b/ent/hibp_create.go @@ -188,6 +188,14 @@ func (hc *HIBPCreate) SetID(u uuid.UUID) *HIBPCreate { return hc } +// SetNillableID sets the "id" field if the given value is not nil. +func (hc *HIBPCreate) SetNillableID(u *uuid.UUID) *HIBPCreate { + if u != nil { + hc.SetID(*u) + } + return hc +} + // SetTrackedBreachesID sets the "tracked_breaches" edge to the TrackedBreaches entity by ID. func (hc *HIBPCreate) SetTrackedBreachesID(id uuid.UUID) *HIBPCreate { hc.mutation.SetTrackedBreachesID(id) @@ -266,6 +274,10 @@ func (hc *HIBPCreate) defaults() { v := hibp.DefaultIsMalware hc.mutation.SetIsMalware(v) } + if _, ok := hc.mutation.ID(); !ok { + v := hibp.DefaultID() + hc.mutation.SetID(v) + } } // check runs all checks and user-defined validators on the builder. diff --git a/ent/runtime.go b/ent/runtime.go index 7506e01..3e019ed 100644 --- a/ent/runtime.go +++ b/ent/runtime.go @@ -69,6 +69,10 @@ func init() { hibpDescIsMalware := hibpFields[15].Descriptor() // hibp.DefaultIsMalware holds the default value on creation for the is_malware field. hibp.DefaultIsMalware = hibpDescIsMalware.Default.(bool) + // hibpDescID is the schema descriptor for id field. + hibpDescID := hibpFields[0].Descriptor() + // hibp.DefaultID holds the default value on creation for the id field. + hibp.DefaultID = hibpDescID.Default.(func() uuid.UUID) localbreachFields := schema.LocalBreach{}.Fields() _ = localbreachFields // localbreachDescName is the schema descriptor for name field. @@ -196,8 +200,6 @@ func init() { userDescLastLogin := userFields[8].Descriptor() // user.DefaultLastLogin holds the default value on creation for the last_login field. user.DefaultLastLogin = userDescLastLogin.Default.(time.Time) - // user.UpdateDefaultLastLogin holds the default value on update for the last_login field. - user.UpdateDefaultLastLogin = userDescLastLogin.UpdateDefault.(func() time.Time) // userDescID is the schema descriptor for id field. userDescID := userFields[0].Descriptor() // user.DefaultID holds the default value on creation for the id field. diff --git a/ent/schema/hibp.go b/ent/schema/hibp.go index fa65b0e..2999e17 100644 --- a/ent/schema/hibp.go +++ b/ent/schema/hibp.go @@ -60,6 +60,7 @@ type HIBPSchema struct { func (HIBP) Fields() []ent.Field { return []ent.Field{ field.UUID("id", uuid.UUID{}). + Default(uuid.New). Unique(). Immutable(), // Unique and permanent. diff --git a/ent/schema/user.go b/ent/schema/user.go index f3ef348..30cc24f 100644 --- a/ent/schema/user.go +++ b/ent/schema/user.go @@ -55,8 +55,8 @@ func (User) Fields() []ent.Field { Default(time.Now). UpdateDefault(time.Now), field.Time("last_login"). - Default(time.Unix(0, 0)). - UpdateDefault(time.Now), + // UpdateDefault(time.Now). + Default(time.Unix(0, 0)), // field.Bool("save_search_queries"). // Default(true), } diff --git a/ent/user/user.go b/ent/user/user.go index 86c4b26..1c3d702 100644 --- a/ent/user/user.go +++ b/ent/user/user.go @@ -104,8 +104,6 @@ var ( UpdateDefaultUpdatedAt func() time.Time // DefaultLastLogin holds the default value on creation for the "last_login" field. DefaultLastLogin time.Time - // UpdateDefaultLastLogin holds the default value on update for the "last_login" field. - UpdateDefaultLastLogin func() time.Time // DefaultID holds the default value on creation for the "id" field. DefaultID func() uuid.UUID ) diff --git a/ent/user_update.go b/ent/user_update.go index efeacdd..f73972d 100644 --- a/ent/user_update.go +++ b/ent/user_update.go @@ -89,6 +89,14 @@ func (uu *UserUpdate) SetLastLogin(t time.Time) *UserUpdate { return uu } +// SetNillableLastLogin sets the "last_login" field if the given value is not nil. +func (uu *UserUpdate) SetNillableLastLogin(t *time.Time) *UserUpdate { + if t != nil { + uu.SetLastLogin(*t) + } + return uu +} + // AddTrackedBreachIDs adds the "tracked_breaches" edge to the TrackedBreaches entity by IDs. func (uu *UserUpdate) AddTrackedBreachIDs(ids ...uuid.UUID) *UserUpdate { uu.mutation.AddTrackedBreachIDs(ids...) @@ -200,10 +208,6 @@ func (uu *UserUpdate) defaults() { v := user.UpdateDefaultUpdatedAt() uu.mutation.SetUpdatedAt(v) } - if _, ok := uu.mutation.LastLogin(); !ok { - v := user.UpdateDefaultLastLogin() - uu.mutation.SetLastLogin(v) - } } // check runs all checks and user-defined validators on the builder. @@ -427,6 +431,14 @@ func (uuo *UserUpdateOne) SetLastLogin(t time.Time) *UserUpdateOne { return uuo } +// SetNillableLastLogin sets the "last_login" field if the given value is not nil. +func (uuo *UserUpdateOne) SetNillableLastLogin(t *time.Time) *UserUpdateOne { + if t != nil { + uuo.SetLastLogin(*t) + } + return uuo +} + // AddTrackedBreachIDs adds the "tracked_breaches" edge to the TrackedBreaches entity by IDs. func (uuo *UserUpdateOne) AddTrackedBreachIDs(ids ...uuid.UUID) *UserUpdateOne { uuo.mutation.AddTrackedBreachIDs(ids...) @@ -551,10 +563,6 @@ func (uuo *UserUpdateOne) defaults() { v := user.UpdateDefaultUpdatedAt() uuo.mutation.SetUpdatedAt(v) } - if _, ok := uuo.mutation.LastLogin(); !ok { - v := user.UpdateDefaultLastLogin() - uuo.mutation.SetLastLogin(v) - } } // check runs all checks and user-defined validators on the builder. diff --git a/handlers/error.go b/handlers/error.go index 876994a..fd0dfa6 100644 --- a/handlers/error.go +++ b/handlers/error.go @@ -8,6 +8,7 @@ import ( "fmt" "strconv" + moduser "git.dotya.ml/mirre-mt/pcmt/modules/user" "github.com/labstack/echo/v4" ) @@ -21,18 +22,22 @@ func renderErrorPage(c echo.Context, status int, statusText, error string) error strStatus := strconv.Itoa(status) + p := newPage() + + p.Title = fmt.Sprintf("Error %s - %s", strStatus, statusText) + p.Current = strStatus + p.Error = error + p.Status = strStatus + p.StatusText = statusText + + u, ok := c.Get("sessUsr").(moduser.User) + if ok { + p.User = u + } + return c.Render( status, "errorPage.tmpl", - page{ - AppName: setting.AppName(), - AppVer: appver, - Title: fmt.Sprintf("Error %s - %s", strStatus, statusText), - DevelMode: setting.IsDevel(), - Current: strStatus, - Error: error, - Status: strStatus, - StatusText: statusText, - }, + p, ) } diff --git a/handlers/handlers.go b/handlers/handlers.go index efa847c..3ccfdb6 100644 --- a/handlers/handlers.go +++ b/handlers/handlers.go @@ -54,6 +54,8 @@ func Index() echo.HandlerFunc { } func Healthz() echo.HandlerFunc { + // TODO: have this respond with some kind of internal state that gets + // read-locked when returning. return func(c echo.Context) error { return c.JSON(http.StatusOK, struct{ Status string }{Status: "OK"}) } diff --git a/handlers/home.go b/handlers/home.go index 9ce1ad6..6d3ca7a 100644 --- a/handlers/home.go +++ b/handlers/home.go @@ -55,6 +55,8 @@ func Home(client *ent.Client) echo.HandlerFunc { u.ID = usr.ID u.Username = usr.Username u.IsActive = usr.IsActive + u.IsAdmin = usr.IsAdmin + // TODO: this is redundant, if there is a user object, the user is logged in... u.IsLoggedIn = true } else { c.Logger().Error("failed to query usr", username) @@ -67,6 +69,22 @@ func Home(client *ent.Client) echo.HandlerFunc { ) } + if !u.IsAdmin { + f, err := moduser.UsrFinishedSetup(ctx, dbclient, u.ID) + if err != nil { + return renderErrorPage( + c, + http.StatusInternalServerError, + http.StatusText(http.StatusInternalServerError), + err.Error(), + ) + } + + if !f { + return c.Redirect(http.StatusSeeOther, "/user/initial-password-change") + } + } + csrf := c.Get("csrf").(string) p := newPage() @@ -76,9 +94,18 @@ func Home(client *ent.Client) echo.HandlerFunc { p.Name = username p.User = u - err := c.Render(http.StatusOK, "home.tmpl", - p, - ) + data := make(map[string]any) + flash := sess.Values["flash"] + + if flash != nil { + data["flash"] = flash.(string) + + delete(sess.Values, "flash") + + _ = sess.Save(c.Request(), c.Response()) + } + + err := c.Render(http.StatusOK, "home.tmpl", p) if err != nil { c.Logger().Errorf("error: %q", err) diff --git a/handlers/logout.go b/handlers/logout.go index e18b18f..aa9b567 100644 --- a/handlers/logout.go +++ b/handlers/logout.go @@ -15,7 +15,7 @@ func Logout() echo.HandlerFunc { addHeaders(c) switch { - case c.Request().Method == "POST": + case c.Request().Method == "POST": // nolint:goconst sess, _ := session.Get(setting.SessionCookieName(), c) if sess != nil { log.Infof("max-age before logout: %d", sess.Options.MaxAge) @@ -33,7 +33,7 @@ func Logout() echo.HandlerFunc { return c.Redirect(http.StatusMovedPermanently, "/logout") - case c.Request().Method == "GET": + case c.Request().Method == "GET": // nolint:goconst sess, _ := session.Get(setting.SessionCookieName(), c) if sess != nil { if username := sess.Values["username"]; username != nil { diff --git a/handlers/manage-user.go b/handlers/manage-user.go index add9898..1732980 100644 --- a/handlers/manage-user.go +++ b/handlers/manage-user.go @@ -227,7 +227,7 @@ func CreateUser() echo.HandlerFunc { //nolint:gocognit usr, err := moduser.CreateUser(ctx, dbclient, uc.Email, uc.Username, uc.Password, uc.IsAdmin) if err == nil && usr != nil { - msg = "created user '" + usr.Username + "'!" + msg = fmt.Sprintf("Successfully created user %q!", usr.Username) if sess, ok := c.Get("sess").(*sessions.Session); ok { sess.Values["flash"] = msg @@ -282,6 +282,16 @@ func ViewUser() echo.HandlerFunc { ) } + sess, ok := c.Get("sess").(*sessions.Session) + if !ok { + return renderErrorPage( + c, + http.StatusInternalServerError, + http.StatusText(http.StatusInternalServerError), + "missing the session", + ) + } + ctx, ok := c.Get("sloggerCtx").(context.Context) if !ok { ctx = context.WithValue(context.Background(), moduser.CtxKey{}, slogger) @@ -303,7 +313,7 @@ func ViewUser() echo.HandlerFunc { if errors.Is(err, moduser.ErrUserNotFound) { //nolint:gocritic c.Logger().Errorf("user not found by ID: '%s'", uid.ID) - data["flash"] = fmt.Sprintf("No user found with the UUID: %q", uid.ID) + data["flash"] = fmt.Sprintf("No user found with UUID: %q", uid.ID) p.Data = data return c.Render( @@ -314,7 +324,7 @@ func ViewUser() echo.HandlerFunc { } else if errors.Is(err, moduser.ErrFailedToQueryUser) { c.Logger().Errorf("failed to query user by ID: '%s'", uid.ID) - data["flash"] = fmt.Sprintf("failed to query user by UUID %q", uid.ID) + data["flash"] = fmt.Sprintf("Error: failed to query user by UUID %q", uid.ID) p.Data = data return c.Render( @@ -325,7 +335,7 @@ func ViewUser() echo.HandlerFunc { } else if errors.Is(err, moduser.ErrBadUUID) { c.Logger().Errorf("Invalid UUID '%s': %q", uid.ID, err) - data["flash"] = fmt.Sprintf("invalid UUID %q", uid.ID) + data["flash"] = fmt.Sprintf("Error: invalid UUID %q", uid.ID) p.Data = data return c.Render( @@ -346,6 +356,16 @@ func ViewUser() echo.HandlerFunc { } data["user"] = usr + + flash := sess.Values["flash"] + if flash != nil { + data["flash"] = flash.(string) + + delete(sess.Values, "flash") + + _ = sess.Save(c.Request(), c.Response()) + } + p.Data = data return c.Render( @@ -567,26 +587,54 @@ func UpdateUser() echo.HandlerFunc { //nolint:gocognit ) } - if uu.Username == "" || uu.Password == "" || uu.RepeatPassword == "" || uu.Password != uu.RepeatPassword { - c.Logger().Error("username or password not set, returning to /manage/users/edit") - - msg := "Error: both username and password need to be set" - - if uu.Password != uu.RepeatPassword { - msg += "; the same password needs to be passed in twice" - } - - data["flash"] = msg - data["form"] = uu - p.Data = data - - return c.Render( - http.StatusBadRequest, - "manage/user-edit.tmpl", - p, + f, err := moduser.UsrFinishedSetup(ctx, dbclient, usr.ID) + if err != nil { + return renderErrorPage( + c, + http.StatusInternalServerError, + http.StatusText(http.StatusInternalServerError), + err.Error(), ) } + if f { + if uu.Username == "" { + c.Logger().Error("username not set, returning to /manage/users/edit") + + msg := "Error: the username needs to be set" + + data["flash"] = msg + data["form"] = uu + p.Data = data + + return c.Render( + http.StatusBadRequest, + "manage/user-edit.tmpl", + p, + ) + } + } else { + if uu.Username == "" || uu.Password == "" || uu.RepeatPassword == "" || uu.Password != uu.RepeatPassword { + c.Logger().Error("username or password not set, returning to /manage/users/edit") + + msg := "Error: both username and password need to be set" + + if uu.Password != uu.RepeatPassword { + msg += "; the same password needs to be passed in twice" + } + + data["flash"] = msg + data["form"] = uu + p.Data = data + + return c.Render( + http.StatusBadRequest, + "manage/user-edit.tmpl", + p, + ) + } + } + if usr.Username != uu.Username { exists, err := moduser.UsernameExists(ctx, dbclient, uu.Username) if err != nil { @@ -640,11 +688,28 @@ func UpdateUser() echo.HandlerFunc { //nolint:gocognit } // now update + err = moduser.UpdateUserByAdmin( + ctx, dbclient, + usr.ID, uu.Email, uu.Username, uu.Password, uu.IsAdmin, uu.IsActive, + ) + if err != nil { + return renderErrorPage( + c, + http.StatusInternalServerError, + http.StatusText(http.StatusInternalServerError), + err.Error(), + ) + } data["user"] = usr p.Data = data - return c.Redirect(http.StatusSeeOther, fmt.Sprintf("/manage/users/%s/edit", usr.ID)) + if sess, ok := c.Get("sess").(*sessions.Session); ok { + sess.Values["flash"] = fmt.Sprintf("Successfully updated user %q", uu.Username) + _ = sess.Save(c.Request(), c.Response()) + } + + return c.Redirect(http.StatusSeeOther, fmt.Sprintf("/manage/users/%s", usr.ID)) } } diff --git a/handlers/middleware.go b/handlers/middleware.go index 3ccef46..e66c99e 100644 --- a/handlers/middleware.go +++ b/handlers/middleware.go @@ -22,12 +22,13 @@ func MiddlewareSession(next echo.HandlerFunc) echo.HandlerFunc { var username string - uname, ok := sess.Values["username"].(string) + // uname, ok := sess.Values["username"].(string) + uname := sess.Values["username"] - if ok { - username = uname + if uname != nil { + username = uname.(string) - log.Info("gorilla session", "username", username) + log.Debug("Refreshing session cookie", "username", username, "module", "middleware") refreshSession( sess, @@ -38,17 +39,6 @@ func MiddlewareSession(next echo.HandlerFunc) echo.HandlerFunc { http.SameSiteStrictMode, ) - if err := sess.Save(c.Request(), c.Response()); err != nil { - c.Logger().Error("failed to save session") - - return renderErrorPage( - c, - http.StatusInternalServerError, - http.StatusText(http.StatusInternalServerError)+" (make sure you've got cookies enabled)", - err.Error(), - ) - } - c.Set("sess", sess) var u moduser.User @@ -75,19 +65,35 @@ func MiddlewareSession(next echo.HandlerFunc) echo.HandlerFunc { c.Set("sloggerCtx", ctx) c.Set("sessUsr", u) + if err := sess.Save(c.Request(), c.Response()); err != nil { + c.Logger().Error("Failed to save session", "module", "middleware") + + return renderErrorPage( + c, + http.StatusInternalServerError, + http.StatusText(http.StatusInternalServerError)+" (make sure you've got cookies enabled)", + err.Error(), + ) + } + return next(c) } if !sess.IsNew { - c.Logger().Debugf("%d - %s", http.StatusUnauthorized, "you need to log in") + c.Logger().Errorf("%d - %s", http.StatusUnauthorized, "you need to log in") + return c.Redirect(http.StatusTemporaryRedirect, "/signin") } - return renderErrorPage( - c, - http.StatusUnauthorized, - http.StatusText(http.StatusUnauthorized), - ErrNoSession.Error(), - ) + // return renderErrorPage( + // c, + // http.StatusUnauthorized, + // http.StatusText(http.StatusUnauthorized), + // ErrNoSession.Error(), + // ) + + c.Logger().Warn("Could not get username from the cookie") + + return next(c) } } diff --git a/handlers/search-hibp.go b/handlers/search-hibp.go new file mode 100644 index 0000000..626bb57 --- /dev/null +++ b/handlers/search-hibp.go @@ -0,0 +1,174 @@ +// Copyright 2023 wanderer +// SPDX-License-Identifier: AGPL-3.0-only + +package handlers + +import ( + "errors" + "fmt" + "net/http" + + "git.dotya.ml/mirre-mt/pcmt/modules/hibp" + moduser "git.dotya.ml/mirre-mt/pcmt/modules/user" + "github.com/gorilla/sessions" + "github.com/labstack/echo/v4" +) + +func GetSearchHIBP() echo.HandlerFunc { + return func(c echo.Context) error { + addHeaders(c) + + u, ok := c.Get("sessUsr").(moduser.User) + if !ok { + c.Logger().Warnf("Error getting user from session cookie") + } else if u.IsAdmin { + status := http.StatusUnauthorized + msg := http.StatusText(status) + + return renderErrorPage( + c, status, msg+": You should not be here", "This endpoint is for users only", + ) + } + + csrf := c.Get("csrf").(string) + p := newPage() + + if sess, ok := c.Get("sess").(*sessions.Session); ok { + if flash := sess.Values["flash"]; flash != nil { + p.Data["flash"] = flash.(string) + + delete(sess.Values, "flash") + + _ = sess.Save(c.Request(), c.Response()) + } + } + + p.Title = "Search HIBP" + p.Current = "hibp-search" + p.CSRF = csrf + p.User = u + + err := c.Render( + http.StatusOK, + "user/hibp-search.tmpl", + p, + ) + if err != nil { + c.Logger().Errorf("error: %q", err) + + return renderErrorPage( + c, + http.StatusInternalServerError, + http.StatusText(http.StatusInternalServerError), + err.Error(), + ) + } + + return nil + } +} + +func SearchHIBP() echo.HandlerFunc { + return func(c echo.Context) error { + addHeaders(c) + + u, ok := c.Get("sessUsr").(moduser.User) + if !ok { + // return renderErrorPage( + // c, + // http.StatusUnauthorized, + // http.StatusText(http.StatusUnauthorized)+", perhaps you need to log in first?", + // "Username was nil", + // ) + c.Logger().Warnf("Error getting user from session cookie") + } else if u.IsAdmin { + status := http.StatusUnauthorized + msg := http.StatusText(status) + + return renderErrorPage( + c, status, msg+": You should not be here", "This endpoint is for users only", + ) + } + + csrf := c.Get("csrf").(string) + + a := new(hibpSearch) + if err := c.Bind(a); err != nil { + return renderErrorPage( + c, + http.StatusBadRequest, + http.StatusText(http.StatusBadRequest), + err.Error(), + ) + } + + breachNames, err := hibp.GetAllBreachesForAccount(a.Account) + if err != nil { + msg := "Error getting breaches for this account" + status := http.StatusInternalServerError + + switch { + case errors.Is(err, hibp.ErrRateLimited): + msg = http.StatusText(http.StatusTooManyRequests) + " - we have been rate limited from the API. Try again in a while." + status = http.StatusTooManyRequests + + case errors.Is(err, hibp.ErrAuthKeyCheckValue): + msg = hibp.ErrAuthKeyCheckValue.Error() + status = http.StatusBadRequest + } + + return renderErrorPage( + c, + status, + msg, + err.Error(), + ) + } + + if len(breachNames) == 0 { + if sess, ok := c.Get("sess").(*sessions.Session); ok { + sess.Values["flash"] = fmt.Sprintf("There is no mention of account %q in any of the HIBP breaches!", a.Account) + _ = sess.Save(c.Request(), c.Response()) + } + + return c.Redirect(http.StatusSeeOther, "/user/hibp-search") + } + + sfx := "breach" + bCount := len(breachNames) + + if bCount > 1 { + sfx = "breaches" + } + + p := newPage() + + p.Title = "Search HIBP" + p.Current = "hibp-search" + p.CSRF = csrf + p.User = u + p.Data["breachNames"] = breachNames + p.Data["flash"] = fmt.Sprintf( + "The account %q was found in %d %s!", + a.Account, bCount, sfx, + ) + + err = c.Render( + http.StatusOK, + "user/hibp-search.tmpl", + p, + ) + if err != nil { + c.Logger().Errorf("error: %q", err) + + return renderErrorPage( + c, + http.StatusInternalServerError, + http.StatusText(http.StatusInternalServerError), + err.Error(), + ) + } + + return nil + } +} diff --git a/handlers/signin.go b/handlers/signin.go index 875abbb..c387063 100644 --- a/handlers/signin.go +++ b/handlers/signin.go @@ -67,7 +67,7 @@ func SigninPost(client *ent.Client) echo.HandlerFunc { p.CSRF = csrf if username == "" || password == "" { - c.Logger().Error("username or password not set, returning to /signin") + c.Logger().Warn("username or password not set, returning to /signin") data["flash"] = "you need to set both the username and the password" data["form"] = cu @@ -134,6 +134,9 @@ func SigninPost(client *ent.Client) echo.HandlerFunc { SameSite: http.SameSiteStrictMode, } sess.Values["foo"] = "bar" + + c.Logger().Debug("saving username to the session cookie") + sess.Values["username"] = username err := sess.Save(c.Request(), c.Response()) diff --git a/handlers/type.go b/handlers/type.go index 56965a1..7777248 100644 --- a/handlers/type.go +++ b/handlers/type.go @@ -14,11 +14,13 @@ type userSignup struct { Password string `form:"password" json:"password" validate:"required,password"` } +// this struct is also used on update by admins, which is why the password fields are omitempty. +// when users finish setting up, admins can no longer change their passwords. type userCreate struct { Username string `form:"username" json:"username" validate:"required,username"` Email string `form:"email" json:"email" validate:"required,email"` - Password string `form:"password" json:"password" validate:"required,password"` - RepeatPassword string `form:"repeatPassword" json:"repeatPassword" validate:"required,repeatPassword"` + Password string `form:"password" json:"password" validate:"omitempty,password"` + RepeatPassword string `form:"repeatPassword" json:"repeatPassword" validate:"omitempty,repeatPassword"` IsAdmin bool `form:"isAdmin" json:"isAdmin" validate:"required,isAdmin"` IsActive *bool `form:"isActive" json:"isActive" validate:"omitempty,isActive"` } @@ -26,3 +28,15 @@ type userCreate struct { type userID struct { ID string `param:"id" validate:"required,id"` } + +type initPasswordChange struct { + NewPassword string `form:"new-password" validate:"required,new-password"` +} + +type hibpSearch struct { + Account string `form:"search" validate:"required,search"` +} + +type hibpBreachDetail struct { + BreachName string `param:"name" validate:"required,name"` +} diff --git a/handlers/user-init-password-change.go b/handlers/user-init-password-change.go new file mode 100644 index 0000000..e86a179 --- /dev/null +++ b/handlers/user-init-password-change.go @@ -0,0 +1,129 @@ +// Copyright 2023 wanderer +// SPDX-License-Identifier: AGPL-3.0-only + +package handlers + +import ( + "context" + "net/http" + + moduser "git.dotya.ml/mirre-mt/pcmt/modules/user" + "github.com/gorilla/sessions" + "github.com/labstack/echo/v4" +) + +func InitialPasswordChange() echo.HandlerFunc { + return func(c echo.Context) error { + addHeaders(c) + + u, ok := c.Get("sessUsr").(moduser.User) + if !ok { + return renderErrorPage( + c, + http.StatusUnauthorized, + http.StatusText(http.StatusUnauthorized)+", perhaps you need to log in first?", + "Username was nil", + ) + } else if u.IsAdmin { + status := http.StatusUnauthorized + msg := http.StatusText(status) + + return renderErrorPage( + c, status, msg+": You should not be here", "This endpoint is for users only", + ) + } + + ctx, ok := c.Get("sloggerCtx").(context.Context) + if !ok { + ctx = context.WithValue(context.Background(), moduser.CtxKey{}, slogger) + } + + f, err := moduser.UsrFinishedSetup(ctx, dbclient, u.ID) + + switch { + case c.Request().Method == "POST": + switch { + case err != nil: + return renderErrorPage( + c, + http.StatusInternalServerError, + http.StatusText(http.StatusInternalServerError), + err.Error(), + ) + + case f: + return c.Redirect(http.StatusSeeOther, "/user/init-password-change") + } + + pw := new(initPasswordChange) + + if err := c.Bind(pw); err != nil { + return renderErrorPage( + c, + http.StatusBadRequest, + http.StatusText(http.StatusBadRequest), + err.Error(), + ) + } + + err = moduser.ChangePassFirstLogin(ctx, dbclient, u.ID, pw.NewPassword) + if err != nil { + c.Logger().Errorf("error changing initial user password: %q", err) + + return renderErrorPage( + c, + http.StatusInternalServerError, + http.StatusText(http.StatusInternalServerError), + err.Error(), + ) + } + + if sess, ok := c.Get("sess").(*sessions.Session); ok { + sess.Values["flash"] = "Successfully updated your password" + _ = sess.Save(c.Request(), c.Response()) + } + + return c.Redirect(http.StatusSeeOther, "/home") + + case c.Request().Method == "GET": + switch { + case err != nil: + return renderErrorPage( + c, + http.StatusInternalServerError, + http.StatusText(http.StatusInternalServerError), + err.Error(), + ) + + case f: + return c.Redirect(http.StatusSeeOther, "/home") + } + + csrf := c.Get("csrf").(string) + p := newPage() + + p.Title = "Initial password change" + p.Current = "init-password-change" + p.CSRF = csrf + p.User = u + + err := c.Render( + http.StatusOK, + "user/init-password-change.tmpl", + p, + ) + if err != nil { + c.Logger().Errorf("error: %q", err) + + return renderErrorPage( + c, + http.StatusInternalServerError, + http.StatusText(http.StatusInternalServerError), + err.Error(), + ) + } + } + + return nil + } +} diff --git a/handlers/view-hibp.go b/handlers/view-hibp.go new file mode 100644 index 0000000..68601d7 --- /dev/null +++ b/handlers/view-hibp.go @@ -0,0 +1,81 @@ +// Copyright 2023 wanderer +// SPDX-License-Identifier: AGPL-3.0-only + +package handlers + +import ( + "context" + "net/http" + + "git.dotya.ml/mirre-mt/pcmt/modules/hibp" + moduser "git.dotya.ml/mirre-mt/pcmt/modules/user" + "github.com/labstack/echo/v4" +) + +func ViewHIBP() echo.HandlerFunc { + return func(c echo.Context) error { + addHeaders(c) + + u, ok := c.Get("sessUsr").(moduser.User) + if !ok { + c.Logger().Warnf("Error getting user from session cookie") + } else if u.IsAdmin { + status := http.StatusUnauthorized + msg := http.StatusText(status) + + return renderErrorPage( + c, status, msg+": You should not be here", "This endpoint is for users only", + ) + } + + h := new(hibpBreachDetail) + if err := c.Bind(h); err != nil { + return renderErrorPage( + c, + http.StatusBadRequest, + http.StatusText(http.StatusBadRequest), + err.Error(), + ) + } + + ctx, ok := c.Get("sloggerCtx").(context.Context) + if !ok { + ctx = context.WithValue(context.Background(), hibp.CtxKey{}, slogger) + } + + breachDetail, err := hibp.BreachForBreachName(ctx, dbclient, h.BreachName) + if err != nil { + return renderErrorPage( + c, + http.StatusInternalServerError, + http.StatusText(http.StatusInternalServerError), + err.Error(), + ) + } + + p := newPage() + + p.Title = "HIBP breach details" + p.Current = "hibp-breach-details" + p.User = u + p.Data["hibp"] = breachDetail + + err = c.Render( + http.StatusOK, + "user/hibp-details.tmpl", + p, + ) + if err != nil { + c.Logger().Errorf("error: %q", err) + + return renderErrorPage( + c, + http.StatusInternalServerError, + http.StatusText(http.StatusInternalServerError), + err.Error(), + ) + } + + return nil + } +} diff --git a/justfile b/justfile index df95473..4b7c76d 100644 --- a/justfile +++ b/justfile @@ -1,6 +1,6 @@ cmd := "podman" cfile := "Containerfile" -tag := "docker.io/immawanderer/pcmt:testbuild" +tag := "docker.io/immawanderer/mt-pcmt:testbuild" args := "build -t "+ tag + " " + buildargs + " --no-cache --pull -f " + cfile buildargs := "--build-arg VERSION=" + vcs_ref + " --build-arg BUILD_DATE=" + build_date + " --build-arg VCS_REF=" + vcs_ref kanikoargs := "run -it -w=" + kanikowdir + " -v $(pwd):" + kanikowdir + ":z " + kanikoexecutorimg + " -f=" + cfile + " -c=" + kanikocontext + " --use-new-run --snapshotMode=redo --no-push " + buildargs diff --git a/modules/db/db.go b/modules/db/db.go index 102369a..f84f467 100644 --- a/modules/db/db.go +++ b/modules/db/db.go @@ -115,6 +115,7 @@ func PrintMigration(ctx context.Context, client *ent.Client) error { // has not been set up prior, it creates and admin user with the initPasswd and // the default username and email (see ../user/const.go). func SetUp(ctx context.Context, client *ent.Client, createAdmin bool, initPasswd string) error { + // TODO: https://entgo.io/blog/2022/05/09/versioned-migrations-sum-file/ if err := client.Schema.Create(ctx); err != nil { return fmt.Errorf("failed to create schema resources: %v", err) } diff --git a/modules/funcmap/funcmap.go b/modules/funcmap/funcmap.go index ea5b423..57ed3e6 100644 --- a/modules/funcmap/funcmap.go +++ b/modules/funcmap/funcmap.go @@ -6,6 +6,8 @@ package funcmap import ( "html/template" "io/fs" + "strings" + "time" modbluemonday "git.dotya.ml/mirre-mt/pcmt/modules/bluemonday" ) @@ -24,6 +26,15 @@ func FuncMap() template.FuncMap { modbluemonday.Policy.Sanitize(html), ) }, + // a voluntarily unsafe func. + "htmlRaw": func(value string) template.HTML { + // nolint:gosec + return template.HTML(value) + }, + "htmlLinkStyle": func(value string) string { + value = strings.ReplaceAll(value, "", ``) + }, "pageIs": func(want, got string) bool { return want == got }, @@ -57,6 +68,9 @@ func FuncMap() template.FuncMap { return *r }, + "usrFinishedSetup": func(lastLogin time.Time) bool { + return lastLogin.After(time.Unix(0, 0)) + }, } } diff --git a/modules/hibp/hibp.go b/modules/hibp/hibp.go index 0b41e7b..b74f860 100644 --- a/modules/hibp/hibp.go +++ b/modules/hibp/hibp.go @@ -7,7 +7,6 @@ import ( "context" "encoding/json" "io" - "log" "net/http" "os" "time" @@ -57,6 +56,9 @@ const ( var ( apiKey = os.Getenv("PCMT_HIBP_API_KEY") client = &http.Client{Timeout: reqTmOut} + log = slog.With( + slog.Group("pcmt extra", slog.String("module", "modules/hibp")), + ) ) // SubscriptionStatus models https://haveibeenpwned.com/API/v3#SubscriptionStatus. @@ -65,7 +67,7 @@ func SubscriptionStatus() (*Subscription, error) { req, err := http.NewRequest("GET", u, nil) if err != nil { - log.Fatalln(err) + log.Error("Could not create a new HTTP request", "error", err) } setUA(req) @@ -100,7 +102,8 @@ func GetAllBreaches() (*[]schema.HIBPSchema, error) { req, err := http.NewRequest("GET", u, nil) if err != nil { - log.Fatalln(err) + log.Error("Could not create a new HTTP request", "error", err) + return nil, err } respCh, errCh := rChans() @@ -140,7 +143,8 @@ func GetAllBreachesForAccount(account string) ([]BreachName, error) { req, err := http.NewRequest("GET", u, nil) if err != nil { - log.Fatalln(err) + log.Error("Could not create a new HTTP request", "error", err) + return nil, err } respCh, errCh := rChans() @@ -187,13 +191,6 @@ func GetAllBreachesForAccount(account string) ([]BreachName, error) { // GetBreachesForBreachNames retrieves HIBP breaches from the database for a // list of names. func GetBreachesForBreachNames(ctx context.Context, client *ent.Client, names []string) ([]*ent.HIBP, error) { - slogger := ctx.Value(CtxKey{}).(*slogging.Slogger) - log := *slogger - - log.Logger = log.Logger.With( - slog.Group("pcmt extra", slog.String("module", "modules/hibp")), - ) - hs := make([]*ent.HIBP, 0) for _, name := range names { @@ -204,11 +201,11 @@ func GetBreachesForBreachNames(ctx context.Context, client *ent.Client, names [] if err != nil { switch { case ent.IsNotFound(err): - log.Warnf("breach not found by name %q: %q", name, err) + log.Warn("Breach not found by name", "name", name, "error", err) return nil, ErrBreachNotFound case ent.IsNotSingular(err): - log.Warnf("multiple breaches returned for name %q: %q", name, err) + log.Warn("Multiple breaches returned for name", "name", name, "error", err) return nil, ErrBreachNotSingular case err != nil: @@ -226,6 +223,39 @@ func GetBreachesForBreachNames(ctx context.Context, client *ent.Client, names [] return hs, nil } +// BreachForBreachName retrieves a single HIBP breach from the database for a +// given name. +func BreachForBreachName(ctx context.Context, client *ent.Client, name string) (*ent.HIBP, error) { + log := slog.With( + slog.Group("pcmt extra", slog.String("module", "modules/hibp")), + ) + + b, err := client.HIBP. + Query(). + Where(hibp.NameEQ(name)). + Only(ctx) + if err != nil { + switch { + case ent.IsNotFound(err): + log.Error("Breach not found by name", "name", name, "error", err) + return nil, ErrBreachNotFound + + case ent.IsNotSingular(err): + log.Error("Multiple breaches returned for breach name", "name", name, "error", err) + return nil, ErrBreachNotSingular + + case err != nil: + log.Error("Failed to query breach by name", "error", err, "name requested", name) + return nil, ErrFailedToQueryBreaches + + default: + return nil, err + } + } + + return b, nil +} + // SaveAllBreaches saves all breaches to DB as a cache. func SaveAllBreaches(ctx context.Context, client *ent.Client, breaches *[]schema.HIBPSchema) error { slogger := ctx.Value(CtxKey{}).(*slogging.Slogger) @@ -235,10 +265,13 @@ func SaveAllBreaches(ctx context.Context, client *ent.Client, breaches *[]schema slog.Group("pcmt extra", slog.String("module", "modules/hibp")), ) - if breaches == nil { + if breaches == nil || len(*breaches) == 0 { + log.Error("Received 0 HIBP breaches / nil breaches object") return ErrNoBreachesToSave } + log.Infof("HIBP API returned %d breaches, saving...", len(*breaches)) + for _, b := range *breaches { _, err := client.HIBP. Create(). @@ -260,6 +293,7 @@ func SaveAllBreaches(ctx context.Context, client *ent.Client, breaches *[]schema SetLogoPath(b.LogoPath). Save(ctx) if err != nil { + log.Errorf("Could not save HIBP breaches to DB, err: %q", err) return err } } @@ -267,6 +301,41 @@ func SaveAllBreaches(ctx context.Context, client *ent.Client, breaches *[]schema return nil } +// CheckSaveAllBreaches checks if there are any in the DB and if not then +// queries the API and saves what it gets. TODO: have this function consolidate +// existing vs. new breaches. +func CheckSaveAllBreaches(ctx context.Context, client *ent.Client) error { + slogger := ctx.Value(CtxKey{}).(*slogging.Slogger) + log := *slogger + + log.Logger = log.Logger.With( + slog.Group("pcmt extra", slog.String("module", "modules/hibp")), + ) + + log.Info("Checking if we have any HIBP breaches saved") + + alreadySaved, err := client.HIBP.Query().Count(ctx) + + switch { + case err != nil: + return err + + case alreadySaved > 0: + log.Infof("There are %d HIBP breaches already, not attempting to save new breaches into DB", alreadySaved) + return nil + } + + log.Info("No HIBP breaches found in the DB, refreshing from API...") + + breaches, err := GetAllBreaches() + if err != nil { + log.Error("Could not save HIBP breaches") + return err + } + + return SaveAllBreaches(ctx, client, breaches) +} + func setUA(r *http.Request) { r.Header.Set(headerUA, appID) } diff --git a/modules/hibp/schedule.go b/modules/hibp/schedule.go index b9f53d3..8aa7576 100644 --- a/modules/hibp/schedule.go +++ b/modules/hibp/schedule.go @@ -215,11 +215,12 @@ func doReqs(ok chan error, wg *sync.WaitGroup) { nxckpLock.Lock() timeToWait = time.Until(nextCheckpoint) + time.Sleep(timeToWait) // yes, sleep while locked. ttwLock.Unlock() nxckpLock.Unlock() - break + return } } @@ -311,7 +312,6 @@ func zeroQueue() bool { defer rqqLock.RUnlock() if len(queue.Requests) == 0 { - slog.Debug("Queue empty") return true } diff --git a/modules/user/user.go b/modules/user/user.go index 1f44b64..47eaf60 100644 --- a/modules/user/user.go +++ b/modules/user/user.go @@ -26,6 +26,7 @@ type User struct { CreatedAt time.Time UpdatedAt time.Time IsLoggedIn bool + LastLogin time.Time } // CreateUser adds a user entry to the database. @@ -139,6 +140,146 @@ func QueryUserByID(ctx context.Context, client *ent.Client, strID string) (*ent. return u, nil } +func UsrFinishedSetup(ctx context.Context, client *ent.Client, id uuid.UUID) (bool, error) { + u, err := client.User.Get(ctx, id) + if err != nil { + return false, err + } + + if u.LastLogin.After(time.Unix(0, 0)) { + return true, nil + } + + return false, nil +} + +func ChangePassFirstLogin(ctx context.Context, client *ent.Client, id uuid.UUID, password string) error { + slogger := ctx.Value(CtxKey{}).(*slogging.Slogger) + log := *slogger + + log.Logger = log.Logger.With( + slog.Group("pcmt extra", slog.String("module", "modules/user")), + ) + + finishedSetup, err := UsrFinishedSetup(ctx, client, id) + + switch { + case err != nil: + return err + + case finishedSetup: + return nil + } + + var digest []byte + + digest, err = passwd.GetHash(password) + if err != nil { + log.Errorf("error hashing password: %s", err) + return errors.New("could not hash password") + } + + // TODO: turn this into a transaction. + u, err := client.User. + Update().Where(user.IDEQ(id)). + SetPassword(digest). + Save(ctx) + + switch { + case err != nil: + return fmt.Errorf("failed to update user password: %w", err) + + case u > 1: + return fmt.Errorf("somehow updated password of more than one user? count: %d", u) + } + + u, err = client.User. + Update().Where(user.IDEQ(id)). + SetLastLogin(time.Now()). + Save(ctx) + + switch { + case err != nil: + return fmt.Errorf("failed to set last login: %w", err) + + case u > 1: + return fmt.Errorf("somehow updated last login information of more than one user? count: %d", u) + } + + return nil +} + +func UpdateUserByAdmin(ctx context.Context, client *ent.Client, id uuid.UUID, email, username, password string, isAdmin bool, isActive *bool) error { + slogger := ctx.Value(CtxKey{}).(*slogging.Slogger) + log := *slogger + + log.Logger = log.Logger.With( + slog.Group("pcmt extra", slog.String("module", "modules/user")), + ) + + finishedSetup, err := UsrFinishedSetup(ctx, client, id) + if err != nil { + return err + } + + var active bool + + if isActive != nil { + active = *isActive + } + + var u int + + // ignore updates to password when user finished setting up (if not admin). + if !isAdmin && finishedSetup { + u, err = client.User. + Update().Where(user.IDEQ(id)). + SetEmail(email). + SetUsername(username). + SetIsAdmin(isAdmin). + SetIsActive(active). + Save(ctx) + } else { + var digest []byte + + digest, err = passwd.GetHash(password) + if err != nil { + log.Errorf("error hashing password: %s", err) + return errors.New("could not hash password") + } + + u, err = client.User. + Update().Where(user.IDEQ(id)). + SetEmail(email). + SetUsername(username). + SetPassword(digest). + SetIsAdmin(isAdmin). + SetIsActive(active). + Save(ctx) + } + + switch { + case ent.IsConstraintError(err): + log.Errorf("the username '%s' already exists", username) + return errors.New("username is not unique") + + case err != nil: + return fmt.Errorf("failed to update user: %w", err) + + case u > 1: + return fmt.Errorf("somehow updated more than one user? count: %d", u) + } + + log.Debug( + fmt.Sprintf( + "user successfully updated - id: %s, name: %s, active: %t, admin: %t", + id, username, *isActive, isAdmin, + ), + ) + + return nil +} + // DeleteUserByID returns nil on successful deletion, err otherwise. func DeleteUserByID(ctx context.Context, client *ent.Client, strID string) error { slogger := ctx.Value(CtxKey{}).(*slogging.Slogger) diff --git a/run.go b/run.go index 1a9309a..855ef93 100644 --- a/run.go +++ b/run.go @@ -189,7 +189,7 @@ func run() error { //nolint:gocognit ctx := context.WithValue(context.Background(), moddb.CtxKey{}, slogger) if *printMigrationFlag { - log.Debug("printing the following migration to stdout") + log.Debug("printing the upcoming migration to stdout") return moddb.PrintMigration(ctx, db) } @@ -276,9 +276,23 @@ func run() error { //nolint:gocognit handleSigs(schedQuit, quit) - go func() { + wg.Add(1) + go func() { // nolint:wsl + defer wg.Done() // for this goroutine + + // this is for the scheduler goroutine. wg.Add(1) // nolint:staticcheck go hibp.RunReqScheduler(schedQuit, errCh, wg) // nolint:wsl + + // TODO: pass a chan to the scheduler for reporting readiness. + log.Info("Giving HIBP requests scheduler time to start") + time.Sleep(time.Second) + + if err = hibp.CheckSaveAllBreaches( + context.WithValue(context.Background(), hibp.CtxKey{}, slogger), + db); err != nil { + log.Error("failed to refresh HIBP db cache") + } }() // non-blocking channel receive. diff --git a/templates/head.tmpl b/templates/head.tmpl index 3a0be69..8f41059 100644 --- a/templates/head.tmpl +++ b/templates/head.tmpl @@ -15,14 +15,16 @@ {{- end -}} - + + {{- if .DevelMode -}} - + + {{ template "browsersync.tmpl" }} {{ else }} diff --git a/templates/home.tmpl b/templates/home.tmpl index f6e631c..b7e0f31 100644 --- a/templates/home.tmpl +++ b/templates/home.tmpl @@ -3,6 +3,11 @@ {{ template "navbar.tmpl" . }}
+ {{ if and .Data .Data.flash }} +

+ {{ .Data.flash }} +

+ {{- end }} {{ if .Name -}}

Welcome, {{.Name}}!
diff --git a/templates/manage/apikeys.tmpl b/templates/manage/apikeys.tmpl index a9f0faf..c85b97f 100644 --- a/templates/manage/apikeys.tmpl +++ b/templates/manage/apikeys.tmpl @@ -19,17 +19,17 @@
-
-
diff --git a/templates/manage/user-details.tmpl b/templates/manage/user-details.tmpl index 4cb9b65..aae416a 100644 --- a/templates/manage/user-details.tmpl +++ b/templates/manage/user-details.tmpl @@ -3,6 +3,11 @@ {{ template "navbar.tmpl" . }}
+ {{ if and .Data .Data.flash }} +

+ {{ .Data.flash }} +

+ {{- end }}

User details @@ -51,15 +56,30 @@ {{- .Data.user.IsActive -}}

+
+ Last login: +

+ {{ if usrFinishedSetup .Data.user.LastLogin }} + {{- .Data.user.LastLogin -}} + {{- else -}} + never + {{- end -}} +

+
+
+ Created: +

+ {{- .Data.user.CreatedAt -}} +

+
+
+ Updated: +

+ {{- .Data.user.UpdatedAt -}} +

+
{{- end -}} - {{- if and .Data .Data.flash -}} -
-

- {{ .Data.flash -}} -

-
- {{- end}}

{{ template "footer.tmpl" . }} diff --git a/templates/manage/user-edit.tmpl b/templates/manage/user-edit.tmpl index 16aae52..c71609b 100644 --- a/templates/manage/user-edit.tmpl +++ b/templates/manage/user-edit.tmpl @@ -43,18 +43,20 @@ Please provide a valid email address.

+ {{- if or (and .Data.user .Data.user.IsAdmin) (not (usrFinishedSetup .Data.user.LastLogin)) -}}
{{ template "svg-password.tmpl" }} - +
{{ template "svg-password.tmpl" }} - +
+ {{- end -}}
Username Email + Last login Created Admin Active @@ -41,6 +42,15 @@ {{- $u.Email -}} + + + {{ if usrFinishedSetup $u.LastLogin }} + {{- $u.LastLogin -}} + {{- else -}} + never + {{- end -}} + + {{- $u.CreatedAt -}} diff --git a/templates/navbar.tmpl b/templates/navbar.tmpl index 27f6b9b..937b248 100644 --- a/templates/navbar.tmpl +++ b/templates/navbar.tmpl @@ -24,6 +24,7 @@ {{ if and .User .User.IsLoggedIn }} + {{ if .User.IsAdmin }}
  • {{- if or (pageIs .Current "manage-users") (pageIs .Current "manage-users-new") (pageIs .Current "manage-users-user-details") (pageIs .Current "manage-users-edit-user") (pageIs .Current "manage-users-delete-user") -}} @@ -42,7 +43,18 @@ API keys
  • - {{end}} + {{- else -}} +
  • + {{- if or (pageIs .Current "hibp-search") -}} + + {{else}} + + {{end}} + Search HIBP + +
  • + {{- end -}} + {{- end -}}
  • diff --git a/templates/user/hibp-details.tmpl b/templates/user/hibp-details.tmpl new file mode 100644 index 0000000..fd9682e --- /dev/null +++ b/templates/user/hibp-details.tmpl @@ -0,0 +1,158 @@ +{{ template "head.tmpl" . }} + + {{ template "navbar.tmpl" . }} +
    +
    + +
    + + + 🛈 + + Breach data on this page is sourced from + + Have I Been Pwned? + and is + + available + under + + CC-BY-4.0 + + +
    + {{if and .Data .Data.hibp -}} +
    +
    + Name: + + {{- .Data.hibp.Name -}} + +
    +
    + Title: + + {{- .Data.hibp.Title -}} + +
    +
    + Domain: + + {{- .Data.hibp.Domain -}} + +
    +
    + Breach date: + + {{- .Data.hibp.BreachDate -}} + +
    +
    + Added date: + + {{- .Data.hibp.AddedDate -}} + +
    +
    + Modified date: + + {{- .Data.hibp.ModifiedDate -}} + +
    +
    + Pwn count: + + {{- .Data.hibp.PwnCount -}} + +
    +
    + Description: + + {{- htmlRaw (htmlLinkStyle .Data.hibp.Description) -}} + +
    +
    + Affects: +
    + {{ $dcs := index .Data.hibp.Dataclasses }} + {{ range $_, $dc := $dcs }} + + {{- $dc -}} + + {{end}} +
    +
    +
    +
    + Verified: + {{- if .Data.hibp.IsVerified -}} + true + {{- else -}} + + {{- end -}} + + Fabricated: + {{- if .Data.hibp.IsFabricated -}} + true + {{- else -}} + + {{- end -}} + + Sensitive: + {{- if .Data.hibp.IsSensitive -}} + true + {{- else -}} + + {{- end -}} + + Retired: + {{- if .Data.hibp.IsRetired -}} + true + {{- else -}} + + {{- end -}} + + Spam list: + {{- if .Data.hibp.IsSpamList -}} + true + {{- else -}} + + {{- end -}} + + Malware: + {{- if .Data.hibp.IsMalware -}} + true + {{- else -}} + + {{- end -}} + +
    +
    +
    + {{- else -}} +
    +

    + No data in the system for this breach +

    +
    + {{- end -}} + {{- if and .Data .Data.flash -}} +
    +

    + {{ .Data.flash -}} +

    +
    + {{- end}} +
    +
    +{{ template "footer.tmpl" . }} diff --git a/templates/user/hibp-search.tmpl b/templates/user/hibp-search.tmpl new file mode 100644 index 0000000..b284b5e --- /dev/null +++ b/templates/user/hibp-search.tmpl @@ -0,0 +1,78 @@ +{{ template "head.tmpl" . }} + + {{ template "navbar.tmpl" . }} +
    +
    +
    +

    + Breach search +

    +
    +
    + + + 🛈 + + Breach data on this page is sourced from + + Have I Been Pwned? + and is + + available + under + + CC-BY-4.0 + + +
    + {{- if and .Data .Data.flash -}} +
    +

    + {{ .Data.flash -}} +

    +
    + {{- end}} +
    + + +
    + +
    + + +
    + {{ if and .Data .Data.breachNames }} +
    +
    + + The account was found in all of the following breaches: + +
      + {{ $bn := index .Data.breachNames }} + {{ range $_, $b := $bn }} +
    • + + {{$b.Name}} + +
    • + {{ end }} +
    + + {{end}} +
    +
    +
    +{{ template "footer.tmpl" . }} diff --git a/templates/user/init-password-change.tmpl b/templates/user/init-password-change.tmpl new file mode 100644 index 0000000..77edbe9 --- /dev/null +++ b/templates/user/init-password-change.tmpl @@ -0,0 +1,47 @@ +{{ template "head.tmpl" . }} + + {{ template "navbar.tmpl" . }} +
    +
    + {{ if and .Data .Data.flash }} +

    + {{ .Data.flash }} +

    + {{- end }} + {{ if .User -}} +

    + Welcome, {{.Name}}, since you have never changed your password here before, now is the time to do that!
    +

    + + This is necessary in order to make sure that only you know the password, since the account was pre-created for you. + +
    +
    +
    + +
    + + {{ template "svg-password.tmpl" }} + + +
    + +
    +
    +
    + + +
    +
    + {{- else }} +

    + Please log in. +

    + {{- end -}} +
    +
    +{{ template "footer.tmpl" . }}