- PHP 94.9%
- CSS 5.1%
| public | ||
| src | ||
| .editorconfig | ||
| README.md | ||
Seidos
Internal web application for managing users, roles, privileges, customers, and mail accounts. Built as a hand-rolled PHP 8.3+ MVC framework — no external web framework, custom IoC container, custom router.
Table of Contents
- Architecture Overview
- Request Lifecycle
- Authentication & Session
- Authorization
- Layers in Detail
- Entities & Data Model
- Admin Panel
- Security Model
- Configuration
- Known Issues / TODOs
Architecture Overview
public/index.php
└── App::run()
├── Router::find() maps URI → [ControllerClass, method]
├── Csrf::validate() blocks forged POST/DELETE
├── Container::get() autowires the controller
└── $controller->$method() returns Response
Layers (all under src/):
| Layer | Directory | Responsibility |
|---|---|---|
| Entry point | public/index.php |
Bootstrap, error wrapper |
| App / Router | Core/App.php, Http/Router.php |
Dispatch request to controller |
| Controllers | Controllers/ |
Auth checks, delegate to service, return Response |
| Services | Services/ |
Business logic, orchestrate repositories |
| Repositories | Repositories/ |
All SQL, return plain arrays |
| Definitions | Definitions/ |
Static UI metadata (columns, actions, form fields) |
| Views | Views/ |
Assemble HTML from templates, init layout data |
| Templates | Templates/ |
Pure HTML string builders |
| Http | Http/ |
Request, Response, Session, CSRF, Router, Exceptions |
| Core | Core/ |
App, Config, Database, Helper, Normalizer, LDAP |
| Container | Container/ |
PSR-11 autowiring DI container |
Request Lifecycle
Browser → public/index.php
1. Load config/config.php and config/routes.php
2. new Request() — wraps $_SERVER / $_GET / $_POST / php://input
3. new App($config, $routes, $request)
4. App::run()
a. Session::start()
b. Router::find($request)
- static routes: O(1) lookup by method + depth + path
- dynamic routes: regex match by method + depth
- throws NotFoundException if no match
c. CSRF check for POST/PUT/PATCH/DELETE
- reads csrf_token from POST body OR X-CSRF-TOKEN header
- throws CsrfException if invalid
d. Container::get('Seidos\Controllers\FooController')
- autowires all constructor dependencies recursively
- Config and Request are pre-registered
e. $controller->$method() → Response
f. Response::send() → http_response_code + headers + echo body
5. Any Throwable is caught in index.php → ExceptionHandler::handle()
- HTTP exceptions: use their own code + hint
- Other exceptions: 500, empty hint, stack trace if APP_DEBUG=true
- API requests (XHR/JSON Accept): JSON error body
- Browser requests: ErrorView HTML page
Authentication & Session
Login flow (POST /login)
AuthController::login()
→ AuthService::authenticate($username, $password)
1. UserRepository::findAuthBundleByLogin() — fetch id, enabled, password_hash
2. Check enabled flag
3. UserRepository::isLocked($id) — check locked_until timestamp
4. password_verify($password, $hash)
- On failure: increaseFailedLoginAttempts()
→ updateFailedLoginAttempt()
→ setBanTime() if threshold crossed (3 / 5 / 7 / 15 attempts)
- On success: resetFailedLoginAttempts()
5. password_needs_rehash() → updatePasswordHash() if needed (Argon2ID)
6. UserRepository::getRoleForUser() — fetch role name
7. UserRepository::getPrivilegesForUser() — fetch privilege names
8. new SessionUser($id, $username, $role, $privileges[])
9. Session::setUser($user) — regenerate session ID
→ AuditlogService::log('login' / 'login_failed')
→ redirect to Session::getIntendedUrl() (default: /dashboard)
Lockout thresholds
| Failed attempts | Lockout |
|---|---|
| 3 | 30 minutes |
| 5 | 1 hour |
| 7 | 2 hours |
| 15 | 24 hours |
Lockout is stored as a Unix timestamp in users.locked_until.
Session storage
SessionUser is serialised to $_SESSION['user'] as:
['id' => int, 'name' => string, 'role' => string, 'privs' => string[]]
Session cookie is HttpOnly, SameSite=Strict, Secure when HTTPS is detected.
Logout (GET /logout)
Clears CSRF token, wipes $_SESSION, expires the cookie, calls session_destroy(). Redirects to /login.
Authorization
Three guard methods on BaseController:
| Method | Condition |
|---|---|
requireLogin() |
Redirects to /login if not authenticated (stores intended URL) |
requireAdmin() |
Same as above + requires role === 'globaladmin' |
requirePrivilege(string ...$p) |
Authenticated + has ALL listed privileges |
requireAnyPrivilege(string ...$p) |
Authenticated + has AT LEAST ONE privilege |
Privileges are plain strings stored in the session array. SessionUser::hasPrivilege() does a strict in_array check.
Layers in Detail
Container (Container/Container.php)
PSR-11 compliant DI container with full autowiring:
set(id, Closure)— lazy singleton factoryset(id, object)— pre-built instanceget(id)— returns instance, builds it if needed- Constructor autowiring via
ReflectionClass, supports named types, union types - Detects circular dependencies
Router (Http/Router.php)
Routes registered as [method][depth][path] = [ControllerClass, method].
- Static routes: exact string match, O(1) lookup
- Dynamic routes: compiled to named-capture regex, matched by path depth
{param}placeholders captured asdynamic_paramsin the handler array
Route definitions live in config/routes.php using short class names (namespace prepended by App::run()).
Request (Http/Request.php)
Wraps $_SERVER, $_GET, $_POST and php://input (JSON). Key methods:
post(key),get(key),json(key)— typed accessors with defaultheader(name)— readsHTTP_*server varsisApi()— true ifAccept: application/jsonorX-Requested-With: XMLHttpRequest- Only
GET,POST,HEADare allowed; others throwMethodNotAllowedException
Response (Http/Response.php)
Fluent builder. Sent via send() which calls http_response_code(), emits headers, echoes body.
rejson(code, data)— JSON response withContent-Type: application/jsonredirect(code, url)— setsLocationheaderhtml(html, code)— sets HTML content type
CSRF (Http/Csrf.php)
Token stored in $_SESSION['csrf_token'], generated with random_bytes(32). Validated with hash_equals (timing-safe). Can be read from POST csrf_token or X-CSRF-TOKEN header. Cleared after successful login.
Database (Core/Database.php)
Lazy PDO connection. Supports MySQL, PostgreSQL, SQLite (via driver config key). Error mode is ERRMODE_EXCEPTION.
getConnection()— returns (or creates) PDO singletonfetchOne(sql, params)— prepared statement → single row or nullfetchValue(sql, params)— prepared statement → scalar or nullfetchAll(sql, params)— prepared statement → array of rowsgetEnumValues(table, column)— parses MySQL ENUM definition string
Config (Core/Config.php)
Immutable wrapper around the config array. get(key1, key2, ...) traverses nested keys, returns null if any segment is missing.
Normalizer (Core/Normalizer.php)
Static utility class for coercing and validating values before DB storage. Methods: Int, Boolean, Decimal, Name, Slug, Text, RichText, Email, IPv4, IPv6, IP, Date, DateTime, Time, Filepath, Array. All throw InvalidArgumentException on bad input.
LDAP (Core/LDAP.php)
Thin wrapper over PHP's ldap_* functions. Lazy-connects using config array keys: host, port, user, pass, version, tls, base. Methods: search, fetchOne, fetchValue, create, update, delete, disconnect.
Definitions (Definitions/)
Pure-static declarative metadata classes. Each entity has one Definition that declares:
STRINGS— singular/plural labels for UI and JS configTABLE_COLUMNS— columns shown in the data table, with formatting optionsACTIONS— toolbar buttons with scope (none/single/any)FIELD_DEFINITIONS— all possible form fields with type, label, validation hintsFORM_FIELDS— which fields appear in each action's modal
Nothing in a Definition ever touches the database or runs code.
Services (Services/)
Sit between controllers and repositories. BaseService provides generic create, update, delete backed by BaseRepository. Entity services override these or add custom methods.
Notable:
UserService::create()— validates username uniqueness, hashes password with Argon2ID, validates role_idUserService::changePassword()— validates new/confirm match, hashes with Argon2IDAuditlogService::log()— writes an audit record; changes serialised as JSONAuthService— standalone, does not extend BaseService; handles the full login/lockout cycle
Repositories (Repositories/)
All SQL lives here. Use PDO prepared statements throughout. BaseRepository provides:
getOptions()—SELECT id AS value, name AS label FROM table(for dropdowns)deleteById(id)— generic delete
Each entity repository extends BaseRepository and implements protected function db(): Database.
Views (Views/)
Each view extends BaseView. init(array $data) populates layout properties from $data and from $this->definition (if set). Views that have no Definition (e.g. AuthView) work fine — definition-dependent code is guarded.
Render methods assemble: head() . startBody() . startTopbar() . template->show() . endBody().
JSConfigGenerator renders a <script> block injecting entity strings into window.AppConfig for use by frontend JS modules.
Templates (Templates/)
Pure PHP string builders — no output buffering, no includes. Each returns an HTML string. Shared components:
Head—<head>with CSS links, CSRF meta tagTopbar— top navigation bar with username and logoutSidebar— left navigation, permission-aware (hides links the user can't access)Table— data table with column formatting (badge, tag, code, bold, date, boolean, link)Toolbar— action buttons + search field + modal formsAction— inline row action buttons (edit, delete, etc.)
Entities & Data Model
users
| Column | Type | Notes |
|---|---|---|
| id | INT PK | |
| username | VARCHAR | unique login name |
| fullname | VARCHAR | display name |
| role_id | FK → roles | |
| password_hash | VARCHAR | Argon2ID |
| primary_email | VARCHAR | |
| enabled | TINYINT(1) | 0 = disabled |
| last_login | DATETIME | |
| failed_attempts | INT | reset on success |
| locked_until | INT | Unix timestamp |
| created_at / updated_at | DATETIME |
roles
Many-to-one with users. Has name, description. Linked to privileges via role_privileges.
privileges
Named capabilities (plain strings). Checked via SessionUser::hasPrivilege().
role_privileges
Junction table: role_id, privilege_id.
customers
External customers: name, domain, contact_email, enabled.
accounts
Mail accounts: username, domain_id, customer_id, enabled. Linked to domains and customers.
auditlog
| Column | Notes |
|---|---|
| user_id | FK → users (nullable — system actions) |
| action | string, e.g. 'login', 'create', 'delete' |
| entity_type | e.g. 'user', 'customer' |
| entity_id | nullable int |
| changes | JSON blob |
| ip_address | string |
| created_at | DATETIME |
Admin Panel
All admin routes require requireAdmin() (role === 'globaladmin').
Each admin entity (users, roles, privileges) follows the same pattern:
GET /admin/{entity} → show{Entity}() render HTML page
POST /admin/{entity}/get → apiGet() return JSON list
POST /admin/{entity}/create → apiCreate() create via service, audit
POST /admin/{entity}/update → apiUpdate() update via service, audit
POST /admin/{entity}/delete → apiDelete() delete via service, audit
All mutation endpoints expect a JSON body: [{"id": N, "data": {...}}, ...] (array of operations). Results are returned as {"results": [{status, message, id}, ...]} with HTTP 207 for mixed outcomes.
The frontend JS reads the Definition-derived config from window.AppConfig and drives the table, toolbar, and modals dynamically.
Security Model
| Concern | Mechanism |
|---|---|
| CSRF | Session token, hash_equals, cleared after login |
| SQL injection | PDO prepared statements throughout |
| XSS (output) | Helper::html() / htmlspecialchars() on all user data in templates; BaseComponent::h() used in table/form components |
| Session fixation | session_regenerate_id(true) on login |
| Password storage | Argon2ID via password_hash(), auto-rehash on next login |
| Brute force | Failed-attempt counter + progressive lockout |
| Method restriction | Only GET/POST/HEAD accepted; others throw 405 |
| Debug mode | Stack traces only shown when APP_DEBUG=true |
Configuration
config/config.php — returns an array consumed by Config. Keys:
| Key | Purpose |
|---|---|
app_name |
Application title shown in UI |
debug |
true = show stack traces on errors |
database.driver/host/name/user/pass/port |
PDO connection |
ldap.host/port/user/pass/version/tls/base |
LDAP connection |
redis.host/port |
Redis (future use) |
Security note:
config/config.phpcontains plain-text database and LDAP credentials. This file must not be committed to version control. Use environment variables or a.env-based loader instead. Also ensuredebugisfalsein production.
Known Issues / TODOs
Http/ErrorHandler.php— file exists but is empty; unused.Actions/BaseActions.phpandActions/UserActions.php— stub classes with no implementation.Entities/UserEntity.php— defined but never used; repositories return plain arrays.Helper::json_exit()usesarray_any()which requires PHP 8.4+. Will fail on PHP 8.3.Services/HomeService.php— referenced in routes/memory notes but does not exist.- LDAP credentials in
config/config.phpare plain text and should be moved to environment variables. debug => trueis set in the committed config — must befalsein production.