Files
summercms/modules/cabana/account.go
Jakub Zych 143eca5a1f feat(cabana): add the My account page with subtitle and password change
- lagoon: 202610090001_backend_users_metadata adds WinterCMS's nullable
  jsonable backend_users.metadata column (TEXT); rollback drops only it
- cabana.BackendUser.Metadata and Subtitle read metadata.subtitle leniently
  (NULL, blank, null, [], invalid JSON and non-strings read as empty)
- GET /auth/me returns subtitle; PUT /account edits the signed-in admin's
  own names, email and subtitle, and the password with current_password;
  any other key is a 422 and nothing is written
- email: required, valid, 6-255 chars, unique against other admins' email
  and login ignoring case (soft-deleted included); a unique-index race is 422
- a password change sets tokens_valid_after, revokes the presented token and
  gives a cookie session a fresh cookie
- swag annotation, regenerated admin.json and schema.d.ts; route inventories,
  CSRF walk (27) and OpenAPI conformance learn the route
- SPA: My account item in the user menu opens /account (AccountView), which
  saves through PUT /account and updates the top bar via setCurrentUser
- backend::lang.myaccount strings (en, pl), rebuilt boardwalk dist
- cabana and lagoon READMEs, users-and-permissions and admin-spa docs
2026-10-09 17:38:04 +02:00

433 lines
15 KiB
Go

