118 lines
4.1 KiB
Go
118 lines
4.1 KiB
Go
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),
|
|
})
|
|
}
|