credentials($request); if ($this->users->findByEmail($email) !== null) { throw new ApiException('That email address is already registered.', 409); } $user = $this->users->create($email, password_hash($password, PASSWORD_DEFAULT)); // Best effort: a failed send must not fail registration — the user can // resend from their profile. try { $this->verifier->sendVerification($user); } catch (MailException $e) { error_log('Verification email failed for user ' . $user['id'] . ': ' . $e->getMessage()); } return $this->json($response, $this->session->forUser($user), 201); } /** * POST /api/auth/login */ public function login(Request $request, Response $response): Response { [$email, $password] = $this->credentials($request); $user = $this->users->findByEmail($email); if ($user === null || !password_verify($password, $user['password_hash'])) { // Same message either way so we don't reveal which emails are registered. throw new ApiException('Invalid email or password.', 401); } return $this->json($response, $this->session->forUser($user)); } /** * POST /api/auth/magic-link (public) * * Emails a one-time login link for the given address. Always responds the * same way so registered addresses can't be enumerated; a link is only sent * when the account exists and hasn't been sent one in the last minute. * Opening the link signs the user in and verifies the address. */ public function requestLoginLink(Request $request, Response $response): Response { $body = (array) ($request->getParsedBody() ?? []); $email = is_string($body['email'] ?? null) ? mb_strtolower(trim($body['email'])) : ''; if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > self::EMAIL_MAX) { throw new ValidationException(['email' => ['Enter a valid email address.']]); } $user = $this->users->findByEmail($email); if ($user !== null && !$this->recentlyEmailed($user)) { try { $this->verifier->sendLoginLink($user); } catch (MailException $e) { error_log('Login link failed for user ' . $user['id'] . ': ' . $e->getMessage()); } } return $this->json($response, [ 'message' => 'If that address has an account, a login link is on its way.', ], 202); } /** * GET /api/me (requires AuthMiddleware) */ public function me(Request $request, Response $response): Response { return $this->json($response, ['user' => $this->session->present($this->user($request))]); } /** * @param array{verification_email_sent_at?: string|null} $user */ private function recentlyEmailed(array $user): bool { $lastSent = $user['verification_email_sent_at'] ?? null; return $lastSent !== null && (time() - (int) strtotime($lastSent)) < EmailVerifier::RESEND_INTERVAL_SECONDS; } /** * Extract and validate the email/password pair from the request body. * * @return array{0: string, 1: string} Normalised email and raw password. */ private function credentials(Request $request): array { $body = (array) ($request->getParsedBody() ?? []); $email = is_string($body['email'] ?? null) ? trim($body['email']) : ''; $password = is_string($body['password'] ?? null) ? $body['password'] : ''; $errors = []; if ($email === '') { $errors['email'][] = 'Email is required.'; } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors['email'][] = 'Email must be a valid address.'; } elseif (strlen($email) > self::EMAIL_MAX) { $errors['email'][] = sprintf('Email must be at most %d characters.', self::EMAIL_MAX); } if ($password === '') { $errors['password'][] = 'Password is required.'; } elseif (strlen($password) < self::PASSWORD_MIN) { $errors['password'][] = sprintf('Password must be at least %d characters.', self::PASSWORD_MIN); } elseif (strlen($password) > self::PASSWORD_MAX) { $errors['password'][] = sprintf('Password must be at most %d characters.', self::PASSWORD_MAX); } if ($errors !== []) { throw new ValidationException($errors); } return [mb_strtolower($email), $password]; } }