286 lines
7.6 KiB
Go
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]
|
|
}
|