Replace Andmore ID SSO with local login
fitme3070.pt delegated all authentication to hub.andmore.pt: views/auth/*
were bounce stubs, and because this domain is outside the .andmore.pt cookie
scope the real handoff was a one-time HTTPS ticket redeem. Bring identity back
in-house — passwords live in users.password_hash, which the SSO migration
never dropped.
Removed: config/sso.php, IDENTITY_MODE, every SSO_* constant and env key,
identity_is_federated() and the cookie/ticket paths in the auth views and the
public-site bootstrap. ANDMORE_ID_URL stays — it is still the hub /notify
endpoint for outbound email/SMS, which the reset mails ride on, and has
nothing to do with authentication now.
Added, ported from the fotografia.andmore.pt sibling rather than invented:
- auth_login() with transparent password_needs_rehash upgrades and the
last_login trail; auth_register_client(); auth_set_password(); the
single-use 2h reset tokens.
- Real login / register / forgot_password / reset_password pages on a new
anonymous shell (includes/auth_header.php). includes/header.php renders
the backoffice sidebar and a logout button, which is wrong for a visitor
who is not signed in yet.
- safe_return_path(), so a crafted ?return_to= cannot bounce a freshly
authenticated user off-site.
- Per-IP throttling on login, register and reset-request. Successes count
too, so a guessed password cannot be parlayed into stuffing the rest of
the user list from the same IP.
- views/user/set_password.php: admin override for anyone whose email is
undeliverable. Prefers mailing an invite link so the backoffice never
handles the password. A plain admin cannot act on a sysadmin, and nobody
can use it on themselves — self-service goes through profile.php, which
demands the current password so a hijacked session cannot lock the owner
out.
Accounts provisioned during the SSO era have password_hash NULL. They get in
via the reset email, or by registering again with the same address:
auth_register_client() claims a passwordless row, but only a plain `client`
one, so knowing a staff address does not let anyone self-assign the role.
users.andmore_id is deliberately left in place. No code reads it any more, but
it records which central identity each row was linked to.
Deployment: apply database/migration_20260913_local_auth.sql (password_resets
+ auth_attempts) in the SAME sysadmin session that pulls this commit — the
forgot-password flow needs that table, and after the pull a login requires a
local password.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dc8a36e126
commit
aec7b47820
23 files changed
+1195
-710
No files matched your search
+27
-26
@@ -1,29 +1,16 @@
|
||||
<?php
|
||||
// Auth guards — require functions.php (and session_start) before this file.
|
||||
//
|
||||
// This install owns its passwords: they live in `users.password_hash` and
|
||||
// views/auth/login.php is a real form. There is no identity provider in front
|
||||
// of the app.
|
||||
|
||||
function require_login(): array {
|
||||
if (empty($_SESSION['user_id'])) {
|
||||
if (identity_is_federated()) {
|
||||
sso_resolve_session($GLOBALS['pdo']);
|
||||
}
|
||||
if (empty($_SESSION['user_id'])) {
|
||||
if (SSO_REQUIRED) {
|
||||
// Bounce through this platform's local login stub instead of
|
||||
// sending id.andmore.pt's return_to straight at a protected
|
||||
// page. The stub (views/auth/login.php) carries the
|
||||
// ?from=sso loop-guard and loads the full functions.php
|
||||
// bootstrap before calling sso_resolve_session().
|
||||
$_rl_return = APP_BASE_URL . 'views/auth/login.php?from=sso';
|
||||
$_rl_qs = '?return_to=' . urlencode($_rl_return)
|
||||
. '&platform=' . urlencode(PLATFORM_SLUG);
|
||||
header('Location: ' . ANDMORE_ID_URL . 'auth/login.php' . $_rl_qs);
|
||||
} else {
|
||||
$return = $_SERVER['REQUEST_URI'] ?? '';
|
||||
$qs = $return ? ('?return_to=' . urlencode($return)) : '';
|
||||
header('Location: ' . base_url('views/auth/login.php' . $qs));
|
||||
}
|
||||
exit;
|
||||
}
|
||||
$return = $_SERVER['REQUEST_URI'] ?? '';
|
||||
$qs = $return ? ('?return_to=' . urlencode($return)) : '';
|
||||
header('Location: ' . base_url('views/auth/login.php' . $qs));
|
||||
exit;
|
||||
}
|
||||
return $_SESSION['user'];
|
||||
}
|
||||
@@ -134,9 +121,9 @@ function is_designer(): bool {
|
||||
}
|
||||
|
||||
function auth_set_session(array $user): void {
|
||||
// Rotate the session id on every privilege change (login, SSO resolve,
|
||||
// Rotate the session id on every privilege change (login, registration,
|
||||
// password reset). Defeats session fixation: an attacker who pre-seeded
|
||||
// FOTO_SESS now ends up holding a dead id while the real user gets a fresh
|
||||
// FIT_SESS now ends up holding a dead id while the real user gets a fresh
|
||||
// one. The CSRF token is rotated alongside the session id.
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_regenerate_id(true);
|
||||
@@ -148,8 +135,22 @@ function auth_set_session(array $user): void {
|
||||
'name' => $user['name'],
|
||||
'email' => $user['email'],
|
||||
'role' => $user['role'],
|
||||
// Carried so sso_resolve_session() can detect when the am_sso cookie
|
||||
// identifies a different person than the current local session.
|
||||
'andmore_id' => isset($user['andmore_id']) ? (int) $user['andmore_id'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
// Sanitises a ?return_to= value into a path safe to redirect to after login.
|
||||
// Anything absolute, protocol-relative or backslash-prefixed is discarded so a
|
||||
// crafted login link can't bounce the user off-site with a fresh session in hand.
|
||||
function safe_return_path(?string $raw): string {
|
||||
$raw = trim((string) $raw);
|
||||
if ($raw === '' || $raw[0] !== '/') return '';
|
||||
if (str_starts_with($raw, '//') || str_starts_with($raw, '/' . chr(92))) return '';
|
||||
|
||||
// REQUEST_URI includes the deployment base (e.g. /fitme3070.pt/…) while
|
||||
// base_url() adds it back, so strip it to avoid doubling the prefix.
|
||||
$base = parse_url(APP_BASE_URL, PHP_URL_PATH) ?: '/';
|
||||
if ($base !== '/' && str_starts_with($raw, $base)) {
|
||||
$raw = substr($raw, strlen($base));
|
||||
}
|
||||
return ltrim($raw, '/');
|
||||
}
|
||||
+10
-33
@@ -28,36 +28,13 @@ if (APP_ENV !== 'prod') {
|
||||
|
||||
require_once __DIR__ . '/error_handler.php';
|
||||
|
||||
// ─── Identity / Andmore ID ────────────────────────────────────────────────────
|
||||
// IDENTITY_MODE: 'standalone' (default) or 'federated'. In federated mode every
|
||||
// register/login syncs the user to the central andmore_id DB so users.andmore_id
|
||||
// is always populated and cross-platform relationships work by canonical id.
|
||||
define('IDENTITY_MODE', strtolower(env('IDENTITY_MODE', 'standalone')) === 'federated' ? 'federated' : 'standalone');
|
||||
|
||||
// Slug identifying this install on andmore_id.linked_platforms. Used to scope
|
||||
// link tokens and resolve the satellite's display name on the central verify page.
|
||||
define('PLATFORM_SLUG', (string) env('PLATFORM_SLUG', 'fitme3070'));
|
||||
|
||||
define('ANDMORE_ID_URL', rtrim(env('ANDMORE_ID_URL', 'https://id.andmore.pt'), '/') . '/');
|
||||
|
||||
// Parent domain the am_sso cookie is scoped to (e.g. .andmore.pt). Empty in
|
||||
// standalone mode — federated mode requires a non-empty value.
|
||||
define('SSO_COOKIE_DOMAIN', (string) env('SSO_COOKIE_DOMAIN', ''));
|
||||
// Cookie name. Override per environment when prod and dev share a parent
|
||||
// domain (e.g. SSO_COOKIE_NAME=am_sso_dev in dev) so the prod cookie on
|
||||
// .andmore.pt doesn't clobber the dev session and vice versa.
|
||||
define('SSO_COOKIE_NAME', (string) env('SSO_COOKIE_NAME', 'am_sso'));
|
||||
define('SSO_SESSION_TTL_DAYS', (int) env('SSO_SESSION_TTL_DAYS', 30));
|
||||
|
||||
// When true (federated mode only): local login/register pages redirect to
|
||||
// Andmore ID; require_login() sends unauthenticated users directly there.
|
||||
define('SSO_REQUIRED', filter_var(env('SSO_REQUIRED', 'false'), FILTER_VALIDATE_BOOLEAN));
|
||||
|
||||
// When true (federated mode only): a register/login attempt fails with a clear
|
||||
// error if the central DB is unreachable, instead of silently falling through
|
||||
// and creating an orphan local row. Standalone mode ignores this.
|
||||
define('IDENTITY_REQUIRE_UPSTREAM', filter_var(env('IDENTITY_REQUIRE_UPSTREAM', 'false'), FILTER_VALIDATE_BOOLEAN));
|
||||
|
||||
function identity_is_federated(): bool { return IDENTITY_MODE === 'federated'; }
|
||||
function identity_is_standalone(): bool { return IDENTITY_MODE === 'standalone'; }
|
||||
function identity_require_upstream(): bool { return identity_is_federated() && IDENTITY_REQUIRE_UPSTREAM; }
|
||||
// ─── Andmore hub (notifications only) ────────────────────────────────────────
|
||||
// This install owns its own identity: passwords live in `users.password_hash`
|
||||
// and views/auth/login.php is a real form (see config/auth.php). The former
|
||||
// Andmore ID / SSO delegation — config/sso.php, the am_sso cookie, the central
|
||||
// sso_sessions table and the one-time ticket redeem — was removed.
|
||||
//
|
||||
// ANDMORE_ID_URL survives because outbound email/SMS is still centralized at
|
||||
// the hub's /notify endpoint; see config/functions_notify_client.php. It has
|
||||
// nothing to do with authentication anymore.
|
||||
define('ANDMORE_ID_URL', rtrim(env('ANDMORE_ID_URL', 'https://hub.andmore.pt'), '/') . '/');
|
||||
@@ -2,19 +2,19 @@
|
||||
// ─── Bootstrap chain ─────────────────────────────────────────────────────────
|
||||
// Loaded by every page right after config/db.php (which opens $pdo and starts
|
||||
// the FIT_SESS session). Mirrors the fotografia/Andmore fleet shape, trimmed to
|
||||
// what fitme3070 needs this pass: settings, generic CRUD, auth + SSO, the
|
||||
// what fitme3070 needs this pass: settings, generic CRUD, local auth, the
|
||||
// migrations runner, and the centralized notify façade. Domain CRUD
|
||||
// (plataforma_fitness, team, modalidades, horário, galerias) is added per
|
||||
// feature in a follow-up under config/functions_*.php.
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) session_start();
|
||||
|
||||
require_once __DIR__ . '/config.php'; // APP_ENV + identity/SSO consts + error_handler
|
||||
require_once __DIR__ . '/config.php'; // APP_ENV + hub notify URL + error_handler
|
||||
require_once __DIR__ . '/functions_settings.php'; // setting() + system_settings cache
|
||||
require_once __DIR__ . '/functions_db.php'; // generic DB helpers
|
||||
require_once __DIR__ . '/functions_user.php'; // backoffice user creators (setup + admin/staff)
|
||||
require_once __DIR__ . '/functions_user.php'; // auth_login/register, password reset, user creators
|
||||
require_once __DIR__ . '/auth.php'; // require_login/admin/staff/sysadmin(), role helpers
|
||||
require_once __DIR__ . '/sso.php'; // sso_resolve_session() + upstream link/cookie
|
||||
require_once __DIR__ . '/functions_throttle.php'; // per-IP attempt throttle for the auth endpoints
|
||||
require_once __DIR__ . '/functions_migrations.php';// platform_update.php migrations runner
|
||||
|
||||
// Outbound delivery is centralized at id.andmore.pt — no local PHPMailer/SMTP.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
// ─── Per-IP attempt throttling ───────────────────────────────────────────────
|
||||
// Generic, scope-scoped IP throttle for endpoints where an attacker gains by
|
||||
// repeating: login, password-reset requests, registration.
|
||||
//
|
||||
// Two limits applied together:
|
||||
// - burst: N attempts per minute (default 5)
|
||||
// - sustained: N attempts per hour (default 30)
|
||||
//
|
||||
// Counts FAILED *and* SUCCESSFUL attempts, so a guessed password can't be
|
||||
// parlayed into stuffing the rest of the user list from the same IP.
|
||||
//
|
||||
// Schema: database/migration_20260913_local_auth.sql (`auth_attempts`).
|
||||
|
||||
function _throttle_ip(): string {
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
return $ip !== '' ? $ip : '0.0.0.0';
|
||||
}
|
||||
|
||||
function throttle_check(PDO $pdo, string $scope, int $maxPerMinute = 5, int $maxPerHour = 30): bool {
|
||||
try {
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT
|
||||
SUM(attempted_at > DATE_SUB(NOW(), INTERVAL 1 MINUTE)) AS minute,
|
||||
SUM(attempted_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)) AS hour
|
||||
FROM auth_attempts
|
||||
WHERE ip = ? AND scope = ? AND attempted_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)"
|
||||
);
|
||||
$stmt->execute([_throttle_ip(), $scope]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC) ?: ['minute' => 0, 'hour' => 0];
|
||||
} catch (PDOException $e) {
|
||||
// Fail open if the table is missing (migration not applied yet during a
|
||||
// deploy). Log loudly so an operator notices.
|
||||
error_log('[throttle] check failed (run migration_20260913_local_auth.sql?): ' . $e->getMessage());
|
||||
return true;
|
||||
}
|
||||
if ((int) $row['minute'] >= $maxPerMinute) return false;
|
||||
if ((int) $row['hour'] >= $maxPerHour) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function throttle_record(PDO $pdo, string $scope): void {
|
||||
try {
|
||||
$pdo->prepare("INSERT INTO auth_attempts (ip, scope) VALUES (?, ?)")
|
||||
->execute([_throttle_ip(), $scope]);
|
||||
// Probabilistic cleanup so the table doesn't grow forever — cheap on a
|
||||
// 1% sample of writes, and saves wiring up a cron for a small install.
|
||||
if (random_int(0, 99) === 0) {
|
||||
@$pdo->exec("DELETE FROM auth_attempts WHERE attempted_at < DATE_SUB(NOW(), INTERVAL 1 DAY)");
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
error_log('[throttle] record failed (run migration_20260913_local_auth.sql?): ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience: 429 + generic message, then exit. Callers that would rather keep
|
||||
// the visitor on the form can use throttle_check() and render their own error.
|
||||
function throttle_block(string $reason = ''): void {
|
||||
http_response_code(429);
|
||||
header('Retry-After: 60');
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
echo '<!doctype html><meta charset="utf-8"><title>Demasiadas tentativas</title>'
|
||||
. '<div style="font-family:sans-serif;max-width:520px;margin:80px auto;text-align:center;">'
|
||||
. '<h2>Demasiadas tentativas</h2>'
|
||||
. '<p>Aguarde alguns minutos antes de tentar novamente.</p>'
|
||||
. '</div>';
|
||||
if ($reason !== '') error_log("[throttle] blocked: {$reason} ip=" . _throttle_ip());
|
||||
exit;
|
||||
}
|
||||
+228
-43
@@ -1,14 +1,22 @@
|
||||
<?php
|
||||
// ─── Backoffice user creation ────────────────────────────────────────────────
|
||||
// This install is SSO-only — clients authenticate on id.andmore.pt and their
|
||||
// local row is provisioned by sso_resolve_session() (config/sso.php). The only
|
||||
// users created locally are backoffice accounts (sysadmin / admin / staff):
|
||||
// - auth_create_sysadmin() → first-install bootstrap (views/auth/setup.php)
|
||||
// - auth_create_admin()/staff() → admin-vouched, link the central Andmore ID
|
||||
// row immediately so the new person can SSO on any sibling .andmore.pt site.
|
||||
// ─── Users: lookup, authentication, registration, password reset ─────────────
|
||||
// This install owns its identity. Passwords live in `users.password_hash`;
|
||||
// views/auth/login.php is a real form and require_login() (config/auth.php)
|
||||
// bounces unauthenticated visitors there. Nothing is delegated to a hub.
|
||||
//
|
||||
// parse_user_agent() lives in config/functions.php; db_get_all_staff() /
|
||||
// db_get_active_admins() live in config/functions_db.php — not redefined here.
|
||||
// parse_user_agent() lives in config/functions.php; db_get_user() /
|
||||
// db_get_all_staff() / db_get_active_admins() live in config/functions_db.php
|
||||
// — not redefined here.
|
||||
|
||||
// ─── Lookup ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Active user by email, or false. Used by the login and reset-request flows,
|
||||
// which must never act on a deactivated account.
|
||||
function user_find_by_email(PDO $pdo, string $email): array|false {
|
||||
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = ? AND is_active = 1 LIMIT 1");
|
||||
$stmt->execute([strtolower(trim($email))]);
|
||||
return $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
// True once any sysadmin/admin row exists — gates views/auth/setup.php.
|
||||
function admin_exists(PDO $pdo): bool {
|
||||
@@ -17,8 +25,113 @@ function admin_exists(PDO $pdo): bool {
|
||||
)->fetchColumn();
|
||||
}
|
||||
|
||||
// Creates a sysadmin (full access incl. backups + platform update). Local-only
|
||||
// password row; federated linkage happens on first SSO by email match.
|
||||
// ─── Authentication ──────────────────────────────────────────────────────────
|
||||
|
||||
// Verifies credentials and stamps the login trail. Returns the user row on
|
||||
// success, null on bad email / bad password / inactive account / no password
|
||||
// set. Callers must not leak which of the four it was.
|
||||
function auth_login(PDO $pdo, string $email, string $password): ?array {
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT * FROM users WHERE email = ? AND is_active = 1 AND password_hash IS NOT NULL LIMIT 1"
|
||||
);
|
||||
$stmt->execute([strtolower(trim($email))]);
|
||||
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$user || !password_verify($password, $user['password_hash'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Transparently upgrade hashes when PHP's default algorithm/cost moves on.
|
||||
if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
|
||||
$pdo->prepare("UPDATE users SET password_hash = ? WHERE id = ?")
|
||||
->execute([password_hash($password, PASSWORD_DEFAULT), $user['id']]);
|
||||
}
|
||||
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? null;
|
||||
$ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512) ?: null;
|
||||
$pdo->prepare("UPDATE users SET last_login_at = NOW(), last_login_ip = ?, last_login_ua = ? WHERE id = ?")
|
||||
->execute([$ip, $ua, $user['id']]);
|
||||
$user['last_login_at'] = date('Y-m-d H:i:s');
|
||||
$user['last_login_ip'] = $ip;
|
||||
$user['last_login_ua'] = $ua;
|
||||
return $user;
|
||||
}
|
||||
|
||||
// Writes a new password hash. Shared by the reset flow, the profile page and
|
||||
// the admin override in views/user/set_password.php. Any outstanding reset
|
||||
// token is burned so an old "set your password" email can't be replayed to
|
||||
// take the account back.
|
||||
function auth_set_password(PDO $pdo, int $userId, string $password): void {
|
||||
$pdo->prepare("UPDATE users SET password_hash = ? WHERE id = ?")
|
||||
->execute([password_hash($password, PASSWORD_DEFAULT), $userId]);
|
||||
|
||||
// Best-effort: the password itself is already saved, so a missing
|
||||
// `password_resets` table (migration not applied yet) must not turn a
|
||||
// successful change into a 500 that makes the caller think it failed.
|
||||
try {
|
||||
$pdo->prepare("UPDATE password_resets SET used_at = NOW() WHERE user_id = ? AND used_at IS NULL")
|
||||
->execute([$userId]);
|
||||
} catch (PDOException $e) {
|
||||
error_log('[auth] could not burn reset tokens (run migration_20260913_local_auth.sql?): ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Registration ────────────────────────────────────────────────────────────
|
||||
|
||||
// Self-registration for clients (views/auth/register.php).
|
||||
//
|
||||
// Returns ['id' => int] on success, or ['error' => 'email_taken'].
|
||||
//
|
||||
// A row with no password — left behind by an admin who created the account, or
|
||||
// by the SSO era, when client rows were provisioned from the hub with
|
||||
// password_hash NULL — is *claimable* by whoever registers with that address,
|
||||
// but only when it is a plain client. A passwordless admin/staff/sysadmin row
|
||||
// must not be claimable through the public form: that would let anyone who
|
||||
// knows a staff email self-assign the role. Those report as taken and have to
|
||||
// be set up from the backoffice.
|
||||
function auth_register_client(PDO $pdo, array $data): array {
|
||||
$email = strtolower(trim($data['email']));
|
||||
$name = trim($data['name']);
|
||||
$phone = trim($data['phone'] ?? '') ?: null;
|
||||
|
||||
$stmt = $pdo->prepare("SELECT id, role, password_hash FROM users WHERE email = ? LIMIT 1");
|
||||
$stmt->execute([$email]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($row) {
|
||||
if (!empty($row['password_hash']) || $row['role'] !== 'client') {
|
||||
return ['error' => 'email_taken'];
|
||||
}
|
||||
$pdo->prepare(
|
||||
"UPDATE users SET name = ?, phone = COALESCE(?, phone), password_hash = ?, is_active = 1 WHERE id = ?"
|
||||
)->execute([$name, $phone, password_hash($data['password'], PASSWORD_DEFAULT), $row['id']]);
|
||||
return ['id' => (int) $row['id']];
|
||||
}
|
||||
|
||||
// UNIQUE(email) is the real guard — the SELECT above can lose a race with a
|
||||
// concurrent signup, so the insert is what decides.
|
||||
try {
|
||||
$pdo->prepare("
|
||||
INSERT INTO users (name, email, phone, password_hash, role, marketing_consent, is_active)
|
||||
VALUES (?, ?, ?, ?, 'client', ?, 1)
|
||||
")->execute([
|
||||
$name,
|
||||
$email,
|
||||
$phone,
|
||||
password_hash($data['password'], PASSWORD_DEFAULT),
|
||||
!empty($data['marketing_consent']) ? 1 : 0,
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
if ((int) ($e->errorInfo[1] ?? 0) === 1062) return ['error' => 'email_taken'];
|
||||
throw $e;
|
||||
}
|
||||
return ['id' => (int) $pdo->lastInsertId()];
|
||||
}
|
||||
|
||||
// ─── Backoffice user creation ────────────────────────────────────────────────
|
||||
|
||||
// Creates a sysadmin (full access incl. backups + platform update). Used once,
|
||||
// by views/auth/setup.php, on a fresh install.
|
||||
function auth_create_sysadmin(PDO $pdo, string $name, string $email, string $password): int {
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO users (name, email, password_hash, role)
|
||||
@@ -32,7 +145,7 @@ function auth_create_sysadmin(PDO $pdo, string $name, string $email, string $pas
|
||||
return (int) $pdo->lastInsertId();
|
||||
}
|
||||
|
||||
// Returns ['id'=>int] on success, or ['error'=>'andmore_email_taken'|'unavailable'].
|
||||
// Returns ['id' => int] on success, or ['error' => 'email_taken'].
|
||||
function auth_create_admin(PDO $pdo, string $name, string $email, string $phone, string $password): array {
|
||||
return _create_backoffice_user($pdo, 'admin', $name, $email, $phone, $password);
|
||||
}
|
||||
@@ -41,41 +154,113 @@ function auth_create_staff(PDO $pdo, string $name, string $email, string $phone,
|
||||
return _create_backoffice_user($pdo, 'staff', $name, $email, $phone, $password);
|
||||
}
|
||||
|
||||
// Internal: create an admin/staff row and, in federated mode, link it to the
|
||||
// central Andmore ID (admin-vouched — the local password is mirrored upstream
|
||||
// so SSO_REQUIRED=true never locks out the new user). See config/sso.php
|
||||
// identity_upstream_link_immediate() for the upstream rules.
|
||||
function _create_backoffice_user(PDO $pdo, string $role, string $name, string $email, string $phone, string $password): array {
|
||||
if (!in_array($role, ['admin', 'staff'], true)) {
|
||||
throw new InvalidArgumentException("Invalid backoffice role: {$role}");
|
||||
}
|
||||
$email = strtolower(trim($email));
|
||||
|
||||
$canonicalId = null;
|
||||
if (identity_is_federated()) {
|
||||
$link = identity_upstream_link_immediate($email, $name, $phone, $password);
|
||||
if (($link['error'] ?? '') === 'andmore_email_taken') {
|
||||
return ['error' => 'andmore_email_taken'];
|
||||
}
|
||||
if (($link['error'] ?? '') === 'unavailable' && identity_require_upstream()) {
|
||||
return ['error' => 'unavailable'];
|
||||
}
|
||||
if (isset($link['canonical_id'])) {
|
||||
$canonicalId = (int) $link['canonical_id'];
|
||||
}
|
||||
try {
|
||||
$pdo->prepare("
|
||||
INSERT INTO users (name, email, phone, password_hash, role)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
")->execute([
|
||||
$name,
|
||||
strtolower(trim($email)),
|
||||
$phone ?: null,
|
||||
password_hash($password, PASSWORD_DEFAULT),
|
||||
$role,
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
if ((int) ($e->errorInfo[1] ?? 0) === 1062) return ['error' => 'email_taken'];
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO users (name, email, phone, password_hash, role, andmore_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
$stmt->execute([
|
||||
$name,
|
||||
$email,
|
||||
$phone ?: null,
|
||||
password_hash($password, PASSWORD_DEFAULT),
|
||||
$role,
|
||||
$canonicalId,
|
||||
]);
|
||||
return ['id' => (int) $pdo->lastInsertId()];
|
||||
}
|
||||
|
||||
// ─── Password reset ──────────────────────────────────────────────────────────
|
||||
// Single-use tokens in `password_resets`, valid for 2 hours. Minting a new one
|
||||
// invalidates any outstanding token for that user.
|
||||
|
||||
function auth_create_reset_token(PDO $pdo, int $userId): string {
|
||||
$pdo->prepare("DELETE FROM password_resets WHERE user_id = ? AND used_at IS NULL")
|
||||
->execute([$userId]);
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$pdo->prepare(
|
||||
"INSERT INTO password_resets (user_id, token, expires_at)
|
||||
VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 2 HOUR))"
|
||||
)->execute([$userId, $token]);
|
||||
return $token;
|
||||
}
|
||||
|
||||
function auth_get_valid_reset_token(PDO $pdo, string $token): array|false {
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT pr.user_id, pr.token, u.name, u.email, u.role
|
||||
FROM password_resets pr
|
||||
JOIN users u ON u.id = pr.user_id
|
||||
WHERE pr.token = ?
|
||||
AND pr.used_at IS NULL
|
||||
AND pr.expires_at > NOW()
|
||||
AND u.is_active = 1
|
||||
LIMIT 1
|
||||
");
|
||||
$stmt->execute([$token]);
|
||||
return $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
function auth_consume_reset_token(PDO $pdo, string $token, string $newPassword): bool {
|
||||
$reset = auth_get_valid_reset_token($pdo, $token);
|
||||
if (!$reset) return false;
|
||||
// auth_set_password() burns every outstanding token for this user, this one
|
||||
// included, so the link can't be replayed.
|
||||
auth_set_password($pdo, (int) $reset['user_id'], $newPassword);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Email: password reset / first-password invitation ───────────────────────
|
||||
// Delivery is centralized at the hub's /notify endpoint — see
|
||||
// config/functions_queue.php (enqueue_email + flush_message).
|
||||
|
||||
function send_password_reset_email(string $name, string $email, string $resetUrl, ?int $userId = null): bool {
|
||||
$body = load_email_template('password_reset', [
|
||||
'user_name' => $name,
|
||||
'reset_url' => $resetUrl,
|
||||
'app_name' => APP_NAME,
|
||||
]);
|
||||
$id = enqueue_email([
|
||||
'to_user_id' => $userId,
|
||||
'to_address' => $email,
|
||||
'to_name' => $name,
|
||||
'subject' => 'Redefinir palavra-passe',
|
||||
'body' => $body,
|
||||
'alt_body' => "Olá {$name},\n\nPara definir uma nova palavra-passe aceda a:\n{$resetUrl}\n\nO link é válido durante 2 horas.",
|
||||
'context_type' => 'password_reset',
|
||||
'context_id' => $userId,
|
||||
]);
|
||||
return flush_message($id);
|
||||
}
|
||||
|
||||
// An account created from the backoffice has no password, so the person has no
|
||||
// way of knowing it exists. Invites them to set one, reusing the reset token —
|
||||
// same mechanism, different copy.
|
||||
function send_password_invite_email(PDO $pdo, array $user): bool {
|
||||
if (empty($user['email'])) return false;
|
||||
$token = auth_create_reset_token($pdo, (int) $user['id']);
|
||||
$url = base_url('views/auth/reset_password.php?token=' . $token);
|
||||
$name = (string) ($user['name'] ?? '');
|
||||
|
||||
$body = load_email_template('password_invite', [
|
||||
'user_name' => $name,
|
||||
'reset_url' => $url,
|
||||
'app_name' => APP_NAME,
|
||||
]);
|
||||
$id = enqueue_email([
|
||||
'to_user_id' => (int) $user['id'],
|
||||
'to_address' => $user['email'],
|
||||
'to_name' => $name,
|
||||
'subject' => 'A sua conta ' . APP_NAME,
|
||||
'body' => $body,
|
||||
'alt_body' => "Olá {$name},\n\nFoi criada uma conta para si em " . APP_NAME . ".\nDefina a sua palavra-passe em:\n{$url}\n\nO link é válido durante 2 horas.",
|
||||
'context_type' => 'password_invite',
|
||||
'context_id' => (int) $user['id'],
|
||||
]);
|
||||
return flush_message($id);
|
||||
}
|
||||
-378
@@ -1,378 +0,0 @@
|
||||
<?php
|
||||
// Identity / SSO integration with id.andmore.pt.
|
||||
//
|
||||
// Two modes, picked by IDENTITY_MODE in .env:
|
||||
//
|
||||
// standalone — every function in this file is a no-op (returns null/false).
|
||||
// Local users.id is the only identity that matters.
|
||||
// federated — register/login flows on this install also write to the central
|
||||
// andmore_id DB so users.andmore_id is always populated. The
|
||||
// am_sso cookie is issued by this install so the user lands
|
||||
// already-logged-in on any other .andmore.pt platform.
|
||||
//
|
||||
// Direct DB access is used rather than HTTP for now — easier and matches how
|
||||
// sso_resolve_session() already works. Swap to an HTTP API later if the
|
||||
// platforms stop sharing a DB host.
|
||||
|
||||
// SSO_COOKIE_NAME is defined in config/config.php (defaults to 'am_sso',
|
||||
// override via env so prod and dev can coexist on the same parent domain).
|
||||
|
||||
// Cached central-DB connection — federated calls reuse it within a request.
|
||||
function _sso_open_pdo(): ?PDO {
|
||||
static $cached = null;
|
||||
if ($cached !== null) return $cached ?: null; // false sentinel = tried and failed
|
||||
|
||||
if (!identity_is_federated()) { $cached = false; return null; }
|
||||
|
||||
$host = env('SSO_DB_HOST') ?: env('DB_HOST', '127.0.0.1');
|
||||
$port = (int) (env('SSO_DB_PORT') ?: env('DB_PORT', 3306));
|
||||
$name = env('SSO_DB_NAME', '');
|
||||
$user = env('SSO_DB_USER') ?: env('DB_USER', 'root');
|
||||
$pass = env('SSO_DB_PASS') ?: env('DB_PASS', '');
|
||||
|
||||
if (!$name) { $cached = false; return null; }
|
||||
|
||||
try {
|
||||
$cached = new PDO(
|
||||
"mysql:host={$host};port={$port};dbname={$name};charset=utf8mb4",
|
||||
$user, $pass,
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]
|
||||
);
|
||||
return $cached;
|
||||
} catch (PDOException $e) {
|
||||
error_log('[sso] DB connection failed: ' . $e->getMessage());
|
||||
$cached = false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Called by require_login() (and login.php / register.php) before any
|
||||
// redirect-to-login fallback. When a valid am_sso cookie is present it
|
||||
// makes sure the local PHP session reflects that exact identity:
|
||||
// - same andmore_id as the cookie → no-op (fast path).
|
||||
// - different andmore_id (or no andmore_id) → clear the local session and
|
||||
// re-provision from the SSO row so a stale Ana-Paula-style login doesn't
|
||||
// mask the real federated user.
|
||||
// No-op in standalone mode (the parent-domain cookie wouldn't reach this
|
||||
// install anyway).
|
||||
function sso_resolve_session(PDO $pdo): void {
|
||||
if (!identity_is_federated()) return;
|
||||
|
||||
$token = $_COOKIE[SSO_COOKIE_NAME] ?? '';
|
||||
if (strlen($token) !== 64 || !ctype_xdigit($token)) return;
|
||||
|
||||
$ssoPdo = _sso_open_pdo();
|
||||
if (!$ssoPdo) return;
|
||||
|
||||
try {
|
||||
$stmt = $ssoPdo->prepare("
|
||||
SELECT s.user_id, u.email, u.name, u.phone, u.is_active
|
||||
FROM sso_sessions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token = ?
|
||||
AND s.expires_at > NOW()
|
||||
AND u.is_active = 1
|
||||
LIMIT 1
|
||||
");
|
||||
$stmt->execute([$token]);
|
||||
$ssoRow = $stmt->fetch();
|
||||
} catch (PDOException $e) {
|
||||
error_log('[sso] token lookup failed: ' . $e->getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$ssoRow) return;
|
||||
|
||||
$canonicalId = (int) $ssoRow['user_id'];
|
||||
|
||||
// If a local session is already set, decide whether to keep it or replace.
|
||||
if (!empty($_SESSION['user_id'])) {
|
||||
$sessionAndmoreId = (int) ($_SESSION['user']['andmore_id'] ?? 0);
|
||||
if ($sessionAndmoreId === $canonicalId) {
|
||||
return; // Same person — keep the existing session.
|
||||
}
|
||||
// Either: (a) session is for an unrelated andmore_id (user switched
|
||||
// identities at the hub), or (b) session is for a local-only user that
|
||||
// never got linked. In both cases the SSO cookie reflects the real
|
||||
// intent — clear the stale identity bits and re-provision.
|
||||
unset($_SESSION['user_id'], $_SESSION['user']);
|
||||
}
|
||||
|
||||
_sso_provision_local($pdo, $ssoRow);
|
||||
}
|
||||
|
||||
// Cross-domain handoff: redeem a one-time ticket minted by the hub. This is the
|
||||
// real login path for this install, because fitme3070.pt is NOT under
|
||||
// .andmore.pt — the am_sso cookie never reaches it, so sso_resolve_session()'s
|
||||
// cookie lookup always comes up empty. The hub appends ?sso_ticket=… to the
|
||||
// return URL after authenticating.
|
||||
//
|
||||
// We redeem over HTTPS, NOT the direct central DB: this install lives on
|
||||
// external hosting and can't reach the central DB's private LAN address. The
|
||||
// hub's api/sso_redeem.php burns the ticket against its local DB (single-use,
|
||||
// bound to PLATFORM_SLUG, ~60s TTL) and returns the central user, which we
|
||||
// provision into the LOCAL DB. Auth reuses the content-pull HMAC scheme with
|
||||
// the ticket folded in. Returns true when a session was established.
|
||||
function sso_redeem_ticket(PDO $pdo, string $ticket): bool {
|
||||
if (!identity_is_federated()) return false;
|
||||
if (strlen($ticket) !== 64 || !ctype_xdigit($ticket)) return false;
|
||||
|
||||
$secret = (string) env('HUB_CONTENT_SECRET', '');
|
||||
if ($secret === '') { error_log('[sso] ticket redeem: HUB_CONTENT_SECRET not set'); return false; }
|
||||
|
||||
$url = rtrim(ANDMORE_ID_URL, '/') . '/api/sso_redeem.php';
|
||||
$path = (string) (parse_url($url, PHP_URL_PATH) ?? '/');
|
||||
$date = gmdate('c');
|
||||
$token = hash_hmac('sha256', $date . $path . $ticket, $secret);
|
||||
$body = http_build_query(['ticket' => $ticket]);
|
||||
$headers = [
|
||||
'X-Hub-Platform: ' . PLATFORM_SLUG,
|
||||
'X-Hub-Date: ' . $date,
|
||||
'X-Hub-Token: ' . $token,
|
||||
'Accept: application/json',
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
];
|
||||
|
||||
// cURL first (same transport the notify/content clients use); stream
|
||||
// fallback for installs without the extension.
|
||||
$raw = false;
|
||||
if (function_exists('curl_init')) {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $body,
|
||||
CURLOPT_TIMEOUT => 12,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_USERAGENT => 'fitme3070-SSO/1.0',
|
||||
]);
|
||||
$raw = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
}
|
||||
if ($raw === false) {
|
||||
$ctx = stream_context_create(['http' => [
|
||||
'method' => 'POST',
|
||||
'timeout' => 12,
|
||||
'header' => implode("\r\n", $headers),
|
||||
'content' => $body,
|
||||
'ignore_errors' => true,
|
||||
]]);
|
||||
$raw = @file_get_contents($url, false, $ctx);
|
||||
}
|
||||
if ($raw === false) { error_log('[sso] ticket redeem: no response from hub'); return false; }
|
||||
|
||||
$data = json_decode((string) $raw, true);
|
||||
if (!is_array($data) || empty($data['ok']) || empty($data['user'])) {
|
||||
error_log('[sso] ticket redeem rejected: ' . substr((string) $raw, 0, 200));
|
||||
return false;
|
||||
}
|
||||
|
||||
$u = $data['user'];
|
||||
$ssoRow = [
|
||||
'user_id' => (int) ($u['user_id'] ?? 0),
|
||||
'email' => (string) ($u['email'] ?? ''),
|
||||
'name' => (string) ($u['name'] ?? ''),
|
||||
'phone' => $u['phone'] ?? null,
|
||||
];
|
||||
if ($ssoRow['user_id'] <= 0 || $ssoRow['email'] === '') return false;
|
||||
|
||||
// Explicit fresh-login intent — drop any stale local identity, then provision.
|
||||
unset($_SESSION['user_id'], $_SESSION['user']);
|
||||
return _sso_provision_local($pdo, $ssoRow);
|
||||
}
|
||||
|
||||
// Find-or-create the local users row for a central identity, sync name/phone,
|
||||
// stamp last_login, and set the local PHP session. $ssoRow carries the central
|
||||
// columns: user_id (canonical andmore id), email, name, phone. Returns true
|
||||
// when a session was established. Shared by sso_resolve_session() (cookie path,
|
||||
// same-site siblings) and sso_redeem_ticket() (ticket path, this install).
|
||||
function _sso_provision_local(PDO $pdo, array $ssoRow): bool {
|
||||
$canonicalId = (int) $ssoRow['user_id'];
|
||||
$email = strtolower(trim((string) $ssoRow['email']));
|
||||
|
||||
// ─── Find or create the local row ────────────────────────────────────
|
||||
// Lookup order: andmore_id (fast, set on prior logins) → email (canonical
|
||||
// identity per the SSO-only model). The email branch claims any matching
|
||||
// row regardless of password_hash — the central platform vouches for the
|
||||
// email so there is no "another person owns this address" scenario to
|
||||
// worry about. UNIQUE(email) on the users table guarantees at most one
|
||||
// match.
|
||||
$stmt = $pdo->prepare("SELECT * FROM users WHERE andmore_id = ? LIMIT 1");
|
||||
$stmt->execute([$canonicalId]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if (!$user) {
|
||||
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = ? LIMIT 1");
|
||||
$stmt->execute([$email]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if (!$user) {
|
||||
// No local row yet — provision a fresh client row stamped with
|
||||
// andmore_id. Role default = client; staff promotion happens by
|
||||
// an admin editing the row later.
|
||||
try {
|
||||
$pdo->prepare("
|
||||
INSERT INTO users (name, email, phone, role, andmore_id, is_active)
|
||||
VALUES (?, ?, ?, 'client', ?, 1)
|
||||
")->execute([
|
||||
(string) $ssoRow['name'],
|
||||
$email,
|
||||
$ssoRow['phone'] ?: null,
|
||||
$canonicalId,
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
error_log('[sso] provisioning insert failed: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
$stmt = $pdo->prepare("SELECT * FROM users WHERE andmore_id = ? LIMIT 1");
|
||||
$stmt->execute([$canonicalId]);
|
||||
$user = $stmt->fetch();
|
||||
}
|
||||
}
|
||||
|
||||
if (!$user || !$user['is_active']) return false;
|
||||
|
||||
// ─── Sync per-tenant row with central data ───────────────────────────
|
||||
// Stamp the link if missing, refresh name/phone when the central record
|
||||
// has a newer value. Local role is preserved — the hub does not know about
|
||||
// per-tenant roles.
|
||||
$patch = [];
|
||||
if (empty($user['andmore_id']) || (int) $user['andmore_id'] !== $canonicalId) {
|
||||
$patch['andmore_id'] = $canonicalId;
|
||||
}
|
||||
if (!empty($ssoRow['name']) && $ssoRow['name'] !== $user['name']) {
|
||||
$patch['name'] = (string) $ssoRow['name'];
|
||||
}
|
||||
if (!empty($ssoRow['phone']) && $ssoRow['phone'] !== ($user['phone'] ?? '')) {
|
||||
$patch['phone'] = (string) $ssoRow['phone'];
|
||||
}
|
||||
if ($patch) {
|
||||
$set = implode(', ', array_map(fn ($k) => "$k = ?", array_keys($patch)));
|
||||
$pdo->prepare("UPDATE users SET $set WHERE id = ?")
|
||||
->execute([...array_values($patch), (int) $user['id']]);
|
||||
$user = array_merge($user, $patch);
|
||||
}
|
||||
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? null;
|
||||
$ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512) ?: null;
|
||||
$pdo->prepare("UPDATE users SET last_login_at = NOW(), last_login_ip = ?, last_login_ua = ? WHERE id = ?")
|
||||
->execute([$ip, $ua, $user['id']]);
|
||||
|
||||
auth_set_session($user);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Upstream sync (federated mode) ──────────────────────────────────────────
|
||||
//
|
||||
// Only the admin-vouched immediate link + the cookie issuer remain. The older
|
||||
// two-phase verify-email flow (request_link / redeem_pending / has_pending)
|
||||
// was used by the local register/login pages, which were removed when the
|
||||
// platform became SSO-only. sso_resolve_session() does the email-match work
|
||||
// directly now — no token round-trip needed.
|
||||
|
||||
// Admin-vouched immediate link. Used by auth_create_admin() / auth_create_staff()
|
||||
// when a sysadmin/admin creates a backoffice user on this satellite. Instead of
|
||||
// the verify-email round-trip, we trust the admin's vouching: the password the
|
||||
// admin set on the local form is also written to the central row so the new
|
||||
// person can immediately use SSO on any sibling .andmore.pt platform.
|
||||
//
|
||||
// - No central row for this email → create central row WITH the password
|
||||
// (admin-set, email_verified_at stamped).
|
||||
// - Passwordless central row exists → claim it AND set the password.
|
||||
// - Passworded central row exists → refuse with 'andmore_email_taken'.
|
||||
// An admin can't silently reset
|
||||
// someone else's Andmore ID.
|
||||
//
|
||||
// Outcomes:
|
||||
// ['canonical_id' => int] ok — stamp this on the local row
|
||||
// ['error' => 'andmore_email_taken'] central row exists with a password
|
||||
// ['error' => 'unavailable'] central DB unreachable
|
||||
// ['error' => 'standalone'] IDENTITY_MODE != federated
|
||||
function identity_upstream_link_immediate(string $email, string $name, ?string $phone, ?string $password = null): array {
|
||||
if (!identity_is_federated()) return ['error' => 'standalone'];
|
||||
|
||||
$ssoPdo = _sso_open_pdo();
|
||||
if (!$ssoPdo) return ['error' => 'unavailable'];
|
||||
|
||||
$email = strtolower(trim($email));
|
||||
$hash = ($password !== null && $password !== '')
|
||||
? password_hash($password, PASSWORD_DEFAULT)
|
||||
: null;
|
||||
|
||||
try {
|
||||
$stmt = $ssoPdo->prepare("SELECT id, password_hash FROM users WHERE email = ? LIMIT 1");
|
||||
$stmt->execute([$email]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
if ($row) {
|
||||
if (!empty($row['password_hash'])) {
|
||||
return ['error' => 'andmore_email_taken'];
|
||||
}
|
||||
// Passwordless placeholder — claim it AND set the admin-vouched password.
|
||||
if ($hash !== null) {
|
||||
$ssoPdo->prepare("
|
||||
UPDATE users
|
||||
SET name = COALESCE(NULLIF(?, ''), name),
|
||||
phone = COALESCE(NULLIF(?, ''), phone),
|
||||
password_hash = ?,
|
||||
email_verified_at = COALESCE(email_verified_at, NOW())
|
||||
WHERE id = ?
|
||||
")->execute([$name, (string) $phone, $hash, (int) $row['id']]);
|
||||
}
|
||||
return ['canonical_id' => (int) $row['id']];
|
||||
}
|
||||
|
||||
$ssoPdo->prepare("
|
||||
INSERT INTO users (name, email, phone, password_hash, email_verified_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
")->execute([
|
||||
$name,
|
||||
$email,
|
||||
$phone,
|
||||
$hash,
|
||||
$hash !== null ? date('Y-m-d H:i:s') : null,
|
||||
]);
|
||||
return ['canonical_id' => (int) $ssoPdo->lastInsertId()];
|
||||
} catch (PDOException $e) {
|
||||
error_log('[sso] upstream link-immediate failed: ' . $e->getMessage());
|
||||
return ['error' => 'unavailable'];
|
||||
}
|
||||
}
|
||||
|
||||
// Issue an am_sso cookie scoped to the parent domain by inserting a row into
|
||||
// the central sso_sessions table. Lets the user land already-logged-in on any
|
||||
// other .andmore.pt platform. No-op when standalone or when SSO_COOKIE_DOMAIN
|
||||
// isn't set.
|
||||
function identity_upstream_issue_cookie(int $canonicalUserId): void {
|
||||
if (!identity_is_federated() || SSO_COOKIE_DOMAIN === '') return;
|
||||
|
||||
$ssoPdo = _sso_open_pdo();
|
||||
if (!$ssoPdo) return;
|
||||
|
||||
try {
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$ttl = max(1, SSO_SESSION_TTL_DAYS) * 86400;
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? null;
|
||||
$ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512) ?: null;
|
||||
$ssoPdo->prepare("
|
||||
INSERT INTO sso_sessions (token, user_id, expires_at, ip, ua, last_seen_at)
|
||||
VALUES (?, ?, DATE_ADD(NOW(), INTERVAL ? SECOND), ?, ?, NOW())
|
||||
")->execute([$token, $canonicalUserId, $ttl, $ip, $ua]);
|
||||
|
||||
setcookie(SSO_COOKIE_NAME, $token, [
|
||||
'expires' => time() + $ttl,
|
||||
'path' => '/',
|
||||
'domain' => SSO_COOKIE_DOMAIN,
|
||||
// request_is_https() respects X-Forwarded-Proto when TRUSTED_PROXY=true
|
||||
// — needed so the Secure flag isn't dropped behind nginx/Cloudflare.
|
||||
'secure' => request_is_https(),
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
error_log('[sso] upstream cookie issue failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user