- 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
197 lines
5.7 KiB
Go
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
|
|
}
|