OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
645
app/Domain/Oidc/Services/Oidc.php
Normal file
645
app/Domain/Oidc/Services/Oidc.php
Normal file
@@ -0,0 +1,645 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Oidc\Services;
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Domain\Auth\Services\Auth as AuthService;
|
||||
use Leantime\Domain\Users\Repositories\Users as UserRepository;
|
||||
use OpenSSLAsymmetricKey;
|
||||
use phpseclib3\Crypt\PublicKeyLoader;
|
||||
use phpseclib3\Math\BigInteger;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class Oidc
|
||||
{
|
||||
private Environment $config;
|
||||
|
||||
private AuthService $authService;
|
||||
|
||||
public UserRepository $userRepo;
|
||||
|
||||
private bool $configLoaded = false;
|
||||
|
||||
private string $providerUrl;
|
||||
|
||||
private string $autoDiscoverUrl;
|
||||
|
||||
private string $clientId;
|
||||
|
||||
private string $clientSecret;
|
||||
|
||||
private string $authUrl;
|
||||
|
||||
private string $tokenUrl;
|
||||
|
||||
private string $jwksUrl;
|
||||
|
||||
private string $userInfoUrl;
|
||||
|
||||
private string $certificateString;
|
||||
|
||||
private string $certificateFile;
|
||||
|
||||
private string $scopes;
|
||||
|
||||
private bool $createUser;
|
||||
|
||||
private int $defaultRole; // 20 == editor
|
||||
|
||||
private string $fieldEmail;
|
||||
|
||||
private string $fieldFirstName;
|
||||
|
||||
private string $fieldLastName;
|
||||
|
||||
private string $fieldPhone;
|
||||
|
||||
private string $fieldJobtitle;
|
||||
|
||||
private string $fieldJoblevel;
|
||||
|
||||
private string $fieldDepartment;
|
||||
|
||||
private Language $language;
|
||||
|
||||
public function __construct(
|
||||
Environment $config,
|
||||
Language $language,
|
||||
AuthService $authService,
|
||||
UserRepository $userRepo
|
||||
) {
|
||||
$this->config = $config;
|
||||
$this->authService = $authService;
|
||||
$this->userRepo = $userRepo;
|
||||
$this->language = $language;
|
||||
|
||||
$providerUrl = $this->config->get('oidcProviderUrl');
|
||||
|
||||
$this->providerUrl = ! empty($providerUrl) ? $this->trimTrailingSlash($providerUrl) : $providerUrl;
|
||||
$this->autoDiscoverUrl = $this->config->get('oidcAutoDiscoverUrl', '');
|
||||
$this->clientId = $this->config->get('oidcClientId', '');
|
||||
$this->clientSecret = $this->config->get('oidcClientSecret', '');
|
||||
$this->authUrl = $this->config->get('oidcAuthUrl', '');
|
||||
$this->tokenUrl = $this->config->get('oidcTokenUrl', '');
|
||||
$this->jwksUrl = $this->config->get('oidcJwksUrl', '');
|
||||
$this->userInfoUrl = $this->config->get('oidcUserInfoUrl', '');
|
||||
$this->certificateString = $this->config->get('oidcCertificateString', '');
|
||||
$this->certificateFile = $this->config->get('oidcCertificateFile', '');
|
||||
$this->scopes = $this->config->get('oidcScopes', '');
|
||||
$this->createUser = $this->config->get('oidcCreateUser', false);
|
||||
$this->defaultRole = $this->config->get('oidcDefaultRole', 20);
|
||||
|
||||
$this->fieldEmail = $this->config->get('oidcFieldEmail', '');
|
||||
$this->fieldFirstName = $this->config->get('oidcFieldFirstName', '');
|
||||
$this->fieldLastName = $this->config->get('oidcFieldLastName', '');
|
||||
$this->fieldPhone = $this->config->get('oidcFieldPhone', '');
|
||||
$this->fieldJobtitle = $this->config->get('oidcFieldJobtitle', '');
|
||||
$this->fieldJoblevel = $this->config->get('oidcFieldJoblevel', '');
|
||||
$this->fieldDepartment = $this->config->get('oidcFieldDepartment', '');
|
||||
}
|
||||
|
||||
private function trimTrailingSlash(string $str): string
|
||||
{
|
||||
$almost = strlen($str) - 1;
|
||||
if ($str[$almost] === '/') {
|
||||
return substr($str, 0, $almost);
|
||||
}
|
||||
|
||||
return $str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the OIDC authorization redirect URL and stores the CSRF state in
|
||||
* the session. Returns false when the provider's auth endpoint cannot be
|
||||
* resolved (callers treat a falsy result as "OIDC unavailable").
|
||||
*
|
||||
* @throws GuzzleException
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function buildLoginUrl(bool $mobile = false, string $mobileRedirect = '', string $codeChallenge = ''): string|false
|
||||
{
|
||||
|
||||
if ($this->getAuthUrl()) {
|
||||
|
||||
$state = $this->generateState();
|
||||
session(['oidc.state' => $state]);
|
||||
|
||||
// Mobile-brokered SSO: remember that this flow began in the app + where
|
||||
// to hand the result back, so the callback mints a token + one-time
|
||||
// code instead of a web session. Only a whitelisted app scheme is
|
||||
// honored; anything else falls through to the normal web flow. The
|
||||
// flags ride the session across the provider round-trip, same as
|
||||
// oidc.state.
|
||||
if ($mobile && $this->isAllowedMobileRedirect($mobileRedirect)) {
|
||||
session([
|
||||
'oidc.mobile' => true,
|
||||
'oidc.mobileRedirect' => $mobileRedirect,
|
||||
'oidc.mobileChallenge' => $codeChallenge,
|
||||
]);
|
||||
} else {
|
||||
session()->forget(['oidc.mobile', 'oidc.mobileRedirect', 'oidc.mobileChallenge']);
|
||||
}
|
||||
|
||||
return $this->getAuthUrl().'?'.http_build_query([
|
||||
'client_id' => $this->clientId,
|
||||
'redirect_uri' => $this->buildRedirectUrl(),
|
||||
'response_type' => 'code',
|
||||
'scope' => $this->scopes,
|
||||
'state' => $state,
|
||||
]);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
private function getAuthUrl(): string|false
|
||||
{
|
||||
if (! empty($this->authUrl || $this->loadEndpoints())) {
|
||||
return $this->authUrl;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
public function callback(string $code, string $state): Response
|
||||
{
|
||||
if (! $this->verifyState($state)) {
|
||||
$this->displayError('oidc.error.invalidState');
|
||||
}
|
||||
|
||||
$tokens = $this->requestTokens($code);
|
||||
|
||||
if (! is_array($tokens)) {
|
||||
$this->displayError($tokens);
|
||||
}
|
||||
|
||||
$userInfo = null;
|
||||
// echo '<pre>' . print_r($tokens, true) . '</pre>';
|
||||
if (isset($tokens['id_token'])) {
|
||||
$userInfo = $this->decodeJWT($tokens['id_token']);
|
||||
} elseif (isset($tokens['access_token'])) {
|
||||
// fallback to OAuth userinfo endpoint
|
||||
$userInfo = $this->pollUserInfo($tokens['access_token']);
|
||||
} else {
|
||||
$this->displayError('oidc.error.unsupportedToken');
|
||||
}
|
||||
|
||||
if ($userInfo == null) {
|
||||
// TODO: invalid token
|
||||
$this->displayError('oidc.error.invalidToken');
|
||||
}
|
||||
|
||||
return $this->login($userInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
private function pollUserInfo(string $token): array
|
||||
{
|
||||
return $this->getMultiUrl($this->userInfoUrl, $token);
|
||||
}
|
||||
|
||||
private function login(array $userInfo): Response
|
||||
{
|
||||
// Capture the mobile-origin flags up front — before setUserSession may
|
||||
// rebuild the session below — so the brokered redirect survives it.
|
||||
$isMobile = (bool) session('oidc.mobile');
|
||||
$mobileRedirect = (string) session('oidc.mobileRedirect');
|
||||
$mobileChallenge = (string) session('oidc.mobileChallenge');
|
||||
|
||||
$userName = $this->readMultilayerKey($userInfo, $this->fieldEmail);
|
||||
|
||||
if (! $userName) {
|
||||
$this->displayError('oidc.error.emailUnavailable');
|
||||
}
|
||||
|
||||
$user = $this->userRepo->getUserByEmail($userName);
|
||||
|
||||
if ($user === false) {
|
||||
if ($this->createUser) {
|
||||
// create user if it doesn't exist yet
|
||||
$userArray = [
|
||||
'firstname' => $this->readMultilayerKey($userInfo, $this->fieldFirstName),
|
||||
'lastname' => $this->readMultilayerKey($userInfo, $this->fieldLastName),
|
||||
'phone' => $this->readMultilayerKey($userInfo, $this->fieldPhone),
|
||||
'jobTitle' => $this->readMultilayerKey($userInfo, $this->fieldJobtitle),
|
||||
'jobLevel' => $this->readMultilayerKey($userInfo, $this->fieldJoblevel),
|
||||
'department' => $this->readMultilayerKey($userInfo, $this->fieldDepartment),
|
||||
'user' => $userName,
|
||||
'role' => $this->defaultRole,
|
||||
'password' => '',
|
||||
'clientId' => '',
|
||||
'source' => 'oidc',
|
||||
'status' => 'a',
|
||||
];
|
||||
|
||||
$userId = $this->userRepo->addUser($userArray);
|
||||
|
||||
if ($userId !== false) {
|
||||
$user = $this->userRepo->getUserByEmail($userName);
|
||||
} else {
|
||||
throw new \Exception('OIDC user creation failed.');
|
||||
}
|
||||
} else {
|
||||
$this->displayError('oidc.error.user_not_found');
|
||||
}
|
||||
} else {
|
||||
// update user if it exists
|
||||
$user['user'] = $user['username'];
|
||||
$user['firstname'] = $this->readMultilayerKey($userInfo, $this->fieldFirstName) != '' ? $this->readMultilayerKey($userInfo, $this->fieldFirstName) : $user['firstname'];
|
||||
$user['lastname'] = $this->readMultilayerKey($userInfo, $this->fieldLastName) != '' ? $this->readMultilayerKey($userInfo, $this->fieldLastName) : $user['lastname'];
|
||||
$user['phone'] = $this->readMultilayerKey($userInfo, $this->fieldPhone) != '' ? $this->readMultilayerKey($userInfo, $this->fieldPhone) : $user['phone'];
|
||||
$user['jobTitle'] = $this->readMultilayerKey($userInfo, $this->fieldJobtitle) != '' ? $this->readMultilayerKey($userInfo, $this->fieldJobtitle) : $user['jobTitle'];
|
||||
$user['jobLevel'] = $this->readMultilayerKey($userInfo, $this->fieldJoblevel) != '' ? $this->readMultilayerKey($userInfo, $this->fieldJoblevel) : $user['jobLevel'];
|
||||
$user['department'] = $this->readMultilayerKey($userInfo, $this->fieldDepartment) != '' ? $this->readMultilayerKey($userInfo, $this->fieldDepartment) : $user['department'];
|
||||
|
||||
$user['role'] = $this->getUserRole($userInfo, $user);
|
||||
|
||||
// $user carries the full zp_user row from getUserByEmail(), including the stored
|
||||
// bcrypt password hash and session/reset tokens. Passing those back into editUser()
|
||||
// would re-hash the already-hashed password on every login and clobber local
|
||||
// credentials, so drop them — OIDC never manages the local password.
|
||||
unset($user['password'], $user['session'], $user['pwReset'], $user['pwResetExpiration']);
|
||||
|
||||
$this->userRepo->editUser($user, $user['id']);
|
||||
|
||||
// Get updated user
|
||||
$user = $this->userRepo->getUserByEmail($userName);
|
||||
}
|
||||
|
||||
$this->authService->setUserSession($user, false);
|
||||
|
||||
// Mobile-brokered SSO: instead of landing on the web dashboard, mint a
|
||||
// single-use one-time code bound to this user and hand it to the app via
|
||||
// the app-scheme redirect. The app exchanges it for a bearer token at
|
||||
// /oidc/mobile/exchange. The token itself never travels in the URL.
|
||||
if ($isMobile && $this->isAllowedMobileRedirect($mobileRedirect)) {
|
||||
session()->forget(['oidc.mobile', 'oidc.mobileRedirect', 'oidc.mobileChallenge']);
|
||||
|
||||
$code = app(\Leantime\Domain\Oidc\Services\OidcMobileCode::class)
|
||||
->createCode((int) $user['id'], $mobileChallenge !== '' ? $mobileChallenge : null);
|
||||
|
||||
$separator = str_contains($mobileRedirect, '?') ? '&' : '?';
|
||||
|
||||
return Frontcontroller::redirect($mobileRedirect.$separator.'code='.urlencode($code), 302);
|
||||
}
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.'/dashboard/home');
|
||||
}
|
||||
|
||||
/**
|
||||
* Only app-scheme redirects are honored for mobile brokering — never an
|
||||
* arbitrary http(s) URL, which would let a crafted login link redirect the
|
||||
* one-time code to an attacker.
|
||||
*/
|
||||
private function isAllowedMobileRedirect(string $redirect): bool
|
||||
{
|
||||
// Also reject whitespace/control chars — the redirect ultimately
|
||||
// lands in a Location header, where Symfony rejects control chars
|
||||
// (500) and header-injection payloads embed CR/LF.
|
||||
if ($redirect !== trim($redirect)) {
|
||||
return false;
|
||||
}
|
||||
if (preg_match('/[\p{C}\s]/u', $redirect) === 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return str_starts_with($redirect, 'leantime://');
|
||||
}
|
||||
|
||||
private function getUserRole(array $userInfo, array $user = []): string
|
||||
{
|
||||
return $user['role'] ?? 'readonly';
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
private function requestTokens(string $code): array|string
|
||||
{
|
||||
$httpClient = Http::withoutVerifying();
|
||||
|
||||
// Add proper client authentication headers
|
||||
$response = $httpClient->asForm()->post($this->getTokenUrl(), [
|
||||
'client_id' => $this->clientId,
|
||||
'client_secret' => $this->clientSecret,
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'redirect_uri' => $this->buildRedirectUrl(),
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('OIDC Token Request Failed: '.$response->body());
|
||||
throw new \RuntimeException('Failed to retrieve tokens: '.$response->body());
|
||||
}
|
||||
|
||||
$headerArray = [];
|
||||
foreach ($response->getHeaders() as $header => $values) {
|
||||
$headerArray[strtolower($header)] = $values;
|
||||
}
|
||||
$contentType = array_pop($headerArray['content-type']);
|
||||
|
||||
switch ($contentType) {
|
||||
case 'application/x-www-form-urlencoded; charset=utf-8':
|
||||
case 'application/x-www-form-urlencoded':
|
||||
$result = [];
|
||||
parse_str($response->getBody()->getContents(), $result);
|
||||
|
||||
return $result;
|
||||
default:
|
||||
return json_decode($response->getBody()->getContents(), true);
|
||||
}
|
||||
}
|
||||
|
||||
private function readMultilayerKey(array $topic, string $key): string
|
||||
{
|
||||
$keyList = explode('.', $key);
|
||||
$layer = $topic;
|
||||
foreach ($keyList as $layerKey) {
|
||||
if (! isset($layer[$layerKey])) {
|
||||
return '';
|
||||
}
|
||||
$layer = $layer[$layerKey];
|
||||
}
|
||||
|
||||
return $layer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
private function decodeJWT(string $jwt): ?array
|
||||
{
|
||||
[$header, $content, $signature] = explode('.', $jwt);
|
||||
|
||||
$tokenData = json_decode($this->decodeBase64Url($content), true);
|
||||
|
||||
if ($this->trimTrailingSlash($tokenData['iss']) != $this->providerUrl) {
|
||||
$this->displayError('oidc.error.providerMismatch', $tokenData['iss'], $this->providerUrl);
|
||||
}
|
||||
|
||||
$headerData = json_decode($this->decodeBase64Url($header), true);
|
||||
|
||||
if (! isset($headerData['kid'])) {
|
||||
throw new \RuntimeException('JWT token could not be decoded');
|
||||
}
|
||||
|
||||
$key = $this->getPublicKey($headerData['kid']);
|
||||
|
||||
if ($key === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $header.'.'.$content;
|
||||
|
||||
if (openssl_verify($data, $this->decodeBase64Url($signature), $key, $this->getAlgorythm($header)) === 1) {
|
||||
return $tokenData;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function getAlgorythm(string $header): int
|
||||
{
|
||||
$algorythmName = json_decode($this->decodeBase64Url($header), true)['alg'];
|
||||
|
||||
$map = [
|
||||
'RS256' => OPENSSL_ALGO_SHA256,
|
||||
];
|
||||
|
||||
if (! isset($map[$algorythmName])) {
|
||||
$this->displayError('oidc.error.unsupportedAlgorythm', $algorythmName);
|
||||
}
|
||||
|
||||
return $map[$algorythmName];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
private function getPublicKey(string $kid): OpenSSLAsymmetricKey|false
|
||||
{
|
||||
if ($this->certificateString) {
|
||||
return openssl_pkey_get_public($this->certificateString);
|
||||
}
|
||||
if ($this->certificateFile) {
|
||||
return openssl_pkey_get_public(file_get_contents($this->certificateFile));
|
||||
}
|
||||
|
||||
$httpClient = Http::withoutVerifying();
|
||||
// AUTH HEADER?
|
||||
$response = $httpClient->get($this->getJwksUrl()); // https://cloud.lukas-sieper.de/apps/oidc/jwks
|
||||
$keys = json_decode($response->getBody()->getContents(), true);
|
||||
if (isset($keys['keys'])) {
|
||||
$keys = $keys['keys'];
|
||||
}
|
||||
|
||||
foreach ($keys as $possibleKid => $key) {
|
||||
$keySource = '';
|
||||
|
||||
if (is_string($possibleKid)) {
|
||||
// old format like https://www.googleapis.com/oauth2/v1/certs
|
||||
if ($possibleKid == $kid) {
|
||||
$keySource = $key;
|
||||
}
|
||||
} elseif (! isset($kid[0]) || $kid == $key['kid']) {
|
||||
$keySource = '';
|
||||
if (isset($key['x5c'])) {
|
||||
$keySource = '-----BEGIN CERTIFICATE-----'.PHP_EOL.chunk_split($key['x5c'][0], 64, PHP_EOL).'-----END CERTIFICATE-----';
|
||||
} elseif (isset($key['n']) && isset($key['e'])) {
|
||||
// Parse the public key from n and e
|
||||
$modulus = $this->base64UrlDecode($key['n']);
|
||||
$exponent = $this->base64UrlDecode($key['e']);
|
||||
$keySource = $this->createPublicKey($modulus, exponent: $exponent);
|
||||
} else {
|
||||
$this->displayError('oidc.error.unsupportedKeyFormat');
|
||||
}
|
||||
}
|
||||
|
||||
if ($keySource) {
|
||||
return openssl_pkey_get_public($keySource);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function base64UrlDecode(string $input): string
|
||||
{
|
||||
$remainder = strlen($input) % 4;
|
||||
if ($remainder) {
|
||||
$padlen = 4 - $remainder;
|
||||
$input .= str_repeat('=', $padlen);
|
||||
}
|
||||
|
||||
return base64_decode(strtr($input, '-_', '+/'));
|
||||
}
|
||||
|
||||
// key to PEM
|
||||
private function createPublicKey(string $modulus, string $exponent): string
|
||||
{
|
||||
|
||||
$rsa = PublicKeyLoader::load([
|
||||
'e' => new BigInteger($exponent, 256),
|
||||
'n' => new BigInteger($modulus, 256),
|
||||
]);
|
||||
|
||||
return $rsa->__toString();
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
private function getJwksUrl(): string
|
||||
{
|
||||
$this->loadEndpoints();
|
||||
|
||||
return $this->jwksUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
private function getTokenUrl(): string|false
|
||||
{
|
||||
if (! empty($this->tokenUrl || $this->loadEndpoints())) {
|
||||
return $this->tokenUrl;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
private function loadEndpoints(): bool
|
||||
{
|
||||
if ($this->configLoaded) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->authUrl && $this->tokenUrl && $this->jwksUrl) {
|
||||
$this->configLoaded = true;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$httpClient = Http::withoutVerifying();
|
||||
try {
|
||||
// $uri = strlen() ? $this->autoDiscoverUrl : $this->providerUrl;
|
||||
$uri = empty($this->autoDiscoverUrl) ? $this->providerUrl : $this->autoDiscoverUrl;
|
||||
$response = $httpClient->get($uri.'/.well-known/openid-configuration');
|
||||
$endpoints = $response->collect()->toArray();
|
||||
} catch (\Exception $e) {
|
||||
|
||||
Log::error('OIDC: '.$e->getMessage());
|
||||
Log::error($e);
|
||||
|
||||
return false;
|
||||
}
|
||||
// load all not yet defined endpoints from well-known configuration
|
||||
|
||||
if (! $this->authUrl) {
|
||||
$this->authUrl = $endpoints['authorization_endpoint'];
|
||||
}
|
||||
|
||||
if (! $this->tokenUrl) {
|
||||
$this->tokenUrl = $endpoints['token_endpoint'];
|
||||
}
|
||||
|
||||
if (! $this->jwksUrl) {
|
||||
$this->jwksUrl = $endpoints['jwks_uri'];
|
||||
}
|
||||
|
||||
if (! $this->userInfoUrl) {
|
||||
$this->userInfoUrl = $endpoints['userinfo_endpoint'];
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
private function getMultiUrl(string $urls, string $token = ''): array
|
||||
{
|
||||
$urlList = explode(',', $urls);
|
||||
$httpClient = new Client;
|
||||
$combinedArray = [];
|
||||
|
||||
$options = [];
|
||||
if ($token) {
|
||||
$options = [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($urlList as $url) {
|
||||
$response = $httpClient->get($url, $options);
|
||||
$urlData = json_decode($response->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR);
|
||||
if (is_array($urlData)) {
|
||||
$combinedArray = array_merge_recursive($combinedArray, $urlData);
|
||||
}
|
||||
}
|
||||
|
||||
return $combinedArray;
|
||||
}
|
||||
|
||||
private function buildRedirectUrl(): string
|
||||
{
|
||||
return $this->trimTrailingSlash(BASE_URL).'/oidc/callback';
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function generateState(): string
|
||||
{
|
||||
return bin2hex(random_bytes(16));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the state parameter returned by the OIDC provider against the
|
||||
* value stored in the session when the login flow was initiated. The state
|
||||
* is consumed (one-time use) regardless of the outcome to prevent replay.
|
||||
*/
|
||||
private function verifyState(string $state): bool
|
||||
{
|
||||
$storedState = (string) session('oidc.state');
|
||||
session()->forget('oidc.state');
|
||||
|
||||
return $storedState !== '' && hash_equals($storedState, $state);
|
||||
}
|
||||
|
||||
private function decodeBase64Url(string $value): string
|
||||
{
|
||||
return base64_decode(strtr($value, '-_', '+/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws HttpResponseException
|
||||
*/
|
||||
private function displayError(string $translationKey, string ...$values): void
|
||||
{
|
||||
|
||||
throw new \RuntimeException(sprintf($this->language->__($translationKey), ...$values));
|
||||
}
|
||||
}
|
||||
134
app/Domain/Oidc/Services/OidcMobileCode.php
Normal file
134
app/Domain/Oidc/Services/OidcMobileCode.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Oidc\Services;
|
||||
|
||||
use Illuminate\Contracts\Cache\LockProvider;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Short-lived, single-use codes for the mobile SSO bridge, kept in the cache.
|
||||
*
|
||||
* The OIDC callback (mobile-origin branch) mints one AFTER the user is
|
||||
* authenticated and hands it to the app via the app-scheme redirect. The app
|
||||
* POSTs it to /oidc/mobile/exchange to receive a bearer token. Passing a code
|
||||
* (not the token) through the redirect URL is what keeps a scheme-hijacking app
|
||||
* from lifting a usable token — it would have to win the HTTPS exchange race for
|
||||
* a <=60s, single-use code.
|
||||
*
|
||||
* Cache, not the DB: these live for at most 60s and are consumed once, so a TTL
|
||||
* cache entry is the natural fit (and self-expiring — no sweep needed). The code
|
||||
* is hashed into the cache key so the raw code is never stored. consumeCode()
|
||||
* guards its read-and-delete with a cache lock, so a replayed or concurrently
|
||||
* exchanged code can't be exchanged twice.
|
||||
*
|
||||
* PKCE: each code is bound to the app-supplied `code_challenge`. The exchange
|
||||
* requires the matching verifier, so a code intercepted from the app-scheme
|
||||
* redirect is useless without the secret the app kept (see Controllers/Mobile).
|
||||
*
|
||||
* NOTE (deployment): this uses the default cache store. On a multi-node install
|
||||
* that store must be SHARED across app nodes (e.g. redis) — the code is written
|
||||
* by the callback request and read by a later exchange request that may land on
|
||||
* a different node. The per-node file store ('installation') is fine for a
|
||||
* single-node install but would miss on multi-node.
|
||||
*/
|
||||
class OidcMobileCode
|
||||
{
|
||||
private const KEY_PREFIX = 'oidc.mobile.code.';
|
||||
|
||||
private const TTL_SECONDS = 60;
|
||||
|
||||
private const LOCK_SECONDS = 5;
|
||||
|
||||
/**
|
||||
* Mint a code bound to a user id + the PKCE code_challenge. Returns the RAW
|
||||
* code (only its hash is used as the cache key, so the raw value is never
|
||||
* persisted).
|
||||
*/
|
||||
public function createCode(int $userId, ?string $codeChallenge = null): string
|
||||
{
|
||||
$code = Str::random(64);
|
||||
|
||||
Cache::put(
|
||||
$this->key($code),
|
||||
['userId' => $userId, 'challenge' => $codeChallenge],
|
||||
self::TTL_SECONDS
|
||||
);
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-destructive read of a code's payload. Callers verify PKCE against
|
||||
* the returned challenge FIRST, then call consumeCode() to burn the code.
|
||||
* This prevents a scheme-hijacker from DoSing legit logins by submitting
|
||||
* an intercepted code with a bad verifier (which would delete the code
|
||||
* before the real client's exchange arrived).
|
||||
*/
|
||||
public function peekCode(string $rawCode): ?array
|
||||
{
|
||||
$data = Cache::get($this->key($rawCode));
|
||||
|
||||
if (! is_array($data) || ! isset($data['userId'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'userId' => (int) $data['userId'],
|
||||
'challenge' => $data['challenge'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Burn the code once and report whether THIS caller consumed it.
|
||||
*
|
||||
* The read-and-delete is guarded by a non-blocking cache lock keyed on the
|
||||
* code: of two concurrent exchanges that both called peekCode() on it, only
|
||||
* the lock holder performs the get()+forget() and can return true — so at
|
||||
* most one exchange mints from a single-use code. A caller that can't take
|
||||
* the lock (a concurrent consume is in flight) gets false and must not mint;
|
||||
* so does an already-consumed or unknown code.
|
||||
*
|
||||
* (A bare Cache::pull() is NOT used precisely because it is get()+forget(),
|
||||
* not a single atomic op on any driver — two callers could both read the code
|
||||
* before either deletes it, and both mint. The lock closes that window.)
|
||||
*/
|
||||
public function consumeCode(string $rawCode): bool
|
||||
{
|
||||
$key = $this->key($rawCode);
|
||||
|
||||
// Fail CLOSED: without atomic locks we cannot guarantee single-use, so
|
||||
// refuse rather than fall back to a racy non-atomic consume (which would
|
||||
// reintroduce the double-mint window this method exists to prevent). All
|
||||
// default Leantime stores implement LockProvider; a store here that
|
||||
// doesn't is a misconfiguration worth surfacing loudly, not degrading to.
|
||||
if (! Cache::getStore() instanceof LockProvider) {
|
||||
Log::error('OidcMobileCode: cache store does not support atomic locks; refusing mobile SSO code consume. Configure a lock-capable cache store (file/redis/memcached/database).');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$lock = Cache::lock($key.'.lock', self::LOCK_SECONDS);
|
||||
|
||||
// Non-blocking: the loser of a concurrent consume gets false immediately
|
||||
// instead of waiting, and its exchange is rejected.
|
||||
if (! $lock->get()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$existed = Cache::get($key) !== null;
|
||||
Cache::forget($key);
|
||||
|
||||
return $existed;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
private function key(string $rawCode): string
|
||||
{
|
||||
return self::KEY_PREFIX.hash('sha256', $rawCode);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user