Files
llmgw/internal/account/service.go
T
Ilker ManapandClaude Opus 5.5 66f702d5c1 A forgotten password is set through a one-time link
/forgot mails a link to set a new password when the cluster's mail settings
say how - SMTP relay, sender, and the public base_url the link points at,
never the request's Host - and says the same whether or not the address has
an account. /reset sets it once, closing every session. Links are kept by
their hash in password_resets, good for an hour, a newer one replacing an
older. Without mail, /forgot says to ask an operator, and llmgw users
reset-link makes the link to hand over. Single sign-on accounts get none.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 13:20:11 +02:00

436 lines
14 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
}
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) {
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)
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
}
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)
if err != nil || 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
}