go: add user onboarding, HIBP search functionality
All checks were successful
continuous-integration/drone/push Build is passing

* 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
This commit is contained in:
surtur 2023-08-24 18:43:24 +02:00
parent dcb3dfdecc
commit 6b45213649
Signed by: wanderer
SSH Key Fingerprint: SHA256:MdCZyJ2sHLltrLBp0xQO0O1qTW9BT/xl5nXkDvhlMCI
36 changed files with 1221 additions and 110 deletions

View File

@ -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)

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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),
}

View File

@ -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
)

View File

@ -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.

View File

@ -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,
)
}

View File

@ -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"})
}

View File

@ -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)

View File

@ -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 {

View File

@ -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))
}
}

View File

@ -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)
}
}

174
handlers/search-hibp.go Normal file
View File

@ -0,0 +1,174 @@
// Copyright 2023 wanderer <a_mirre at utb dot cz>
// 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
}
}

View File

@ -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())

View File

@ -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"`
}

View File

@ -0,0 +1,129 @@
// Copyright 2023 wanderer <a_mirre at utb dot cz>
// 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
}
}

81
handlers/view-hibp.go Normal file
View File

@ -0,0 +1,81 @@
// Copyright 2023 wanderer <a_mirre at utb dot cz>
// 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
}
}

View File

@ -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

View File

@ -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)
}

View File

@ -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, "<a ", `<span><a class="w-auto py-1 mt-2 text-center text-blue-500 md:mt-0 mx-0 hover:underline dark:text-blue-400"`)
return strings.ReplaceAll(value, "</a>", `</a></span>`)
},
"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))
},
}
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)

18
run.go
View File

@ -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.

View File

@ -15,14 +15,16 @@
<meta property="og:site_name" content="{{ .AppName }}">
{{- end -}}
<meta property="og:type" content="website">
<meta name="referrer" content="no-referrer, strict-origin-when-cross-origin">
<!-- <meta name="referrer" content="no-referrer, strict-origin-when-cross-origin"> -->
<meta name="referrer" content="strict-origin-when-cross-origin">
<link rel="icon" href="/assets/img/logo-pcmt.svg" type="image/svg+xml">
<link href="/assets/css/pcmt.css" rel="preload" as="style" integrity="{{- sha384 "css/pcmt.css" -}}">
<link href="/assets/css/pcmt.css" rel="stylesheet" integrity="{{- sha384 "css/pcmt.css" -}}">
{{- if .DevelMode -}}
<meta http-equiv="content-security-policy" content="upgrade-insecure-requests; default-src 'self'; connect-src 'self' ws://localhost:3002 http://localhost:3002; script-src 'self' http://localhost:3002;"/>
<!-- <meta http-equiv="content-security-policy" content="upgrade-insecure-requests; default-src 'self'; connect-src 'self' ws://localhost:3002 http://localhost:3002; script-src 'self' http://localhost:3002; style-src 'self' 'unsafe-inline';"/> -->
<meta http-equiv="content-security-policy" content="default-src 'self'; connect-src 'self' ws://localhost:3002 http://localhost:3002; script-src 'self' http://localhost:3002;"/>
<!-- inject browsersync script if running in devel mode -->
{{ template "browsersync.tmpl" }}
{{ else }}

View File

@ -3,6 +3,11 @@
{{ template "navbar.tmpl" . }}
<main class="grow">
<div class="px-2 md:px-0 place-items-center text-center">
{{ if and .Data .Data.flash }}
<h1 class="text-xl text-pink-600 dark:text-pink-500 py-2">
{{ .Data.flash }}
</h1>
{{- end }}
{{ if .Name -}}
<h1 class="mt-20 text-2xl text-pink-400 font-bold">
Welcome, <code>{{.Name}}</code>!<br>

View File

