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