NextMyDay/internal/core/auth/sessions.go
Claus Lohmar 76a4a6950c feat(auth): add WebAuthn, TOTP and the enroll/login flow (SPEC-004/006)
- webauthn: the passkey ceremony service (a fake in tests)
- totp: the TOTP authenticator; the secret is AEAD-sealed at rest (A9)
- sealer: the AEAD TOTP sealer
- identities/sessions/credentials: the GUID state machine
- flow: the enroll/login screens + the passkey nudge (pre-session
  double-submit CSRF — A15), rendered inside the shell
2026-10-14 10:53:53 +00:00

197 lines
5.7 KiB
Go

package auth
import (
"context"
"errors"
"fmt"
"time"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
)
// SessionsService implements Sessions.
type SessionsService struct {
*Core
}
var _ Sessions = (*SessionsService)(nil)
// Mint implements Sessions.Mint (SPEC-004 §6): issue a session for an ACTIVE
// identity. A pending or revoked identity gets no session.
func (s *SessionsService) Mint(ctx context.Context, identityID int64, ip, ua string) (Session, error) {
idn, err := s.identityByID(identityID)
if err != nil {
return Session{}, err
}
if idn.Status != StatusActive {
return Session{}, fmt.Errorf("%w: identity:%d is %s", ErrIdentityNotActive, identityID, idn.Status)
}
sid, err := newSessionID()
if err != nil {
return Session{}, err
}
now := s.clock()
sess := Session{
ID: sid,
IdentityID: identityID,
UserID: idn.UserID,
CreatedAt: now,
ExpiresAt: now.Add(s.sessionTTL),
ClientIP: ip,
UserAgent: ua,
}
if _, err := s.db.Exec(
`INSERT INTO sessions
(session_id, identity_id, user_id, created_at, expires_at, client_ip, user_agent)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
sess.ID, sess.IdentityID, sess.UserID,
store.FormatTime(sess.CreatedAt), store.FormatTime(sess.ExpiresAt),
nullString(ip), nullString(ua),
); err != nil {
return Session{}, fmt.Errorf("auth: insert session: %w", err)
}
s.record(ctx, audit.Event{
ActorUserID: idn.UserID,
Action: "session.mint",
Subject: fmt.Sprintf("identity:%d", identityID),
Detail: "expires_at=" + store.FormatTime(sess.ExpiresAt),
ClientIP: ip,
})
return sess, nil
}
// Validate implements Sessions.Validate (A2). An empty id is rejected outright;
// a missing, revoked or expired session is an error.
func (s *SessionsService) Validate(ctx context.Context, sessionID string) (Session, error) {
if sessionID == "" {
return Session{}, ErrEmptySessionID
}
row := s.db.QueryRow(`
SELECT session_id, identity_id, user_id, created_at, expires_at, revoked_at, client_ip, user_agent
FROM sessions WHERE session_id = ?`, sessionID)
sess, err := scanSession(row)
if err != nil {
return Session{}, err
}
if sess.RevokedAt != nil {
return Session{}, fmt.Errorf("%w: %s", ErrSessionRevoked, sessionID)
}
if !sess.ExpiresAt.After(s.clock()) {
return Session{}, fmt.Errorf("%w: %s", ErrSessionExpired, sessionID)
}
return sess, nil
}
// Rotate implements Sessions.Rotate (fixation defence, SPEC-006 §5): the old
// session is revoked and a fresh one issued for the same identity.
func (s *SessionsService) Rotate(ctx context.Context, sessionID string) (Session, error) {
old, err := s.Validate(ctx, sessionID)
if err != nil {
return Session{}, err
}
if _, err := s.db.Exec(
`UPDATE sessions SET revoked_at = ? WHERE session_id = ? AND revoked_at IS NULL`,
store.FormatTime(s.clock()), sessionID,
); err != nil {
return Session{}, fmt.Errorf("auth: revoke on rotate: %w", err)
}
sess, err := s.Mint(ctx, old.IdentityID, old.ClientIP, old.UserAgent)
if err != nil {
return Session{}, err
}
s.record(ctx, audit.Event{
ActorUserID: old.UserID,
Action: "session.rotate",
Subject: fmt.Sprintf("identity:%d", old.IdentityID),
Detail: "session rotated on login",
ClientIP: old.ClientIP,
})
return sess, nil
}
// Revoke implements Sessions.Revoke (logout). It is idempotent for a session
// that is already revoked.
func (s *SessionsService) Revoke(ctx context.Context, sessionID string) error {
if sessionID == "" {
return ErrEmptySessionID
}
sess, err := s.lookupSession(sessionID)
if err != nil {
return err
}
if sess.RevokedAt != nil {
return nil
}
if _, err := s.db.Exec(
`UPDATE sessions SET revoked_at = ? WHERE session_id = ?`,
store.FormatTime(s.clock()), sessionID,
); err != nil {
return fmt.Errorf("auth: revoke session: %w", err)
}
s.record(ctx, audit.Event{
ActorUserID: sess.UserID,
Action: "session.revoke",
Subject: fmt.Sprintf("identity:%d", sess.IdentityID),
Detail: "logout",
ClientIP: sess.ClientIP,
})
return nil
}
// RevokeAll implements Sessions.RevokeAll (D-007): kill every live session of
// an identity.
func (s *SessionsService) RevokeAll(ctx context.Context, identityID int64) error {
res, err := s.db.Exec(
`UPDATE sessions SET revoked_at = ? WHERE identity_id = ? AND revoked_at IS NULL`,
store.FormatTime(s.clock()), identityID,
)
if err != nil {
return fmt.Errorf("auth: revoke all: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return nil
}
s.record(ctx, audit.Event{
Action: "session.revoke_all",
Subject: fmt.Sprintf("identity:%d", identityID),
Detail: fmt.Sprintf("revoked=%d", n),
})
return nil
}
// GC implements Sessions.GC (SPEC-004 §6): delete expired rows only. It never
// removes a still-valid session — "never the principal's last valid session".
func (s *SessionsService) GC(ctx context.Context, now time.Time) (int, error) {
res, err := s.db.Exec(
`DELETE FROM sessions WHERE expires_at <= ?`,
store.FormatTime(now),
)
if err != nil {
return 0, fmt.Errorf("auth: gc: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("auth: gc rows affected: %w", err)
}
return int(n), nil
}
// lookupSession loads a session by id without validating it (used by Revoke,
// which must tolerate a revoked row).
func (s *SessionsService) lookupSession(sessionID string) (Session, error) {
row := s.db.QueryRow(`
SELECT session_id, identity_id, user_id, created_at, expires_at, revoked_at, client_ip, user_agent
FROM sessions WHERE session_id = ?`, sessionID)
sess, err := scanSession(row)
if errors.Is(err, ErrSessionNotFound) {
return Session{}, fmt.Errorf("%w: %s", ErrSessionNotFound, sessionID)
}
return sess, err
}