Password setting and resetting

This commit is contained in:
Teajey 2025-12-07 17:48:40 +09:00
parent f110283b8e
commit 3509cb9666
Signed by: Teajey
GPG Key ID: 970E790FE834A713
15 changed files with 444 additions and 184 deletions

View File

@ -31,6 +31,8 @@ func (a *Admin) RemoveUserFromGroup(userId, groupId string) error
func (u *Admin) RenameUser(userReference string, displayName string) error
func (u *Admin) SetUserPassword(userReference string, newPassword string) error
func (u *Admin) UserSetLive(userReference string, setting bool) error
type ErrorInvalidCredentials error
@ -87,6 +89,7 @@ type User struct {
Reference string
IsAdmin bool
IsLive bool
PasswordFromAdmin bool
}
func GetUserByReference(reference string) (*User, error)
@ -95,6 +98,8 @@ func Register(username, newPassword string) (*User, error)
func (u *User) GetTodo() ([]Wish, error)
func (u *User) SetPassword(newPassword string) error
func (u *User) WishCount() (int, error)
type Wish struct {

View File

@ -8,6 +8,7 @@ CREATE TABLE IF NOT EXISTS "user" (
"password_hash" TEXT NOT NULL,
"is_admin" INTEGER NOT NULL DEFAULT 0,
"is_live" INTEGER NOT NULL DEFAULT 1,
"password_from_admin" INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY("id" AUTOINCREMENT)
);
CREATE TABLE IF NOT EXISTS "wish" (

View File

@ -23,7 +23,7 @@ func (s *Session) User() User {
func SessionFromKey(key string) (*Session, error) {
s := Session{}
query := "SELECT user.id, user.name, user.display_name, user.reference, user.is_admin, user.is_live, session.key, session.expiry FROM v_user as user JOIN session ON user.id = session.user_id WHERE session.key = ?"
query := "SELECT user.id, user.name, user.display_name, user.reference, user.is_admin, user.is_live, user.password_from_admin, session.key, session.expiry FROM v_user as user JOIN session ON user.id = session.user_id WHERE session.key = ?"
var expiry string
err := db.Connection.QueryRow(query, key).Scan(
&s.user.Id,
@ -32,6 +32,7 @@ func SessionFromKey(key string) (*Session, error) {
&s.user.Reference,
&s.user.IsAdmin,
&s.user.IsLive,
&s.user.PasswordFromAdmin,
&s.Key,
&expiry,
)

View File

@ -4,6 +4,7 @@ import (
"fmt"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"lishwist/core/internal/db"
"lishwist/core/internal/normalize"
@ -12,11 +13,11 @@ import (
type User struct {
Id string
NormalName string
// TODO: rename to DisplayName
Name string
Reference string
IsAdmin bool
IsLive bool
PasswordFromAdmin bool
}
func queryManyUsers(query string, args ...any) ([]User, error) {
@ -28,7 +29,7 @@ func queryManyUsers(query string, args ...any) ([]User, error) {
users := []User{}
for rows.Next() {
var u User
err = rows.Scan(&u.Id, &u.NormalName, &u.Name, &u.Reference, &u.IsAdmin, &u.IsLive)
err = rows.Scan(&u.Id, &u.NormalName, &u.Name, &u.Reference, &u.IsAdmin, &u.IsLive, &u.PasswordFromAdmin)
if err != nil {
return nil, err
}
@ -54,7 +55,7 @@ func queryOneUser(query string, args ...any) (*User, error) {
func getUserByName(username string) (*User, error) {
username = normalize.Name(username)
stmt := "SELECT id, name, display_name, reference, is_admin, is_live FROM v_user WHERE name = ?"
stmt := "SELECT id, name, display_name, reference, is_admin, is_live, password_from_admin FROM v_user WHERE name = ?"
return queryOneUser(stmt, username)
}
@ -91,12 +92,12 @@ func (u *User) getPassHash() ([]byte, error) {
}
func getUserByReference(reference string) (*User, error) {
stmt := "SELECT id, name, display_name, reference, is_admin, is_live FROM v_user WHERE reference = ?"
stmt := "SELECT id, name, display_name, reference, is_admin, is_live, password_from_admin FROM v_user WHERE reference = ?"
return queryOneUser(stmt, reference)
}
func getUserById(id string) (*User, error) {
stmt := "SELECT id, name, display_name, reference, is_admin, is_live FROM v_user WHERE id = ?"
stmt := "SELECT id, name, display_name, reference, is_admin, is_live, password_from_admin FROM v_user WHERE id = ?"
return queryOneUser(stmt, id)
}
@ -111,7 +112,7 @@ func hasUsers() (bool, error) {
}
func (*Admin) ListUsers() ([]User, error) {
stmt := "SELECT id, name, display_name, reference, is_admin, is_live FROM user"
stmt := "SELECT id, name, display_name, reference, is_admin, is_live, password_from_admin FROM user"
return queryManyUsers(stmt)
}
@ -172,3 +173,29 @@ func (u *Admin) RenameUser(userReference string, displayName string) error {
}
return err
}
func (u *Admin) SetUserPassword(userReference string, newPassword string) error {
hashedPasswordBytes, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.MinCost)
if err != nil {
return fmt.Errorf("Failed to hash password: %w", err)
}
query := "UPDATE user SET password_hash = ?, password_from_admin = 1 WHERE reference = ?"
_, err = db.Connection.Exec(query, hashedPasswordBytes, userReference)
if err != nil {
return err
}
return err
}
func (u *User) SetPassword(newPassword string) error {
hashedPasswordBytes, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.MinCost)
if err != nil {
return fmt.Errorf("Failed to hash password: %w", err)
}
query := "UPDATE user SET password_hash = ?, password_from_admin = 0 WHERE id = ?"
_, err = db.Connection.Exec(query, hashedPasswordBytes, u.Id)
if err != nil {
return err
}
return err
}

View File

@ -7,6 +7,7 @@ import (
type LoginProps struct {
GeneralError string `json:",omitempty"`
SuccessfulRegistration bool `json:",omitempty"`
SuccessfulSetPassword bool `json:",omitempty"`
Username templates.InputProps
Password templates.InputProps
}

View File

@ -9,31 +9,28 @@ type Session struct {
written bool
}
func (s *Session) FlashGet() any {
list := s.inner.Flashes()
if len(list) < 1 {
return nil
} else {
s.written = true
return list[0]
}
}
const flashKey = "_flash"
func (s *Session) FlashPeek() any {
flash, ok := s.inner.Values["_flash"]
func (s *Session) FlashGet() any {
val, ok := s.inner.Values[flashKey]
if !ok {
return nil
}
list := flash.([]any)
if len(list) < 1 {
return nil
} else {
return list[0]
delete(s.inner.Values, flashKey)
s.written = true
return val
}
func (s *Session) FlashPeek() any {
val, ok := s.inner.Values[flashKey]
if !ok {
return nil
}
return val
}
func (s *Session) FlashSet(value any) {
s.inner.AddFlash(value)
s.inner.Values[flashKey] = value
s.written = true
}
@ -47,12 +44,17 @@ func (s *Session) SetValue(key any, value any) {
s.written = true
}
func (s *Session) RemoveValue(key any) {
delete(s.inner.Values, key)
s.written = true
}
func (s *Session) GetValue(key any) any {
return s.inner.Values[key]
}
func (s *Session) ClearValues() {
s.inner.Values = nil
s.inner.Values = make(map[any]any)
s.written = true
}

116
http/routing/account.go Normal file
View File

@ -0,0 +1,116 @@
package routing
import (
"log"
"net/http"
lishwist "lishwist/core"
"lishwist/http/api"
"lishwist/http/response"
"lishwist/http/templates"
"github.com/Teajey/rsvp"
)
type AccountProps struct {
CurrentUsername string
GeneralError string `json:",omitempty"`
PasswordFromAdmin bool `json:",omitempty"`
Password templates.InputProps
ConfirmPassword templates.InputProps
}
func (p *AccountProps) Validate() (valid bool) {
valid = true
if p.Password.Value != p.ConfirmPassword.Value {
p.ConfirmPassword.Error = "Passwords didn't match"
valid = false
}
if !p.Password.Validate() {
valid = false
}
if !p.ConfirmPassword.Validate() {
valid = false
}
return
}
func NewAccountProps(username string, passwordFromAdmin bool, passwordVal, confirmPassVal string) *AccountProps {
return &AccountProps{
CurrentUsername: username,
PasswordFromAdmin: passwordFromAdmin,
Password: templates.InputProps{
Type: "password",
Name: "new_password",
Required: true,
MinLength: 5,
Value: passwordVal,
},
ConfirmPassword: templates.InputProps{
Type: "password",
Name: "confirm_password",
Required: true,
Value: confirmPassVal,
},
}
}
func Account(app *lishwist.Session, session *response.Session, h http.Header, r *http.Request) rsvp.Response {
user := app.User()
props := NewAccountProps(user.Name, user.PasswordFromAdmin, "", "")
flash := session.FlashGet()
flashProps, _ := flash.(*AccountProps)
if flashProps != nil {
props.GeneralError = flashProps.GeneralError
props.ConfirmPassword.Error = flashProps.ConfirmPassword.Error
}
return response.Data("account.gotmpl", props)
}
func AccountPost(app *lishwist.Session, session *response.Session, h http.Header, r *http.Request) rsvp.Response {
user := app.User()
err := r.ParseForm()
if err != nil {
return response.Error(http.StatusBadRequest, "Failed to parse form")
}
intent := r.Form.Get("intent")
if intent != "set_password" {
return response.Error(http.StatusBadRequest, "Invalid intent %q", intent)
}
newPassword := r.Form.Get("new_password")
confirmPassword := r.Form.Get("confirm_password")
props := NewAccountProps(user.Name, user.PasswordFromAdmin, newPassword, confirmPassword)
valid := props.Validate()
props.Password.Value = ""
props.ConfirmPassword.Value = ""
if !valid {
log.Printf("Invalid account props: %#v\n", props)
session.FlashSet(&props)
return rsvp.SeeOther("/account", props)
}
err = user.SetPassword(newPassword)
if err != nil {
props.GeneralError = "Something went wrong."
log.Printf("Set password failed: %s\n", err)
session.FlashSet(&props)
return rsvp.SeeOther("/account", props)
}
session.RemoveValue("sessionKey")
session.FlashSet(&api.LoginProps{SuccessfulSetPassword: true})
return rsvp.SeeOther("/", "Set password successful!")
}

View File

@ -18,6 +18,7 @@ type HomeProps struct {
Reference string
HostUrl string
Groups []lishwist.Group
AccountAlert bool
}
func Home(app *lishwist.Session, session *response.Session, h http.Header, r *http.Request) rsvp.Response {
@ -37,7 +38,7 @@ func Home(app *lishwist.Session, session *response.Session, h http.Header, r *ht
log.Printf("Failed to get groups: %s\n", err)
return response.Error(http.StatusInternalServerError, "An error occurred while fetching your wishlist :(")
}
p := HomeProps{Username: user.Name, Gifts: gifts, Todo: todo, Reference: user.Reference, HostUrl: env.Configuration.HostUrl, Groups: groups}
p := HomeProps{Username: user.Name, Gifts: gifts, Todo: todo, Reference: user.Reference, HostUrl: env.Configuration.HostUrl, Groups: groups, AccountAlert: user.PasswordFromAdmin}
return response.Data("home.gotmpl", p)
}

View File

@ -24,12 +24,9 @@ func Login(s *response.Session, h http.Header, r *http.Request) rsvp.Response {
props.GeneralError = flashProps.GeneralError
props.Username.Error = flashProps.Username.Error
props.Password.Error = flashProps.Password.Error
}
flash = s.FlashGet()
successfulReg, _ := flash.(bool)
if successfulReg {
props.SuccessfulRegistration = true
props.SuccessfulRegistration = flashProps.SuccessfulRegistration
props.SuccessfulSetPassword = flashProps.SuccessfulSetPassword
}
return rsvp.Response{TemplateName: "login.gotmpl", Body: props}
@ -61,7 +58,7 @@ func LoginPost(session *response.Session, h http.Header, r *http.Request) rsvp.R
var targ lishwist.ErrorInvalidCredentials
switch {
case errors.As(err, &targ):
props.GeneralError = "Username or password invalid"
props.GeneralError = "Username or password invalid. If you're having trouble accessing your account, you may want to consider asking the System Admin (Thomas) to reset your password"
session.FlashSet(&props)
log.Printf("Invalid credentials: %s: %#v\n", err, props)
return resp

View File

@ -61,6 +61,6 @@ func RegisterPost(s *response.Session, h http.Header, r *http.Request) rsvp.Resp
return rsvp.SeeOther(r.URL.Path, props)
}
s.FlashSet(true)
s.FlashSet(&api.LoginProps{SuccessfulRegistration: true})
return rsvp.SeeOther("/", "Registration successful!")
}

View File

@ -53,14 +53,15 @@ func UserPost(app *lishwist.Session, session *response.Session, h http.Header, r
}
reference := r.PathValue("userReference")
if reference == app.User().Reference {
return response.Error(http.StatusForbidden, "You cannot delete yourself.")
}
intent := r.Form.Get("intent")
if intent != "" {
switch intent {
case "delete":
if reference == app.User().Reference {
return response.Error(http.StatusForbidden, "You cannot delete yourself.")
}
err = admin.UserSetLive(reference, false)
if err != nil {
return response.Error(http.StatusInternalServerError, "Failed to delete user: %s", err)
@ -71,6 +72,15 @@ func UserPost(app *lishwist.Session, session *response.Session, h http.Header, r
if err != nil {
return response.Error(http.StatusInternalServerError, "Failed to rename user: %s", err)
}
case "set_password":
newPassword := r.Form.Get("new_password")
err = admin.SetUserPassword(reference, newPassword)
if err != nil {
return response.Error(http.StatusInternalServerError, "Failed to set new password: %s", err)
}
default:
return response.Error(http.StatusBadRequest, "Invalid intent %q", intent)
}
}
user, err := lishwist.GetUserByReference(reference)

View File

@ -32,6 +32,7 @@ func prefixPermanentRedirect(before, after string) response.HandlerFunc {
func Create(useSecureCookies bool) *router.VisibilityRouter {
gob.Register(&api.RegisterProps{})
gob.Register(&api.LoginProps{})
gob.Register(&routing.AccountProps{})
store := session.NewInMemoryStore([]byte(env.Configuration.SessionSecret))
store.Options.MaxAge = 86_400 // 24 hours in seconds
@ -49,6 +50,7 @@ func Create(useSecureCookies bool) *router.VisibilityRouter {
r.Public.HandleFunc("POST /", routing.LoginPost)
r.Public.HandleFunc("POST /register", routing.RegisterPost)
r.Private.HandleFunc("GET /account", routing.ExpectAppSession(routing.Account))
r.Private.HandleFunc("GET /health", routing.ExpectAppSession(routing.Health))
r.Private.HandleFunc("GET /", routing.NotFound)
r.Private.HandleFunc("GET /groups", routing.ExpectAppSession(routing.Groups))
@ -57,6 +59,7 @@ func Create(useSecureCookies bool) *router.VisibilityRouter {
r.Private.HandleFunc("GET /users", routing.ExpectAppSession(routing.Users))
r.Private.HandleFunc("GET /users/{userReference}", routing.ExpectAppSession(routing.User))
r.Private.HandleFunc("GET /{$}", routing.ExpectAppSession(routing.Home))
r.Private.HandleFunc("POST /account", routing.ExpectAppSession(routing.AccountPost))
r.Private.HandleFunc("POST /groups/{groupReference}", routing.ExpectAppSession(routing.GroupPost))
r.Private.HandleFunc("POST /lists/{userReference}", routing.ExpectAppSession(routing.ForeignWishlistPost))
r.Private.HandleFunc("POST /logout", routing.LogoutPost)

View File

@ -0,0 +1,83 @@
<!doctype html>
<html>
<head>
{{template "head" .}}
</head>
<body>
<div style="height: 100svh;" class="d-flex flex-column">
<div class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<div class="navbar-brand">Lishwist</div>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarToggle"
aria-controls="navbarToggle" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarToggle">
<nav>
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="/">Home</a>
</li>
</ul>
</nav>
<div class="flex-grow-1"></div>
<ul class="navbar-nav">
<li class="nav-item">
<div class="dropdown">
<button class="nav-link dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
Logged in as '{{.CurrentUsername}}'
</button>
<ul class="dropdown-menu">
<li>
<form class="d-contents" method="post" action="/logout">
<button class="dropdown-item" type="submit">Logout</button>
</form>
</li>
</ul>
</div>
</li>
</ul>
</div>
</div>
</div>
<div class="overflow-y-scroll flex-grow-1">
<div class="container py-5">
{{with .GeneralError}}
<div class="alert alert-danger" role="alert">
<p class="mb-0">{{.}}</p>
</div>
{{end}}
<section class="card mb-4">
<div class="card-body">
<h2>Submit new password</h2>
<div class="form-text">You can set a new password by submitting this form.</div>
{{with .PasswordFromAdmin}}
<div class="alert alert-warning" role="alert">
<p class="mb-0"><span class="badge text-bg-danger">!</span> This is recommended, because your password has
been set by the admin. Change it to
something they don't know!</p>
</div>
{{end}}
<form method="post">
<div class="d-flex flex-column">
<label>
New Password
{{template "input" .Password}}
</label>
<label>
Confirm password
{{template "input" .ConfirmPassword}}
</label>
<button class="btn btn-primary" type="submit" name="intent" value="set_password">Submit</button>
</div>
</form>
</div>
</section>
</div>
</div>
</div>
</body>
</html>

View File

@ -1,5 +1,6 @@
<!doctype html>
<html>
<head>
{{template "head" .}}
</head>
@ -11,7 +12,8 @@
<div class="navbar-brand">Lishwist</div>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarToggle"
aria-controls="navbarToggle" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
<span class="navbar-toggler-icon"></span>{{if .AccountAlert}} <span
class="badge text-bg-danger">!</span>{{end}}
</button>
<div class="collapse navbar-collapse" id="navbarToggle">
<div class="flex-grow-1"></div>
@ -22,9 +24,13 @@
<li class="nav-item">
<div class="dropdown">
<button class="nav-link dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
Logged in as '{{.Username}}'
Logged in as '{{.Username}}'{{if .AccountAlert}} <span class="badge text-bg-danger">!</span>{{end}}
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="/account">Account{{if .AccountAlert}} <span
class="badge text-bg-danger">!</span>{{end}}</a>
</li>
<li>
<form class="d-contents" method="post" action="/logout">
<button class="dropdown-item" type="submit">Logout</button>
@ -47,7 +53,8 @@
<ul class="list-group mb-3">
{{range .}}
<li class="list-group-item">
<input id="wishlist_select_{{.Id}}" class="form-check-input" type="checkbox" name="gift" value="{{.Id}}">
<input id="wishlist_select_{{.Id}}" class="form-check-input" type="checkbox" name="gift"
value="{{.Id}}">
<label class="form-check-label stretched-link" for="wishlist_select_{{.Id}}">
{{.Name}}
</label>
@ -127,4 +134,5 @@
</div>
</div>
</body>
</html>

View File

@ -23,6 +23,11 @@
<p class="mb-0">Registration successful. Now you can login.</p>
</div>
{{end}}
{{if .SuccessfulSetPassword}}
<div class="alert alert-success" role="alert">
<p class="mb-0">Set password successfully. You can now login back in.</p>
</div>
{{end}}
{{with .GeneralError}}
<div class="alert alert-danger" role="alert">
<p class="mb-0">{{.}}</p>