From aec7b47820093bd0534431eacfec45a12edfe515 Mon Sep 17 00:00:00 2001 From: andre Date: Sun, 13 Sep 2026 21:00:57 +0100 Subject: [PATCH] Replace Andmore ID SSO with local login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 39 +-- config/auth.php | 53 +-- config/config.php | 43 +-- config/functions.php | 8 +- config/functions_throttle.php | 69 ++++ config/functions_user.php | 271 ++++++++++++--- config/sso.php | 378 --------------------- database/migration_20260913_local_auth.sql | 34 ++ includes/auth_footer.php | 21 ++ includes/auth_header.php | 56 +++ site/_open.php | 4 - site/evento.php | 4 - templates/emails/password_invite.html | 67 ++++ templates/emails/password_reset.html | 67 ++++ views/auth/forgot_password.php | 82 ++++- views/auth/login.php | 150 ++++---- views/auth/logout.php | 27 +- views/auth/register.php | 141 ++++++-- views/auth/reset_password.php | 78 ++++- views/auth/setup.php | 78 ++--- views/user/index.php | 17 +- views/user/profile.php | 97 ++++-- views/user/set_password.php | 121 +++++++ 23 files changed, 1195 insertions(+), 710 deletions(-) create mode 100644 config/functions_throttle.php delete mode 100644 config/sso.php create mode 100644 database/migration_20260913_local_auth.sql create mode 100644 includes/auth_footer.php create mode 100644 includes/auth_header.php create mode 100644 templates/emails/password_invite.html create mode 100644 templates/emails/password_reset.html create mode 100644 views/user/set_password.php 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 @@ + + + + + + diff --git a/includes/auth_header.php b/includes/auth_header.php new file mode 100644 index 0000000..cc7bf18 --- /dev/null +++ b/includes/auth_header.php @@ -0,0 +1,56 @@ +. +$page_title = $page_title ?? 'Entrar'; +$app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070'; +?> + + + + + + <?= htmlspecialchars($page_title) ?> — <?= htmlspecialchars($app_name) ?> + + + + + +
+
diff --git a/site/_open.php b/site/_open.php index 765951c..08a1c38 100644 --- a/site/_open.php +++ b/site/_open.php @@ -5,10 +5,6 @@ require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../config/functions.php'; -if (empty($_SESSION['user_id']) && identity_is_federated()) { - sso_resolve_session($pdo); -} - $info = site_info($pdo); $navbar = site_navbar(); $current_uri_2 = $current_uri_2 ?? ''; diff --git a/site/evento.php b/site/evento.php index 45dcec5..f7124c2 100644 --- a/site/evento.php +++ b/site/evento.php @@ -9,10 +9,6 @@ require_once __DIR__ . '/../config/functions.php'; // Resolve any SSO session up front (mirrors _open.php) so the is_staff() preview // gate below sees a logged-in admin. _open.php's later call then no-ops. -if (empty($_SESSION['user_id']) && identity_is_federated()) { - sso_resolve_session($pdo); -} - $slug = preg_replace('/[^a-z0-9\-]/', '', strtolower((string) ($_GET['slug'] ?? ''))); $event = $slug !== '' ? db_events_get_by_slug($pdo, $slug) : null; diff --git a/templates/emails/password_invite.html b/templates/emails/password_invite.html new file mode 100644 index 0000000..9b6b4f1 --- /dev/null +++ b/templates/emails/password_invite.html @@ -0,0 +1,67 @@ + + + + + + A sua conta + + + + + + +
+ + + + + + + + + + + + + + + + + +
+ {{logo_html}} +
+

Olá {{user_name}},

+

+ Foi criada uma conta para si em {{app_name}}. Para começar a usá-la, + defina a sua palavra-passe através do botão abaixo. +

+ + + + +
+ + Definir palavra-passe + +
+

+ Ou copie este endereço para o seu browser: +

+

+ {{reset_url}} +

+

+ Este link é válido durante 2 horas e só pode ser usado uma vez. + Se expirar, use a opção Esqueceu a palavra-passe? na página de entrada. +

+
+
+
+

+ {{app_name}} +

+
+
+ + diff --git a/templates/emails/password_reset.html b/templates/emails/password_reset.html new file mode 100644 index 0000000..49d8893 --- /dev/null +++ b/templates/emails/password_reset.html @@ -0,0 +1,67 @@ + + + + + + Redefinir palavra-passe + + + + + + +
+ + + + + + + + + + + + + + + + + +
+ {{logo_html}} +
+

Olá {{user_name}},

+

+ Recebemos um pedido para definir uma nova palavra-passe da sua conta + {{app_name}}. Clique no botão abaixo para continuar. +

+ + + + +
+ + Definir palavra-passe + +
+

+ Ou copie este endereço para o seu browser: +

+

+ {{reset_url}} +

