Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117813284
D5253.1775281082.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
23 KB
Referenced Files
None
Subscribers
None
D5253.1775281082.diff
View Options
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
Details
Attached
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)
Attached To
Mode
D5253: [POC] Helpdesk mode
Attached
Detach File
Event Timeline