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 }