+

+ Este link é válido durante 2 horas e só pode ser usado uma vez. + Se não pediu esta alteração, ignore este email — a sua conta permanece segura. +

+
+
+
+

+ {{app_name}} +

+
+
+ + diff --git a/views/auth/forgot_password.php b/views/auth/forgot_password.php index b9cadbb..0a068ae 100644 --- a/views/auth/forgot_password.php +++ b/views/auth/forgot_password.php @@ -1,10 +1,82 @@ +

Enviamos-lhe um link para definir uma nova palavra-passe.

+ + +
+ + + +
+ Se existir uma conta com esse email, enviámos-lhe as instruções. + O link é válido durante 2 horas. +
+ Voltar ao login + +
+ +
+ + +
+ +
+
+

+ Voltar ao login +

+ + diff --git a/views/auth/login.php b/views/auth/login.php index f6a7aa5..3011c3a 100644 --- a/views/auth/login.php +++ b/views/auth/login.php @@ -1,76 +1,106 @@ 0); -$from_sso = isset($_GET['from']) && $_GET['from'] === 'sso'; - -$dashboard_for = function (string $role) use ($claim_intent, $session_id): string { - if ($claim_intent && $role === 'client') { - return 'views/user/dashboard.php?focus_session=' . $session_id; - } - return post_login_destination($role); +$destination = function (string $role) use ($return_to): string { + return $return_to !== '' ? $return_to : post_login_destination($role); }; if (!empty($_SESSION['user_id'])) { - header('Location: ' . base_url($dashboard_for($_SESSION['user']['role'] ?? ''))); + header('Location: ' . base_url($destination($_SESSION['user']['role'] ?? ''))); exit; } -// We came back from id.andmore.pt but sso_resolve_session() couldn't pick up -// a valid session. Bouncing to id.andmore.pt again would loop forever — show -// a clean error instead so the user can see what's wrong. -if ($from_sso) { - http_response_code(500); - $am_cookie_present = !empty($_COOKIE[SSO_COOKIE_NAME] ?? ''); - ?>Erro de autenticação - - -

Não foi possível concluir o login

-

O servidor Andmore ID autenticou-o, mas esta plataforma não conseguiu carregar a sua sessão.

-

- Cookie am_sso: em falta' ?>.
- Plataforma: · Modo: . +$error = ''; +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + csrf_check(); + + // Counts successes too, so a guessed password can't be parlayed into + // stuffing the rest of the user list from the same IP. + if (!throttle_check($pdo, 'login')) { + throttle_block('login form'); + } + throttle_record($pdo, 'login'); + + $user = auth_login($pdo, $_POST['email'] ?? '', $_POST['password'] ?? ''); + if ($user) { + auth_set_session($user); + header('Location: ' . base_url($destination($user['role']))); + exit; + } + // Deliberately vague — doesn't reveal whether the address exists. + $error = 'Email ou palavra-passe incorretos.'; +} + +// Preserve the redirect intent across the POST. +$form_qs = $return_to !== '' ? ('?' . http_build_query(['return_to' => $_GET['return_to'] ?? ''])) : ''; + +$page_title = 'Entrar'; +include __DIR__ . '/../../includes/auth_header.php'; +?> +

Entre na sua conta para continuar.

+ + +
+ +

+ Se a sua conta foi criada por nós, ainda não tem palavra-passe definida — + use Esqueceu a palavra-passe? + para definir uma.

-

Voltar ao início · Tentar novamente

- + +
Conta criada. Já pode entrar.
+ + +
Palavra-passe alterada. Entre com a nova palavra-passe.
+ + +
Sessão terminada.
+ -// First arrival — bounce to id.andmore.pt for credentials, tagging the -// return URL with ?from=sso so the loop-guard above fires on the way back. -$return_to = base_url('views/auth/login.php?from=sso' - . ($claim_intent ? '&next=session&session_id=' . $session_id : '')); +
+ +
+ + +
+
+ + +
+ + +
+ +
+ +
-$_login_qs = '?return_to=' . urlencode($return_to) . '&platform=' . urlencode(PLATFORM_SLUG); -header('Location: ' . ANDMORE_ID_URL . 'auth/login.php' . $_login_qs); -exit; +
+

+ Novo cliente? + Criar conta +

+ diff --git a/views/auth/logout.php b/views/auth/logout.php index b4ed175..49a3fa1 100644 --- a/views/auth/logout.php +++ b/views/auth/logout.php @@ -1,26 +1,17 @@ 0); - -$dashboard_for = function (string $role) use ($claim_intent, $session_id): string { - if ($claim_intent && $role === 'client') { - return 'views/user/dashboard.php?focus_session=' . $session_id; - } - return post_login_destination($role); -}; - if (!empty($_SESSION['user_id'])) { - header('Location: ' . base_url($dashboard_for($_SESSION['user']['role'] ?? ''))); + header('Location: ' . base_url(post_login_destination($_SESSION['user']['role'] ?? ''))); exit; } -$return_to = base_url('views/auth/login.php' - . ($claim_intent ? '?next=session&session_id=' . $session_id : '')); +$error = ''; +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + csrf_check(); -header('Location: ' . ANDMORE_ID_URL . 'auth/register.php?return_to=' . urlencode($return_to)); -exit; + // Tighter than login: each signup writes a row. + if (!throttle_check($pdo, 'register', 3, 10)) { + throttle_block('register form'); + } + throttle_record($pdo, 'register'); + + $name = trim($_POST['name'] ?? ''); + $email = trim($_POST['email'] ?? ''); + $phone = trim($_POST['phone'] ?? ''); + $password = $_POST['password'] ?? ''; + $confirm = $_POST['confirm'] ?? ''; + + if ($name === '' || $email === '' || $password === '') { + $error = 'Nome, email e palavra-passe são obrigatórios.'; + } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + $error = 'Endereço de email inválido.'; + } elseif (strlen($password) < 8) { + $error = 'A palavra-passe deve ter pelo menos 8 caracteres.'; + } elseif ($password !== $confirm) { + $error = 'As palavras-passe não coincidem.'; + } elseif (empty($_POST['accept_terms'])) { + $error = 'É necessário aceitar a política de privacidade.'; + } else { + $res = auth_register_client($pdo, [ + 'name' => $name, + 'email' => $email, + 'phone' => $phone, + 'password' => $password, + 'marketing_consent' => !empty($_POST['marketing_consent']), + ]); + if (isset($res['error'])) { + $error = 'Já existe uma conta com esse email. Use "Esqueceu a palavra-passe?" para entrar.'; + } else { + // Sign the new client straight in rather than bouncing them to the + // login form with credentials they just typed. + $user = db_get_user($pdo, (int) $res['id']); + if ($user) { + auth_set_session($user); + header('Location: ' . base_url(post_login_destination($user['role']))); + exit; + } + header('Location: ' . base_url('views/auth/login.php?registered=1')); + exit; + } + } +} + +$page_title = 'Criar conta'; +include __DIR__ . '/../../includes/auth_header.php'; +?> +

