- 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
433 lines
15 KiB
Go
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)
|
|
}
|