Files
aura-crm/core/backend/internal/auth/handler.go
T

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),
})
}