package auth import ( "context" "crypto/rand" "database/sql" "encoding/hex" "errors" "fmt" "time" "github.com/mjensen/weimar/internal/db" "golang.org/x/crypto/bcrypt" ) var ( ErrInvalidCredentials = errors.New("invalid username or password") ErrSessionExpired = errors.New("session expired") ErrSessionNotFound = errors.New("session not found") ) const ( SessionCookieName = "weimar_session" SessionDuration = 30 * 24 * time.Hour tokenBytes = 32 ) // SessionManager handles login, logout, and session validation. type SessionManager struct { db *db.DB } // NewSessionManager creates a SessionManager backed by the given database. func NewSessionManager(database *db.DB) *SessionManager { return &SessionManager{db: database} } // Login verifies credentials and creates a session. Returns the user and // a 64-character hex session token. func (sm *SessionManager) Login(ctx context.Context, username, password string) (*db.User, string, error) { user, err := sm.db.GetUserByUsername(ctx, username) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, "", ErrInvalidCredentials } return nil, "", fmt.Errorf("lookup user: %w", err) } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { return nil, "", ErrInvalidCredentials } token, err := sm.createSession(ctx, user.ID) if err != nil { return nil, "", err } return user, token, nil } // Authenticate validates a session token and returns the associated user. // On success the session expiry is extended by SessionDuration. func (sm *SessionManager) Authenticate(ctx context.Context, token string) (*db.User, error) { sess, err := sm.db.GetSessionByToken(ctx, token) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrSessionNotFound } return nil, fmt.Errorf("lookup session: %w", err) } if time.Now().After(sess.ExpiresAt) { sm.db.DeleteSession(ctx, token) return nil, ErrSessionExpired } // Extend session on each authenticated request. newExpiry := time.Now().UTC().Add(SessionDuration) if err := sm.db.ExtendSession(ctx, token, newExpiry); err != nil { return nil, fmt.Errorf("extend session: %w", err) } user, err := sm.db.GetUserByID(ctx, sess.UserID) if err != nil { return nil, fmt.Errorf("lookup user: %w", err) } return user, nil } // Logout deletes the session identified by the given token. func (sm *SessionManager) Logout(ctx context.Context, token string) error { return sm.db.DeleteSession(ctx, token) } func (sm *SessionManager) createSession(ctx context.Context, userID int64) (string, error) { token, err := generateToken() if err != nil { return "", err } expiresAt := time.Now().UTC().Add(SessionDuration) if _, err := sm.db.CreateSession(ctx, userID, token, expiresAt); err != nil { return "", fmt.Errorf("create session: %w", err) } return token, nil } func generateToken() (string, error) { buf := make([]byte, tokenBytes) if _, err := rand.Read(buf); err != nil { return "", fmt.Errorf("generate token: %w", err) } return hex.EncodeToString(buf), nil }