OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
198
app/Domain/Oidc/Controllers/Mobile.php
Normal file
198
app/Domain/Oidc/Controllers/Mobile.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?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'] ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user