feat: login gates and the login event
Register publishes a *classes.LoginGates registry. POST /login consults it through controllers.CheckLoginGates after the password, activation and throttle checks and the bcrypt rehash, and before the restore of a deactivated account, last_seen and the mint: a gate result replaces the token response, a gate error answers the opaque 500. The restore and its reactivate mail now run after the gates, so a password alone cannot reactivate an account a second factor protects. Without a gate /login answers as before; refresh never consults the gates. classes.LoginEvent (golem15.user.login) fires after every password login that minted a token; controllers.FireLoginEvent lets other plugins fire it.
This commit is contained in:
@@ -18,6 +18,8 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap
|
||||
|
||||
- **Registration and activation.** `POST /register`, `POST /activate`, `POST /activate-by-code`. Registration can be switched off and throttled; `activate_mode` is `auto`, `user` (a mailed code) or `admin`, with codes from `IssueActivationCode` and `VerifyActivationCode`. A `/register` body sets only the `User.RegisterFillable` columns: `name`, `surname`, `email`, `password` (stored as its hash), `preferred_locale` and `marketing_consent`. It cannot set `organisation_id` or `organisation_role` (membership is the administrator's or the application's), `is_onboarded`, `must_change_password`, or the activation, reset, address proof, handle and permission columns; other keys are ignored (they still reach `RegisterEvent.Payload`). `created_ip_address` and `last_ip_address` are server-set from the client address only. With `require_activation` on (the default), `POST /login` refuses an account that is not activated with the same 401 invalid-login answer as a wrong password and counts the attempt for the login throttle. Such an account is activated by the mailed code (`POST /activate-by-code` answers with a token), by the invitation link or by an administrator. `POST /activate` serves the sessions of not-activated accounts, which exist when `require_activation` is off. `Login` checks in this order: a ban or an active suspension (`RejectIfThrottled`), the password, the activation, then the deactivation restore.
|
||||
- **JWT sessions.** `POST /login`, `GET /fetch`, `POST /refresh`, `POST /logout`. Boot registers the `jwt` guard with the framework's `bouncer.Registry` and exposes it as the `jwt.auth` middleware for other plugins. The guard reads the session cookies `classes.SessionCookieNames()` lists and resolves the published `bouncer.BlacklistStore` on every request. Boot installs the plugin's GORM hooks (`classes.RegisterHooks`), publishes the Postgres `jwt_blacklist` store (`bouncer.NewPostgresBlacklist`) and starts its background sweep, which prunes the blacklist, through `lagoon.OnDatabase`. Under `summer serve`, which publishes the database after Boot, they take effect when `lagoon.Publish` runs, before the router is built; when the database is already published they take effect at once. While no store is published, no token counts as blacklisted. `POST /logout` ends the session however the token travels: the bearer header, or only the `token` or `auth_token` cookie the guard also reads. Every session token the request carries goes to the Postgres `jwt_blacklist` until its refresh window closes (the later of `exp` and `iat` plus `refresh_ttl`, plus a minute), so the guard refuses it on the next request and `POST /refresh` cannot revive it after the access token expired; a token whose access lifetime passed is revoked too while it could still be refreshed. The answer, a 401 included, expires the `auth.cookie.*` token cookie (the one `golem15.sociallogin`'s `token_cookie` sets), and any other session cookie the request carries, with that cookie's path, domain, SameSite and Secure. `POST /refresh` loads the token's user and refuses (401, `Could not refresh token`, `User not found`) a deactivated or banned user and a token issued before the user's `tokens_valid_after`; a suspension alone does not stop a refresh. `GET /fetch`, `POST /refresh`, `POST /update` and `POST /change-password`, like the plugin's other signed-in routes, read the session token as the `jwt` guard does: the bearer header first, then the `token` and `auth_token` cookies, so a cookie-only session works on them and a bearer header wins when both are sent. A `POST /refresh` whose token came from the `auth.cookie.*` cookie (`auth.cookie.name`, default `token`) with no bearer header gets that cookie back with the rotated token: the cookie has the `bouncer.LoadCookieConfig` attributes, HttpOnly and Max-Age `refresh_ttl`, and the answer is `{}`, so the token never reaches JavaScript. This holds whoever wrote the cookie (this plugin's `golem15.user.jwt.token_cookie`, `golem15.sociallogin`'s `token_cookie` or the application); a frontend that writes the `token` cookie itself and refreshes through it gets it back HttpOnly, without a body token, so such a frontend sends the bearer header to keep rotating in the body. A refresh carried by the bearer header (a cookie sent with it included) or by the other session cookie answers `{"token": …}` and writes no cookie; the old token is blacklisted after `golem15.user.jwt.blacklist_grace`, so that caller stores the new token itself. A refused refresh writes and forgets no cookie. When `bouncer.LoadCookieConfig` refuses the `auth.cookie` block, a cookie-carried refresh answers the opaque 500 before anything is rotated; a bearer refresh never reads that block. Keep `auth.cookie.name` at `token` or `auth_token`, the cookies the guard reads. With `golem15.user.jwt.token_cookie` on, `POST /login`, `POST /register` (when it signs the new account in) and `POST /activate-by-code` set that HttpOnly session cookie (`bouncer.LoadCookieConfig` attributes, Max-Age `refresh_ttl`) and leave `token` out of the body; `user`, and `message` on activate-by-code, stay. With it off (the default) the token is in the body and no cookie is set. SameSite stays the `auth.cookie` setting (`lax` by default) and there is no CSRF token, so do not set `same_site: none` for this cookie. With the key on, a broken `auth.cookie` block answers those three routes with the opaque 500 before they sign in, create or activate anything. Personal API tokens (`POST /tokens`) are not sessions and are unaffected. A cookie-carried session relies on the cookie's SameSite attribute against cross-site requests (the `auth.cookie` default is `lax`), as on the `jwt.auth` routes.
|
||||
- **Login gates.** Register publishes a `*classes.LoginGates` registry. Another plugin (a two-factor plugin, for example) looks it up with `app.Lookup[*classes.LoginGates]()` and adds a `classes.LoginGate` in its Boot. `POST /login` consults the gates through `controllers.CheckLoginGates` after the password, activation and throttle checks (and the bcrypt rehash) and before it restores a deactivated account, sends the reactivate mail, stamps `last_seen` and mints the token. A gate sees a `classes.LoginAttempt` (`User`, `Source`, the already-read login body as `Fields`, `Request`). A nil result allows the sign-in; a `classes.LoginGateResult` replaces the token response with its `Status` (0 means 200) and `Body`, for example `{"two_factor_required":true,"challenge_token":…,"available_methods":[…],"expires_at":…}`, and no token is issued, no account is restored and no login event fires. A gate error fails closed with the opaque 500. Without a gate, `/login` answers exactly as before. `POST /refresh` never consults the gates.
|
||||
- **Login event.** Every sign-in that issued a session token fires `classes.LoginEvent` (`classes.LoginEventName`, `golem15.user.login`, PHP's name) with `User` and `Source` (`LoginSourcePassword`, `LoginSourceSocial`, `LoginSourcePasskey`, `LoginSourceTwoFactor`, `LoginSourceDevice`), after the token was minted. `POST /login` fires it with `password`; another plugin that completes a sign-in fires it through `controllers.FireLoginEvent`. A listener error is logged and never fails the sign-in. Refresh and `/register` fire none.
|
||||
- **Password reset and change.** `POST /forgot-password` answers the same body for known and unknown addresses; `POST /reset-password` consumes a code from `IssueResetCode`. `POST /change-password` requires the current password. With `golem15.user.password.public_routes` false, `POST /login`, `POST /forgot-password` and `POST /reset-password` are not registered, so they answer the router's own 404 like any unknown path (any method, so no 405 shows they exist), while the `controllers` handlers stay exported.
|
||||
- **Must-change-password lock.** The `user:require-password-change` console command flags an account. The flag rides on the authenticated principal (`bouncer.Principal.MustChangePassword`) for guards and other plugins to act on, and a successful `POST /change-password` clears it.
|
||||
- **Login throttling.** Failed logins are counted per user and client address in `user_throttle` (`CheckAndRecordLogin`, `RejectIfThrottled`) and suspend the account for a configured time. The client address is `surf.ClientIP` with the framework's `http.trusted_proxies`, as for the `/register` IP limit and the `user-api` rate limit: behind a listed reverse proxy it is the `X-Forwarded-For` client, otherwise the connection address, and an `X-Forwarded-For` header from any other peer is ignored. A deployment behind a proxy lists the proxy's address in `http.trusted_proxies`; otherwise every visitor shares the proxy's counter.
|
||||
@@ -215,7 +217,9 @@ Packages:
|
||||
- `IssueToken(app, user, r)` mints the session JWT as `/register` and `/login` do.
|
||||
- `BackendLinkShow`, `BackendLinkShowPost`, `BackendLinkConfirm` and `TokenMe`, the handlers of the account link and `/token/me` routes.
|
||||
- `APIArray(ctx, app, user)` builds the user payload, extended by `GetApiArrayEvent` listeners.
|
||||
- `FinishLogin(ctx, app, db, user, ip)` runs the steps `POST /login` takes after authentication, for a caller that authenticated the user another way (a social sign-in): it restores a soft-deleted account and sends the reactivate mail, records `ip` as `last_ip_address` and touches `last_seen` (a failure there is only logged). It checks no password, throttle, activation or ban and fires no event; the caller does those checks.
|
||||
- `FinishLogin(ctx, app, db, user, ip)` runs the steps `POST /login` takes after authentication, for a caller that authenticated the user another way (a social sign-in): it restores a soft-deleted account and sends the reactivate mail, records `ip` as `last_ip_address` and touches `last_seen` (a failure there is only logged). It checks no password, throttle, activation or ban and fires no event; the caller does those checks, consults the login gates before it and fires the login event after it minted the token.
|
||||
- `CheckLoginGates(ctx, app, attempt)` consults the published `*classes.LoginGates` (none published allows the sign-in) and returns its answer: nil allows, a `*classes.LoginGateResult` replaces the token response, an error must fail the sign-in closed. A social-login plugin calls it before it issues a token.
|
||||
- `FireLoginEvent(ctx, app, user, source)` fires `classes.LoginEvent` and returns the listeners' error; call it after the token was minted and log the error.
|
||||
- `RegistrationSettings(app)` returns `allow_registration`, `use_register_throttle`, `activate_mode` and `require_activation` with their defaults (`true`, `true`, `auto`, `true`), and `RegistrationsFromIP(ctx, db, ip)` counts the accounts created from an IP in the last 60 minutes (`/register` refuses at 3 with the throttle on).
|
||||
- `RefreshTTL(app)` is the refresh window, `golem15.user.jwt.refresh_ttl` minutes (default 20160), and `RemoteIP(r)` the host part of `r.RemoteAddr`, with no proxy handling (`/login` and `/register` use `surf.ClientIP` with `http.trusted_proxies`).
|
||||
- `classes`: `JWTSecret`, `GormUsers` (the `bouncer.UserProvider`), `LookupByEmail`, `UserPermissionGrants`, `UserGroupCodes`, `HasGroupCode`, `APITokenPrefix`, `MintAPIToken`, `MintAPITokenFor`, `HashAPIToken` and `RevokeAPIToken`, the member token scope catalog `APITokenScope`, `APITokenScopeCatalog` (`Scopes`, `Allowed`), `CompileAPITokenScopes`, `APITokenScopesFor` and the test seam `ResetDeclaredAPITokenScopes`, plus the activation/reset, throttle, mail locale and event helpers. `RegisterEvent` carries `User` and `Payload map[string]any`, a read-only copy of the register input without the password keys. For the admin and for other plugins it also has:
|
||||
@@ -233,6 +237,8 @@ Packages:
|
||||
- `BackendLinks` (`CreateLinkRequest`, `LinkRequestByCode`, `ConfirmLinkRequest`, `SetLink`, `RemoveLink`, `LinkFor`, `LinkOf`, `LinkedBackendUser`, `RegisterBackendLinkListeners`), its errors `ErrLinkRequestNotFound`, `ErrLinkRequestUsed`, `ErrLinkRequestExpired`, `ErrStaleSession`, `ErrAlreadyLinked`, `ErrBackendUserUnavailable` and `ErrFrontendUserUnavailable`, the events `BackendLinkedEvent` / `BackendLinkedEventName` and `BackendUnlinkedEvent` / `BackendUnlinkedEventName`, `BackendLinkRequestTTL(app)`, `DefaultBackendLinkRequestTTL`, `FrontendURL(app)`, `BackendLinkPagePath(app)`, `DefaultBackendLinkPagePath`, `BackendLinksFor(app)` (the published service, or one over the app) and `BackendDisplayName(admin)`.
|
||||
- `PermissionManageBackendLinks`, `FrontendPermissionCatalog(ctx, app, db)`, `MCPUserGuard`, `FireSubjectChanged(ctx, app, guard, userID, kind)` and `FireSubjectChangedAfterCommit(ctx, app, tx, guard, userID, kind)` with the guard names `SubjectGuardUser` and `SubjectGuardBackend`, and `FireSubjectEvent(ctx, app, ev)` and `FireSubjectEventAfterCommit(ctx, app, tx, ev)`, which fire a whole `pact.MCPSubjectChanged` (with `TokenID` or `ClientID` to end only one token's or one client's sessions).
|
||||
- `AccessRevokedEvent` / `AccessRevokedEventName` and `FireAccessRevoked(ctx, app, tx, userIDs, before, reason)` (see Access revocation).
|
||||
- `LoginAttempt`, `LoginGateResult`, `LoginGate` (`CheckLogin(ctx, attempt)`), `LoginGates` with `NewLoginGates()`, `Add(gate)` and `Check(ctx, attempt)`, the login-gate seam (see Login gates), and the sources `LoginSourcePassword`, `LoginSourceSocial`, `LoginSourcePasskey`, `LoginSourceTwoFactor` and `LoginSourceDevice`.
|
||||
- `LoginEvent` / `LoginEventName` (see Login event).
|
||||
- `HandleGenerator`, `AssignHandle(ctx, db, user)`, `CreatedEvent` / `CreatedEventName`, `DeletingEvent` / `DeletingEventName`, `DeactivatingEvent` / `DeactivatingEventName`, `AuthorizeUser` and `RegisterUserAuthorizer`.
|
||||
- `middleware`: `APITokenGuard`, `Scope`, `Permission` and `BearerToken`.
|
||||
- `models`: `User`, `Throttle`, `Organisation`, `UserGroup`, `UsersGroup` (the `users_groups` pivot), `APIToken`, `OAuthIdentity`, `FrontendPermission`, `BackendLink`, `BackendLinkRequest` and `PermissionSet`. `PermissionSet` reads a stored permission column tolerantly (NULL, an empty string, `[]`, numeric strings) and writes a JSON object of integers; `ParsePermissionSet` and `PermissionSet.Text` do the same for a column that is a plain string on its model. `UserGroup.UsersCount` is filled only by a query that selects `users_count` (the admin list does). `Organisation.Members` is the relation behind the members manager, `Organisation.AttachRelations` declares its avatar (`OrganisationAvatarField`) and its logo (`OrganisationLogoField`), and `Slugify` is the slug rule. `User.FilterScope` answers the admin list scope `FilterByGroup`, and `User.AttachRelations` declares the avatar (`AvatarField`). `User.RegisterFillable` is the registration allow-list; `User.Fillable` is the wider mass-assignment backstop of the admin Users form (cabana intersects it with the form's fields) and of trusted application code.
|
||||
|
||||
@@ -25,6 +25,10 @@ const BackendUnlinkedEventName = "user.BackendUnlinked"
|
||||
// AccessRevokedEventName is the festival name of AccessRevokedEvent.
|
||||
const AccessRevokedEventName = "user.AccessRevoked"
|
||||
|
||||
// LoginEventName is the festival name of LoginEvent, the PHP plugin's
|
||||
// golem15.user.login.
|
||||
const LoginEventName = "golem15.user.login"
|
||||
|
||||
// GetApiArrayEvent is the fire-and-collect payload other plugins extend.
|
||||
type GetApiArrayEvent struct {
|
||||
User *models.User
|
||||
@@ -111,3 +115,14 @@ type AccessRevokedEvent struct {
|
||||
Reason string
|
||||
Tx *gorm.DB
|
||||
}
|
||||
|
||||
// LoginEvent fires after a sign-in issued a session token: a password login,
|
||||
// a sign-in completed by a second factor, a passkey sign-in or a device
|
||||
// hand-over (Source is one of the LoginSource constants). It does not fire
|
||||
// on refresh, on /register or for a sign-in a login gate held back. It fires
|
||||
// after the token was minted and outside any transaction; a listener error
|
||||
// is logged and never fails the sign-in (PHP's golem15.user.login).
|
||||
type LoginEvent struct {
|
||||
User *models.User
|
||||
Source string
|
||||
}
|
||||
|
||||
124
classes/login_gate.go
Normal file
124
classes/login_gate.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
)
|
||||
|
||||
// Login sources name the first factor a LoginAttempt or a LoginEvent comes
|
||||
// from.
|
||||
const (
|
||||
// LoginSourcePassword is POST /_user/api/v1/login (email and password).
|
||||
LoginSourcePassword = "password"
|
||||
// LoginSourceSocial is a social sign-in completed by a social-login
|
||||
// plugin.
|
||||
LoginSourceSocial = "social"
|
||||
// LoginSourcePasskey is a passwordless passkey sign-in.
|
||||
LoginSourcePasskey = "passkey"
|
||||
// LoginSourceTwoFactor is a sign-in completed by a second factor after a
|
||||
// gate challenged it.
|
||||
LoginSourceTwoFactor = "two_factor"
|
||||
// LoginSourceDevice is the device authorization hand-over (the first poll
|
||||
// of devices/status after a signed-in user authorized the device).
|
||||
LoginSourceDevice = "device"
|
||||
)
|
||||
|
||||
// LoginAttempt is a first-factor sign-in that already succeeded: the
|
||||
// password (or the social identity) checked out, the throttle was reset and
|
||||
// the account may sign in. A LoginGate decides whether a token is issued
|
||||
// now.
|
||||
type LoginAttempt struct {
|
||||
// User is the account signing in. A deactivated account still has
|
||||
// DeletedAt set: Login restores it only after every gate allowed the
|
||||
// sign-in.
|
||||
User *models.User
|
||||
// Source is one of the LoginSource constants.
|
||||
Source string
|
||||
// Fields is the login body Login already read (a gate must not read the
|
||||
// request body again, it is consumed). It carries keys a gate needs,
|
||||
// such as trusted_device_token. It is nil on a social sign-in. Treat it
|
||||
// as read-only, untrusted input; it holds the password on a password
|
||||
// login.
|
||||
Fields map[string]any
|
||||
// Request is the sign-in request, for the client address and the user
|
||||
// agent.
|
||||
Request *http.Request
|
||||
}
|
||||
|
||||
// LoginGateResult replaces the token response of a sign-in a gate held
|
||||
// back.
|
||||
type LoginGateResult struct {
|
||||
// Status is the HTTP status of the answer; 0 means 200. A challenge
|
||||
// answers 200, a refusal (for example a second-factor failure cap)
|
||||
// answers its own status.
|
||||
Status int
|
||||
// Body is the JSON body of the answer, for example
|
||||
// {two_factor_required, challenge_token, available_methods, expires_at}.
|
||||
Body map[string]any
|
||||
}
|
||||
|
||||
// LoginGate inspects a first-factor sign-in that already succeeded, before
|
||||
// any token exists. A two-factor plugin implements one to challenge users
|
||||
// who enabled a second factor.
|
||||
type LoginGate interface {
|
||||
// CheckLogin returns nil, nil to allow the sign-in. A non-nil result
|
||||
// replaces the token response with its Status and Body, and no token is
|
||||
// issued. An error must fail the sign-in closed: the caller answers the
|
||||
// opaque 500 and issues no token. Error strings must not carry a token,
|
||||
// a code or a password.
|
||||
CheckLogin(ctx context.Context, a LoginAttempt) (*LoginGateResult, error)
|
||||
}
|
||||
|
||||
// LoginGates is the registry of login gates. The user plugin publishes one
|
||||
// in its Register (app.Publish(classes.NewLoginGates())), and another plugin
|
||||
// looks it up with app.Lookup[*classes.LoginGates]() and adds its gate in
|
||||
// its Boot: every Register runs before every Boot. Password Login consults
|
||||
// it after the password, activation and throttle checks and before it
|
||||
// restores a deactivated account and mints the token; JWT refresh never
|
||||
// consults it. It is safe for concurrent use.
|
||||
type LoginGates struct {
|
||||
mu sync.RWMutex
|
||||
gates []LoginGate
|
||||
}
|
||||
|
||||
// NewLoginGates returns an empty registry.
|
||||
func NewLoginGates() *LoginGates {
|
||||
return &LoginGates{}
|
||||
}
|
||||
|
||||
// Add appends gate; gates run in the order they were added. A nil gate is
|
||||
// ignored.
|
||||
func (g *LoginGates) Add(gate LoginGate) {
|
||||
if g == nil || gate == nil {
|
||||
return
|
||||
}
|
||||
g.mu.Lock()
|
||||
g.gates = append(g.gates, gate)
|
||||
g.mu.Unlock()
|
||||
}
|
||||
|
||||
// Check runs the gates in order and returns the first non-nil result or the
|
||||
// first error; gates after it are not called. A nil registry and an empty
|
||||
// one return nil, nil, which allows the sign-in.
|
||||
func (g *LoginGates) Check(ctx context.Context, a LoginAttempt) (*LoginGateResult, error) {
|
||||
if g == nil {
|
||||
return nil, nil
|
||||
}
|
||||
g.mu.RLock()
|
||||
gates := slices.Clone(g.gates)
|
||||
g.mu.RUnlock()
|
||||
for _, gate := range gates {
|
||||
res, err := gate.CheckLogin(ctx, a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res != nil {
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
@@ -47,8 +47,11 @@ const invalidLoginBody = "Invalid email or password"
|
||||
//
|
||||
// It checks in this order: a ban or an active suspension
|
||||
// (classes.RejectIfThrottled), the password (bcrypt), the activation, then
|
||||
// restores a deactivated (deleted_at) account and sends it the reactivate
|
||||
// mail, records the successful attempt and mints the token. With
|
||||
// records the successful attempt, consults the login gates
|
||||
// (CheckLoginGates: a gate answer replaces the token response, a gate error
|
||||
// answers the opaque 500), restores a deactivated (deleted_at) account and
|
||||
// sends it the reactivate mail, mints the token and fires
|
||||
// classes.LoginEvent. With
|
||||
// golem15.user.activation.require_activation on (the default) the correct
|
||||
// password of a not-activated account gets the same 401 invalid-login answer
|
||||
// as a wrong password and counts as a failed attempt for the throttle; an
|
||||
@@ -106,13 +109,6 @@ func Login(app *backpack.App) http.HandlerFunc {
|
||||
refuseLogin(ctx, w, r, app, gdb, user, ip)
|
||||
return
|
||||
}
|
||||
if user.DeletedAt.Valid {
|
||||
if err := gdb.WithContext(ctx).Unscoped().Model(user).Update("deleted_at", nil).Error; err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.reactivate"), user.Email, mailVars(displayName(user), "", ""))
|
||||
}
|
||||
if err := classes.CheckAndRecordLogin(ctx, gdb, user, ip, true); err != nil {
|
||||
if classes.IsLoginRejected(err) {
|
||||
writeInvalidLogin(w, r, app)
|
||||
@@ -121,15 +117,51 @@ func Login(app *backpack.App) http.HandlerFunc {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
// The rehash runs before the login gates, so a user who is always
|
||||
// challenged still gets the stronger hash. It is not a password
|
||||
// change: it fires no PasswordChangedEvent. Unscoped, because a
|
||||
// deactivated account is restored only after the gates.
|
||||
cost := 10
|
||||
if app.Config != nil && app.Config.Int("golem15.user.password.bcrypt_cost") > 0 {
|
||||
cost = app.Config.Int("golem15.user.password.bcrypt_cost")
|
||||
}
|
||||
if bouncer.NeedsRehash(user.Password, cost) {
|
||||
if next, err := bouncer.HashPassword(cost, password); err == nil {
|
||||
_ = gdb.WithContext(ctx).Model(user).Update("password", next).Error
|
||||
_ = gdb.WithContext(ctx).Unscoped().Model(user).Update("password", next).Error
|
||||
}
|
||||
}
|
||||
// Login gates (D-02) run after the password, activation and throttle
|
||||
// checks and before anything that signs the user in. A gate answer
|
||||
// replaces the token response; a gate error fails closed. The
|
||||
// restore of a deactivated account and its reactivate mail come
|
||||
// after the gates (D-18 A10), so the password alone cannot reactivate
|
||||
// an account that a second factor protects.
|
||||
gate, err := CheckLoginGates(ctx, app, classes.LoginAttempt{
|
||||
User: user,
|
||||
Source: classes.LoginSourcePassword,
|
||||
Fields: fields,
|
||||
Request: r,
|
||||
})
|
||||
if err != nil {
|
||||
pluginLog.Error("golem15.user: login gate", "user_id", user.ID, "err", err)
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
if gate != nil {
|
||||
status := gate.Status
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
writeJSON(w, status, gate.Body)
|
||||
return
|
||||
}
|
||||
if user.DeletedAt.Valid {
|
||||
if err := gdb.WithContext(ctx).Unscoped().Model(user).Update("deleted_at", nil).Error; err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.reactivate"), user.Email, mailVars(displayName(user), "", ""))
|
||||
}
|
||||
// A failed last_seen write is logged and never fails the login.
|
||||
if err := classes.TouchLastSeen(ctx, gdb, user.ID); err != nil {
|
||||
pluginLog.Warn("golem15.user: stamp last_seen on login", "user_id", user.ID, "err", err)
|
||||
@@ -144,6 +176,9 @@ func Login(app *backpack.App) http.HandlerFunc {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
if err := FireLoginEvent(ctx, app, user, classes.LoginSourcePassword); err != nil {
|
||||
pluginLog.Warn("golem15.user: login event listener", "user_id", user.ID, "err", err)
|
||||
}
|
||||
writeSession(w, app, cc, token, map[string]any{"user": payload})
|
||||
}
|
||||
}
|
||||
|
||||
26
controllers/login_gates.go
Normal file
26
controllers/login_gates.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
)
|
||||
|
||||
// CheckLoginGates consults the published *classes.LoginGates for a
|
||||
// first-factor sign-in that already succeeded and returns its answer: nil,
|
||||
// nil allows the sign-in, a result replaces the token response, and an error
|
||||
// must fail the sign-in closed (answer the opaque 500, issue no token). It
|
||||
// looks the registry up on every call and allows the sign-in when none is
|
||||
// published. Password Login calls it; a social-login plugin calls it before
|
||||
// it issues a token.
|
||||
func CheckLoginGates(ctx context.Context, app *backpack.App, a classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||
if app == nil {
|
||||
return nil, nil
|
||||
}
|
||||
gates, ok := app.Lookup[*classes.LoginGates]()
|
||||
if !ok || gates == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return gates.Check(ctx, a)
|
||||
}
|
||||
@@ -102,8 +102,10 @@ func RegisterUser(ctx context.Context, app *backpack.App, db *gorm.DB, fields ma
|
||||
// (a social sign-in). It restores a soft-deleted account and sends the
|
||||
// reactivate mail, records ip as last_ip_address, and touches last_seen. It
|
||||
// checks no password, throttle, activation or ban and fires no event; the
|
||||
// caller does those checks (Go has no login event). A failed last_seen
|
||||
// write is logged and does not fail it, as in Login.
|
||||
// caller does those checks, consults the login gates (CheckLoginGates)
|
||||
// before it, and fires the login event (FireLoginEvent) after it minted the
|
||||
// token. A failed last_seen write is logged and does not fail it, as in
|
||||
// Login.
|
||||
func FinishLogin(ctx context.Context, app *backpack.App, db *gorm.DB, user *models.User, ip string) error {
|
||||
if db == nil {
|
||||
return errors.New("golem15.user: gorm handle is missing")
|
||||
@@ -179,6 +181,17 @@ func FireDeactivatingEvent(ctx context.Context, app *backpack.App, user *models.
|
||||
return app.Events.Fire(ctx, &classes.DeactivatingEvent{User: user, Tx: tx})
|
||||
}
|
||||
|
||||
// FireLoginEvent fires classes.LoginEvent (golem15.user.login) for user
|
||||
// with source, one of the classes.LoginSource constants, and returns the
|
||||
// listeners' error. Call it after the session token was minted; a caller
|
||||
// logs the error and still answers the sign-in.
|
||||
func FireLoginEvent(ctx context.Context, app *backpack.App, user *models.User, source string) error {
|
||||
if app == nil || app.Events == nil {
|
||||
return nil
|
||||
}
|
||||
return app.Events.Fire(ctx, &classes.LoginEvent{User: user, Source: source})
|
||||
}
|
||||
|
||||
// IssueToken mints the session JWT for user exactly as /register and
|
||||
// /login do: the plugin's JWT secret, golem15.user.jwt.ttl and the request
|
||||
// URL as the issuer.
|
||||
|
||||
264
login_gate_smoke_test.go
Normal file
264
login_gate_smoke_test.go
Normal file
@@ -0,0 +1,264 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-user-plugin/controllers"
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/postcard"
|
||||
)
|
||||
|
||||
// gateFunc adapts a function to classes.LoginGate.
|
||||
type gateFunc func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error)
|
||||
|
||||
func (f gateFunc) CheckLogin(ctx context.Context, a classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||
return f(ctx, a)
|
||||
}
|
||||
|
||||
// TestLoginGatesSmoke pins the registry: nil and empty allow, the first
|
||||
// non-nil result or error wins and stops the chain.
|
||||
func TestLoginGatesSmoke(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var nilGates *classes.LoginGates
|
||||
if res, err := nilGates.Check(ctx, classes.LoginAttempt{}); res != nil || err != nil {
|
||||
t.Fatalf("nil registry = %v, %v", res, err)
|
||||
}
|
||||
empty := classes.NewLoginGates()
|
||||
if res, err := empty.Check(ctx, classes.LoginAttempt{}); res != nil || err != nil {
|
||||
t.Fatalf("empty registry = %v, %v", res, err)
|
||||
}
|
||||
|
||||
want := &classes.LoginGateResult{Body: map[string]any{"two_factor_required": true}}
|
||||
calls := 0
|
||||
g := classes.NewLoginGates()
|
||||
g.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) { calls++; return nil, nil }))
|
||||
g.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) { calls++; return want, nil }))
|
||||
g.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) { calls++; return nil, nil }))
|
||||
res, err := g.Check(ctx, classes.LoginAttempt{})
|
||||
if err != nil || res != want {
|
||||
t.Fatalf("A(nil) then B(result) = %v, %v", res, err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("gates called %d times, want 2 (the chain stops at the result)", calls)
|
||||
}
|
||||
|
||||
boom := errors.New("gate failed")
|
||||
later := 0
|
||||
e := classes.NewLoginGates()
|
||||
e.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) { return nil, boom }))
|
||||
e.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||
later++
|
||||
return nil, nil
|
||||
}))
|
||||
if res, err := e.Check(ctx, classes.LoginAttempt{}); res != nil || !errors.Is(err, boom) {
|
||||
t.Fatalf("erroring gate = %v, %v", res, err)
|
||||
}
|
||||
if later != 0 {
|
||||
t.Fatal("a gate after the erroring one was called")
|
||||
}
|
||||
}
|
||||
|
||||
// loginEvents records every classes.LoginEvent fired on app.
|
||||
type loginEvents struct {
|
||||
mu sync.Mutex
|
||||
got []classes.LoginEvent
|
||||
}
|
||||
|
||||
func listenLoginEvents(app *backpack.App, owner string) *loginEvents {
|
||||
rec := &loginEvents{}
|
||||
app.Events.Listen[*classes.LoginEvent](owner, func(_ context.Context, e *classes.LoginEvent) error {
|
||||
rec.mu.Lock()
|
||||
rec.got = append(rec.got, *e)
|
||||
rec.mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
return rec
|
||||
}
|
||||
|
||||
func (l *loginEvents) count() int {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return len(l.got)
|
||||
}
|
||||
|
||||
// gateApp is sessionApp with a published login-gate registry (bootPlugin
|
||||
// runs Boot only, so the test publishes what Register would).
|
||||
func gateApp(t *testing.T) (*backpack.App, *classes.LoginGates) {
|
||||
t.Helper()
|
||||
app, _ := sessionApp(t, "0s")
|
||||
gates := classes.NewLoginGates()
|
||||
if err := app.Publish(gates); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return app, gates
|
||||
}
|
||||
|
||||
func decodeBody(t *testing.T, raw []byte) map[string]any {
|
||||
t.Helper()
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Fatalf("decode %s: %v", raw, err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// TestLoginGateSmoke drives POST /login through a registered gate.
|
||||
func TestLoginGateSmoke(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Postgres (testcontainers)")
|
||||
}
|
||||
_, gdb := sessionApp(t, "0s")
|
||||
|
||||
t.Run("no_gate", func(t *testing.T) {
|
||||
app, _ := gateApp(t)
|
||||
events := listenLoginEvents(app, "test.login-gate.no-gate")
|
||||
user := insertUser(t, gdb, "gate-none@example.test", "correct-horse")
|
||||
rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"gate-none@example.test","password":"correct-horse"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := decodeBody(t, rec.Body.Bytes())
|
||||
if tok, _ := body["token"].(string); tok == "" {
|
||||
t.Fatalf("no token: %s", rec.Body.String())
|
||||
}
|
||||
if _, ok := body["user"].(map[string]any); !ok {
|
||||
t.Fatalf("no user: %s", rec.Body.String())
|
||||
}
|
||||
if events.count() != 1 {
|
||||
t.Fatalf("login events = %d, want 1", events.count())
|
||||
}
|
||||
if e := events.got[0]; e.Source != classes.LoginSourcePassword || e.User == nil || e.User.ID != user.ID {
|
||||
t.Fatalf("event = %+v", e)
|
||||
}
|
||||
})
|
||||
|
||||
challengeBody := map[string]any{
|
||||
"two_factor_required": true,
|
||||
"challenge_token": strings.Repeat("x", 64),
|
||||
"available_methods": []any{"totp"},
|
||||
"expires_at": "2026-10-21T12:00:00+00:00",
|
||||
}
|
||||
|
||||
t.Run("challenge", func(t *testing.T) {
|
||||
app, gates := gateApp(t)
|
||||
events := listenLoginEvents(app, "test.login-gate.challenge")
|
||||
var seen classes.LoginAttempt
|
||||
gates.Add(gateFunc(func(_ context.Context, a classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||
seen = a
|
||||
return &classes.LoginGateResult{Status: http.StatusOK, Body: challengeBody}, nil
|
||||
}))
|
||||
insertUser(t, gdb, "gate-challenge@example.test", "correct-horse")
|
||||
rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login",
|
||||
`{"email":"gate-challenge@example.test","password":"correct-horse","trusted_device_token":"td-123"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := decodeBody(t, rec.Body.Bytes())
|
||||
if !reflect.DeepEqual(body, challengeBody) {
|
||||
t.Fatalf("body = %#v", body)
|
||||
}
|
||||
if _, ok := body["token"]; ok {
|
||||
t.Fatal("a challenged login answered a token")
|
||||
}
|
||||
if seen.Source != classes.LoginSourcePassword || seen.User == nil || seen.Request == nil {
|
||||
t.Fatalf("attempt = %+v", seen)
|
||||
}
|
||||
if seen.Fields["trusted_device_token"] != "td-123" {
|
||||
t.Fatalf("fields = %#v", seen.Fields)
|
||||
}
|
||||
if events.count() != 0 {
|
||||
t.Fatalf("a challenged login fired %d login events", events.count())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("refusal", func(t *testing.T) {
|
||||
app, gates := gateApp(t)
|
||||
gates.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||
return &classes.LoginGateResult{Status: http.StatusUnauthorized, Body: map[string]any{"error": "Invalid or expired challenge."}}, nil
|
||||
}))
|
||||
insertUser(t, gdb, "gate-refusal@example.test", "correct-horse")
|
||||
rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"gate-refusal@example.test","password":"correct-horse"}`)
|
||||
if rec.Code != http.StatusUnauthorized || rec.Body.String() != `{"error":"Invalid or expired challenge."}` {
|
||||
t.Fatalf("refusal = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gate_error", func(t *testing.T) {
|
||||
app, gates := gateApp(t)
|
||||
events := listenLoginEvents(app, "test.login-gate.error")
|
||||
gates.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||
return nil, errors.New("challenge store down")
|
||||
}))
|
||||
insertUser(t, gdb, "gate-error@example.test", "correct-horse")
|
||||
rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"gate-error@example.test","password":"correct-horse"}`)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("gate error = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), `"token"`) {
|
||||
t.Fatalf("gate error answered a token: %s", rec.Body.String())
|
||||
}
|
||||
if events.count() != 0 {
|
||||
t.Fatal("a failed gate fired a login event")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("soft_deleted_stays", func(t *testing.T) {
|
||||
app, gates := gateApp(t)
|
||||
gates.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||
return &classes.LoginGateResult{Body: challengeBody}, nil
|
||||
}))
|
||||
mail := &captureMail{}
|
||||
if err := app.Publish[postcard.Mailer](mail); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := insertUser(t, gdb, "gate-soft@example.test", "correct-horse")
|
||||
if err := gdb.Model(user).Update("deleted_at", time.Now()).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"gate-soft@example.test","password":"correct-horse"}`)
|
||||
if rec.Code != http.StatusOK || decodeBody(t, rec.Body.Bytes())["two_factor_required"] != true {
|
||||
t.Fatalf("challenge = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var still models.User
|
||||
if err := gdb.Unscoped().First(&still, user.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !still.DeletedAt.Valid {
|
||||
t.Fatal("a challenged login restored the deactivated account")
|
||||
}
|
||||
if len(mail.got) != 0 {
|
||||
t.Fatalf("a challenged login sent %v", mail.got)
|
||||
}
|
||||
|
||||
// Without a gate the same login restores the account, as before.
|
||||
plain, _ := gateApp(t)
|
||||
plainMail := &captureMail{}
|
||||
if err := plain.Publish[postcard.Mailer](plainMail); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec = postJSON(t, controllers.Login(plain), "/_user/api/v1/login", `{"email":"gate-soft@example.test","password":"correct-horse"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("restore = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var restored models.User
|
||||
if err := gdb.Unscoped().First(&restored, user.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if restored.DeletedAt.Valid {
|
||||
t.Fatal("login without a gate did not restore the account")
|
||||
}
|
||||
if len(plainMail.got) != 1 || !strings.HasPrefix(plainMail.got[0], "golem15.user::mail.reactivate") {
|
||||
t.Fatalf("reactivate mails = %v", plainMail.got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -60,9 +60,14 @@ type Plugin struct {
|
||||
func (p *Plugin) ID() string { return "golem15.user" }
|
||||
func (p *Plugin) Requires() []string { return nil }
|
||||
|
||||
// Register publishes the login-gate registry (*classes.LoginGates), so a
|
||||
// plugin that requires this one can add its gate in its Boot.
|
||||
func (p *Plugin) Register(app *backpack.App) error {
|
||||
p.app = app
|
||||
return nil
|
||||
if gates, ok := app.Lookup[*classes.LoginGates](); ok && gates != nil {
|
||||
return nil
|
||||
}
|
||||
return app.Publish(classes.NewLoginGates())
|
||||
}
|
||||
|
||||
func (p *Plugin) Boot(app *backpack.App) error {
|
||||
|
||||
Reference in New Issue
Block a user