OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
37
app/Domain/Oidc/Controllers/Callback.php
Normal file
37
app/Domain/Oidc/Controllers/Callback.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Oidc\Controllers;
|
||||
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Domain\Oidc\Services\Oidc as OidcService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class Callback extends Controller
|
||||
{
|
||||
private OidcService $oidc;
|
||||
|
||||
public function init(OidcService $oidc): void
|
||||
{
|
||||
$this->oidc = $oidc;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GuzzleException|HttpResponseException
|
||||
*/
|
||||
public function get($params): Response
|
||||
{
|
||||
$code = $_GET['code'];
|
||||
$state = $_GET['state'];
|
||||
|
||||
try {
|
||||
return $this->oidc->callback($code, $state);
|
||||
} catch (\Exception $e) {
|
||||
$this->tpl->setNotification($e->getMessage(), 'danger', 'oidc_error');
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.'/auth/login');
|
||||
}
|
||||
}
|
||||
}
|
||||
86
app/Domain/Oidc/Controllers/Login.php
Normal file
86
app/Domain/Oidc/Controllers/Login.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Oidc\Controllers;
|
||||
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Domain\Oidc\Services\Oidc as OidcService;
|
||||
use Leantime\Domain\Plugins\Services\Plugins;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class Login extends Controller
|
||||
{
|
||||
private OidcService $oidc;
|
||||
|
||||
private Plugins $plugins;
|
||||
|
||||
/**
|
||||
* Initializes dependencies.
|
||||
*
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
public function init(OidcService $oidc, Plugins $plugins): void
|
||||
{
|
||||
$this->oidc = $oidc;
|
||||
$this->plugins = $plugins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects to the OIDC provider login page.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*/
|
||||
public function get(array $params): Response
|
||||
{
|
||||
try {
|
||||
// Mobile-brokered SSO: the app passes ?mobile=1&redirect_uri=<app scheme>
|
||||
// + a PKCE code_challenge, so the callback mints a token + one-time
|
||||
// code (bound to that challenge) and redirects back to the app instead
|
||||
// of establishing a web session. The service validates the redirect
|
||||
// scheme; a non-mobile web login passes none of these.
|
||||
$mobile = ! empty($params['mobile']);
|
||||
$redirectUri = is_string($params['redirect_uri'] ?? null) ? $params['redirect_uri'] : '';
|
||||
$codeChallenge = is_string($params['code_challenge'] ?? null) ? $params['code_challenge'] : '';
|
||||
|
||||
// The mobile-brokered branch is an AdvancedAuth capability. Without the
|
||||
// plugin, ignore the mobile params and fall back to normal web login —
|
||||
// the mint endpoint refuses too, so no mobile session can be brokered.
|
||||
if ($mobile && ! $this->plugins->isEnabled('AdvancedAuth')) {
|
||||
return Frontcontroller::redirect(BASE_URL.'/auth/login');
|
||||
}
|
||||
|
||||
// Mobile flow requires a PKCE challenge — otherwise the callback
|
||||
// would mint a code whose exchange can never succeed (pkceMatches
|
||||
// rejects an empty challenge). Fail loudly at the front door.
|
||||
if ($mobile && $codeChallenge === '') {
|
||||
$this->tpl->setNotification('Mobile login requires a PKCE code_challenge.', 'error');
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.'/auth/login');
|
||||
}
|
||||
|
||||
// A PKCE S256 challenge is base64url(sha256(verifier)) — the
|
||||
// base64url charset, 43–128 chars (RFC 7636). Reject a present-but-
|
||||
// malformed value so a crafted request can't persist junk into the
|
||||
// code store and to enforce the intended mobile contract.
|
||||
if ($codeChallenge !== '' && ! preg_match('/^[A-Za-z0-9\-_]{43,128}$/', $codeChallenge)) {
|
||||
$this->tpl->setNotification('Invalid PKCE code_challenge.', 'error');
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.'/auth/login');
|
||||
}
|
||||
|
||||
$loginUrl = $this->oidc->buildLoginUrl($mobile, $redirectUri, $codeChallenge);
|
||||
|
||||
if ($loginUrl) {
|
||||
return Frontcontroller::redirect($loginUrl, 302);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error($e);
|
||||
}
|
||||
|
||||
$this->tpl->setNotification('Auth URL could not be found. Check the logs for more details', 'error');
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.'/auth/login');
|
||||
}
|
||||
}
|
||||
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'] ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
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