feat: users.email_verified_at records a proved address (mail activation, reset code)
- additive migration 202610210001_add_users_email_verified_at (ADD COLUMN IF NOT EXISTS, no backfill, no-op rollback) - User.EmailVerifiedAt (json:"-", not fillable) - VerifyActivationCode and ResetPassword stamp it in the write that activates or stores the password
This commit is contained in:
@@ -31,6 +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.
|
||||
- **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:<scope>` limits delegated access and `user.permission:<code>` 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.
|
||||
@@ -158,6 +159,12 @@ Migrations added for @mention handles, all additive:
|
||||
|----|------|
|
||||
| `202610160002_add_handle_to_users` | `users.handle` (`VARCHAR(255)` NULL, unique index `users_handle_unique`, `ADD COLUMN IF NOT EXISTS`; rollback is a no-op) |
|
||||
|
||||
Migrations added for address proof, all additive:
|
||||
|
||||
| ID | Adds |
|
||||
|----|------|
|
||||
| `202610210001_add_users_email_verified_at` | `users.email_verified_at` (`TIMESTAMPTZ` NULL, `ADD COLUMN IF NOT EXISTS`, no backfill; rollback is a no-op) |
|
||||
|
||||
Packages:
|
||||
|
||||
- `controllers`: the route handlers above, `AdminControllers` (the admin controller list), `PermissionAccessUsers` and `PermissionAccessGroups`, plus the registration steps `POST /register` runs, for other plugins:
|
||||
|
||||
@@ -113,7 +113,9 @@ func VerifyResetCode(ctx context.Context, db *gorm.DB, userID uint, code string)
|
||||
}
|
||||
|
||||
// VerifyActivationCode accepts the stored code inside the activation TTL.
|
||||
// A soft-deleted row is visible and is restored on success.
|
||||
// A soft-deleted row is visible and is restored on success. The code was
|
||||
// mailed to the account's address, so success also records the proof of that
|
||||
// address (email_verified_at, D-27) in the same write that activates it.
|
||||
func VerifyActivationCode(ctx context.Context, db *gorm.DB, userID uint, code string) (*models.User, bool) {
|
||||
user, err := takeUser(ctx, db, userID, true)
|
||||
if err != nil || user == nil || user.IsActivated || user.ActivationCode == nil {
|
||||
@@ -134,11 +136,13 @@ func VerifyActivationCode(ctx context.Context, db *gorm.DB, userID uint, code st
|
||||
"activated_at": now,
|
||||
"activation_code": nil,
|
||||
"activation_code_issued_at": nil,
|
||||
"email_verified_at": now,
|
||||
}).Error; err != nil {
|
||||
return nil, false
|
||||
}
|
||||
user.IsActivated = true
|
||||
user.ActivatedAt = &now
|
||||
user.EmailVerifiedAt = &now
|
||||
user.ActivationCode = nil
|
||||
user.ActivationCodeIssuedAt = nil
|
||||
return user, true
|
||||
|
||||
@@ -329,10 +329,14 @@ func ResetPassword(app *backpack.App) http.HandlerFunc {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
// The reset code was mailed to the account's address, so the write
|
||||
// that stores the new password also records the proof of that
|
||||
// address (D-27). A reset that fails here records nothing.
|
||||
now := time.Now()
|
||||
if err := gdb.WithContext(r.Context()).Model(&models.User{}).Where("id = ?", user.ID).Updates(map[string]any{
|
||||
"password": hashed,
|
||||
"tokens_valid_after": now,
|
||||
"email_verified_at": now,
|
||||
}).Error; err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
|
||||
@@ -41,6 +41,13 @@ type User struct {
|
||||
// minutes (classes.TouchLastSeen). It is shown in the admin list and is
|
||||
// never part of a user API payload.
|
||||
LastSeen *time.Time `gorm:"column:last_seen" json:"-"`
|
||||
// EmailVerifiedAt records when the owner proved control of Email (D-27).
|
||||
// Only two flows write it: the mailed activation code
|
||||
// (classes.VerifyActivationCode) and a completed reset-code password
|
||||
// reset (controllers.ResetPassword). An email change through /update or
|
||||
// the admin form clears it. It is never mass-assigned and never part of a
|
||||
// user API payload; social sign-in matching reads it.
|
||||
EmailVerifiedAt *time.Time `gorm:"column:email_verified_at" json:"-"`
|
||||
// Permissions are the user's own frontend permissions (1 allows, -1
|
||||
// denies). classes.MergedPermissions combines them with the groups'. They
|
||||
// are written by the admin permission editor only and are never part of
|
||||
|
||||
33
updates/202610210001_add_users_email_verified_at.go
Normal file
33
updates/202610210001_add_users_email_verified_at.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// usersEmailVerifiedAtMigrations adds users.email_verified_at, the time the
|
||||
// account's owner proved control of the address: a mailed activation code or
|
||||
// a completed reset-code password reset (D-27). Social sign-in matching links
|
||||
// a verified provider's identity onto an existing account only when it is set.
|
||||
//
|
||||
// The migration writes no rows: accounts that exist before it runs have no
|
||||
// recorded proof (no backfill, D-26). A users table that already carries the
|
||||
// column (a Laravel-derived schema) keeps it and its values, which mean the
|
||||
// same thing. The rollback is a no-op for the same reason: the shared users
|
||||
// column may predate this plugin.
|
||||
var usersEmailVerifiedAtMigrations = []*gormigrate.Migration{
|
||||
{
|
||||
ID: "202610210001_add_users_email_verified_at",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ NULL`).Error
|
||||
},
|
||||
Rollback: func(*gorm.DB) error {
|
||||
// No-op: the shared users column may predate the plugin.
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(usersEmailVerifiedAtMigrations...)
|
||||
}
|
||||
@@ -51,6 +51,7 @@ func TestCreateOrganisationsMigration(t *testing.T) {
|
||||
"202610040003_create_frontend_permissions",
|
||||
"202610050001_create_oauth_identities",
|
||||
"202610160002_add_handle_to_users",
|
||||
"202610210001_add_users_email_verified_at",
|
||||
}
|
||||
if len(ids) != len(want) {
|
||||
t.Fatalf("history = %v", ids)
|
||||
@@ -61,9 +62,9 @@ func TestCreateOrganisationsMigration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle, OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist,
|
||||
// Email proof, handle, OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist,
|
||||
// throttle, extend-users, then organisations.
|
||||
for range 11 {
|
||||
for range 12 {
|
||||
if err := m.RollbackLast(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user