Page MenuHomePhorge

D5253.1775281082.diff
No OneTemporary

Authored By
Unknown
Size
23 KB
Referenced Files
None
Subscribers
None

D5253.1775281082.diff

diff --git a/src/app/Auth/OAuth.php b/src/app/Auth/OAuth.php
new file mode 100644
--- /dev/null
+++ b/src/app/Auth/OAuth.php
@@ -0,0 +1,210 @@
+<?php
+
+namespace App\Auth;
+
+use App\Http\Controllers\Controller;
+use App\Support\Facades\Roundcube;
+use App\User;
+use App\Utils;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Cache;
+use League\OAuth2\Server\AuthorizationServer;
+use League\OAuth2\Server\Exception\OAuthServerException;
+use Nyholm\Psr7\Response as Psr7Response;
+use Psr\Http\Message\ServerRequestInterface;
+
+class OAuth
+{
+ /**
+ * Approval request for the oauth authorization endpoint
+ *
+ * The implementation is based on Laravel\Passport\Http\Controllers\AuthorizationController
+ *
+ * @param User $user Authenticating user
+ * @param ServerRequestInterface $psrRequest PSR request
+ * @param Request $request The API request
+ * @param AuthorizationServer $server Authorization server
+ * @param bool $use_cache Cache the approval state
+ *
+ * @return JsonResponse
+ */
+ public static function approve(
+ User $user,
+ ServerRequestInterface $psrRequest,
+ Request $request,
+ AuthorizationServer $server,
+ bool $use_cache = true
+ ) {
+ $clientId = $request->input('client_id');
+
+ try {
+ if ($request->response_type != 'code') {
+ throw new \Exception('Invalid response_type');
+ }
+
+ $cacheKey = "oauth-seen-{$user->id}-{$clientId}";
+
+ // 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);
+
+ // Check if the client was approved before (in last x days)
+ if ($clientId && $use_cache && $request->ifSeen) {
+ $client = PassportClient::find($clientId);
+
+ if ($client && !Cache::has($cacheKey)) {
+ throw new \Exception('Not seen yet');
+ }
+ }
+
+ // 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());
+
+ // Remember the approval for x days.
+ // In this time we'll not show the UI form and we'll redirect automatically
+ // TODO: If we wanted to give users ability to remove this "approved" state for a client,
+ // we would have to store these records in SQL table. It would become handy especially
+ // if we give users possibility to register external OAuth apps.
+ if ($use_cache) {
+ Cache::put($cacheKey, 1, now()->addDays(14));
+ }
+ } catch (OAuthServerException $e) {
+ // Note: We don't want 401 or 400 codes here, use 422 which is used in our API
+ $code = $e->getHttpStatusCode();
+ $response = $e->getPayload();
+ $response['redirectUrl'] = !empty($client) ? $client->redirect : $request->input('redirect_uri');
+
+ return Controller::errorResponse($code < 500 ? 422 : 500, $e->getMessage(), $response);
+ } catch (\Exception $e) {
+ if (!empty($client)) {
+ $scopes = preg_split('/\s+/', (string) $request->input('scope'));
+
+ $claims = [];
+ foreach (array_intersect($scopes, $client->allowed_scopes) as $claim) {
+ $claims[$claim] = Controller::trans("auth.claim.{$claim}");
+ }
+
+ return response()->json([
+ 'status' => 'prompt',
+ 'client' => [
+ 'name' => $client->name,
+ 'url' => $client->redirect,
+ 'claims' => $claims,
+ ],
+ ]);
+ }
+
+ $response = [
+ 'error' => $e->getMessage() == 'Invalid response_type' ? 'unsupported_response_type' : 'server_error',
+ 'redirectUrl' => $request->input('redirect_uri'),
+ ];
+
+ return Controller::errorResponse(422, Controller::trans('auth.error.invalidrequest'), $response);
+ }
+
+ return response()->json([
+ 'status' => 'success',
+ 'redirectUrl' => $response->getHeader('Location')[0],
+ ]);
+ }
+
+ /**
+ * Get the authenticated User information (using access token claims)
+ *
+ * @param User $user User
+ */
+ public static function userInfo(User $user): array
+ {
+ $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
+ $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;
+ }
+
+ /**
+ * Webmail Login-As session initialization (via SSO)
+ *
+ * @param User $user The user to log in as
+ * @param ServerRequestInterface $psrRequest PSR request
+ * @param Request $request The API request
+ * @param AuthorizationServer $server Authorization server
+ *
+ * @return JsonResponse
+ */
+ public static function loginAs(User $user, ServerRequestInterface $psrRequest, Request $request, AuthorizationServer $server)
+ {
+ // Use OAuth client for Webmail
+ $client = PassportClient::where('name', 'Webmail SSO client')->whereNull('user_id')->first();
+
+ if (!$client) {
+ return Controller::errorResponse(404);
+ }
+
+ // Abuse the self::oauthApprove() handler to init the OAuth session (code)
+ $request->merge([
+ 'client_id' => $client->id,
+ 'redirect_uri' => $client->redirect,
+ 'scope' => 'email openid auth.token',
+ 'state' => Utils::uuidStr(),
+ 'nonce' => Utils::uuidStr(),
+ 'response_type' => 'code',
+ 'ifSeen' => false,
+ ]);
+
+ $response = self::approve($user, $psrRequest, $request, $server, false);
+
+ // Check status, on error remove the redirect url
+ if ($response->status() != 200) {
+ return Controller::errorResponse($response->status(), $response->getData()->error);
+ }
+
+ $url = $response->getData()->redirectUrl;
+
+ // Store state+nonce in Roundcube database (for the kolab plugin)
+ // for request origin validation and token validation there
+ // Get the code from the URL
+ parse_str(parse_url($url, \PHP_URL_QUERY), $query);
+
+ Roundcube::cacheSet(
+ 'helpdesk.' . md5($query['code']),
+ [
+ 'state' => $request->state,
+ 'nonce' => $request->nonce,
+ ],
+ 30 // TTL
+ );
+
+ // Tell the kolab plugin that the request origin is helpdesk mode, it will read
+ // the cache entry and make sure the token is accepted by Roundcube OAuth code.
+ $response->setData([
+ 'redirectUrl' => $url . '&helpdesk=1',
+ 'status' => 'success',
+ ]);
+
+ return $response;
+ }
+}
diff --git a/src/app/Auth/PassportClient.php b/src/app/Auth/PassportClient.php
--- a/src/app/Auth/PassportClient.php
+++ b/src/app/Auth/PassportClient.php
@@ -22,6 +22,7 @@
if ($this->allowed_scopes) {
return $this->allowed_scopes;
}
+
return [];
}
}
diff --git a/src/app/Backends/Roundcube.php b/src/app/Backends/Roundcube.php
--- a/src/app/Backends/Roundcube.php
+++ b/src/app/Backends/Roundcube.php
@@ -13,6 +13,7 @@
private const FILESTORE_TABLE = 'filestore';
private const USERS_TABLE = 'users';
private const IDENTITIES_TABLE = 'identities';
+ private const SHARED_CACHE_TABLE = 'cache_shared';
/** @var array List of GnuPG files to store */
private static $enigma_files = ['pubring.gpg', 'secring.gpg', 'pubring.kbx'];
@@ -33,6 +34,28 @@
return DB::connection('roundcube');
}
+ /**
+ * Store a shared cache entry (used in the kolab plugin)
+ *
+ * @param string $key Cache key name
+ * @param string|array $value Cache value
+ * @param int $ttl TTL (in seconds)
+ */
+ public static function cacheSet(string $key, $value, int $ttl): void
+ {
+ if (is_array($value)) {
+ $value = json_encode($value);
+ }
+
+ $db = self::dbh();
+
+ $db->table(self::SHARED_CACHE_TABLE)->insert([
+ 'cache_key' => 'kolab.' . $key,
+ 'data' => $value,
+ 'expires' => DB::raw("now() + INTERVAL {$ttl} SECOND"),
+ ]);
+ }
+
/**
* Create delegator's identities for the delegatee
*
diff --git a/src/app/Http/Controllers/API/AuthController.php b/src/app/Http/Controllers/API/AuthController.php
--- a/src/app/Http/Controllers/API/AuthController.php
+++ b/src/app/Http/Controllers/API/AuthController.php
@@ -2,19 +2,16 @@
namespace App\Http\Controllers\API;
-use App\Auth\PassportClient;
+use App\Auth\OAuth;
use App\Http\Controllers\Controller;
use App\User;
use App\Utils;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
-use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Validator;
use Laravel\Passport\RefreshTokenRepository;
use Laravel\Passport\TokenRepository;
use League\OAuth2\Server\AuthorizationServer;
-use League\OAuth2\Server\Exception\OAuthServerException;
-use Nyholm\Psr7\Response as Psr7Response;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\HttpFoundation\Response;
@@ -117,83 +114,9 @@
*/
public function oauthApprove(ServerRequestInterface $psrRequest, Request $request, AuthorizationServer $server)
{
- $clientId = $request->input('client_id');
$user = $this->guard()->user();
- $cacheKey = "oauth-seen-{$user->id}-{$clientId}";
- try {
- if ($request->response_type != 'code') {
- throw new \Exception('Invalid response_type');
- }
-
- // 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);
-
- // Check if the client was approved before (in last x days)
- if ($clientId && $request->ifSeen) {
- $client = PassportClient::find($clientId);
-
- if ($client && !Cache::has($cacheKey)) {
- throw new \Exception('Not seen yet');
- }
- }
-
- // 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());
-
- // Remember the approval for x days.
- // In this time we'll not show the UI form and we'll redirect automatically
- // TODO: If we wanted to give users ability to remove this "approved" state for a client,
- // we would have to store these records in SQL table. It would become handy especially
- // if we give users possibility to register external OAuth apps.
- Cache::put($cacheKey, 1, now()->addDays(14));
- } catch (OAuthServerException $e) {
- // Note: We don't want 401 or 400 codes here, use 422 which is used in our API
- $code = $e->getHttpStatusCode();
- $response = $e->getPayload();
- $response['redirectUrl'] = !empty($client) ? $client->redirect : $request->input('redirect_uri');
-
- return self::errorResponse($code < 500 ? 422 : 500, $e->getMessage(), $response);
- } catch (\Exception $e) {
- if (!empty($client)) {
- $scopes = preg_split('/\s+/', (string) $request->input('scope'));
-
- $claims = [];
- foreach (array_intersect($scopes, $client->allowed_scopes) as $claim) {
- $claims[$claim] = self::trans("auth.claim.{$claim}");
- }
-
- return response()->json([
- 'status' => 'prompt',
- 'client' => [
- 'name' => $client->name,
- 'url' => $client->redirect,
- 'claims' => $claims,
- ],
- ]);
- }
-
- $response = [
- 'error' => $e->getMessage() == 'Invalid response_type' ? 'unsupported_response_type' : 'server_error',
- 'redirectUrl' => $request->input('redirect_uri'),
- ];
-
- return self::errorResponse(422, self::trans('auth.error.invalidrequest'), $response);
- }
-
- return response()->json([
- 'status' => 'success',
- 'redirectUrl' => $response->getHeader('Location')[0],
- ]);
+ return OAuth::approve($user, $psrRequest, $request, $server);
}
/**
@@ -205,23 +128,7 @@
{
$user = $this->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
- $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
+ $response = OAuth::userInfo($user);
return response()->json($response);
}
diff --git a/src/app/Http/Controllers/API/V4/Admin/UsersController.php b/src/app/Http/Controllers/API/V4/Admin/UsersController.php
--- a/src/app/Http/Controllers/API/V4/Admin/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php
@@ -2,6 +2,7 @@
namespace App\Http\Controllers\API\V4\Admin;
+use App\Auth\OAuth;
use App\Domain;
use App\Entitlement;
use App\EventLog;
@@ -19,6 +20,8 @@
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
+use League\OAuth2\Server\AuthorizationServer;
+use Psr\Http\Message\ServerRequestInterface;
class UsersController extends \App\Http\Controllers\API\V4\UsersController
{
@@ -155,6 +158,35 @@
return response()->json($result);
}
+ /**
+ * Webmail Login-As session initialization (via SSO)
+ *
+ * @param string $id The account to log into
+ * @param ServerRequestInterface $psrRequest PSR request
+ * @param Request $request The API request
+ * @param AuthorizationServer $server Authorization server
+ *
+ * @return JsonResponse
+ */
+ public function loginAs($id, ServerRequestInterface $psrRequest, Request $request, AuthorizationServer $server)
+ {
+ $user = User::find($id);
+
+ if (!$this->checkTenant($user)) {
+ return $this->errorResponse(404);
+ }
+
+ if ($this->guard()->user()->role != User::ROLE_ADMIN) {
+ return $this->errorResponse(403);
+ }
+
+ if (!$user->hasSku('mailbox')) {
+ return $this->errorResponse(403);
+ }
+
+ return OAuth::loginAs($user, $psrRequest, $request, $server);
+ }
+
/**
* Reset 2-Factor Authentication for the user
*
diff --git a/src/config/openid.php b/src/config/openid.php
--- a/src/config/openid.php
+++ b/src/config/openid.php
@@ -5,10 +5,8 @@
return [
'passport' => [
- /*
- * Place your Passport and OpenID Connect scopes here.
- * To receive an `id_token`, you should at least provide the openid scope.
- */
+ // Place your Passport and OpenID Connect scopes here.
+ // To receive an `id_token`, you should at least provide the openid scope.
'tokens_can' => [
'openid' => 'Enable OpenID Connect',
'email' => 'Information about your email address',
@@ -43,26 +41,22 @@
],
'routes' => [
- /*
- * When set to true, this package will expose the OpenID Connect Discovery endpoint.
- * - /.well-known/openid-configuration
- */
+ // When set to true, this package will expose the OpenID Connect Discovery endpoint.
+ // - /.well-known/openid-configuration
'discovery' => true,
+
// When set to true, this package will expose the JSON Web Key Set endpoint.
'jwks' => false,
- /*
- * Optional URL to change the JWKS path to align with your custom Passport routes.
- * Defaults to /oauth/jwks
- */
+
+ // Optional URL to change the JWKS path to align with your custom Passport routes.
+ // Defaults to /oauth/jwks
'jwks_url' => '/oauth/jwks',
],
// Settings for the discovery endpoint
'discovery' => [
- /*
- * Hide scopes that aren't from the OpenID Core spec from the Discovery,
- * default = false (all scopes are listed)
- */
+ // Hide scopes that aren't from the OpenID Core spec from the Discovery,
+ // default = false (all scopes are listed)
'hide_scopes' => false,
],
diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php
--- a/src/resources/lang/en/ui.php
+++ b/src/resources/lang/en/ui.php
@@ -531,6 +531,7 @@
'imapproxy-text' => "Enables IMAP proxy that filters out non-mail groupware folders, so your IMAP clients do not see them.",
'list-title' => "User accounts",
'list-empty' => "There are no users in this account.",
+ 'login-as' => "Log into webmail",
'managed-by' => "Managed by",
'new' => "New user account",
'org' => "Organization",
diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue
--- a/src/resources/vue/Admin/User.vue
+++ b/src/resources/vue/Admin/User.vue
@@ -83,6 +83,9 @@
<btn id="button-resync" class="btn-outline-primary" @click="resyncUser">
{{ $t('btn.resync') }}
</btn>
+ <btn id="button-login-as" class="btn-outline-primary" @click="loginAs">
+ {{ $t('user.login-as') }}
+ </btn>
</div>
</div>
</div>
@@ -550,6 +553,14 @@
this.$root.clearFormValidation($('#email-dialog'))
this.$refs.emailDialog.show()
},
+ loginAs() {
+ axios.post('/api/v4/users/' + this.user.id + '/login-as')
+ .then(response => {
+ if (response.data.redirectUrl) {
+ window.open(response.data.redirectUrl)
+ }
+ })
+ },
setMandateState() {
let mandate = this.wallet.mandate
if (mandate && mandate.id) {
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -154,6 +154,7 @@
Route::post('users/{id}/config', [API\V4\UsersController::class, 'setConfig']);
Route::get('users/{id}/skus', [API\V4\UsersController::class, 'skus']);
Route::get('users/{id}/status', [API\V4\UsersController::class, 'status']);
+ Route::post('users/{id}/login-as', [API\AuthController::class, 'loginAs']);
if (\config('app.with_delegation')) {
Route::get('users/{id}/delegations', [API\V4\UsersController::class, 'delegations']);
@@ -261,6 +262,7 @@
Route::apiResource('users', API\V4\Admin\UsersController::class);
Route::get('users/{id}/discounts', [API\V4\Admin\DiscountsController::class, 'userDiscounts']);
+ Route::post('users/{id}/login-as', [API\V4\Admin\UsersController::class, 'loginAs']);
Route::post('users/{id}/reset2FA', [API\V4\Admin\UsersController::class, 'reset2FA']);
Route::post('users/{id}/resetGeoLock', [API\V4\Admin\UsersController::class, 'resetGeoLock']);
Route::post('users/{id}/resync', [API\V4\Admin\UsersController::class, 'resync']);
diff --git a/src/tests/Feature/Controller/Admin/UsersTest.php b/src/tests/Feature/Controller/Admin/UsersTest.php
--- a/src/tests/Feature/Controller/Admin/UsersTest.php
+++ b/src/tests/Feature/Controller/Admin/UsersTest.php
@@ -319,6 +319,45 @@
$this->assertTrue($json['list'][0]['isDeleted']);
}
+ /**
+ * Test login-as request (POST /api/v4/users/<user-id>/login-as)
+ */
+ public function testLoginAs(): void
+ {
+ Queue::fake();
+
+ $user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+
+ // Test non-existing user
+ $response = $this->actingAs($admin)->post("/api/v4/users/123456/login-as", []);
+ $response->assertStatus(404);
+
+ // Test unauthorized access to admin API
+ $response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/login-as", []);
+ $response->assertStatus(403);
+
+ // Test user w/o mailbox SKU
+ $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/login-as", []);
+ $response->assertStatus(403);
+
+ $sku = Sku::withObjectTenantContext($user)->where(['title' => 'mailbox'])->first();
+ $user->assignSku($sku);
+
+ // Test login-as
+ $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/login-as", []);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ parse_str(parse_url($json['redirectUrl'], \PHP_URL_QUERY), $params);
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame('1', $params['helpdesk']);
+
+ // TODO: Assert the Roundcube cache entry
+ }
+
/**
* Test reseting 2FA (POST /api/v4/users/<user-id>/reset2FA)
*/

File Metadata

Mime Type
text/plain
Expires
Sat, Apr 4, 5:38 AM (12 h, 54 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18828254
Default Alt Text
D5253.1775281082.diff (23 KB)

Event Timeline