package service import ( "context" "errors" "log/slog" "strings" "time" "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" ) // maxPasswordLen caps accepted password length. It bounds argon2 input and // request work, and sits far above any real password. const maxPasswordLen = 1024 // RegisterInput is the payload for local self-service signup. type RegisterInput struct { Email string DisplayName string Password string } // Providers reports which login methods the server offers, so the login page // (#6) can render the right controls. OIDCLabel is only meaningful when OIDC is // true. type Providers struct { Local bool `json:"local"` OIDC bool `json:"oidc"` OIDCLabel string `json:"oidcLabel"` } // Providers returns the enabled auth methods. OIDC is always false until #5 // wires the endpoints; advertising it before then would point the UI at routes // that don't exist. func (s *Service) Providers() Providers { return Providers{ Local: s.cfg.LocalAuth, OIDC: false, OIDCLabel: s.cfg.OIDC.ButtonLabel, } } // Register creates a local (password) account and returns it. The first user on // a fresh instance becomes admin and may always register (bootstrap), even when // PANSY_REGISTRATION=closed; afterward, closed registration is enforced. func (s *Service) Register(ctx context.Context, in RegisterInput) (*domain.User, error) { if !s.cfg.LocalAuth { return nil, domain.ErrLocalAuthDisabled } email := normalizeEmail(in.Email) displayName := strings.TrimSpace(in.DisplayName) if email == "" || displayName == "" || in.Password == "" || len(in.Password) > maxPasswordLen { return nil, domain.ErrInvalidInput } // Cheap best-effort gate so a closed instance doesn't burn argon2 work on // signups it will reject anyway. The authoritative, race-free gate — plus // atomic first-user-is-admin assignment and duplicate-email detection — lives // in store.CreateUser's single INSERT. if !s.cfg.RegistrationOpen() { n, err := s.store.CountUsers(ctx) if err != nil { return nil, err } if n > 0 { return nil, domain.ErrRegistrationClosed } } hash, err := hashPassword(in.Password) if err != nil { return nil, err } return s.store.CreateUser(ctx, &domain.User{ Email: email, DisplayName: displayName, PasswordHash: &hash, }, s.cfg.RegistrationOpen()) } // Login verifies an email/password pair and returns the user. Unknown-email and // wrong-password both return domain.ErrInvalidCredentials, and both spend the // same argon2 work, so neither the error nor the timing reveals which failed. func (s *Service) Login(ctx context.Context, email, password string) (*domain.User, error) { if !s.cfg.LocalAuth { return nil, domain.ErrLocalAuthDisabled } if len(password) > maxPasswordLen { // No stored password is this long; reject without spending argon2 work. return nil, domain.ErrInvalidCredentials } u, err := s.store.GetUserByEmail(ctx, normalizeEmail(email)) switch { case errors.Is(err, domain.ErrNotFound): // Spend comparable time so timing can't distinguish a missing account. _, _ = verifyPassword(s.dummyHash, password) return nil, domain.ErrInvalidCredentials case err != nil: return nil, err case u.PasswordHash == nil: // OIDC-only account: no local password to check, but equalize timing. _, _ = verifyPassword(s.dummyHash, password) return nil, domain.ErrInvalidCredentials } ok, err := verifyPassword(*u.PasswordHash, password) if err != nil { // A stored hash we can't parse is a data problem, not a wrong password; // surface it so a corrupt row doesn't silently lock a user out unnoticed. slog.Error("service: malformed stored password hash", "user_id", u.ID, "error", err) return nil, domain.ErrInvalidCredentials } if !ok { return nil, domain.ErrInvalidCredentials } return u, nil } // CreateSession issues a new session for a user and returns the raw bearer token // (to place in the cookie) and its expiry. func (s *Service) CreateSession(ctx context.Context, userID int64) (token string, expiresAt time.Time, err error) { raw, err := newSessionToken() if err != nil { return "", time.Time{}, err } exp := s.now().Add(sessionTTL) if err := s.store.CreateSession(ctx, &domain.Session{ TokenHash: hashToken(raw), UserID: userID, ExpiresAt: formatTime(exp), }); err != nil { return "", time.Time{}, err } return raw, exp, nil } // ResolveSession validates a raw bearer token and returns its user and the // session's current expiry (so the caller can slide the client cookie in // lockstep with the server). An expired session is deleted and treated as absent // (domain.ErrNotFound). A still-valid session has its expiry slid forward, but // only when that moves it by more than an hour, so a busy client doesn't write // on every request. func (s *Service) ResolveSession(ctx context.Context, rawToken string) (*domain.User, time.Time, error) { if rawToken == "" { return nil, time.Time{}, domain.ErrNotFound } hash := hashToken(rawToken) sess, err := s.store.GetSession(ctx, hash) if err != nil { return nil, time.Time{}, err } exp, err := parseTime(sess.ExpiresAt) if err != nil { // A corrupt expiry means we can't trust the session; drop it. if delErr := s.store.DeleteSession(ctx, hash); delErr != nil { slog.Warn("service: deleting session with corrupt expiry", "error", delErr) } return nil, time.Time{}, domain.ErrNotFound } now := s.now() if !now.Before(exp) { if delErr := s.store.DeleteSession(ctx, hash); delErr != nil { slog.Warn("service: deleting expired session", "error", delErr) } return nil, time.Time{}, domain.ErrNotFound } if newExp := now.Add(sessionTTL); newExp.Sub(exp) > time.Hour { if err := s.store.TouchSession(ctx, hash, formatTime(newExp)); err != nil { // Non-fatal: the session is still valid at its current expiry. slog.Warn("service: sliding session expiry", "error", err) } else { exp = newExp } } user, err := s.store.GetUserByID(ctx, sess.UserID) if err != nil { return nil, time.Time{}, err } return user, exp, nil } // Logout deletes the session behind a raw bearer token. It is idempotent. func (s *Service) Logout(ctx context.Context, rawToken string) error { if rawToken == "" { return nil } return s.store.DeleteSession(ctx, hashToken(rawToken)) } // CleanupExpiredSessions deletes all sessions that have passed their expiry and // returns the count. Called periodically; expired sessions are also dropped // lazily on access by ResolveSession. func (s *Service) CleanupExpiredSessions(ctx context.Context) (int64, error) { return s.store.DeleteExpiredSessions(ctx, formatTime(s.now())) } // normalizeEmail trims and lowercases an email for consistent storage and // lookup. (The users.email column is also NOCASE, so lookups are robust either // way; normalizing keeps stored values tidy.) func normalizeEmail(email string) string { return strings.ToLower(strings.TrimSpace(email)) }