Skip to content

Customer Registration & Login

Flow ID: CF-10 | Module(s): eshop, Auth domain | Complexity: High | Last Updated: 2026-04-27

Business Overview

Customer authentication supports four paths: email/password login, registration, social OAuth (Google/Facebook), and guest checkout. The system uses JWT tokens (REST) alongside traditional sessions (legacy).

Key business behaviors:

  • Guest customers get DB record with random password + is_guest=1 — cannot log in later
  • Social auth creates/links accounts by email (auto-registers with random password if new)
  • Password recovery via SHA1 token-based email links (40-char tokens) — available via both legacy storefront and REST API
  • Triple-SHA1 hashing for customer passwords (legacy); bcrypt for admin
  • Login referrer validated against base_url() to prevent open redirects

API Reference

REST Endpoints

MethodPathAuthDescription
POST/rest/auth/customer/loginNoneLogin with email/password → returns JWT
POST/rest/auth/customer/refreshNoneRefresh access token
POST/rest/auth/customer/registerNoneRegister new account → returns JWT
POST/rest/auth/customer/forgot-passwordNoneRequest password reset email
POST/rest/auth/customer/reset-passwordNoneConsume reset token + set new password
GET/rest/customer/meCustomer JWTGet own profile
POST/rest/customer/meCustomer JWTUpdate own profile
POST/rest/customer/me/passwordCustomer JWTChange password

Legacy Storefront

URLMethodPurpose
/customer/loginlogin()Email/password login
/signupregister()Registration form
/customer/logoutlogout()Clear session
/customer/recover_passwordrecover_password()Reset flow
/customer/google_logingoogleLogin()OAuth2 Google
/customer/facebook_loginfacebookLogin()OAuth2 Facebook

JWT Token Structure

json
{
  "iss": "base_url()",
  "aud": "base_url()",
  "iat": 1234567890,
  "exp": 1234567890,
  "data": {
    "userId": "123",
    "type": "customer"
  }
}

Token expiry configurable via JWT_ACCESS_TOKEN_EXPIRY (default 3600s) and JWT_REFRESH_TOKEN_EXPIRY (default 604800s).


Code Flow — REST Password Reset

Two new anonymous REST endpoints added in commit 747fd2989 handle the full reset cycle.

Request reset (POST /rest/auth/customer/forgot-password)

src/Rest/Auth/CustomerAuth.php:256-267

POST /rest/auth/customer/forgot-password
  └─ CustomerAuth::forgotPassword()               [src/Rest/Auth/CustomerAuth.php:256]
       └─ PasswordResetService::requestReset($email)
               [src/Domains/Customer/Customer/PasswordResetService.php:22]
            |-- empty email        → 422 {errors: {email: "Email is required."}}
            |-- invalid format     → 422 {errors: {email: "Email is invalid."}}
            |-- getCustomerBy([mail, has_access=1, is_guest=0]) → null
            |                      → 422 {errors: {email: "No account found with this email."}}
            └─ Adv_mailer::recover_password_mail($email)
                 |-- sha1(time() . $customer->id) → writes to shop_customer.active_token
                 └─ sends email (template: resetPassword,
                      registry keys: EMAIL_SHOP_MAILER / PASSWORD_RESET)
  Success → 200 {"message": "Reset link sent."}

The getCustomerBy() pre-check at src/Domains/Customer/Customer/PasswordResetService.php:35-44 enforces has_access=1 and is_guest=0 before delegating to the mailer, preventing a fatal null-dereference inside recover_password_mail().

Consume reset token (POST /rest/auth/customer/reset-password)

Body accepts newPassword (camelCase) or new_password (snake_case): src/Rest/Auth/CustomerAuth.php:313

src/Rest/Auth/CustomerAuth.php:309-321

POST /rest/auth/customer/reset-password
  └─ CustomerAuth::resetPassword()                [src/Rest/Auth/CustomerAuth.php:309]
       └─ PasswordResetService::consumeToken($token, $newPassword)
               [src/Domains/Customer/Customer/PasswordResetService.php:49]
            |-- empty token        → 422 {errors: {token: "Token is required."}}
            |-- empty password     → 422 {errors: {newPassword: "New password is required."}}
            |-- password < 6 chars → 422 {errors: {newPassword: "New password must be at least 6 characters."}}
            |-- isValidToken($token) == false
            |                      → 422 {errors: {token: "Invalid token."}}
            └─ customer_model::resetPassword($token, $newPassword)
                 |-- triple-SHA1 encrypt with new salt
                 └─ UPDATE shop_customer
                      SET password=..., salt=..., active_token=NULL
                      WHERE active_token=$token
  Success → 200 {"message": "Password updated."}

Cheap validation (empty, length) runs before any DB call — src/Domains/Customer/Customer/PasswordResetService.php:51-61. isValidToken() is called only after all local checks pass.

Token format

