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:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+63
View File
@@ -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
}
+28
View File
@@ -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))
}
}
+41
View File
@@ -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
}
}
+327
View File
@@ -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
}
+113
View File
@@ -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")
}
}
+61
View File
@@ -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
}
+173
View File
@@ -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)
}
}
}
+59
View File
@@ -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)
}
}
+491
View File
@@ -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
}
+33
View File
@@ -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
)
+45
View File
@@ -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)
}
}
+39
View File
@@ -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)
}
}
+264
View File
@@ -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")
}
}
+83
View File
@@ -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)
}
}
+69
View File
@@ -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
}
+67
View File
@@ -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")
}
}