Skip to content

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)

MethodPathControllerDescription
POST/rest/auth/admin/loginAdvisable\Rest\Auth\AdminAuth::loginAuthenticate admin user, returns JWT access + refresh tokens. Returns 429 + Retry-After when brute-force throttle fires.
POST/rest/auth/admin/refreshAdvisable\Rest\Auth\AdminAuth::refreshExchange refresh token for new access token

Admin Panel Routes (Session-Based)

RouteControllerDescription
advisable/loginAdv_login::loginLogin form + POST handler
advisable/login/logoutAdv_login::logoutDestroy session, redirect to login
advisable/sso/loginAdvSso::loginRedirect to Keycloak authorization URL
advisable/sso/callbackAdvSso::callbackOAuth2 code exchange callback
advisable/sso/logoutAdvSso::logoutKeycloak 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 admin

Config 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 true

Config users are defined in application/config/auth.php via environment variables:

  • APP_ADMIN_USERNAME / APP_ADMIN_PWD with AUTH_ROLE_ADMIN
  • APP_ADMIN_ADVISABLE_USERNAME / APP_ADMIN_ADVISABLE_PWD with AUTH_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 page

SSO 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 roles

Domain Layer

Legacy Auth Module (ecommercen/auth/)

FileDescription
controllers/Adv_auth.phpUser 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.phpUsers table CRUD + audit log queries. Soft-delete pattern. guardUserEdit() prevents password/soft_delete modification via edit.
models/Adv_roles_model.phpDB-based roles. Constructor reads the roles table. Provides dropdown, getRoles, getRole, getRolesByIds, getRoleByName, getRolesByName.
models/Adv_user_role_model.phpJunction table user_role management. Diff-based update: calculates roles to add/remove.
models/Adv_tasks_model.phpTasks CRUD — see AD-55 Task Management for full model reference.

SSO Module (ecommercen/sso/)

FileDescription
controllers/AdvSso.phpLogin, callback, logout endpoints. Extends Base_c (not Admin_c -- unauthenticated entry point). Uses guard() wrapper for exception handling.

Modern SSO Layer (src/Auth/SingleSignOn/)

FileDescription
SingleSignOn.phpComplete OIDC client (472 lines). Handles discovery, JWK loading, token exchange, token refresh, JWT decoding, group-based role determination.
Exception/BaseException.phpBase with HTTP status code support
Exception/NotEnabledException.phpSSO feature flag disabled
Exception/AuthorizationCodeMissingException.phpMissing ?code parameter
Exception/AuthorizationExchangeFailedException.phpToken exchange HTTP error
Exception/DecodeTokenException.phpJWT decode failure (expired, malformed, no matching key)
Exception/JWKsIrretrievableException.phpCannot fetch JWK set
Exception/MissingGroupsClaimException.phpJWT has no groups claim
Exception/OpenIDConfigurationIrretrievableException.phpCannot fetch OIDC discovery
Exception/RoleMismatchException.phpGroup matched but role not in ROLE_MAPPING
Exception/RoleMissingException.phpNo matching group path found
Exception/TokenRefreshFailedException.phpRefresh token exchange failed
Exception/UnexpectedIssuerException.php?iss does not match expected realm

REST Auth Layer (src/Rest/Auth/)

FileDescription
AdminAuth.phpAdmin JWT login + refresh (164 lines). OpenAPI annotated. Brute-force throttle injected as $throttle.
LoginThrottle.phpFixed-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.phpCustomer JWT login + refresh (separate system).
Tokens.phpJWT generation (access) and refresh token generation/validation. Uses firebase/php-jwt.
Config.phpConfig DTO: key, algorithm, accessTokenExpire, refreshTokenExpire.
RefreshTokenModel.phpCRUD for refresh_tokens table. Extends CI_Model.
TokenProtect.phpMiddleware-style JWT validation for protected endpoints.
BackendGuard.php / FrontendGuard.php / AnyGuard.phpRoute guards by user type.
RemoveExpiredRefreshTokens.phpCleanup job for expired refresh tokens.

Auth Helper (ecommercen/helpers/auth_helper.php)

Global functions loaded on every admin request:

  • isLoggedIn() -- checks session loggedIn
  • validateSSOSession() -- auto-refreshes SSO tokens
  • allowRole($roles, $roleIds) -- checks if any session role matches allowed list
  • advAuthHash($val, $token) -- delegates to Advauth::advAuthHash()
  • isAdvisableUser(), isDeveloper(), isAdmin(), isCmsUser(), isProductsUser(), isReportingUser(), isOrdersUser(), isMarketingUser(), isMediaUser() -- individual role checks
  • mergeUsersRoles($users, $usersRoles) -- decorates user objects with roles
  • allowedUsers($users, $roles) -- filters users by roles

Architecture

Authentication Providers

IDConstantSourcePassword HandlingSession uid
1AUTH_PROVIDER_CONFIGapplication/config/auth.php (config_auth)Plain-text comparisonnull
2AUTH_PROVIDER_DBusers tablepassword_verify() (bcrypt/argon2i)User ID
3AUTH_PROVIDER_SSOKeycloak OIDCJWT signature verificationnull

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

