Appearance
Customer Registration & Login
Flow ID: CF-10 | Module(s): eshop, Auth domain | Complexity: High | Last Updated: 2026-07-21 — 4.119.0 release resync: documented #462, #498 and #463 as fixed Known Issues and cross-linked the REST Customer Login Recovery guide
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 cryptographically random token-based email links (64-char hex, 60-minute TTL) — 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; 429 + Retry-After when throttle hit (v1.11, #324); 409 on duplicate email (deliberately retained) |
| 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:295-321
POST /rest/auth/customer/forgot-password
└─ CustomerAuth::forgotPassword() [src/Rest/Auth/CustomerAuth.php:295]
└─ 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
| → silent return (no error, no email)
└─ Adv_mailer::recover_password_mail($email)
|-- bin2hex(random_bytes(32)) → writes active_token
| + active_token_expires_at = now + 3600s
| to shop_customer [application/models/Adv_mailer.php:65]
└─ sends email (template: resetPassword,
registry keys: EMAIL_SHOP_MAILER / PASSWORD_RESET)
Success → 200 {"message": "If an account exists for this email, a reset link has been sent."}
(same body returned for both known and unknown email — see #193)The getCustomerBy() pre-check at src/Domains/Customer/Customer/PasswordResetService.php:36-43 enforces has_access=1 and is_guest=0 before delegating to the mailer, preventing a fatal null-dereference inside recover_password_mail(). Unknown addresses return silently so the response cannot be used to confirm account existence.
Consume reset token (POST /rest/auth/customer/reset-password)
Body accepts newPassword (camelCase) or new_password (snake_case): src/Rest/Auth/CustomerAuth.php:363-375
POST /rest/auth/customer/reset-password
└─ CustomerAuth::resetPassword() [src/Rest/Auth/CustomerAuth.php:363]
└─ PasswordResetService::consumeToken($token, $newPassword)
[src/Domains/Customer/Customer/PasswordResetService.php:48]
|-- 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,
active_token_expires_at=NULL
WHERE active_token=$token
AND active_token_expires_at > now()
Success → 200 {"message": "Password updated."}Cheap validation (empty, length) runs before any DB call — src/Domains/Customer/Customer/PasswordResetService.php:50-60. isValidToken() is called only after all local checks pass.
Token format
The token written to shop_customer.active_token is bin2hex(random_bytes(32)) (application/models/Adv_mailer.php:65) — a 64-character hex string (256 bits of entropy), shared by both the REST and legacy recover_password() storefront flows. It is single-use: resetPassword() (ecommercen/eshop/models/Adv_customer_model.php:197) sets both active_token = NULL and active_token_expires_at = NULL on success. The TTL is 60 minutes, enforced by active_token_expires_at written by set_forgot_password_token() (ecommercen/eshop/models/Adv_customer_model.php:151) using PASSWORD_RESET_TOKEN_TTL = 3600 (ecommercen/eshop/models/Adv_customer_model.php:8). Expiry is checked in both isValidToken() (ecommercen/eshop/models/Adv_customer_model.php:167) and in the resetPassword() UPDATE WHERE clause, so a stale token cannot reset a password even if the pre-check is bypassed.
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 password-reset request flow — shared by the REST endpoint and, since #323, the legacy storefront recover-password form. requestReset() validates email + active-account status, silently skips unknown addresses, 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:36-43) 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 = 64 chars | bin2hex(random_bytes(32)) (application/models/Adv_mailer.php:65) — single-use, 60-minute TTL via active_token_expires_at; consumed by setting both active_token = NULL and active_token_expires_at = NULL |
| Password minimum (REST reset) | 6 characters — enforced at src/Domains/Customer/Customer/PasswordResetService.php:58-60 |
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 Throttle
Since API v1.9 (#5), the REST JWT login endpoints (/rest/auth/admin/login, /rest/auth/customer/login) are brute-force throttled server-side via Advisable\Rest\Auth\LoginThrottle — per submitted identifier (default 5 / 15 min, cleared on success) and per client IP (default 20 / 15 min, never cleared), returning 429 + Retry-After. The same response is returned for both counters and identifiers are counted whether or not the account exists, so a lockout never confirms account existence. Counters live in the shared L2 cache (limits hold across pods); the throttle fails open with a logged warning if the cache backend is down. See Known Issues & Security Gaps item 5 for the per-IP forwarding-header caveat (#322), and item 4 for the separate forgot-password throttle (#193).
Since API v1.11 (#324), POST /rest/auth/customer/register is also brute-force throttled via a third LoginThrottle instance — DI name rest.auth.register_throttle, realm customer-register (src/Rest/Auth/container.php:70-76,78-80; src/Rest/Auth/CustomerAuth.php:23,32,222-228). Defaults: 3 per email + 5 per IP per hour (env vars REST_AUTH_REGISTER_THROTTLE_MAX_PER_EMAIL=3, REST_AUTH_REGISTER_THROTTLE_MAX_PER_IP=5, REST_AUTH_REGISTER_THROTTLE_WINDOW_SECONDS=3600, REST_AUTH_REGISTER_THROTTLE_ENABLED=true — src/Rest/Auth/container.php:26-29). The per-IP cap is deliberately tighter than login's (5 vs 20) because legitimate signups from a single IP are rare (src/Rest/Auth/container.php:66-69). The throttle gate runs after cheap 400 validation so malformed requests never consume quota; recordFailure() is called on every well-formed request — a 409 and a 201 cost the same counter slot, and the counter is never cleared (src/Rest/Auth/CustomerAuth.php:195-208,222-228). Counting happens before the duplicate-email check: counting only unregistered addresses would itself be a free existence oracle; the trade-off is that a not-yet-registered email's per-email quota can be griefed for one window, bounded by the per-IP cap (src/Rest/Auth/CustomerAuth.php:210-228). The 409 "Email already registered." response is deliberately retained — generic-response was declined to avoid breaking the registration UX for real users (src/Rest/Auth/CustomerAuth.php:230-235). Counters in the shared L2 cache; fail-open with a logged warning if the cache is down (src/Rest/Auth/LoginThrottle.php:61-64,150-158). The #322 forwarding-header caveat (noted under login's throttle) applies to the per-IP layer here too; the per-email counter is unaffected.
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 (#193 + #323, fixed) —
POST /rest/auth/customer/forgot-passwordreturns200with the same generic message whether or not an account exists (API v1.10);422remains only for a missing/malformed email. The legacy storefront recover-password form was hardened the same way in #323: thecallback_recoverPasswordEmailValidationexistence-check rule (which showed a visible "email not found" error for unknown addresses) was removed, andAdv_customer::recoverPasswordPost()now routes throughPasswordResetService::requestReset()— so both storefront themes render the existing neutral confirmation (customer.password.recover.alert.success) whether or not the email exists. Residual (both surfaces): a timing side-channel remains (known emails trigger a DB write + SMTP send), mitigated on the REST path by the request throttle below (item 4); the storefront form is still unthrottled (see item 4).Weak reset-token entropy (#193, fixed) — Tokens are now
bin2hex(random_bytes(32))(64 hex chars, 256 bits) generated inAdv_mailer::recover_password_mail(), replacingsha1(time() . $customer->id). Both the REST and legacy storefront flows share this generation path.No token TTL (#193, fixed) —
shop_customer.active_token_expires_at(new migration) is set to +60 min on issue (Adv_customer_model::PASSWORD_RESET_TOKEN_TTL) and enforced in bothisValidToken()andresetPassword()(the consuming UPDATE re-checks expiry, so a stale token cannot reset a password even if a caller skips the pre-check). Tokens with a NULL expiry — i.e. every token issued before the migration — are treated as expired, deliberately invalidating the weak legacy tokens at deploy. Both REST and the legacy storefront flow enforce this (shared model methods).No rate limiting on forgot-password (#193, fixed) —
POST /rest/auth/customer/forgot-passwordis throttled via a secondLoginThrottleinstance (realmforgot-password): 3 requests per email and 10 per IP per hour by default (REST_AUTH_RESET_THROTTLE_*env vars), counting every request (each accepted one can send an email), never cleared,429+Retry-Afterwhen exceeded. The legacy storefront recover-password form is not throttled server-side — #323 closed its enumeration disclosure (item 1) but deliberately left throttling as a follow-up (it also depends on theip_address()trust fix #322 for a per-IP layer). The #322 forwarding-header caveat applies to the REST per-IP layer.Rate limiting on REST JWT login (#5, fixed) —
POST /rest/auth/customer/login(and the admin counterpart) are brute-force throttled since API v1.9: failed attempts are counted per submitted identifier (default 5/15 min, cleared on successful login) and per client IP (default 20/15 min) viaAdvisable\Rest\Auth\LoginThrottle(counters in the shared L2 cache), returning429+Retry-Afterwhen exceeded — the same response for both counters, so a lockout never confirms account existence. Configurable viaREST_AUTH_THROTTLE_*env vars; fail-open with a logged warning if the cache backend is down. Caveat: the per-IP layer relies onMY_Input::ip_address(), which currently trusts client-controlled forwarding headers (GitHub #322) — until that is fixed, the per-identifier counter is the effective control./refreshremains deliberately unthrottled — refresh tokens are high-entropy random values, so online brute-force is impractical.forgot-passwordthrottling landed with #193 (see item 4).registerthrottle landed in commit7b0ae98f96(v1.11, #324 — see item 6); the409duplicate-email response is deliberately retained and the enumeration vector is mitigated by throttle (not closed); the #322 per-IP caveat applies to the register throttle's IP layer as well.Register endpoint enumeration + no throttle (#324, fixed in v1.11) —
POST /rest/auth/customer/registeris brute-force throttled since commit7b0ae98f96(API v1.11): 3 per email + 5 per IP per hour via a thirdLoginThrottleinstance (rest.auth.register_throttle, realmcustomer-register;src/Rest/Auth/container.php:70-76,78-80). The409"Email already registered." response is deliberately retained — generic-response was declined to avoid breaking registration UX for real users (src/Rest/Auth/CustomerAuth.php:230-235). The throttle mitigates the409enumeration vector but does not close it: a caller within quota can still observe409vs201to probe email existence. The per-IP layer is subject to the #322 forwarding-header caveat; the per-email counter is unaffected.Bare empty
200on an uncaught REST error (#462, fixed) — A\Throwableescaping a REST action's success path (e.g. insideTokens::generateAccessToken()/generateRefreshToken()duringCustomerAuth::login()) previously left CI3's default empty200(Content-Type: text/html, 0 bytes):RouterDispatcher::dispatch()caught only\Exception— so PHPErrors escaped entirely — and even when it caught it merely logged and returned without writing a response. Every action is now wrapped byAdvisable\Rest\Support\SafeActionDispatch::invokeControllerAction()(src/Rest/Support/SafeActionDispatch.php:45-64), which catches\Throwableand emits a well-formed500{"message":"Internal server error."}viasendError();RouterDispatcherdispatches through it (application/controllers/RouterDispatcher.php:126) and widened its DI-resolution and middleware catches to\Throwable(:73,:117). CI3 buffers output and flushes only afterdispatch()returns, so the rewritten status + body reaches the client. The401and429paths are unchanged; true uncatchable fatals (OOM / time-limit) remain out of scope and are still handled by the existing_shutdown_handler.Phantom
200carrying an unpersisted refresh token (#498, fixed) — Under CI3db_debug=false(the production default) a failingrefresh_tokenswrite returnedfalsewith no throw, soTokens::generateRefreshToken()handed back a token that was never persisted and login / token-refresh / register replied a normal200carrying it — a later/refreshwith that token then failed.RefreshTokenModel::save()/::delete()now inspect the driver-level error after the write via a privateassertNoDbError()and throw\RuntimeExceptionon a real DB error; a zero-rowDELETE(e.g. a first-ever login with no prior token) is deliberately not treated as an error (src/Rest/Auth/RefreshTokenModel.php:9-28,36-44,61-69). The throw is caught by item 7'sSafeActionDispatch, so these endpoints now surface a500instead of the phantom200. Error-path only — the success contract ({access_token, refresh_token}on a healthy write) is unchanged.Correct credentials rejected on duplicated non-guest rows + NULL-hash deprecation (#463, fixed) —
Adv_customer_model::checkCustomer()previously authenticated against an arbitrary first->row()for an email, so an email owning more than one non-guest row could have valid credentials rejected. It now orders non-guest rows byid ASCand authenticates against whichever duplicate row actually owns the supplied password (ecommercen/eshop/models/Adv_customer_model.php:307-341); the return contract (customer id on success,0on failure) is unchanged and a valid password is still required, so there is no auth bypass.checkPassword()additionally rejects aNULL/empty stored hash before hashing and casts salt/password to string (:414-417), removing a PHP 8.1 null-to-string deprecation that a strict error handler could escalate to a fatal. Root-cause confirmation for the originally-stranded account needs production DB access and remains open — see the REST Customer Login Recovery guide below.
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 |
tests/Integration/Rest/Auth/LoginThrottleContainerTest.php | 3 (lines 75-103) | Container regression guards for #324: register_throttle_is_registered_in_container, register_throttle_resolves_as_login_throttle, customer_auth_resolves_with_all_three_throttles |
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
- AD-53 Email Template Viewer — admin preview of the
reset_passwordtemplate sent byPasswordResetService
Wiki Guide: OAuth provider setup and configuration — see Social Auth Guide. Diagnosing and remediating a stranded REST customer login (duplicate rows, NULL/empty password hash, failed
refresh_tokenswrite) — see REST Customer Login Recovery.