Corebanq Public Docs
Users

Description

Overview

The Users API provides functionality for managing users and authentication:

  • User management (CRUD operations)
  • Multi-factor authentication (MFA)
  • Credential management
  • Password management
  • User registration
  • User roles and permissions
  • User context management
  • User avatar management

Core Concepts

User Credentials

  • email: Email address
  • phone: Phone number
  • telegram: Telegram ID
  • totp: Time-based One-Time Password
  • whatsapp: WhatsApp number

MFA Modes

  • off: MFA disabled
  • email: Email-based MFA
  • phone: SMS-based MFA
  • totp: TOTP-based MFA

MFA Session Binding

Security Feature: The system implements cryptographic session binding for 2FA verification to prevent authentication bypass attacks.

Configuration: auth.2fa_challenge only controls whether /v1/verify-2FA requires the explicit X-MFA-Challenge header. It does not control whether challenge tokens can be used as authenticated bearer tokens — that separation is always enforced.

Backward Compatibility Mode (2fa_challenge: false)

  • /v1/verify-2FA can operate without the explicit X-MFA-Challenge header
  • Challenge tokens are still never accepted on protected routes or refresh endpoints
  • MFA-enabled login responses remain pre-auth only (no authenticated access token / refresh cookie before MFA success)
  • Use Case: Existing deployments, gradual migration of explicit challenge-header enforcement
  • 2FA verification requires cryptographic session binding
  • Prevents authentication bypass via stolen user_id
  • Blocks OTP phishing and session hijacking attacks
  • Security Level: Enhanced with transient session tokens
  • Use Case: Production environments requiring high security

How It Works:

  1. Login (Step 1): User submits username + password
    • Backend validates credentials
    • Generates MFA Challenge Token (JWT, 5-min expiration)
    • Stores token in Redis for single-use validation
    • Returns challenge_token in response
  • Does not issue authenticated access or refresh-token session artifacts yet
  1. 2FA Verification (Step 2): User submits OTP
    • Frontend includes challenge token in X-MFA-Challenge header
    • Backend validates:
      • JWT signature and expiration
      • Token subject is "mfa_challenge"
      • Token's user_id matches request user_id
      • Token exists in Redis (not already used)
      • IP/Device changes (logged for monitoring)
    • After successful OTP validation, token is deleted (single-use)
  2. Result: Authenticated access token and refresh-token cookie are issued only if both password AND OTP are valid in the same session

Security Properties:

  • ✅ Session binding: OTP tied to specific authentication session
  • ✅ Single-use: Challenge token deleted after verification
  • ✅ Time-bound: 5-minute window from login to OTP verification
  • ✅ Cryptographically signed: JWT signature prevents forgery
  • ✅ Prevents authentication bypass: Cannot use OTP without valid challenge token
  • ✅ Prevents session hijacking: Different session cannot reuse challenge token
  • ✅ Prevents session puzzling: challenge_token cannot be replayed as bearer auth on protected routes or refresh
  • ✅ Audit trail: IP/Device changes logged for security monitoring

Addresses Security Audit Finding 7.6: "Authentication bypass via One-Time Password" (CVSS 8.1)

Registration Status

  • pending: Registration initiated
  • verified: Registration completed

Password Policy

  • Minimum length: 8 characters
  • Must contain uppercase letters
  • Must contain lowercase letters
  • Must contain numbers
  • Must contain special characters
  • Expires after configured security.password_policy.expiration_days (default 90 days)
  • Maximum failed attempts: 5
  • Reuse of recent passwords is blocked per security.password_policy.history_count

Password Expiry Status (password_status)

Self-read user responses may include a password_status object so clients can show in-app expiry notices without exposing other users' password metadata. The API omits this field unless user.id equals the authenticated caller's ID, so list and get-by-id responses cannot leak another user's expiry state (IDOR).

Endpointpassword_status present?
GET /v1/users/{id} (self)Yes
GET /v1/users/{id} (other user)No — omitted
GET /v1/usersOnly on the authenticated user's own row in the array
{
  "password_status": {
    "expires_at": "2026-09-02T12:00:00Z",
    "days_until_expiry": 7,
    "expired": false,
    "change_recommended": true
  }
}
  • change_recommended is true when 0 < days_until_expiry <= max(security.password_policy.warning_days).
  • Expired passwords are rejected at login (auth.password_expired); change via PUT /v1/users/{id}/password or the reset flow.

Endpoints

User Registration

Initiate Registration

POST /v1/users/initiate-registration

Start user registration process.

Request Body:

{
  "credential_type": "email",
  "credential_value": "user@example.com",
  "password": "SecureP@ss123",
  "terms_accepted": true,
  "privacy_policy_accepted": true
}

Response:

{
  "message": "OTP sent to user@example.com",
  "user_id": "uuid"
}

