// Package auth implements session-based authentication with secure cookies. package auth import ( "context" "crypto/rand" "database/sql" "encoding/hex" "errors" "net/http" "time" "vibetree/internal/models" "vibetree/internal/users" ) const ( CookieName = "vibetree_session" SessionTTL = 7 * 24 * time.Hour contextUserK = ctxKey("current_user") ) type ctxKey string var ErrNoSession = errors.New("no valid session") type Store struct { db *sql.DB users *users.Store } func NewStore(db *sql.DB, u *users.Store) *Store { return &Store{db: db, users: u} } func newToken() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil } // CreateSession issues a new session for the given user and returns the // opaque session token to be stored in a cookie. func (s *Store) CreateSession(ctx context.Context, userID int64) (string, time.Time, error) { token, err := newToken() if err != nil { return "", time.Time{}, err } expires := time.Now().Add(SessionTTL) _, err = s.db.ExecContext(ctx, `INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)`, token, userID, expires.UTC().Format("2006-01-02T15:04:05.000Z")) if err != nil { return "", time.Time{}, err } return token, expires, nil } func (s *Store) DestroySession(ctx context.Context, token string) error { _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, token) return err } // UserForToken resolves a session token to the current, enabled user. Expired // sessions are treated as invalid (and lazily cleaned up). func (s *Store) UserForToken(ctx context.Context, token string) (*models.User, error) { if token == "" { return nil, ErrNoSession } var userID int64 var expiresAt string err := s.db.QueryRowContext(ctx, `SELECT user_id, expires_at FROM sessions WHERE id = ?`, token).Scan(&userID, &expiresAt) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNoSession } if err != nil { return nil, err } exp, _ := time.Parse("2006-01-02T15:04:05.000Z", expiresAt) if time.Now().After(exp) { _, _ = s.db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, token) return nil, ErrNoSession } u, err := s.users.Get(ctx, userID) if err != nil { return nil, ErrNoSession } if !u.Enabled { return nil, ErrNoSession } return u, nil } // CleanExpired removes expired sessions; safe to call periodically. func (s *Store) CleanExpired(ctx context.Context) error { _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < ?`, time.Now().UTC().Format("2006-01-02T15:04:05.000Z")) return err } func SetCookie(w http.ResponseWriter, token string, expires time.Time, secure bool) { http.SetCookie(w, &http.Cookie{ Name: CookieName, Value: token, Path: "/", Expires: expires, HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, }) } func ClearCookie(w http.ResponseWriter, secure bool) { http.SetCookie(w, &http.Cookie{ Name: CookieName, Value: "", Path: "/", Expires: time.Unix(0, 0), MaxAge: -1, HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, }) } // WithUser stores the current user in the request context. func WithUser(ctx context.Context, u *models.User) context.Context { return context.WithValue(ctx, contextUserK, u) } // UserFromContext returns the current authenticated user, if any. func UserFromContext(ctx context.Context) (*models.User, bool) { u, ok := ctx.Value(contextUserK).(*models.User) return u, ok && u != nil }