internal/auth/session.go (view raw)
1package auth
2
3import (
4 "context"
5 "crypto/rand"
6 "database/sql"
7 "encoding/hex"
8 "errors"
9 "fmt"
10 "time"
11
12 "github.com/mjensen/weimar/internal/db"
13 "golang.org/x/crypto/bcrypt"
14)
15
16var (
17 ErrInvalidCredentials = errors.New("invalid username or password")
18 ErrSessionExpired = errors.New("session expired")
19 ErrSessionNotFound = errors.New("session not found")
20)
21
22const (
23 SessionCookieName = "weimar_session"
24 SessionDuration = 30 * 24 * time.Hour
25 tokenBytes = 32
26)
27
28// SessionManager handles login, logout, and session validation.
29type SessionManager struct {
30 db *db.DB
31}
32
33// NewSessionManager creates a SessionManager backed by the given database.
34func NewSessionManager(database *db.DB) *SessionManager {
35 return &SessionManager{db: database}
36}
37
38// Login verifies credentials and creates a session. Returns the user and
39// a 64-character hex session token.
40func (sm *SessionManager) Login(ctx context.Context, username, password string) (*db.User, string, error) {
41 user, err := sm.db.GetUserByUsername(ctx, username)
42 if err != nil {
43 if errors.Is(err, sql.ErrNoRows) {
44 return nil, "", ErrInvalidCredentials
45 }
46 return nil, "", fmt.Errorf("lookup user: %w", err)
47 }
48
49 if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
50 return nil, "", ErrInvalidCredentials
51 }
52
53 token, err := sm.createSession(ctx, user.ID)
54 if err != nil {
55 return nil, "", err
56 }
57
58 return user, token, nil
59}
60
61// Authenticate validates a session token and returns the associated user.
62// On success the session expiry is extended by SessionDuration.
63func (sm *SessionManager) Authenticate(ctx context.Context, token string) (*db.User, error) {
64 sess, err := sm.db.GetSessionByToken(ctx, token)
65 if err != nil {
66 if errors.Is(err, sql.ErrNoRows) {
67 return nil, ErrSessionNotFound
68 }
69 return nil, fmt.Errorf("lookup session: %w", err)
70 }
71
72 if time.Now().After(sess.ExpiresAt) {
73 sm.db.DeleteSession(ctx, token)
74 return nil, ErrSessionExpired
75 }
76
77 // Extend session on each authenticated request.
78 newExpiry := time.Now().UTC().Add(SessionDuration)
79 if err := sm.db.ExtendSession(ctx, token, newExpiry); err != nil {
80 return nil, fmt.Errorf("extend session: %w", err)
81 }
82
83 user, err := sm.db.GetUserByID(ctx, sess.UserID)
84 if err != nil {
85 return nil, fmt.Errorf("lookup user: %w", err)
86 }
87 return user, nil
88}
89
90// Logout deletes the session identified by the given token.
91func (sm *SessionManager) Logout(ctx context.Context, token string) error {
92 return sm.db.DeleteSession(ctx, token)
93}
94
95func (sm *SessionManager) createSession(ctx context.Context, userID int64) (string, error) {
96 token, err := generateToken()
97 if err != nil {
98 return "", err
99 }
100 expiresAt := time.Now().UTC().Add(SessionDuration)
101 if _, err := sm.db.CreateSession(ctx, userID, token, expiresAt); err != nil {
102 return "", fmt.Errorf("create session: %w", err)
103 }
104 return token, nil
105}
106
107func generateToken() (string, error) {
108 buf := make([]byte, tokenBytes)
109 if _, err := rand.Read(buf); err != nil {
110 return "", fmt.Errorf("generate token: %w", err)
111 }
112 return hex.EncodeToString(buf), nil
113}