Skip to content

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

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; 429 + Retry-After when throttle hit (v1.11, #324); 409 on duplicate email (deliberately retained)
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: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

FileResponsibility
src/Domains/Customer/Customer/PasswordResetService.phpWraps 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.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:36-43) 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 = 64 charsbin2hex(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_access database 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=truesrc/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 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-password (#193 + #323, fixed)POST /rest/auth/customer/forgot-password returns 200 with the same generic message whether or not an account exists (API v1.10); 422 remains only for a missing/malformed email. The legacy storefront recover-password form was hardened the same way in #323: the callback_recoverPasswordEmailValidation existence-check rule (which showed a visible "email not found" error for unknown addresses) was removed, and Adv_customer::recoverPasswordPost() now routes through PasswordResetService::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).

  2. Weak reset-token entropy (#193, fixed) — Tokens are now bin2hex(random_bytes(32)) (64 hex chars, 256 bits) generated in Adv_mailer::recover_password_mail(), replacing sha1(time() . $customer->id). Both the REST and legacy storefront flows share this generation path.

  3. 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 both isValidToken() and resetPassword() (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).

  4. No rate limiting on forgot-password (#193, fixed)POST /rest/auth/customer/forgot-password is throttled via a second LoginThrottle instance (realm forgot-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-After when 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 the ip_address() trust fix #322 for a per-IP layer). The #322 forwarding-header caveat applies to the REST per-IP layer.

  5. 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) via Advisable\Rest\Auth\LoginThrottle (counters in the shared L2 cache), returning 429 + Retry-After when exceeded — the same response for both counters, so a lockout never confirms account existence. Configurable via REST_AUTH_THROTTLE_* env vars; fail-open with a logged warning if the cache backend is down. Caveat: the per-IP layer relies on MY_Input::ip_address(), which currently trusts client-controlled forwarding headers (GitHub #322) — until that is fixed, the per-identifier counter is the effective control. /refresh remains deliberately unthrottled — refresh tokens are high-entropy random values, so online brute-force is impractical. forgot-password throttling landed with #193 (see item 4). register throttle landed in commit 7b0ae98f96 (v1.11, #324 — see item 6); the 409 duplicate-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.

  6. Register endpoint enumeration + no throttle (#324, fixed in v1.11)POST /rest/auth/customer/register is brute-force throttled since commit 7b0ae98f96 (API v1.11): 3 per email + 5 per IP per hour via a third LoginThrottle instance (rest.auth.register_throttle, realm customer-register; src/Rest/Auth/container.php:70-76,78-80). The 409 "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 the 409 enumeration vector but does not close it: a caller within quota can still observe 409 vs 201 to probe email existence. The per-IP layer is subject to the #322 forwarding-header caveat; the per-email counter is unaffected.

  7. Bare empty 200 on an uncaught REST error (#462, fixed) — A \Throwable escaping a REST action's success path (e.g. inside Tokens::generateAccessToken()/generateRefreshToken() during CustomerAuth::login()) previously left CI3's default empty 200 (Content-Type: text/html, 0 bytes): RouterDispatcher::dispatch() caught only \Exception — so PHP Errors escaped entirely — and even when it caught it merely logged and returned without writing a response. Every action is now wrapped by Advisable\Rest\Support\SafeActionDispatch::invokeControllerAction() (src/Rest/Support/SafeActionDispatch.php:45-64), which catches \Throwable and emits a well-formed 500 {"message":"Internal server error."} via sendError(); RouterDispatcher dispatches 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 after dispatch() returns, so the rewritten status + body reaches the client. The 401 and 429 paths are unchanged; true uncatchable fatals (OOM / time-limit) remain out of scope and are still handled by the existing _shutdown_handler.

  8. Phantom 200 carrying an unpersisted refresh token (#498, fixed) — Under CI3 db_debug=false (the production default) a failing refresh_tokens write returned false with no throw, so Tokens::generateRefreshToken() handed back a token that was never persisted and login / token-refresh / register replied a normal 200 carrying it — a later /refresh with that token then failed. RefreshTokenModel::save()/::delete() now inspect the driver-level error after the write via a private assertNoDbError() and throw \RuntimeException on a real DB error; a zero-row DELETE (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's SafeActionDispatch, so these endpoints now surface a 500 instead of the phantom 200. Error-path only — the success contract ({access_token, refresh_token} on a healthy write) is unchanged.

  9. 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 by id ASC and 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, 0 on failure) is unchanged and a valid password is still required, so there is no auth bypass. checkPassword() additionally rejects a NULL/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

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
tests/Integration/Rest/Auth/LoginThrottleContainerTest.php3 (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

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_tokens write) — see REST Customer Login Recovery.