/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>
436 lines
14 KiB
Go
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
|
|
}
|