@ -19,17 +19,17 @@
<form method="post" class="w-full lg:max-w-7xl px-2">
<input type="hidden" name="csrf" value="{{- .CSRF -}}">
<div class="items-center">
<label for="hibp-api-key" class="text-purple-500 font-bold">
<label for="hibpApiKey" class="text-purple-500 font-bold">
Have I Been Pwned?
</label>
<input name="hibp-api-key" type="text" placeholder="HIBP API key" {{if and .Data.form .Data.form.HibpAPIKey}}value="{{.Data.form.HibpAPIKey}}"{{end}} autofocus class="py-3 px-8 mt-2 w-full valid:border text-gray-700 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:valid:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
<input name="hibpApiKey" type="text" placeholder="HIBP API key" {{if and .Data .Data.hibpApiKey}}value="{{.Data.hibpApiKey}}"{{end}} autofocus class="py-3 px-8 mt-2 w-full valid:border text-gray-700 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:valid:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
<p id="hibp-expl" class="hover:cursor-help mt-2 text-sm text-gray-500 dark:text-gray-400">Adds support for querying Troy Hunt's <a href="https://haveibeenpwned.com/API/v3" class="font-medium text-blue-600 hover:underline dark:text-blue-500">Have I Been Pwned? API</a>.</p>
</div>
<div class="mt-4 block md:flex md:items-center place-items-end space-x-0 md:space-x-2">
<button class="px-2 py-3 w-full md:w-auto text-sm font-medium tracking-wide text-white transition-colors duration-300 transform bg-blue-500 rounded-lg hover:bg-blue-400 focus:outline-none focus:ring focus:ring-blue-300 focus:ring-opacity-50">
Save
</button>
<button {{if and (not .Data.form) (not .Data.form.HubpAPIKey)}}disabled class="cursor-not-allowed{{else}}class="transition-colors duration-300 transform hover:bg-gray-400 focus:outline-none focus:ring focus:ring-gray-300 focus:ring-opacity-50{{end}} px-2 py-3 mt-2 md:mt-0 w-full md:w-auto text-sm font-medium tracking-wide text-white bg-gray-500 rounded-lg">
<button {{if and (not .Data) (not .Data.hibpApiKey)}}disabled class="cursor-not-allowed{{else}}class="transition-colors duration-300 transform hover:bg-gray-400 focus:outline-none focus:ring focus:ring-gray-300 focus:ring-opacity-50{{end}} px-2 py-3 mt-2 md:mt-0 w-full md:w-auto text-sm font-medium tracking-wide text-white bg-gray-500 rounded-lg">
Test key
</button>
</div>

View File

@ -3,6 +3,11 @@
{{ template "navbar.tmpl" . }}
<main class="grow">
<div class="container mx-auto place-items-center px-8 md:px-12 lg:px-14">
{{ if and .Data .Data.flash }}
<h1 class="text-xl text-pink-600 dark:text-pink-500 py-2">
{{ .Data.flash }}
</h1>
{{- end }}
<div class="flex justify-between place-items-center">
<h1 class="text-xl font-bold text-fuchsia-600 dark:text-fuchsia-400 capitalize py-2">
User details
@ -51,15 +56,30 @@
{{- .Data.user.IsActive -}}
</h3>
</div><!-- isActive -->
<div class="flex place-items-center justify-left lg:justify-between overflow-x-auto text-ellipsis hover:bg-gray-100 dark:hover:bg-gray-700">
<span class="w-24 md:w-32 px-2 text-purple-500 dark:text-purple-300">Last login:</span>
<h3 class="text-lg text-fuchsia-500 dark:text-fuchsia-400 px-2">
{{ if usrFinishedSetup .Data.user.LastLogin }}
{{- .Data.user.LastLogin -}}
{{- else -}}
never
{{- end -}}
</h3>
</div><!-- updated -->
<div class="flex place-items-center justify-left lg:justify-between overflow-x-auto text-ellipsis hover:bg-gray-100 dark:hover:bg-gray-700">
<span class="w-24 md:w-32 px-2 text-purple-500 dark:text-purple-300">Created:</span>
<h3 class="text-lg text-fuchsia-500 dark:text-fuchsia-400 px-2">
{{- .Data.user.CreatedAt -}}
</h3>
</div><!-- created -->
<div class="flex place-items-center justify-left lg:justify-between overflow-x-auto text-ellipsis hover:bg-gray-100 dark:hover:bg-gray-700">
<span class="w-24 md:w-32 px-2 text-purple-500 dark:text-purple-300">Updated:</span>
<h3 class="text-lg text-fuchsia-500 dark:text-fuchsia-400 px-2">
{{- .Data.user.UpdatedAt -}}
</h3>
</div><!-- updated -->
</div>
{{- end -}}
{{- if and .Data .Data.flash -}}
<div class="place-items-center">
<h1 class="text-2xl text-pink-600 dark:text-pink-500 py-2">
{{ .Data.flash -}}
</h1>
</div>
{{- end}}
</div>
</main>
{{ template "footer.tmpl" . }}