Verify Registration

POST /v1/users/verify-registration

Verify registration OTP.

Request Body:

{
  "user_id": "uuid",
  "credential_type": "email",
  "otp": "123456"
}

User Management

Create User

POST /v1/users

Create a new user.

Request Body:

{
  "name": "John Doe",
  "description": "System user",
  "password": "SecureP@ss123",
  "active": true,
  "lang": "en",
  "mfa_mode": "off"
}

Create User with Persona

POST /v1/users/persona

Create user with associated persona.

Request Body:

{
  "user": {
    "name": "John Doe",
    "password": "SecureP@ss123",
    "active": true,
    "lang": "en",
    "mfa_mode": "off"
  },
  "persona": {
    "first_name": "John",
    "last_name": "Doe",
    "date_of_birth": "1990-01-01",
    "nationality": "USA"
  },
  "initiate_registration_input": {
    "credential_type": "email",
    "credential_value": "john@example.com",
    "password": "SecureP@ss123",
    "terms_accepted": true,
    "privacy_policy_accepted": true
  }
}

Get User

GET /v1/users/{id}

Retrieve a user by ID. Response fields mirror the safe user read model plus totp_enabled.

When the path id matches the authenticated user, the response also includes password_status (see Password Expiry Status above). Reading another user's record omits password_status.

Response (self-read excerpt):

{
  "id": "uuid",
  "name": "John Doe",
  "active": true,
  "totp_enabled": false,
  "password_status": {
    "expires_at": "2026-09-02T12:00:00Z",
    "days_until_expiry": 7,
    "expired": false,
    "change_recommended": true
  }
}

List Users

GET /v1/users

List users visible to the caller. Each array entry uses the same read model as Get User. Only the row whose id matches the authenticated user includes password_status; all other entries omit it.

Credential Management

Add Credential

POST /v1/users/credentials

Add new credential to user.

Request Body:

{
  "user_id": "uuid",
  "type": "email",
  "value": "user@example.com",
  "send_otp": true
}

Validate Credential

POST /v1/users/credentials/validate

Validate credential with OTP.

Request Body:

{
  "user_id": "uuid",
  "cred_id": "uuid",
  "otp": "123456"
}

Update Credential

PUT /v1/users/credentials/{cred_id}

Update existing credential.

Request Body:

{
  "value": "new@example.com",
  "preferred": true,
  "active": true,
  "metadata": {
    "verified_at": "2024-03-21T10:00:00Z"
  }
}

Password Management

Change Password

PUT /v1/users/{id}/password

Change user password.

Request Body:

{
  "current_password": "OldP@ss123",
  "new_password": "NewP@ss123"
}

Request Password Reset

POST /v1/users/request-password-reset

Request a password reset link for an email or phone credential. Returns a generic success response when the credential is unknown to prevent enumeration.

Request Body:

{
  "credential_type": "email",
  "credential_value": "user@example.com"
}

Error Responses:

  • 400 Bad Request: Invalid credential type or malformed input
  • 429 Too Many Requests: IP rate limit exceeded (users_m.password_reset_rate_limit_exceeded; includes retry_after, max_attempts, and window in error params)

Reset Password

POST /v1/users/reset-password

Complete a password reset using the token from the reset email.

Request Body:

{
  "token": "reset_token",
  "new_password": "NewP@ss123"
}

Error Responses:

  • 400 Bad Request: Invalid or expired token, password policy violation
  • 429 Too Many Requests: IP rate limit exceeded (users_m.password_reset_rate_limit_exceeded; invalid tokens also increment the failed-attempt window)
  • 503 Service Unavailable: Rate-limit cache unavailable on completion path (fail closed)

Rate Limiting (password reset endpoints):

  • Shared IP counters for request-password-reset and reset-password (isolated from activation limits)
  • Layer 1: max attempts per IP per 15 minutes (security.activation_rate_limiting.max_attempts_per_15min, default 5)
  • Layer 2: max failed reset attempts per IP per hour (security.activation_rate_limiting.max_failed_attempts_per_hour, default 10)
  • Toggle: security.activation_rate_limiting.enabled
  • Request path fails open when cache is unavailable; completion path fails closed with 503

User Roles

Get Users by Role ID

GET /v1/users/by-role/{role_id}

Get all users assigned to a specific role by the role's UUID.

URL Parameters:

  • role_id: UUID of the role

Query Parameters:

  • page: Page number (optional, for pagination)
  • page_size: Number of users per page (optional, for pagination)

Response:

// Without pagination
[
  {
    "id": "uuid",
    "name": "John Doe",
    "description": "Account manager",
    "active": true,
    "lang": "en",
    "mfa_mode": "email"
  },
  // ...more users
]

