Files
2026-05-24 17:15:48 +00:00

286 lines
7.6 KiB
Go

// Package auth provides session management and brute-force protection.
package auth
import (
"context"
"database/sql"
"fmt"
"net"
"net/http"
"strings"
"time"
"ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto"
"ghb.freebede.com/nahakubuilder/mailgosend/internal/db"
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
)
const (
sessionCookieName = "mgs_session"
sessionPurpose = "session"
)
// SessionStore manages user sessions stored in the database.
type SessionStore struct {
db *db.DB
maxAge int // seconds
secureCookie bool
}
// NewSessionStore creates a session store.
func NewSessionStore(database *db.DB, maxAge int, secureCookie bool) *SessionStore {
return &SessionStore{
db: database,
maxAge: maxAge,
secureCookie: secureCookie,
}
}
// Create creates a new session for userID, sets the cookie, and returns the session.
func (s *SessionStore) Create(w http.ResponseWriter, r *http.Request, userID int64) (*models.Session, error) {
raw, hash, err := crypto.NewToken()
if err != nil {
return nil, fmt.Errorf("session token: %w", err)
}
now := time.Now().UTC()
expires := now.Add(time.Duration(s.maxAge) * time.Second)
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
_, err = s.db.SQL().ExecContext(ctx, `
INSERT INTO sessions (user_id, token_hash, ip, user_agent, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)`,
userID,
hash,
realIP(r),
truncate(r.UserAgent(), 512),
now,
expires,
)
if err != nil {
return nil, fmt.Errorf("insert session: %w", err)
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: raw,
Path: "/",
MaxAge: s.maxAge,
HttpOnly: true,
Secure: s.secureCookie,
SameSite: http.SameSiteStrictMode,
})
return &models.Session{
UserID: userID,
TokenHash: hash,
IP: realIP(r),
CreatedAt: now,
ExpiresAt: expires,
}, nil
}
// Get validates the session cookie and returns the session + user.
// Returns (nil, nil, nil) if no valid session exists.
func (s *SessionStore) Get(r *http.Request) (*models.Session, *models.User, error) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
return nil, nil, nil // no cookie = not logged in
}
raw := cookie.Value
if len(raw) == 0 {
return nil, nil, nil
}
hash := crypto.HashToken(raw)
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
row := s.db.SQL().QueryRowContext(ctx, `
SELECT s.id, s.user_id, s.token_hash, s.ip, s.user_agent, s.created_at, s.expires_at,
u.id, u.domain_id, u.username, u.email, u.display_name,
u.quota_bytes, u.used_bytes, u.enabled, u.admin, u.domain_admin,
u.mfa_enabled, u.created_at, u.last_login
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ?
AND s.expires_at > ?
AND u.enabled = 1`,
hash, time.Now().UTC())
var sess models.Session
var user models.User
var lastLogin sql.NullTime
var createdAt sql.NullTime
err = row.Scan(
&sess.ID, &sess.UserID, &sess.TokenHash, &sess.IP, &sess.UserAgent,
&sess.CreatedAt, &sess.ExpiresAt,
&user.ID, &user.DomainID, &user.Username, &user.Email, &user.DisplayName,
&user.QuotaBytes, &user.UsedBytes, &user.Enabled, &user.Admin, &user.DomainAdmin,
&user.MFAEnabled, &createdAt, &lastLogin,
)
if err == sql.ErrNoRows {
return nil, nil, nil
}
if err != nil {
return nil, nil, fmt.Errorf("session lookup: %w", err)
}
if createdAt.Valid {
user.CreatedAt = createdAt.Time
}
if lastLogin.Valid {
user.LastLogin = lastLogin.Time
}
return &sess, &user, nil
}
// Destroy deletes the session from the database and clears the cookie.
func (s *SessionStore) Destroy(w http.ResponseWriter, r *http.Request) error {
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
return nil // no session to destroy
}
hash := crypto.HashToken(cookie.Value)
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
_, err = s.db.SQL().ExecContext(ctx,
"DELETE FROM sessions WHERE token_hash = ?", hash)
if err != nil {
return fmt.Errorf("delete session: %w", err)
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: s.secureCookie,
SameSite: http.SameSiteStrictMode,
})
return nil
}
// DestroyAll deletes all sessions for a user (logout everywhere).
func (s *SessionStore) DestroyAll(ctx context.Context, userID int64) error {
_, err := s.db.SQL().ExecContext(ctx,
"DELETE FROM sessions WHERE user_id = ?", userID)
return err
}
// PurgeExpired deletes all expired sessions. Call periodically (e.g. hourly).
func (s *SessionStore) PurgeExpired(ctx context.Context) error {
_, err := s.db.SQL().ExecContext(ctx,
"DELETE FROM sessions WHERE expires_at <= ?", time.Now().UTC())
return err
}
// UpdateLastLogin updates the user's last_login timestamp.
func UpdateLastLogin(ctx context.Context, database *db.DB, userID int64) {
database.SQL().ExecContext(ctx, // nolint: errcheck — best effort
"UPDATE users SET last_login = ? WHERE id = ?",
time.Now().UTC(), userID)
}
// ---- User lookup ----
// GetUserByEmail returns the user matching email (case-insensitive), or nil.
func GetUserByEmail(ctx context.Context, database *db.DB, email string) (*models.User, error) {
row := database.SQL().QueryRowContext(ctx, `
SELECT id, domain_id, username, email, password_hash, display_name,
quota_bytes, used_bytes, enabled, admin, domain_admin,
mfa_secret_enc, mfa_enabled, recovery_codes_enc,
created_at, last_login
FROM users
WHERE lower(email) = lower(?)`, email)
return scanUser(row)
}
// GetUserByID returns the user by ID, or nil.
func GetUserByID(ctx context.Context, database *db.DB, id int64) (*models.User, error) {
row := database.SQL().QueryRowContext(ctx, `
SELECT id, domain_id, username, email, password_hash, display_name,
quota_bytes, used_bytes, enabled, admin, domain_admin,
mfa_secret_enc, mfa_enabled, recovery_codes_enc,
created_at, last_login
FROM users
WHERE id = ?`, id)
return scanUser(row)
}
func scanUser(row *sql.Row) (*models.User, error) {
var u models.User
var mfaSecretEnc, recoveryCodesEnc []byte
var lastLogin sql.NullTime
err := row.Scan(
&u.ID, &u.DomainID, &u.Username, &u.Email, &u.PasswordHash,
&u.DisplayName, &u.QuotaBytes, &u.UsedBytes, &u.Enabled,
&u.Admin, &u.DomainAdmin,
&mfaSecretEnc, &u.MFAEnabled, &recoveryCodesEnc,
&u.CreatedAt, &lastLogin,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan user: %w", err)
}
u.MFASecretEnc = mfaSecretEnc
u.RecoveryCodesEnc = recoveryCodesEnc
if lastLogin.Valid {
u.LastLogin = lastLogin.Time
}
return &u, nil
}
// ---- Helpers ----
func realIP(r *http.Request) string {
if xri := r.Header.Get("X-Real-IP"); xri != "" {
if parsed := net.ParseIP(strings.TrimSpace(xri)); parsed != nil {
return parsed.String()
}
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
for _, part := range strings.Split(xff, ",") {
trimmed := strings.TrimSpace(part)
if parsed := net.ParseIP(trimmed); parsed != nil {
return parsed.String()
}
}
}
host, _, err := parseHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
func parseHostPort(addr string) (string, string, error) {
// net.SplitHostPort returns error for bare IPs without port.
for i := len(addr) - 1; i >= 0; i-- {
if addr[i] == ':' {
return addr[:i], addr[i+1:], nil
}
}
return addr, "", fmt.Errorf("no port")
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max]
}