Replace Andmore ID SSO with local login
fitme3070.pt delegated all authentication to hub.andmore.pt: views/auth/*
were bounce stubs, and because this domain is outside the .andmore.pt cookie
scope the real handoff was a one-time HTTPS ticket redeem. Bring identity back
in-house — passwords live in users.password_hash, which the SSO migration
never dropped.
Removed: config/sso.php, IDENTITY_MODE, every SSO_* constant and env key,
identity_is_federated() and the cookie/ticket paths in the auth views and the
public-site bootstrap. ANDMORE_ID_URL stays — it is still the hub /notify
endpoint for outbound email/SMS, which the reset mails ride on, and has
nothing to do with authentication now.
Added, ported from the fotografia.andmore.pt sibling rather than invented:
- auth_login() with transparent password_needs_rehash upgrades and the
last_login trail; auth_register_client(); auth_set_password(); the
single-use 2h reset tokens.
- Real login / register / forgot_password / reset_password pages on a new
anonymous shell (includes/auth_header.php). includes/header.php renders
the backoffice sidebar and a logout button, which is wrong for a visitor
who is not signed in yet.
- safe_return_path(), so a crafted ?return_to= cannot bounce a freshly
authenticated user off-site.
- Per-IP throttling on login, register and reset-request. Successes count
too, so a guessed password cannot be parlayed into stuffing the rest of
the user list from the same IP.
- views/user/set_password.php: admin override for anyone whose email is
undeliverable. Prefers mailing an invite link so the backoffice never
handles the password. A plain admin cannot act on a sysadmin, and nobody
can use it on themselves — self-service goes through profile.php, which
demands the current password so a hijacked session cannot lock the owner
out.
Accounts provisioned during the SSO era have password_hash NULL. They get in
via the reset email, or by registering again with the same address:
auth_register_client() claims a passwordless row, but only a plain `client`
one, so knowing a staff address does not let anyone self-assign the role.
users.andmore_id is deliberately left in place. No code reads it any more, but
it records which central identity each row was linked to.
Deployment: apply database/migration_20260913_local_auth.sql (password_resets
+ auth_attempts) in the SAME sysadmin session that pulls this commit — the
forgot-password flow needs that table, and after the pull a login requires a
local password.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dc8a36e126
commit
aec7b47820
23 files changed
+1195
-710
No files matched your search
+228
-43
@@ -1,14 +1,22 @@
|
||||
<?php
|
||||
// ─── Backoffice user creation ────────────────────────────────────────────────
|
||||
// This install is SSO-only — clients authenticate on id.andmore.pt and their
|
||||
// local row is provisioned by sso_resolve_session() (config/sso.php). The only
|
||||
// users created locally are backoffice accounts (sysadmin / admin / staff):
|
||||
// - auth_create_sysadmin() → first-install bootstrap (views/auth/setup.php)
|
||||
// - auth_create_admin()/staff() → admin-vouched, link the central Andmore ID
|
||||
// row immediately so the new person can SSO on any sibling .andmore.pt site.
|
||||
// ─── Users: lookup, authentication, registration, password reset ─────────────
|
||||
// This install owns its identity. Passwords live in `users.password_hash`;
|
||||
// views/auth/login.php is a real form and require_login() (config/auth.php)
|
||||
// bounces unauthenticated visitors there. Nothing is delegated to a hub.
|
||||
//
|
||||
// parse_user_agent() lives in config/functions.php; db_get_all_staff() /
|
||||
// db_get_active_admins() live in config/functions_db.php — not redefined here.
|
||||
// parse_user_agent() lives in config/functions.php; db_get_user() /
|
||||
// db_get_all_staff() / db_get_active_admins() live in config/functions_db.php
|
||||
// — not redefined here.
|
||||
|
||||
// ─── Lookup ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Active user by email, or false. Used by the login and reset-request flows,
|
||||
// which must never act on a deactivated account.
|
||||
function user_find_by_email(PDO $pdo, string $email): array|false {
|
||||
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = ? AND is_active = 1 LIMIT 1");
|
||||
$stmt->execute([strtolower(trim($email))]);
|
||||
return $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
// True once any sysadmin/admin row exists — gates views/auth/setup.php.
|
||||
function admin_exists(PDO $pdo): bool {
|
||||
@@ -17,8 +25,113 @@ function admin_exists(PDO $pdo): bool {
|
||||
)->fetchColumn();
|
||||
}
|
||||
|
||||
// Creates a sysadmin (full access incl. backups + platform update). Local-only
|
||||
// password row; federated linkage happens on first SSO by email match.
|
||||
// ─── Authentication ──────────────────────────────────────────────────────────
|
||||
|
||||
// Verifies credentials and stamps the login trail. Returns the user row on
|
||||
// success, null on bad email / bad password / inactive account / no password
|
||||
// set. Callers must not leak which of the four it was.
|
||||
function auth_login(PDO $pdo, string $email, string $password): ?array {
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT * FROM users WHERE email = ? AND is_active = 1 AND password_hash IS NOT NULL LIMIT 1"
|
||||
);
|
||||
$stmt->execute([strtolower(trim($email))]);
|
||||
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$user || !password_verify($password, $user['password_hash'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Transparently upgrade hashes when PHP's default algorithm/cost moves on.
|
||||
if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
|
||||
$pdo->prepare("UPDATE users SET password_hash = ? WHERE id = ?")
|
||||
->execute([password_hash($password, PASSWORD_DEFAULT), $user['id']]);
|
||||
}
|
||||
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? null;
|
||||
$ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512) ?: null;
|
||||
$pdo->prepare("UPDATE users SET last_login_at = NOW(), last_login_ip = ?, last_login_ua = ? WHERE id = ?")
|
||||
->execute([$ip, $ua, $user['id']]);
|
||||
$user['last_login_at'] = date('Y-m-d H:i:s');
|
||||
$user['last_login_ip'] = $ip;
|
||||
$user['last_login_ua'] = $ua;
|
||||
return $user;
|
||||
}
|
||||
|
||||
// Writes a new password hash. Shared by the reset flow, the profile page and
|
||||
// the admin override in views/user/set_password.php. Any outstanding reset
|
||||
// token is burned so an old "set your password" email can't be replayed to
|
||||
// take the account back.
|
||||
function auth_set_password(PDO $pdo, int $userId, string $password): void {
|
||||
$pdo->prepare("UPDATE users SET password_hash = ? WHERE id = ?")
|
||||
->execute([password_hash($password, PASSWORD_DEFAULT), $userId]);
|
||||
|
||||
// Best-effort: the password itself is already saved, so a missing
|
||||
// `password_resets` table (migration not applied yet) must not turn a
|
||||
// successful change into a 500 that makes the caller think it failed.
|
||||
try {
|
||||
$pdo->prepare("UPDATE password_resets SET used_at = NOW() WHERE user_id = ? AND used_at IS NULL")
|
||||
->execute([$userId]);
|
||||
} catch (PDOException $e) {
|
||||
error_log('[auth] could not burn reset tokens (run migration_20260913_local_auth.sql?): ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Registration ────────────────────────────────────────────────────────────
|
||||
|
||||
// Self-registration for clients (views/auth/register.php).
|
||||
//
|
||||
// Returns ['id' => int] on success, or ['error' => 'email_taken'].
|
||||
//
|
||||
// A row with no password — left behind by an admin who created the account, or
|
||||
// by the SSO era, when client rows were provisioned from the hub with
|
||||
// password_hash NULL — is *claimable* by whoever registers with that address,
|
||||
// but only when it is a plain client. A passwordless admin/staff/sysadmin row
|
||||
// must not be claimable through the public form: that would let anyone who
|
||||
// knows a staff email self-assign the role. Those report as taken and have to
|
||||
// be set up from the backoffice.
|
||||
function auth_register_client(PDO $pdo, array $data): array {
|
||||
$email = strtolower(trim($data['email']));
|
||||
$name = trim($data['name']);
|
||||
$phone = trim($data['phone'] ?? '') ?: null;
|
||||
|
||||
$stmt = $pdo->prepare("SELECT id, role, password_hash FROM users WHERE email = ? LIMIT 1");
|
||||
$stmt->execute([$email]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($row) {
|
||||
if (!empty($row['password_hash']) || $row['role'] !== 'client') {
|
||||
return ['error' => 'email_taken'];
|
||||
}
|
||||
$pdo->prepare(
|
||||
"UPDATE users SET name = ?, phone = COALESCE(?, phone), password_hash = ?, is_active = 1 WHERE id = ?"
|
||||
)->execute([$name, $phone, password_hash($data['password'], PASSWORD_DEFAULT), $row['id']]);
|
||||
return ['id' => (int) $row['id']];
|
||||
}
|
||||
|
||||
// UNIQUE(email) is the real guard — the SELECT above can lose a race with a
|
||||
// concurrent signup, so the insert is what decides.
|
||||
try {
|
||||
$pdo->prepare("
|
||||
INSERT INTO users (name, email, phone, password_hash, role, marketing_consent, is_active)
|
||||
VALUES (?, ?, ?, ?, 'client', ?, 1)
|
||||
")->execute([
|
||||
$name,
|
||||
$email,
|
||||
$phone,
|
||||
password_hash($data['password'], PASSWORD_DEFAULT),
|
||||
!empty($data['marketing_consent']) ? 1 : 0,
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
if ((int) ($e->errorInfo[1] ?? 0) === 1062) return ['error' => 'email_taken'];
|
||||
throw $e;
|
||||
}
|
||||
return ['id' => (int) $pdo->lastInsertId()];
|
||||
}
|
||||
|
||||
// ─── Backoffice user creation ────────────────────────────────────────────────
|
||||
|
||||
// Creates a sysadmin (full access incl. backups + platform update). Used once,
|
||||
// by views/auth/setup.php, on a fresh install.
|
||||
function auth_create_sysadmin(PDO $pdo, string $name, string $email, string $password): int {
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO users (name, email, password_hash, role)
|
||||
@@ -32,7 +145,7 @@ function auth_create_sysadmin(PDO $pdo, string $name, string $email, string $pas
|
||||
return (int) $pdo->lastInsertId();
|
||||
}
|
||||
|
||||
// Returns ['id'=>int] on success, or ['error'=>'andmore_email_taken'|'unavailable'].
|
||||
// Returns ['id' => int] on success, or ['error' => 'email_taken'].
|
||||
function auth_create_admin(PDO $pdo, string $name, string $email, string $phone, string $password): array {
|
||||
return _create_backoffice_user($pdo, 'admin', $name, $email, $phone, $password);
|
||||
}
|
||||
@@ -41,41 +154,113 @@ function auth_create_staff(PDO $pdo, string $name, string $email, string $phone,
|
||||
return _create_backoffice_user($pdo, 'staff', $name, $email, $phone, $password);
|
||||
}
|
||||
|
||||
// Internal: create an admin/staff row and, in federated mode, link it to the
|
||||
// central Andmore ID (admin-vouched — the local password is mirrored upstream
|
||||
// so SSO_REQUIRED=true never locks out the new user). See config/sso.php
|
||||
// identity_upstream_link_immediate() for the upstream rules.
|
||||
function _create_backoffice_user(PDO $pdo, string $role, string $name, string $email, string $phone, string $password): array {
|
||||
if (!in_array($role, ['admin', 'staff'], true)) {
|
||||
throw new InvalidArgumentException("Invalid backoffice role: {$role}");
|
||||
}
|
||||
$email = strtolower(trim($email));
|
||||
|
||||
$canonicalId = null;
|
||||
if (identity_is_federated()) {
|
||||
$link = identity_upstream_link_immediate($email, $name, $phone, $password);
|
||||
if (($link['error'] ?? '') === 'andmore_email_taken') {
|
||||
return ['error' => 'andmore_email_taken'];
|
||||
}
|
||||
if (($link['error'] ?? '') === 'unavailable' && identity_require_upstream()) {
|
||||
return ['error' => 'unavailable'];
|
||||
}
|
||||
if (isset($link['canonical_id'])) {
|
||||
$canonicalId = (int) $link['canonical_id'];
|
||||
}
|
||||
try {
|
||||
$pdo->prepare("
|
||||
INSERT INTO users (name, email, phone, password_hash, role)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
")->execute([
|
||||
$name,
|
||||
strtolower(trim($email)),
|
||||
$phone ?: null,
|
||||
password_hash($password, PASSWORD_DEFAULT),
|
||||
$role,
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
if ((int) ($e->errorInfo[1] ?? 0) === 1062) return ['error' => 'email_taken'];
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO users (name, email, phone, password_hash, role, andmore_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
$stmt->execute([
|
||||
$name,
|
||||
$email,
|
||||
$phone ?: null,
|
||||
password_hash($password, PASSWORD_DEFAULT),
|
||||
$role,
|
||||
$canonicalId,
|
||||
]);
|
||||
return ['id' => (int) $pdo->lastInsertId()];
|
||||
}
|
||||
|
||||
// ─── Password reset ──────────────────────────────────────────────────────────
|
||||
// Single-use tokens in `password_resets`, valid for 2 hours. Minting a new one
|
||||
// invalidates any outstanding token for that user.
|
||||
|
||||
function auth_create_reset_token(PDO $pdo, int $userId): string {
|
||||
$pdo->prepare("DELETE FROM password_resets WHERE user_id = ? AND used_at IS NULL")
|
||||
->execute([$userId]);
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$pdo->prepare(
|
||||
"INSERT INTO password_resets (user_id, token, expires_at)
|
||||
VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 2 HOUR))"
|
||||
)->execute([$userId, $token]);
|
||||
return $token;
|
||||
}
|
||||
|
||||
function auth_get_valid_reset_token(PDO $pdo, string $token): array|false {
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT pr.user_id, pr.token, u.name, u.email, u.role
|
||||
FROM password_resets pr
|
||||
JOIN users u ON u.id = pr.user_id
|
||||
WHERE pr.token = ?
|
||||
AND pr.used_at IS NULL
|
||||
AND pr.expires_at > NOW()
|
||||
AND u.is_active = 1
|
||||
LIMIT 1
|
||||
");
|
||||
$stmt->execute([$token]);
|
||||
return $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
function auth_consume_reset_token(PDO $pdo, string $token, string $newPassword): bool {
|
||||
$reset = auth_get_valid_reset_token($pdo, $token);
|
||||
if (!$reset) return false;
|
||||
// auth_set_password() burns every outstanding token for this user, this one
|
||||
// included, so the link can't be replayed.
|
||||
auth_set_password($pdo, (int) $reset['user_id'], $newPassword);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Email: password reset / first-password invitation ───────────────────────
|
||||
// Delivery is centralized at the hub's /notify endpoint — see
|
||||
// config/functions_queue.php (enqueue_email + flush_message).
|
||||
|
||||
function send_password_reset_email(string $name, string $email, string $resetUrl, ?int $userId = null): bool {
|
||||
$body = load_email_template('password_reset', [
|
||||
'user_name' => $name,
|
||||
'reset_url' => $resetUrl,
|
||||
'app_name' => APP_NAME,
|
||||
]);
|
||||
$id = enqueue_email([
|
||||
'to_user_id' => $userId,
|
||||
'to_address' => $email,
|
||||
'to_name' => $name,
|
||||
'subject' => 'Redefinir palavra-passe',
|
||||
'body' => $body,
|
||||
'alt_body' => "Olá {$name},\n\nPara definir uma nova palavra-passe aceda a:\n{$resetUrl}\n\nO link é válido durante 2 horas.",
|
||||
'context_type' => 'password_reset',
|
||||
'context_id' => $userId,
|
||||
]);
|
||||
return flush_message($id);
|
||||
}
|
||||
|
||||
// An account created from the backoffice has no password, so the person has no
|
||||
// way of knowing it exists. Invites them to set one, reusing the reset token —
|
||||
// same mechanism, different copy.
|
||||
function send_password_invite_email(PDO $pdo, array $user): bool {
|
||||
if (empty($user['email'])) return false;
|
||||
$token = auth_create_reset_token($pdo, (int) $user['id']);
|
||||
$url = base_url('views/auth/reset_password.php?token=' . $token);
|
||||
$name = (string) ($user['name'] ?? '');
|
||||
|
||||
$body = load_email_template('password_invite', [
|
||||
'user_name' => $name,
|
||||
'reset_url' => $url,
|
||||
'app_name' => APP_NAME,
|
||||
]);
|
||||
$id = enqueue_email([
|
||||
'to_user_id' => (int) $user['id'],
|
||||
'to_address' => $user['email'],
|
||||
'to_name' => $name,
|
||||
'subject' => 'A sua conta ' . APP_NAME,
|
||||
'body' => $body,
|
||||
'alt_body' => "Olá {$name},\n\nFoi criada uma conta para si em " . APP_NAME . ".\nDefina a sua palavra-passe em:\n{$url}\n\nO link é válido durante 2 horas.",
|
||||
'context_type' => 'password_invite',
|
||||
'context_id' => (int) $user['id'],
|
||||
]);
|
||||
return flush_message($id);
|
||||
}
|
||||
Reference in new issue
Block a user