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:
2026-10-26 09:55:48 +01:00
parent 50da92c1fb
commit 8501f3475a
8 changed files with 502 additions and 14 deletions

View File

@@ -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.