Crie a sua conta de cliente.

+ + +
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ > + +
+
+ > + +
+ +
+ +
+

+ Já tem conta? Entrar +

+ diff --git a/views/auth/reset_password.php b/views/auth/reset_password.php index 5c52634..a34655b 100644 --- a/views/auth/reset_password.php +++ b/views/auth/reset_password.php @@ -1,10 +1,78 @@ + +

Este link já não é válido.

+
+ O link expirou ou já foi utilizado. Peça um novo para continuar. +
+ Pedir novo link + +

+ A definir uma nova palavra-passe para . +

+ + +
+ + +
+ + +
+ +
+ + +
+
+
+ + +
+ +
+ + diff --git a/views/auth/setup.php b/views/auth/setup.php index 696be0a..6c6dbf1 100644 --- a/views/auth/setup.php +++ b/views/auth/setup.php @@ -39,48 +39,42 @@ if ($alreadyDone) { } } -include __DIR__ . '/../../includes/header.php'; +$page_title = 'Configuração inicial'; +include __DIR__ . '/../../includes/auth_header.php'; ?> -
-
-
-

Configuração inicial

-

Crie a conta de administrador. Elimine este ficheiro depois.

+

Crie a conta de administrador. Elimine este ficheiro depois.

- -
- - -
- -
- -
- - -
-
- - -
-
- - -
-
- - -
- -
- -
+ +
+ + +
+ +
+ +
+ +
-
- +
+ + +
+
+ + +
+
+ + +
+ + + + diff --git a/views/user/index.php b/views/user/index.php index 7c20ec3..e1d4ea4 100644 --- a/views/user/index.php +++ b/views/user/index.php @@ -4,7 +4,7 @@ require __DIR__ . '/../../config/functions.php'; $user = require_admin(); $users = $pdo->query( - "SELECT id, name, email, phone, role, is_active, andmore_id, last_login_at, last_login_ua + "SELECT id, name, email, phone, role, is_active, password_hash, last_login_at, last_login_ua FROM users ORDER BY FIELD(role,'sysadmin','admin','staff','client'), name" )->fetchAll(PDO::FETCH_ASSOC); @@ -17,7 +17,7 @@ include __DIR__ . '/../../includes/header.php'; - + @@ -26,13 +26,24 @@ include __DIR__ . '/../../includes/header.php'; - + + diff --git a/views/user/profile.php b/views/user/profile.php index 9c37e91..aecbe3b 100644 --- a/views/user/profile.php +++ b/views/user/profile.php @@ -3,24 +3,51 @@ require __DIR__ . '/../../config/db.php'; require __DIR__ . '/../../config/functions.php'; $user = require_login(); -// Editable per-tenant fields only. Name/email are owned by Andmore ID and synced -// on login by sso_resolve_session(); the password is managed centrally. -$saved = false; +// Two forms on this page, told apart by the hidden `form` field: the contact +// details and the password change. Name and email are not self-editable — an +// admin changes those, so a client can't quietly take over another address. +$saved = false; +$pw_saved = false; +$pw_error = ''; + if ($_SERVER['REQUEST_METHOD'] === 'POST') { csrf_check(); - $phone = trim($_POST['phone'] ?? ''); - $address = trim($_POST['address'] ?? ''); - $city = trim($_POST['city'] ?? ''); - $postal_code = trim($_POST['postal_code'] ?? ''); - $email_opt = isset($_POST['email_opt_out']) ? 1 : 0; - $sms_opt = isset($_POST['sms_opt_out']) ? 1 : 0; - $marketing = isset($_POST['marketing_consent']) ? 1 : 0; - $pdo->prepare( - "UPDATE users SET phone=?, address=?, city=?, postal_code=?, - email_opt_out=?, sms_opt_out=?, marketing_consent=? - WHERE id=?" - )->execute([$phone, $address, $city, $postal_code, $email_opt, $sms_opt, $marketing, $user['id']]); - $saved = true; + + if (($_POST['form'] ?? '') === 'password') { + $current = $_POST['current_password'] ?? ''; + $new = $_POST['new_password'] ?? ''; + $confirm = $_POST['confirm_password'] ?? ''; + + // Re-authenticate against the stored hash: a hijacked session must not + // be enough to change the password and lock the owner out. + $row = db_get_user($pdo, (int) $user['id']); + if (!$row || empty($row['password_hash']) || !password_verify($current, $row['password_hash'])) { + $pw_error = 'A palavra-passe atual está incorreta.'; + } elseif (strlen($new) < 8) { + $pw_error = 'A nova palavra-passe deve ter pelo menos 8 caracteres.'; + } elseif ($new !== $confirm) { + $pw_error = 'As palavras-passe não coincidem.'; + } else { + auth_set_password($pdo, (int) $user['id'], $new); + // Fresh session id on a credential change — see auth_set_session(). + auth_set_session($row); + $pw_saved = true; + } + } else { + $phone = trim($_POST['phone'] ?? ''); + $address = trim($_POST['address'] ?? ''); + $city = trim($_POST['city'] ?? ''); + $postal_code = trim($_POST['postal_code'] ?? ''); + $email_opt = isset($_POST['email_opt_out']) ? 1 : 0; + $sms_opt = isset($_POST['sms_opt_out']) ? 1 : 0; + $marketing = isset($_POST['marketing_consent']) ? 1 : 0; + $pdo->prepare( + "UPDATE users SET phone=?, address=?, city=?, postal_code=?, + email_opt_out=?, sms_opt_out=?, marketing_consent=? + WHERE id=?" + )->execute([$phone, $address, $city, $postal_code, $email_opt, $sms_opt, $marketing, $user['id']]); + $saved = true; + } } $row = db_get_user($pdo, (int) $user['id']) ?? []; @@ -33,22 +60,21 @@ include __DIR__ . '/../../includes/header.php';
Nome
Email
-

Nome, email e palavra-passe são geridos no Andmore ID. - Gerir conta → -

+

Para alterar o nome ou o email, contacte-nos.

-
+
-
-
-
-
+ +
+
+
+
-
-
-
-
+
+
+
+
>
@@ -58,5 +84,20 @@ include __DIR__ . '/../../includes/header.php';
+ +
+ + +

Alterar palavra-passe

+
Palavra-passe alterada.
+
+
+
+
+
+
+
+ +
diff --git a/views/user/set_password.php b/views/user/set_password.php new file mode 100644 index 0000000..d0be688 --- /dev/null +++ b/views/user/set_password.php @@ -0,0 +1,121 @@ + +
+

Palavra-passe

+

+ + · + · +

+ + +
+ Voltar + + +
+
+ +
+ Comunique-a por um canal seguro e peça que a altere no primeiro acesso. + Esta é a única vez que aparece: + +
+ + +
+

Enviar link por email

+

+ A pessoa define a própria palavra-passe. Ninguém no backoffice chega a conhecê-la. + +
Esta conta ainda não tem palavra-passe definida. + +

+
+ + + + + +
+ +
+ + + +

Definir manualmente

+

Use apenas quando o email não for uma opção.

+
+
+
+
+ + Voltar +
+ + +
+
NomeEmailPerfilAtivoAndmore IDÚltimo acessoPalavra-passeÚltimo acesso
' : '' ?>—' ?> + + + + Por definir + + · — + +