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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user