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

@@ -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})
}
}

View 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)
}

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.