all repos — weimar @ link-previews

Unnamed repository; edit this file 'description' to name the repository.

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	"codeberg.org/maxwelljensen/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}