Files

199 lines
8.0 KiB
PHP

<?php
namespace Leantime\Domain\Oidc\Controllers;
use Illuminate\Support\Facades\RateLimiter;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Http\IncomingRequest;
use Leantime\Domain\Auth\Repositories\AccessTokenRepository;
use Leantime\Domain\Oidc\Services\OidcMobileCode;
use Leantime\Domain\Plugins\Services\Plugins;
use Leantime\Domain\Users\Repositories\Users as UserRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
/**
* Mobile SSO bridge — the code→token exchange.
*
* POST /oidc/mobile/exchange. Public (no session/cookie): the validated,
* single-use one-time code IS the authorization. See OidcMobileCode.
*
* This route must be allow-listed in AuthCheck::$publicActions as 'oidc.mobile'.
*/
class Mobile extends Controller
{
/** Per-IP cap on exchange attempts per minute — throttles code/verifier guessing. */
private const MAX_ATTEMPTS_PER_MINUTE = 10;
/** Mobile SSO bearer lifetime. Deliberately NOT non-expiring; a lost device's
* token self-expires, and it can be revoked early via AccessTokenRepository::deleteToken. */
private const TOKEN_TTL_DAYS = 30;
private OidcMobileCode $codes;
private AccessTokenRepository $tokens;
private UserRepository $userRepo;
private IncomingRequest $request;
private Plugins $plugins;
public function init(
OidcMobileCode $codes,
AccessTokenRepository $tokens,
UserRepository $userRepo,
IncomingRequest $request,
Plugins $plugins
): void {
$this->codes = $codes;
$this->tokens = $tokens;
$this->userRepo = $userRepo;
$this->request = $request;
$this->plugins = $plugins;
}
/**
* Exchange a one-time code for a bearer token.
*
* Reached at /oidc/mobile/exchange (segment[2] "exchange" → this method).
* POST only — GET is refused so secrets can't be exchanged from a query
* string (URLs land in access logs; POST bodies don't).
*/
public function exchange(array $params): Response
{
// Mobile auth is an AdvancedAuth capability. The OIDC bridge lives in core,
// so — unlike getToken, which lives in the plugin and is gated by absence —
// it must ask explicitly whether AdvancedAuth is installed before minting.
// Without it, treat the endpoint as nonexistent (404) so an unlicensed
// instance reveals nothing. This is the enforcement boundary: even a direct
// caller that never touched /status is refused here.
if (! $this->plugins->isEnabled('AdvancedAuth')) {
return new JsonResponse(['error' => 'not_found'], 404);
}
// Frontcontroller resolves methods by URL segment regardless of verb;
// enforce POST here so `?code=...&code_verifier=...` on a GET is
// rejected before we touch the code store.
if ($this->request->getMethod() !== 'POST') {
return new JsonResponse(['error' => 'method_not_allowed'], 405, ['Allow' => 'POST']);
}
// Per-IP throttle: this endpoint is public (allow-listed in AuthCheck) and
// returns distinct 400/401 codes, so an unauthenticated caller could probe
// codes/verifiers. Even with <=60s single-use codes, cap the attempt rate.
$throttleKey = 'oidc.mobile.exchange:'.$this->request->ip();
if (RateLimiter::tooManyAttempts($throttleKey, self::MAX_ATTEMPTS_PER_MINUTE)) {
return new JsonResponse(
['error' => 'too_many_requests'],
429,
['Retry-After' => (string) RateLimiter::availableIn($throttleKey)]
);
}
RateLimiter::hit($throttleKey, 60);
// Secrets are read from the POST BODY only (->post()), never the query
// string — URLs land in access logs, request bodies don't. A ?code=... in
// the URL is ignored; $params (the merged bag) is intentionally not used.
$code = $this->bodyParam('code');
if ($code === '') {
return new JsonResponse(['error' => 'missing_code'], 400);
}
// Peek (non-destructive) so a bad verifier from a scheme-hijacker
// can't burn the code before the legitimate app's exchange arrives.
// The code is only consumed after PKCE + user validation succeed.
$data = $this->codes->peekCode($code);
if ($data === null) {
// Unknown, expired, or already-used code — all indistinguishable to
// the caller on purpose.
return new JsonResponse(['error' => 'invalid_code'], 401);
}
// PKCE: the code was bound to a code_challenge at login. Require the
// matching verifier so a code intercepted from the app-scheme redirect
// is useless without the secret the app kept and never put in a URL.
$verifier = $this->bodyParam('code_verifier');
if (! $this->pkceMatches($data['challenge'] ?? null, $verifier)) {
return new JsonResponse(['error' => 'invalid_verifier'], 401);
}
// The code came from a completed OIDC auth (+ verified PKCE), so minting
// for this user is authorized. Confirm the user still exists FIRST — if
// they were deleted between callback and exchange, minting would leave an
// orphaned token row. Use the repository directly (the AccessToken
// service gates on an active session, which this cookieless request
// doesn't have).
$userId = (int) $data['userId'];
$user = $this->userRepo->getUser($userId);
if (! is_array($user) || empty($user)) {
return new JsonResponse(['error' => 'invalid_user'], 401);
}
// All checks passed — atomically burn the code. consumeCode() returns
// false if a concurrent exchange already consumed it, so only the winner
// of that race mints (no double-mint from one single-use code).
if (! $this->codes->consumeCode($code)) {
return new JsonResponse(['error' => 'invalid_code'], 401);
}
// Mint a 'mobile-sso' bearer with an explicit TTL (see TOKEN_TTL_DAYS) so
// it isn't valid forever. Scope stays ['*'] — the mobile app is a full
// API client, same as the password-login token — but the TTL plus
// AccessTokenRepository::deleteToken give expiry and revocation.
$token = $this->tokens->createToken(
$userId,
'mobile-sso',
['*'],
now()->addDays(self::TOKEN_TTL_DAYS)
);
return new JsonResponse([
'token' => $token['token'],
'user' => $this->safeUser($user, $userId),
]);
}
/**
* Read a request value from the POST body ONLY (never the query string), so
* the one-time code + verifier can't be supplied via a logged URL.
*/
private function bodyParam(string $key): string
{
$value = $this->request->post($key);
return is_string($value) ? trim($value) : '';
}
/**
* PKCE S256 check: base64url(sha256(verifier)) must equal the stored
* challenge. Every mobile login sends a challenge, so a code with no bound
* challenge — or a missing/mismatched verifier — is rejected.
*/
private function pkceMatches(?string $challenge, string $verifier): bool
{
if (empty($challenge) || $verifier === '') {
return false;
}
$computed = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
return hash_equals($challenge, $computed);
}
/**
* Return ONLY safe identity fields. Never the password hash, 2FA seed, or
* session/reset tokens (cf. the users.getUser credential-dump incident) —
* the full zp_user row carries all of those.
*/
private function safeUser(array $user, int $userId): array
{
return [
'id' => $userId,
'firstname' => $user['firstname'] ?? '',
'lastname' => $user['lastname'] ?? '',
'username' => $user['username'] ?? '',
];
}
}