Replace Andmore ID SSO with local login

fitme3070.pt delegated all authentication to hub.andmore.pt: views/auth/*
were bounce stubs, and because this domain is outside the .andmore.pt cookie
scope the real handoff was a one-time HTTPS ticket redeem. Bring identity back
in-house — passwords live in users.password_hash, which the SSO migration
never dropped.

Removed: config/sso.php, IDENTITY_MODE, every SSO_* constant and env key,
identity_is_federated() and the cookie/ticket paths in the auth views and the
public-site bootstrap. ANDMORE_ID_URL stays — it is still the hub /notify
endpoint for outbound email/SMS, which the reset mails ride on, and has
nothing to do with authentication now.

Added, ported from the fotografia.andmore.pt sibling rather than invented:

  - auth_login() with transparent password_needs_rehash upgrades and the
    last_login trail; auth_register_client(); auth_set_password(); the
    single-use 2h reset tokens.
  - Real login / register / forgot_password / reset_password pages on a new
    anonymous shell (includes/auth_header.php). includes/header.php renders
    the backoffice sidebar and a logout button, which is wrong for a visitor
    who is not signed in yet.
  - safe_return_path(), so a crafted ?return_to= cannot bounce a freshly
    authenticated user off-site.
  - Per-IP throttling on login, register and reset-request. Successes count
    too, so a guessed password cannot be parlayed into stuffing the rest of
    the user list from the same IP.
  - views/user/set_password.php: admin override for anyone whose email is
    undeliverable. Prefers mailing an invite link so the backoffice never
    handles the password. A plain admin cannot act on a sysadmin, and nobody
    can use it on themselves — self-service goes through profile.php, which
    demands the current password so a hijacked session cannot lock the owner
    out.

Accounts provisioned during the SSO era have password_hash NULL. They get in
via the reset email, or by registering again with the same address:
auth_register_client() claims a passwordless row, but only a plain `client`
one, so knowing a staff address does not let anyone self-assign the role.

users.andmore_id is deliberately left in place. No code reads it any more, but
it records which central identity each row was linked to.

Deployment: apply database/migration_20260913_local_auth.sql (password_resets
+ auth_attempts) in the SAME sysadmin session that pulls this commit — the
forgot-password flow needs that table, and after the pull a login requires a
local password.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
andreandClaude Opus 5 committed 2026-09-13 21:00:57 +01:00
1 parent dc8a36e126
commit aec7b47820
23 files changed
+1138 -653

No files matched your search

+7 -32
View File
@@ -25,40 +25,15 @@ DB_PASS=
BACKUP_TOKEN=replace_me BACKUP_TOKEN=replace_me
BACKUP_KEEP_DAYS=7 BACKUP_KEEP_DAYS=7
# ─── Identity / Andmore ID ───────────────────────────────────────────────────── # ─── Andmore hub (notifications only) ────────────────────────────────────────
# federated — auth is delegated to id.andmore.pt; the am_sso cookie + central # This install owns its own login: passwords live in users.password_hash and
# sso_sessions/users tables drive login. This site is part of the ecosystem, so # views/auth/login.php is a real form. The Andmore ID / SSO delegation was
# federated is the expected mode (standalone disables all SSO traffic). # removed — no IDENTITY_MODE, no SSO_* keys, no central sso_sessions table.
IDENTITY_MODE=federated #
# ANDMORE_ID_URL stays only because outbound email/SMS is still relayed through
# Slug identifying this install on andmore_id.linked_platforms. # the hub's /notify endpoint (see NOTIFY_API_KEY below).
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_ID_URL=https://hub.andmore.pt/ 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) ────────────────────── # ─── Notificações centralizadas (id.andmore.pt /notify) ──────────────────────
# App key for POST {ANDMORE_ID_URL}notify/send. Minted on id.andmore.pt → # App key for POST {ANDMORE_ID_URL}notify/send. Minted on id.andmore.pt →
# Notificações → Chaves de app. # Notificações → Chaves de app.
+23 -22
View File
@@ -1,30 +1,17 @@
<?php <?php
// Auth guards — require functions.php (and session_start) before this file. // Auth guards — require functions.php (and session_start) before this file.
//
// This install owns its passwords: they live in `users.password_hash` and
// views/auth/login.php is a real form. There is no identity provider in front
// of the app.
function require_login(): array { function require_login(): array {
if (empty($_SESSION['user_id'])) { if (empty($_SESSION['user_id'])) {
if (identity_is_federated()) {
sso_resolve_session($GLOBALS['pdo']);
}
if (empty($_SESSION['user_id'])) {
if (SSO_REQUIRED) {
// Bounce through this platform's local login stub instead of
// sending id.andmore.pt's return_to straight at a protected
// page. The stub (views/auth/login.php) carries the
// ?from=sso loop-guard and loads the full functions.php
// bootstrap before calling sso_resolve_session().
$_rl_return = APP_BASE_URL . 'views/auth/login.php?from=sso';
$_rl_qs = '?return_to=' . urlencode($_rl_return)
. '&platform=' . urlencode(PLATFORM_SLUG);
header('Location: ' . ANDMORE_ID_URL . 'auth/login.php' . $_rl_qs);
} else {
$return = $_SERVER['REQUEST_URI'] ?? ''; $return = $_SERVER['REQUEST_URI'] ?? '';
$qs = $return ? ('?return_to=' . urlencode($return)) : ''; $qs = $return ? ('?return_to=' . urlencode($return)) : '';
header('Location: ' . base_url('views/auth/login.php' . $qs)); header('Location: ' . base_url('views/auth/login.php' . $qs));
}
exit; exit;
} }
}
return $_SESSION['user']; return $_SESSION['user'];
} }
@@ -134,9 +121,9 @@ function is_designer(): bool {
} }
function auth_set_session(array $user): void { function auth_set_session(array $user): void {
// Rotate the session id on every privilege change (login, SSO resolve, // Rotate the session id on every privilege change (login, registration,
// password reset). Defeats session fixation: an attacker who pre-seeded // password reset). Defeats session fixation: an attacker who pre-seeded
// FOTO_SESS now ends up holding a dead id while the real user gets a fresh // FIT_SESS now ends up holding a dead id while the real user gets a fresh
// one. The CSRF token is rotated alongside the session id. // one. The CSRF token is rotated alongside the session id.
if (session_status() === PHP_SESSION_ACTIVE) { if (session_status() === PHP_SESSION_ACTIVE) {
session_regenerate_id(true); session_regenerate_id(true);
@@ -148,8 +135,22 @@ function auth_set_session(array $user): void {
'name' => $user['name'], 'name' => $user['name'],
'email' => $user['email'], 'email' => $user['email'],
'role' => $user['role'], 'role' => $user['role'],
// Carried so sso_resolve_session() can detect when the am_sso cookie
// identifies a different person than the current local session.
'andmore_id' => isset($user['andmore_id']) ? (int) $user['andmore_id'] : null,
]; ];
} }
// Sanitises a ?return_to= value into a path safe to redirect to after login.
// Anything absolute, protocol-relative or backslash-prefixed is discarded so a
// crafted login link can't bounce the user off-site with a fresh session in hand.
function safe_return_path(?string $raw): string {
$raw = trim((string) $raw);
if ($raw === '' || $raw[0] !== '/') return '';
if (str_starts_with($raw, '//') || str_starts_with($raw, '/' . chr(92))) return '';
// REQUEST_URI includes the deployment base (e.g. /fitme3070.pt/…) while
// base_url() adds it back, so strip it to avoid doubling the prefix.
$base = parse_url(APP_BASE_URL, PHP_URL_PATH) ?: '/';
if ($base !== '/' && str_starts_with($raw, $base)) {
$raw = substr($raw, strlen($base));
}
return ltrim($raw, '/');
}
+10 -33
View File
@@ -28,36 +28,13 @@ if (APP_ENV !== 'prod') {
require_once __DIR__ . '/error_handler.php'; require_once __DIR__ . '/error_handler.php';
// ─── Identity / Andmore ID ──────────────────────────────────────────────────── // ─── Andmore hub (notifications only) ────────────────────────────────────────
// IDENTITY_MODE: 'standalone' (default) or 'federated'. In federated mode every // This install owns its own identity: passwords live in `users.password_hash`
// register/login syncs the user to the central andmore_id DB so users.andmore_id // and views/auth/login.php is a real form (see config/auth.php). The former
// is always populated and cross-platform relationships work by canonical id. // Andmore ID / SSO delegation — config/sso.php, the am_sso cookie, the central
define('IDENTITY_MODE', strtolower(env('IDENTITY_MODE', 'standalone')) === 'federated' ? 'federated' : 'standalone'); // sso_sessions table and the one-time ticket redeem — was removed.
//
// Slug identifying this install on andmore_id.linked_platforms. Used to scope // ANDMORE_ID_URL survives because outbound email/SMS is still centralized at
// link tokens and resolve the satellite's display name on the central verify page. // the hub's /notify endpoint; see config/functions_notify_client.php. It has
define('PLATFORM_SLUG', (string) env('PLATFORM_SLUG', 'fitme3070')); // nothing to do with authentication anymore.
define('ANDMORE_ID_URL', rtrim(env('ANDMORE_ID_URL', 'https://hub.andmore.pt'), '/') . '/');
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; }
+4 -4
View File
@@ -2,19 +2,19 @@
// ─── Bootstrap chain ───────────────────────────────────────────────────────── // ─── Bootstrap chain ─────────────────────────────────────────────────────────
// Loaded by every page right after config/db.php (which opens $pdo and starts // 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 // 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 // migrations runner, and the centralized notify façade. Domain CRUD
// (plataforma_fitness, team, modalidades, horário, galerias) is added per // (plataforma_fitness, team, modalidades, horário, galerias) is added per
// feature in a follow-up under config/functions_*.php. // feature in a follow-up under config/functions_*.php.
if (session_status() === PHP_SESSION_NONE) session_start(); 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_settings.php'; // setting() + system_settings cache
require_once __DIR__ . '/functions_db.php'; // generic DB helpers 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__ . '/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 require_once __DIR__ . '/functions_migrations.php';// platform_update.php migrations runner
// Outbound delivery is centralized at id.andmore.pt — no local PHPMailer/SMTP. // Outbound delivery is centralized at id.andmore.pt — no local PHPMailer/SMTP.
+69
View File
@@ -0,0 +1,69 @@
<?php
// ─── Per-IP attempt throttling ───────────────────────────────────────────────
// Generic, scope-scoped IP throttle for endpoints where an attacker gains by
// repeating: login, password-reset requests, registration.
//
// Two limits applied together:
// - burst: N attempts per minute (default 5)
// - sustained: N attempts per hour (default 30)
//
// Counts FAILED *and* SUCCESSFUL attempts, so a guessed password can't be
// parlayed into stuffing the rest of the user list from the same IP.
//
// Schema: database/migration_20260913_local_auth.sql (`auth_attempts`).
function _throttle_ip(): string {
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
return $ip !== '' ? $ip : '0.0.0.0';
}
function throttle_check(PDO $pdo, string $scope, int $maxPerMinute = 5, int $maxPerHour = 30): bool {
try {
$stmt = $pdo->prepare(
"SELECT
SUM(attempted_at > DATE_SUB(NOW(), INTERVAL 1 MINUTE)) AS minute,
SUM(attempted_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)) AS hour
FROM auth_attempts
WHERE ip = ? AND scope = ? AND attempted_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)"
);
$stmt->execute([_throttle_ip(), $scope]);
$row = $stmt->fetch(PDO::FETCH_ASSOC) ?: ['minute' => 0, 'hour' => 0];
} catch (PDOException $e) {
// Fail open if the table is missing (migration not applied yet during a
// deploy). Log loudly so an operator notices.
error_log('[throttle] check failed (run migration_20260913_local_auth.sql?): ' . $e->getMessage());
return true;
}
if ((int) $row['minute'] >= $maxPerMinute) return false;
if ((int) $row['hour'] >= $maxPerHour) return false;
return true;
}
function throttle_record(PDO $pdo, string $scope): void {
try {
$pdo->prepare("INSERT INTO auth_attempts (ip, scope) VALUES (?, ?)")
->execute([_throttle_ip(), $scope]);
// Probabilistic cleanup so the table doesn't grow forever — cheap on a
// 1% sample of writes, and saves wiring up a cron for a small install.
if (random_int(0, 99) === 0) {
@$pdo->exec("DELETE FROM auth_attempts WHERE attempted_at < DATE_SUB(NOW(), INTERVAL 1 DAY)");
}
} catch (PDOException $e) {
error_log('[throttle] record failed (run migration_20260913_local_auth.sql?): ' . $e->getMessage());
}
}
// Convenience: 429 + generic message, then exit. Callers that would rather keep
// the visitor on the form can use throttle_check() and render their own error.
function throttle_block(string $reason = ''): void {
http_response_code(429);
header('Retry-After: 60');
header('Content-Type: text/html; charset=utf-8');
echo '<!doctype html><meta charset="utf-8"><title>Demasiadas tentativas</title>'
. '<div style="font-family:sans-serif;max-width:520px;margin:80px auto;text-align:center;">'
. '<h2>Demasiadas tentativas</h2>'
. '<p>Aguarde alguns minutos antes de tentar novamente.</p>'
. '</div>';
if ($reason !== '') error_log("[throttle] blocked: {$reason} ip=" . _throttle_ip());
exit;
}
+224 -39
View File
@@ -1,14 +1,22 @@
<?php <?php
// ─── Backoffice user creation ──────────────────────────────────────────────── // ─── Users: lookup, authentication, registration, password reset ─────────────
// This install is SSO-only — clients authenticate on id.andmore.pt and their // This install owns its identity. Passwords live in `users.password_hash`;
// local row is provisioned by sso_resolve_session() (config/sso.php). The only // views/auth/login.php is a real form and require_login() (config/auth.php)
// users created locally are backoffice accounts (sysadmin / admin / staff): // bounces unauthenticated visitors there. Nothing is delegated to a hub.
// - auth_create_sysadmin() → first-install bootstrap (views/auth/setup.php)
// - auth_create_admin()/staff() → admin-vouched, link the central Andmore ID
// row immediately so the new person can SSO on any sibling .andmore.pt site.
// //
// parse_user_agent() lives in config/functions.php; db_get_all_staff() / // parse_user_agent() lives in config/functions.php; db_get_user() /
// db_get_active_admins() live in config/functions_db.php — not redefined here. // db_get_all_staff() / db_get_active_admins() live in config/functions_db.php
// — not redefined here.
// ─── Lookup ──────────────────────────────────────────────────────────────────
// Active user by email, or false. Used by the login and reset-request flows,
// which must never act on a deactivated account.
function user_find_by_email(PDO $pdo, string $email): array|false {
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = ? AND is_active = 1 LIMIT 1");
$stmt->execute([strtolower(trim($email))]);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
// True once any sysadmin/admin row exists — gates views/auth/setup.php. // True once any sysadmin/admin row exists — gates views/auth/setup.php.
function admin_exists(PDO $pdo): bool { function admin_exists(PDO $pdo): bool {
@@ -17,8 +25,113 @@ function admin_exists(PDO $pdo): bool {
)->fetchColumn(); )->fetchColumn();
} }
// Creates a sysadmin (full access incl. backups + platform update). Local-only // ─── Authentication ──────────────────────────────────────────────────────────
// password row; federated linkage happens on first SSO by email match.
// 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 { function auth_create_sysadmin(PDO $pdo, string $name, string $email, string $password): int {
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
INSERT INTO users (name, email, password_hash, role) 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(); 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 { 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); 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); 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 { function _create_backoffice_user(PDO $pdo, string $role, string $name, string $email, string $phone, string $password): array {
if (!in_array($role, ['admin', 'staff'], true)) { if (!in_array($role, ['admin', 'staff'], true)) {
throw new InvalidArgumentException("Invalid backoffice role: {$role}"); throw new InvalidArgumentException("Invalid backoffice role: {$role}");
} }
$email = strtolower(trim($email)); try {
$pdo->prepare("
$canonicalId = null; INSERT INTO users (name, email, phone, password_hash, role)
if (identity_is_federated()) { VALUES (?, ?, ?, ?, ?)
$link = identity_upstream_link_immediate($email, $name, $phone, $password); ")->execute([
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'];
}
}
$stmt = $pdo->prepare("
INSERT INTO users (name, email, phone, password_hash, role, andmore_id)
VALUES (?, ?, ?, ?, ?, ?)
");
$stmt->execute([
$name, $name,
$email, strtolower(trim($email)),
$phone ?: null, $phone ?: null,
password_hash($password, PASSWORD_DEFAULT), password_hash($password, PASSWORD_DEFAULT),
$role, $role,
$canonicalId,
]); ]);
} catch (PDOException $e) {
if ((int) ($e->errorInfo[1] ?? 0) === 1062) return ['error' => 'email_taken'];
throw $e;
}
return ['id' => (int) $pdo->lastInsertId()]; return ['id' => (int) $pdo->lastInsertId()];
} }
// ─── Password reset ──────────────────────────────────────────────────────────
// Single-use tokens in `password_resets`, valid for 2 hours. Minting a new one
// invalidates any outstanding token for that user.
function auth_create_reset_token(PDO $pdo, int $userId): string {
$pdo->prepare("DELETE FROM password_resets WHERE user_id = ? AND used_at IS NULL")
->execute([$userId]);
$token = bin2hex(random_bytes(32));
$pdo->prepare(
"INSERT INTO password_resets (user_id, token, expires_at)
VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 2 HOUR))"
)->execute([$userId, $token]);
return $token;
}
function auth_get_valid_reset_token(PDO $pdo, string $token): array|false {
$stmt = $pdo->prepare("
SELECT pr.user_id, pr.token, u.name, u.email, u.role
FROM password_resets pr
JOIN users u ON u.id = pr.user_id
WHERE pr.token = ?
AND pr.used_at IS NULL
AND pr.expires_at > NOW()
AND u.is_active = 1
LIMIT 1
");
$stmt->execute([$token]);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
function auth_consume_reset_token(PDO $pdo, string $token, string $newPassword): bool {
$reset = auth_get_valid_reset_token($pdo, $token);
if (!$reset) return false;
// auth_set_password() burns every outstanding token for this user, this one
// included, so the link can't be replayed.
auth_set_password($pdo, (int) $reset['user_id'], $newPassword);
return true;
}
// ─── Email: password reset / first-password invitation ───────────────────────
// Delivery is centralized at the hub's /notify endpoint — see
// config/functions_queue.php (enqueue_email + flush_message).
function send_password_reset_email(string $name, string $email, string $resetUrl, ?int $userId = null): bool {
$body = load_email_template('password_reset', [
'user_name' => $name,
'reset_url' => $resetUrl,
'app_name' => APP_NAME,
]);
$id = enqueue_email([
'to_user_id' => $userId,
'to_address' => $email,
'to_name' => $name,
'subject' => 'Redefinir palavra-passe',
'body' => $body,
'alt_body' => "Olá {$name},\n\nPara definir uma nova palavra-passe aceda a:\n{$resetUrl}\n\nO link é válido durante 2 horas.",
'context_type' => 'password_reset',
'context_id' => $userId,
]);
return flush_message($id);
}
// An account created from the backoffice has no password, so the person has no
// way of knowing it exists. Invites them to set one, reusing the reset token —
// same mechanism, different copy.
function send_password_invite_email(PDO $pdo, array $user): bool {
if (empty($user['email'])) return false;
$token = auth_create_reset_token($pdo, (int) $user['id']);
$url = base_url('views/auth/reset_password.php?token=' . $token);
$name = (string) ($user['name'] ?? '');
$body = load_email_template('password_invite', [
'user_name' => $name,
'reset_url' => $url,
'app_name' => APP_NAME,
]);
$id = enqueue_email([
'to_user_id' => (int) $user['id'],
'to_address' => $user['email'],
'to_name' => $name,
'subject' => 'A sua conta ' . APP_NAME,
'body' => $body,
'alt_body' => "Olá {$name},\n\nFoi criada uma conta para si em " . APP_NAME . ".\nDefina a sua palavra-passe em:\n{$url}\n\nO link é válido durante 2 horas.",
'context_type' => 'password_invite',
'context_id' => (int) $user['id'],
]);
return flush_message($id);
}
-378
View File
@@ -1,378 +0,0 @@
<?php
// Identity / SSO integration with id.andmore.pt.
//
// Two modes, picked by IDENTITY_MODE in .env:
//
// standalone — every function in this file is a no-op (returns null/false).
// Local users.id is the only identity that matters.
// federated — register/login flows on this install also write to the central
// andmore_id DB so users.andmore_id is always populated. The
// am_sso cookie is issued by this install so the user lands
// already-logged-in on any other .andmore.pt platform.
//
// Direct DB access is used rather than HTTP for now — easier and matches how
// sso_resolve_session() already works. Swap to an HTTP API later if the
// platforms stop sharing a DB host.
// SSO_COOKIE_NAME is defined in config/config.php (defaults to 'am_sso',
// override via env so prod and dev can coexist on the same parent domain).
// Cached central-DB connection — federated calls reuse it within a request.
function _sso_open_pdo(): ?PDO {
static $cached = null;
if ($cached !== null) return $cached ?: null; // false sentinel = tried and failed
if (!identity_is_federated()) { $cached = false; return null; }
$host = env('SSO_DB_HOST') ?: env('DB_HOST', '127.0.0.1');
$port = (int) (env('SSO_DB_PORT') ?: env('DB_PORT', 3306));
$name = env('SSO_DB_NAME', '');
$user = env('SSO_DB_USER') ?: env('DB_USER', 'root');
$pass = env('SSO_DB_PASS') ?: env('DB_PASS', '');
if (!$name) { $cached = false; return null; }
try {
$cached = new PDO(
"mysql:host={$host};port={$port};dbname={$name};charset=utf8mb4",
$user, $pass,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
return $cached;
} catch (PDOException $e) {
error_log('[sso] DB connection failed: ' . $e->getMessage());
$cached = false;
return null;
}
}
// Called by require_login() (and login.php / register.php) before any
// redirect-to-login fallback. When a valid am_sso cookie is present it
// makes sure the local PHP session reflects that exact identity:
// - same andmore_id as the cookie → no-op (fast path).
// - different andmore_id (or no andmore_id) → clear the local session and
// re-provision from the SSO row so a stale Ana-Paula-style login doesn't
// mask the real federated user.
// No-op in standalone mode (the parent-domain cookie wouldn't reach this
// install anyway).
function sso_resolve_session(PDO $pdo): void {
if (!identity_is_federated()) return;
$token = $_COOKIE[SSO_COOKIE_NAME] ?? '';
if (strlen($token) !== 64 || !ctype_xdigit($token)) return;
$ssoPdo = _sso_open_pdo();
if (!$ssoPdo) return;
try {
$stmt = $ssoPdo->prepare("
SELECT s.user_id, u.email, u.name, u.phone, u.is_active
FROM sso_sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token = ?
AND s.expires_at > NOW()
AND u.is_active = 1
LIMIT 1
");
$stmt->execute([$token]);
$ssoRow = $stmt->fetch();
} catch (PDOException $e) {
error_log('[sso] token lookup failed: ' . $e->getMessage());
return;
}
if (!$ssoRow) return;
$canonicalId = (int) $ssoRow['user_id'];
// If a local session is already set, decide whether to keep it or replace.
if (!empty($_SESSION['user_id'])) {
$sessionAndmoreId = (int) ($_SESSION['user']['andmore_id'] ?? 0);
if ($sessionAndmoreId === $canonicalId) {
return; // Same person — keep the existing session.
}
// Either: (a) session is for an unrelated andmore_id (user switched
// identities at the hub), or (b) session is for a local-only user that
// never got linked. In both cases the SSO cookie reflects the real
// intent — clear the stale identity bits and re-provision.
unset($_SESSION['user_id'], $_SESSION['user']);
}
_sso_provision_local($pdo, $ssoRow);
}
// Cross-domain handoff: redeem a one-time ticket minted by the hub. This is the
// real login path for this install, because fitme3070.pt is NOT under
// .andmore.pt — the am_sso cookie never reaches it, so sso_resolve_session()'s
// cookie lookup always comes up empty. The hub appends ?sso_ticket=… to the
// return URL after authenticating.
//
// We redeem over HTTPS, NOT the direct central DB: this install lives on
// external hosting and can't reach the central DB's private LAN address. The
// hub's api/sso_redeem.php burns the ticket against its local DB (single-use,
// bound to PLATFORM_SLUG, ~60s TTL) and returns the central user, which we
// provision into the LOCAL DB. Auth reuses the content-pull HMAC scheme with
// the ticket folded in. Returns true when a session was established.
function sso_redeem_ticket(PDO $pdo, string $ticket): bool {
if (!identity_is_federated()) return false;
if (strlen($ticket) !== 64 || !ctype_xdigit($ticket)) return false;
$secret = (string) env('HUB_CONTENT_SECRET', '');
if ($secret === '') { error_log('[sso] ticket redeem: HUB_CONTENT_SECRET not set'); return false; }
$url = rtrim(ANDMORE_ID_URL, '/') . '/api/sso_redeem.php';
$path = (string) (parse_url($url, PHP_URL_PATH) ?? '/');
$date = gmdate('c');
$token = hash_hmac('sha256', $date . $path . $ticket, $secret);
$body = http_build_query(['ticket' => $ticket]);
$headers = [
'X-Hub-Platform: ' . PLATFORM_SLUG,
'X-Hub-Date: ' . $date,
'X-Hub-Token: ' . $token,
'Accept: application/json',
'Content-Type: application/x-www-form-urlencoded',
];
// cURL first (same transport the notify/content clients use); stream
// fallback for installs without the extension.
$raw = false;
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_TIMEOUT => 12,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_USERAGENT => 'fitme3070-SSO/1.0',
]);
$raw = curl_exec($ch);
curl_close($ch);
}
if ($raw === false) {
$ctx = stream_context_create(['http' => [
'method' => 'POST',
'timeout' => 12,
'header' => implode("\r\n", $headers),
'content' => $body,
'ignore_errors' => true,
]]);
$raw = @file_get_contents($url, false, $ctx);
}
if ($raw === false) { error_log('[sso] ticket redeem: no response from hub'); return false; }
$data = json_decode((string) $raw, true);
if (!is_array($data) || empty($data['ok']) || empty($data['user'])) {
error_log('[sso] ticket redeem rejected: ' . substr((string) $raw, 0, 200));
return false;
}
$u = $data['user'];
$ssoRow = [
'user_id' => (int) ($u['user_id'] ?? 0),
'email' => (string) ($u['email'] ?? ''),
'name' => (string) ($u['name'] ?? ''),
'phone' => $u['phone'] ?? null,
];
if ($ssoRow['user_id'] <= 0 || $ssoRow['email'] === '') return false;
// Explicit fresh-login intent — drop any stale local identity, then provision.
unset($_SESSION['user_id'], $_SESSION['user']);
return _sso_provision_local($pdo, $ssoRow);
}
// Find-or-create the local users row for a central identity, sync name/phone,
// stamp last_login, and set the local PHP session. $ssoRow carries the central
// columns: user_id (canonical andmore id), email, name, phone. Returns true
// when a session was established. Shared by sso_resolve_session() (cookie path,
// same-site siblings) and sso_redeem_ticket() (ticket path, this install).
function _sso_provision_local(PDO $pdo, array $ssoRow): bool {
$canonicalId = (int) $ssoRow['user_id'];
$email = strtolower(trim((string) $ssoRow['email']));
// ─── Find or create the local row ────────────────────────────────────
// Lookup order: andmore_id (fast, set on prior logins) → email (canonical
// identity per the SSO-only model). The email branch claims any matching
// row regardless of password_hash — the central platform vouches for the
// email so there is no "another person owns this address" scenario to
// worry about. UNIQUE(email) on the users table guarantees at most one
// match.
$stmt = $pdo->prepare("SELECT * FROM users WHERE andmore_id = ? LIMIT 1");
$stmt->execute([$canonicalId]);
$user = $stmt->fetch();
if (!$user) {
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = ? LIMIT 1");
$stmt->execute([$email]);
$user = $stmt->fetch();
if (!$user) {
// No local row yet — provision a fresh client row stamped with
// andmore_id. Role default = client; staff promotion happens by
// an admin editing the row later.
try {
$pdo->prepare("
INSERT INTO users (name, email, phone, role, andmore_id, is_active)
VALUES (?, ?, ?, 'client', ?, 1)
")->execute([
(string) $ssoRow['name'],
$email,
$ssoRow['phone'] ?: null,
$canonicalId,
]);
} catch (PDOException $e) {
error_log('[sso] provisioning insert failed: ' . $e->getMessage());
return false;
}
$stmt = $pdo->prepare("SELECT * FROM users WHERE andmore_id = ? LIMIT 1");
$stmt->execute([$canonicalId]);
$user = $stmt->fetch();
}
}
if (!$user || !$user['is_active']) return false;
// ─── Sync per-tenant row with central data ───────────────────────────
// Stamp the link if missing, refresh name/phone when the central record
// has a newer value. Local role is preserved — the hub does not know about
// per-tenant roles.
$patch = [];
if (empty($user['andmore_id']) || (int) $user['andmore_id'] !== $canonicalId) {
$patch['andmore_id'] = $canonicalId;
}
if (!empty($ssoRow['name']) && $ssoRow['name'] !== $user['name']) {
$patch['name'] = (string) $ssoRow['name'];
}
if (!empty($ssoRow['phone']) && $ssoRow['phone'] !== ($user['phone'] ?? '')) {
$patch['phone'] = (string) $ssoRow['phone'];
}
if ($patch) {
$set = implode(', ', array_map(fn ($k) => "$k = ?", array_keys($patch)));
$pdo->prepare("UPDATE users SET $set WHERE id = ?")
->execute([...array_values($patch), (int) $user['id']]);
$user = array_merge($user, $patch);
}
$ip = $_SERVER['REMOTE_ADDR'] ?? null;
$ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512) ?: null;
$pdo->prepare("UPDATE users SET last_login_at = NOW(), last_login_ip = ?, last_login_ua = ? WHERE id = ?")
->execute([$ip, $ua, $user['id']]);
auth_set_session($user);
return true;
}
// ─── Upstream sync (federated mode) ──────────────────────────────────────────
//
// Only the admin-vouched immediate link + the cookie issuer remain. The older
// two-phase verify-email flow (request_link / redeem_pending / has_pending)
// was used by the local register/login pages, which were removed when the
// platform became SSO-only. sso_resolve_session() does the email-match work
// directly now — no token round-trip needed.
// Admin-vouched immediate link. Used by auth_create_admin() / auth_create_staff()
// when a sysadmin/admin creates a backoffice user on this satellite. Instead of
// the verify-email round-trip, we trust the admin's vouching: the password the
// admin set on the local form is also written to the central row so the new
// person can immediately use SSO on any sibling .andmore.pt platform.
//
// - No central row for this email → create central row WITH the password
// (admin-set, email_verified_at stamped).
// - Passwordless central row exists → claim it AND set the password.
// - Passworded central row exists → refuse with 'andmore_email_taken'.
// An admin can't silently reset
// someone else's Andmore ID.
//
// Outcomes:
// ['canonical_id' => int] ok — stamp this on the local row
// ['error' => 'andmore_email_taken'] central row exists with a password
// ['error' => 'unavailable'] central DB unreachable
// ['error' => 'standalone'] IDENTITY_MODE != federated
function identity_upstream_link_immediate(string $email, string $name, ?string $phone, ?string $password = null): array {
if (!identity_is_federated()) return ['error' => 'standalone'];
$ssoPdo = _sso_open_pdo();
if (!$ssoPdo) return ['error' => 'unavailable'];
$email = strtolower(trim($email));
$hash = ($password !== null && $password !== '')
? password_hash($password, PASSWORD_DEFAULT)
: null;
try {
$stmt = $ssoPdo->prepare("SELECT id, password_hash FROM users WHERE email = ? LIMIT 1");
$stmt->execute([$email]);
$row = $stmt->fetch();
if ($row) {
if (!empty($row['password_hash'])) {
return ['error' => 'andmore_email_taken'];
}
// Passwordless placeholder — claim it AND set the admin-vouched password.
if ($hash !== null) {
$ssoPdo->prepare("
UPDATE users
SET name = COALESCE(NULLIF(?, ''), name),
phone = COALESCE(NULLIF(?, ''), phone),
password_hash = ?,
email_verified_at = COALESCE(email_verified_at, NOW())
WHERE id = ?
")->execute([$name, (string) $phone, $hash, (int) $row['id']]);
}
return ['canonical_id' => (int) $row['id']];
}
$ssoPdo->prepare("
INSERT INTO users (name, email, phone, password_hash, email_verified_at)
VALUES (?, ?, ?, ?, ?)
")->execute([
$name,
$email,
$phone,
$hash,
$hash !== null ? date('Y-m-d H:i:s') : null,
]);
return ['canonical_id' => (int) $ssoPdo->lastInsertId()];
} catch (PDOException $e) {
error_log('[sso] upstream link-immediate failed: ' . $e->getMessage());
return ['error' => 'unavailable'];
}
}
// Issue an am_sso cookie scoped to the parent domain by inserting a row into
// the central sso_sessions table. Lets the user land already-logged-in on any
// other .andmore.pt platform. No-op when standalone or when SSO_COOKIE_DOMAIN
// isn't set.
function identity_upstream_issue_cookie(int $canonicalUserId): void {
if (!identity_is_federated() || SSO_COOKIE_DOMAIN === '') return;
$ssoPdo = _sso_open_pdo();
if (!$ssoPdo) return;
try {
$token = bin2hex(random_bytes(32));
$ttl = max(1, SSO_SESSION_TTL_DAYS) * 86400;
$ip = $_SERVER['REMOTE_ADDR'] ?? null;
$ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512) ?: null;
$ssoPdo->prepare("
INSERT INTO sso_sessions (token, user_id, expires_at, ip, ua, last_seen_at)
VALUES (?, ?, DATE_ADD(NOW(), INTERVAL ? SECOND), ?, ?, NOW())
")->execute([$token, $canonicalUserId, $ttl, $ip, $ua]);
setcookie(SSO_COOKIE_NAME, $token, [
'expires' => time() + $ttl,
'path' => '/',
'domain' => SSO_COOKIE_DOMAIN,
// request_is_https() respects X-Forwarded-Proto when TRUSTED_PROXY=true
// — needed so the Secure flag isn't dropped behind nginx/Cloudflare.
'secure' => request_is_https(),
'httponly' => true,
'samesite' => 'Lax',
]);
} catch (PDOException $e) {
error_log('[sso] upstream cookie issue failed: ' . $e->getMessage());
}
}
@@ -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.
+21
View File
@@ -0,0 +1,21 @@
<?php
// Closes the shell opened by includes/auth_header.php.
?>
</main>
<script src="<?= base_url('assets/vendor/bootstrap5/js/bootstrap.bundle.min.js') ?>"></script>
<script>
// Show/hide toggles on any button carrying data-toggle-password="<input id>".
document.querySelectorAll('[data-toggle-password]').forEach(function (btn) {
btn.addEventListener('click', function () {
var input = document.getElementById(btn.getAttribute('data-toggle-password'));
if (!input) return;
var hidden = input.type === 'password';
input.type = hidden ? 'text' : 'password';
btn.setAttribute('aria-label', hidden ? 'Ocultar palavra-passe' : 'Mostrar palavra-passe');
var icon = btn.querySelector('i');
if (icon) icon.className = hidden ? 'bi bi-eye-slash' : 'bi bi-eye';
});
});
</script>
</body>
</html>
+56
View File
@@ -0,0 +1,56 @@
<?php
// Anonymous-safe shell for the auth pages (login / register / forgot / reset /
// setup). Deliberately NOT includes/header.php: that one renders the whole
// backoffice chrome — sidebar, topbar, logout button — and calls current_user(),
// none of which makes sense for a visitor who isn't signed in yet.
//
// Pair with includes/auth_footer.php. Optional $page_title sets the <title>.
$page_title = $page_title ?? 'Entrar';
$app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
?><!doctype html>
<html lang="pt">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title><?= htmlspecialchars($page_title) ?> — <?= htmlspecialchars($app_name) ?></title>
<link href="<?= base_url('assets/vendor/bootstrap5/css/bootstrap.min.css') ?>" rel="stylesheet">
<link href="<?= base_url('assets/vendor/bootstrap-icons/bootstrap-icons.css') ?>" rel="stylesheet">
<style>
:root {
--bo-accent: #C2D500;
--bo-accent-rgb: 194,213,0;
--bo-dark: #0a0a0a;
}
body {
min-height: 100vh; margin: 0;
display: flex; align-items: center; justify-content: center;
padding: 2rem 1rem;
background: var(--bo-dark);
background-image: radial-gradient(circle at 50% 0%, rgba(var(--bo-accent-rgb), .16), transparent 60%);
font-size: .925rem; color: #f5f5f5;
}
.auth-card {
width: 100%; max-width: 420px;
background: #fff; color: #0a0a0a;
border-radius: 14px; padding: 2rem;
box-shadow: 0 18px 50px rgba(0,0,0,.45);
}
.auth-brand {
text-align: center; font-weight: 700; font-size: 1.25rem;
letter-spacing: .5px; margin-bottom: .25rem;
}
.auth-brand i { color: var(--bo-accent); }
.auth-sub { text-align: center; color: #6c757d; font-size: .875rem; margin-bottom: 1.5rem; }
.btn-primary { background: var(--bo-accent); border-color: var(--bo-accent); color: var(--bo-dark); font-weight: 600; }
.btn-primary:hover, .btn-primary:focus { background: #a8ba00; border-color: #a8ba00; color: var(--bo-dark); }
.form-control:focus { border-color: var(--bo-accent); box-shadow: 0 0 0 .2rem rgba(var(--bo-accent-rgb), .25); }
.auth-foot { color: #6c757d; }
.auth-foot a, .auth-card a { color: #6a7a00; }
/* 16px inputs stop iOS from auto-zooming on focus */
@media (max-width: 575.98px) { .form-control { font-size: 16px; } .auth-card { padding: 1.5rem; } }
</style>
</head>
<body>
<main class="auth-card">
<div class="auth-brand"><i class="bi bi-lightning-charge-fill me-1"></i><?= htmlspecialchars($app_name) ?></div>
-4
View File
@@ -5,10 +5,6 @@
require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/functions.php'; require_once __DIR__ . '/../config/functions.php';
if (empty($_SESSION['user_id']) && identity_is_federated()) {
sso_resolve_session($pdo);
}
$info = site_info($pdo); $info = site_info($pdo);
$navbar = site_navbar(); $navbar = site_navbar();
$current_uri_2 = $current_uri_2 ?? ''; $current_uri_2 = $current_uri_2 ?? '';
-4
View File
@@ -9,10 +9,6 @@ require_once __DIR__ . '/../config/functions.php';
// Resolve any SSO session up front (mirrors _open.php) so the is_staff() preview // 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. // 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'] ?? ''))); $slug = preg_replace('/[^a-z0-9\-]/', '', strtolower((string) ($_GET['slug'] ?? '')));
$event = $slug !== '' ? db_events_get_by_slug($pdo, $slug) : null; $event = $slug !== '' ? db_events_get_by_slug($pdo, $slug) : null;
+67
View File
@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="pt">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>A sua conta</title>
</head>
<body style="margin:0;padding:0;background:#f4f4f4;font-family:Arial,Helvetica,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f4;padding:30px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:8px;overflow:hidden;max-width:600px;width:100%;">
<!-- Header — dark background, so {{logo_html}} pulls the light logo -->
<tr>
<td style="background:#0a0a0a;padding:32px 40px;text-align:center;border-bottom:3px solid #C2D500;">
{{logo_html}}
</td>
</tr>
<!-- Body -->
<tr>
<td style="padding:40px 40px 32px;">
<p style="margin:0 0 16px;font-size:16px;color:#333333;">Olá {{user_name}},</p>
<p style="margin:0 0 24px;font-size:16px;color:#333333;">
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.
</p>
<table cellpadding="0" cellspacing="0" style="margin:28px 0;">
<tr>
<td style="background:#C2D500;border-radius:4px;">
<a href="{{reset_url}}" style="display:inline-block;padding:14px 32px;color:#0a0a0a;text-decoration:none;font-size:15px;font-weight:bold;">
Definir palavra-passe
</a>
</td>
</tr>
</table>
<p style="margin:0 0 8px;font-size:14px;color:#888888;">
Ou copie este endereço para o seu browser:
</p>
<p style="margin:0 0 24px;font-size:13px;color:#555555;word-break:break-all;">
<a href="{{reset_url}}" style="color:#0a0a0a;">{{reset_url}}</a>
</p>
<p style="margin:0;font-size:13px;color:#999999;">
Este link é válido durante <strong>2 horas</strong> e só pode ser usado uma vez.
Se expirar, use a opção <strong>Esqueceu a palavra-passe?</strong> na página de entrada.
</p>
</td>
</tr>
<!-- Divider -->
<tr>
<td style="padding:0 40px;">
<hr style="border:none;border-top:1px solid #eeeeee;margin:0;">
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding:24px 40px;text-align:center;">
<p style="margin:0;font-size:13px;color:#999999;">
{{app_name}}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+67
View File
@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="pt">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Redefinir palavra-passe</title>
</head>
<body style="margin:0;padding:0;background:#f4f4f4;font-family:Arial,Helvetica,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f4;padding:30px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:8px;overflow:hidden;max-width:600px;width:100%;">
<!-- Header — dark background, so {{logo_html}} pulls the light logo -->
<tr>
<td style="background:#0a0a0a;padding:32px 40px;text-align:center;border-bottom:3px solid #C2D500;">
{{logo_html}}
</td>
</tr>
<!-- Body -->
<tr>
<td style="padding:40px 40px 32px;">
<p style="margin:0 0 16px;font-size:16px;color:#333333;">Olá {{user_name}},</p>
<p style="margin:0 0 24px;font-size:16px;color:#333333;">
Recebemos um pedido para definir uma nova palavra-passe da sua conta
{{app_name}}. Clique no botão abaixo para continuar.
</p>
<table cellpadding="0" cellspacing="0" style="margin:28px 0;">
<tr>
<td style="background:#C2D500;border-radius:4px;">
<a href="{{reset_url}}" style="display:inline-block;padding:14px 32px;color:#0a0a0a;text-decoration:none;font-size:15px;font-weight:bold;">
Definir palavra-passe
</a>
</td>
</tr>
</table>
<p style="margin:0 0 8px;font-size:14px;color:#888888;">
Ou copie este endereço para o seu browser:
</p>
<p style="margin:0 0 24px;font-size:13px;color:#555555;word-break:break-all;">
<a href="{{reset_url}}" style="color:#0a0a0a;">{{reset_url}}</a>
</p>
<p style="margin:0;font-size:13px;color:#999999;">
Este link é válido durante <strong>2 horas</strong> e só pode ser usado uma vez.
Se não pediu esta alteração, ignore este email — a sua conta permanece segura.
</p>
</td>
</tr>
<!-- Divider -->
<tr>
<td style="padding:0 40px;">
<hr style="border:none;border-top:1px solid #eeeeee;margin:0;">
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding:24px 40px;text-align:center;">
<p style="margin:0;font-size:13px;color:#999999;">
{{app_name}}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+76 -4
View File
@@ -1,10 +1,82 @@
<?php <?php
// Password recovery lives on id.andmore.pt — every local form/handler was // "Forgot password" request. Mints a single-use token in `password_resets` and
// removed when this install became SSO-only. This file is a bounce-only stub. // emails the reset link through the centralized notify façade.
//
// The response is identical whether or not the address exists — otherwise this
// page would be an account-enumeration oracle.
//
// This is also how anyone carried over from the Andmore ID era sets their
// first local password: those rows have password_hash NULL, and the flow keys
// on the address on file, so only its owner can act on the link.
require __DIR__ . '/../../config/db.php'; require __DIR__ . '/../../config/db.php';
require_once __DIR__ . '/../../config/functions.php'; require_once __DIR__ . '/../../config/functions.php';
$return_to = base_url('views/auth/login.php'); if (!empty($_SESSION['user_id'])) {
header('Location: ' . ANDMORE_ID_URL . 'auth/forgot_password.php?return_to=' . urlencode($return_to)); header('Location: ' . base_url(post_login_destination($_SESSION['user']['role'] ?? '')));
exit; exit;
}
$sent = false;
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_check();
// Tighter than login: minting a reset token costs an email each time.
if (!throttle_check($pdo, 'pwreset', 3, 10)) {
throttle_block('forgot password');
}
throttle_record($pdo, 'pwreset');
$email = trim($_POST['email'] ?? '');
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$error = 'Endereço de email inválido.';
} else {
$user = user_find_by_email($pdo, $email);
if ($user) {
$token = auth_create_reset_token($pdo, (int) $user['id']);
send_password_reset_email(
(string) $user['name'],
(string) $user['email'],
base_url('views/auth/reset_password.php?token=' . $token),
(int) $user['id']
);
}
// Same message either way.
$sent = true;
}
}
$page_title = 'Recuperar palavra-passe';
include __DIR__ . '/../../includes/auth_header.php';
?>
<p class="auth-sub">Enviamos-lhe um link para definir uma nova palavra-passe.</p>
<?php if ($error): ?>
<div class="alert alert-danger py-2 small"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<?php if ($sent): ?>
<div class="alert alert-success py-2 small">
Se existir uma conta com esse email, enviámos-lhe as instruções.
O link é válido durante 2 horas.
</div>
<a href="<?= htmlspecialchars(base_url('views/auth/login.php')) ?>" class="btn btn-outline-secondary w-100">Voltar ao login</a>
<?php else: ?>
<form method="POST" novalidate>
<?= csrf_field() ?>
<div class="mb-4">
<label class="form-label" for="fp-email">Email</label>
<input type="email" id="fp-email" name="email" class="form-control" required autofocus
autocomplete="email" inputmode="email"
value="<?= htmlspecialchars($_POST['email'] ?? '') ?>">
</div>
<button type="submit" class="btn btn-primary w-100">Enviar link</button>
</form>
<hr class="my-3">
<p class="text-center small mb-0 auth-foot">
<a href="<?= htmlspecialchars(base_url('views/auth/login.php')) ?>">Voltar ao login</a>
</p>
<?php endif; ?>
<?php include __DIR__ . '/../../includes/auth_footer.php'; ?>
+90 -60
View File
@@ -1,76 +1,106 @@
<?php <?php
// SSO-only platform: there is no local login form. All credentials live on // Local login form. This install owns its passwords (users.password_hash);
// id.andmore.pt. This file exists solely so old links / bookmarks pointing // there is no identity provider in front of it.
// at /views/auth/login.php still land the visitor on the central form.
// //
// Also acts as the post-login landing for the id.andmore.pt return_to: the // Carries through ?return_to=/path, set by require_login(), so the visitor
// am_sso cookie is now in the browser, sso_resolve_session() picks it up, // lands back on the page they originally asked for.
// and we forward to the dashboard. The `?from=sso` flag prevents an infinite
// loop if resolution fails here — we render a clean error instead of
// bouncing back to id.andmore.pt (which would just send us back here again).
//
// Carries through the optional claim intent (?next=session&session_id=X)
// from the homepage "vagas disponíveis" cards so that after authenticating
// the user lands on their dashboard scrolled to the open session.
require __DIR__ . '/../../config/db.php'; require __DIR__ . '/../../config/db.php';
require_once __DIR__ . '/../../config/functions.php'; require_once __DIR__ . '/../../config/functions.php';
// Establish the session on return from the hub. fitme3070.pt is off the $return_to = safe_return_path($_GET['return_to'] ?? '');
// .andmore.pt cookie scope, so the real handoff is the one-time ?sso_ticket=…
// the hub appended to this return URL — redeem it. (sso_resolve_session() is
// kept as a fallback for the rare case a same-site am_sso cookie is present.)
if (empty($_SESSION['user_id']) && identity_is_federated()) {
if (!empty($_GET['sso_ticket'])) {
sso_redeem_ticket($pdo, (string) $_GET['sso_ticket']);
}
if (empty($_SESSION['user_id'])) {
sso_resolve_session($pdo);
}
}
$next = $_GET['next'] ?? ''; $destination = function (string $role) use ($return_to): string {
$session_id = (int) ($_GET['session_id'] ?? 0); return $return_to !== '' ? $return_to : post_login_destination($role);
$claim_intent = ($next === 'session' && $session_id > 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);
}; };
if (!empty($_SESSION['user_id'])) { if (!empty($_SESSION['user_id'])) {
header('Location: ' . base_url($dashboard_for($_SESSION['user']['role'] ?? ''))); header('Location: ' . base_url($destination($_SESSION['user']['role'] ?? '')));
exit; exit;
} }
// We came back from id.andmore.pt but sso_resolve_session() couldn't pick up $error = '';
// a valid session. Bouncing to id.andmore.pt again would loop forever — show if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// a clean error instead so the user can see what's wrong. csrf_check();
if ($from_sso) {
http_response_code(500); // Counts successes too, so a guessed password can't be parlayed into
$am_cookie_present = !empty($_COOKIE[SSO_COOKIE_NAME] ?? ''); // stuffing the rest of the user list from the same IP.
?><!doctype html><html lang="pt"><head><meta charset="utf-8"><title>Erro de autenticação</title> if (!throttle_check($pdo, 'login')) {
<style>body{font-family:system-ui,-apple-system,sans-serif;max-width:560px;margin:80px auto;padding:0 1.5rem;color:#1c1c1a;background:#f7f5f1;line-height:1.6}h1{font-weight:300;font-size:1.6rem;margin-bottom:.5rem}code{background:rgba(0,0,0,.05);padding:2px 6px;border-radius:3px;font-size:.9em}.muted{color:#6b6863;font-size:.9rem}a{color:#5a7a62}</style> throttle_block('login form');
</head><body> }
<h1>Não foi possível concluir o login</h1> throttle_record($pdo, 'login');
<p>O servidor Andmore ID autenticou-o, mas esta plataforma não conseguiu carregar a sua sessão.</p>
<p class="muted"> $user = auth_login($pdo, $_POST['email'] ?? '', $_POST['password'] ?? '');
Cookie <code>am_sso</code>: <?= $am_cookie_present ? 'presente' : '<strong>em falta</strong>' ?>.<br> if ($user) {
Plataforma: <code><?= htmlspecialchars(PLATFORM_SLUG) ?></code> · Modo: <code><?= htmlspecialchars(IDENTITY_MODE) ?></code>. 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';
?>
<p class="auth-sub">Entre na sua conta para continuar.</p>
<?php if ($error): ?>
<div class="alert alert-danger py-2 small mb-2"><?= htmlspecialchars($error) ?></div>
<?php // Shown on any failure — an account created by an admin (or carried
// over from the Andmore ID era) has no password yet and would
// otherwise be stuck here. Phrased so it never confirms the address
// exists. ?>
<p class="small auth-foot">
Se a sua conta foi criada por nós, ainda não tem palavra-passe definida —
use <a href="<?= htmlspecialchars(base_url('views/auth/forgot_password.php')) ?>">Esqueceu a palavra-passe?</a>
para definir uma.
</p> </p>
<p><a href="<?= htmlspecialchars(base_url('index.php')) ?>">Voltar ao início</a> · <a href="<?= htmlspecialchars(base_url('views/auth/logout.php')) ?>">Tentar novamente</a></p> <?php endif; ?>
</body></html><?php <?php if (isset($_GET['registered'])): ?>
exit; <div class="alert alert-success py-2 small">Conta criada. Já pode entrar.</div>
} <?php endif; ?>
<?php if (isset($_GET['reset'])): ?>
<div class="alert alert-success py-2 small">Palavra-passe alterada. Entre com a nova palavra-passe.</div>
<?php endif; ?>
<?php if (isset($_GET['loggedout'])): ?>
<div class="alert alert-success py-2 small">Sessão terminada.</div>
<?php endif; ?>
// First arrival — bounce to id.andmore.pt for credentials, tagging the <form method="POST" action="<?= htmlspecialchars('login.php' . $form_qs) ?>" novalidate>
// return URL with ?from=sso so the loop-guard above fires on the way back. <?= csrf_field() ?>
$return_to = base_url('views/auth/login.php?from=sso' <div class="mb-3">
. ($claim_intent ? '&next=session&session_id=' . $session_id : '')); <label class="form-label" for="login-email">Email</label>
<input type="email" id="login-email" name="email" class="form-control" required autofocus
autocomplete="email" inputmode="email"
value="<?= htmlspecialchars($_POST['email'] ?? '') ?>">
</div>
<div class="mb-4">
<!-- O link de recuperação vem DEPOIS do input: dentro do <label> ficava
antes dele no DOM, e o Tab a partir do email saltava para o link
em vez de ir para a palavra-passe. -->
<label class="form-label" for="login-password">Palavra-passe</label>
<div class="input-group">
<input type="password" id="login-password" name="password" class="form-control" required
autocomplete="current-password">
<button class="btn btn-outline-secondary" type="button" tabindex="-1"
data-toggle-password="login-password" aria-label="Mostrar palavra-passe">
<i class="bi bi-eye"></i>
</button>
</div>
<div class="text-end mt-1">
<a href="<?= htmlspecialchars(base_url('views/auth/forgot_password.php')) ?>" class="small">Esqueceu a <span class="text-nowrap">palavra-passe</span>?</a>
</div>
</div>
<button type="submit" class="btn btn-primary w-100">Entrar</button>
</form>
$_login_qs = '?return_to=' . urlencode($return_to) . '&platform=' . urlencode(PLATFORM_SLUG); <hr class="my-3">
header('Location: ' . ANDMORE_ID_URL . 'auth/login.php' . $_login_qs); <p class="text-center small mb-0 auth-foot">
exit; Novo cliente?
<a href="<?= htmlspecialchars(base_url('views/auth/register.php')) ?>">Criar conta</a>
</p>
<?php include __DIR__ . '/../../includes/auth_footer.php'; ?>
+9 -18
View File
@@ -1,26 +1,17 @@
<?php <?php
// Ends the local session. Nothing to coordinate with an identity provider —
// this install owns its own sessions.
require __DIR__ . '/../../config/db.php'; require __DIR__ . '/../../config/db.php';
require __DIR__ . '/../../config/functions.php'; require __DIR__ . '/../../config/functions.php';
// Always clear the local session first.
$_SESSION = []; $_SESSION = [];
// Drop the cookie too, so the browser doesn't keep presenting a dead id.
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
}
session_destroy(); session_destroy();
// Federated mode: always bounce through Andmore ID's logout endpoint so both header('Location: ' . base_url('views/auth/login.php?loggedout=1'));
// the central sso_sessions row and the am_sso cookie are destroyed AND
// id.andmore.pt's own PHP session is cleared. The old check for the am_sso
// cookie was a bug: if the cookie happened to be absent from $_COOKIE (e.g.
// due to a Secure-flag or SameSite edge case), the central logout was skipped,
// leaving id.andmore.pt's session alive. That session then caused a redirect
// loop — id.andmore.pt auto-logged the user in and bounced them back to the
// login stub, which immediately bounced them back to id.andmore.pt, ad inf.
//
// Return to the public landing page (not the login stub) so the post-logout
// redirect from id.andmore.pt lands on a real page and the chain stops.
if (identity_is_federated()) {
header('Location: ' . ANDMORE_ID_URL . 'auth/logout.php?return_to=' . urlencode(APP_BASE_URL));
exit;
}
header('Location: ' . base_url('views/auth/login.php'));
exit; exit;
+115 -24
View File
@@ -1,35 +1,126 @@
<?php <?php
// SSO-only platform: there is no local registration form. Accounts live on // Local self-registration for clients. Creates (or claims — see
// id.andmore.pt and are mirrored locally by sso_resolve_session() the first // auth_register_client) a `client` row with a password and signs the person in.
// time the user lands on this platform. This file exists only so old
// /views/auth/register.php links bounce through to the central registration.
require __DIR__ . '/../../config/db.php'; require __DIR__ . '/../../config/db.php';
require_once __DIR__ . '/../../config/functions.php'; require_once __DIR__ . '/../../config/functions.php';
// Already authenticated? Skip the bounce and land on the right dashboard.
if (empty($_SESSION['user_id']) && identity_is_federated()) {
sso_resolve_session($pdo);
}
$next = $_GET['next'] ?? '';
$session_id = (int) ($_GET['session_id'] ?? 0);
$claim_intent = ($next === 'session' && $session_id > 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'])) { 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; exit;
} }
$return_to = base_url('views/auth/login.php' $error = '';
. ($claim_intent ? '?next=session&session_id=' . $session_id : '')); if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_check();
header('Location: ' . ANDMORE_ID_URL . 'auth/register.php?return_to=' . urlencode($return_to)); // 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; exit;
}
header('Location: ' . base_url('views/auth/login.php?registered=1'));
exit;
}
}
}
$page_title = 'Criar conta';
include __DIR__ . '/../../includes/auth_header.php';
?>
<p class="auth-sub">Crie a sua conta de cliente.</p>
<?php if ($error): ?>
<div class="alert alert-danger py-2 small"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<form method="POST" novalidate>
<?= csrf_field() ?>
<div class="mb-3">
<label class="form-label" for="reg-name">Nome</label>
<input type="text" id="reg-name" name="name" class="form-control" required autofocus
autocomplete="name" value="<?= htmlspecialchars($_POST['name'] ?? '') ?>">
</div>
<div class="mb-3">
<label class="form-label" for="reg-email">Email</label>
<input type="email" id="reg-email" name="email" class="form-control" required
autocomplete="email" inputmode="email"
value="<?= htmlspecialchars($_POST['email'] ?? '') ?>">
</div>
<div class="mb-3">
<label class="form-label" for="reg-phone">Telefone <span class="text-muted small">(opcional)</span></label>
<input type="tel" id="reg-phone" name="phone" class="form-control"
autocomplete="tel" value="<?= htmlspecialchars($_POST['phone'] ?? '') ?>">
</div>
<div class="mb-3">
<label class="form-label" for="reg-password">Palavra-passe <span class="text-muted small">(mín. 8 caracteres)</span></label>
<div class="input-group">
<input type="password" id="reg-password" name="password" class="form-control" required minlength="8"
autocomplete="new-password">
<button class="btn btn-outline-secondary" type="button" tabindex="-1"
data-toggle-password="reg-password" aria-label="Mostrar palavra-passe">
<i class="bi bi-eye"></i>
</button>
</div>
</div>
<div class="mb-3">
<label class="form-label" for="reg-confirm">Confirmar palavra-passe</label>
<input type="password" id="reg-confirm" name="confirm" class="form-control" required
autocomplete="new-password">
</div>
<div class="form-check small mb-2">
<input class="form-check-input" type="checkbox" name="accept_terms" id="reg-terms" value="1"
<?= !empty($_POST['accept_terms']) ? 'checked' : '' ?>>
<label class="form-check-label" for="reg-terms">
Li e aceito a <a href="<?= htmlspecialchars(base_url('home/privacidade')) ?>" target="_blank" rel="noopener">política de privacidade</a>.
</label>
</div>
<div class="form-check small mb-4">
<input class="form-check-input" type="checkbox" name="marketing_consent" id="reg-marketing" value="1"
<?= !empty($_POST['marketing_consent']) ? 'checked' : '' ?>>
<label class="form-check-label" for="reg-marketing">Aceito receber comunicações de marketing.</label>
</div>
<button type="submit" class="btn btn-primary w-100">Criar conta</button>
</form>
<hr class="my-3">
<p class="text-center small mb-0 auth-foot">
Já tem conta? <a href="<?= htmlspecialchars(base_url('views/auth/login.php')) ?>">Entrar</a>
</p>
<?php include __DIR__ . '/../../includes/auth_footer.php'; ?>
+72 -4
View File
@@ -1,10 +1,78 @@
<?php <?php
// Password reset happens on id.andmore.pt — bounce anyone landing here with // Consumes a `password_resets` token and sets a new password. Tokens are
// an old reset link so they don't get a "page not working" experience. // single-use and expire 2 hours after being minted (auth_create_reset_token).
// Also serves the "define a sua palavra-passe" invitation links.
require __DIR__ . '/../../config/db.php'; require __DIR__ . '/../../config/db.php';
require_once __DIR__ . '/../../config/functions.php'; require_once __DIR__ . '/../../config/functions.php';
header('Location: ' . ANDMORE_ID_URL . 'auth/forgot_password.php?return_to=' if (!empty($_SESSION['user_id'])) {
. urlencode(base_url('views/auth/login.php'))); header('Location: ' . base_url(post_login_destination($_SESSION['user']['role'] ?? '')));
exit; exit;
}
// The token travels in the query string on the GET and in a hidden field on
// the POST, so a mistyped/expired one is caught before rendering the form.
$token = (string) ($_POST['token'] ?? $_GET['token'] ?? '');
$reset = $token !== '' ? auth_get_valid_reset_token($pdo, $token) : false;
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $reset) {
csrf_check();
$password = $_POST['password'] ?? '';
$confirm = $_POST['confirm'] ?? '';
if (strlen($password) < 8) {
$error = 'A palavra-passe deve ter pelo menos 8 caracteres.';
} elseif ($password !== $confirm) {
$error = 'As palavras-passe não coincidem.';
} elseif (auth_consume_reset_token($pdo, $token, $password)) {
header('Location: ' . base_url('views/auth/login.php?reset=1'));
exit;
} else {
$error = 'Não foi possível repor a palavra-passe. Peça um novo link.';
}
}
$page_title = 'Nova palavra-passe';
include __DIR__ . '/../../includes/auth_header.php';
?>
<?php if (!$reset): ?>
<p class="auth-sub">Este link já não é válido.</p>
<div class="alert alert-warning py-2 small">
O link expirou ou já foi utilizado. Peça um novo para continuar.
</div>
<a href="<?= htmlspecialchars(base_url('views/auth/forgot_password.php')) ?>" class="btn btn-primary w-100">Pedir novo link</a>
<?php else: ?>
<p class="auth-sub">
A definir uma nova palavra-passe para <strong><?= htmlspecialchars($reset['email']) ?></strong>.
</p>
<?php if ($error): ?>
<div class="alert alert-danger py-2 small"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<form method="POST" novalidate>
<?= csrf_field() ?>
<input type="hidden" name="token" value="<?= htmlspecialchars($token) ?>">
<div class="mb-3">
<label class="form-label" for="rp-password">Palavra-passe <span class="text-muted small">(mín. 8 caracteres)</span></label>
<div class="input-group">
<input type="password" id="rp-password" name="password" class="form-control" required minlength="8"
autocomplete="new-password" autofocus>
<button class="btn btn-outline-secondary" type="button" tabindex="-1"
data-toggle-password="rp-password" aria-label="Mostrar palavra-passe">
<i class="bi bi-eye"></i>
</button>
</div>
</div>
<div class="mb-4">
<label class="form-label" for="rp-confirm">Confirmar palavra-passe</label>
<input type="password" id="rp-confirm" name="confirm" class="form-control" required
autocomplete="new-password">
</div>
<button type="submit" class="btn btn-primary w-100">Guardar palavra-passe</button>
</form>
<?php endif; ?>
<?php include __DIR__ . '/../../includes/auth_footer.php'; ?>
+4 -10
View File
@@ -39,13 +39,10 @@ if ($alreadyDone) {
} }
} }
include __DIR__ . '/../../includes/header.php'; $page_title = 'Configuração inicial';
include __DIR__ . '/../../includes/auth_header.php';
?> ?>
<div class="container" style="max-width:480px; margin-top:80px;"> <p class="auth-sub">Crie a conta de administrador. Elimine este ficheiro depois.</p>
<div class="card shadow-sm">
<div class="card-body p-4">
<h3 class="mb-1">Configuração inicial</h3>
<p class="text-muted small mb-4">Crie a conta de administrador. Elimine este ficheiro depois.</p>
<?php if ($error): ?> <?php if ($error): ?>
<div class="alert alert-danger"><?= htmlspecialchars($error) ?></div> <div class="alert alert-danger"><?= htmlspecialchars($error) ?></div>
@@ -80,7 +77,4 @@ include __DIR__ . '/../../includes/header.php';
<button type="submit" class="btn btn-primary w-100">Criar conta de administrador</button> <button type="submit" class="btn btn-primary w-100">Criar conta de administrador</button>
</form> </form>
<?php endif; ?> <?php endif; ?>
</div> <?php include __DIR__ . '/../../includes/auth_footer.php'; ?>
</div>
</div>
<?php include __DIR__ . '/../../includes/footer.php'; ?>
+14 -3
View File
@@ -4,7 +4,7 @@ require __DIR__ . '/../../config/functions.php';
$user = require_admin(); $user = require_admin();
$users = $pdo->query( $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" FROM users ORDER BY FIELD(role,'sysadmin','admin','staff','client'), name"
)->fetchAll(PDO::FETCH_ASSOC); )->fetchAll(PDO::FETCH_ASSOC);
@@ -17,7 +17,7 @@ include __DIR__ . '/../../includes/header.php';
<table class="am-datatable table table-hover align-middle"> <table class="am-datatable table table-hover align-middle">
<thead><tr> <thead><tr>
<th>Nome</th><th>Email</th><th>Perfil</th><th>Ativo</th> <th>Nome</th><th>Email</th><th>Perfil</th><th>Ativo</th>
<th>Andmore ID</th><th>Último acesso</th> <th>Palavra-passe</th><th>Último acesso</th><th></th>
</tr></thead> </tr></thead>
<tbody> <tbody>
<?php foreach ($users as $u): ?> <?php foreach ($users as $u): ?>
@@ -26,13 +26,24 @@ include __DIR__ . '/../../includes/header.php';
<td><?= htmlspecialchars($u['email']) ?></td> <td><?= htmlspecialchars($u['email']) ?></td>
<td><span class="badge text-bg-secondary"><?= htmlspecialchars(role_label($u['role'])) ?></span></td> <td><span class="badge text-bg-secondary"><?= htmlspecialchars(role_label($u['role'])) ?></span></td>
<td><?= $u['is_active'] ? '<i class="bi bi-check-circle text-success"></i>' : '<i class="bi bi-x-circle text-muted"></i>' ?></td> <td><?= $u['is_active'] ? '<i class="bi bi-check-circle text-success"></i>' : '<i class="bi bi-x-circle text-muted"></i>' ?></td>
<td><?= $u['andmore_id'] ? (int) $u['andmore_id'] : '<span class="text-muted">—</span>' ?></td> <td>
<?php if (!empty($u['password_hash'])): ?>
<i class="bi bi-key-fill text-success" title="Definida"></i>
<?php else: ?>
<span class="badge text-bg-warning-subtle text-warning-emphasis">Por definir</span>
<?php endif; ?>
</td>
<td class="small text-muted"> <td class="small text-muted">
<?php if ($u['last_login_at']): ?> <?php if ($u['last_login_at']): ?>
<?= htmlspecialchars(format_date_pt($u['last_login_at'])) ?> <?= htmlspecialchars(format_date_pt($u['last_login_at'])) ?>
· <?= htmlspecialchars(parse_user_agent((string) $u['last_login_ua'])) ?> · <?= htmlspecialchars(parse_user_agent((string) $u['last_login_ua'])) ?>
<?php else: ?>—<?php endif; ?> <?php else: ?>—<?php endif; ?>
</td> </td>
<td class="text-end">
<a class="btn btn-sm btn-outline-secondary"
href="<?= base_url('views/user/set_password.php?id=' . (int) $u['id']) ?>"
title="Gerir palavra-passe"><i class="bi bi-key"></i></a>
</td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
+55 -14
View File
@@ -3,11 +3,37 @@ require __DIR__ . '/../../config/db.php';
require __DIR__ . '/../../config/functions.php'; require __DIR__ . '/../../config/functions.php';
$user = require_login(); $user = require_login();
// Editable per-tenant fields only. Name/email are owned by Andmore ID and synced // Two forms on this page, told apart by the hidden `form` field: the contact
// on login by sso_resolve_session(); the password is managed centrally. // 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; $saved = false;
$pw_saved = false;
$pw_error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_check(); csrf_check();
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'] ?? ''); $phone = trim($_POST['phone'] ?? '');
$address = trim($_POST['address'] ?? ''); $address = trim($_POST['address'] ?? '');
$city = trim($_POST['city'] ?? ''); $city = trim($_POST['city'] ?? '');
@@ -22,6 +48,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
)->execute([$phone, $address, $city, $postal_code, $email_opt, $sms_opt, $marketing, $user['id']]); )->execute([$phone, $address, $city, $postal_code, $email_opt, $sms_opt, $marketing, $user['id']]);
$saved = true; $saved = true;
} }
}
$row = db_get_user($pdo, (int) $user['id']) ?? []; $row = db_get_user($pdo, (int) $user['id']) ?? [];
include __DIR__ . '/../../includes/header.php'; include __DIR__ . '/../../includes/header.php';
@@ -33,22 +60,21 @@ include __DIR__ . '/../../includes/header.php';
<div class="card shadow-sm mb-3"><div class="card-body"> <div class="card shadow-sm mb-3"><div class="card-body">
<div class="row mb-2"><div class="col-4 text-muted">Nome</div><div class="col-8"><?= htmlspecialchars($row['name'] ?? '') ?></div></div> <div class="row mb-2"><div class="col-4 text-muted">Nome</div><div class="col-8"><?= htmlspecialchars($row['name'] ?? '') ?></div></div>
<div class="row mb-2"><div class="col-4 text-muted">Email</div><div class="col-8"><?= htmlspecialchars($row['email'] ?? '') ?></div></div> <div class="row mb-2"><div class="col-4 text-muted">Email</div><div class="col-8"><?= htmlspecialchars($row['email'] ?? '') ?></div></div>
<p class="text-muted small mb-0">Nome, email e palavra-passe são geridos no Andmore ID. <p class="text-muted small mb-0">Para alterar o nome ou o email, contacte-nos.</p>
<a href="<?= htmlspecialchars(rtrim(ANDMORE_ID_URL, '/')) ?>/" target="_blank" rel="noopener">Gerir conta &rarr;</a>
</p>
</div></div> </div></div>
<form method="POST" class="card shadow-sm"><div class="card-body"> <form method="POST" class="card shadow-sm mb-3"><div class="card-body">
<?= csrf_field() ?> <?= csrf_field() ?>
<div class="mb-3"><label class="form-label">Telefone</label> <input type="hidden" name="form" value="details">
<input class="form-control" name="phone" value="<?= htmlspecialchars($row['phone'] ?? '') ?>"></div> <div class="mb-3"><label class="form-label" for="pf-phone">Telefone</label>
<div class="mb-3"><label class="form-label">Morada</label> <input class="form-control" id="pf-phone" name="phone" value="<?= htmlspecialchars($row['phone'] ?? '') ?>"></div>
<input class="form-control" name="address" value="<?= htmlspecialchars($row['address'] ?? '') ?>"></div> <div class="mb-3"><label class="form-label" for="pf-address">Morada</label>
<input class="form-control" id="pf-address" name="address" value="<?= htmlspecialchars($row['address'] ?? '') ?>"></div>
<div class="row"> <div class="row">
<div class="col-md-8 mb-3"><label class="form-label">Localidade</label> <div class="col-md-8 mb-3"><label class="form-label" for="pf-city">Localidade</label>
<input class="form-control" name="city" value="<?= htmlspecialchars($row['city'] ?? '') ?>"></div> <input class="form-control" id="pf-city" name="city" value="<?= htmlspecialchars($row['city'] ?? '') ?>"></div>
<div class="col-md-4 mb-3"><label class="form-label">Código postal</label> <div class="col-md-4 mb-3"><label class="form-label" for="pf-postal">Código postal</label>
<input class="form-control" name="postal_code" value="<?= htmlspecialchars($row['postal_code'] ?? '') ?>"></div> <input class="form-control" id="pf-postal" name="postal_code" value="<?= htmlspecialchars($row['postal_code'] ?? '') ?>"></div>
</div> </div>
<div class="form-check"><input class="form-check-input" type="checkbox" name="email_opt_out" id="eo" <?= !empty($row['email_opt_out']) ? 'checked' : '' ?>> <div class="form-check"><input class="form-check-input" type="checkbox" name="email_opt_out" id="eo" <?= !empty($row['email_opt_out']) ? 'checked' : '' ?>>
<label class="form-check-label" for="eo">Não receber emails</label></div> <label class="form-check-label" for="eo">Não receber emails</label></div>
@@ -58,5 +84,20 @@ include __DIR__ . '/../../includes/header.php';
<label class="form-check-label" for="mc">Aceito comunicações de marketing</label></div> <label class="form-check-label" for="mc">Aceito comunicações de marketing</label></div>
<button class="btn btn-primary">Guardar</button> <button class="btn btn-primary">Guardar</button>
</div></form> </div></form>
<form method="POST" class="card shadow-sm"><div class="card-body">
<?= csrf_field() ?>
<input type="hidden" name="form" value="password">
<h2 class="h6 mb-3">Alterar palavra-passe</h2>
<?php if ($pw_saved): ?><div class="alert alert-success py-2 small">Palavra-passe alterada.</div><?php endif; ?>
<?php if ($pw_error): ?><div class="alert alert-danger py-2 small"><?= htmlspecialchars($pw_error) ?></div><?php endif; ?>
<div class="mb-3"><label class="form-label" for="pf-current">Palavra-passe atual</label>
<input type="password" class="form-control" id="pf-current" name="current_password" required autocomplete="current-password"></div>
<div class="mb-3"><label class="form-label" for="pf-new">Nova palavra-passe <span class="text-muted small">(mín. 8 caracteres)</span></label>
<input type="password" class="form-control" id="pf-new" name="new_password" required minlength="8" autocomplete="new-password"></div>
<div class="mb-3"><label class="form-label" for="pf-confirm">Confirmar nova palavra-passe</label>
<input type="password" class="form-control" id="pf-confirm" name="confirm_password" required autocomplete="new-password"></div>
<button class="btn btn-primary">Alterar palavra-passe</button>
</div></form>
</div> </div>
<?php include __DIR__ . '/../../includes/footer.php'; ?> <?php include __DIR__ . '/../../includes/footer.php'; ?>
+121
View File
@@ -0,0 +1,121 @@
<?php
// Admin override for a user's password — the way in for anyone whose email is
// wrong or undeliverable, and for the accounts carried over from the Andmore ID
// era that have no local password yet.
//
// Two actions:
// set — write a password the admin chose, and read it out once.
// invite — mail a single-use "define a sua palavra-passe" link instead, so
// the admin never handles the password at all. Preferred.
require __DIR__ . '/../../config/db.php';
require __DIR__ . '/../../config/functions.php';
$user = require_admin();
$target = db_get_user($pdo, (int) ($_GET['id'] ?? $_POST['id'] ?? 0));
if (!$target) {
header('Location: ' . base_url('views/user/index.php'));
exit;
}
// Guards. Self-service goes through profile.php, which demands the current
// password — routing it through here would let a hijacked admin session change
// the password without knowing the old one. And a plain admin must not be able
// to take over a sysadmin account; only a sysadmin can act on a sysadmin.
$is_self = ((int) $target['id'] === (int) $user['id']);
$blocked = '';
if ($is_self) {
$blocked = 'Para alterar a sua própria palavra-passe use O meu perfil.';
} elseif ($target['role'] === 'sysadmin' && !is_sysadmin()) {
$blocked = 'Apenas um administrador de sistema pode alterar a palavra-passe de outro administrador de sistema.';
}
$error = '';
$success = '';
$plain = ''; // shown once, only for the `set` action
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $blocked === '') {
csrf_check();
$action = $_POST['action'] ?? '';
if ($action === 'invite') {
if (send_password_invite_email($pdo, $target)) {
$success = 'Email enviado para ' . $target['email'] . '. O link é válido durante 2 horas.';
} else {
$error = 'Não foi possível enviar o email. Verifique as definições de notificações e tente novamente.';
}
} elseif ($action === 'set') {
$password = $_POST['password'] ?? '';
$confirm = $_POST['confirm'] ?? '';
if (strlen($password) < 8) {
$error = 'A palavra-passe deve ter pelo menos 8 caracteres.';
} elseif ($password !== $confirm) {
$error = 'As palavras-passe não coincidem.';
} else {
auth_set_password($pdo, (int) $target['id'], $password);
$plain = $password;
$success = 'Palavra-passe definida.';
}
}
$target = db_get_user($pdo, (int) $target['id']) ?? $target;
}
$page_title = 'Palavra-passe';
include __DIR__ . '/../../includes/header.php';
?>
<div class="container" style="max-width:640px;">
<h1 class="h3 mb-1">Palavra-passe</h1>
<p class="text-muted mb-4">
<?= htmlspecialchars($target['name']) ?>
· <?= htmlspecialchars($target['email']) ?>
· <span class="badge text-bg-secondary"><?= htmlspecialchars(role_label($target['role'])) ?></span>
</p>
<?php if ($blocked): ?>
<div class="alert alert-warning"><?= htmlspecialchars($blocked) ?></div>
<a class="btn btn-outline-secondary" href="<?= base_url('views/user/index.php') ?>">Voltar</a>
<?php else: ?>
<?php if ($error): ?><div class="alert alert-danger"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<?php if ($success): ?><div class="alert alert-success"><?= htmlspecialchars($success) ?></div><?php endif; ?>
<?php if ($plain !== ''): ?>
<div class="alert alert-warning">
Comunique-a por um canal seguro e peça que a altere no primeiro acesso.
Esta é a única vez que aparece:
<code class="d-block mt-2 fs-6"><?= htmlspecialchars($plain) ?></code>
</div>
<?php endif; ?>
<div class="card shadow-sm mb-3"><div class="card-body">
<h2 class="h6">Enviar link por email</h2>
<p class="text-muted small">
A pessoa define a própria palavra-passe. Ninguém no backoffice chega a conhecê-la.
<?php if (empty($target['password_hash'])): ?>
<br><strong>Esta conta ainda não tem palavra-passe definida.</strong>
<?php endif; ?>
</p>
<form method="POST">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int) $target['id'] ?>">
<input type="hidden" name="action" value="invite">
<button class="btn btn-primary"><i class="bi bi-envelope me-1"></i>Enviar link</button>
</form>
</div></div>
<form method="POST" class="card shadow-sm"><div class="card-body">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int) $target['id'] ?>">
<input type="hidden" name="action" value="set">
<h2 class="h6">Definir manualmente</h2>
<p class="text-muted small">Use apenas quando o email não for uma opção.</p>
<div class="mb-3"><label class="form-label" for="sp-password">Nova palavra-passe <span class="text-muted small">(mín. 8 caracteres)</span></label>
<input type="password" class="form-control" id="sp-password" name="password" required minlength="8" autocomplete="new-password"></div>
<div class="mb-3"><label class="form-label" for="sp-confirm">Confirmar</label>
<input type="password" class="form-control" id="sp-confirm" name="confirm" required autocomplete="new-password"></div>
<button class="btn btn-outline-primary">Definir palavra-passe</button>
<a class="btn btn-link" href="<?= base_url('views/user/index.php') ?>">Voltar</a>
</div></form>
<?php endif; ?>
</div>
<?php include __DIR__ . '/../../includes/footer.php'; ?>