ConstantIDNameHome RouteDescription
AUTH_ROLE_ADVISABLE1AdvisableadvisableSuperuser -- platform developers
AUTH_ROLE_DEVELOPER2DeveloperadvisableDevelopment access
AUTH_ROLE_ADMIN3AdminadvisableClient admin
AUTH_ROLE_CMS4CMScategory/category_adminContent management
AUTH_ROLE_PRODUCTS5Productsproducts_adminProduct catalog
AUTH_ROLE_ORDERS6Ordersorders_adminOrder management
AUTH_ROLE_REPORTING7Reportingeshop/reportingReports & analytics
AUTH_ROLE_MARKETING8Marketingpromo_adminCampaigns & promotions
AUTH_ROLE_MEDIA9Mediasliders/sliders_adminSlider/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:

  1. /group-org-{orgId}/group-site-{siteId}/role-{roleName} (exact match)
  2. /group-org-{orgId}/group-site-default/role-{roleName} (default site)
  3. /group-org-default/group-site-{siteId}/role-{roleName} (default org)
  4. /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 ValueAlgorithmNotes
password-hash-mcrypt (default)PASSWORD_BCRYPT via password_hash()Recommended
password-hash-argon2iPASSWORD_ARGON2I via password_hash()PHP 7.2+
md5md5()Deprecated -- do not use
md5-tokenmd5($val . $token)Deprecated -- salt-based MD5
wecare-cryptsha1()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

ColumnTypeDescription
idINT(11) PK AIRecord ID
ipVARCHAR(15)Client IP address
attemptsTINYINT(1)Failed attempt count
last_attemptDATETIMETimestamp 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

ColumnTypeDescription
user_idVARCHARUser ID or username
user_typeVARCHARbackend or frontend
tokenVARCHAR64-char hex refresh token
expires_atDATETIMEToken expiry timestamp

Configuration

Environment Variables (SSO)

VariableDescription
APP_ADMIN_SSO_ENABLEDEnable/disable SSO (boolean)
APP_ADMIN_SSO_KEYCLOAK_SERVER_URLKeycloak base URL
APP_ADMIN_SSO_KEYCLOAK_REALMKeycloak realm name
APP_ADMIN_SSO_KEYCLOAK_CLIENT_IDOAuth2 client ID
APP_ADMIN_SSO_KEYCLOAK_CLIENT_SECRETOAuth2 client secret
APP_SAAS_SITE_IDSite identifier for group matching
APP_SAAS_ORG_IDOrganization identifier for group matching

Environment Variables (Static Auth)

VariableDescription
APP_ADMIN_USERNAMEConfig auth admin username
APP_ADMIN_PWDConfig auth admin password
APP_ADMIN_ADVISABLE_USERNAMEConfig auth advisable username
APP_ADMIN_ADVISABLE_PWDConfig auth advisable password

Environment Variables (REST Login Throttle)

VariableDefaultDescription
REST_AUTH_THROTTLE_ENABLEDtrueMaster switch. Set to false to disable throttling entirely (e.g., staging load tests).
REST_AUTH_THROTTLE_MAX_PER_USER5Max failed attempts per submitted username/email before a 429 is returned.
REST_AUTH_THROTTLE_MAX_PER_IP20Max failed attempts per client IP before a 429 is returned.
REST_AUTH_THROTTLE_WINDOW_SECONDS900Fixed window duration in seconds (default 15 minutes).

Config Files

FileContents
application/config/auth.phpAuth flags (simple_auth, db_auth), hash type, static users
application/config/constants.phpAUTH_ROLE_* and AUTH_PROVIDER_* constants
src/Rest/Auth/container.phpDI 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_auth ships 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_login controller extends Base_c (not Admin_c) so it can be overridden in application/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 = 1 prevents login. Users are never hard-deleted.
  • SSO uid is null: SSO users have no local users table record. The uid session key is null.
  • Rate limiting is IP-based (session login): The block_access table 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 a 429 response 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 on MY_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 /advisable redirects 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 PHPSESSID cookie (HttpOnly).

REST JWT Auth

  • Endpoint: POST /rest/auth/admin/login returns access_token and refresh_token at the root level of the JSON response (not nested under a data key).

SSO Configuration

  • SSO is enabled via Keycloak at auth.ecommercen.com, realm ecommercen.

Admin Panel Sidebar

After login, the admin panel sidebar contains 60+ menu items organized across 10 major sections:

SectionKey Items
DashboardHome, Quick Stats
CustomersCustomer list, Loyalty, Reviews
OrdersOrder list, Returns, Payments
ProductsCatalog, Categories, Brands, Variations, Bundles
SEORedirects, Sitemap, Meta tags
MarketingCampaigns, Coupons, Newsletters, Promotions
ContentPages, Blog, Sliders, Menus, Builder
ReportingSales, Traffic, Analytics
ECOMMERCEN PlusAI features, Marketplace, Integrations
CacheCache management, Rebuild
SettingsGeneral, Shipping, Payments, Taxes, Emails

Known Issues & Security Gaps

  1. REST throttle IP spoofable via forwarding headers (src/Rest/Auth/AdminAuth.php:69). $this->input->ip_address() resolves the client IP using MY_Input, which trusts X-Forwarded-For and 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.