Files
synapbus/internal/auth/session_store.go
T
Algis DumbrisandClaude Opus 4.6 8a1c096355 feat: implement human auth with OAuth 2.1 (fosite)
Add complete auth subsystem with OAuth 2.1 authorization server using
ory/fosite, local user accounts with bcrypt password hashing, session
management, and HTTP handlers for the Web UI.

Components:
- User store with bcrypt hashing (configurable cost, default 12), CRUD,
  validation (username 3-64 chars alphanumeric+underscore, password 8-72 bytes)
- Session store with secure random IDs, configurable lifetime (default 24h),
  expiration cleanup, and per-user invalidation
- OAuth client store with client_id/secret generation and bcrypt verification
- Fosite storage adapter implementing CoreStorage, TokenRevocationStorage,
  and PKCERequestStorage backed by SQLite
- OAuth provider configured with authorization code (PKCE S256 mandatory),
  client credentials, refresh token rotation, and token introspection
- HTTP handlers: POST /auth/register, POST /auth/login, POST /auth/logout,
  GET /auth/me, PUT /auth/password, GET /oauth/authorize, POST /oauth/token,
  POST /oauth/introspect
- Middleware: RequireSession (cookie), RequireBearer (access token),
  RequireAuth (either), RequireAdmin (role check)
- Structured auth event logging (login, token issuance, session lifecycle)
- Schema migration 002_auth.sql extending users, oauth_clients, oauth_tokens
  tables and adding sessions, oauth_authorization_codes tables
- Initial admin user auto-created on first run with random password printed
  to stdout
- All tests pass with CGO_ENABLED=0, zero external runtime dependencies

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:59:37 +02:00

133 lines
3.9 KiB
Go

package auth
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"time"
)
// SessionStore defines the storage interface for session operations.
type SessionStore interface {
CreateSession(ctx context.Context, userID int64, lifetime time.Duration) (*Session, error)
GetSession(ctx context.Context, sessionID string) (*Session, error)
DeleteSession(ctx context.Context, sessionID string) error
DeleteSessionsByUser(ctx context.Context, userID int64) error
DeleteSessionsByUserExcept(ctx context.Context, userID int64, exceptSessionID string) error
CleanupExpired(ctx context.Context) (int64, error)
}
// SQLiteSessionStore implements SessionStore using SQLite.
type SQLiteSessionStore struct {
db *sql.DB
}
// NewSQLiteSessionStore creates a new SQLite-backed session store.
func NewSQLiteSessionStore(db *sql.DB) *SQLiteSessionStore {
return &SQLiteSessionStore{db: db}
}
// CreateSession creates a new session with a cryptographically random session ID.
func (s *SQLiteSessionStore) CreateSession(ctx context.Context, userID int64, lifetime time.Duration) (*Session, error) {
sessionID, err := generateSessionID()
if err != nil {
return nil, fmt.Errorf("generate session id: %w", err)
}
now := time.Now()
expiresAt := now.Add(lifetime)
_, err = s.db.ExecContext(ctx,
`INSERT INTO sessions (session_id, user_id, created_at, expires_at, last_active_at)
VALUES (?, ?, ?, ?, ?)`,
sessionID, userID, now, expiresAt, now,
)
if err != nil {
return nil, fmt.Errorf("insert session: %w", err)
}
return &Session{
SessionID: sessionID,
UserID: userID,
CreatedAt: now,
ExpiresAt: expiresAt,
LastActiveAt: now,
}, nil
}
// GetSession retrieves a session by its ID. Returns ErrSessionExpired if the session has expired.
func (s *SQLiteSessionStore) GetSession(ctx context.Context, sessionID string) (*Session, error) {
session := &Session{}
err := s.db.QueryRowContext(ctx,
`SELECT session_id, user_id, created_at, expires_at, last_active_at
FROM sessions WHERE session_id = ?`, sessionID,
).Scan(&session.SessionID, &session.UserID, &session.CreatedAt,
&session.ExpiresAt, &session.LastActiveAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, ErrSessionNotFound
}
return nil, fmt.Errorf("query session: %w", err)
}
if time.Now().After(session.ExpiresAt) {
// Clean up the expired session
s.DeleteSession(ctx, sessionID)
return nil, ErrSessionExpired
}
// Update last_active_at
s.db.ExecContext(ctx,
`UPDATE sessions SET last_active_at = CURRENT_TIMESTAMP WHERE session_id = ?`,
sessionID,
)
return session, nil
}
// DeleteSession removes a session.
func (s *SQLiteSessionStore) DeleteSession(ctx context.Context, sessionID string) error {
_, err := s.db.ExecContext(ctx,
"DELETE FROM sessions WHERE session_id = ?", sessionID,
)
return err
}
// DeleteSessionsByUser removes all sessions for a user.
func (s *SQLiteSessionStore) DeleteSessionsByUser(ctx context.Context, userID int64) error {
_, err := s.db.ExecContext(ctx,
"DELETE FROM sessions WHERE user_id = ?", userID,
)
return err
}
// DeleteSessionsByUserExcept removes all sessions for a user except the specified one.
func (s *SQLiteSessionStore) DeleteSessionsByUserExcept(ctx context.Context, userID int64, exceptSessionID string) error {
_, err := s.db.ExecContext(ctx,
"DELETE FROM sessions WHERE user_id = ? AND session_id != ?", userID, exceptSessionID,
)
return err
}
// CleanupExpired removes all expired sessions and returns the count removed.
func (s *SQLiteSessionStore) CleanupExpired(ctx context.Context) (int64, error) {
result, err := s.db.ExecContext(ctx,
"DELETE FROM sessions WHERE expires_at < ?", time.Now(),
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
// generateSessionID creates a cryptographically random session identifier.
func generateSessionID() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}