Appearance
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
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /rest/auth/customer/login | None | Login with email/password → returns JWT |
| POST | /rest/auth/customer/refresh | None | Refresh access token |
| POST | /rest/auth/customer/register | None | Register new account → returns JWT |
| POST | /rest/auth/customer/forgot-password | None | Request password reset email |
| POST | /rest/auth/customer/reset-password | None | Consume reset token + set new password |
| GET | /rest/customer/me | Customer JWT | Get own profile |
| POST | /rest/customer/me | Customer JWT | Update own profile |
| POST | /rest/customer/me/password | Customer JWT | Change password |
Legacy Storefront
| URL | Method | Purpose |
|---|---|---|
/customer/login | login() | Email/password login |
/signup | register() | Registration form |
/customer/logout | logout() | Clear session |
/customer/recover_password | recover_password() | Reset flow |
/customer/google_login | googleLogin() | OAuth2 Google |
/customer/facebook_login | facebookLogin() | 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
| File | Responsibility |
|---|---|
src/Domains/Customer/Customer/PasswordResetService.php | Wraps 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.php | REST auth controller. forgotPassword() + resetPassword() delegate entirely to PasswordResetService. login(), refresh(), register() covered in this doc. |
Business Rules
| Rule | Description |
|---|---|
| Guests cannot log in | checkCustomer() requires is_guest = 0 |
| Guests cannot recover password | has_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 lowercased | mb_strtolower() before every DB operation |
| OAuth creates if not exists | mapOAuthEmailToCustomer() auto-registers |
| CSRF on OAuth | oauth2state session token validated on callback |
| Password reset token = 40 chars | sha1(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_accessdatabase 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
rolesin the JWT payload, enabling role-based access control on token renewal - Customer refresh tokens do not include
roles-- customer authorization is determined by theREST_FRONTEND_USERtype only
Password Verification
- Modern password check uses
password_verify()(admin auth only) supporting bcrypt and argon2i hashes. Customer passwords use triple-SHA1 viaAdv_customer_model::checkCustomer(). - Config-based static users (from
config_authconfiguration) are checked before database users (admin only) -- allows emergency/backdoor access when DB is unavailable
Known Issues & Security Gaps
Email enumeration on forgot-password —
POST /rest/auth/customer/forgot-passwordreturns422 {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-43Weak 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 withrandom_bytes(32)deferred to GitHub #193.No token TTL —
shop_customer.active_tokenis never expired by time. A token issued today remains valid indefinitely until consumed. Adding anactive_token_expires_atcolumn is deferred to GitHub #193.No rate limiting on forgot-password —
POST /rest/auth/customer/forgot-passwordhas no request-rate guard, enabling email-flooding abuse and enumeration amplification. External mitigation (WAF / reverse proxy) required until GitHub #193 is resolved.No rate limiting on REST JWT endpoints (pre-existing) —
POST /rest/auth/customer/loginand/refreshhave no server-side rate limiting. Documented in the Security section above; tracked as a separate gap.
Client Extension Points
| Type | Details |
|---|---|
| Customer controller | Override in application/modules/eshop/controllers/ |
| Customer model | Override password hashing, session data |
| Auth library | Advauth — configurable hash strategy (md5, sha1, bcrypt, argon2i) |
| Social auth | Registry: SOCIAL_AUTH.GOOGLE_CLIENT_ID/SECRET, FACEBOOK_CLIENT_ID/SECRET |
Tests
| File | Tests | What is covered |
|---|---|---|
tests/Unit/Domains/Customer/Customer/PasswordResetServiceTest.php | 9 | requestReset: 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.php | 3 | Real-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
Related Flows
- CF-05 Cart Management — guest cart merging on login
- CF-06 Order Preview — guest customer creation during checkout
- CF-11 Customer Account — profile management after login
- AD-01 Admin Auth — separate admin auth system
- AD-04 Customer Management — admin customer CRUD
- AD-44 Social Auth Settings — Google/Facebook OAuth admin config
Wiki Guide: OAuth provider setup and configuration — see Social Auth Guide.