The token written to shop_customer.active_token is sha1(time() . $customer->id) — a 40-character hex string, identical in format to the token used by the legacy recover_password() storefront flow. It is single-use: resetPassword() sets active_token = NULL on success. There is no TTL (hardening tracked in #193).

Routes are registered with a (\w{2})/ locale-prefix variant in application/config/rest_routes.php. The CustomerAuth controller policy defaults to 'auth' => 'none' in application/config/rest_policies.php, so both new endpoints are publicly accessible without change.


Domain Layer

FileResponsibility
src/Domains/Customer/Customer/PasswordResetService.phpWraps legacy Adv_customer_model + Adv_mailer for the REST password reset flow. requestReset() validates email + active-account status then delegates to the legacy mailer. consumeToken() validates token + password then delegates to the legacy model. Constructor is side-effect-free — legacy CI models load lazily via private accessors.
src/Rest/Auth/CustomerAuth.phpREST auth controller. forgotPassword() + resetPassword() delegate entirely to PasswordResetService. login(), refresh(), register() covered in this doc.

Business Rules

RuleDescription
Guests cannot log incheckCustomer() requires is_guest = 0
Guests cannot recover passwordhas_access = 1 and is_guest = 0 required — enforced by PasswordResetService::requestReset() (src/Domains/Customer/Customer/PasswordResetService.php:35-44) and by the legacy storefront
Emails always lowercasedmb_strtolower() before every DB operation
OAuth creates if not existsmapOAuthEmailToCustomer() auto-registers
CSRF on OAuthoauth2state session token validated on callback
Password reset token = 40 charssha1(time() . $customer->id) — single-use, no TTL; consumed by setting active_token = NULL
Password minimum (REST reset)6 characters — enforced at src/Domains/Customer/Customer/PasswordResetService.php:59-61

Security & Rate Limiting

Legacy Admin Login Protection

The Blocking library enforces rate limiting on the legacy admin login path:

  • 5 login attempts per 10-minute window, IP-based
  • Failed attempts tracked in the block_access database table
  • After threshold, the IP is blocked for the remainder of the window

REST API Authentication -- Critical Gap

Critical gap: No rate limiting exists on REST API JWT endpoints (/rest/auth/admin/login, /rest/auth/customer/login). These endpoints are vulnerable to brute-force attacks without external mitigation (e.g., WAF or reverse proxy rate limiting).

JWT Token Role Handling

  • Admin refresh tokens include roles in the JWT payload, enabling role-based access control on token renewal
  • Customer refresh tokens do not include roles -- customer authorization is determined by the REST_FRONTEND_USER type only

Password Verification

  • Modern password check uses password_verify() (admin auth only) supporting bcrypt and argon2i hashes. Customer passwords use triple-SHA1 via Adv_customer_model::checkCustomer().
  • Config-based static users (from config_auth configuration) are checked before database users (admin only) -- allows emergency/backdoor access when DB is unavailable

Known Issues & Security Gaps

  1. Email enumeration on forgot-passwordPOST /rest/auth/customer/forgot-password returns 422 {errors: {email: "No account found with this email."}} when the address is unknown, distinguishing it from other error types. An attacker can confirm whether an email is registered. Hardening (always-200 response) deferred to GitHub #193. src/Domains/Customer/Customer/PasswordResetService.php:41-43

  2. Weak reset-token entropy — Token is sha1(time() . $customer->id). Both components (time() and a numeric customer ID) are predictable or low-entropy. A determined attacker who knows a customer's ID and approximate request time can brute-force the token offline. Replacement with random_bytes(32) deferred to GitHub #193.

  3. No token TTLshop_customer.active_token is never expired by time. A token issued today remains valid indefinitely until consumed. Adding an active_token_expires_at column is deferred to GitHub #193.

  4. No rate limiting on forgot-passwordPOST /rest/auth/customer/forgot-password has no request-rate guard, enabling email-flooding abuse and enumeration amplification. External mitigation (WAF / reverse proxy) required until GitHub #193 is resolved.

  5. No rate limiting on REST JWT endpoints (pre-existing)POST /rest/auth/customer/login and /refresh have no server-side rate limiting. Documented in the Security section above; tracked as a separate gap.


Client Extension Points

TypeDetails
Customer controllerOverride in application/modules/eshop/controllers/
Customer modelOverride password hashing, session data
Auth libraryAdvauth — configurable hash strategy (md5, sha1, bcrypt, argon2i)
Social authRegistry: SOCIAL_AUTH.GOOGLE_CLIENT_ID/SECRET, FACEBOOK_CLIENT_ID/SECRET

Tests

FileTestsWhat is covered
tests/Unit/Domains/Customer/Customer/PasswordResetServiceTest.php9requestReset: empty email, invalid email format, unknown email, known email sends mail. consumeToken: missing token, invalid token, missing password, short password, happy path
tests/Integration/Domains/Customer/Customer/PasswordResetServiceIntegrationTest.php3Real-DB round-trip: token written on requestReset, new password persists and authenticates after consumeToken, old password rejected, token column cleared

Coverage gaps:

  • No REST controller tests for CustomerAuth::forgotPassword() / resetPassword() (controllers are not unit-tested in this codebase)
  • No end-to-end test that verifies the email is actually sent and received

Wiki Guide: OAuth provider setup and configuration — see Social Auth Guide.