View File

@ -43,18 +43,20 @@
Please provide a valid email address.
</p>
</div>
{{- if or (and .Data.user .Data.user.IsAdmin) (not (usrFinishedSetup .Data.user.LastLogin)) -}}
<div class="relative flex items-center mt-4">
<span class="absolute" role="img" aria-label="password lock icon">
{{ template "svg-password.tmpl" }}
</span>
<input name="password" type="password" {{if and .Data.user .Data.user.Password}}value="{{.Data.user.Password}}"{{end}} placeholder="Password" required class="block w-full px-10 py-3 required:border-blue-300 text-gray-700 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
<input name="password" type="password" value="" placeholder="Password" required class="block w-full px-10 py-3 required:border-blue-300 text-gray-700 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
</div>
<div class="relative flex items-center mt-4">
<span class="absolute" role="img" aria-label="password lock icon">
{{ template "svg-password.tmpl" }}
</span>
<input name="repeatPassword" type="password" {{if and .Data.user .Data.user.Password}}value="{{.Data.user.Password}}"{{end}} placeholder="Repeat Password" required class="block w-full px-10 py-3 required:border-blue-300 text-gray-700 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
<input name="repeatPassword" type="password" value="" placeholder="Repeat Password" required class="block w-full px-10 py-3 required:border-blue-300 text-gray-700 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
</div>
{{- end -}}
<div class="flex pt-2 px-2 items-center justify-center gap-6">
<div class="mb-1 block min-h-3">
<input

View File

@ -22,6 +22,7 @@
<tr>
<th scope="col" class="md:p-2 sm:p-0 text-slate-600 dark:text-slate-400">Username</th>
<th scope="col" class="md:p-2 sm:p-0 text-left text-slate-600 dark:text-slate-400">Email</th>
<th scope="col" class="md:p-2 sm:p-0 text-left px-2 text-slate-600 dark:text-slate-400">Last login</th>
<th scope="col" class="md:p-2 sm:p-0 text-left px-2 text-slate-600 dark:text-slate-400">Created</th>
<th scope="col" class="md:p-2 sm:p-0 sm:px-1 text-slate-600 dark:text-slate-400">Admin</th>
<th scope="col" class="md:p-2 sm:p-0 text-slate-600 dark:text-slate-400">Active</th>
@ -41,6 +42,15 @@
{{- $u.Email -}}
</span>
</td>
<td class="text-left">
<span class="p-2 font-mono select-all">
{{ if usrFinishedSetup $u.LastLogin }}
{{- $u.LastLogin -}}
{{- else -}}
never
{{- end -}}
</span>
</td>
<td class="text-left">
<span class="p-2 text-sm mx-auto font-mono">
{{- $u.CreatedAt -}}

View File

