diff --git a/src/app/Http/Controllers/API/AuthController.php b/src/app/Http/Controllers/API/AuthController.php index 0dfc5125..e6d1967d 100644 --- a/src/app/Http/Controllers/API/AuthController.php +++ b/src/app/Http/Controllers/API/AuthController.php @@ -1,284 +1,328 @@ user(); + $user = $this->guard()->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'])); - } + $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); - $user = Auth::guard()->user(); + // 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 (\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()); + $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) { - return self::errorResponse(422, self::trans('auth.error.invalidrequest')); + 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], ]); } /** * Get the authenticated User information (using access token claims) * * @return \Illuminate\Http\JsonResponse */ public function oauthUserInfo() { - $user = Auth::guard()->user(); + $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 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; - + $tokenId = $this->guard()->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/resources/lang/en/auth.php b/src/resources/lang/en/auth.php index a1a08359..616080f0 100644 --- a/src/resources/lang/en/auth.php +++ b/src/resources/lang/en/auth.php @@ -1,28 +1,31 @@ 'Invalid username or password.', 'password' => 'The provided password is incorrect.', 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', 'logoutsuccess' => 'Successfully logged out.', + 'claim.unknown' => "Unknown claim", + 'claim.email' => "See your email address", + 'claim.auth.token' => "Have read and write access to all your data", + 'error.password' => "Invalid password", 'error.invalidrequest' => "Invalid authorization request.", 'error.geolocation' => "Country code mismatch", 'error.notfound' => "User not found", 'error.2fa' => "Second factor failure", 'error.2fa-generic' => "Second factor failure", - ]; diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php index bb905f16..f13d3c50 100644 --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -1,586 +1,594 @@ [ 'faq' => "FAQ", ], + 'auth' => [ + 'allow' => "Allow access", + 'authorize-header' => "The application would like to:", + 'authorize-title' => "{name} is asking for permission", + 'authorize-footer' => "On any action you will be redirected to {url}", + 'deny' => "No, thanks", + ], + 'btn' => [ 'add' => "Add", 'accept' => "Accept", 'back' => "Back", 'cancel' => "Cancel", 'close' => "Close", 'confirm' => "Confirm", 'continue' => "Continue", 'copy' => "Copy", 'delete' => "Delete", 'deny' => "Deny", 'download' => "Download", 'edit' => "Edit", 'file' => "Choose file...", 'moreinfo' => "More information", 'refresh' => "Refresh", 'reset' => "Reset", 'resend' => "Resend", 'resync' => "Resync", 'save' => "Save", 'search' => "Search", 'share' => "Share", 'signup' => "Sign Up", 'submit' => "Submit", 'subscribe' => "Subscribe", 'suspend' => "Suspend", 'tryagain' => "Try again", 'unsuspend' => "Unsuspend", 'verify' => "Verify", ], 'collection' => [ 'create' => "Create collection", 'new' => "New Collection", 'name' => "Name", ], 'companion' => [ 'title' => "Companion Apps", 'companion' => "Companion App", 'name' => "Name", 'create' => "Pair new device", 'create-recovery-device' => "Prepare recovery code", 'description' => "Use the Companion App on your mobile phone as multi-factor authentication device.", 'download-description' => "You may download the Companion App for Android here: " . "Download", 'description-detailed' => "Here is how this works: " . "Pairing a device will automatically enable multi-factor autentication for all login attempts. " . "This includes not only the Cockpit, but also logins via Webmail, IMAP, SMPT, DAV and ActiveSync. " . "Any authentication attempt will result in a notification on your device, " . "that you can use to confirm if it was you, or deny otherwise. " . "Once confirmed, the same username + IP address combination will be whitelisted for 8 hours. " . "Unpair all your active devices to disable multi-factor authentication again.", 'description-warning' => "Warning: Loosing access to all your multi-factor authentication devices, " . "will permanently lock you out of your account with no course for recovery. " . "Always make sure you have a recovery QR-Code printed to pair a recovery device.", 'new' => "Pair new device", 'recovery' => "Prepare recovery device", 'paired' => "Paired devices", 'print' => "Print for backup", 'pairing-instructions' => "Pair your device using the following QR-Code.", 'recovery-device' => "Recovery Device", 'new-device' => "New Device", 'deviceid' => "Device ID", 'list-empty' => "There are currently no devices", 'delete' => "Delete/Unpair", 'delete-companion' => "Delete/Unpair", 'delete-text' => "You are about to delete this entry and unpair any paired companion app. " . "This cannot be undone, but you can pair the device again.", 'pairing-successful' => "Your companion app is paired and ready to be used " . "as a multi-factor authentication device.", ], 'dashboard' => [ 'beta' => "beta", 'distlists' => "Distribution lists", 'chat' => "Video chat", 'companion' => "Companion app", 'domains' => "Domains", 'files' => "Files", 'invitations' => "Invitations", 'myaccount' => "My account", 'policies' => "Policies", 'profile' => "Your profile", 'resources' => "Resources", 'shared-folders' => "Shared folders", 'users' => "User accounts", 'wallet' => "Wallet", 'webmail' => "Webmail", 'stats' => "Stats", ], 'distlist' => [ 'list-title' => "Distribution list | Distribution lists", 'create' => "Create list", 'delete' => "Delete list", 'email' => "Email", 'list-empty' => "There are no distribution lists in this account.", 'name' => "Name", 'new' => "New distribution list", 'recipients' => "Recipients", 'sender-policy' => "Sender Access List", 'sender-policy-text' => "With this list you can specify who can send mail to the distribution list." . " You can put a complete email address (jane@kolab.org), domain (kolab.org) or suffix (.org) that the sender email address is compared to." . " If the list is empty, mail from anyone is allowed.", ], 'domain' => [ 'config' => "Domain configuration", 'config-intro' => "In order to let {app} receive email traffic for your domain you need to adjust the DNS settings, more precisely the MX entries, accordingly.", 'config-sample' => "Edit your domain's zone file and replace existing MX entries with the following values:", 'config-hint' => "If you don't know how to set DNS entries for your domain, please contact the registration service where you registered the domain or your web hosting provider.", 'confirm' => "Domain ownership confirmation", 'confirm-intro' => "In order to confirm that you're the actual owner or administrator of the domain, " . "we need to run a confirmation process before finally activating it for email delivery.", 'confirm-dns' => "The domain must have one of the following entries in DNS:", 'confirm-dns-txt' => "TXT entry with value:", 'confirm-dns-cname' => "or CNAME entry:", 'confirm-outro' => "Please add one of those records to the DNS of your domain via your domain name provider. " . "When this is done press the button below to start the confirmation.", 'confirm-sample' => "Here's a sample zone file for your domain:", 'create' => "Create domain", 'delete' => "Delete domain", 'delete-domain' => "Delete {domain}", 'delete-text' => "Do you really want to delete this domain permanently?" . " This is only possible if there are no users, aliases or other objects in this domain." . " Please note that this action cannot be undone.", 'dns-confirm' => "Domain DNS confirmation sample:", 'dns-config' => "Domain DNS configuration sample:", 'list-empty' => "There are no domains in this account.", 'namespace' => "Namespace", 'new' => "New domain", 'spf-whitelist' => "SPF Whitelist", 'spf-whitelist-text' => "The Sender Policy Framework allows a sender domain to disclose, through DNS, " . "which systems are allowed to send emails with an envelope sender address within said domain.", 'spf-whitelist-ex' => "Here you can specify a list of allowed servers, for example: .ess.barracuda.com.", ], 'error' => [ '400' => "Bad request", '401' => "Unauthorized", '403' => "Access denied", '404' => "Not found", '405' => "Method not allowed", '500' => "Internal server error", 'unknown' => "Unknown Error", 'server' => "Server Error", 'form' => "Form validation error", ], 'file' => [ 'create' => "Create file", 'delete' => "Delete file", 'drop' => "Click or drop file(s) here", 'list-empty' => "There are no files in this account.", 'mimetype' => "Mimetype", 'mtime' => "Modified", 'new' => "New file", 'search' => "File name", 'sharing' => "Sharing", 'sharing-links-text' => "You can share the file with other users by giving them read-only access " . "to the file via a unique link.", ], 'form' => [ 'acl' => "Access rights", 'acl-full' => "All", 'acl-read-only' => "Read-only", 'acl-read-write' => "Read-write", 'amount' => "Amount", 'anyone' => "Anyone", 'code' => "Confirmation Code", 'config' => "Configuration", 'comment' => "Comment", 'companion' => "Companion App", 'date' => "Date", 'description' => "Description", 'details' => "Details", 'disabled' => "disabled", 'domain' => "Domain", 'email' => "Email Address", 'emails' => "Email Addresses", 'enabled' => "enabled", 'firstname' => "First Name", 'general' => "General", 'geolocation' => "Your current location: {location}", 'lastname' => "Last Name", 'less' => "Less", 'name' => "Name", 'months' => "months", 'more' => "More", 'none' => "none", 'norestrictions' => "No restrictions", 'or' => "or", 'password' => "Password", 'password-confirm' => "Confirm Password", 'personal' => "Personal information", 'phone' => "Phone", 'selectcountries' => "Select countries", 'settings' => "Settings", 'shared-folder' => "Shared Folder", 'size' => "Size", 'status' => "Status", 'subscriptions' => "Subscriptions", 'surname' => "Surname", 'type' => "Type", 'unknown' => "unknown", 'user' => "User", 'primary-email' => "Primary Email", 'id' => "ID", 'created' => "Created", 'deleted' => "Deleted", ], 'invitation' => [ 'create' => "Create invite(s)", 'create-title' => "Invite for a signup", 'create-email' => "Enter an email address of the person you want to invite.", 'create-csv' => "To send multiple invitations at once, provide a CSV (comma separated) file, or alternatively a plain-text file, containing one email address per line.", 'list-empty' => "There are no invitations in the database.", 'title' => "Signup invitations", 'search' => "Email address or domain", 'send' => "Send invite(s)", 'status-completed' => "User signed up", 'status-failed' => "Sending failed", 'status-sent' => "Sent", 'status-new' => "Not sent yet", ], 'lang' => [ 'en' => "English", 'de' => "German", 'fr' => "French", 'it' => "Italian", ], 'log' => [ 'event' => "Event", 'list-none' => "There's no events in the log", 'history' => "History", ], 'login' => [ '2fa' => "Second factor code", '2fa_desc' => "Second factor code is optional for users with no 2-Factor Authentication setup.", 'forgot_password' => "Forgot password?", 'header' => "Please sign in", 'sign_in' => "Sign in", 'signing_in' => "Signing in...", 'webmail' => "Webmail" ], 'meet' => [ // Room options dialog 'options' => "Room options", 'password' => "Password", 'password-none' => "none", 'password-clear' => "Clear password", 'password-set' => "Set password", 'password-text' => "You can add a password to your meeting. Participants will have to provide the password before they are allowed to join the meeting.", 'lock' => "Locked room", 'lock-text' => "When the room is locked participants have to be approved by a moderator before they could join the meeting.", 'nomedia' => "Subscribers only", 'nomedia-text' => "Forces all participants to join as subscribers (with camera and microphone turned off)." . " Moderators will be able to promote them to publishers throughout the session.", // Room menu 'partcnt' => "Number of participants", 'menu-audio-mute' => "Mute audio", 'menu-audio-unmute' => "Unmute audio", 'menu-video-mute' => "Mute video", 'menu-video-unmute' => "Unmute video", 'menu-screen' => "Share screen", 'menu-hand-lower' => "Lower hand", 'menu-hand-raise' => "Raise hand", 'menu-channel' => "Interpreted language channel", 'menu-chat' => "Chat", 'menu-fullscreen' => "Full screen", 'menu-fullscreen-exit' => "Exit full screen", 'menu-leave' => "Leave session", // Room setup screen 'setup-title' => "Set up your session", 'mic' => "Microphone", 'cam' => "Camera", 'nick' => "Nickname", 'nick-placeholder' => "Your name", 'join' => "JOIN", 'joinnow' => "JOIN NOW", 'imaowner' => "I'm the owner", // Room 'qa' => "Q & A", 'leave-title' => "Room closed", 'leave-body' => "The session has been closed by the room owner.", 'media-title' => "Media setup", 'join-request' => "Join request", 'join-requested' => "{user} requested to join.", // Status messages 'status-init' => "Checking the room...", 'status-323' => "The room is closed. Please, wait for the owner to start the session.", 'status-324' => "The room is closed. It will be open for others after you join.", 'status-325' => "The room is ready. Please, provide a valid password.", 'status-326' => "The room is locked. Please, enter your name and try again.", 'status-327' => "Waiting for permission to join the room.", 'status-404' => "The room does not exist.", 'status-429' => "Too many requests. Please, wait.", 'status-500' => "Failed to connect to the room. Server error.", // Other menus 'media-setup' => "Media setup", 'perm' => "Permissions", 'perm-av' => "Audio & Video publishing", 'perm-mod' => "Moderation", 'lang-int' => "Language interpreter", 'menu-options' => "Options", ], 'menu' => [ 'cockpit' => "Cockpit", 'login' => "Login", 'logout' => "Logout", 'signup' => "Signup", 'toggle' => "Toggle navigation", ], 'msg' => [ 'initializing' => "Initializing...", 'loading' => "Loading...", 'loading-failed' => "Failed to load data.", 'notfound' => "Resource not found.", 'info' => "Information", 'error' => "Error", 'redirecting' => "Redirecting...", 'uploading' => "Uploading...", 'warning' => "Warning", 'success' => "Success", ], 'nav' => [ 'more' => "Load more", 'step' => "Step {i}/{n}", ], 'password' => [ 'link-invalid' => "The password reset code is expired or invalid.", 'reset' => "Password Reset", 'reset-step1' => "Enter your email address to reset your password.", 'reset-step1-hint' => "You may need to check your spam folder or unblock {email}.", 'reset-step2' => "We sent out a confirmation code to your external email address." . " Enter the code we sent you, or click the link in the message.", ], 'policies' => [ 'password-policy' => "Password Policy", 'password-retention' => "Password Retention", 'password-max-age' => "Require a password change every", ], 'resource' => [ 'create' => "Create resource", 'delete' => "Delete resource", 'invitation-policy' => "Invitation policy", 'invitation-policy-text' => "Event invitations for a resource are normally accepted automatically" . " if there is no conflicting event on the requested time slot. Invitation policy allows" . " for rejecting such requests or to require a manual acceptance from a specified user.", 'ipolicy-manual' => "Manual (tentative)", 'ipolicy-accept' => "Accept", 'ipolicy-reject' => "Reject", 'list-title' => "Resource | Resources", 'list-empty' => "There are no resources in this account.", 'new' => "New resource", ], 'room' => [ 'create' => "Create room", 'delete' => "Delete room", 'copy-location' => "Copy room location", 'description-hint' => "This is an optional short description for the room, so you can find it more easily on the list.", 'goto' => "Enter the room", 'list-empty' => "There are no conference rooms in this account.", 'list-empty-nocontroller' => "Do you need a room? Ask your account owner to create one and share it with you.", 'list-title' => "Voice & video conferencing rooms", 'moderators' => "Moderators", 'moderators-text' => "You can share your room with other users. They will become the room moderators with all moderator powers and ability to open the room without your presence.", 'new' => "New room", 'new-hint' => "We'll generate a unique name for the room that will then allow you to access the room.", 'title' => "Room: {name}", 'url' => "You can access the room at the URL below. Use this URL to invite people to join you. This room is only open when you (or another room moderator) is in attendance.", ], 'shf' => [ 'aliases-none' => "This shared folder has no email aliases.", 'create' => "Create folder", 'delete' => "Delete folder", 'acl-text' => "Defines user permissions to access the shared folder.", 'list-title' => "Shared folder | Shared folders", 'list-empty' => "There are no shared folders in this account.", 'new' => "New shared folder", 'type-mail' => "Mail", 'type-event' => "Calendar", 'type-contact' => "Address Book", 'type-task' => "Tasks", 'type-note' => "Notes", 'type-file' => "Files", ], 'signup' => [ 'email' => "Existing Email Address", 'login' => "Login", 'title' => "Sign Up", 'step1' => "Sign up to start your free month.", 'step2' => "We sent out a confirmation code to your email address. Enter the code we sent you, or click the link in the message.", 'step3' => "Create your {app} identity (you can choose additional addresses later).", 'created' => "The account is about to be created!", 'token' => "Signup authorization token", 'voucher' => "Voucher Code", ], 'status' => [ 'prepare-account' => "We are preparing your account.", 'prepare-domain' => "We are preparing the domain.", 'prepare-distlist' => "We are preparing the distribution list.", 'prepare-resource' => "We are preparing the resource.", 'prepare-shared-folder' => "We are preparing the shared folder.", 'prepare-user' => "We are preparing the user account.", 'prepare-hint' => "Some features may be missing or readonly at the moment.", 'prepare-refresh' => "The process never ends? Press the \"Refresh\" button, please.", 'ready-account' => "Your account is almost ready.", 'ready-domain' => "The domain is almost ready.", 'ready-distlist' => "The distribution list is almost ready.", 'ready-resource' => "The resource is almost ready.", 'ready-shared-folder' => "The shared-folder is almost ready.", 'ready-user' => "The user account is almost ready.", 'confirm' => "Confirm your domain to finish the setup process.", 'confirm-domain' => "Confirm domain", 'degraded' => "Degraded", 'deleted' => "Deleted", 'restricted' => "Restricted", 'suspended' => "Suspended", 'notready' => "Not Ready", 'active' => "Active", ], 'support' => [ 'title' => "Contact Support", 'id' => "Customer number or email address you have with us", 'id-pl' => "e.g. 12345678 or the affected email address", 'id-hint' => "Leave blank if you are not a customer yet", 'name' => "Name", 'name-pl' => "how we should call you in our reply", 'email' => "Working email address", 'email-pl' => "make sure we can reach you at this address", 'summary' => "Issue Summary", 'summary-pl' => "one sentence that summarizes your issue", 'expl' => "Issue Explanation", ], 'user' => [ '2fa-hint1' => "This will remove 2-Factor Authentication entitlement as well as the user-configured factors.", '2fa-hint2' => "Please, make sure to confirm the user identity properly.", 'add-beta' => "Enable beta program", 'address' => "Address", 'aliases' => "Aliases", 'aliases-none' => "This user has no email aliases.", 'add-bonus' => "Add bonus", 'add-bonus-title' => "Add a bonus to the wallet", 'add-penalty' => "Add penalty", 'add-penalty-title' => "Add a penalty to the wallet", 'auto-payment' => "Auto-payment", 'auto-payment-text' => "Fill up by {amount} when under {balance} using {method}", 'country' => "Country", 'create' => "Create user", 'custno' => "Customer No.", 'degraded-warning' => "The account is degraded. Some features have been disabled.", 'degraded-hint' => "Please, make a payment.", 'delete' => "Delete user", 'delete-email' => "Delete {email}", 'delete-text' => "Do you really want to delete this user permanently?" . " This will delete all account data and withdraw the permission to access the email account." . " Please note that this action cannot be undone.", 'discount' => "Discount", 'discount-hint' => "applied discount", 'discount-title' => "Account discount", 'distlists' => "Distribution lists", 'domains' => "Domains", 'ext-email' => "External Email", 'email-aliases' => "Email Aliases", 'finances' => "Finances", 'geolimit' => "Geo-lockin", 'geolimit-text' => "Defines a list of locations that are allowed for logon. You will not be able to login from a country that is not listed here.", 'greylisting' => "Greylisting", 'greylisting-text' => "Greylisting is a method of defending users against spam. Any incoming mail from an unrecognized sender " . "is temporarily rejected. The originating server should try again after a delay. " . "This time the email will be accepted. Spammers usually do not reattempt mail delivery.", 'imapproxy' => "IMAP proxy", '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.", 'managed-by' => "Managed by", 'new' => "New user account", 'org' => "Organization", 'package' => "Package", 'pass-input' => "Enter password", 'pass-link' => "Set via link", 'pass-link-label' => "Link:", 'pass-link-hint' => "Press Submit to activate the link", 'passwordpolicy' => "Password Policy", 'price' => "Price", 'profile-delete' => "Delete account", 'profile-delete-title' => "Delete this account?", 'profile-delete-text1' => "This will delete the account as well as all domains, users and aliases associated with this account.", 'profile-delete-warning' => "This operation is irreversible", 'profile-delete-text2' => "As you will not be able to recover anything after this point, please make sure that you have migrated all data before proceeding.", 'profile-delete-support' => "As we always strive to improve, we would like to ask for 2 minutes of your time. " . "The best tool for improvement is feedback from users, and we would like to ask " . "for a few words about your reasons for leaving our service. Please send your feedback to {email}.", 'profile-delete-contact' => "Also feel free to contact {app} Support with any questions or concerns that you may have in this context.", 'reset-2fa' => "Reset 2-Factor Auth", 'reset-2fa-title' => "2-Factor Authentication Reset", 'resources' => "Resources", 'title' => "User account", 'search' => "User email address or name", 'search-pl' => "User ID, email or domain", 'skureq' => "{sku} requires {list}.", 'subscription' => "Subscription", 'subscriptions-none' => "This user has no subscriptions.", 'users' => "Users", ], 'wallet' => [ 'add-credit' => "Add credit", 'auto-payment-cancel' => "Cancel auto-payment", 'auto-payment-change' => "Change auto-payment", 'auto-payment-failed' => "The setup of automatic payments failed. Restart the process to enable automatic top-ups.", 'auto-payment-hint' => "Here is how it works: Every time your account runs low, we will charge your preferred payment method for an amount you choose." . " You can cancel or change the auto-payment option at any time.", 'auto-payment-setup' => "Set up auto-payment", 'auto-payment-disabled' => "The configured auto-payment has been disabled. Top up your wallet or raise the auto-payment amount.", 'auto-payment-info' => "Auto-payment is set to fill up your account by {amount} every time your account balance gets under {balance}.", 'auto-payment-inprogress' => "The setup of the automatic payment is still in progress.", 'auto-payment-next' => "Next, you will be redirected to the checkout page, where you can provide your credit card details.", 'auto-payment-disabled-next' => "The auto-payment is disabled. Immediately after you submit new settings we'll enable it and attempt to top up your wallet.", 'auto-payment-update' => "Update auto-payment", 'banktransfer-hint' => "Please note that a bank transfer can take several days to complete.", 'coinbase-hint' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}." . " We will then create a charge on Coinbase for the specified amount that you can pay using Bitcoin.", 'currency-conv' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}." . " We will then convert this to {pc}, and on the next page you will be provided with the bank-details to transfer the amount in {pc}.", 'fill-up' => "Fill up by", 'history' => "History", 'locked-text' => "The account is locked until you set up auto-payment successfully.", 'month' => "month", 'noperm' => "Only account owners can access a wallet.", 'norefund' => "The money in your wallet is non-refundable.", 'payment-amount-hint' => "Choose the amount by which you want to top up your wallet.", 'payment-method' => "Method of payment: {method}", 'payment-warning' => "You will be charged for {price}.", 'pending-payments' => "Pending Payments", 'pending-payments-warning' => "You have payments that are still in progress. See the \"Pending Payments\" tab below.", 'pending-payments-none' => "There are no pending payments for this account.", 'receipts' => "Receipts", 'receipts-hint' => "Here you can download receipts (in PDF format) for payments in specified period. Select the period and press the Download button.", 'receipts-none' => "There are no receipts for payments in this account. Please, note that you can download receipts after the month ends.", 'title' => "Account balance", 'top-up' => "Top up your wallet", 'transactions' => "Transactions", 'transactions-none' => "There are no transactions for this account.", 'when-below' => "when account balance is below", ], ]; diff --git a/src/resources/vue/Authorize.vue b/src/resources/vue/Authorize.vue index da3181cd..96cecbb8 100644 --- a/src/resources/vue/Authorize.vue +++ b/src/resources/vue/Authorize.vue @@ -1,33 +1,109 @@ diff --git a/src/tests/Browser/AuthorizeTest.php b/src/tests/Browser/AuthorizeTest.php index 5c898343..9eeddeab 100644 --- a/src/tests/Browser/AuthorizeTest.php +++ b/src/tests/Browser/AuthorizeTest.php @@ -1,80 +1,107 @@ client = \App\Auth\PassportClient::firstOrCreate( ['id' => 'test'], [ 'user_id' => null, 'name' => 'Test', 'secret' => '123', 'provider' => 'users', - 'redirect' => 'https://kolab.org', + 'redirect' => \App\Utils::serviceUrl('support'), 'personal_access_client' => 0, 'password_client' => 0, 'revoked' => false, - 'allowed_scopes' => ['email'], + 'allowed_scopes' => ['email', 'auth.token'], ] ); } /** * {@inheritDoc} */ public function tearDown(): void { $this->client->delete(); parent::tearDown(); } /** * Test /oauth/authorize page */ public function testAuthorize(): void { + $user = $this->getTestUser('john@kolab.org'); + $url = '/oauth/authorize?' . http_build_query([ 'client_id' => $this->client->id, 'response_type' => 'code', - 'scope' => 'email', + 'scope' => 'email auth.token', 'state' => 'state', + 'redirect_uri' => $this->client->redirect, ]); - $this->browse(function (Browser $browser) use ($url) { - $redirect_check = "window.location.host == 'kolab.org'" - . " && window.location.search.match(/^\?code=[a-f0-9]+&state=state/)"; + Cache::forget("oauth-seen-{$user->id}-{$this->client->id}"); - // Unauthenticated user + $this->browse(function (Browser $browser) use ($url, $user) { + // Visit the page and expect logon form, then log in $browser->visit($url) ->on(new Home()) - ->submitLogon('john@kolab.org', 'simple123') - ->waitUntil($redirect_check); + ->submitLogon($user->email, 'simple123'); + + // Expect the claims form + $browser->waitFor('#auth-form') + ->assertSeeIn('#auth-form h1', "Test is asking for permission") + ->assertSeeIn('#auth-email', $user->email) + ->assertVisible('#auth-header') + ->assertElementsCount('#auth-claims li', 2) + ->assertSeeIn('#auth-claims li:nth-child(1)', "See your email address") + ->assertSeeIn('#auth-claims li:nth-child(2)', "Have read and write access to") + ->assertSeeIn('#auth-footer', $this->client->redirect) + ->assertSeeIn('#auth-form button.btn-success', 'Allow access') + ->assertSeeIn('#auth-form button.btn-danger', 'No, thanks'); + + // Click the "No, thanks" button + $browser->click('#auth-form button.btn-danger') + ->waitForLocation('/support') + ->assertScript("location.search.match(/^\?error=access_denied&state=state/) !== null"); + + // Visit the page again and click the "Allow access" button + $browser->visit($url) + ->waitFor('#auth-form button.btn-success') + ->click('#auth-form button.btn-success') + ->waitForLocation('/support') + ->assertScript("location.search.match(/^\?code=[a-f0-9]+&state=state/) !== null"); - // Authenticated user + // Visit the page and expect an immediate redirect $browser->visit($url) - ->waitUntil($redirect_check); + ->waitForLocation('/support') + ->assertScript("location.search.match(/^\?code=[a-f0-9]+&state=state/) !== null"); // Error handling (invalid response_type) - $browser->visit('oauth/authorize?response_type=invalid') - ->assertErrorPage(422) - ->assertToast(Toast::TYPE_ERROR, 'Invalid value of request property: response_type.'); + $browser->visit(str_replace('response_type=code', 'response_type=invalid', $url)) + ->waitForLocation('/support') + ->assertScript("location.search.match(/^\?error=unsupported_response_type&state=state/) !== null"); }); } } diff --git a/src/tests/BrowserAddonTrait.php b/src/tests/BrowserAddonTrait.php index 7fa8a34f..f2f87570 100644 --- a/src/tests/BrowserAddonTrait.php +++ b/src/tests/BrowserAddonTrait.php @@ -1,85 +1,85 @@ driver(); }, 50); $this->browser = new Browser($driver); $screenshots_dir = __DIR__ . '/Browser/screenshots/'; Browser::$storeScreenshotsAt = $screenshots_dir; if (!file_exists($screenshots_dir)) { mkdir($screenshots_dir, 0777, true); } return $this->browser; } /** * (Automatically) stop the browser and driver process * * @afterClass */ protected function stopBrowser(): void { if ($this->browser) { $this->browser->quit(); static::stopChromeDriver(); $this->browser = null; } } /** * Initialize and start Chrome driver */ protected function driver() { - static::startChromeDriver(); + static::startChromeDriver(['--port=9515']); $options = (new ChromeOptions())->addArguments([ '--lang=en_US', '--disable-gpu', '--headless', '--no-sandbox', ]); return RemoteWebDriver::create( 'http://localhost:9515', DesiredCapabilities::chrome()->setCapability( ChromeOptions::CAPABILITY, $options ) ); } /** * Register an "after class" tear down callback. * * @param \Closure $callback */ public static function afterClass(\Closure $callback): void { // This method is required by SupportsChrome trait } } diff --git a/src/tests/Feature/Controller/AuthTest.php b/src/tests/Feature/Controller/AuthTest.php index 9915f03b..4d511d54 100644 --- a/src/tests/Feature/Controller/AuthTest.php +++ b/src/tests/Feature/Controller/AuthTest.php @@ -1,586 +1,650 @@ app['auth']->forgetGuards(); } /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestDomain('userscontroller.com'); $this->expectedExpiry = \config('auth.token_expiry_minutes') * 60; \App\IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete(); $user = $this->getTestUser('john@kolab.org'); $user->setSetting('limit_geo', null); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestDomain('userscontroller.com'); \App\IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete(); $user = $this->getTestUser('john@kolab.org'); $user->setSetting('limit_geo', null); parent::tearDown(); } /** * Test fetching current user info (/api/auth/info) */ public function testInfo(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com', ['status' => User::STATUS_NEW]); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $response = $this->get("api/auth/info"); $response->assertStatus(401); $response = $this->actingAs($user)->get("api/auth/info"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($user->id, $json['id']); $this->assertEquals($user->email, $json['email']); $this->assertEquals(User::STATUS_NEW, $json['status']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); $this->assertTrue(!isset($json['access_token'])); // Note: Details of the content are tested in testUserResponse() // Test token refresh via the info request // First we log in to get the refresh token $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $user = $this->getTestUser('john@kolab.org'); $response = $this->post("api/auth/login", $post); $json = $response->json(); $response = $this->actingAs($user) ->post("api/auth/info?refresh=1", ['refresh_token' => $json['refresh_token']]); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('john@kolab.org', $json['email']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue(!empty($json['expires_in'])); } /** * Test fetching current user location (/api/auth/location) */ public function testLocation(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); // Authentication required $response = $this->get("api/auth/location"); $response->assertStatus(401); $headers = ['X-Client-IP' => '127.0.0.2']; $response = $this->actingAs($user)->withHeaders($headers)->get("api/auth/location"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('127.0.0.2', $json['ipAddress']); $this->assertSame('', $json['countryCode']); \App\IP4Net::create([ 'net_number' => '127.0.0.0', 'net_broadcast' => '127.255.255.255', 'net_mask' => 8, 'country' => 'US', 'rir_name' => 'test', 'serial' => 1, ]); $response = $this->actingAs($user)->withHeaders($headers)->get("api/auth/location"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('127.0.0.2', $json['ipAddress']); $this->assertSame('US', $json['countryCode']); } /** * Test /api/auth/login */ public function testLogin(): string { $user = $this->getTestUser('john@kolab.org'); // Request with no data $response = $this->post("api/auth/login", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); $this->assertArrayHasKey('password', $json['errors']); // Request with invalid password $post = ['email' => 'john@kolab.org', 'password' => 'wrong']; $response = $this->post("api/auth/login", $post); $response->assertStatus(401); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame('Invalid username or password.', $json['message']); // Valid user+password $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $response = $this->post("api/auth/login", $post); $json = $response->json(); $response->assertStatus(200); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue( ($this->expectedExpiry - 5) < $json['expires_in'] && $json['expires_in'] < ($this->expectedExpiry + 5) ); $this->assertEquals('bearer', $json['token_type']); $this->assertEquals($user->id, $json['id']); $this->assertEquals($user->email, $json['email']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); // Valid user+password (upper-case) $post = ['email' => 'John@Kolab.org', 'password' => 'simple123']; $response = $this->post("api/auth/login", $post); $json = $response->json(); $response->assertStatus(200); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue( ($this->expectedExpiry - 5) < $json['expires_in'] && $json['expires_in'] < ($this->expectedExpiry + 5) ); $this->assertEquals('bearer', $json['token_type']); // TODO: We have browser tests for 2FA but we should probably also test it here return $json['access_token']; } /** * Test /api/auth/login with geo-lockin */ public function testLoginGeoLock(): void { $user = $this->getTestUser('john@kolab.org'); $user->setConfig(['limit_geo' => ['US']]); $headers['X-Client-IP'] = '127.0.0.2'; $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $response = $this->withHeaders($headers)->post("api/auth/login", $post); $response->assertStatus(401); $json = $response->json(); $this->assertSame("Invalid username or password.", $json['message']); $this->assertSame('error', $json['status']); \App\IP4Net::create([ 'net_number' => '127.0.0.0', 'net_broadcast' => '127.255.255.255', 'net_mask' => 8, 'country' => 'US', 'rir_name' => 'test', 'serial' => 1, ]); $response = $this->withHeaders($headers)->post("api/auth/login", $post); $response->assertStatus(200); $json = $response->json(); $this->assertTrue(!empty($json['access_token'])); $this->assertEquals($user->id, $json['id']); } /** * Test /api/auth/logout * * @depends testLogin */ public function testLogout($token): void { // Request with no token, testing that it requires auth $response = $this->post("api/auth/logout"); $response->assertStatus(401); // Test the same using JSON mode $response = $this->json('POST', "api/auth/logout", []); $response->assertStatus(401); // Request with invalid token $response = $this->withHeaders(['Authorization' => 'Bearer ' . "foobar"])->post("api/auth/logout"); $response->assertStatus(401); // Request with valid token $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->post("api/auth/logout"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('Successfully logged out.', $json['message']); $this->resetAuth(); // Check if it really destroyed the token? $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->get("api/auth/info"); $response->assertStatus(401); } /** * Test /api/auth/refresh */ public function testRefresh(): void { // Request with no token, testing that it requires auth $response = $this->post("api/auth/refresh"); $response->assertStatus(401); // Test the same using JSON mode $response = $this->json('POST', "api/auth/refresh", []); $response->assertStatus(401); // Login the user to get a valid token $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $response = $this->post("api/auth/login", $post); $response->assertStatus(200); $json = $response->json(); $token = $json['access_token']; $user = $this->getTestUser('john@kolab.org'); // Request with a valid token $response = $this->actingAs($user)->post("api/auth/refresh", ['refresh_token' => $json['refresh_token']]); $response->assertStatus(200); $json = $response->json(); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue($json['access_token'] != $token); $this->assertTrue( ($this->expectedExpiry - 5) < $json['expires_in'] && $json['expires_in'] < ($this->expectedExpiry + 5) ); $this->assertEquals('bearer', $json['token_type']); $new_token = $json['access_token']; // TODO: Shall we invalidate the old token? // And if the new token is working $response = $this->withHeaders(['Authorization' => 'Bearer ' . $new_token])->get("api/auth/info"); $response->assertStatus(200); } /** * Test OAuth2 Authorization Code Flow */ public function testOAuthAuthorizationCodeFlow(): void { $user = $this->getTestUser('john@kolab.org'); // Request unauthenticated, testing that it requires auth $response = $this->post("api/oauth/approve"); $response->assertStatus(401); // Request authenticated, invalid POST data $post = ['response_type' => 'unknown']; $response = $this->actingAs($user)->post("api/oauth/approve", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); - $this->assertSame('Invalid value of request property: response_type.', $json['message']); + $this->assertSame('unsupported_response_type', $json['error']); + $this->assertSame('Invalid authorization request.', $json['message']); // Request authenticated, invalid POST data $post = [ 'client_id' => 'unknown', 'response_type' => 'code', 'scope' => 'email', // space-separated 'state' => 'state', // optional 'nonce' => 'nonce', // optional ]; $response = $this->actingAs($user)->post("api/oauth/approve", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); + $this->assertSame('invalid_client', $json['error']); $this->assertSame('Client authentication failed', $json['message']); $client = \App\Auth\PassportClient::find(\config('auth.synapse.client_id')); $post['client_id'] = $client->id; // Request authenticated, invalid scope $post['scope'] = 'unknown'; $response = $this->actingAs($user)->post("api/oauth/approve", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); + $this->assertSame('invalid_scope', $json['error']); $this->assertSame('The requested scope is invalid, unknown, or malformed', $json['message']); // Request authenticated, valid POST data $post['scope'] = 'email'; $response = $this->actingAs($user)->post("api/oauth/approve", $post); $response->assertStatus(200); $json = $response->json(); $url = $json['redirectUrl']; parse_str(parse_url($url, \PHP_URL_QUERY), $params); $this->assertTrue(str_starts_with($url, $client->redirect . '?')); $this->assertCount(2, $params); $this->assertSame('state', $params['state']); $this->assertMatchesRegularExpression('/^[a-f0-9]{50,}$/', $params['code']); $this->assertSame('success', $json['status']); // Note: We do not validate the code trusting Passport to do the right thing. Should we not? // Token endpoint tests // Valid authorization code, but invalid secret $post = [ 'grant_type' => 'authorization_code', 'client_id' => $client->id, 'client_secret' => 'invalid', // 'redirect_uri' => '', 'code' => $params['code'], ]; // Note: This is a 'web' route, not 'api' $this->resetAuth(); // reset guards $response = $this->post("/oauth/token", $post); $response->assertStatus(401); $json = $response->json(); $this->assertSame('invalid_client', $json['error']); $this->assertTrue(!empty($json['error_description'])); // Valid authorization code $post['client_secret'] = \config('auth.synapse.client_secret'); $response = $this->post("/oauth/token", $post); $response->assertStatus(200); $params = $response->json(); $this->assertSame('Bearer', $params['token_type']); $this->assertTrue(!empty($params['access_token'])); $this->assertTrue(!empty($params['refresh_token'])); $this->assertTrue(!empty($params['expires_in'])); $this->assertTrue(empty($params['id_token'])); // Invalid authorization code // Note: The code is being revoked on use, so we expect it does not work anymore $response = $this->post("/oauth/token", $post); $response->assertStatus(400); $json = $response->json(); $this->assertSame('invalid_request', $json['error']); $this->assertTrue(!empty($json['error_description'])); // Token refresh unset($post['code']); $post['grant_type'] = 'refresh_token'; $post['refresh_token'] = $params['refresh_token']; $response = $this->post("/oauth/token", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('Bearer', $json['token_type']); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue(!empty($json['refresh_token'])); $this->assertTrue(!empty($json['expires_in'])); $this->assertTrue(empty($json['id_token'])); $this->assertNotEquals($json['access_token'], $params['access_token']); $this->assertNotEquals($json['refresh_token'], $params['refresh_token']); $token = $json['access_token']; // Validate the access token works on /oauth/userinfo endpoint $this->resetAuth(); // reset guards $headers = ['Authorization' => 'Bearer ' . $token]; $response = $this->withHeaders($headers)->get("/oauth/userinfo"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($user->id, $json['sub']); $this->assertEquals($user->email, $json['email']); // Validate that the access token does not give access to API other than /oauth/userinfo $this->resetAuth(); // reset guards $response = $this->withHeaders($headers)->get("/api/auth/location"); $response->assertStatus(403); } + /** + * Test Oauth approve end-point in ifSeen mode + */ + public function testOAuthApprovePrompt(): void + { + // HTTP_HOST is not set in tests for some reason, but it's required down the line + $host = parse_url(\App\Utils::serviceUrl('/'), \PHP_URL_HOST); + $_SERVER['HTTP_HOST'] = $host; + + $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); + $client = \App\Auth\PassportClient::find(\config('auth.sso.client_id')); + + $post = [ + 'client_id' => $client->id, + 'response_type' => 'code', + 'scope' => 'openid email auth.token', + 'state' => 'state', + 'nonce' => 'nonce', + 'ifSeen' => '1', + ]; + + $response = $this->actingAs($user)->post("api/oauth/approve", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $claims = [ + 'email' => 'See your email address', + 'auth.token' => 'Have read and write access to all your data', + ]; + + $this->assertSame('prompt', $json['status']); + $this->assertSame($client->name, $json['client']['name']); + $this->assertSame($client->redirect, $json['client']['url']); + $this->assertSame($claims, $json['client']['claims']); + + // Approve the request + $post['ifSeen'] = 0; + $response = $this->actingAs($user)->post("api/oauth/approve", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertTrue(!empty($json['redirectUrl'])); + + // Second request with ifSeen=1 should succeed with the code + $post['ifSeen'] = 1; + $response = $this->actingAs($user)->post("api/oauth/approve", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertTrue(!empty($json['redirectUrl'])); + } + /** * Test OpenID-Connect Authorization Code Flow */ public function testOIDCAuthorizationCodeFlow(): void { + // HTTP_HOST is not set in tests for some reason, but it's required down the line + $host = parse_url(\App\Utils::serviceUrl('/'), \PHP_URL_HOST); + $_SERVER['HTTP_HOST'] = $host; + $user = $this->getTestUser('john@kolab.org'); $client = \App\Auth\PassportClient::find(\config('auth.sso.client_id')); // Note: Invalid input cases were tested above, we omit them here // This is essentially the same as for OAuth2, but with extended scopes $post = [ 'client_id' => $client->id, 'response_type' => 'code', 'scope' => 'openid email auth.token', 'state' => 'state', 'nonce' => 'nonce', ]; $response = $this->actingAs($user)->post("api/oauth/approve", $post); $response->assertStatus(200); $json = $response->json(); $url = $json['redirectUrl']; parse_str(parse_url($url, \PHP_URL_QUERY), $params); $this->assertTrue(str_starts_with($url, $client->redirect . '?')); $this->assertCount(2, $params); $this->assertSame('state', $params['state']); $this->assertMatchesRegularExpression('/^[a-f0-9]{50,}$/', $params['code']); $this->assertSame('success', $json['status']); // Token endpoint tests $post = [ 'grant_type' => 'authorization_code', 'client_id' => $client->id, 'client_secret' => \config('auth.synapse.client_secret'), 'code' => $params['code'], ]; $this->resetAuth(); // reset guards state $response = $this->post("/oauth/token", $post); $response->assertStatus(200); $params = $response->json(); $this->assertSame('Bearer', $params['token_type']); $this->assertTrue(!empty($params['access_token'])); $this->assertTrue(!empty($params['refresh_token'])); $this->assertTrue(!empty($params['id_token'])); $this->assertTrue(!empty($params['expires_in'])); $token = $this->parseIdToken($params['id_token']); $this->assertSame('JWT', $token['typ']); $this->assertSame('RS256', $token['alg']); $this->assertSame('nonce', $token['nonce']); $this->assertSame(url('/'), $token['iss']); $this->assertSame($user->email, $token['email']); $this->assertSame((string) $user->id, \App\Auth\Utils::tokenValidate($token['auth.token'])); // TODO: Validate JWT token properly // Token refresh unset($post['code']); $post['grant_type'] = 'refresh_token'; $post['refresh_token'] = $params['refresh_token']; $response = $this->post("/oauth/token", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('Bearer', $json['token_type']); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue(!empty($json['refresh_token'])); $this->assertTrue(!empty($json['id_token'])); $this->assertTrue(!empty($json['expires_in'])); // Validate the access token works on /oauth/userinfo endpoint $this->resetAuth(); // reset guards state $headers = ['Authorization' => 'Bearer ' . $json['access_token']]; $response = $this->withHeaders($headers)->get("/oauth/userinfo"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($user->id, $json['sub']); $this->assertEquals($user->email, $json['email']); // Validate that the access token does not give access to API other than /oauth/userinfo $this->resetAuth(); // reset guards state $response = $this->withHeaders($headers)->get("/api/auth/location"); $response->assertStatus(403); } /** * Test to make sure Passport routes are disabled */ public function testPassportDisabledRoutes(): void { $this->post("/oauth/authorize", [])->assertStatus(405); $this->post("/oauth/token/refresh", [])->assertStatus(405); } /** * Parse JWT token into an array */ private function parseIdToken($token): array { [$headb64, $bodyb64, $cryptob64] = explode('.', $token); $header = json_decode(base64_decode(strtr($headb64, '-_', '+/'), true), true); $body = json_decode(base64_decode(strtr($bodyb64, '-_', '+/'), true), true); return array_merge($header, $body); } } diff --git a/src/tests/Feature/Controller/WellKnownTest.php b/src/tests/Feature/Controller/WellKnownTest.php index 5cf7fa93..d0a40c73 100644 --- a/src/tests/Feature/Controller/WellKnownTest.php +++ b/src/tests/Feature/Controller/WellKnownTest.php @@ -1,54 +1,58 @@ get('.well-known/openid-configuration'); $response->assertStatus(200) ->assertJson([ 'issuer' => $href, 'authorization_endpoint' => $href . '/oauth/authorize', 'token_endpoint' => $href . '/oauth/token', 'userinfo_endpoint' => $href . '/oauth/userinfo', 'grant_types_supported' => [ 'authorization_code', 'client_credentials', 'refresh_token', 'password', ], 'response_types_supported' => [ 'code' ], 'id_token_signing_alg_values_supported' => [ 'RS256' ], 'scopes_supported' => [ 'openid', 'email', ], ]); } /** * Test ./well-known/mta-sts.txt */ public function testMtaSts(): void { $domain = \config('app.domain'); $response = $this->get('.well-known/mta-sts.txt'); $response->assertStatus(200) ->assertHeader('Content-Type', 'text/plain; charset=UTF-8') ->assertContent("version: STSv1\nmode: enforce\nmx: {$domain}\nmax_age: 604800"); } } diff --git a/src/tests/TestCaseDusk.php b/src/tests/TestCaseDusk.php index b6f5a5f4..d71b7677 100644 --- a/src/tests/TestCaseDusk.php +++ b/src/tests/TestCaseDusk.php @@ -1,140 +1,141 @@ = 8 with PHPUnit v9. It can be removed * when we switch to PHPUnit v10. * * @afterClass * @return void */ public static function tearDownDuskClass() { static::closeAll(); foreach (static::$afterClassCallbacks as $callback) { $callback(); } } /** * Create the RemoteWebDriver instance. * * @return \Facebook\WebDriver\Remote\RemoteWebDriver */ protected function driver() { $options = (new ChromeOptions())->addArguments([ '--lang=en_US', '--disable-gpu', '--headless', '--no-sandbox', '--disable-dev-shm-usage', '--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream', '--enable-usermedia-screen-capturing', // '--auto-select-desktop-capture-source="Entire screen"', '--ignore-certificate-errors', '--incognito', ]); // For file download handling $prefs = [ 'profile.default_content_settings.popups' => 0, 'download.default_directory' => __DIR__ . '/Browser/downloads', ]; $options->setExperimentalOption('prefs', $prefs); if (getenv('TESTS_MODE') == 'phone') { // Fake User-Agent string for mobile mode $ua = 'Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/537.36' . ' (KHTML, like Gecko) Chrome/60.0.3112.90 Mobile Safari/537.36'; $options->setExperimentalOption('mobileEmulation', ['userAgent' => $ua]); $options->addArguments(['--window-size=375,667']); } elseif (getenv('TESTS_MODE') == 'tablet') { - // Fake User-Agent string for mobile mode + // Fake User-Agent string for tablet mode $ua = 'Mozilla/5.0 (Linux; Android 6.0.1; vivo 1603 Build/MMB29M) AppleWebKit/537.36 ' . ' (KHTML, like Gecko) Chrome/58.0.3029.83 Mobile Safari/537.36'; $options->setExperimentalOption('mobileEmulation', ['userAgent' => $ua]); $options->addArguments(['--window-size=800,640']); } else { $options->addArguments(['--window-size=1280,1024']); } // Make sure downloads dir exists and is empty if (!file_exists(__DIR__ . '/Browser/downloads')) { mkdir(__DIR__ . '/Browser/downloads', 0777, true); } else { foreach (glob(__DIR__ . '/Browser/downloads/*') as $file) { @unlink($file); } } return RemoteWebDriver::create( 'http://localhost:9515', DesiredCapabilities::chrome()->setCapability( ChromeOptions::CAPABILITY, $options ) ); } /** * Replace Dusk's Browser with our (extended) Browser */ protected function newBrowser($driver) { return new Browser($driver); } /** * Set baseURL to the admin UI location */ protected static function useAdminUrl(): void { // This will set baseURL for all tests in this file // If we wanted to visit both user and admin in one test // we can also just call visit() with full url Browser::$baseUrl = str_replace('//', '//admin.', \config('app.url')); } /** * Set baseURL to the reseller UI location */ protected static function useResellerUrl(): void { // This will set baseURL for all tests in this file // If we wanted to visit both user and admin in one test // we can also just call visit() with full url Browser::$baseUrl = str_replace('//', '//reseller.', \config('app.url')); } }