// With pagination
{
  "users": [
    {
      "id": "uuid",
      "name": "John Doe",
      "description": "Account manager",
      "active": true,
      "lang": "en",
      "mfa_mode": "email"
    },
    // ...more users
  ],
  "total_count": 42,
  "page": 1,
  "page_size": 10
}

Get Users by Role Name

GET /v1/users/by-role-name/{role_name}

Get all users assigned to a specific role by the role's name.

URL Parameters:

  • role_name: Name of the role (e.g., "Administrator", "User")

Response:

[
  {
    "id": "uuid",
    "name": "John Doe",
    "description": "Account manager",
    "active": true,
    "lang": "en",
    "mfa_mode": "email"
  },
  // ...more users
]

Admin Operations

Invite User

POST /v1/users/invite

Invite regular users to the client application. This endpoint creates a new inactive user and sends an invitation email with an activation link.

Security Requirements:

  1. Requires valid JWT token with "Internal" role
  2. Requires Create permission on "users" record type

Request Body:

{
  "email": "user@example.com",
  "first_name": "John",
  "last_name": "Doe"
}

Field Descriptions:

  • email (required): Email address for the new user
  • first_name (optional): User's first name
  • last_name (optional): User's last name

Response (201 Created):

{
  "user_id": "uuid",
  "email": "user@example.com",
  "status": "pending_activation",
  "invite_sent_at": "2026-03-24T10:30:00Z",
  "invite_expires_at": "2026-03-24T10:32:00Z"
}

Error Responses:

  • 400 Bad Request: Invalid input (missing email, invalid email format)
  • 403 Forbidden: User lacks Internal role OR Create permission on users
  • 409 Conflict: User with this email already exists
  • 500 Internal Server Error: Token generation or email sending failed

Example:

curl -X POST https://api.corebanq.com/v1/users/invite \
  -H "Authorization: Bearer {jwt_token}" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "john@example.com",
    "first_name": "John",
    "last_name": "Doe"
  }'

Activate User Account

POST /v1/users/activate

Activate invited user account and set initial password. This is a public endpoint (API key auth only) that validates the invitation token from the email. Activation is transactional (password, activation flags, optional metadata merge, RBAC credential grants); if a step fails, prior steps roll back. Recommended: call /v1/users/verify-invitation first, then call this endpoint with the same token.

Request Body:

{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "password": "SecureP@ss123!",
  "reference": "",
  "terms_accepted": true,
  "privacy_policy_accepted": true
}

Field Descriptions:

  • token (required): Invitation token from the email link
  • password (required): Initial password (min 12 characters, must meet password policy)
  • reference (optional): External reference string persisted on activation
  • terms_accepted (required): Must be true to activate account
  • privacy_policy_accepted (required): Must be true to activate account

Password Requirements:

  • Minimum 12 characters
  • Must contain uppercase letter
  • Must contain lowercase letter
  • Must contain number
  • Must contain special character

Response (200 OK):

{
  "message": "Account activated successfully",
  "redirect_url": "https://app.corebanq.com/login"
}

Error Responses:

  • 400 Bad Request: Invalid/expired token, weak password, missing or false terms_accepted / privacy_policy_accepted
  • 409 Conflict: Token already used, user already active
  • 422 Unprocessable Entity: Inconsistent server state after validation (for example missing email credential row); error code users_m.email_credential_not_found. See mfa_console_policy.md in this package.

Verify Invitation Token

GET /v1/users/verify-invitation

Validate an invitation token before activation and determine whether it belongs to a regular or privileged invitation flow. This is a public endpoint (API key auth only).

Query Parameters:

  • token (required): Invitation token from email link

Response (200 OK):

{
  "valid": true,
  "invite_type": "internal",
  "can_activate": true,
  "expires_at": "2026-03-24T10:32:00Z"
}

Error Responses:

  • 400 Bad Request: Missing token

Invite Privileged User

POST /v1/users/admin/invite

Invite internal platform users with role assignment. This endpoint creates a new inactive user, grants specified roles, and sends an invitation email. The new user's stored mfa_mode is set from auth.internal_invite.mfa_mode in the auth app-config module (totp, email, or phone), not from the tenant default.

Security Requirements:

  1. Requires valid JWT token with "Internal" role
  2. Requires Create permission on "users" record type

Request Body:

{
  "email": "newuser@corebanq.com",
  "first_name": "John",
  "last_name": "Doe",
  "role_ids": [
    "uuid-role-1",
    "uuid-role-2"
  ],
  "department": "Engineering"
}

Field Descriptions:

  • email (required): Email address for the new user
  • first_name (required): User's first name
  • last_name (required): User's last name
  • role_ids (optional): Array of role UUIDs to grant
  • department (optional): Department or team name

Response (201 Created):