@ -24,6 +24,7 @@
</a>
</li>
{{ if and .User .User.IsLoggedIn }}
{{ if .User.IsAdmin }}
<li>
{{- 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") -}}
<a href="/manage/users" class="block py-2 pl-3 pr-4 text-white bg-blue-500 rounded md:bg-transparent md:text-blue-700 md:p-0 md:dark:text-blue-500 dark:bg-blue-500 md:dark:bg-transparent" aria-current="page">
@ -42,7 +43,18 @@
API keys
</a>
</li>
{{end}}
{{- else -}}
<li>
{{- if or (pageIs .Current "hibp-search") -}}
<a href="/user/hibp-search" class="block py-2 pl-3 pr-4 text-white bg-blue-500 rounded md:bg-transparent md:text-blue-700 md:p-0 md:dark:text-blue-500 dark:bg-blue-500 md:dark:bg-transparent" aria-current="page">
{{else}}
<a href="/user/hibp-search" class="block py-2 pl-3 pr-4 text-gray-900 rounded hover:bg-gray-300 md:hover:bg-transparent md:border-0 md:hover:text-blue-500 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">
{{end}}
Search HIBP
</a>
</li>
{{- end -}}
{{- end -}}
<li>
<a href="https://git.dotya.ml/mirre-mt/masters-thesis/" target="_blank" rel="noopener"
class="block py-2 pl-3 pr-4 text-gray-900 rounded hover:bg-gray-300 md:hover:bg-transparent md:border-0 md:hover:text-blue-500 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">

View File

@ -0,0 +1,158 @@
{{ template "head.tmpl" . }}
<body class="h-screen bg-white dark:bg-gray-900">
{{ template "navbar.tmpl" . }}
<main class="grow">
<div class="container mx-auto place-items-center px-8 md:px-12 lg:px-14">
<div class="flex justify-between place-items-center">
<h1 class="text-xl font-bold text-fuchsia-600 dark:text-fuchsia-400 capitalize py-2">
Breach details
</h1>
<a href="/user/hibp-search" class="w-auto py-1 mt-2 text-center text-blue-500 md:mt-0 md:mx-6 lg:mx-4 hover:underline dark:text-blue-400">
⏎ Breach search
</a>
</div>
<div class="flex justify-between place-items-center">
<span class="text-sm text-gray-500 dark:text-gray-300 hover:cursor-help">
<span class="text-lg text-pink-500 dark:text-pink-300">
&#x1F6C8;
</span>
Breach data on this page is sourced from
<a href="https://haveibeenpwned.com" target="_blank" rel="noopener"
class="w-auto py-1 mt-2 text-center text-blue-500 md:mt-0 mx-0 hover:underline dark:text-blue-400">
Have I Been Pwned?</a>
and is
<a href="https://haveibeenpwned.com/API/v3#License" target="_blank" rel="noopener"
class="w-auto py-1 mt-2 text-center text-blue-500 md:mt-0 mx-0 hover:underline dark:text-blue-400">
available</a>
under
<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener"
class="w-auto py-1 mt-2 text-center text-blue-500 md:mt-0 mx-0 hover:underline dark:text-blue-400">
CC-BY-4.0
</a>
</span>
</div>
{{if and .Data .Data.hibp -}}
<div class="p-2 mt-3 lg:mx-auto border-2 dark:border-slate-500 rounded-sm space-y-0">
<div class="flex max-h-14 place-items-baseline justify-left lg:justify-between overflow-x-auto text-ellipsis hover:bg-gray-100 dark:hover:bg-gray-700">
<span class="w-24 md:w-32 px-2 text-purple-500 dark:text-purple-300">Name:</span>
<span class="text-md text-fuchsia-500 dark:text-fuchsia-400 px-2 overflow-x-auto text-ellipsis select-all">
{{- .Data.hibp.Name -}}
</span>
</div><!-- name -->
<div class="flex place-items-center justify-left lg:justify-between overflow-x-auto text-ellipsis hover:bg-gray-100 dark:hover:bg-gray-700">
<span class="w-24 md:w-32 px-2 text-purple-500 dark:text-purple-300">Title:</span>
<span class="text-md text-fuchsia-500 dark:text-fuchsia-400 px-2">
{{- .Data.hibp.Title -}}
</span>
</div><!-- title -->
<div class="flex place-items-center justify-left lg:justify-between overflow-x-auto text-ellipsis hover:bg-gray-100 dark:hover:bg-gray-700">
<span class="w-24 md:w-32 px-2 text-purple-500 dark:text-purple-300">Domain:</span>
<span class="text-md text-fuchsia-500 dark:text-fuchsia-400 px-2">
{{- .Data.hibp.Domain -}}
</span>
</div><!-- domain -->
<div class="flex place-items-center justify-left lg:justify-between overflow-x-auto text-ellipsis hover:bg-gray-100 dark:hover:bg-gray-700">
<span class="w-24 md:w-32 px-2 text-purple-500 dark:text-purple-300">Breach date:</span>
<span class="text-md text-fuchsia-500 dark:text-fuchsia-400 px-2">
{{- .Data.hibp.BreachDate -}}
</span>
</div><!-- breachDate -->
<div class="flex place-items-center justify-left lg:justify-between overflow-x-auto text-ellipsis hover:bg-gray-100 dark:hover:bg-gray-700">
<span class="w-24 md:w-32 px-2 text-purple-500 dark:text-purple-300">Added date:</span>
<span class="text-md text-fuchsia-500 dark:text-fuchsia-400 px-2">
{{- .Data.hibp.AddedDate -}}
</span>
</div><!-- addedDate -->
<div class="flex place-items-center justify-left lg:justify-between overflow-x-auto text-ellipsis hover:bg-gray-100 dark:hover:bg-gray-700">
<span class="w-24 md:w-32 px-2 text-purple-500 dark:text-purple-300">Modified date:</span>
<span class="text-md text-fuchsia-500 dark:text-fuchsia-400 px-2">
{{- .Data.hibp.ModifiedDate -}}
</span>
</div><!-- modifiedDate -->
<div class="flex place-items-center justify-left lg:justify-between overflow-x-auto text-ellipsis hover:bg-gray-100 dark:hover:bg-gray-700">
<span class="w-24 md:w-32 px-2 text-purple-500 dark:text-purple-300">Pwn count:</span>
<span class="text-md text-fuchsia-500 dark:text-fuchsia-400 px-2">
{{- .Data.hibp.PwnCount -}}
</span>
</div><!-- pwnCount -->
<div class="flex place-items-center justify-left lg:justify-between overflow-x-auto text-ellipsis hover:bg-gray-100 dark:hover:bg-gray-700">
<span class="w-24 md:w-32 px-2 text-purple-500 dark:text-purple-300">Description:</span>
<span class="text-md italic text-fuchsia-500 dark:text-fuchsia-400 px-2">
{{- htmlRaw (htmlLinkStyle .Data.hibp.Description) -}}
</span>
</div><!-- description -->
<div class="flex place-items-center justify-left lg:justify-between overflow-x-auto text-ellipsis hover:bg-gray-100 dark:hover:bg-gray-700">
<span class="w-24 md:w-32 px-2 text-purple-500 dark:text-purple-300">Affects:</span>
<div class="justify-center">
{{ $dcs := index .Data.hibp.Dataclasses }}
{{ range $_, $dc := $dcs }}
<span class="text-sm italic text-pink-500 dark:text-pink-400 px-2">
{{- $dc -}}
</span>
{{end}}
</div>
</div><!-- dataClasses -->
<div class="flex place-items-center justify-left mt-2 lg:justify-between overflow-x-auto text-ellipsis hover:bg-gray-100 dark:hover:bg-gray-700">
<div class="justify-center">
<span class="w-24 md:w-32 px-2 text-purple-500 dark:text-purple-300">Verified:
{{- if .Data.hibp.IsVerified -}}
<span class="w-24 md:w-32 px-2 text-blue-500 dark:text-blue-300">true</span>
{{- else -}}
<span class="w-16 md:w-16 px-2 text-pink-500 dark:text-pink-300"></span>
{{- end -}}
</span>
<span class="w-24 md:w-32 px-2 text-purple-500 dark:text-purple-300">Fabricated:
{{- if .Data.hibp.IsFabricated -}}
<span class="w-24 md:w-32 px-2 text-pink-500 dark:text-pink-300">true</span>
{{- else -}}
<span class="w-16 md:w-16 px-2 text-blue-500 dark:text-blue-300"></span>
{{- end -}}
</span>
<span class="w-24 md:w-32 px-2 text-purple-500 dark:text-purple-300">Sensitive:
{{- if .Data.hibp.IsSensitive -}}
<span class="w-24 md:w-32 px-2 text-pink-500 dark:text-pink-300">true</span>
{{- else -}}
<span class="w-16 md:w-16 px-2 text-blue-500 dark:text-blue-300"></span>
{{- end -}}
</span>
<span class="w-24 md:w-32 px-2 text-purple-500 dark:text-purple-300">Retired:
{{- if .Data.hibp.IsRetired -}}
<span class="w-24 md:w-32 px-2 text-pink-500 dark:text-pink-300">true</span>
{{- else -}}
<span class="w-16 md:w-16 px-2 text-blue-500 dark:text-blue-300"></span>
{{- end -}}
</span>
<span class="w-24 md:w-32 px-2 text-purple-500 dark:text-purple-300">Spam list:
{{- if .Data.hibp.IsSpamList -}}
<span class="w-24 md:w-32 px-2 text-pink-500 dark:text-pink-300">true</span>
{{- else -}}
<span class="w-16 md:w-16 px-2 text-blue-500 dark:text-blue-300"></span>
{{- end -}}
</span>
<span class="w-24 md:w-32 px-2 text-purple-500 dark:text-purple-300">Malware:
{{- if .Data.hibp.IsMalware -}}
<span class="w-24 md:w-32 px-2 text-pink-500 dark:text-pink-300">true</span>
{{- else -}}
<span class="w-16 md:w-16 px-2 text-blue-500 dark:text-blue-300"></span>
{{- end -}}
</span>
</div>
</div>
</div>
{{- else -}}
<div class="flex justify-between place-items-center">
<h1 class="text-2xl text-pink-600 dark:text-pink-500 py-2">
No data in the system for this breach
</h1>
</div>
{{- end -}}
{{- if and .Data .Data.flash -}}
<div class="place-items-center">
<h1 class="text-2xl text-pink-600 dark:text-pink-500 py-2">
{{ .Data.flash -}}
</h1>
</div>
{{- end}}
</div>
</main>
{{ template "footer.tmpl" . }}

View File

@ -0,0 +1,78 @@
{{ template "head.tmpl" . }}
<body class="h-screen bg-white dark:bg-gray-900">
{{ template "navbar.tmpl" . }}
<main class="grow">
<div class="container mx-auto place-items-center px-8 md:px-12 lg:px-14">
<div class="flex justify-between place-items-center">
<h1 class="text-xl font-bold text-fuchsia-600 dark:text-fuchsia-400 capitalize py-2">
Breach search
</h1>
</div>
<div class="flex justify-between place-items-center">
<span class="text-sm text-gray-500 dark:text-gray-300 hover:cursor-help">
<span class="text-lg text-pink-500 dark:text-pink-300">
&#x1F6C8;
</span>
Breach data on this page is sourced from
<a href="https://haveibeenpwned.com" target="_blank" rel="noopener"
class="w-auto py-1 mt-2 text-center text-blue-500 md:mt-0 mx-0 hover:underline dark:text-blue-400">
Have I Been Pwned?</a>
and is
<a href="https://haveibeenpwned.com/API/v3#License" target="_blank" rel="noopener"
class="w-auto py-1 mt-2 text-center text-blue-500 md:mt-0 mx-0 hover:underline dark:text-blue-400">
available</a>
under
<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener"
class="w-auto py-1 mt-2 text-center text-blue-500 md:mt-0 mx-0 hover:underline dark:text-blue-400">
CC-BY-4.0
</a>
</span>
</div>
{{- if and .Data .Data.flash -}}
<div class="place-items-center">
<h1 class="text-2xl text-pink-600 dark:text-pink-500 py-2">
{{ .Data.flash -}}
</h1>
</div>
{{- end}}
<div class="mt-6 md:flex md:items-center md:place-items-center md:justify-between">
<form method="POST" class="w-full md:w-5xl" action="/user/hibp-search">
<input type="hidden" name="csrf" value="{{- .CSRF -}}">
<div class="relative mb-2 flex w-full flex-wrap items-stretch">
<input
name="search"
type="search"
placeholder="Search by entering an email address"
aria-label="Search the Have I Been Pwned? API by entering an email address"
required
class="relative m-0 px-3 py-1 block w-[2px] min-w-0 flex-auto rounded border border-solid border-gray-300 bg-transparent bg-clip-padding text-base text-gray-700 outline-none transition duration-100 ease-in-out focus:z-[3] focus:border-gray-300 focus:text-gray-700 dark:focus:text-gray-200 focus:shadow-sm focus:outline-none dark:border-gray-600 dark:text-gray-200 dark:placeholder:text-gray-300 dark:focus:border-gray-300"/>
</div>
<button
class="w-full my-0 text-sm font-medium tracking-wide text-white capitalize transition-colors duration-300 transform bg-blue-500 rounded-md md:w-1/4 hover:bg-blue-400 focus:outline-none focus:ring focus:ring-blue-300 focus:ring-opacity-50"
type="submit">Submit
</button>
</form>
</div>
{{ if and .Data .Data.breachNames }}
<div class="mt-6 md:flex md:items-center md:place-items-center md:justify-between">
<div class="block">
<span class="text-sm text-gray-500 dark:text-gray-300">
The account was found in all of the following breaches:
</span>
<ul class="list-none list-outside">
{{ $bn := index .Data.breachNames }}
{{ range $_, $b := $bn }}
<li>
<a href="/user/hibp-breach-details/{{$b.Name}}" target="_blank" rel="noopener"
class="text-xl w-auto py-1 mt-2 text-center text-blue-500 md:mt-0 md:mx-6 lg:mx-4 hover:underline dark:text-blue-400">
{{$b.Name}}
</a>
</li>
{{ end }}
</div>
</ul>
{{end}}
</div>
</div>
</main>
{{ template "footer.tmpl" . }}

View File

@ -0,0 +1,47 @@
{{ template "head.tmpl" . }}
<body class="min-h-screen flex flex-col justify-between bg-white dark:bg-gray-900">
{{ template "navbar.tmpl" . }}
<main class="grow mb-auto">
<div class="container mx-auto place-items-center px-8">
{{ if and .Data .Data.flash }}
<h1 class="text-xl text-pink-600 dark:text-pink-500 py-2">
{{ .Data.flash }}
</h1>
{{- end }}
{{ if .User -}}
<h1 class="mt-20 text-2xl text-pink-400 font-bold">
Welcome, <code>{{.Name}}</code>, since you have never changed your password here before, now is the time to do that!<br>
</h1>
<span class="text-lg text-gray-500 dark:text-gray-300 font-italic">
This is necessary in order to make sure that only you know the password, since the account was pre-created <em>for</em> you.
</span>
<div class="block">
<div class="mt-8 md:flex md:items-center md:place-items-center md:justify-between">
<form method="POST" class="w-full md:w-5xl" action="/user/initial-password-change">
<input type="hidden" name="csrf" value="{{- .CSRF -}}">
<div class="relative flex items-center mt-4">
<span class="absolute" role="img" aria-label="password lock icon">
{{ template "svg-password.tmpl" }}
</span>
<input name="password" type="new-password" placeholder="New password" {{if and .Data.form .Data.form.Password}}value="{{.Data.form.Password}}"{{else}}{{end}} minlength=20 required class="block w-full px-10 py-3 required:border-slate-500 dark:required:border-slate-300 required:border-3 valid:border text-gray-700 bg-white border rounded-lg dark:bg-gray-900 dark:text-gray-300 dark:valid:border-gray-600 focus:border-blue-400 dark:focus:border-blue-300 focus:ring-blue-300 focus:outline-none focus:ring focus:ring-opacity-40">
</div>
<button
class="w-full my-2 text-sm font-medium tracking-wide text-white capitalize transition-colors duration-300 transform bg-blue-500 rounded-lg md:w-1/4 hover:bg-blue-400 focus:outline-none focus:ring focus:ring-blue-300 focus:ring-opacity-50"
type="submit">Submit</button>
</form>
</div>
<form method="POST" class="w-full md:w-5xl" action="/logout">
<input type="hidden" name="csrf" value="{{- .CSRF -}}">
<button
class="w-full my-2 text-sm font-medium tracking-wide text-white capitalize transition-colors duration-300 transform bg-blue-500 rounded-lg md:w-1/4 hover:bg-blue-400 focus:outline-none focus:ring focus:ring-blue-300 focus:ring-opacity-50"
type="submit">Logout</button>
</form>
</div>
{{- else }}
<h1 class="mt-20 text-2xl text-pink-400 font-bold">
Please log in.
</h1>
{{- end -}}
</div>
</main>
{{ template "footer.tmpl" . }}