package auth import ( "context" "github.com/gofiber/fiber/v2" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "golang.org/x/crypto/bcrypt" ) type Handler struct { db *pgxpool.Pool } func NewHandler(db *pgxpool.Pool) *Handler { return &Handler{db: db} } // A precomputed bcrypt hash (cost 12, matching staff.Create/EnsureOwner) of a // password nobody will ever type. Login compares against this when the email // isn't found, so an unknown email takes exactly as long as a known one with // the wrong password — without it, the early return below would make lookups // for nonexistent accounts measurably faster than real ones, letting an // attacker enumerate valid staff emails purely from response timing. const dummyPasswordHash = "$2a$12$Nj5.M8rQF2mZg.OslIWNB.pvGNP9jQSeOrLCO228dxkpTbZCXZFl." // Login is the only public auth endpoint — staff accounts are provisioned by // an owner via the staff package, there is no public self-registration. func (h *Handler) Login(c *fiber.Ctx) error { var body struct { Email string `json:"email"` Password string `json:"password"` } if err := c.BodyParser(&body); err != nil { return c.Status(400).JSON(fiber.Map{"error": "invalid request body"}) } var staffID, name, role, hash string var isActive bool var rolePermissions, grants, revokes []string err := h.db.QueryRow(context.Background(), `SELECT su.id, su.name, su.role, su.password_hash, su.is_active, r.permissions, su.permission_grants, su.permission_revokes FROM staff_users su JOIN roles r ON r.name = su.role WHERE su.email = $1`, body.Email, ).Scan(&staffID, &name, &role, &hash, &isActive, &rolePermissions, &grants, &revokes) if err != nil && err != pgx.ErrNoRows { return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } if err == pgx.ErrNoRows { hash = dummyPasswordHash } // Password compare always runs, and the not-found/disabled checks are // decided only after it — so every branch (unknown email, wrong // password, disabled account, success) costs the same one bcrypt call // before the response goes out. Deciding is_active first (the previous // order) let a disabled account skip straight to a fast 403, which was // itself a timing oracle for "this email belongs to a disabled staff // account" even after fixing the not-found case. pwErr := bcrypt.CompareHashAndPassword([]byte(hash), []byte(body.Password)) if err == pgx.ErrNoRows || pwErr != nil { return c.Status(401).JSON(fiber.Map{"error": "invalid credentials"}) } if !isActive { return c.Status(403).JSON(fiber.Map{"error": "account disabled"}) } h.db.Exec(context.Background(), `UPDATE staff_users SET last_login_at = NOW() WHERE id = $1`, staffID) permissions := effectivePermissions(rolePermissions, grants, revokes) token, err := GenerateAccessToken(staffID, name, role, permissions) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } return c.JSON(fiber.Map{ "access_token": token, "staff": fiber.Map{ "id": staffID, "name": name, "role": role, "permissions": permissions, }, }) } // effectivePermissions applies a staff member's point overrides on top of // their role's base set — revoke always wins over grant so an accidental // grant+revoke overlap (blocked at write time in internal/staff, but a // belt-and-suspenders here too) never silently grants access. func effectivePermissions(rolePerms, grants, revokes []string) []string { revoked := make(map[string]bool, len(revokes)) for _, p := range revokes { revoked[p] = true } seen := make(map[string]bool, len(rolePerms)+len(grants)) out := []string{} for _, p := range append(append([]string{}, rolePerms...), grants...) { if revoked[p] || seen[p] { continue } seen[p] = true out = append(out, p) } return out } // Me returns the profile of the currently authenticated staff member. func (h *Handler) Me(c *fiber.Ctx) error { return c.JSON(fiber.Map{ "id": StaffID(c), "name": c.Locals("staffName"), "role": StaffRole(c), "permissions": StaffPermissions(c), }) }