{
  "user_id": "uuid",
  "email": "newuser@corebanq.com",
  "status": "pending_activation",
  "invite_sent_at": "2026-03-24T10:30:00Z",
  "invite_expires_at": "2026-03-24T10:32:00Z"
}

Error Responses:

  • 400 Bad Request: Invalid input (missing required fields, invalid email)
  • 403 Forbidden: User lacks Internal role OR Create permission on users
  • 409 Conflict: User with this email already exists
  • 500 Internal Server Error: Token generation failed

Example:

curl -X POST https://api.corebanq.com/v1/users/admin/invite \
  -H "Authorization: Bearer {jwt_token}" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "jane@corebanq.com",
    "first_name": "Jane",
    "last_name": "Smith",
    "role_ids": ["role-uuid-1", "role-uuid-2"],
    "department": "Support"
  }'

Activate Internal User

POST /v1/users/activate-internal

Activate invited internal user account and set initial password. This is a public endpoint (API key auth only) that validates the invitation token from the email. Uses the same transactional activation semantics and compliance requirements as /v1/users/activate.

Request Body:

{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "password": "SecureP@ss123!",
  "reference": "",
  "terms_accepted": true,
  "privacy_policy_accepted": true
}

Field Descriptions:

  • token (required): Invitation token from the email link
  • password (required): Initial password (min 12 characters, must meet password policy)
  • reference (optional): External reference string persisted on activation
  • terms_accepted (required): Must be true to activate account
  • privacy_policy_accepted (required): Must be true to activate account

Password Requirements:

  • Minimum 12 characters
  • Must contain uppercase letter
  • Must contain lowercase letter
  • Must contain number
  • Must contain special character

Response (200 OK):

{
  "message": "Account activated successfully",
  "redirect_url": "https://admin.corebanq.com/login"
}

Error Responses:

  • 400 Bad Request: Invalid or expired token, password doesn't meet requirements, missing or false terms_accepted / privacy_policy_accepted
  • 409 Conflict: Token already used OR user account already active
  • 422 Unprocessable Entity: Same as /v1/users/activate (users_m.email_credential_not_found when the email credential row is missing)
  • 500 Internal Server Error: Server error during activation

Example:

curl -X POST https://api.corebanq.com/v1/users/activate-internal \
  -H "Content-Type: application/json" \
  -d '{
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "password": "MySecureP@ssw0rd!",
    "reference": "",
    "terms_accepted": true,
    "privacy_policy_accepted": true
  }'

Flow:

  1. Admin with Internal role + Create permission invites user via /v1/users/admin/invite
  2. System creates inactive user, grants roles, sends invitation email
  3. Invited user receives email with activation link containing token
  4. User opens verification link, client calls /v1/users/verify-invitation, then sets password via /v1/users/activate-internal
  5. Account becomes active, user can now sign in

Security Features:

  • Tokens are single-use (deleted from cache after activation)
  • Tokens expire after configured TTL (default 2 minutes)
  • Failed activation attempts are rate-limited
  • Passwords are hashed using bcrypt before storage

Resend Invitation (Regular User)

POST /v1/users/{id}/resend-invitation

Regenerate and resend the invitation email for an inactive regular user. This endpoint allows resending the invitation if the original token expired or the email was lost.

URL Parameters:

  • id (required): UUID of the user to resend invitation to

Request Body: None

Response (200 OK):

{
  "user_id": "uuid",
  "email": "user@example.com",
  "status": "invitation_resent",
  "invite_sent_at": "2026-03-24T10:45:00Z",
  "invite_expires_at": "2026-03-24T10:47:00Z"
}

Error Responses:

  • 400 Bad Request: User is already active, no email credential found
  • 403 Forbidden: User lacks Internal role (only internal users can resend invitations)
  • 404 Not Found: User does not exist
  • 429 Too Many Requests: Rate limit exceeded (cooldown active or max attempts reached)
  • 500 Internal Server Error: Token generation or caching failed

Rate Limiting:

  • Cooldown Period: 10 minutes between successive resends for the same user (configurable via users.invite_resend_cooldown_minutes)
  • Sliding Window: Maximum 3 resends per hour per user (configurable via users.invite_resend_max_per_hour)
  • Rate limit error includes retry-after information in response

Example:

curl -X POST https://api.corebanq.com/v1/users/a1b2c3d4-e5f6-7890-abcd-ef1234567890/resend-invitation \
  -H "Authorization: Bearer {jwt_token}"

Resend Invitation (Internal User)

POST /v1/users/{id}/admin/resend-invitation

Regenerate and resend the invitation email for an inactive internal user. This endpoint follows the /admin/ pattern for internal user operations.

URL Parameters:

  • id (required): UUID of the internal user to resend invitation to

Request Body: None

Response (200 OK):