package cabana
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"slices"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"github.com/jackc/pgx/v5/pgconn"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// metadataSubtitle is the backend_users.metadata key of the admin subtitle,
// the key WinterCMS's Journal author component reads.
const metadataSubtitle = "subtitle"
// passwordMaxBytes is bcrypt's input limit: HashPassword refuses longer input.
const passwordMaxBytes = 72
// accountKeys are the only keys PUT /account accepts. Any other key (login,
// role_id, is_superuser, permissions, is_activated, id and so on) is refused
// with a 422 and nothing is written.
var accountKeys = []string{
"first_name", "last_name", "email", "subtitle",
"current_password", "password", "password_confirmation",
}
// accountRules are the field rules of PUT /account. The confirmation, the
// current password and the email uniqueness are checked in Go afterwards.
var accountRules = []lagoon.RequestRule{
{Field: "first_name", Rules: lagoon.ParseRules("nullable|string|max:255")},
{Field: "last_name", Rules: lagoon.ParseRules("nullable|string|max:255")},
{Field: "email", Rules: lagoon.ParseRules("required|string|email|between:6,255")},
{Field: "subtitle", Rules: lagoon.ParseRules("nullable|string|max:255")},
{Field: "current_password", Rules: lagoon.ParseRules("nullable|string")},
{Field: "password", Rules: lagoon.ParseRules("nullable|string|min:4|max:72")},
{Field: "password_confirmation", Rules: lagoon.ParseRules("nullable|string")},
}
// errAccountGone is a signed-in admin whose row is missing or deactivated.
var errAccountGone = errors.New("cabana: account is missing or deactivated")
// errMetadataNotObject is stored metadata that is neither empty, [] nor a
// JSON object; it is never overwritten.
var errMetadataNotObject = errors.New("cabana: backend user metadata is not a JSON object")
// decodeMetadata parses Winter's jsonable metadata. NULL, blank, null and []
// (PHP's json_encode of an empty array) are an empty object. ok is false for
// text that is not a JSON object.
func decodeMetadata(raw *string) (map[string]json.RawMessage, bool) {
if raw == nil {
return map[string]json.RawMessage{}, true
}
text := strings.TrimSpace(*raw)
if text == "" || text == "null" || text == "[]" {
return map[string]json.RawMessage{}, true
}
var out map[string]json.RawMessage
if err := json.Unmarshal([]byte(text), &out); err != nil || out == nil {
return nil, false
}
return out, true
}
// metadataString returns the string value of key in Winter's jsonable
// metadata, or "" when the metadata is empty or malformed or the value is
// not a string.
func metadataString(raw *string, key string) string {
values, ok := decodeMetadata(raw)
if !ok {
return ""
}
encoded, ok := values[key]
if !ok {
return ""
}
var s string
if err := json.Unmarshal(encoded, &s); err != nil {
return ""
}
return s
}
// setMetadataString returns metadata with key set to value, every other key
// kept as stored. An empty value removes the key, and metadata without keys
// is nil (SQL NULL). Stored text that is not empty, [] or a JSON object is
// an error and is never overwritten.
func setMetadataString(raw *string, key, value string) (*string, error) {
values, ok := decodeMetadata(raw)
if !ok {
return nil, errMetadataNotObject
}
if value == "" {
delete(values, key)
} else {
encoded, err := json.Marshal(value)
if err != nil {
return nil, err
}
values[key] = encoded
}
if len(values) == 0 {
return nil, nil
}
out, err := json.Marshal(values)
if err != nil {
return nil, err
}
text := string(out)
return &text, nil
}
// profileFor is the AdminProfile of user, the payload of GET /auth/me and of
// the My account routes.
func (s *service) profileFor(_ context.Context, _ *gorm.DB, user BackendUser) AdminProfile {
profile := AdminProfile{
ID: user.ID,
Login: user.Login,
Email: user.Email,
FirstName: user.FirstName,
LastName: user.LastName,
Subtitle: user.Subtitle(),
IsSuperuser: user.IsSuperuser,
}
if user.Role.ID != 0 {
profile.Role = &AdminRoleSummary{ID: user.Role.ID, Code: user.Role.Code, Name: user.Role.Name}
}
return profile
}
// accountPrincipal is the signed-in backend principal, or nil after a 401
// was written.
func accountPrincipal(w http.ResponseWriter, r *http.Request) *bouncer.Principal {
principal, ok := bouncer.User(r.Context())
if !ok || principal == nil || !principal.Backend || principal.ID == 0 {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
return nil
}
return principal
}
// lockAccount loads the admin's own row FOR UPDATE with the role, by the
// principal id only. A missing or deactivated row is errAccountGone.
func lockAccount(ctx context.Context, tx *gorm.DB, id uint) (BackendUser, error) {
var user BackendUser
err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).
Clauses(clause.Locking{Strength: "UPDATE"}).
Preload("Role").Where("id = ?", id).Take(&user).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return BackendUser{}, errAccountGone
}
if err != nil {
return BackendUser{}, err
}
if !user.IsActivated {
return BackendUser{}, errAccountGone
}
return user, nil
}
// backendMessage is a backend::lang line in the request locale, or fallback
// when no translator has the key.
func backendMessage(ctx context.Context, tr *phrasebook.Translator, key, fallback string) string {
full := "backend::lang." + key
if tr != nil && tr.Has(full) {
if s := tr.Get(ctx, full, nil); s != "" && s != full {
return s
}
}
return fallback
}
// accountMessage is a lagoon::validation line for a My account field, with
// Laravel's English text for the rules fieldMessage has no fallback for.
func accountMessage(ctx context.Context, tr *phrasebook.Translator, rule, field string, params map[string]string) string {
if params == nil {
params = map[string]string{}
}
key := "lagoon::validation." + rule
if tr == nil || !tr.Has(key) {
attr := strings.ReplaceAll(field, "_", " ")
switch rule {
case "confirmed":
return "The " + attr + " confirmation does not match."
case "unique":
return "The " + attr + " has already been taken."
case "max.string":
return "The " + attr + " may not be greater than " + params["max"] + " characters."
}
}
return fieldMessage(ctx, tr, rule, field, params)
}
// accountString is the trimmed string value of key, "" when it is absent,
// null or not a string.
func accountString(body map[string]any, key string) string {
s, _ := body[key].(string)
return strings.TrimSpace(s)
}
// accountUpdate serves PUT /account: the signed-in admin edits their own
// first name, last name, email and subtitle and, with the current password,
// changes the password. The row is chosen by the token subject, never by the
// request. A password change ends every other session (tokens_valid_after)
// and gives a cookie session a fresh cookie.
func (s *service) accountUpdate(w http.ResponseWriter, r *http.Request) {
principal := accountPrincipal(w, r)
if principal == nil {
return
}
body, err := s.decodeCappedObject(w, r)
if err != nil {
writeRelationError(w, err)
return
}
tr := s.translator()
if details := refusedAccountKeys(r.Context(), tr, body); len(details) > 0 {
writeCRUDError(w, &ValidationError{Details: details})
return
}
db, err := s.db()
if err != nil {
slog.Default().ErrorContext(r.Context(), "cabana: account update without a database", "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
// Laravel's TrimStrings: names, email and subtitle are trimmed before
// they are validated; passwords are taken as typed.
for _, key := range []string{"first_name", "last_name", "email", "subtitle"} {
if v, ok := body[key].(string); ok {
body[key] = strings.TrimSpace(v)
}
}
password, _ := body["password"].(string)
changePassword := password != ""
var saved BackendUser
var failedCurrent bool
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
user, err := lockAccount(ctx, tx, principal.ID)
if err != nil {
return err
}
errs, err := lagoon.ValidateRequest(ctx, tx, body, accountRules, tr)
if err != nil {
return err
}
if errs == nil {
errs = map[string][]string{}
}
add := func(field, message string) { errs[field] = append(errs[field], message) }
if changePassword {
if _, failed := errs["password"]; !failed && len(password) > passwordMaxBytes {
add("password", accountMessage(ctx, tr, "max.string", "password", map[string]string{"max": strconv.Itoa(passwordMaxBytes)}))
}
if confirmation, _ := body["password_confirmation"].(string); confirmation != password {
add("password", accountMessage(ctx, tr, "confirmed", "password", nil))
}
current, _ := body["current_password"].(string)
switch {
case len(errs["current_password"]) > 0:
case current == "":
add("current_password", fieldMessage(ctx, tr, "required", "current_password", nil))
case !bouncer.CheckPassword(user.Password, current):
add("current_password", backendMessage(ctx, tr, "myaccount.current_password_invalid", "The current password is incorrect."))
failedCurrent = true
}
}
email := accountString(body, "email")
if len(errs["email"]) == 0 {
taken, err := emailTaken(ctx, tx, user.ID, email)
if err != nil {
return err
}
if taken {
add("email", accountMessage(ctx, tr, "unique", "email", nil))
}
}
now := time.Now().UTC()
updates := map[string]any{
"first_name": accountString(body, "first_name"),
"last_name": accountString(body, "last_name"),
"email": email,
"updated_at": now,
}
if subtitle := accountString(body, "subtitle"); len(errs["subtitle"]) == 0 && subtitle != user.Subtitle() {
metadata, err := setMetadataString(user.Metadata, metadataSubtitle, subtitle)
if err != nil {
add("subtitle", backendMessage(ctx, tr, "myaccount.subtitle_unavailable", "The subtitle cannot be saved because the stored profile metadata is not a JSON object."))
} else {
updates["metadata"] = metadata
}
}
if len(errs) > 0 {
return &ValidationError{Details: accountDetails(errs)}
}
if changePassword {
hash, err := bouncer.HashPassword(s.bcryptCost, password)
if err != nil {
return err
}
updates["password"] = hash
// JWT iat has whole-second precision: the cutoff is the start of
// this second, so the fresh cookie minted after it still passes
// while every token issued in an earlier second is refused.
updates["tokens_valid_after"] = now.Truncate(time.Second)
}
q := tx.Session(&gorm.Session{NewDB: true, Context: ctx})
if err := q.Model(&BackendUser{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil {
if emailConflict(err) {
return &ValidationError{Details: fieldDetail("email", accountMessage(ctx, tr, "unique", "email", nil))}
}
return err
}
saved, err = lockAccount(ctx, tx, user.ID)
return err
})
if failedCurrent {
s.logAuth(r, "failed", principal.ID)
}
if err != nil {
s.writeAccountError(w, r, err)
return
}
if changePassword {
s.logAuth(r, "password_changed", principal.ID)
s.revokePresentedToken(r)
if _, fromCookie := sessionToken(r); fromCookie {
token, _, err := bouncer.MintAudience(s.secret, uitoa(principal.ID), s.issuer, s.ttl, bouncer.AudienceBackend)
if err != nil {
slog.Default().ErrorContext(r.Context(), "cabana: minting the session after a password change failed", "admin_id", principal.ID, "error", err)
} else {
s.writeSessionCookie(w, token)
}
}
}
WriteData(w, http.StatusOK, s.profileFor(r.Context(), db, saved), map[string]any{})
}
// revokePresentedToken blacklists the token the request was made with, so
// the session that changed the password cannot be replayed even within the
// second the tokens_valid_after cutoff cannot separate.
func (s *service) revokePresentedToken(r *http.Request) {
if s.bl == nil {
return
}
raw, _ := sessionToken(r)
_, iat, exp, jti, err := bouncer.VerifyRefreshableClaimsAudience(raw, s.secret, bouncer.AudienceBackend, s.refreshTTL)
if err != nil {
return
}
expiresAt := iat.Add(s.refreshTTL).Add(time.Minute)
if until := exp.Add(time.Minute); until.After(expiresAt) {
expiresAt = until
}
if err := s.bl.Add(r.Context(), jti, expiresAt, time.Now()); err != nil {
slog.Default().WarnContext(r.Context(), "cabana: revoking the token after a password change failed", "error", err)
}
}
// refusedAccountKeys answers one detail per body key PUT /account does not
// accept.
func refusedAccountKeys(ctx context.Context, tr *phrasebook.Translator, body map[string]any) map[string]any {
var details map[string]any
for key := range body {
if slices.Contains(accountKeys, key) {
continue
}
if details == nil {
details = map[string]any{}
}
details[key] = []string{backendMessage(ctx, tr, "myaccount.field_not_editable", "This field cannot be changed on My account.")}
}
return details
}
// accountDetails orders each field's messages as lagoon.ErrorKeys ranks the
// fields and returns them as validation details.
func accountDetails(errs map[string][]string) map[string]any {
details := make(map[string]any, len(errs))
for _, key := range lagoon.ErrorKeys(errs, accountRules) {
details[key] = errs[key]
}
return details
}
// emailTaken reports whether email equals, ignoring case, the email or login
// of any other admin, soft-deleted admins included.
func emailTaken(ctx context.Context, tx *gorm.DB, id uint, email string) (bool, error) {
if email == "" {
return false, nil
}
var n int64
err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Unscoped().Model(&BackendUser{}).
Where("id <> ? AND (lower(email) = lower(?) OR lower(login) = lower(?))", id, email, email).
Count(&n).Error
return n > 0, err
}
// emailConflict reports a unique violation on a backend_users email index,
// the race the emailTaken check cannot close.
func emailConflict(err error) bool {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) || pgErr.Code != "23505" {
return false
}
return pgErr.ConstraintName == "backend_users_email_lower_unique" || pgErr.ConstraintName == "backend_users_email_key"
}
// writeAccountError maps a My account failure: a missing or deactivated
// admin is 401, a body past the cap 413, validation 422 and anything else a
// logged 500.
func (s *service) writeAccountError(w http.ResponseWriter, r *http.Request, err error) {
if errors.Is(err, errAccountGone) {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
return
}
var ve *ValidationError
var tooBig *http.MaxBytesError
if !errors.As(err, &ve) && !errors.As(err, &tooBig) {
slog.Default().ErrorContext(r.Context(), "cabana: account route failed", "path", r.URL.Path, "error", err)
}
writeCRUDError(w, err)
}