Files
llmgw/internal/account/service.go
T
Ilker ManapandClaude Opus 5.5 f27d722111 People sign in with their Active Directory account
auth.ldap checks a username and password against the domain controllers
over LDAPS or StartTLS: found with a service account, checked by binding
as the person. Groups, nested ones included, give staff roles and places
in the tree. Accounts are tied to the objectGUID; the leader reads the
directory every 15 minutes, suspends whom it disabled or removed and
reinstates them after, and refuses a sync that would suspend most.

Entra ID sends no groups for someone in more than 200, only where to
fetch them; that was read as none and took an administrator's role away.
It is now left as it was, and logged. An account made by SCIM or OIDC
can be reinstated again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 10:59:50 +02:00

471 lines
15 KiB
Go

package account
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base32"
"errors"
"log/slog"
"strings"
"sync"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// What a sign-in or sign-up can fail with.
var (
ErrBadCredentials = errors.New("that email and password do not match an account")
ErrNotApproved = errors.New("this account is waiting for approval")
ErrSuspended = errors.New("this account has been suspended")
ErrTooManyTries = errors.New("too many attempts; try again shortly")
ErrInvalidEmail = errors.New("that does not look like an email address")
)
// Store is what the service needs from persistence.
type Store interface {
CreateUser(ctx context.Context, u store.User) error
UserByEmail(ctx context.Context, email string) (store.User, error)
UserByID(ctx context.Context, id string) (store.User, error)
TouchLogin(ctx context.Context, id string, at time.Time) error
CreateSession(ctx context.Context, userID, token, csrf string, ttl time.Duration) error
SessionByToken(ctx context.Context, token string) (store.Session, store.User, error)
DeleteSession(ctx context.Context, token string) error
TouchSession(ctx context.Context, token string, at time.Time) error
}
// Config tunes the service.
type Config struct {
// SignupOpen decides whether anyone may create an account. Off, the form is
// not served at all; accounts are made with `llmgw users create`.
SignupOpen bool
// SessionTTL is how long a sign-in lasts.
SessionTTL time.Duration
// MaxAttempts and AttemptWindow bound password guessing per address.
MaxAttempts int
AttemptWindow time.Duration
}
// DefaultConfig returns sensible values.
func DefaultConfig() Config {
return Config{
SignupOpen: true,
SessionTTL: store.SessionTTL,
MaxAttempts: 10,
AttemptWindow: 15 * time.Minute,
}
}
func (c *Config) normalize() {
d := DefaultConfig()
if c.SessionTTL <= 0 {
c.SessionTTL = d.SessionTTL
}
if c.MaxAttempts <= 0 {
c.MaxAttempts = d.MaxAttempts
}
if c.AttemptWindow <= 0 {
c.AttemptWindow = d.AttemptWindow
}
}
// Service is signup, sign-in and sessions.
type Service struct {
store Store
cfg Config
log *slog.Logger
now func() time.Time
mu sync.Mutex
attempts map[string]*attemptWindow
// dir checks the passwords of accounts this store keeps none for.
dir Directory
}
// Directory checks a password against an outside directory — Active
// Directory over LDAP — and answers the account to sign in: made or kept in
// step with the directory. Its errors are this package's ErrBadCredentials
// and ErrSuspended for the person's own, and anything else for the
// directory's.
type Directory interface {
SignIn(ctx context.Context, login, password string) (store.User, error)
}
// SetDirectory has passwords for accounts with none here, and names that are
// not addresses, checked by the directory. An account with a password here
// — the administrator init made — is still checked here, so a directory that
// is down locks nobody out.
func (s *Service) SetDirectory(d Directory) { s.dir = d }
type attemptWindow struct {
count int
since time.Time
}
// New builds the service.
func New(s Store, cfg Config, log *slog.Logger) *Service {
cfg.normalize()
if log == nil {
log = slog.Default()
}
return &Service{store: s, cfg: cfg, log: log, now: time.Now, attempts: map[string]*attemptWindow{}}
}
// SignupOpen reports whether the form should be served.
func (s *Service) SignupOpen() bool { return s.cfg.SignupOpen }
// SignUp creates a pending account.
//
// It returns nil whether or not the address was already taken. A signup form
// that says "this address already has an account" is an account enumerator that
// anyone can run, and the person signing up learns nothing they need: either
// way, what happens next is that they wait for approval.
func (s *Service) SignUp(ctx context.Context, email, name, password string) error {
email = store.NormalizeEmail(email)
if !validEmail(email) {
return ErrInvalidEmail
}
hash, err := HashPassword(password)
if err != nil {
return err
}
if name = strings.TrimSpace(name); name == "" {
name = email
}
err = s.store.CreateUser(ctx, store.User{
ID: randomID("usr"), Email: email, Name: name,
PasswordHash: hash, State: store.UserPending, CreatedAt: s.now(),
})
if errors.Is(err, store.ErrEmailTaken) {
// Logged so an operator can see it; not told to the caller.
s.log.Info("signup for an address that already has an account")
return nil
}
if err != nil {
return err
}
s.log.Info("a new account is waiting for approval", "email", email)
return nil
}
// SignIn checks a password and opens a session, returning the session token and
// the CSRF token that must accompany the forms in it.
func (s *Service) SignIn(ctx context.Context, email, password string) (token, csrf string, err error) {
login := strings.TrimSpace(email)
email = store.NormalizeEmail(email)
// Counted per address and the client trying it, so somebody guessing
// from elsewhere cannot lock the owner out of their own account; and per
// address alone, more loosely, so guesses spread over many clients are
// still bounded.
byClient := email
if c := clientFrom(ctx); c != "" {
byClient = email + "|" + c
}
if !s.allowAttempt(byClient, s.cfg.MaxAttempts) || !s.allowAttempt("all|"+email, 10*s.cfg.MaxAttempts) {
return "", "", ErrTooManyTries
}
u, err := s.store.UserByEmail(ctx, email)
// No password kept here — the directory's, or nobody's — and a
// directory to ask: it decides.
if s.dir != nil && (errors.Is(err, store.ErrNotFound) || (err == nil && strings.HasPrefix(u.PasswordHash, "!"))) {
if u, err = s.dir.SignIn(ctx, login, password); err != nil {
return "", "", err
}
s.clearAttempts(byClient)
return s.open(ctx, u)
}
if errors.Is(err, store.ErrNotFound) {
// Verify anyway, against a hash that cannot match. Returning early here
// would let a stopwatch enumerate every account on the system.
_, _ = VerifyPassword(password, dummyHash)
return "", "", ErrBadCredentials
}
if err != nil {
return "", "", err
}
ok, err := VerifyPassword(password, u.PasswordHash)
if errors.Is(err, ErrBusy) {
// Not an answer about the password: said as what it is.
return "", "", err
}
if err != nil || !ok {
return "", "", ErrBadCredentials
}
// The password was right, so this was not an attack.
s.clearAttempts(byClient)
// State is checked after the password, on purpose. Checking first would
// tell anyone who guesses an address whether it has a pending account.
switch u.State {
case store.UserPending:
return "", "", ErrNotApproved
case store.UserSuspended:
return "", "", ErrSuspended
}
return s.open(ctx, u)
}
// open makes a session for a person whose password was right.
func (s *Service) open(ctx context.Context, u store.User) (token, csrf string, err error) {
if token, err = store.NewSessionToken(); err != nil {
return "", "", err
}
if csrf, err = store.NewSessionToken(); err != nil {
return "", "", err
}
if err := s.store.CreateSession(ctx, u.ID, token, csrf, s.cfg.SessionTTL); err != nil {
return "", "", err
}
if err := s.store.TouchLogin(ctx, u.ID, s.now()); err != nil {
s.log.Warn("could not record a sign-in", "err", err)
}
return token, csrf, nil
}
// Resolve turns a session cookie into the person holding it.
func (s *Service) Resolve(ctx context.Context, token string) (store.Session, store.User, error) {
if token == "" {
return store.Session{}, store.User{}, store.ErrNoSession
}
sess, u, err := s.store.SessionByToken(ctx, token)
if err != nil {
return store.Session{}, store.User{}, err
}
if err := s.store.TouchSession(ctx, token, s.now()); err != nil {
s.log.Debug("could not touch a session", "err", err)
}
return sess, u, nil
}
// SignOut closes one session.
func (s *Service) SignOut(ctx context.Context, token string) error {
if token == "" {
return nil
}
return s.store.DeleteSession(ctx, token)
}
// CheckCSRF compares a submitted token against the session's.
//
// A cookie authenticates the browser, not the page that asked. Without this, any
// site the person visits can post a form to ours and have the browser attach the
// session automatically.
func (s *Service) CheckCSRF(sess store.Session, submitted string) bool {
if submitted == "" || len(sess.CSRFHash) == 0 {
return false
}
return subtle.ConstantTimeCompare(store.HashKey(submitted), sess.CSRFHash) == 1
}
// allowAttempt bounds password guessing per address.
//
// Per address rather than per IP: an attacker has many addresses to come from
// and only one to guess against, and limiting by IP would lock out everyone
// behind one office NAT the moment somebody fat-fingers their password.
func (s *Service) allowAttempt(email string, limit int) bool {
now := s.now()
s.mu.Lock()
defer s.mu.Unlock()
w, ok := s.attempts[email]
if !ok && len(s.attempts) >= maxTrackedAddresses {
// Full of addresses nobody has signed in as for a while: forget the
// ones whose window has passed. Only expiring, never evicting early —
// dropping a live window is how an attacker spraying random addresses
// would buy unlimited guesses at the one they care about.
for k, v := range s.attempts {
if now.Sub(v.since) > s.cfg.AttemptWindow {
delete(s.attempts, k)
}
}
if len(s.attempts) >= maxTrackedAddresses {
// Still full, so somebody is spraying addresses faster than they
// expire. New ones wait; those already tracked are unaffected.
return false
}
}
if !ok || now.Sub(w.since) > s.cfg.AttemptWindow {
s.attempts[email] = &attemptWindow{count: 1, since: now}
return true
}
w.count++
return w.count <= limit
}
type clientKey struct{}
// WithClient says which client a request comes from — its address — for the
// sign-in throttle.
func WithClient(ctx context.Context, client string) context.Context {
return context.WithValue(ctx, clientKey{}, client)
}
func clientFrom(ctx context.Context) string {
c, _ := ctx.Value(clientKey{}).(string)
return c
}
// maxTrackedAddresses bounds the throttle's memory. Unbounded, it grew by one
// entry per address tried and shrank only on a successful sign-in.
const maxTrackedAddresses = 50000
func (s *Service) clearAttempts(email string) {
s.mu.Lock()
delete(s.attempts, email)
s.mu.Unlock()
}
// validEmail is deliberately crude. A real check is delivery, which this does
// not do; this only rejects what is obviously not an address so the database
// does not fill with them.
func validEmail(s string) bool {
at := strings.IndexByte(s, '@')
if at <= 0 || at != strings.LastIndexByte(s, '@') || at == len(s)-1 {
return false
}
if strings.ContainsAny(s, " \t\r\n<>\"") {
return false
}
return strings.Contains(s[at+1:], ".")
}
func randomID(prefix string) string {
var buf [10]byte
_, _ = rand.Read(buf[:])
return prefix + "-" + strings.ToLower(
base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf[:]))
}
// ErrSamePassword says the new password is the old one.
var ErrSamePassword = errors.New("that is the password you already have")
// PasswordChanger is the extra storage a password change needs.
type PasswordChanger interface {
SetPassword(ctx context.Context, userID, hash string) error
DeleteUserSessions(ctx context.Context, userID string) error
}
// ChangePassword replaces a person's password and signs them out everywhere.
//
// The current password is required even though the caller already holds a
// session. A session is what an attacker gets from a borrowed laptop or a stolen
// cookie; the password is what they do not have, and without this check the
// first thing they would do is take the account.
//
// Every session is closed, this browser's included. Leaving the others open
// would mean a password change does nothing about the reason someone is
// changing it. The caller re-issues a session for the browser in front of them.
func (s *Service) ChangePassword(ctx context.Context, pw PasswordChanger,
u store.User, current, next string,
) error {
if !s.allowAttempt("pw:"+u.ID, s.cfg.MaxAttempts) {
return ErrTooManyTries
}
ok, err := VerifyPassword(current, u.PasswordHash)
if err != nil || !ok {
return ErrBadCredentials
}
s.clearAttempts("pw:" + u.ID)
if same, _ := VerifyPassword(next, u.PasswordHash); same {
return ErrSamePassword
}
hash, err := HashPassword(next)
if err != nil {
return err
}
if err := pw.SetPassword(ctx, u.ID, hash); err != nil {
return err
}
if err := pw.DeleteUserSessions(ctx, u.ID); err != nil {
return err
}
s.log.Info("a password was changed", "user", u.ID)
return nil
}
// OpenSession starts a session for a person who has just proved who they are by
// some other means — immediately after changing their password, which closed
// the one they were using.
func (s *Service) OpenSession(ctx context.Context, userID string) (token, csrf string, err error) {
if token, err = store.NewSessionToken(); err != nil {
return "", "", err
}
if csrf, err = store.NewSessionToken(); err != nil {
return "", "", err
}
if err := s.store.CreateSession(ctx, userID, token, csrf, s.cfg.SessionTTL); err != nil {
return "", "", err
}
return token, csrf, nil
}
// ResetLinkTTL is how long a password reset link works.
const ResetLinkTTL = time.Hour
// ResetStore keeps password reset links.
type ResetStore interface {
UserByEmail(ctx context.Context, email string) (store.User, error)
CreatePasswordReset(ctx context.Context, userID string, ttl time.Duration) (string, error)
PasswordResetUser(ctx context.Context, token string) (string, error)
UsePasswordReset(ctx context.Context, token string) (string, error)
}
// ErrNoReset is an address that gets no reset link: none has an account, the
// account signs in through single sign-on, or it is not active. Never told to
// whoever asked, who learns the same either way.
var ErrNoReset = errors.New("no reset link for that address")
// RequestReset mints a reset link token for the person an address belongs
// to, when they sign in with a password and are active. A few a quarter of
// an hour per address: each sends a mail.
func (s *Service) RequestReset(ctx context.Context, rs ResetStore, email string) (string, store.User, error) {
email = store.NormalizeEmail(email)
if !validEmail(email) {
return "", store.User{}, ErrNoReset
}
if !s.allowAttempt("reset:"+email, 3) {
return "", store.User{}, ErrTooManyTries
}
u, err := rs.UserByEmail(ctx, email)
// An account whose password is kept elsewhere — "!oidc", "!scim",
// "!ldap" — gets no link: setting one here would give it a second way
// in that its directory knows nothing of, and outlives its leaving.
if err != nil || u.PasswordHash == "" || strings.HasPrefix(u.PasswordHash, "!") || u.State != store.UserActive {
return "", store.User{}, ErrNoReset
}
token, err := rs.CreatePasswordReset(ctx, u.ID, ResetLinkTTL)
if err != nil {
return "", store.User{}, err
}
s.log.Info("a password reset link was made", "user", u.ID)
return token, u, nil
}
// ResetPassword sets a password from a reset link, which is spent, and signs
// the person out everywhere: if they lost it, someone else may have it.
func (s *Service) ResetPassword(ctx context.Context, rs ResetStore, pw PasswordChanger, token, next string) error {
hash, err := HashPassword(next)
if err != nil {
return err
}
userID, err := rs.UsePasswordReset(ctx, token)
if err != nil {
return ErrNoReset
}
if err := pw.SetPassword(ctx, userID, hash); err != nil {
return err
}
if err := pw.DeleteUserSessions(ctx, userID); err != nil {
return err
}
s.log.Info("a password was set from a reset link", "user", userID)
return nil
}