{
  "user_id": "uuid",
  "email": "admin@corebanq.com",
  "status": "invitation_resent",
  "invite_sent_at": "2026-03-24T10:45:00Z",
  "invite_expires_at": "2026-03-24T10:47:00Z"
}

Error Responses:

  • 400 Bad Request: User is already active, no email credential found
  • 403 Forbidden: User lacks Internal role (only internal users can resend invitations)
  • 404 Not Found: User does not exist
  • 429 Too Many Requests: Rate limit exceeded (cooldown active or max attempts reached)
  • 500 Internal Server Error: Token generation or caching failed

Rate Limiting: Same rate limits apply as regular user resend:

  • Cooldown Period: 10 minutes between successive resends
  • Sliding Window: Maximum 3 resends per hour per user
  • Both limits share the same cache keys (user-specific, not endpoint-specific)

Example:

curl -X POST https://api.corebanq.com/v1/users/a1b2c3d4-e5f6-7890-abcd-ef1234567890/admin/resend-invitation \
  -H "Authorization: Bearer {jwt_token}"

Flow:

  1. User never received invitation OR token expired
  2. Admin calls resend endpoint for that user
  3. System validates user is inactive, generates new token
  4. System checks rate limits (cooldown + sliding window)
  5. New invitation email sent with fresh activation link
  6. User receives email and can activate account

Error Handling

All errors follow a standard format:

{
  "code": "error_code",
  "message": "Error description"
}

Error Codes

User Errors

CodeDescription
users_m.invalid_user_inputInvalid user input data
users_m.name_already_existsUsername already taken
users_m.invalid_emailInvalid email format
users_m.invalid_phoneInvalid phone number
users_m.invalid_mfa_modeInvalid MFA mode
users_m.user_already_existsUser with this email already exists
users_m.user_already_activeUser account is already active

Permission Errors

CodeDescription
users_m.insufficient_permissionsUser lacks required permissions
users_m.user_creation_permission_deniedUser lacks Create permission on users

Token Errors

CodeDescription
users_m.invalid_tokenInvalid or expired invitation token
users_m.token_already_usedInvitation token has already been used
users_m.token_generation_failedFailed to generate invitation token

Credential Errors

CodeDescription
users_m.credential_already_validatedCredential already validated
users_m.duplicate_credentialCredential already exists
users_m.invalid_credential_idInvalid credential ID
users_m.credential_not_foundCredential not found
users_m.email_credential_not_foundNo email credential found for user

Rate Limiting Errors

CodeDescription
users_m.invitation_resend_cooldown_activeCooldown period active, must wait before resending
users_m.invitation_resend_limit_exceededMaximum resend attempts exceeded within sliding window

Authentication & MFA Errors

CodeDescription
auth_m.missing_challenge_tokenAuthentication challenge token required for 2FA operations (when 2fa_challenge: true)
auth_m.invalid_challengeInvalid or expired MFA challenge token
auth_m.challenge_already_usedMFA challenge token has already been used (single-use enforcement)
auth_m.invalid_or_expired_otpInvalid or expired one-time password

Note: Authentication errors prefixed with auth_m. are defined in the common/auth module and apply across all authentication flows including login, 2FA verification, and TOTP operations.

Implementation Notes

User Invitation System

The invitation system supports two distinct flows for creating users without requiring password upfront:

Regular User Invitation (/v1/users/invite)

  • Purpose: Invite users to the client application
  • Authorization: Requires JWT + Internal role + Create permission on users
  • Activation URL: {main.application.url}/activate?token={token}
  • Role Assignment: No roles granted during invitation (assigned after activation)
  • Email Template: user_invite (configured in email_templates.yaml)
  • Token Cache Prefix: user_invite:

Internal User Invitation (/v1/users/admin/invite)

  • Purpose: Invite internal platform users to the admin application
  • Authorization: Requires JWT + Internal role + Create permission on users
  • Activation URL: {main.application.admin_url}/verify-invitation?token={token}
  • Role Assignment: Automatically grants Internal role + optional additional roles
  • Email Template: internal_user_invite (includes role listing)
  • Token Cache Prefix: internal_user_invite:

Token Security

JWT Structure:

  • Subject: user_invitation (regular) or internal_user_invitation (internal)
  • Claims: user_id, email, invited_by, exp
  • TTL: Configurable via auth.access_token_ttl_minutes (default: 2 minutes)
  • Signing: Uses server's JWT secret key

Single-Use Validation:

  1. Token stored in Redis cache with prefix + token as key
  2. On activation, token is validated from cache
  3. After successful activation, token is deleted from cache
  4. Subsequent use of same token returns token_already_used error

Email Configuration

Email templates are configured in config/samples/email_templates.yaml:

