Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
var ErrInvalidAPIKey = errors.New("invalid api key")
|
||||
|
||||
// APIKeyIdentity is the tenant binding resolved from a valid API key.
|
||||
type APIKeyIdentity struct {
|
||||
KeyID uuid.UUID
|
||||
CompanyID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
MembershipRole string // active membership role for the key owner (admin|member)
|
||||
}
|
||||
|
||||
// HashAPIKey returns a SHA-256 hex digest for O(1) api_keys.key_hash lookup.
|
||||
// Matches hashes written by dashboard key creation. Also used for invite tokens at rest.
|
||||
func HashAPIKey(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// AuthenticateAPIKey looks up a non-revoked key by hash and updates last_used_at.
|
||||
// Keys owned by inactive users or without an active company membership are rejected.
|
||||
// MembershipRole is returned so HTTP middleware can withhold company-admin powers
|
||||
// when the owner is no longer an admin (keys are admin-created; scopes/expiry columns
|
||||
// do not exist yet — empty/full privilege remains the default for admin-owned keys).
|
||||
func (s *Service) AuthenticateAPIKey(ctx context.Context, raw string) (APIKeyIdentity, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return APIKeyIdentity{}, ErrInvalidAPIKey
|
||||
}
|
||||
hash := HashAPIKey(raw)
|
||||
var id APIKeyIdentity
|
||||
var role string
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT k.id, k.company_id, k.user_id, m.role
|
||||
FROM api_keys k
|
||||
INNER JOIN users u ON u.id = k.user_id AND u.is_active = true
|
||||
INNER JOIN memberships m ON m.user_id = k.user_id
|
||||
AND m.company_id = k.company_id
|
||||
AND m.status = 'active'
|
||||
WHERE k.key_hash = $1 AND k.revoked_at IS NULL`, hash).
|
||||
Scan(&id.KeyID, &id.CompanyID, &id.UserID, &role)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return APIKeyIdentity{}, ErrInvalidAPIKey
|
||||
}
|
||||
if err != nil {
|
||||
return APIKeyIdentity{}, err
|
||||
}
|
||||
id.MembershipRole = NormalizeMembershipRole(role)
|
||||
_, _ = s.Pool.Exec(ctx, `
|
||||
UPDATE api_keys SET last_used_at = now(), updated_at = now() WHERE id = $1`, id.KeyID)
|
||||
return id, nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHashAPIKeyDeterministic(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := HashAPIKey("dk_test_secret_value")
|
||||
b := HashAPIKey("dk_test_secret_value")
|
||||
if a != b {
|
||||
t.Fatalf("hash not deterministic")
|
||||
}
|
||||
if len(a) != 64 {
|
||||
t.Fatalf("expected sha256 hex length 64, got %d", len(a))
|
||||
}
|
||||
if HashAPIKey("other") == a {
|
||||
t.Fatal("different keys must not collide")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashAPIKeyEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := HashAPIKey("")
|
||||
if len(got) != 64 {
|
||||
t.Fatalf("empty input still hashes: len=%d", len(got))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package auth
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrRegisterFieldsRequired = errors.New("email, password, and company name are required")
|
||||
ErrPasswordTooShort = errors.New("password must be at least 8 characters")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrNotCompanyMember = errors.New("not a member of company")
|
||||
ErrInviteNotFound = errors.New("invite not found")
|
||||
ErrEmailRequired = errors.New("email is required")
|
||||
ErrSyntheticEmail = errors.New("synthetic migration email cannot receive invites")
|
||||
ErrNotEligibleSetPassword = errors.New("user not eligible for set-password invite")
|
||||
ErrEmailMismatch = errors.New("signed-in email does not match invite email")
|
||||
)
|
||||
|
||||
// ClientError reports whether err is a known client-facing auth error.
|
||||
func ClientError(err error) (msg string, ok bool) {
|
||||
switch {
|
||||
case err == nil:
|
||||
return "", false
|
||||
case errors.Is(err, ErrRegisterFieldsRequired),
|
||||
errors.Is(err, ErrPasswordTooShort),
|
||||
errors.Is(err, ErrPasswordAlreadySet),
|
||||
errors.Is(err, ErrUserExists),
|
||||
errors.Is(err, ErrInviteInvalid),
|
||||
errors.Is(err, ErrInvalidCredentials),
|
||||
errors.Is(err, ErrMustSetPassword),
|
||||
errors.Is(err, ErrUserNotFound),
|
||||
errors.Is(err, ErrNotCompanyMember),
|
||||
errors.Is(err, ErrInviteNotFound),
|
||||
errors.Is(err, ErrTokenInvalid),
|
||||
errors.Is(err, ErrEmailRequired),
|
||||
errors.Is(err, ErrSyntheticEmail),
|
||||
errors.Is(err, ErrNotEligibleSetPassword),
|
||||
errors.Is(err, ErrEmailMismatch):
|
||||
return err.Error(), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Invite is a pending company invite row (plaintext token returned once at creation; hashed at rest).
|
||||
type Invite struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
CompanyID uuid.UUID `json:"company_id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
func normalizeInviteRole(role string) string {
|
||||
role = strings.TrimSpace(strings.ToLower(role))
|
||||
if role == "admin" {
|
||||
return "admin"
|
||||
}
|
||||
return "member"
|
||||
}
|
||||
|
||||
// NormalizeMembershipRole maps invite/membership role strings to admin|member.
|
||||
// Unknown values collapse to member (safe default for invites).
|
||||
func NormalizeMembershipRole(role string) string {
|
||||
return normalizeInviteRole(role)
|
||||
}
|
||||
|
||||
// ErrInvalidMembershipRole is returned when a role is not exactly admin|member.
|
||||
var ErrInvalidMembershipRole = errors.New("invalid membership role")
|
||||
|
||||
// ParseMembershipRole accepts only admin|member (case-insensitive). Unlike
|
||||
// NormalizeMembershipRole it does not coerce unknown values to member — use for
|
||||
// PATCH/role updates where coercion would silently demote admins.
|
||||
func ParseMembershipRole(role string) (string, error) {
|
||||
role = strings.TrimSpace(strings.ToLower(role))
|
||||
switch role {
|
||||
case "admin", "member":
|
||||
return role, nil
|
||||
default:
|
||||
return "", ErrInvalidMembershipRole
|
||||
}
|
||||
}
|
||||
|
||||
// preferMembershipRole keeps admin on invite accept conflict (never demote admin→member).
|
||||
// Mirrors AcceptInvite ON CONFLICT role CASE.
|
||||
func preferMembershipRole(existing, invited string) string {
|
||||
if existing == "admin" {
|
||||
return "admin"
|
||||
}
|
||||
return normalizeInviteRole(invited)
|
||||
}
|
||||
|
||||
// HashInviteToken returns the SHA-256 hex digest stored in invites.token (same construction as API keys).
|
||||
func HashInviteToken(raw string) string {
|
||||
return HashAPIKey(raw)
|
||||
}
|
||||
|
||||
// IsSyntheticLegacyEmail reports Clerk-missing synthetic addresses that must not receive invites.
|
||||
func IsSyntheticLegacyEmail(email string) bool {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
return strings.HasSuffix(email, "@legacy.local")
|
||||
}
|
||||
|
||||
// EmailsEqual compares emails case-insensitively after trim.
|
||||
func EmailsEqual(a, b string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(a), strings.TrimSpace(b))
|
||||
}
|
||||
|
||||
// ResolveInviteEmail returns the invitee email for a pending, unexpired invite token.
|
||||
func (s *Service) ResolveInviteEmail(ctx context.Context, token string) (string, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return "", ErrInviteInvalid
|
||||
}
|
||||
var (
|
||||
email string
|
||||
expiresAt time.Time
|
||||
acceptedAt *time.Time
|
||||
)
|
||||
tokenHash := HashInviteToken(token)
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT email, expires_at, accepted_at
|
||||
FROM invites
|
||||
WHERE token = $1 OR token = $2
|
||||
ORDER BY CASE WHEN token = $1 THEN 0 ELSE 1 END
|
||||
LIMIT 1`, tokenHash, token).Scan(&email, &expiresAt, &acceptedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) || (acceptedAt != nil) || time.Now().After(expiresAt) {
|
||||
return "", ErrInviteInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(email)), nil
|
||||
}
|
||||
|
||||
// SetPasswordInvite is a one-time accept-invite token for a migrated user (plaintext returned once).
|
||||
type SetPasswordInvite struct {
|
||||
InviteID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
Email string
|
||||
CompanyID uuid.UUID
|
||||
Role string
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// CreateInvite inserts a pending invite (token hashed at rest) and returns the plaintext token once.
|
||||
func (s *Service) CreateInvite(ctx context.Context, companyID, invitedBy uuid.UUID, email, role string) (Invite, string, error) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
if email == "" {
|
||||
return Invite{}, "", ErrEmailRequired
|
||||
}
|
||||
role = normalizeInviteRole(role)
|
||||
token, err := RandomToken(24)
|
||||
if err != nil {
|
||||
return Invite{}, "", err
|
||||
}
|
||||
expires := time.Now().UTC().Add(7 * 24 * time.Hour)
|
||||
var inv Invite
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO invites (company_id, email, role, token, invited_by, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, company_id, email, role, expires_at`,
|
||||
companyID, email, role, HashInviteToken(token), invitedBy, expires,
|
||||
).Scan(&inv.ID, &inv.CompanyID, &inv.Email, &inv.Role, &inv.ExpiresAt)
|
||||
if err != nil {
|
||||
return Invite{}, "", err
|
||||
}
|
||||
return inv, token, nil
|
||||
}
|
||||
|
||||
// ListPendingInvites returns unaccepted, unexpired invites for a company.
|
||||
func (s *Service) ListPendingInvites(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]Invite, int64, error) {
|
||||
const where = `company_id = $1 AND accepted_at IS NULL AND expires_at > now()`
|
||||
var total int64
|
||||
if err := s.Pool.QueryRow(ctx, `SELECT count(*) FROM invites WHERE `+where, companyID).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, company_id, email, role, expires_at
|
||||
FROM invites
|
||||
WHERE `+where+`
|
||||
ORDER BY created_at DESC LIMIT $2 OFFSET $3`, companyID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Invite
|
||||
for rows.Next() {
|
||||
var inv Invite
|
||||
if err := rows.Scan(&inv.ID, &inv.CompanyID, &inv.Email, &inv.Role, &inv.ExpiresAt); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, inv)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// RevokeInvite deletes a pending invite owned by the company.
|
||||
func (s *Service) RevokeInvite(ctx context.Context, companyID, inviteID uuid.UUID) error {
|
||||
ct, err := s.Pool.Exec(ctx, `
|
||||
DELETE FROM invites
|
||||
WHERE id = $1 AND company_id = $2 AND accepted_at IS NULL`, inviteID, companyID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return ErrInviteNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CompanyName returns the display name for a company.
|
||||
func (s *Service) CompanyName(ctx context.Context, companyID uuid.UUID) (string, error) {
|
||||
var name string
|
||||
err := s.Pool.QueryRow(ctx, `SELECT name FROM companies WHERE id = $1`, companyID).Scan(&name)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", errors.New("company not found")
|
||||
}
|
||||
return name, err
|
||||
}
|
||||
|
||||
// UpdateMembershipRole sets an active membership role to admin or member.
|
||||
func (s *Service) UpdateMembershipRole(ctx context.Context, companyID, userID uuid.UUID, role string) (Membership, error) {
|
||||
role = normalizeInviteRole(role)
|
||||
var m Membership
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
UPDATE memberships
|
||||
SET role = $3, updated_at = now()
|
||||
WHERE company_id = $1 AND user_id = $2 AND status = 'active'
|
||||
RETURNING id, company_id, user_id, role, status`,
|
||||
companyID, userID, role,
|
||||
).Scan(&m.ID, &m.CompanyID, &m.UserID, &m.Role, &m.Status)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Membership{}, ErrNotCompanyMember
|
||||
}
|
||||
return m, err
|
||||
}
|
||||
|
||||
// IsPlatformAdmin reports whether the user has full platform admin privileges
|
||||
// (admin/developer or legacy is_platform_admin). support_staff is excluded.
|
||||
func (s *Service) IsPlatformAdmin(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||
access, err := s.GetStaffAccess(ctx, userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return access.FullAdmin, nil
|
||||
}
|
||||
|
||||
// UpdateProfile updates the user's display name.
|
||||
func (s *Service) UpdateProfile(ctx context.Context, userID uuid.UUID, name string) (User, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
var n *string
|
||||
if name != "" {
|
||||
n = &name
|
||||
}
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
UPDATE users SET name = $2, updated_at = now() WHERE id = $1`, userID, n)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return s.GetUser(ctx, userID)
|
||||
}
|
||||
|
||||
// ListUsersNeedingPassword returns active users with must_set_password (migration cutover).
|
||||
func (s *Service) ListUsersNeedingPassword(ctx context.Context, limit int) ([]User, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, email, name, must_set_password, is_platform_admin, staff_role, is_active
|
||||
FROM users
|
||||
WHERE must_set_password = true AND is_active = true
|
||||
ORDER BY email
|
||||
LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []User
|
||||
for rows.Next() {
|
||||
var u User
|
||||
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.MustSetPassword, &u.IsPlatformAdmin, &u.StaffRole, &u.IsActive); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ReissueSetPasswordInvite expires prior pending invites and creates a durable invite for a
|
||||
// must_set_password user with an active membership. Token plaintext is returned once.
|
||||
func (s *Service) ReissueSetPasswordInvite(ctx context.Context, userID uuid.UUID, ttl time.Duration) (SetPasswordInvite, error) {
|
||||
if ttl <= 0 {
|
||||
ttl = 7 * 24 * time.Hour
|
||||
}
|
||||
var (
|
||||
out SetPasswordInvite
|
||||
mustSetPassword bool
|
||||
isActive bool
|
||||
)
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, email, must_set_password, is_active
|
||||
FROM users WHERE id = $1`, userID).Scan(&out.UserID, &out.Email, &mustSetPassword, &isActive)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return SetPasswordInvite{}, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return SetPasswordInvite{}, err
|
||||
}
|
||||
if !isActive || !mustSetPassword {
|
||||
return SetPasswordInvite{}, ErrNotEligibleSetPassword
|
||||
}
|
||||
if IsSyntheticLegacyEmail(out.Email) {
|
||||
return SetPasswordInvite{}, ErrSyntheticEmail
|
||||
}
|
||||
out.Email = strings.ToLower(strings.TrimSpace(out.Email))
|
||||
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
SELECT company_id, role
|
||||
FROM memberships
|
||||
WHERE user_id = $1 AND status = 'active'
|
||||
ORDER BY created_at
|
||||
LIMIT 1`, userID).Scan(&out.CompanyID, &out.Role)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return SetPasswordInvite{}, ErrNotEligibleSetPassword
|
||||
}
|
||||
if err != nil {
|
||||
return SetPasswordInvite{}, err
|
||||
}
|
||||
out.Role = normalizeInviteRole(out.Role)
|
||||
|
||||
token, err := RandomToken(24)
|
||||
if err != nil {
|
||||
return SetPasswordInvite{}, err
|
||||
}
|
||||
out.Token = token
|
||||
out.ExpiresAt = time.Now().UTC().Add(ttl)
|
||||
|
||||
// Expire prior unaccepted invites for this email+company so re-issue is safe.
|
||||
if _, err := s.Pool.Exec(ctx, `
|
||||
UPDATE invites
|
||||
SET expires_at = least(expires_at, now())
|
||||
WHERE company_id = $1 AND lower(email) = lower($2) AND accepted_at IS NULL`,
|
||||
out.CompanyID, out.Email); err != nil {
|
||||
return SetPasswordInvite{}, err
|
||||
}
|
||||
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO invites (company_id, email, role, token, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id`,
|
||||
out.CompanyID, out.Email, out.Role, HashInviteToken(token), out.ExpiresAt,
|
||||
).Scan(&out.InviteID)
|
||||
if err != nil {
|
||||
return SetPasswordInvite{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPreferMembershipRoleNeverDemotesAdmin(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
existing string
|
||||
invited string
|
||||
want string
|
||||
}{
|
||||
{name: "admin stays admin on member invite", existing: "admin", invited: "member", want: "admin"},
|
||||
{name: "admin stays admin on admin invite", existing: "admin", invited: "admin", want: "admin"},
|
||||
{name: "member promotes to admin", existing: "member", invited: "admin", want: "admin"},
|
||||
{name: "member stays member", existing: "member", invited: "member", want: "member"},
|
||||
{name: "unknown invited normalizes to member", existing: "member", invited: "owner", want: "member"},
|
||||
{name: "empty existing yields invited role", existing: "", invited: "admin", want: "admin"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := preferMembershipRole(tc.existing, tc.invited)
|
||||
if got != tc.want {
|
||||
t.Fatalf("preferMembershipRole(%q, %q)=%q want %q", tc.existing, tc.invited, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashInviteTokenMatchesAPIKeyHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
const raw = "invite-plaintext-secret"
|
||||
got := HashInviteToken(raw)
|
||||
if got != HashAPIKey(raw) {
|
||||
t.Fatalf("HashInviteToken must match HashAPIKey construction")
|
||||
}
|
||||
if len(got) != 64 {
|
||||
t.Fatalf("expected sha256 hex length 64, got %d", len(got))
|
||||
}
|
||||
if got == raw {
|
||||
t.Fatal("invite token must not be stored as plaintext")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeInviteRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
if normalizeInviteRole("Admin") != "admin" {
|
||||
t.Fatal("expected admin")
|
||||
}
|
||||
if normalizeInviteRole(" MEMBER ") != "member" {
|
||||
t.Fatal("expected member")
|
||||
}
|
||||
if normalizeInviteRole("owner") != "member" {
|
||||
t.Fatal("unknown roles collapse to member")
|
||||
}
|
||||
if NormalizeMembershipRole("Admin") != "admin" {
|
||||
t.Fatal("NormalizeMembershipRole should accept Admin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMembershipRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := ParseMembershipRole(" Admin ")
|
||||
if err != nil || got != "admin" {
|
||||
t.Fatalf("admin: got %q err=%v", got, err)
|
||||
}
|
||||
got, err = ParseMembershipRole("MEMBER")
|
||||
if err != nil || got != "member" {
|
||||
t.Fatalf("member: got %q err=%v", got, err)
|
||||
}
|
||||
if _, err := ParseMembershipRole("owner"); !errors.Is(err, ErrInvalidMembershipRole) {
|
||||
t.Fatalf("owner: err=%v want ErrInvalidMembershipRole", err)
|
||||
}
|
||||
if _, err := ParseMembershipRole(""); !errors.Is(err, ErrInvalidMembershipRole) {
|
||||
t.Fatalf("empty: err=%v want ErrInvalidMembershipRole", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSyntheticLegacyEmail(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !IsSyntheticLegacyEmail("user_abc@legacy.local") {
|
||||
t.Fatal("expected synthetic")
|
||||
}
|
||||
if !IsSyntheticLegacyEmail(" User@Legacy.Local ") {
|
||||
t.Fatal("expected case-insensitive synthetic")
|
||||
}
|
||||
if IsSyntheticLegacyEmail("real@example.com") {
|
||||
t.Fatal("real email must not be treated as synthetic")
|
||||
}
|
||||
if IsSyntheticLegacyEmail("legacy.local@example.com") {
|
||||
t.Fatal("suffix-only match; local-part must not trigger")
|
||||
}
|
||||
if IsSyntheticLegacyEmail("") {
|
||||
t.Fatal("empty must not be synthetic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailsEqual(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !EmailsEqual("A@Example.COM", " a@example.com ") {
|
||||
t.Fatal("expected equal after normalize")
|
||||
}
|
||||
if EmailsEqual("a@example.com", "b@example.com") {
|
||||
t.Fatal("expected mismatch")
|
||||
}
|
||||
if !EmailsEqual("", "") {
|
||||
t.Fatal("empty emails should compare equal")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
const (
|
||||
argonTime = 1
|
||||
argonMemory = 64 * 1024
|
||||
argonThreads = 4
|
||||
argonKeyLen = 32
|
||||
argonSaltLen = 16
|
||||
)
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
if len(password) < 8 {
|
||||
return "", ErrPasswordTooShort
|
||||
}
|
||||
salt := make([]byte, argonSaltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
|
||||
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
|
||||
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
argon2.Version, argonMemory, argonTime, argonThreads, b64Salt, b64Hash), nil
|
||||
}
|
||||
|
||||
func VerifyPassword(encoded, password string) (bool, error) {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||
return false, errors.New("invalid password hash format")
|
||||
}
|
||||
var version int
|
||||
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
|
||||
return false, err
|
||||
}
|
||||
var memory, timeCost uint32
|
||||
var threads uint8
|
||||
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &timeCost, &threads); err != nil {
|
||||
return false, err
|
||||
}
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
want, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
got := argon2.IDKey([]byte(password), salt, timeCost, memory, threads, uint32(len(want)))
|
||||
return subtle.ConstantTimeCompare(want, got) == 1, nil
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// DefaultPasswordResetTTL is the self-serve reset link lifetime.
|
||||
const DefaultPasswordResetTTL = time.Hour
|
||||
|
||||
// PasswordResetIssue is returned once when a reset token is created (plaintext token for email only).
|
||||
type PasswordResetIssue struct {
|
||||
UserID uuid.UUID
|
||||
Email string
|
||||
Token string
|
||||
}
|
||||
|
||||
// IssuePasswordReset creates a durable hashed reset token for an active user with a deliverable email.
|
||||
// Unknown, inactive, and synthetic @legacy.local addresses return ErrUserNotFound / ErrSyntheticEmail
|
||||
// so callers can respond opaquely without enumeration.
|
||||
func (s *Service) IssuePasswordReset(ctx context.Context, email string, ttl time.Duration) (PasswordResetIssue, error) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
if email == "" {
|
||||
return PasswordResetIssue{}, ErrEmailRequired
|
||||
}
|
||||
if IsSyntheticLegacyEmail(email) {
|
||||
return PasswordResetIssue{}, ErrSyntheticEmail
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = DefaultPasswordResetTTL
|
||||
}
|
||||
|
||||
var (
|
||||
userID uuid.UUID
|
||||
isActive bool
|
||||
)
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, is_active
|
||||
FROM users
|
||||
WHERE lower(email) = $1`, email).Scan(&userID, &isActive)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return PasswordResetIssue{}, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return PasswordResetIssue{}, err
|
||||
}
|
||||
if !isActive {
|
||||
return PasswordResetIssue{}, ErrUserNotFound
|
||||
}
|
||||
|
||||
token, err := RandomToken(24)
|
||||
if err != nil {
|
||||
return PasswordResetIssue{}, err
|
||||
}
|
||||
expiresAt := time.Now().UTC().Add(ttl)
|
||||
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return PasswordResetIssue{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// Invalidate prior unused tokens so only the latest link works.
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE password_reset_tokens
|
||||
SET expires_at = least(expires_at, now())
|
||||
WHERE user_id = $1 AND consumed_at IS NULL AND expires_at > now()`, userID); err != nil {
|
||||
return PasswordResetIssue{}, err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO password_reset_tokens (user_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3)`, userID, HashInviteToken(token), expiresAt); err != nil {
|
||||
return PasswordResetIssue{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return PasswordResetIssue{}, err
|
||||
}
|
||||
|
||||
return PasswordResetIssue{UserID: userID, Email: email, Token: token}, nil
|
||||
}
|
||||
|
||||
// ResetPasswordWithToken consumes a one-time reset token and sets a new password
|
||||
// regardless of must_set_password (dedicated reset path — not SetPassword / ForceSetPassword).
|
||||
func (s *Service) ResetPasswordWithToken(ctx context.Context, rawToken, password string) error {
|
||||
rawToken = strings.TrimSpace(rawToken)
|
||||
if rawToken == "" {
|
||||
return ErrTokenInvalid
|
||||
}
|
||||
passwordHash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var (
|
||||
tokenID uuid.UUID
|
||||
userID uuid.UUID
|
||||
expiresAt time.Time
|
||||
consumedAt *time.Time
|
||||
)
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT id, user_id, expires_at, consumed_at
|
||||
FROM password_reset_tokens
|
||||
WHERE token_hash = $1
|
||||
FOR UPDATE`, HashInviteToken(rawToken)).Scan(&tokenID, &userID, &expiresAt, &consumedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrTokenInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if consumedAt != nil || !expiresAt.After(time.Now().UTC()) {
|
||||
return ErrTokenInvalid
|
||||
}
|
||||
|
||||
var isActive bool
|
||||
err = tx.QueryRow(ctx, `SELECT is_active FROM users WHERE id = $1 FOR UPDATE`, userID).Scan(&isActive)
|
||||
if errors.Is(err, pgx.ErrNoRows) || (err == nil && !isActive) {
|
||||
return ErrTokenInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ct, err := tx.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET password_hash = $2,
|
||||
must_set_password = false,
|
||||
session_version = session_version + 1,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND is_active = true`, userID, passwordHash)
|
||||
if isUndefinedColumn(err) {
|
||||
// Pre-042 DBs: still reset password; session revoke requires session_version migration.
|
||||
ct, err = tx.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET password_hash = $2, must_set_password = false, updated_at = now()
|
||||
WHERE id = $1 AND is_active = true`, userID, passwordHash)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return ErrTokenInvalid
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE password_reset_tokens
|
||||
SET consumed_at = now()
|
||||
WHERE id = $1`, tokenID); err != nil {
|
||||
return err
|
||||
}
|
||||
// Expire any sibling unused tokens for this user.
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE password_reset_tokens
|
||||
SET expires_at = least(expires_at, now())
|
||||
WHERE user_id = $1 AND id <> $2 AND consumed_at IS NULL AND expires_at > now()`,
|
||||
userID, tokenID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDefaultPasswordResetTTL(t *testing.T) {
|
||||
t.Parallel()
|
||||
if DefaultPasswordResetTTL != time.Hour {
|
||||
t.Fatalf("DefaultPasswordResetTTL=%v want 1h", DefaultPasswordResetTTL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuePasswordResetRejectsSyntheticEmail(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Service{} // no Pool — synthetic check must return before any DB use
|
||||
for _, email := range []string{
|
||||
"user_abc@legacy.local",
|
||||
" User_ABC@Legacy.Local ",
|
||||
} {
|
||||
_, err := s.IssuePasswordReset(t.Context(), email, 0)
|
||||
if !errors.Is(err, ErrSyntheticEmail) {
|
||||
t.Fatalf("email=%q err=%v want ErrSyntheticEmail", email, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHashPasswordRejectsShort(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := HashPassword("short")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for password shorter than 8 characters")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashPasswordAndVerifyRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
const password = "correct-horse-battery"
|
||||
encoded, err := HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(encoded, "$argon2id$") {
|
||||
t.Fatalf("unexpected encoding prefix: %q", encoded)
|
||||
}
|
||||
ok, err := VerifyPassword(encoded, password)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyPassword: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected password to verify")
|
||||
}
|
||||
ok, err = VerifyPassword(encoded, "wrong-password")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyPassword wrong: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("expected wrong password to fail verification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPasswordInvalidFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := VerifyPassword("not-a-hash", "anything12")
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid format error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandomTokenLength(t *testing.T) {
|
||||
t.Parallel()
|
||||
tok, err := RandomToken(24)
|
||||
if err != nil {
|
||||
t.Fatalf("RandomToken: %v", err)
|
||||
}
|
||||
if len(tok) != 48 {
|
||||
t.Fatalf("expected hex length 48, got %d (%q)", len(tok), tok)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrMustSetPassword = errors.New("password_not_set")
|
||||
ErrInviteInvalid = errors.New("invite invalid or expired")
|
||||
ErrPasswordAlreadySet = errors.New("password already set")
|
||||
ErrUserExists = errors.New("user already exists")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
MustSetPassword bool `json:"must_set_password"`
|
||||
IsPlatformAdmin bool `json:"is_platform_admin"`
|
||||
StaffRole *string `json:"staff_role,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
type Membership struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
CompanyID uuid.UUID `json:"company_id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type Company struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type RegisterInput struct {
|
||||
Email string
|
||||
Password string
|
||||
Name string
|
||||
CompanyName string
|
||||
}
|
||||
|
||||
type LoginResult struct {
|
||||
User User `json:"user"`
|
||||
CompanyID uuid.UUID `json:"company_id"`
|
||||
Companies []Company `json:"companies"`
|
||||
}
|
||||
|
||||
func (s *Service) Register(ctx context.Context, in RegisterInput) (LoginResult, error) {
|
||||
email := strings.ToLower(strings.TrimSpace(in.Email))
|
||||
if email == "" || in.Password == "" || strings.TrimSpace(in.CompanyName) == "" {
|
||||
return LoginResult{}, ErrRegisterFieldsRequired
|
||||
}
|
||||
hash, err := HashPassword(in.Password)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var existing uuid.UUID
|
||||
err = tx.QueryRow(ctx, `SELECT id FROM users WHERE email = $1`, email).Scan(&existing)
|
||||
if err == nil {
|
||||
return LoginResult{}, ErrUserExists
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
|
||||
var userID uuid.UUID
|
||||
var name *string
|
||||
if strings.TrimSpace(in.Name) != "" {
|
||||
n := strings.TrimSpace(in.Name)
|
||||
name = &n
|
||||
}
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO users (email, name, password_hash, must_set_password)
|
||||
VALUES ($1, $2, $3, false)
|
||||
RETURNING id`, email, name, hash).Scan(&userID)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
|
||||
var companyID uuid.UUID
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO companies (name) VALUES ($1) RETURNING id`, strings.TrimSpace(in.CompanyName)).Scan(&companyID)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO memberships (company_id, user_id, role, status)
|
||||
VALUES ($1, $2, 'admin', 'active')`, companyID, userID)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO company_settings (company_id) VALUES ($1)
|
||||
ON CONFLICT DO NOTHING`, companyID)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO credit_balances (company_id) VALUES ($1)
|
||||
ON CONFLICT DO NOTHING`, companyID)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
|
||||
user, err := s.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
return LoginResult{
|
||||
User: user,
|
||||
CompanyID: companyID,
|
||||
Companies: []Company{{ID: companyID, Name: strings.TrimSpace(in.CompanyName)}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Login(ctx context.Context, email, password string) (LoginResult, error) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
var (
|
||||
user User
|
||||
hash *string
|
||||
)
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, email, name, password_hash, must_set_password, is_platform_admin, staff_role, is_active
|
||||
FROM users WHERE email = $1`, email).Scan(
|
||||
&user.ID, &user.Email, &user.Name, &hash, &user.MustSetPassword, &user.IsPlatformAdmin, &user.StaffRole, &user.IsActive,
|
||||
)
|
||||
if isUndefinedColumn(err) {
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
SELECT id, email, name, password_hash, must_set_password, is_platform_admin, is_active
|
||||
FROM users WHERE email = $1`, email).Scan(
|
||||
&user.ID, &user.Email, &user.Name, &hash, &user.MustSetPassword, &user.IsPlatformAdmin, &user.IsActive,
|
||||
)
|
||||
}
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return LoginResult{}, ErrInvalidCredentials
|
||||
}
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
if !user.IsActive {
|
||||
return LoginResult{}, ErrInvalidCredentials
|
||||
}
|
||||
// Migrated / invite-pending accounts have no usable password until accept-invite or set-password.
|
||||
if user.MustSetPassword || hash == nil || *hash == "" {
|
||||
if user.MustSetPassword {
|
||||
return LoginResult{}, ErrMustSetPassword
|
||||
}
|
||||
return LoginResult{}, ErrInvalidCredentials
|
||||
}
|
||||
ok, err := VerifyPassword(*hash, password)
|
||||
if err != nil || !ok {
|
||||
return LoginResult{}, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
companies, err := s.ListUserCompanies(ctx, user.ID)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
var companyID uuid.UUID
|
||||
if len(companies) > 0 {
|
||||
companyID = companies[0].ID
|
||||
}
|
||||
_, _ = s.Pool.Exec(ctx, `UPDATE users SET last_login_at = now(), updated_at = now() WHERE id = $1`, user.ID)
|
||||
return LoginResult{User: user, CompanyID: companyID, Companies: companies}, nil
|
||||
}
|
||||
|
||||
func (s *Service) AcceptInvite(ctx context.Context, token, password, name string) (LoginResult, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return LoginResult{}, ErrInviteInvalid
|
||||
}
|
||||
var (
|
||||
inviteID, companyID uuid.UUID
|
||||
email, role string
|
||||
expiresAt time.Time
|
||||
acceptedAt *time.Time
|
||||
)
|
||||
// Prefer hashed lookup (at-rest); also accept legacy plaintext rows until they expire.
|
||||
tokenHash := HashInviteToken(token)
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, company_id, email, role, expires_at, accepted_at
|
||||
FROM invites
|
||||
WHERE token = $1 OR token = $2
|
||||
ORDER BY CASE WHEN token = $1 THEN 0 ELSE 1 END
|
||||
LIMIT 1`, tokenHash, token).Scan(
|
||||
&inviteID, &companyID, &email, &role, &expiresAt, &acceptedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) || (acceptedAt != nil) || time.Now().After(expiresAt) {
|
||||
return LoginResult{}, ErrInviteInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var userID uuid.UUID
|
||||
var existingHash string
|
||||
var mustSet bool
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT id, password_hash, must_set_password FROM users WHERE email = $1`,
|
||||
strings.ToLower(email)).Scan(&userID, &existingHash, &mustSet)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
hash, herr := HashPassword(password)
|
||||
if herr != nil {
|
||||
return LoginResult{}, herr
|
||||
}
|
||||
var n *string
|
||||
if strings.TrimSpace(name) != "" {
|
||||
nn := strings.TrimSpace(name)
|
||||
n = &nn
|
||||
}
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO users (email, name, password_hash, must_set_password)
|
||||
VALUES ($1, $2, $3, false) RETURNING id`, strings.ToLower(email), n, hash).Scan(&userID)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
} else if err != nil {
|
||||
return LoginResult{}, err
|
||||
} else if mustSet {
|
||||
// Migration / first-password invites may set a password once.
|
||||
hash, herr := HashPassword(password)
|
||||
if herr != nil {
|
||||
return LoginResult{}, herr
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE users SET password_hash = $2, must_set_password = false, updated_at = now()
|
||||
WHERE id = $1 AND must_set_password = true`, userID, hash)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
} else {
|
||||
// Existing accounts keep their password; invitee must prove ownership.
|
||||
ok, verr := VerifyPassword(existingHash, password)
|
||||
if verr != nil || !ok {
|
||||
return LoginResult{}, ErrInvalidCredentials
|
||||
}
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO memberships (company_id, user_id, role, status)
|
||||
VALUES ($1, $2, $3, 'active')
|
||||
ON CONFLICT (company_id, user_id) DO UPDATE
|
||||
SET role = CASE
|
||||
WHEN memberships.role = 'admin' THEN memberships.role
|
||||
ELSE EXCLUDED.role
|
||||
END,
|
||||
status = 'active', updated_at = now()`,
|
||||
companyID, userID, role)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
ct, err := tx.Exec(ctx, `
|
||||
UPDATE invites SET accepted_at = now()
|
||||
WHERE id = $1 AND accepted_at IS NULL`, inviteID)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return LoginResult{}, ErrInviteInvalid
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
|
||||
user, err := s.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
companies, err := s.ListUserCompanies(ctx, userID)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
return LoginResult{User: user, CompanyID: companyID, Companies: companies}, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetPassword(ctx context.Context, userID uuid.UUID, password string) error {
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Only users flagged must_set_password may set via token/session bootstrap.
|
||||
// This also makes HMAC set-password tokens single-use after success.
|
||||
ct, err := s.Pool.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET password_hash = $2,
|
||||
must_set_password = false,
|
||||
session_version = session_version + 1,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND must_set_password = true`, userID, hash)
|
||||
if isUndefinedColumn(err) {
|
||||
ct, err = s.Pool.Exec(ctx, `
|
||||
UPDATE users SET password_hash = $2, must_set_password = false, updated_at = now()
|
||||
WHERE id = $1 AND must_set_password = true`, userID, hash)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
var exists bool
|
||||
_ = s.Pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)`, userID).Scan(&exists)
|
||||
if exists {
|
||||
return ErrPasswordAlreadySet
|
||||
}
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChangePassword verifies the current password then sets a new one (in-app Settings).
|
||||
// Bumps session_version so other sessions are revoked; callers must re-stamp the cookie.
|
||||
func (s *Service) ChangePassword(ctx context.Context, userID uuid.UUID, currentPassword, newPassword string) error {
|
||||
var (
|
||||
hash string
|
||||
mustSetPassword bool
|
||||
isActive bool
|
||||
)
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT password_hash, must_set_password, is_active
|
||||
FROM users WHERE id = $1`, userID).Scan(&hash, &mustSetPassword, &isActive)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !isActive {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
if mustSetPassword {
|
||||
return ErrMustSetPassword
|
||||
}
|
||||
ok, err := VerifyPassword(hash, currentPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
newHash, err := HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ct, err := s.Pool.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET password_hash = $2,
|
||||
must_set_password = false,
|
||||
session_version = session_version + 1,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND is_active = true AND must_set_password = false`, userID, newHash)
|
||||
if isUndefinedColumn(err) {
|
||||
ct, err = s.Pool.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET password_hash = $2, must_set_password = false, updated_at = now()
|
||||
WHERE id = $1 AND is_active = true AND must_set_password = false`, userID, newHash)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForceSetPassword sets a password regardless of must_set_password (local/admin bootstrap).
|
||||
func (s *Service) ForceSetPassword(ctx context.Context, userID uuid.UUID, password string) error {
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ct, err := s.Pool.Exec(ctx, `
|
||||
UPDATE users SET password_hash = $2, must_set_password = false, updated_at = now()
|
||||
WHERE id = $1 AND is_active = true`, userID, hash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) GetUser(ctx context.Context, id uuid.UUID) (User, error) {
|
||||
var u User
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, email, name, must_set_password, is_platform_admin, staff_role, is_active
|
||||
FROM users WHERE id = $1`, id).Scan(
|
||||
&u.ID, &u.Email, &u.Name, &u.MustSetPassword, &u.IsPlatformAdmin, &u.StaffRole, &u.IsActive,
|
||||
)
|
||||
if isUndefinedColumn(err) {
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
SELECT id, email, name, must_set_password, is_platform_admin, is_active
|
||||
FROM users WHERE id = $1`, id).Scan(
|
||||
&u.ID, &u.Email, &u.Name, &u.MustSetPassword, &u.IsPlatformAdmin, &u.IsActive,
|
||||
)
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
|
||||
func (s *Service) ListUserCompanies(ctx context.Context, userID uuid.UUID) ([]Company, error) {
|
||||
// Prefer Platform Demo sandbox when present, then richest tenant (products/feeds).
|
||||
// A1 Slovenija wins remaining ties; accept old Local Demo Co rename as A1 alias.
|
||||
const a1LegacyCompanyID = "97e1a309-3d23-4aa2-b518-8e8d7afdfec7"
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT c.id, c.name
|
||||
FROM memberships m
|
||||
JOIN companies c ON c.id = m.company_id
|
||||
WHERE m.user_id = $1 AND m.status = 'active'
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN lower(c.name) IN ('platform demo', 'demo') THEN 0
|
||||
ELSE 1
|
||||
END,
|
||||
(SELECT COUNT(*) FROM processed_products p WHERE p.company_id = c.id) DESC,
|
||||
(SELECT COUNT(*) FROM input_feeds f WHERE f.company_id = c.id) DESC,
|
||||
CASE
|
||||
WHEN lower(c.name) = 'a1 slovenija' THEN 0
|
||||
WHEN lower(c.name) = 'local demo co' THEN 0
|
||||
WHEN lower(COALESCE(c.legacy_company_id, '')) = lower($2) THEN 0
|
||||
ELSE 1
|
||||
END,
|
||||
c.name`, userID, a1LegacyCompanyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Company
|
||||
for rows.Next() {
|
||||
var c Company
|
||||
if err := rows.Scan(&c.ID, &c.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) EnsureMembership(ctx context.Context, userID, companyID uuid.UUID) (Membership, error) {
|
||||
var m Membership
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, company_id, user_id, role, status
|
||||
FROM memberships
|
||||
WHERE user_id = $1 AND company_id = $2 AND status = 'active'`,
|
||||
userID, companyID).Scan(&m.ID, &m.CompanyID, &m.UserID, &m.Role, &m.Status)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Membership{}, ErrNotCompanyMember
|
||||
}
|
||||
return m, err
|
||||
}
|
||||
|
||||
func RandomToken(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/scs/pgxstore"
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func NewSessionManager(pool *pgxpool.Pool, cookieName string, secure bool, idleHours int) *scs.SessionManager {
|
||||
sm := scs.New()
|
||||
sm.Store = pgxstore.New(pool)
|
||||
sm.Lifetime = 7 * 24 * time.Hour
|
||||
if idleHours <= 0 {
|
||||
idleHours = 24
|
||||
}
|
||||
sm.IdleTimeout = time.Duration(idleHours) * time.Hour
|
||||
sm.Cookie.Name = cookieName
|
||||
sm.Cookie.HttpOnly = true
|
||||
sm.Cookie.Secure = secure
|
||||
sm.Cookie.SameSite = http.SameSiteLaxMode
|
||||
sm.Cookie.Path = "/"
|
||||
return sm
|
||||
}
|
||||
|
||||
const (
|
||||
SessionUserIDKey = "user_id"
|
||||
SessionCompanyIDKey = "company_id"
|
||||
SessionImpersonatorIDKey = "impersonator_id" // non-prod user switch: original admin/demo
|
||||
SessionVersionKey = "session_version" // must match users.session_version
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewSessionManagerCookieFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sm := NewSessionManager(nil, "descrybe_session", true, 12)
|
||||
if sm.Cookie.Name != "descrybe_session" {
|
||||
t.Fatalf("Name = %q", sm.Cookie.Name)
|
||||
}
|
||||
if !sm.Cookie.HttpOnly {
|
||||
t.Fatal("session cookie must be HttpOnly")
|
||||
}
|
||||
if !sm.Cookie.Secure {
|
||||
t.Fatal("secure=true must set Secure")
|
||||
}
|
||||
if sm.Cookie.SameSite != http.SameSiteLaxMode {
|
||||
t.Fatalf("SameSite = %v, want Lax", sm.Cookie.SameSite)
|
||||
}
|
||||
if sm.Cookie.Path != "/" {
|
||||
t.Fatalf("Path = %q, want /", sm.Cookie.Path)
|
||||
}
|
||||
if sm.IdleTimeout != 12*time.Hour {
|
||||
t.Fatalf("IdleTimeout = %v, want 12h", sm.IdleTimeout)
|
||||
}
|
||||
if sm.Lifetime != 7*24*time.Hour {
|
||||
t.Fatalf("Lifetime = %v, want 7d", sm.Lifetime)
|
||||
}
|
||||
|
||||
insecure := NewSessionManager(nil, "descrybe_session", false, 0)
|
||||
if insecure.Cookie.Secure {
|
||||
t.Fatal("secure=false must not set Secure")
|
||||
}
|
||||
if !insecure.Cookie.HttpOnly {
|
||||
t.Fatal("session cookie must remain HttpOnly")
|
||||
}
|
||||
if insecure.IdleTimeout != 24*time.Hour {
|
||||
t.Fatalf("default IdleTimeout = %v, want 24h", insecure.IdleTimeout)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// UserSessionState is the cookie-session gate (active flag + version for revoke-on-reset).
|
||||
type UserSessionState struct {
|
||||
Active bool
|
||||
Version int
|
||||
}
|
||||
|
||||
// UserSessionState loads is_active and session_version for RequireSession.
|
||||
// When session_version is not migrated yet, Version defaults to 0 (pre-hardening sessions keep working).
|
||||
func (s *Service) UserSessionState(ctx context.Context, userID uuid.UUID) (UserSessionState, error) {
|
||||
var st UserSessionState
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT is_active, session_version
|
||||
FROM users
|
||||
WHERE id = $1`, userID).Scan(&st.Active, &st.Version)
|
||||
if isUndefinedColumn(err) {
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
SELECT is_active
|
||||
FROM users
|
||||
WHERE id = $1`, userID).Scan(&st.Active)
|
||||
st.Version = 0
|
||||
}
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return UserSessionState{}, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return UserSessionState{}, err
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestResetPasswordWithTokenBumpsSessionVersion(t *testing.T) {
|
||||
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL not set")
|
||||
}
|
||||
ctx := t.Context()
|
||||
pg, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("postgres: %v", err)
|
||||
}
|
||||
t.Cleanup(pg.Close)
|
||||
|
||||
var ready bool
|
||||
if err := pg.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'session_version'
|
||||
)`).Scan(&ready); err != nil || !ready {
|
||||
t.Skip("users.session_version missing — run goose up for 042_user_session_version")
|
||||
}
|
||||
if err := pg.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'password_reset_tokens'
|
||||
)`).Scan(&ready); err != nil || !ready {
|
||||
t.Skip("password_reset_tokens missing — run goose up for 041_password_reset_tokens")
|
||||
}
|
||||
|
||||
svc := &Service{Pool: pg}
|
||||
userID := uuid.New()
|
||||
email := "session-ver-" + userID.String()[:8] + "@example.test"
|
||||
hash, err := HashPassword("OldPassword123!")
|
||||
if err != nil {
|
||||
t.Fatalf("hash: %v", err)
|
||||
}
|
||||
_, err = pg.Exec(ctx, `
|
||||
INSERT INTO users (id, email, name, password_hash, must_set_password, is_platform_admin, is_active, session_version)
|
||||
VALUES ($1, $2, $3, $4, false, false, true, 3)`,
|
||||
userID, email, "Session Ver", hash)
|
||||
if err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx := t.Context()
|
||||
_, _ = pg.Exec(cleanupCtx, `DELETE FROM password_reset_tokens WHERE user_id = $1`, userID)
|
||||
_, _ = pg.Exec(cleanupCtx, `DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
|
||||
issue, err := svc.IssuePasswordReset(ctx, email, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("IssuePasswordReset: %v", err)
|
||||
}
|
||||
if err := svc.ResetPasswordWithToken(ctx, issue.Token, "NewPassword456!"); err != nil {
|
||||
t.Fatalf("ResetPasswordWithToken: %v", err)
|
||||
}
|
||||
|
||||
st, err := svc.UserSessionState(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("UserSessionState: %v", err)
|
||||
}
|
||||
if !st.Active {
|
||||
t.Fatal("expected active user")
|
||||
}
|
||||
if st.Version != 4 {
|
||||
t.Fatalf("session_version=%d want 4 (bumped from 3)", st.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangePasswordBumpsSessionVersion(t *testing.T) {
|
||||
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL not set")
|
||||
}
|
||||
ctx := t.Context()
|
||||
pg, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("postgres: %v", err)
|
||||
}
|
||||
t.Cleanup(pg.Close)
|
||||
|
||||
var ready bool
|
||||
if err := pg.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'session_version'
|
||||
)`).Scan(&ready); err != nil || !ready {
|
||||
t.Skip("users.session_version missing — run goose up for 042_user_session_version")
|
||||
}
|
||||
|
||||
svc := &Service{Pool: pg}
|
||||
userID := uuid.New()
|
||||
email := "change-pw-" + userID.String()[:8] + "@example.test"
|
||||
const oldPassword = "OldPassword123!"
|
||||
hash, err := HashPassword(oldPassword)
|
||||
if err != nil {
|
||||
t.Fatalf("hash: %v", err)
|
||||
}
|
||||
_, err = pg.Exec(ctx, `
|
||||
INSERT INTO users (id, email, name, password_hash, must_set_password, is_platform_admin, is_active, session_version)
|
||||
VALUES ($1, $2, $3, $4, false, false, true, 2)`,
|
||||
userID, email, "Change PW", hash)
|
||||
if err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pg.Exec(t.Context(), `DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
|
||||
if err := svc.ChangePassword(ctx, userID, "wrong-password", "NewPassword456!"); err != ErrInvalidCredentials {
|
||||
t.Fatalf("wrong current: err=%v want ErrInvalidCredentials", err)
|
||||
}
|
||||
|
||||
if err := svc.ChangePassword(ctx, userID, oldPassword, "short"); err != ErrPasswordTooShort {
|
||||
t.Fatalf("short password: err=%v want ErrPasswordTooShort", err)
|
||||
}
|
||||
|
||||
if err := svc.ChangePassword(ctx, userID, oldPassword, "NewPassword456!"); err != nil {
|
||||
t.Fatalf("ChangePassword: %v", err)
|
||||
}
|
||||
|
||||
st, err := svc.UserSessionState(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("UserSessionState: %v", err)
|
||||
}
|
||||
if st.Version != 3 {
|
||||
t.Fatalf("session_version=%d want 3 (bumped from 2)", st.Version)
|
||||
}
|
||||
|
||||
var stored string
|
||||
if err := pg.QueryRow(ctx, `SELECT password_hash FROM users WHERE id = $1`, userID).Scan(&stored); err != nil {
|
||||
t.Fatalf("load hash: %v", err)
|
||||
}
|
||||
ok, err := VerifyPassword(stored, "NewPassword456!")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("new password verify ok=%v err=%v", ok, err)
|
||||
}
|
||||
ok, err = VerifyPassword(stored, oldPassword)
|
||||
if err != nil || ok {
|
||||
t.Fatal("old password should no longer verify")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPasswordRejectsWhenAlreadySet(t *testing.T) {
|
||||
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL not set")
|
||||
}
|
||||
ctx := t.Context()
|
||||
pg, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("postgres: %v", err)
|
||||
}
|
||||
t.Cleanup(pg.Close)
|
||||
|
||||
svc := &Service{Pool: pg}
|
||||
userID := uuid.New()
|
||||
email := "set-pw-" + userID.String()[:8] + "@example.test"
|
||||
hash, err := HashPassword("AlreadySet123!")
|
||||
if err != nil {
|
||||
t.Fatalf("hash: %v", err)
|
||||
}
|
||||
_, err = pg.Exec(ctx, `
|
||||
INSERT INTO users (id, email, name, password_hash, must_set_password, is_platform_admin, is_active)
|
||||
VALUES ($1, $2, $3, $4, false, false, true)`,
|
||||
userID, email, "Set PW", hash)
|
||||
if err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pg.Exec(t.Context(), `DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
|
||||
if err := svc.SetPassword(ctx, userID, "AnotherPass123!"); err != ErrPasswordAlreadySet {
|
||||
t.Fatalf("SetPassword: err=%v want ErrPasswordAlreadySet", err)
|
||||
}
|
||||
if err := svc.ChangePassword(ctx, userID, "AlreadySet123!", "short"); err != ErrPasswordTooShort {
|
||||
// ensure ChangePassword path still works for eligible users after SetPassword rejection
|
||||
t.Fatalf("ChangePassword short: err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// Platform staff roles (users.staff_role). Orthogonal to company membership roles.
|
||||
const (
|
||||
StaffRoleAdmin = "admin"
|
||||
StaffRoleDeveloper = "developer"
|
||||
StaffRoleSupportStaff = "support_staff"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidStaffRole = errors.New("invalid staff_role")
|
||||
ErrStaffUserNotFound = errors.New("user not found")
|
||||
)
|
||||
|
||||
// StaffAccess is the resolved capability set for a platform staff user.
|
||||
type StaffAccess struct {
|
||||
Role string `json:"staff_role,omitempty"`
|
||||
FullAdmin bool `json:"full_admin"`
|
||||
SupportDesk bool `json:"support_desk"`
|
||||
IsSupportOnly bool `json:"is_support_only"`
|
||||
}
|
||||
|
||||
// NormalizeStaffRole returns a known staff role or empty string.
|
||||
func NormalizeStaffRole(raw string) (string, error) {
|
||||
role := strings.ToLower(strings.TrimSpace(raw))
|
||||
switch role {
|
||||
case "", StaffRoleAdmin, StaffRoleDeveloper, StaffRoleSupportStaff:
|
||||
return role, nil
|
||||
default:
|
||||
return "", ErrInvalidStaffRole
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveStaffRole returns the effective staff role per contract 04:
|
||||
// staff_role if set; else admin when is_platform_admin; else empty.
|
||||
func ResolveStaffRole(isPlatformAdmin bool, staffRole string) string {
|
||||
role, _ := NormalizeStaffRole(staffRole)
|
||||
if role != "" {
|
||||
return role
|
||||
}
|
||||
if isPlatformAdmin {
|
||||
return StaffRoleAdmin
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ResolveStaffAccess maps DB flags to capabilities.
|
||||
//
|
||||
// Rules (fail closed):
|
||||
// - staff_role=support_staff → support desk only (never full admin), even if is_platform_admin.
|
||||
// - staff_role=admin|developer → full admin + support desk.
|
||||
// - staff_role empty + is_platform_admin → legacy full admin (backward compatible).
|
||||
// - otherwise → no staff access.
|
||||
func ResolveStaffAccess(isPlatformAdmin bool, staffRole string) StaffAccess {
|
||||
role := ResolveStaffRole(isPlatformAdmin, staffRole)
|
||||
switch role {
|
||||
case StaffRoleSupportStaff:
|
||||
return StaffAccess{
|
||||
Role: StaffRoleSupportStaff,
|
||||
FullAdmin: false,
|
||||
SupportDesk: true,
|
||||
IsSupportOnly: true,
|
||||
}
|
||||
case StaffRoleAdmin, StaffRoleDeveloper:
|
||||
return StaffAccess{
|
||||
Role: role,
|
||||
FullAdmin: true,
|
||||
SupportDesk: true,
|
||||
}
|
||||
default:
|
||||
return StaffAccess{}
|
||||
}
|
||||
}
|
||||
|
||||
// StaffCapabilities lists platform capability keys for a resolved staff role.
|
||||
func StaffCapabilities(role string) []string {
|
||||
switch role {
|
||||
case StaffRoleAdmin, StaffRoleDeveloper:
|
||||
return []string{
|
||||
"staff.admin_shell",
|
||||
"staff.support.queue",
|
||||
"staff.support.reply",
|
||||
"staff.support.assign",
|
||||
"staff.users.read",
|
||||
"staff.users.write",
|
||||
"staff.analytics",
|
||||
"staff.billing",
|
||||
"staff.plans_features",
|
||||
"staff.feature_gates",
|
||||
"staff.settings",
|
||||
"staff.jobs_stuck",
|
||||
"staff.impersonate",
|
||||
"staff.dev_password",
|
||||
}
|
||||
case StaffRoleSupportStaff:
|
||||
return []string{
|
||||
"staff.admin_shell",
|
||||
"staff.support.queue",
|
||||
"staff.support.reply",
|
||||
"staff.support.assign",
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// GetStaffAccess loads is_platform_admin + staff_role for an active user.
|
||||
// Missing staff_role column (pre-migration) falls back to boolean-only admin.
|
||||
func (s *Service) GetStaffAccess(ctx context.Context, userID uuid.UUID) (StaffAccess, error) {
|
||||
if s == nil || s.Pool == nil {
|
||||
return StaffAccess{}, errors.New("auth service unavailable")
|
||||
}
|
||||
var isAdmin bool
|
||||
var staffRole *string
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT is_platform_admin, staff_role
|
||||
FROM users
|
||||
WHERE id = $1 AND is_active = true`, userID,
|
||||
).Scan(&isAdmin, &staffRole)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return StaffAccess{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
if isUndefinedColumn(err) {
|
||||
ok, err2 := s.platformAdminFlag(ctx, userID)
|
||||
if err2 != nil {
|
||||
return StaffAccess{}, err2
|
||||
}
|
||||
return ResolveStaffAccess(ok, ""), nil
|
||||
}
|
||||
return StaffAccess{}, err
|
||||
}
|
||||
role := ""
|
||||
if staffRole != nil {
|
||||
role = *staffRole
|
||||
}
|
||||
return ResolveStaffAccess(isAdmin, role), nil
|
||||
}
|
||||
|
||||
// platformAdminFlag reads users.is_platform_admin without staff_role resolution.
|
||||
func (s *Service) platformAdminFlag(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||
var ok bool
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT is_platform_admin FROM users WHERE id = $1 AND is_active = true`, userID).Scan(&ok)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return ok, err
|
||||
}
|
||||
|
||||
// StaffUser is a platform staff directory row.
|
||||
type StaffUser struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
IsPlatformAdmin bool `json:"is_platform_admin"`
|
||||
StaffRole *string `json:"staff_role,omitempty"`
|
||||
ResolvedRole string `json:"resolved_role"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// ListStaffUsers returns active users with any platform staff access.
|
||||
func (s *Service) ListStaffUsers(ctx context.Context, limit, offset int) ([]StaffUser, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, email, name, is_platform_admin, staff_role, is_active
|
||||
FROM users
|
||||
WHERE is_active = true
|
||||
AND (is_platform_admin = true OR staff_role IS NOT NULL)
|
||||
ORDER BY coalesce(staff_role, ''), email
|
||||
LIMIT $1 OFFSET $2`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]StaffUser, 0)
|
||||
for rows.Next() {
|
||||
var u StaffUser
|
||||
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.IsPlatformAdmin, &u.StaffRole, &u.IsActive); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stored := ""
|
||||
if u.StaffRole != nil {
|
||||
stored = *u.StaffRole
|
||||
}
|
||||
u.ResolvedRole = ResolveStaffRole(u.IsPlatformAdmin, stored)
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SetStaffRole assigns or clears a platform staff role.
|
||||
// Non-empty role sets is_platform_admin=true (contract invariant).
|
||||
// Empty role clears staff_role and is_platform_admin.
|
||||
func (s *Service) SetStaffRole(ctx context.Context, userID uuid.UUID, staffRole string) (StaffUser, error) {
|
||||
role, err := NormalizeStaffRole(staffRole)
|
||||
if err != nil {
|
||||
return StaffUser{}, err
|
||||
}
|
||||
var (
|
||||
u StaffUser
|
||||
stored *string
|
||||
)
|
||||
if role == "" {
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
UPDATE users
|
||||
SET staff_role = NULL, is_platform_admin = false, updated_at = now()
|
||||
WHERE id = $1 AND is_active = true
|
||||
RETURNING id, email, name, is_platform_admin, staff_role, is_active`, userID,
|
||||
).Scan(&u.ID, &u.Email, &u.Name, &u.IsPlatformAdmin, &stored, &u.IsActive)
|
||||
} else {
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
UPDATE users
|
||||
SET staff_role = $2, is_platform_admin = true, updated_at = now()
|
||||
WHERE id = $1 AND is_active = true
|
||||
RETURNING id, email, name, is_platform_admin, staff_role, is_active`, userID, role,
|
||||
).Scan(&u.ID, &u.Email, &u.Name, &u.IsPlatformAdmin, &stored, &u.IsActive)
|
||||
}
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return StaffUser{}, ErrStaffUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return StaffUser{}, err
|
||||
}
|
||||
u.StaffRole = stored
|
||||
storedRole := ""
|
||||
if stored != nil {
|
||||
storedRole = *stored
|
||||
}
|
||||
u.ResolvedRole = ResolveStaffRole(u.IsPlatformAdmin, storedRole)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// IsAssignableSupportStaff reports whether userID may be set as a ticket assignee.
|
||||
func (s *Service) IsAssignableSupportStaff(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||
access, err := s.GetStaffAccess(ctx, userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return access.SupportDesk, nil
|
||||
}
|
||||
|
||||
func isUndefinedColumn(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
return pgErr.Code == "42703"
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StaffRoleFromPlatformAdmin maps the legacy boolean gate onto staff roles.
|
||||
// Until a dedicated staff_role column exists: platform admin → admin.
|
||||
func StaffRoleFromPlatformAdmin(isPlatformAdmin bool) string {
|
||||
if isPlatformAdmin {
|
||||
return StaffRoleAdmin
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// supportStaffFeatureOff keys denied for support_staff (03-roles-matrix.json).
|
||||
var supportStaffFeatureOff = map[string]struct{}{
|
||||
"billing.checkout": {},
|
||||
"billing.customer_portal": {},
|
||||
"billing.quick_upgrade": {},
|
||||
"capability.api_access": {},
|
||||
"capability.brand_ai_apply": {},
|
||||
"capability.byok": {},
|
||||
"capability.campaign_ai": {},
|
||||
"capability.email_live_send": {},
|
||||
"capability.seo_ai_rewrite": {},
|
||||
"catalog.structured_descriptions": {},
|
||||
"catalog.vector_categories": {},
|
||||
"dashboard.store_reconnect": {},
|
||||
"integrations.ai": {},
|
||||
"integrations.ai.byok": {},
|
||||
"integrations.email": {},
|
||||
"integrations.email.blast": {},
|
||||
"integrations.email.test": {},
|
||||
"marketing.brand_ai_apply": {},
|
||||
"marketing.brand_kit": {},
|
||||
"marketing.campaigns": {},
|
||||
"marketing.campaigns.create": {},
|
||||
"marketing.campaigns.generate_ai": {},
|
||||
"marketing.campaigns.send": {},
|
||||
"marketing.content_calendar": {},
|
||||
"marketing.reviews": {},
|
||||
"marketing.seo": {},
|
||||
"marketing.seo.ai_rewrite": {},
|
||||
"marketing.seo.template_fill": {},
|
||||
"settings.api_keys": {},
|
||||
"settings.team_invite": {},
|
||||
"stores.hub": {},
|
||||
"stores.shopify": {},
|
||||
"stores.shopify.connection": {},
|
||||
"stores.shopify.orders": {},
|
||||
"stores.shopify.settings": {},
|
||||
"stores.woocommerce": {},
|
||||
"stores.woocommerce.attributes": {},
|
||||
"stores.woocommerce.categories": {},
|
||||
"stores.woocommerce.connection": {},
|
||||
"stores.woocommerce.orders": {},
|
||||
"stores.woocommerce.reviews": {},
|
||||
"stores.woocommerce.settings": {},
|
||||
}
|
||||
|
||||
// DefaultStaffRoleAllows reports the dashboard feature ceiling for a staff role
|
||||
// when acting in a tenant context (compose with plan_allows at resolve time).
|
||||
// admin / developer → all keys ON; support_staff → limited set; unknown → false.
|
||||
func DefaultStaffRoleAllows(role string, featureKey string) bool {
|
||||
featureKey = strings.TrimSpace(featureKey)
|
||||
normalized, _ := NormalizeStaffRole(role)
|
||||
switch normalized {
|
||||
case StaffRoleAdmin, StaffRoleDeveloper:
|
||||
return true
|
||||
case StaffRoleSupportStaff:
|
||||
_, denied := supportStaffFeatureOff[featureKey]
|
||||
return !denied
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// StaffRoleAllowsAdminRoute is the platform console ceiling (not feature keys).
|
||||
// Per contract 04: support_staff → /admin/support only; admin|developer → all.
|
||||
func StaffRoleAllowsAdminRoute(role string, route string) bool {
|
||||
route = strings.ToLower(strings.TrimSpace(route))
|
||||
normalized, _ := NormalizeStaffRole(role)
|
||||
switch normalized {
|
||||
case StaffRoleAdmin, StaffRoleDeveloper:
|
||||
return true
|
||||
case StaffRoleSupportStaff:
|
||||
return strings.HasPrefix(route, "/admin/support")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefaultStaffRoleAllows(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !DefaultStaffRoleAllows(StaffRoleAdmin, "billing.checkout") {
|
||||
t.Fatal("admin allows all")
|
||||
}
|
||||
if !DefaultStaffRoleAllows(StaffRoleDeveloper, "catalog.vector_categories") {
|
||||
t.Fatal("developer allows debug catalog")
|
||||
}
|
||||
if DefaultStaffRoleAllows(StaffRoleSupportStaff, "billing.checkout") {
|
||||
t.Fatal("support_staff denies billing checkout")
|
||||
}
|
||||
if !DefaultStaffRoleAllows(StaffRoleSupportStaff, "support.center") {
|
||||
t.Fatal("support_staff allows support.center")
|
||||
}
|
||||
if DefaultStaffRoleAllows("", "dashboard.overview") {
|
||||
t.Fatal("unknown role denies")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaffRoleAllowsAdminRoute(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !StaffRoleAllowsAdminRoute(StaffRoleAdmin, "/admin/billing") {
|
||||
t.Fatal("admin billing")
|
||||
}
|
||||
if StaffRoleAllowsAdminRoute(StaffRoleSupportStaff, "/admin/billing") {
|
||||
t.Fatal("support_staff no billing")
|
||||
}
|
||||
if !StaffRoleAllowsAdminRoute(StaffRoleSupportStaff, "/admin/support") {
|
||||
t.Fatal("support_staff support queue")
|
||||
}
|
||||
if StaffRoleFromPlatformAdmin(true) != StaffRoleAdmin {
|
||||
t.Fatal("platform admin maps to admin")
|
||||
}
|
||||
if StaffRoleFromPlatformAdmin(false) != "" {
|
||||
t.Fatal("non-admin maps empty")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveStaffAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
admin bool
|
||||
role string
|
||||
wantRole string
|
||||
wantFull bool
|
||||
wantDesk bool
|
||||
wantOnly bool
|
||||
}{
|
||||
{name: "legacy_platform_admin", admin: true, role: "", wantRole: StaffRoleAdmin, wantFull: true, wantDesk: true},
|
||||
{name: "plain_user", admin: false, role: "", wantFull: false, wantDesk: false},
|
||||
{name: "support_staff", admin: false, role: StaffRoleSupportStaff, wantRole: StaffRoleSupportStaff, wantFull: false, wantDesk: true, wantOnly: true},
|
||||
{name: "support_staff_with_admin_flag", admin: true, role: StaffRoleSupportStaff, wantRole: StaffRoleSupportStaff, wantFull: false, wantDesk: true, wantOnly: true},
|
||||
{name: "admin_role", admin: true, role: StaffRoleAdmin, wantRole: StaffRoleAdmin, wantFull: true, wantDesk: true},
|
||||
{name: "developer_role", admin: false, role: StaffRoleDeveloper, wantRole: StaffRoleDeveloper, wantFull: true, wantDesk: true},
|
||||
{name: "unknown_role_ignored", admin: false, role: "superuser", wantFull: false, wantDesk: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := ResolveStaffAccess(tc.admin, tc.role)
|
||||
if got.Role != tc.wantRole {
|
||||
t.Fatalf("role = %q, want %q", got.Role, tc.wantRole)
|
||||
}
|
||||
if got.FullAdmin != tc.wantFull || got.SupportDesk != tc.wantDesk || got.IsSupportOnly != tc.wantOnly {
|
||||
t.Fatalf("got full=%v desk=%v only=%v want full=%v desk=%v only=%v",
|
||||
got.FullAdmin, got.SupportDesk, got.IsSupportOnly, tc.wantFull, tc.wantDesk, tc.wantOnly)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaffCapabilities(t *testing.T) {
|
||||
t.Parallel()
|
||||
adminCaps := StaffCapabilities(StaffRoleAdmin)
|
||||
if len(adminCaps) < 10 {
|
||||
t.Fatalf("admin caps too small: %v", adminCaps)
|
||||
}
|
||||
supportCaps := StaffCapabilities(StaffRoleSupportStaff)
|
||||
if len(supportCaps) != 4 {
|
||||
t.Fatalf("support caps = %v", supportCaps)
|
||||
}
|
||||
for _, c := range supportCaps {
|
||||
if strings.Contains(c, "billing") || strings.Contains(c, "settings") {
|
||||
t.Fatalf("support must not get %s", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaffRoleAllowsAdminRouteContract(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !StaffRoleAllowsAdminRoute(StaffRoleSupportStaff, "/admin/support") {
|
||||
t.Fatal("support_staff should access /admin/support")
|
||||
}
|
||||
if StaffRoleAllowsAdminRoute(StaffRoleSupportStaff, "/admin/billing") {
|
||||
t.Fatal("support_staff must not access billing")
|
||||
}
|
||||
if !StaffRoleAllowsAdminRoute(StaffRoleDeveloper, "/admin/settings") {
|
||||
t.Fatal("developer should access settings")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeStaffRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := NormalizeStaffRole("nope"); err == nil {
|
||||
t.Fatal("expected error for invalid role")
|
||||
}
|
||||
got, err := NormalizeStaffRole(" Support_Staff ")
|
||||
if err != nil || got != StaffRoleSupportStaff {
|
||||
t.Fatalf("got %q err=%v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var ErrTokenInvalid = errors.New("token invalid or expired")
|
||||
|
||||
// IssueSetPasswordToken creates a signed, time-limited token (no DB row).
|
||||
// secret must come from env (TOKEN_SIGNING_SECRET); never commit secrets.
|
||||
func IssueSetPasswordToken(secret string, userID uuid.UUID, ttl time.Duration) (string, error) {
|
||||
if strings.TrimSpace(secret) == "" {
|
||||
return "", errors.New("token signing secret not configured")
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = 72 * time.Hour
|
||||
}
|
||||
exp := time.Now().Add(ttl).Unix()
|
||||
nonce, err := RandomToken(8)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
payload := fmt.Sprintf("%s.%d.%s", userID.String(), exp, nonce)
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(payload))
|
||||
sig := hex.EncodeToString(mac.Sum(nil))
|
||||
raw := payload + "." + sig
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(raw)), nil
|
||||
}
|
||||
|
||||
func ParseSetPasswordToken(secret, token string) (uuid.UUID, error) {
|
||||
if strings.TrimSpace(secret) == "" || strings.TrimSpace(token) == "" {
|
||||
return uuid.Nil, ErrTokenInvalid
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(token)
|
||||
if err != nil {
|
||||
return uuid.Nil, ErrTokenInvalid
|
||||
}
|
||||
parts := strings.Split(string(raw), ".")
|
||||
if len(parts) != 4 {
|
||||
return uuid.Nil, ErrTokenInvalid
|
||||
}
|
||||
userID, err := uuid.Parse(parts[0])
|
||||
if err != nil {
|
||||
return uuid.Nil, ErrTokenInvalid
|
||||
}
|
||||
exp, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil || time.Now().Unix() > exp {
|
||||
return uuid.Nil, ErrTokenInvalid
|
||||
}
|
||||
payload := parts[0] + "." + parts[1] + "." + parts[2]
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(payload))
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
if !hmac.Equal([]byte(expected), []byte(parts[3])) {
|
||||
return uuid.Nil, ErrTokenInvalid
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestIssueAndParseSetPasswordToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
const secret = "test-signing-secret-not-for-prod"
|
||||
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
|
||||
token, err := IssueSetPasswordToken(secret, uid, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueSetPasswordToken: %v", err)
|
||||
}
|
||||
got, err := ParseSetPasswordToken(secret, token)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSetPasswordToken: %v", err)
|
||||
}
|
||||
if got != uid {
|
||||
t.Fatalf("user id = %s, want %s", got, uid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSetPasswordTokenRejectsWrongSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
uid := uuid.New()
|
||||
token, err := IssueSetPasswordToken("secret-a", uid, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueSetPasswordToken: %v", err)
|
||||
}
|
||||
if _, err := ParseSetPasswordToken("secret-b", token); err == nil {
|
||||
t.Fatal("expected invalid token for wrong secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSetPasswordTokenRejectsExpired(t *testing.T) {
|
||||
t.Parallel()
|
||||
uid := uuid.New()
|
||||
const secret = "secret"
|
||||
// Build an already-expired signed token (IssueSetPasswordToken coerces ttl<=0 to 72h).
|
||||
exp := time.Now().Add(-time.Hour).Unix()
|
||||
nonce := "deadbeefdeadbeef"
|
||||
payload := uid.String() + "." + strconv.FormatInt(exp, 10) + "." + nonce
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(payload))
|
||||
sig := hex.EncodeToString(mac.Sum(nil))
|
||||
token := base64.RawURLEncoding.EncodeToString([]byte(payload + "." + sig))
|
||||
if _, err := ParseSetPasswordToken(secret, token); err == nil {
|
||||
t.Fatal("expected expired token to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueSetPasswordTokenRequiresSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := IssueSetPasswordToken("", uuid.New(), time.Hour); err == nil {
|
||||
t.Fatal("expected error when secret is empty")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user