diff --git a/README.md b/README.md index 1e564c5..3b53819 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap - **Privileged groups.** The groups whose code is listed in `golem15.user.privileged_groups` (default `admin`) are privileged: an application may treat their members as site administrators. Three things therefore need the backend permission `golem15.users.manage_privileged_groups` on top of the screen's own permission, and answer 403 without it, saving nothing. First, membership: adding a privileged group to a user or removing it, on create and on update of the user form. An administrator without the permission sees those groups locked in the field and can still change the user's other groups. Second, the code: creating a group with a privileged code, changing a group's code to or from a privileged one, and deleting a privileged group. Its name, description and frontend permissions need only `golem15.users.access_groups`. Codes compare case-sensitively, and a group without a code is never privileged. Third, the member's account, which is protected against takeover: changing that user's email or password, and deleting them for good, needs the backend permission `golem15.users.manage_privileged_groups` on top of `golem15.users.access_users`. Without it the request answers 403 and nothing is saved; a bulk delete that contains such a user deletes none of the selected users. Everything else on such a user (name, organisation, avatar, frontend permissions, activate, deactivate, restore, ban, unban, unsuspend) needs only `golem15.users.access_users`. `PrivilegedGroupCodes`, `IsPrivilegedCode`, `PrivilegedGroupIDs` and `IsPrivilegedMember` expose the rule to other code. - **Frontend permissions.** `golem15_user_frontend_permissions` is the catalogue of permissions a site defines for its users (rows come from the application's migrations or seeds). A user's own choices are stored in `users.permissions` and a group's in `user_groups.permissions`, as a JSON object of code to `1` (allow) or `-1` (deny). `MergedPermissions` combines them as PHP `getMergedPermissions` does: among a user's groups the most positive value wins, then the user's own value replaces the groups'. `HasPermission` and `UserHasPermission` answer whether a code is held: only at exactly `1`, with Winter's `*` wildcards. Neither value appears in the user payload, whose `permissions` key stays `null`. - **Last seen.** `users.last_seen` is stamped by `POST /login` and `POST /refresh` through `TouchLastSeen`, at most once per five minutes per user. A failed write is logged and never fails authentication. It is shown in the admin list and is not part of any user payload. -- **Address proof.** `users.email_verified_at` records when the account's owner proved control of the address (`User.EmailVerifiedAt`). Exactly two flows write it: the mailed activation code (`POST /activate`, `POST /activate-by-code` and the invitation link, through `VerifyActivationCode`) and a completed reset-code password reset (`POST /reset-password`). `POST /register` in `auto` or `admin` mode, `RegisterUser`, an administrator's create and the admin Activate action, `POST /change-password` and social sign-ins record none. No migration or code path backfills it: accounts from before v0.1.5 have no proof. A social-login plugin links a verified provider's identity onto an existing account only when it is set. +- **Address proof.** `users.email_verified_at` records when the account's owner proved control of the address (`User.EmailVerifiedAt`). Exactly two flows write it: the mailed activation code (`POST /activate`, `POST /activate-by-code` and the invitation link, through `VerifyActivationCode`) and a completed reset-code password reset (`POST /reset-password`). `POST /register` in `auto` or `admin` mode, `RegisterUser`, an administrator's create and the admin Activate action, `POST /change-password` and social sign-ins record none. The proof follows the address: an email change through `POST /update` or the admin Users form clears it, while a change of letter case only keeps it. The column is never mass-assigned and is in no user API payload, admin form or list. No migration or code path backfills it: accounts from before v0.1.5 have no proof. An application that trusts its imported addresses may set the column in its own data migration; this plugin does not. A social-login plugin links a verified provider's identity onto an existing account only when it is set. - **Handle.** `users.handle` is a unique nullable `VARCHAR(255)` generated on create (`classes.HandleGenerator`, charset `[a-z0-9._-]`, length 2–255). It is omitted from the user JSON payload (`json:"-"`). `user:backfill-handles` fills existing rows that still have none. - **User lighthouse authorizer.** Boot calls `classes.RegisterUserAuthorizer`: `lighthouse.From` then `Registry.Register("user", …)` if the namespace is free. `classes.AuthorizeUser` allows only `user:{id}` for that user (`lighthouse.ChannelID` on the two-segment name). - **Shared API tokens.** `user_api_tokens` stores one hashed personal/OAuth credential per user for the whole application. The `user.api_token` guard authenticates it for any plugin; `user.scope:` limits delegated access and `user.permission:` checks the user's current group permissions. `POST/GET/DELETE /tokens` manage personal tokens, and `user:token:create`, `user:token:list` and `user:token:revoke` provide the CLI equivalent. A raw secret is shown only when created. diff --git a/controllers/api_controller.go b/controllers/api_controller.go index c4b4ad6..141c62f 100644 --- a/controllers/api_controller.go +++ b/controllers/api_controller.go @@ -471,17 +471,29 @@ func Update(app *backpack.App) http.HandlerFunc { } name := strField(fields, "name") surname := strField(fields, "surname") - if err := gdb.WithContext(r.Context()).Model(user).Updates(map[string]any{ + email := strField(fields, "email") + columns := map[string]any{ "name": name, "surname": surname, - "email": strField(fields, "email"), - }).Error; err != nil { + "email": email, + } + // The proof of the address follows the address (D-27): a new email + // is unproven until its owner proves it. A change of letter case + // only keeps it, because email matching is case-insensitive. + emailChanged := !strings.EqualFold(email, user.Email) + if emailChanged { + columns["email_verified_at"] = nil + } + if err := gdb.WithContext(r.Context()).Model(user).Updates(columns).Error; err != nil { writeOpaque500(w) return } user.Name = &name user.Surname = &surname - user.Email = strField(fields, "email") + user.Email = email + if emailChanged { + user.EmailVerifiedAt = nil + } payload, err := apiArray(r.Context(), app, user) if err != nil { writeOpaque500(w) diff --git a/controllers/users_admin_controller.go b/controllers/users_admin_controller.go index 7026133..1caa914 100644 --- a/controllers/users_admin_controller.go +++ b/controllers/users_admin_controller.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "strconv" + "strings" "time" "git.golem15.com/golem15/sm-user-plugin/classes" @@ -211,7 +212,8 @@ func (c usersAdminController) guardPrivilegedMember(ctx context.Context, userID // FormBeforeUpdate starts with the takeover guard: a changed email or a // submitted password on a member of a privileged group needs // golem15.users.manage_privileged_groups. Everything else an update does -// comes after it. +// comes after it, including clearing the proof of the address +// (email_verified_at) when the email changes. func (c usersAdminController) FormBeforeUpdate(ctx context.Context, model any) error { user, ok := model.(*models.User) if !ok || user == nil { @@ -223,6 +225,16 @@ func (c usersAdminController) FormBeforeUpdate(ctx context.Context, model any) e if err := c.requireFreeEmail(ctx, user); err != nil { return err } + // The proof of the address follows the address (D-27): a changed email + // is unproven until its owner proves it; a change of letter case only + // keeps it. cabana's Save writes the cleared column. + stored, err := c.storedEmail(ctx, user) + if err != nil { + return err + } + if !strings.EqualFold(stored, user.Email) { + user.EmailVerifiedAt = nil + } changed, err := c.storePassword(ctx, user) if err != nil { return err @@ -462,26 +474,36 @@ func (usersAdminController) AdminSetPermissionValues(_ context.Context, field st return nil } -// guardCredentials compares the email the save would write with the stored -// one and looks for a submitted password, then asks guardPrivilegedMember -// for the fields that would change. -func (c usersAdminController) guardCredentials(ctx context.Context, user *models.User) error { +// storedEmail reads the stored email of the user being updated, before the +// form's values are saved. +func (c usersAdminController) storedEmail(ctx context.Context, user *models.User) (string, error) { db := requestDB(ctx, c.db) if db == nil { - return errors.New("users admin: gorm handle is missing") + return "", errors.New("users admin: gorm handle is missing") } var stored []string err := db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped(). Model(&models.User{}).Where("id = ?", user.ID).Pluck("email", &stored).Error if err != nil { - return err + return "", err } if len(stored) != 1 { - return errors.New("users admin: the user being updated was not found") + return "", errors.New("users admin: the user being updated was not found") + } + return stored[0], nil +} + +// guardCredentials compares the email the save would write with the stored +// one and looks for a submitted password, then asks guardPrivilegedMember +// for the fields that would change. +func (c usersAdminController) guardCredentials(ctx context.Context, user *models.User) error { + stored, err := c.storedEmail(ctx, user) + if err != nil { + return err } const message = usersLang + "privileged_member_forbidden" details := map[string]any{} - if stored[0] != user.Email { + if stored != user.Email { details["email"] = []string{message} } if values, ok := cabana.VirtualFieldsFromContext(ctx); ok {