email_templates:
  user_invite:
    subject:
      en: "You're invited to Corebanq"
    body:
      en: |
        Hello {{.FirstName}},
        
        You've been invited to join Corebanq...
        {{.ActivationLink}}
        
        This link expires at {{.ExpiresAt}}
  
  internal_user_invite:
    subject:
      en: "Welcome to Corebanq Platform"
    body:
      en: |
        Hello {{.FirstName}},
        
        You've been invited as an internal user...
        Roles: {{range .RoleNames}}{{.}}{{end}}

Rate Limiting for Invitation Resends

To prevent abuse of the resend invitation feature, a combined rate limiting strategy is enforced:

Dual Protection Mechanism:

  1. Cooldown Period (Prevents rapid successive resends)

    • Default: 10 minutes between resends for the same user
    • Configurable: users.invite_resend_cooldown_minutes in main.yaml
    • Cache key: user_invite_cooldown:{user_id}
    • TTL: Cooldown duration + 1 minute buffer
  2. Sliding Window (Prevents sustained abuse)

    • Default: Maximum 3 resends per hour per user
    • Configurable: users.invite_resend_max_per_hour in main.yaml
    • Cache key: user_invite_attempts:{user_id}
    • TTL: 1 hour (rolling window)

Configuration Example:

users:
  invite_resend_cooldown_minutes: 10  # Min time between resends
  invite_resend_max_per_hour: 3       # Max resends in 1-hour window

Rate Limit Response (429 Too Many Requests):

{
  "code": "users_m.invitation_resend_cooldown_active",
  "message": "In cooling period for 8m 32s, 512 second(s) left before resending invitation",
  "params": {
    "cooldown_period": "8m 32s",
    "wait": 512
  }
}

or:

{
  "code": "users_m.invitation_resend_limit_exceeded",
  "message": "Maximum invitation resend limit reached. You can send up to 3 invitations per 1 hour",
  "params": {
    "max_attempts": 3,
    "window": "1 hour"
  }
}

Implementation Details:

  • Rate limits are user-specific (not endpoint-specific): both regular and internal resend endpoints share the same counters
  • Cooldown starts immediately after each successful resend
  • Attempts counter increments with each resend, resets after 1 hour
  • Cache failures are logged but non-fatal (resend proceeds if cache is unavailable)
  • Context-aware logging includes request_id for traceability

Database Schema

Users Table:

  • active: Set to false during invitation, true after activation
  • email_verified: Set to true after successful activation
  • hashed_password: Set during activation (not during invitation)
  • last_password_change_at: Set to activation timestamp
  • password_expires_at: Set to activation timestamp + expiration duration

Persona Linking:

  • For internal users: First name + Last name stored in personas table
  • Linked via persona_links table with rec_type_name = 'users'
  • For regular users: Persona is optional, created only if names provided

Role Assignment:

  • Internal users: Insert into user_roles table with role_id = Internal role UUID
  • Additional roles (if provided): Inserted as separate user_roles records
  • All role assignments done within transaction during user creation

Configuration Requirements

Required config keys in main.yaml:

application:
  url: https://app.corebanq.com        # Client app URL
  admin_url: https://admin.corebanq.com # Admin app URL

auth:
  access_token_ttl_minutes: 2           # Invitation token TTL
  jwt_secret: "your-secret-key"         # JWT signing key

email:
  service: "smtp"                       # Email service (smtp, ses, etc.)
  from: "noreply@corebanq.com"         # From address

Error Messages

All error messages are multi-lingual, defined in config/samples/users_m.yaml:

messages:
  user_already_exists:
    en: "User with email {{.email}} already exists"
    de: "Benutzer mit E-Mail {{.email}} existiert bereits"
    fr: "L'utilisateur avec l'email {{.email}} existe déjà"
  
  invalid_token:
    en: "Invalid or expired invitation token"
    de: "Ungültiges oder abgelaufenes Einladungstoken"
    fr: "Jeton d'invitation invalide ou expiré"
  
  token_already_used:
    en: "This invitation has already been used"
    de: "Diese Einladung wurde bereits verwendet"
    fr: "Cette invitation a déjà été utilisée"

Testing with Postman

The Postman collection includes 4 new requests:

  1. Invite User - Test regular user invitation
  2. Invite Privileged User - Test privileged user invitation with roles
  3. Activate User Account - Test activation with password
  4. Activate Privileged User Account - Test privileged activation

Set these Postman environment variables:

  • {{base_url}} - API base URL
  • {{access_token}} - JWT token with Internal role
  • {{api_key}} - API key for public endpoints
  • {{invitation_token}} - Token from invitation email (for activation tests)

Password Errors

CodeDescription
users_m.password_changedPassword successfully changed
users_m.otp_error_validatingError validating OTP
users_m.totp_invalid_codeInvalid TOTP code

