mirror of
git://f0xx.org/ac/ac-deploy
synced 2026-07-29 07:37:47 +03:00
feat(deploy): lab-seeds sync, msmtp nginx-readable secret, hub downloads
Signed-off-by: Anton Afanasyeu <a.afanasieff@gmail.com>
This commit is contained in:
@@ -28,7 +28,7 @@ final class AuthMailer {
|
||||
return self::sendSmtp($to, $subject, $bodyText, $from, $replyTo, $attachments, $bodyHtml, $inlineImages);
|
||||
}
|
||||
|
||||
public static function sendVerifyEmail(string $to, string $verifyUrl): bool {
|
||||
public static function sendVerifyEmail(string $to, string $verifyUrl, string $validFor = '24h'): bool {
|
||||
$subject = 'Verify your Android Cast account';
|
||||
$shortUrl = $verifyUrl;
|
||||
$qrImageUrl = null;
|
||||
@@ -49,7 +49,7 @@ final class AuthMailer {
|
||||
$qrImageUrl = null;
|
||||
}
|
||||
}
|
||||
$body = "Open this link to verify your email (valid 24h):\n\n"
|
||||
$body = "Open this link to verify your email (valid {$validFor}):\n\n"
|
||||
. $shortUrl . "\n";
|
||||
$bodyHtml = null;
|
||||
$inline = [];
|
||||
@@ -70,7 +70,7 @@ final class AuthMailer {
|
||||
$cid = 'verify-qr-' . bin2hex(random_bytes(4)) . '@androidcast';
|
||||
$escUrl = htmlspecialchars($shortUrl, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$bodyHtml = '<!DOCTYPE html><html><body style="font-family:sans-serif;line-height:1.5">'
|
||||
. '<p>Open this link to verify your email (valid 24h):</p>'
|
||||
. '<p>Open this link to verify your email (valid ' . htmlspecialchars($validFor, ENT_QUOTES | ENT_HTML5, 'UTF-8') . '):</p>'
|
||||
. '<p><a href="' . $escUrl . '">' . $escUrl . '</a></p>'
|
||||
. '<p>Or scan this QR code on your phone:</p>'
|
||||
. '<p><img src="cid:' . $cid . '" alt="Verify email QR code" width="256" height="256"></p>'
|
||||
|
||||
@@ -3,8 +3,6 @@ declare(strict_types=1);
|
||||
|
||||
/** Email self-registration (verify link). TOTP enrollment follows login — task 3.x. */
|
||||
final class AuthRegistration {
|
||||
private const TOKEN_TTL_S = 86400;
|
||||
|
||||
private function __construct() {
|
||||
}
|
||||
|
||||
@@ -12,7 +10,12 @@ final class AuthRegistration {
|
||||
AuthEmailSchema::ensure(Database::pdo());
|
||||
}
|
||||
|
||||
/** @return array{ok:bool,error?:string} */
|
||||
public static function verifyEmailTtlSeconds(): int {
|
||||
$ttl = (int) cfg('auth.verify_email_ttl', 86400);
|
||||
return max(60, min($ttl, 604800));
|
||||
}
|
||||
|
||||
/** @return array{ok:bool,error?:string,expires_at?:string,ttl_seconds?:int,email?:string} */
|
||||
public static function register(string $email, string $password, string $username = ''): array {
|
||||
self::ensureSchema();
|
||||
if (AuthAttempts::isRateLimited($username !== '' ? $username : $email)) {
|
||||
@@ -32,9 +35,14 @@ final class AuthRegistration {
|
||||
$username = strstr($emailNorm, '@', true) ?: $emailNorm;
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
$st = $pdo->prepare('SELECT id FROM users WHERE email_normalized = ? OR username = ? LIMIT 1');
|
||||
self::purgeStalePending($pdo);
|
||||
$st = $pdo->prepare('SELECT id, status FROM users WHERE email_normalized = ? OR username = ? LIMIT 1');
|
||||
$st->execute([$emailNorm, $username]);
|
||||
if ($st->fetchColumn()) {
|
||||
$existing = $st->fetch(PDO::FETCH_ASSOC);
|
||||
if ($existing) {
|
||||
if (($existing['status'] ?? '') === 'pending') {
|
||||
return self::resendVerification($emailNorm);
|
||||
}
|
||||
AuthAttempts::record('register_fail', $username);
|
||||
return ['ok' => false, 'error' => 'already_registered'];
|
||||
}
|
||||
@@ -49,21 +57,117 @@ final class AuthRegistration {
|
||||
'pending',
|
||||
]);
|
||||
$userId = (int) $pdo->lastInsertId();
|
||||
$sent = self::issueVerifyToken($pdo, $userId, $emailNorm);
|
||||
if (!$sent['ok']) {
|
||||
return $sent;
|
||||
}
|
||||
AuthAttempts::record('register_ok', $username);
|
||||
return [
|
||||
'ok' => true,
|
||||
'expires_at' => $sent['expires_at'],
|
||||
'ttl_seconds' => self::verifyEmailTtlSeconds(),
|
||||
'email' => $emailNorm,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{ok:bool,error?:string,expires_at?:string,ttl_seconds?:int,email?:string} */
|
||||
public static function resendVerification(string $email): array {
|
||||
self::ensureSchema();
|
||||
$emailNorm = self::normalizeEmail($email);
|
||||
if ($emailNorm === '' || !filter_var($emailNorm, FILTER_VALIDATE_EMAIL)) {
|
||||
return ['ok' => false, 'error' => 'invalid_email'];
|
||||
}
|
||||
if (AuthAttempts::isRateLimited('resend:' . $emailNorm)) {
|
||||
return ['ok' => false, 'error' => 'rate_limited'];
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
self::purgeStalePending($pdo);
|
||||
$st = $pdo->prepare(
|
||||
"SELECT id FROM users WHERE email_normalized = ? AND status = 'pending' AND email_verified_at IS NULL LIMIT 1"
|
||||
);
|
||||
$st->execute([$emailNorm]);
|
||||
$userId = (int) $st->fetchColumn();
|
||||
if ($userId <= 0) {
|
||||
return ['ok' => false, 'error' => 'not_pending'];
|
||||
}
|
||||
$pdo->prepare(
|
||||
"UPDATE auth_tokens SET used_at = ? WHERE user_id = ? AND purpose = 'verify_email' AND used_at IS NULL"
|
||||
)->execute([date('Y-m-d H:i:s'), $userId]);
|
||||
$sent = self::issueVerifyToken($pdo, $userId, $emailNorm);
|
||||
if (!$sent['ok']) {
|
||||
return $sent;
|
||||
}
|
||||
AuthAttempts::record('register_resend', $emailNorm);
|
||||
return [
|
||||
'ok' => true,
|
||||
'expires_at' => $sent['expires_at'],
|
||||
'ttl_seconds' => self::verifyEmailTtlSeconds(),
|
||||
'email' => $emailNorm,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending verification window for countdown UI.
|
||||
*
|
||||
* @return array{ok:bool,error?:string,expires_at?:string,ttl_seconds?:int,email?:string}|null
|
||||
*/
|
||||
public static function pendingVerification(string $email): ?array {
|
||||
self::ensureSchema();
|
||||
$emailNorm = self::normalizeEmail($email);
|
||||
if ($emailNorm === '') {
|
||||
return null;
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
$st = $pdo->prepare(
|
||||
"SELECT u.id FROM users u
|
||||
WHERE u.email_normalized = ? AND u.status = 'pending' AND u.email_verified_at IS NULL
|
||||
LIMIT 1"
|
||||
);
|
||||
$st->execute([$emailNorm]);
|
||||
$userId = (int) $st->fetchColumn();
|
||||
if ($userId <= 0) {
|
||||
return null;
|
||||
}
|
||||
$tok = $pdo->prepare(
|
||||
"SELECT expires_at FROM auth_tokens
|
||||
WHERE user_id = ? AND purpose = 'verify_email' AND used_at IS NULL
|
||||
ORDER BY id DESC LIMIT 1"
|
||||
);
|
||||
$tok->execute([$userId]);
|
||||
$expiresAt = (string) ($tok->fetchColumn() ?: '');
|
||||
if ($expiresAt === '') {
|
||||
return null;
|
||||
}
|
||||
$exp = strtotime($expiresAt);
|
||||
if ($exp !== false && $exp < time()) {
|
||||
return ['ok' => false, 'error' => 'expired_token', 'email' => $emailNorm];
|
||||
}
|
||||
return [
|
||||
'ok' => true,
|
||||
'expires_at' => $expiresAt,
|
||||
'ttl_seconds' => self::verifyEmailTtlSeconds(),
|
||||
'email' => $emailNorm,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{ok:bool,error?:string,expires_at?:string} */
|
||||
private static function issueVerifyToken(\PDO $pdo, int $userId, string $emailNorm): array {
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$tokenHash = hash('sha256', $token);
|
||||
$expires = date('Y-m-d H:i:s', time() + self::TOKEN_TTL_S);
|
||||
$ttl = self::verifyEmailTtlSeconds();
|
||||
$expires = date('Y-m-d H:i:s', time() + $ttl);
|
||||
$pdo->prepare(
|
||||
'INSERT INTO auth_tokens (user_id, purpose, token_hash, email_normalized, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)'
|
||||
)->execute([$userId, 'verify_email', $tokenHash, $emailNorm, $expires, date('Y-m-d H:i:s')]);
|
||||
$base = rtrim((string) cfg('base_path', ''), '/');
|
||||
$origin = rtrim((string) cfg('public_origin', 'https://apps.f0xx.org'), '/');
|
||||
$verifyUrl = $origin . $base . '/verify-email?token=' . urlencode($token);
|
||||
if (!AuthMailer::sendVerifyEmail($emailNorm, $verifyUrl)) {
|
||||
$verifyUrl = $origin . Auth::authUrl('/verify-email') . '?token=' . urlencode($token);
|
||||
$ttlLabel = self::formatTtlLabel($ttl);
|
||||
if (!AuthMailer::sendVerifyEmail($emailNorm, $verifyUrl, $ttlLabel)) {
|
||||
error_log('AuthRegistration: verify mail failed for ' . $emailNorm);
|
||||
return ['ok' => false, 'error' => 'mail_failed'];
|
||||
}
|
||||
AuthAttempts::record('register_ok', $username);
|
||||
return ['ok' => true];
|
||||
return ['ok' => true, 'expires_at' => $expires];
|
||||
}
|
||||
|
||||
/** @return array{ok:bool,error?:string} */
|
||||
@@ -96,7 +200,39 @@ final class AuthRegistration {
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
private static function formatTtlLabel(int $ttlSeconds): string {
|
||||
if ($ttlSeconds >= 86400 && $ttlSeconds % 86400 === 0) {
|
||||
$days = (int) ($ttlSeconds / 86400);
|
||||
return $days === 1 ? '24h' : $days . ' days';
|
||||
}
|
||||
if ($ttlSeconds >= 3600 && $ttlSeconds % 3600 === 0) {
|
||||
$hours = (int) ($ttlSeconds / 3600);
|
||||
return $hours === 1 ? '1 hour' : $hours . ' hours';
|
||||
}
|
||||
$mins = max(1, (int) ceil($ttlSeconds / 60));
|
||||
return $mins === 1 ? '1 minute' : $mins . ' minutes';
|
||||
}
|
||||
|
||||
private static function normalizeEmail(string $email): string {
|
||||
return strtolower(trim($email));
|
||||
}
|
||||
|
||||
/** Drop unverified accounts started more than verify TTL ago. */
|
||||
private static function purgeStalePending(\PDO $pdo): void {
|
||||
$cutoff = date('Y-m-d H:i:s', time() - self::verifyEmailTtlSeconds());
|
||||
$st = $pdo->prepare(
|
||||
"SELECT id FROM users WHERE status = 'pending' AND email_verified_at IS NULL
|
||||
AND (created_at IS NULL OR created_at < ?)"
|
||||
);
|
||||
$st->execute([$cutoff]);
|
||||
$ids = $st->fetchAll(\PDO::FETCH_COLUMN);
|
||||
foreach ($ids as $id) {
|
||||
$uid = (int) $id;
|
||||
if ($uid <= 0) {
|
||||
continue;
|
||||
}
|
||||
$pdo->prepare('DELETE FROM auth_tokens WHERE user_id = ?')->execute([$uid]);
|
||||
$pdo->prepare('DELETE FROM users WHERE id = ? AND status = ?')->execute([$uid, 'pending']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user