diff --git a/src/app/Auth/IdentityEntity.php b/src/app/Auth/IdentityEntity.php index 13735ac8..a173ff07 100644 --- a/src/app/Auth/IdentityEntity.php +++ b/src/app/Auth/IdentityEntity.php @@ -1,50 +1,52 @@ identifier = $identifier; $this->user = User::findOrFail($identifier); } /** * When building the id_token, this entity's claims are collected + * + * @param string[] $scopes Optional scope filter */ - public function getClaims(): array + public function getClaims(array $scopes = []): array { - // TODO: Other claims - // TODO: Should we use this in AuthController::oauthUserInfo() for some de-duplicaton? + $claims = []; - $claims = [ - 'email' => $this->user->email, - ]; + if (in_array('email', $scopes)) { + $claims['email'] = $this->user->email; + } // Short living password for IMAP/SMTP // We use same TTL as for the OAuth tokens, so clients can get a new password on token refresh - // TODO: We should create the password only when the access token scope requests it - $ttl = config('auth.token_expiry_minutes') * 60; - $claims['auth.token'] = Utils::tokenCreate((string) $this->user->id, $ttl); + if (in_array('auth.token', $scopes)) { + $ttl = config('auth.token_expiry_minutes') * 60; + $claims['auth.token'] = Utils::tokenCreate((string) $this->user->id, $ttl); + } return $claims; } } diff --git a/src/app/Http/Controllers/API/AuthController.php b/src/app/Http/Controllers/API/AuthController.php index bc3c0f76..0dfc5125 100644 --- a/src/app/Http/Controllers/API/AuthController.php +++ b/src/app/Http/Controllers/API/AuthController.php @@ -1,284 +1,284 @@ user(); if (!empty(request()->input('refresh'))) { return $this->refreshAndRespond(request(), $user); } $response = V4\UsersController::userResponse($user); return response()->json($response); } /** * Helper method for other controllers with user auto-logon * functionality * * @param \App\User $user User model object * @param string $password Plain text password * @param string|null $secondFactor Second factor code if available */ public static function logonResponse(User $user, string $password, string $secondFactor = null) { $proxyRequest = Request::create('/oauth/token', 'POST', [ 'username' => $user->email, 'password' => $password, 'grant_type' => 'password', 'client_id' => \config('auth.proxy.client_id'), 'client_secret' => \config('auth.proxy.client_secret'), 'scope' => 'api', 'secondfactor' => $secondFactor ]); $proxyRequest->headers->set('X-Client-IP', request()->ip()); $tokenResponse = app()->handle($proxyRequest); return self::respondWithToken($tokenResponse, $user); } /** * Get an oauth token via given credentials. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse */ public function login(Request $request) { $v = Validator::make( $request->all(), [ 'email' => 'required|min:3', 'password' => 'required|min:1', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $user = \App\User::where('email', $request->email)->first(); if (!$user) { \Log::debug("[Auth] User not found on login: {$request->email}"); return response()->json(['status' => 'error', 'message' => self::trans('auth.failed')], 401); } return self::logonResponse($user, $request->password, $request->secondfactor); } /** * Approval request for the oauth authorization endpoint * * * The user is authenticated via the regular login page * * We assume implicit consent in the Authorization page * * Ultimately we return an authorization code to the caller via the redirect_uri * * The implementation is based on Laravel\Passport\Http\Controllers\AuthorizationController * * @param ServerRequestInterface $psrRequest PSR request * @param \Illuminate\Http\Request $request The API request * @param AuthorizationServer $server Authorization server * * @return \Illuminate\Http\JsonResponse */ public function oauthApprove(ServerRequestInterface $psrRequest, Request $request, AuthorizationServer $server) { if ($request->response_type != 'code') { return self::errorResponse(422, self::trans('validation.invalidvalueof', ['attribute' => 'response_type'])); } try { // OpenID handler reads parameters from the request query string (GET) $request->query->replace($request->input()); // OAuth2 server's code also expects GET parameters, but we're using POST here $psrRequest = $psrRequest->withQueryParams($request->input()); $authRequest = $server->validateAuthorizationRequest($psrRequest); $user = Auth::guard()->user(); // TODO I'm not sure if we should still execute this to deny the request $authRequest->setUser(new \Laravel\Passport\Bridge\User($user->getAuthIdentifier())); $authRequest->setAuthorizationApproved(true); // This will generate a 302 redirect to the redirect_uri with the generated authorization code $response = $server->completeAuthorizationRequest($authRequest, new Psr7Response()); } catch (\League\OAuth2\Server\Exception\OAuthServerException $e) { // Note: We don't want 401 or 400 codes here, use 422 which is used in our API $code = $e->getHttpStatusCode(); return self::errorResponse($code < 500 ? 422 : 500, $e->getMessage()); } catch (\Exception $e) { return self::errorResponse(422, self::trans('auth.error.invalidrequest')); } return response()->json([ 'status' => 'success', 'redirectUrl' => $response->getHeader('Location')[0], ]); } /** * Get the authenticated User information (using access token claims) * * @return \Illuminate\Http\JsonResponse */ public function oauthUserInfo() { $user = Auth::guard()->user(); $response = [ // Per OIDC spec. 'sub' must be always returned 'sub' => $user->id, ]; if ($user->tokenCan('email')) { $response['email'] = $user->email; $response['email_verified'] = $user->isActive(); - # At least synapse depends on a "settings" structure being available + // At least synapse depends on a "settings" structure being available $response['settings'] = [ 'name' => $user->name() ]; } // TODO: Other claims (https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims) // address: address // phone: phone_number and phone_number_verified // profile: name, family_name, given_name, middle_name, nickname, preferred_username, // profile, picture, website, gender, birthdate, zoneinfo, locale, and updated_at return response()->json($response); } /** * Get the user (geo) location * * @return \Illuminate\Http\JsonResponse */ public function location() { $ip = request()->ip(); $response = [ 'ipAddress' => $ip, 'countryCode' => \App\Utils::countryForIP($ip, ''), ]; return response()->json($response); } /** * Log the user out (Invalidate the token) * * @return \Illuminate\Http\JsonResponse */ public function logout() { $tokenId = Auth::user()->token()->id; $tokenRepository = app(TokenRepository::class); $refreshTokenRepository = app(RefreshTokenRepository::class); // Revoke an access token... $tokenRepository->revokeAccessToken($tokenId); // Revoke all of the token's refresh tokens... $refreshTokenRepository->revokeRefreshTokensByAccessTokenId($tokenId); return response()->json([ 'status' => 'success', 'message' => self::trans('auth.logoutsuccess') ]); } /** * Refresh a token. * * @return \Illuminate\Http\JsonResponse */ public function refresh(Request $request) { return self::refreshAndRespond($request); } /** * Refresh the token and respond with it. * * @param \Illuminate\Http\Request $request The API request. * @param ?\App\User $user The user being authenticated * * @return \Illuminate\Http\JsonResponse */ protected static function refreshAndRespond(Request $request, $user = null) { $proxyRequest = Request::create('/oauth/token', 'POST', [ 'grant_type' => 'refresh_token', 'refresh_token' => $request->refresh_token, 'client_id' => \config('auth.proxy.client_id'), 'client_secret' => \config('auth.proxy.client_secret'), ]); $tokenResponse = app()->handle($proxyRequest); return self::respondWithToken($tokenResponse, $user); } /** * Get the token array structure. * * @param \Symfony\Component\HttpFoundation\Response $tokenResponse The response containing the token. * @param ?\App\User $user The user being authenticated * * @return \Illuminate\Http\JsonResponse */ protected static function respondWithToken($tokenResponse, $user = null) { $data = json_decode($tokenResponse->getContent()); if ($tokenResponse->getStatusCode() != 200) { if (isset($data->error) && $data->error == 'secondfactor' && isset($data->error_description)) { $errors = ['secondfactor' => $data->error_description]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } \Log::warning("Failed to request a token: " . strval($tokenResponse)); return response()->json(['status' => 'error', 'message' => self::trans('auth.failed')], 401); } if ($user) { $response = V4\UsersController::userResponse($user); } else { $response = []; } $response['status'] = 'success'; $response['access_token'] = $data->access_token; $response['refresh_token'] = $data->refresh_token; $response['token_type'] = 'bearer'; $response['expires_in'] = $data->expires_in; return response()->json($response); } } diff --git a/src/app/Http/Controllers/API/V4/HealthController.php b/src/app/Http/Controllers/API/V4/HealthController.php index 58e80c90..7d894f2c 100644 --- a/src/app/Http/Controllers/API/V4/HealthController.php +++ b/src/app/Http/Controllers/API/V4/HealthController.php @@ -1,32 +1,32 @@ json('success', 200); - $response->noLogging = true; + $response->noLogging = true; // @phpstan-ignore-line return $response; } /** * Readiness probe * * @return \Illuminate\Http\JsonResponse The response */ public function readiness() { $response = response()->json('success', 200); - $response->noLogging = true; + $response->noLogging = true; // @phpstan-ignore-line return $response; } } diff --git a/src/composer.json b/src/composer.json index 61c1cc4c..3437f28a 100644 --- a/src/composer.json +++ b/src/composer.json @@ -1,88 +1,88 @@ { "name": "kolab/kolab4", "type": "project", "description": "Kolab 4", "keywords": [ "framework", "laravel" ], "license": "MIT", "repositories": [ { "type": "vcs", "url": "https://git.kolab.org/diffusion/PNL/php-net_ldap3.git" } ], "require": { "php": "^8.1", "bacon/bacon-qr-code": "^2.0", "barryvdh/laravel-dompdf": "^2.0.1", "doctrine/dbal": "^3.6", "dyrynda/laravel-nullable-fields": "^4.3.0", "garethp/php-ews": "dev-master", "guzzlehttp/guzzle": "^7.8.0", - "jeremy379/laravel-openid-connect": "^2.3", + "jeremy379/laravel-openid-connect": "^2.4", "kolab/net_ldap3": "dev-master", "laravel/framework": "^10.15.0", "laravel/horizon": "^5.9", "laravel/octane": "^2.0", "laravel/passport": "^12.0", "laravel/tinker": "^2.8", "league/flysystem-aws-s3-v3": "^3.0", "mlocati/spf-lib": "^3.1", "mollie/laravel-mollie": "^2.22", "pear/crypt_gpg": "^1.6.6", "predis/predis": "^2.0", "sabre/vobject": "^4.5", "spatie/laravel-translatable": "^6.5", "spomky-labs/otphp": "~10.0.0", "stripe/stripe-php": "^10.7" }, "require-dev": { "code-lts/doctum": "^5.5.1", "laravel/dusk": "~8.2.2", "mockery/mockery": "^1.5", "larastan/larastan": "^2.0", "phpstan/phpstan": "^1.4", "phpunit/phpunit": "^9", "squizlabs/php_codesniffer": "^3.6" }, "config": { "optimize-autoloader": true, "preferred-install": "dist", "sort-packages": true }, "extra": { "laravel": { "dont-discover": [] } }, "autoload": { "psr-4": { "App\\": "app/" }, "classmap": [ "database/seeds", "include" ] }, "autoload-dev": { "psr-4": { "Tests\\": "tests/" } }, "minimum-stability": "stable", "prefer-stable": true, "scripts": { "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi" ], "post-update-cmd": [ "@php artisan vendor:publish --tag=laravel-assets --ansi --force" ], "post-create-project-cmd": [ "@php artisan key:generate --ansi" ] } }