Authentication & MFA Security Errors

CodeDescriptionWhen Occurs
auth_m.missing_challenge_tokenMFA challenge token required2FA operation without X-MFA-Challenge header when 2fa_challenge: true
auth_m.invalid_challengeInvalid or expired challenge tokenJWT signature invalid, expired, or wrong subject type
auth_m.challenge_already_usedChallenge token already usedToken not in Redis cache (already used or expired)
auth_m.invalid_or_expired_otpInvalid or expired OTPOTP validation failed

Note: Authentication errors (prefixed auth_m.) are defined in common/auth module and shared across authentication endpoints.


Security Features

MFA Session Binding (Authentication Bypass Prevention)

Resolves: Security Audit Finding 7.6 - "Authentication bypass via One-Time Password" (CVSS 8.1 - HIGH)

Status: ✅ FULLY IMPLEMENTED

Overview

The authentication system implements cryptographic session binding for 2FA verification to prevent attackers from bypassing password authentication and accessing accounts using only user_id + OTP.

Configuration Flag: auth.2fa_challenge in the auth app-config module

Security Modes

1. Enhanced Security Mode (2fa_challenge: true) - RECOMMENDED

Requires MFA Challenge Token for all 2FA operations:

  • ✅ Prevents authentication bypass attacks
  • ✅ Binds OTP verification to successful password authentication
  • ✅ Single-use token enforcement
  • ✅ 5-minute session window
  • ✅ Cryptographic JWT signature validation

Flow:

Login → Generate Challenge Token → Store in Redis → Return to Client

Client submits OTP with Challenge Token in X-MFA-Challenge header

Validate: JWT signature + user_id match + Redis cache + expiration

Success: Issue access/refresh tokens + Delete challenge token

2. Backward Compatible Mode (2fa_challenge: false) - LEGACY

OTP-only validation without session binding:

  • ⚠️ Vulnerable to authentication bypass if attacker obtains user_id
  • ⚠️ No protection against session hijacking
  • ✅ Maintains compatibility with older clients
  • ✅ Suitable for gradual migration

Attack Scenarios Prevented

Scenario 1: Stolen user_id + Phished OTP

Attacker has: user_id="abc-123", otp="123456"
Attacker attempts: POST /v1/verify-2FA with stolen data
Result (2fa_challenge: true): ❌ BLOCKED - Missing valid challenge token
Result (2fa_challenge: false): ✅ SUCCESS - Vulnerability exploited

Scenario 2: Session Hijacking

Attacker intercepts: login response with challenge_token
Victim receives: OTP code
Attacker attempts: POST /v1/verify-2FA with stolen challenge + guessed OTP
Result: ❌ BLOCKED - OTP validation fails + rate limiting applies

Scenario 3: Replay Attack

Attacker captures: Used challenge_token from network traffic
Attacker attempts: Reuse challenge token with new OTP
Result: ❌ BLOCKED - Token deleted from Redis after first use

Implementation Details

Challenge Token Structure (JWT):

{
  "user_id": "abc-123-def",
  "ip_address": "192.168.1.1",
  "device_id": "device-uuid",
  "issued_at": "2026-03-24T10:00:00Z",
  "sub": "mfa_challenge",
  "exp": 1711274700,
  "iat": 1711274400,
  "jti": "unique-token-id"
}

Validation Steps:

  1. Extract X-MFA-Challenge header from request
  2. Validate JWT signature (HMAC-SHA256)
  3. Verify sub claim equals "mfa_challenge"
  4. Verify exp not expired (5 minutes)
  5. Verify user_id in token matches request
  6. Check token exists in Redis (key: mfa_challenge:{token})
  7. Log IP/Device changes (monitoring, not blocking)
  8. After OTP success: Delete token from Redis

Endpoints Affected:

  • POST /v1/verify-2FA - Requires X-MFA-Challenge header (when enabled)
  • POST /v1/auth/totp/setup - Requires challenge token in Authorization header (when enabled)
  • POST /v1/auth/totp/verify - Requires challenge token in Authorization header (when enabled)

Frontend Integration:

// Step 1: Store challenge token from login response
const loginResponse = await api.post('/v1/authenticate/admin', { username, password });
const challengeToken = loginResponse.data.challenge_token;

// Step 2: Include in header for 2FA verification
await api.post('/v1/verify-2FA', 
  { user_id, otp },
  { headers: { 'X-MFA-Challenge': challengeToken } }
);

Migration Guide

Step 1: Verify Implementation

  • ✅ Backend version supports challenge tokens
  • ✅ Frontend sends X-MFA-Challenge header
  • ✅ Redis cache available

Step 2: Enable in Configuration

# auth app-config module
2fa_challenge: true

