package service import ( "context" "errors" "strings" "time" "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" ) // 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 == "" { return nil, domain.ErrInvalidInput } count, err := s.store.CountUsers(ctx) if err != nil { return nil, err } // Closed registration still allows the very first account so a locked-down // instance can be bootstrapped without editing config. if count > 0 && !s.cfg.RegistrationOpen() { return nil, domain.ErrRegistrationClosed } // Friendly duplicate check. The UNIQUE index is the real guard against a // race; that path surfaces as a generic insert error (500), acceptable at // household scale. if _, err := s.store.GetUserByEmail(ctx, email); err == nil { return nil, domain.ErrEmailTaken } else if !errors.Is(err, domain.ErrNotFound) { return nil, err } hash, err := hashPassword(in.Password) if err != nil { return nil, err } return s.store.CreateUser(ctx, &domain.User{ Email: email, DisplayName: displayName, PasswordHash: &hash, IsAdmin: count == 0, }) } // 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 } 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 || !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. 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, error) { if rawToken == "" { return nil, domain.ErrNotFound } hash := hashToken(rawToken) sess, err := s.store.GetSession(ctx, hash) if err != nil { return nil, err } exp, err := parseTime(sess.ExpiresAt) if err != nil { // A corrupt expiry means we can't trust the session; drop it. _ = s.store.DeleteSession(ctx, hash) return nil, domain.ErrNotFound } now := s.now() if !now.Before(exp) { _ = s.store.DeleteSession(ctx, hash) return nil, domain.ErrNotFound } if newExp := now.Add(sessionTTL); newExp.Sub(exp) > time.Hour { _ = s.store.TouchSession(ctx, hash, formatTime(newExp)) } return s.store.GetUserByID(ctx, sess.UserID) } // 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)) }