Appearance
Admin Authentication & SSO
Flow ID: AD-01 Module(s): auth, sso, advisable Complexity: Medium Last Updated: 2026-06-12
Business Overview
Admin authentication supports three providers: database auth (default), static config auth (fallback), and OAuth2/OIDC SSO via Keycloak. The system uses 9 roles mapped to dedicated admin panel home pages. Rate limiting (IP-based, 5 attempts / 10-minute window) protects the session-based login against brute force. The REST admin login is separately brute-force throttled by LoginThrottle (#5): 5 failures per identifier and 20 failures per IP within a 15-minute fixed window result in a 429 Too Many Requests response with a Retry-After header. In addition to session-based auth for the admin panel, the REST API layer provides stateless JWT authentication for admin users with access + refresh token pairs.
The auth module also includes an internal task management system for admin users (create, assign, complete tasks), and an audit logging subsystem that records admin actions to a dedicated table.
API Reference
REST Endpoints (JWT-Based)
| Method | Path | Controller | Description |
|---|---|---|---|
| POST | /rest/auth/admin/login | Advisable\Rest\Auth\AdminAuth::login | Authenticate admin user, returns JWT access + refresh tokens. Returns 429 + Retry-After when brute-force throttle fires. |
| POST | /rest/auth/admin/refresh | Advisable\Rest\Auth\AdminAuth::refresh | Exchange refresh token for new access token |
Admin Panel Routes (Session-Based)
| Route | Controller | Description |
|---|---|---|
advisable/login | Adv_login::login | Login form + POST handler |
advisable/login/logout | Adv_login::logout | Destroy session, redirect to login |
advisable/sso/login | AdvSso::login | Redirect to Keycloak authorization URL |
advisable/sso/callback | AdvSso::callback | OAuth2 code exchange callback |
advisable/sso/logout | AdvSso::logout | Keycloak end-session + local session destroy |
User management routes (auth, auth/add, auth/edit/{id}, auth/delete/{id}, auth/undelete/{id}, auth/changePassword/{id}, auth/changeMyPassword) are documented in (see AD-58 Admin User Management for the full legacy and REST route tables, RBAC policies, and endpoint details).
Task management routes (auth/tasks, auth/myTasks, auth/tasksTo, etc.) are documented in AD-55 Task Management.
Code Flow
Database Login Flow (Session-Based)
Adv_login::login()
|-- Load Blocking library
|-- POST: username + pwd
| |-- Advauth::login($username, $password)
| | |-- Check $dbAuth flag (from config 'db_auth')
| | |-- users_model->getByUsername($username)
| | |-- Guard: $user->soft_delete must be 0
| | |-- advAuthVerify($password, $user->password) [password_verify for bcrypt/argon2i]
| | |-- user_role_model->getUserRolesIds($user->id) -> array of role IDs
| | |-- session->set_userdata([uid, username, roles, loggedIn, uprovider=2])
| | \-- return true
| |-- blocking->attempt(true) [clears IP record on success]
| |-- roles_model->getRole($roles[0]) -> get role home page
| \-- redirect($firstRole->home)
|-- On failure:
| |-- blocking->attempt(false) [increments IP counter]
| \-- render['wrong_data'] = 1
\-- blocking->allowAttempt() -> false if >=5 attempts within 10 min -> redirect to adminConfig Auth Flow (Fallback)
Advauth::login()
|-- Check $configAuth flag (from config 'simple_auth')
|-- Read config_auth array from auth.php
|-- Plain-text comparison: username == $user['username'] && password == $user['password']
|-- session->set_userdata([uid=null, username, roles, loggedIn, uprovider=1])
\-- return trueConfig users are defined in application/config/auth.php via environment variables:
APP_ADMIN_USERNAME/APP_ADMIN_PWDwithAUTH_ROLE_ADMINAPP_ADMIN_ADVISABLE_USERNAME/APP_ADMIN_ADVISABLE_PWDwithAUTH_ROLE_ADVISABLE
SSO Login Flow (Keycloak OIDC)
AdvSso::login()
|-- SingleSignOn::getLoginUrl()
| |-- OIDC discovery: GET {keycloak}/realms/{realm}/.well-known/openid-configuration [cached via PSCache]
| |-- Build URL: authorization_endpoint + client_id + response_type=code + scope=openid roles
| \-- redirect to Keycloak
|
AdvSso::callback()
|-- Verify ?iss matches expected issuer ({server}/realms/{realm})
|-- failOnError(?error)
|-- expectAuthorizationCode(?code)
|-- exchangeAuthorizationCodeForTokens($code)
| |-- POST to token_endpoint with grant_type=authorization_code
| |-- client_id + client_secret from env
| \-- returns [access_token, refresh_token, full_response]
|-- decodeAccessToken($accessToken)
| |-- Extract kid from JWT header
| |-- Match kid against JWKs (cached from jwks_uri)
| |-- Extract x5c certificate, build PEM
| \-- JWT::decode($accessToken, new Key($pem, $alg))
|-- determineRoleFromAccessToken($decoded)
| |-- Read groups claim from token
| |-- Build group paths to check (org+site combos with defaults):
| | /group-org-{orgId}/group-site-{siteId}/role-{roleName}
| |-- Cross-reference realm_access.roles (prefixed 'ecommercen-v4-{role}')
| |-- Match group path against ROLE_MAPPING
| \-- Return AUTH_ROLE_* constant
|-- session->set_userdata([sso_access_token, sso_refresh_token, uid=null, username=$name, roles=[$role], loggedIn, uprovider=3])
\-- redirect to role home pageSSO Session Validation (Every Admin Page Load)
The Adv_admin_controller constructor calls validateSSOSession() on every request:
validateSSOSession() [ecommercen/helpers/auth_helper.php]
|-- Skip if uprovider != AUTH_PROVIDER_SSO
|-- Try to decode current sso_access_token
|-- On DecodeTokenException (expired):
| |-- refreshAccessToken(sso_refresh_token)
| | |-- POST to token_endpoint with grant_type=refresh_token
| | \-- returns new [access_token, refresh_token]
| |-- Decode new access token
| |-- Determine new role
| \-- Update session with new tokens + roles
|-- On any other SSO exception:
| |-- Destroy session data
| \-- Re-throw (controller catches and redirects)REST API JWT Auth Flow
AdminAuth::login() [AdminAuth.php:59]
|-- Read username + password from JSON body or POST [AdminAuth.php:61-63]
|-- throttle->retryAfter('admin-login', ip, username) [AdminAuth.php:70]
| |-- Checks per-identifier counter and per-IP counter in L2 cache
| |-- Returns seconds-to-retry (int) when either limit is exceeded
| \-- Returns null when attempt is allowed
|-- On throttle hit: set Retry-After header, return 429 [AdminAuth.php:71-73]
|-- Advauth::statelessAdminLogin($username, $password) [AdminAuth.php:76]
| |-- Check DB auth: users_model->getByUsername, password_verify
| |-- Check config auth: plain-text match
| \-- Return user ID or null
|-- On invalid credentials:
| |-- throttle->recordFailure('admin-login', ip, username) [AdminAuth.php:77]
| | |-- Increments per-identifier counter in L2 cache
| | \-- Increments per-IP counter in L2 cache
| \-- Return 401 [AdminAuth.php:78]
|-- throttle->clearIdentifier('admin-login', username) [AdminAuth.php:82]
| \-- Deletes per-identifier counter (IP counter is NOT cleared)
|-- getUserRoles($userId)
| |-- Check config_auth users first (match by username)
| \-- Fall back to user_role_model->getUserRolesIds
|-- Tokens::generateAccessToken($userId, REST_BACKEND_USER, $roles)
| |-- JWT payload: iss, aud, iat, nbf, exp, data{userId, type, roles}
| \-- Signed with HS256 (configurable)
|-- Tokens::generateRefreshToken($userId, REST_BACKEND_USER)
| |-- Delete existing refresh tokens for user
| |-- Generate 64-char random hex token
| \-- Store in refresh_tokens table with expiry
\-- Return {access_token, refresh_token}
AdminAuth::refresh()
|-- Validate refresh token from refresh_tokens table
|-- Check expiry and user_type == REST_BACKEND_USER
\-- Return new access_token with current rolesDomain Layer
Legacy Auth Module (ecommercen/auth/)
| File | Description |
|---|---|
controllers/Adv_auth.php | User CRUD + task management (598 lines). Role-gated: ADVISABLE+ADMIN for user management. ADVISABLE-only users cannot see Advisable/Developer roles in dropdown. Extensible via afterAdd/afterEdit/afterDelete hooks. |
models/Adv_users_model.php | Users table CRUD + audit log queries. Soft-delete pattern. guardUserEdit() prevents password/soft_delete modification via edit. |
models/Adv_roles_model.php | DB-based roles. Constructor reads the roles table. Provides dropdown, getRoles, getRole, getRolesByIds, getRoleByName, getRolesByName. |
models/Adv_user_role_model.php | Junction table user_role management. Diff-based update: calculates roles to add/remove. |
models/Adv_tasks_model.php | Tasks CRUD — see AD-55 Task Management for full model reference. |
SSO Module (ecommercen/sso/)
| File | Description |
|---|---|
controllers/AdvSso.php | Login, callback, logout endpoints. Extends Base_c (not Admin_c -- unauthenticated entry point). Uses guard() wrapper for exception handling. |
Modern SSO Layer (src/Auth/SingleSignOn/)
| File | Description |
|---|---|
SingleSignOn.php | Complete OIDC client (472 lines). Handles discovery, JWK loading, token exchange, token refresh, JWT decoding, group-based role determination. |
Exception/BaseException.php | Base with HTTP status code support |
Exception/NotEnabledException.php | SSO feature flag disabled |
Exception/AuthorizationCodeMissingException.php | Missing ?code parameter |
Exception/AuthorizationExchangeFailedException.php | Token exchange HTTP error |
Exception/DecodeTokenException.php | JWT decode failure (expired, malformed, no matching key) |
Exception/JWKsIrretrievableException.php | Cannot fetch JWK set |
Exception/MissingGroupsClaimException.php | JWT has no groups claim |
Exception/OpenIDConfigurationIrretrievableException.php | Cannot fetch OIDC discovery |
Exception/RoleMismatchException.php | Group matched but role not in ROLE_MAPPING |
Exception/RoleMissingException.php | No matching group path found |
Exception/TokenRefreshFailedException.php | Refresh token exchange failed |
Exception/UnexpectedIssuerException.php | ?iss does not match expected realm |
REST Auth Layer (src/Rest/Auth/)
| File | Description |
|---|---|
AdminAuth.php | Admin JWT login + refresh (164 lines). OpenAPI annotated. Brute-force throttle injected as $throttle. |
LoginThrottle.php | Fixed-window brute-force throttle (159 lines). Two counters per realm (per-identifier + per-IP), counted on failed attempts only, stored in L2 cache. Fail-open: cache errors are logged as warnings and logins proceed unthrottled. |
CustomerAuth.php | Customer JWT login + refresh (separate system). |
Tokens.php | JWT generation (access) and refresh token generation/validation. Uses firebase/php-jwt. |
Config.php | Config DTO: key, algorithm, accessTokenExpire, refreshTokenExpire. |
RefreshTokenModel.php | CRUD for refresh_tokens table. Extends CI_Model. |
TokenProtect.php | Middleware-style JWT validation for protected endpoints. |
BackendGuard.php / FrontendGuard.php / AnyGuard.php | Route guards by user type. |
RemoveExpiredRefreshTokens.php | Cleanup job for expired refresh tokens. |
Auth Helper (ecommercen/helpers/auth_helper.php)
Global functions loaded on every admin request:
isLoggedIn()-- checks sessionloggedInvalidateSSOSession()-- auto-refreshes SSO tokensallowRole($roles, $roleIds)-- checks if any session role matches allowed listadvAuthHash($val, $token)-- delegates toAdvauth::advAuthHash()isAdvisableUser(),isDeveloper(),isAdmin(),isCmsUser(),isProductsUser(),isReportingUser(),isOrdersUser(),isMarketingUser(),isMediaUser()-- individual role checksmergeUsersRoles($users, $usersRoles)-- decorates user objects with rolesallowedUsers($users, $roles)-- filters users by roles
Architecture
Authentication Providers
| ID | Constant | Source | Password Handling | Session uid |
|---|---|---|---|---|
| 1 | AUTH_PROVIDER_CONFIG | application/config/auth.php (config_auth) | Plain-text comparison | null |
| 2 | AUTH_PROVIDER_DB | users table | password_verify() (bcrypt/argon2i) | User ID |
| 3 | AUTH_PROVIDER_SSO | Keycloak OIDC | JWT signature verification | null |
Provider priority in Advauth::login(): DB auth checked first, then config auth. Both can be enabled simultaneously via config flags db_auth and simple_auth.
9 Admin Roles
| Constant | ID | Name | Home Route | Description |
|---|---|---|---|---|
AUTH_ROLE_ADVISABLE | 1 | Advisable | advisable | Superuser -- platform developers |
AUTH_ROLE_DEVELOPER | 2 | Developer | advisable | Development access |
AUTH_ROLE_ADMIN | 3 | Admin | advisable | Client admin |
AUTH_ROLE_CMS | 4 | CMS | category/category_admin | Content management |
AUTH_ROLE_PRODUCTS | 5 | Products | products_admin | Product catalog |
AUTH_ROLE_ORDERS | 6 | Orders | orders_admin | Order management |
AUTH_ROLE_REPORTING | 7 | Reporting | eshop/reporting | Reports & analytics |
AUTH_ROLE_MARKETING | 8 | Marketing | promo_admin | Campaigns & promotions |
AUTH_ROLE_MEDIA | 9 | Media | sliders/sliders_admin | Slider/media management |
Defined in application/config/constants.php (lines 99-107). Role metadata (name, home) stored in the roles database table (seeded by migration 20260409131959_create_roles_table.php).
Keycloak Group Path Resolution
SSO role determination follows a priority-ordered group path search:
/group-org-{orgId}/group-site-{siteId}/role-{roleName}(exact match)/group-org-{orgId}/group-site-default/role-{roleName}(default site)/group-org-default/group-site-{siteId}/role-{roleName}(default org)/group-org-default/group-site-default/role-{roleName}(full default)
The role name is extracted from realm_access.roles with prefix ecommercen-v4- stripped, then matched against ROLE_MAPPING (advisable, developer, admin, cms, products, orders, reporting, marketing, media).
Password Hashing
Configured in application/config/auth.php via auth_hash:
| Config Value | Algorithm | Notes |
|---|---|---|
password-hash-mcrypt (default) | PASSWORD_BCRYPT via password_hash() | Recommended |
password-hash-argon2i | PASSWORD_ARGON2I via password_hash() | PHP 7.2+ |
md5 | md5() | Deprecated -- do not use |
md5-token | md5($val . $token) | Deprecated -- salt-based MD5 |
wecare-crypt | sha1() | Legacy -- verify accepts md5 OR sha1 |
JWT Token Structure (REST API)
Access token payload:
json
{
"iss": "https://site-url.com/",
"aud": "https://site-url.com/",
"iat": 1712345678,
"nbf": 1712345678,
"exp": 1712349278,
"data": {
"userId": "42",
"type": "backend",
"roles": [1, 3]
}
}Refresh tokens are opaque 64-character hex strings stored in the refresh_tokens table.
Session Data Structure
php
// DB Auth
['uid' => 42, 'username' => 'admin', 'roles' => [1, 3], 'loggedIn' => true, 'uprovider' => 2]
// Config Auth
['uid' => null, 'username' => 'admin', 'roles' => [3], 'loggedIn' => true, 'uprovider' => 1]
// SSO Auth
['uid' => null, 'username' => 'John Doe', 'roles' => [1], 'loggedIn' => true, 'uprovider' => 3,
'sso_access_token' => '...jwt...', 'sso_refresh_token' => '...']Data Model
users and user_role Tables
Full column-level schema, indexes, and FK notes for users and user_role are documented in (see AD-58 Admin User Management for canonical schema of both tables). Key auth-relevant facts: soft_delete = 1 blocks login; user_role.role_id has a FK to roles(id) via fk_user_role_role_id.
block_access Table
| Column | Type | Description |
|---|---|---|
id | INT(11) PK AI | Record ID |
ip | VARCHAR(15) | Client IP address |
attempts | TINYINT(1) | Failed attempt count |
last_attempt | DATETIME | Timestamp of last attempt |
Index: ip. Records auto-deleted when block window expires (10 minutes).
user_audit_logs Table
Full schema documented in (see AD-32 Audit Logging for the canonical user_audit_logs column definitions, indexes, and migration reference). Auth-relevant fact: SSO users produce rows with user_id = null; provider records DATABASE or CONFIG.
tasks Table
See AD-55 Task Management for the canonical schema. Key points: title is TEXT (not VARCHAR), there is no updated_at column, and the table has no Phinx migration (defined only in database/initial/initial.sql).
refresh_tokens Table
| Column | Type | Description |
|---|---|---|
user_id | VARCHAR | User ID or username |
user_type | VARCHAR | backend or frontend |
token | VARCHAR | 64-char hex refresh token |
expires_at | DATETIME | Token expiry timestamp |
Configuration
Environment Variables (SSO)
| Variable | Description |
|---|---|
APP_ADMIN_SSO_ENABLED | Enable/disable SSO (boolean) |
APP_ADMIN_SSO_KEYCLOAK_SERVER_URL | Keycloak base URL |
APP_ADMIN_SSO_KEYCLOAK_REALM | Keycloak realm name |
APP_ADMIN_SSO_KEYCLOAK_CLIENT_ID | OAuth2 client ID |
APP_ADMIN_SSO_KEYCLOAK_CLIENT_SECRET | OAuth2 client secret |
APP_SAAS_SITE_ID | Site identifier for group matching |
APP_SAAS_ORG_ID | Organization identifier for group matching |
Environment Variables (Static Auth)
| Variable | Description |
|---|---|
APP_ADMIN_USERNAME | Config auth admin username |
APP_ADMIN_PWD | Config auth admin password |
APP_ADMIN_ADVISABLE_USERNAME | Config auth advisable username |
APP_ADMIN_ADVISABLE_PWD | Config auth advisable password |
Environment Variables (REST Login Throttle)
| Variable | Default | Description |
|---|---|---|
REST_AUTH_THROTTLE_ENABLED | true | Master switch. Set to false to disable throttling entirely (e.g., staging load tests). |
REST_AUTH_THROTTLE_MAX_PER_USER | 5 | Max failed attempts per submitted username/email before a 429 is returned. |
REST_AUTH_THROTTLE_MAX_PER_IP | 20 | Max failed attempts per client IP before a 429 is returned. |
REST_AUTH_THROTTLE_WINDOW_SECONDS | 900 | Fixed window duration in seconds (default 15 minutes). |
Config Files
| File | Contents |
|---|---|
application/config/auth.php | Auth flags (simple_auth, db_auth), hash type, static users |
application/config/constants.php | AUTH_ROLE_* and AUTH_PROVIDER_* constants |
src/Rest/Auth/container.php | DI registration for REST auth (Tokens, Config, AdminAuth, CustomerAuth), API versioning (VersionResolver), and the full middleware pipeline (PolicyResolver, Authentication, Authorization, FeatureGuard, ResourceContext, RelationFilter, Audit). Also registers three LoginThrottle instances backed by cache.l2: the default instance (login throttle, #5), rest.auth.password_reset_throttle (forgot-password, #193, tighter per-email cap), and rest.auth.register_throttle (customer registration, #324, 3/email + 5/IP per hour). |
Client Extension Points
Adv_authships eight empty extension hooks for client repos to override — user-lifecycle hooks (afterAdd,afterEdit,afterDelete,afterUndelete,afterChangePassword) and task-lifecycle hooks (afterAddTask,afterEditTask,afterTaskDelete). Full hook table and override pattern: (see AD-58 Admin User Management for the complete client extension hook reference).- Client repos can add static config users via environment variables in
.env. - The
Adv_logincontroller extendsBase_c(notAdmin_c) so it can be overridden inapplication/controllers/in client repos for custom login page behavior.
Business Rules
- Provider priority: DB auth is checked first, then config auth. Both can be enabled simultaneously.
- Soft-delete:
soft_delete = 1prevents login. Users are never hard-deleted. - SSO uid is null: SSO users have no local
userstable record. Theuidsession key is null. - Rate limiting is IP-based (session login): The
block_accesstable tracks per-IP failed attempts. After 5 failures within 10 minutes, the IP is blocked. On successful login, the record is deleted. Block records auto-expire after 10 minutes. - REST login is separately throttled by
LoginThrottle(src/Rest/Auth/LoginThrottle.php): two independent counters are maintained in L2 cache — per-identifier (submitted username, 5 failures / 15 min) and per-IP (20 failures / 15 min). On successful login, the per-identifier counter is cleared; the per-IP counter is deliberately never cleared so that one cracked account does not reset an attacker's IP budget (LoginThrottle.php:82-96). The identifier is counted whether or not the account exists, so a429response never confirms account existence (LoginThrottle.php:13-16). The throttle is fail-open: if the L2 cache is unavailable, logins proceed unthrottled and a warning is logged (LoginThrottle.php:61-64). The per-IP dimension depends onMY_Input::ip_address(), which currently trusts client-controlled forwarding headers — see Known Issues. - No 2FA: The system does not implement two-factor authentication.
- Role gating:
allowRole($roles, $sessionRoles)is called in every admin controller constructor. Non-ADVISABLE users cannot see/assign ADVISABLE or Developer roles. - Password change: Only ADVISABLE + ADMIN roles can change other users' passwords. Any logged-in user can change their own password via
changeMyPassword(). - Task management: See AD-55 Task Management for all task business rules, security gaps, and lifecycle details.
- SSO caching: OIDC configuration and JWKs are cached via PSCache to avoid hitting Keycloak on every request. TTL matches the L2 cache default.
- SSO session refresh: If the access token is expired but the refresh token is valid,
validateSSOSession()transparently refreshes both tokens and updates session roles.
Live Browser Testing Findings
Login Page (/advisable/login.htm)
- URL behavior: Root
/advisableredirects with HTTP 307 to/advisable/login.htm. - Form fields:
username(text input),pwd(password input), submit button labeled "Σύνδεση". - Visitor IP display: The login page displays the visitor's IP address on the page.
- Session cookie: Standard
PHPSESSIDcookie (HttpOnly).
REST JWT Auth
- Endpoint:
POST /rest/auth/admin/loginreturnsaccess_tokenandrefresh_tokenat the root level of the JSON response (not nested under adatakey).
SSO Configuration
- SSO is enabled via Keycloak at
auth.ecommercen.com, realmecommercen.
Admin Panel Sidebar
After login, the admin panel sidebar contains 60+ menu items organized across 10 major sections:
| Section | Key Items |
|---|---|
| Dashboard | Home, Quick Stats |
| Customers | Customer list, Loyalty, Reviews |
| Orders | Order list, Returns, Payments |
| Products | Catalog, Categories, Brands, Variations, Bundles |
| SEO | Redirects, Sitemap, Meta tags |
| Marketing | Campaigns, Coupons, Newsletters, Promotions |
| Content | Pages, Blog, Sliders, Menus, Builder |
| Reporting | Sales, Traffic, Analytics |
| ECOMMERCEN Plus | AI features, Marketplace, Integrations |
| Cache | Cache management, Rebuild |
| Settings | General, Shipping, Payments, Taxes, Emails |
Known Issues & Security Gaps
- REST throttle IP spoofable via forwarding headers (
src/Rest/Auth/AdminAuth.php:69).$this->input->ip_address()resolves the client IP usingMY_Input, which trustsX-Forwarded-Forand similar headers supplied by the client. An attacker behind a single real IP can rotate spoofed forwarding-header values to bypass the per-IP throttle limit (LoginThrottle.php:51-54). The per-identifier counter is unaffected. Tracked as GitHub #322.
Related Flows
- CF-10 Customer Auth -- separate customer authentication system (triple-SHA1 passwords, different session structure)
- AD-32 Admin Audit Logging -- tracks admin actions via
user_audit_logstable - AD-13 Settings & Configuration -- registry-based platform configuration
- AD-44 Social Auth Settings -- Google/Facebook OAuth credentials for customer social login (managed under settings, not admin auth)
- AD-55 Task Management -- admin task assignments referencing admin user accounts