Step 3: Test Complete Flow

  • Login with password → Receive challenge_token
  • Submit OTP with X-MFA-Challenge header → Success
  • Attempt OTP without header → 400 Bad Request
  • Attempt with used token → 401 Unauthorized

Step 4: Monitor

  • Check Redis cache hit rates
  • Review logs for IP/Device change warnings
  • Monitor authentication failure rates

Compliance & Audit

Standards Met:

  • ✅ OWASP recommendation for 2FA session binding
  • ✅ NIST SP 800-63B guidelines for multi-factor authentication
  • ✅ Iterasec Security Audit Finding 7.6 remediated

Audit Trail:

  • All challenge token validations logged with request_id
  • IP/Device changes logged for security monitoring
  • Token generation/validation errors logged for incident response

Risk Assessment:

  • Before Implementation: HIGH (CVSS 8.1) - Authentication bypass possible
  • After Implementation (Enabled): LOW - Requires stealing both challenge token AND OTP within 5 minutes
  • After Implementation (Disabled): HIGH - Same risk as before (backward compatibility)

TOTP Enforcement for Internal Users

Purpose: Mandatory TOTP authentication for privileged accounts provides stronger security than email/phone OTP.

Automatic Enforcement:

  • All new internal/privileged users are created with mfa_mode = "totp" by default
  • System automatically detects if TOTP is configured during login
  • Users without TOTP setup receive credential_type: "totp_setup_required" response
  • Frontend redirects to TOTP setup flow before completing authentication

Setup Flow:

  1. Internal user activates account and attempts login
  2. API detects mfa_mode = "totp" but totp_enabled = false
  3. API returns challenge token with setup-required status
  4. User generates TOTP secret and scans QR code via /v1/totp/setup
  5. User verifies TOTP code via /v1/totp/verify to complete setup
  6. TOTP is enabled (totp_enabled = true)
  7. Subsequent logins prompt for TOTP code

Security Benefits:

  • ✅ Phishing resistance - harder to steal than email/SMS OTP
  • ✅ No external dependencies - works offline
  • ✅ Industry standard - compatible with Google Authenticator, Authy, 1Password
  • ✅ Mandatory for administrators - ensures privileged accounts have strong 2FA
  • ✅ Recovery codes - users can regain access if device is lost

Existing Users: Update existing internal users to TOTP mode via SQL migration script:

UPDATE users.users
SET mfa_mode = 'totp'
WHERE id IN (
    SELECT DISTINCT ur.user_id
    FROM users.user_roles ur
    JOIN users.roles r ON ur.role_id = r.id
    WHERE r.name IN ('Internal', 'Administrator')
)
AND mfa_mode != 'totp';

Implementation Locations:

  • TOTP endpoints: /connectors/totp/ (setup, verify, disable, recover)

Rate Limiting for Invitation Resends

Purpose: Prevent abuse of invitation resend functionality through dual protection mechanism.

Dual Protection Strategy:

1. Cooldown Period (Prevents rapid successive resends)

  • Default: 10 minutes between resends for the same user
  • Configuration: users.invite_resend_cooldown_minutes in main.yaml
  • Cache key: user_invite_cooldown:{user_id}
  • Blocks immediate retries after sending invitation

2. Sliding Window (Prevents sustained abuse)

  • Default: Maximum 3 resends per hour per user
  • Configuration: users.invite_resend_max_per_hour in main.yaml
  • Cache key: user_invite_attempts:{user_id}
  • Prevents sustained attack over time with multiple resend attempts

Configuration Example:

users:
  invite_resend_cooldown_minutes: 10  # Min time between resends
  invite_resend_max_per_hour: 3       # Max resends in 1-hour window

Recommended Profiles:

  • Strict: cooldown=15min, max=2/hour - High security environments
  • Balanced: cooldown=10min, max=3/hour - Default (production)
  • Lenient: cooldown=5min, max=5/hour - Development/testing

Rate Limit Behavior:

  • Limits are user-specific (not endpoint-specific)
  • Both regular and internal resend endpoints share the same counters
  • Cache failures are logged but non-fatal (service remains available)
  • HTTP 429 response includes retry-after information in error params

Error Responses:

  • users_m.invitation_resend_cooldown_active - Cooldown period still active (includes seconds remaining)
  • users_m.invitation_resend_limit_exceeded - Max attempts exceeded within sliding window

Implementation:

  • Context-aware logging includes request_id for traceability

Security Benefits:

  • ✅ Prevents email bombing attacks
  • ✅ Prevents token enumeration attempts
  • ✅ Reduces email service costs from abuse
  • ✅ Graceful degradation if cache unavailable
  • ✅ Clear user feedback with retry-after guidance

Additional Security Documentation

For complete security audit findings and other security features:

  • Security Audit: See /test-data/security/todo.md for all security audit findings

On this page