Files
sm-user-plugin/classes/login_gate.go
Jakub Zych b179e6b2e5 fix: activate-by-code asks the login gates before it signs in
After the mailed code verifies, ActivateByCode consults the login gates
with source password, as Login does. A gate answer (for example a
second-factor challenge) replaces the token response and sets no session
cookie; a gate error answers the opaque 500. The account is activated
either way. Without a gate, or when every gate allows, the answer is
unchanged. With require_activation off an account can enable a second
factor before it activates, so the mailed code no longer stands in for it.
2026-11-05 17:54:21 +01:00

128 lines
4.4 KiB
Go

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)
// and POST /_user/api/v1/activate-by-code (the mailed activation code,
// the first factor of that sign-in).
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 and the activation code on an activation.
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; activate-by-code
// consults it after the code verified and before it 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
}