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})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user