diff --git a/.env.example b/.env.example
index 16ef828..523ca01 100644
--- a/.env.example
+++ b/.env.example
@@ -25,40 +25,15 @@ DB_PASS=
BACKUP_TOKEN=replace_me
BACKUP_KEEP_DAYS=7
-# ─── Identity / Andmore ID ─────────────────────────────────────────────────────
-# federated — auth is delegated to id.andmore.pt; the am_sso cookie + central
-# sso_sessions/users tables drive login. This site is part of the ecosystem, so
-# federated is the expected mode (standalone disables all SSO traffic).
-IDENTITY_MODE=federated
-
-# Slug identifying this install on andmore_id.linked_platforms.
-PLATFORM_SLUG=fitme3070
-
-# When true (federated): a register/login fails loudly if the central DB is
-# unreachable, instead of creating an orphan local row. True on prod.
-IDENTITY_REQUIRE_UPSTREAM=false
-
-# Andmore hub central service URL.
+# ─── Andmore hub (notifications only) ────────────────────────────────────────
+# This install owns its own login: passwords live in users.password_hash and
+# views/auth/login.php is a real form. The Andmore ID / SSO delegation was
+# removed — no IDENTITY_MODE, no SSO_* keys, no central sso_sessions table.
+#
+# ANDMORE_ID_URL stays only because outbound email/SMS is still relayed through
+# the hub's /notify endpoint (see NOTIFY_API_KEY below).
ANDMORE_ID_URL=https://hub.andmore.pt/
-# Direct DB credentials for the central Andmore ID database (federated mode).
-SSO_DB_HOST=
-SSO_DB_PORT=
-SSO_DB_NAME=andmore_id
-SSO_DB_USER=
-SSO_DB_PASS=
-
-# Parent domain the am_sso cookie is scoped to. Must match id.andmore.pt.
-SSO_COOKIE_DOMAIN=.andmore.pt
-# Override per environment (e.g. am_sso_dev on dev) so prod/dev cookies on the
-# shared parent domain don't clobber each other.
-SSO_COOKIE_NAME=am_sso
-SSO_SESSION_TTL_DAYS=30
-
-# When true (federated): unauthenticated users are sent straight to
-# id.andmore.pt. Leave false during the member migration, flip true after.
-SSO_REQUIRED=false
-
# ─── Notificações centralizadas (id.andmore.pt /notify) ──────────────────────
# App key for POST {ANDMORE_ID_URL}notify/send. Minted on id.andmore.pt →
# Notificações → Chaves de app.
diff --git a/config/auth.php b/config/auth.php
index 5afccd9..c4052b6 100644
--- a/config/auth.php
+++ b/config/auth.php
@@ -1,29 +1,16 @@
$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, '/');
+}
diff --git a/config/config.php b/config/config.php
index 0abf99b..62c1773 100644
--- a/config/config.php
+++ b/config/config.php
@@ -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'), '/') . '/');
diff --git a/config/functions.php b/config/functions.php
index 10cb7ef..4bdc8f0 100644
--- a/config/functions.php
+++ b/config/functions.php
@@ -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.
diff --git a/config/functions_throttle.php b/config/functions_throttle.php
new file mode 100644
index 0000000..08e31c3
--- /dev/null
+++ b/config/functions_throttle.php
@@ -0,0 +1,69 @@
+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 '
Demasiadas tentativas'
+ . ''
+ . '
Demasiadas tentativas
'
+ . '
Aguarde alguns minutos antes de tentar novamente.
'
+ . '
';
+ if ($reason !== '') error_log("[throttle] blocked: {$reason} ip=" . _throttle_ip());
+ exit;
+}
diff --git a/config/functions_user.php b/config/functions_user.php
index 8c168fc..83ee003 100644
--- a/config/functions_user.php
+++ b/config/functions_user.php
@@ -1,14 +1,22 @@
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);
+}
diff --git a/config/sso.php b/config/sso.php
deleted file mode 100644
index 349f984..0000000
--- a/config/sso.php
+++ /dev/null
@@ -1,378 +0,0 @@
- 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());
- }
-}
diff --git a/database/migration_20260913_local_auth.sql b/database/migration_20260913_local_auth.sql
new file mode 100644
index 0000000..18eaf9a
--- /dev/null
+++ b/database/migration_20260913_local_auth.sql
@@ -0,0 +1,34 @@
+-- Local authentication: password reset tokens + per-IP attempt throttling.
+--
+-- Added when fitme3070 stopped delegating login to the Andmore ID hub and went
+-- back to owning its own passwords (users.password_hash, which was never
+-- dropped). See config/functions_user.php and config/functions_throttle.php.
+--
+-- Safe to re-run: every statement is IF NOT EXISTS.
+
+CREATE TABLE IF NOT EXISTS `password_resets` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `user_id` INT UNSIGNED NOT NULL,
+ `token` VARCHAR(64) NOT NULL,
+ `expires_at` DATETIME NOT NULL,
+ `used_at` DATETIME DEFAULT NULL,
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uq_pr_token` (`token`),
+ KEY `idx_pr_user` (`user_id`),
+ CONSTRAINT `fk_pr_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS `auth_attempts` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `ip` VARCHAR(45) NOT NULL,
+ `scope` VARCHAR(20) NOT NULL,
+ `attempted_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_aa_window` (`ip`, `scope`, `attempted_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- `users.andmore_id` is intentionally NOT dropped. It no longer has any code
+-- reading or writing it, but it records which central Andmore ID each row was
+-- linked to during the SSO era — useful if those accounts ever need to be
+-- reconciled. Drop it by hand once that history stops mattering.
diff --git a/includes/auth_footer.php b/includes/auth_footer.php
new file mode 100644
index 0000000..fd5287d
--- /dev/null
+++ b/includes/auth_footer.php
@@ -0,0 +1,21 @@
+
+
+
+
+