diff --git a/components/dashboard/public/flow-result/index.html b/components/dashboard/public/complete-auth/index.html similarity index 78% rename from components/dashboard/public/flow-result/index.html rename to components/dashboard/public/complete-auth/index.html index 9e135a0cf822fd..7e87db0dc735d6 100644 --- a/components/dashboard/public/flow-result/index.html +++ b/components/dashboard/public/complete-auth/index.html @@ -19,6 +19,6 @@ - This browser tab is supposed to close automatically. + If this tab is not closed automatically, feel free to close it and proceed. \ No newline at end of file diff --git a/components/dashboard/src/Login.tsx b/components/dashboard/src/Login.tsx index 0efca69e5fce77..4408b4217f5416 100644 --- a/components/dashboard/src/Login.tsx +++ b/components/dashboard/src/Login.tsx @@ -7,8 +7,8 @@ import { AuthProviderInfo } from "@gitpod/gitpod-protocol"; import { useContext, useEffect, useState } from "react"; import { UserContext } from "./user-context"; -import { getGitpodService, gitpodHostUrl } from "./service/service"; -import { iconForAuthProvider, simplifyProviderName } from "./provider-utils"; +import { getGitpodService } from "./service/service"; +import { iconForAuthProvider, openAuthorizeWindow, simplifyProviderName } from "./provider-utils"; import gitpod from './images/gitpod.svg'; import gitpodIcon from './icons/gitpod.svg'; import automate from "./images/welcome/automate.svg"; @@ -17,10 +17,10 @@ import collaborate from "./images/welcome/collaborate.svg"; import customize from "./images/welcome/customize.svg"; import fresh from "./images/welcome/fresh.svg"; import prebuild from "./images/welcome/prebuild.svg"; +import exclamation from "./images/exclamation.svg"; - -function Item(props: {icon: string, iconSize?: string, text:string}) { +function Item(props: { icon: string, iconSize?: string, text: string }) { const iconSize = props.iconSize || 28; return
@@ -29,7 +29,7 @@ function Item(props: {icon: string, iconSize?: string, text:string}) { } export function markLoggedIn() { - document.cookie = "gitpod-user=loggedIn;max-age=" + 60*60*24*365; + document.cookie = "gitpod-user=loggedIn;max-age=" + 60 * 60 * 24 * 365; } export function hasLoggedInBefore() { @@ -41,40 +41,49 @@ export function Login() { const showWelcome = !hasLoggedInBefore(); const [authProviders, setAuthProviders] = useState([]); + const [errorMessage, setErrorMessage] = useState(undefined); useEffect(() => { (async () => { setAuthProviders(await getGitpodService().server.getAuthProviders()); })(); + }, []) - const listener = (event: MessageEvent) => { - // todo: check event.origin - - if (event.data === "success") { - if (event.source && "close" in event.source && event.source.close) { - console.log(`try to close window`); - event.source.close(); + const openLogin = async (host: string) => { + setErrorMessage(undefined); + + try { + await openAuthorizeWindow({ + login: true, + host, + onSuccess: () => updateUser(), + onError: (error) => { + if (typeof error === "string") { + try { + const payload = JSON.parse(error); + if (typeof payload === "object" && payload.error) { + if (payload.error === "email_taken") { + return setErrorMessage(`Email address already exists. Log in using a different provider.`); + } + return setErrorMessage(payload.description ? payload.description : `Error: ${payload.error}`); + } + } catch (error) { + console.log(error); + } + setErrorMessage(error); + } } - - (async () => { - await getGitpodService().reconnect(); - setUser(await getGitpodService().server.getLoggedInUser()); - markLoggedIn(); - })(); - } - }; - window.addEventListener("message", listener); - return () => { - window.removeEventListener("message", listener); + }); + } catch (error) { + console.log(error) } - }, []) + } - const openLogin = (host: string) => { - const url = getLoginUrl(host); - const newWindow = window.open(url, "gitpod-login"); - if (!newWindow) { - console.log(`Failed to open login window for ${host}`); - } + const updateUser = async () => { + await getGitpodService().reconnect(); + const user = await getGitpodService().server.getLoggedInUser(); + setUser(user); + markLoggedIn(); } return (
@@ -91,18 +100,18 @@ export function Login() {
- - - + + +
- - - + + +
- : null} + : null}
@@ -111,7 +120,7 @@ export function Login() {
-

Log in{showWelcome? ' to Gitpod' : ''}

+

Log in{showWelcome ? ' to Gitpod' : ''}

ALWAYS READY-TO-CODE

@@ -124,6 +133,18 @@ export function Login() { ); })}
+ + {errorMessage && ( +
+
+ +
+
+

{errorMessage}

+
+
+ )} +
@@ -136,11 +157,3 @@ export function Login() {
); } - -function getLoginUrl(host: string) { - const returnTo = gitpodHostUrl.with({ pathname: 'flow-result', search: 'message=success' }).toString(); - return gitpodHostUrl.withApi({ - pathname: '/login', - search: `host=${host}&returnTo=${encodeURIComponent(returnTo)}` - }).toString(); -} diff --git a/components/dashboard/src/prebuilds/InstallGitHubApp.tsx b/components/dashboard/src/prebuilds/InstallGitHubApp.tsx index 587defefe0762f..a93398b5ea6d31 100644 --- a/components/dashboard/src/prebuilds/InstallGitHubApp.tsx +++ b/components/dashboard/src/prebuilds/InstallGitHubApp.tsx @@ -16,7 +16,7 @@ async function registerApp(installationId: string, setModal: (modal: 'done' | st try { await getGitpodService().server.registerGithubApp(installationId); - const returnTo = encodeURIComponent(gitpodHostUrl.with({ pathname: 'flow-result', search: 'message=success' }).toString()); + const returnTo = encodeURIComponent(gitpodHostUrl.with({ pathname: 'complete-auth', search: 'message=success' }).toString()); const url = gitpodHostUrl.withApi({ pathname: '/authorize', search: `returnTo=${returnTo}&host=github.com&scopes=repo` diff --git a/components/dashboard/src/provider-utils.tsx b/components/dashboard/src/provider-utils.tsx index 309bffe67481d4..9209d1c746571f 100644 --- a/components/dashboard/src/provider-utils.tsx +++ b/components/dashboard/src/provider-utils.tsx @@ -36,12 +36,26 @@ function simplifyProviderName(host: string) { } } -async function openAuthorizeWindow({ host, scopes, onSuccess, onError }: { host: string, scopes?: string[], onSuccess?: (payload?: string) => void, onError?: (error?: string) => void }) { - const returnTo = gitpodHostUrl.with({ pathname: 'flow-result', search: 'message=success' }).toString(); - const url = gitpodHostUrl.withApi({ - pathname: '/authorize', - search: `returnTo=${encodeURIComponent(returnTo)}&host=${host}&override=true&scopes=${(scopes || []).join(',')}` - }).toString(); +interface OpenAuthorizeWindowParams { + login?: boolean; + host: string; + scopes?: string[]; + onSuccess?: (payload?: string) => void; + onError?: (error?: string) => void; +} + +async function openAuthorizeWindow(params: OpenAuthorizeWindowParams) { + const { login, host, scopes, onSuccess, onError } = params; + const returnTo = gitpodHostUrl.with({ pathname: 'complete-auth', search: 'message=success' }).toString(); + const url = login + ? gitpodHostUrl.withApi({ + pathname: '/login', + search: `host=${host}&returnTo=${encodeURIComponent(returnTo)}` + }).toString() + : gitpodHostUrl.withApi({ + pathname: '/authorize', + search: `returnTo=${encodeURIComponent(returnTo)}&host=${host}&override=true&scopes=${(scopes || []).join(',')}` + }).toString(); const newWindow = window.open(url, "gitpod-auth-window"); if (!newWindow) { @@ -55,7 +69,7 @@ async function openAuthorizeWindow({ host, scopes, onSuccess, onError }: { host: const killAuthWindow = () => { window.removeEventListener("message", eventListener); - + if (event.source && "close" in event.source && event.source.close) { console.log(`Received Auth Window Result. Closing Window.`); event.source.close(); @@ -67,9 +81,9 @@ async function openAuthorizeWindow({ host, scopes, onSuccess, onError }: { host: onSuccess && onSuccess(); } if (typeof event.data === "string" && event.data.startsWith("error:")) { - const errorText = atob(event.data.substring("error:".length)); + const errorAsText = atob(event.data.substring("error:".length)); killAuthWindow(); - onError && onError(errorText); + onError && onError(errorAsText); } }; window.addEventListener("message", eventListener); diff --git a/components/dashboard/src/settings/Integrations.tsx b/components/dashboard/src/settings/Integrations.tsx index ffcd32e37c272e..8ceab4cdde071a 100644 --- a/components/dashboard/src/settings/Integrations.tsx +++ b/components/dashboard/src/settings/Integrations.tsx @@ -123,7 +123,7 @@ function GitProviders() { const disconnect = async (ap: AuthProviderInfo) => { setDisconnectModal(undefined); - const returnTo = gitpodHostUrl.with({ pathname: 'flow-result', search: 'message=success' }).toString(); + const returnTo = gitpodHostUrl.with({ pathname: 'complete-auth', search: 'message=success' }).toString(); const deauthorizeUrl = gitpodHostUrl.withApi({ pathname: '/deauthorize', search: `returnTo=${returnTo}&host=${ap.host}` diff --git a/components/server/src/auth/errors.ts b/components/server/src/auth/errors.ts index 191dcf4e3c4318..56a9f434427da7 100644 --- a/components/server/src/auth/errors.ts +++ b/components/server/src/auth/errors.ts @@ -67,3 +67,16 @@ export namespace SelectAccountException { return AuthException.is(error) && error.authException === type; } } + +export interface EmailAddressAlreadyTakenException extends AuthException { + payload: string; +} +export namespace EmailAddressAlreadyTakenException { + const type = "EmailAddressAlreadyTakenException"; + export function create(message: string) { + return AuthException.create(type, message, message); + } + export function is(error: any): error is EmailAddressAlreadyTakenException { + return AuthException.is(error) && error.authException === type; + } +} diff --git a/components/server/src/auth/generic-auth-provider.ts b/components/server/src/auth/generic-auth-provider.ts index a5c69d3bc03bd9..ed393b236583b9 100644 --- a/components/server/src/auth/generic-auth-provider.ts +++ b/components/server/src/auth/generic-auth-provider.ts @@ -13,13 +13,12 @@ import { AuthProviderInfo, Identity, Token, User } from '@gitpod/gitpod-protocol import { log, LogContext } from '@gitpod/gitpod-protocol/lib/util/logging'; import fetch from "node-fetch"; import { oauth2tokenCallback, OAuth2 } from 'oauth'; -import { format as formatURL, URL } from 'url'; +import { URL } from 'url'; import { runInNewContext } from "vm"; import { AuthFlow, AuthProvider } from "../auth/auth-provider"; import { AuthProviderParams, AuthUserSetup } from "../auth/auth-provider"; -import { AuthException, SelectAccountException } from "../auth/errors"; +import { AuthException, EmailAddressAlreadyTakenException, SelectAccountException } from "../auth/errors"; import { GitpodCookie } from "./gitpod-cookie"; -import { SelectAccountCookie } from "../user/select-account-cookie"; import { Env } from "../env"; import { getRequestingClientInfo } from "../express-util"; import { TokenProvider } from '../user/token-provider'; @@ -64,7 +63,6 @@ export class GenericAuthProvider implements AuthProvider { @inject(UserDB) protected userDb: UserDB; @inject(Env) protected env: Env; @inject(GitpodCookie) protected gitpodCookie: GitpodCookie; - @inject(SelectAccountCookie) protected selectAccountCookie: SelectAccountCookie; @inject(UserService) protected readonly userService: UserService; @inject(AuthProviderService) protected readonly authProviderService: AuthProviderService; @inject(LoginCompletionHandler) protected readonly loginCompletionHandler: LoginCompletionHandler; @@ -297,17 +295,17 @@ export class GenericAuthProvider implements AuthProvider { const defaultLogPayload = { authFlow, clientInfo, authProviderId, request }; // check OAuth2 errors - const error = new URL(formatURL({ protocol: request.protocol, host: request.get('host'), pathname: request.originalUrl })).searchParams.get("error"); + const callbackParams = new URL(`https://anyhost${request.originalUrl}`).searchParams; + const error = callbackParams.get("error"); + const description: string | null = callbackParams.get("error_description"); + if (error) { // e.g. "access_denied" // Clean up the session await AuthFlow.clear(request.session); await TosFlow.clear(request.session); increaseLoginCounter("failed", this.host); - - log.info(cxt, `(${strategyName}) Received OAuth2 error, thus redirecting to /sorry (${error})`, { ...defaultLogPayload, requestUrl: request.originalUrl }); - response.redirect(this.getSorryUrl(`OAuth2 error. (${error})`)); - return; + return this.sendCompletionRedirectWithError(response, { error, description }); } let result: Parameters; @@ -347,12 +345,10 @@ export class GenericAuthProvider implements AuthProvider { await TosFlow.clear(request.session); if (SelectAccountException.is(err)) { - this.selectAccountCookie.set(response, err.payload); - - // option 1: send as GET param on redirect - const url = this.env.hostUrl.with({ pathname: '/flow-result', search: "message=error:" + Buffer.from(JSON.stringify(err.payload), "utf-8").toString('base64') }).toString(); - response.redirect(url); - return; + return this.sendCompletionRedirectWithError(response, err.payload); + } + if (EmailAddressAlreadyTakenException.is(err)) { + return this.sendCompletionRedirectWithError(response, { error: "email_taken" }); } let message = 'Authorization failed. Please try again.'; @@ -394,6 +390,13 @@ export class GenericAuthProvider implements AuthProvider { } } + protected sendCompletionRedirectWithError(response: express.Response, error: object): void { + log.info(`(${this.strategyName}) Send completion redirect with error`, { error }); + + const url = this.env.hostUrl.with({ pathname: '/complete-auth', search: "message=error:" + Buffer.from(JSON.stringify(error), "utf-8").toString('base64') }).toString(); + response.redirect(url); + } + /** * cf. part (2) of `callback` function * @@ -435,6 +438,21 @@ export class GenericAuthProvider implements AuthProvider { } else { // no user session present, let's initiate a login currentGitpodUser = await this.userService.findUserForLogin({ candidate }); + + if (!currentGitpodUser) { + + // signup new accounts with email adresses already taken is disallowed + const existingUserWithSameEmail = (await this.userDb.findUsersByEmail(primaryEmail))[0]; + if (existingUserWithSameEmail) { + try { + await this.userService.asserNoAccountWithEmail(primaryEmail); + } catch (error) { + log.warn(`Login attempt with matching email address.`, { ...defaultLogPayload, authUser, candidate, clientInfo }); + done(error, undefined); + return; + } + } + } } const token = this.createToken(this.tokenUsername, accessToken, refreshToken, currentScopes, tokenResponse.expires_in); diff --git a/components/server/src/container-module.ts b/components/server/src/container-module.ts index 89719807f20675..9008a285862baa 100644 --- a/components/server/src/container-module.ts +++ b/components/server/src/container-module.ts @@ -64,7 +64,6 @@ import { MonitoringEndpointsApp } from './monitoring-endpoints'; import { BearerAuth } from './auth/bearer-authenticator'; import { TermsProvider } from './terms/terms-provider'; import { TosCookie } from './user/tos-cookie'; -import { SelectAccountCookie } from './user/select-account-cookie'; import { ContentServiceClient } from '@gitpod/content-service/lib/content_grpc_pb'; import { BlobServiceClient } from '@gitpod/content-service/lib/blobs_grpc_pb'; import { WorkspaceServiceClient } from '@gitpod/content-service/lib/workspace_grpc_pb'; @@ -90,7 +89,6 @@ export const productionContainerModule = new ContainerModule((bind, unbind, isBo bind(LoginCompletionHandler).toSelf().inSingletonScope(); bind(GitpodCookie).toSelf().inSingletonScope(); bind(TosCookie).toSelf().inSingletonScope(); - bind(SelectAccountCookie).toSelf().inSingletonScope(); bind(SessionHandlerProvider).toSelf().inSingletonScope(); bind(Server).toSelf().inSingletonScope(); diff --git a/components/server/src/user/select-account-cookie.ts b/components/server/src/user/select-account-cookie.ts deleted file mode 100644 index 5c6098d3aa145f..00000000000000 --- a/components/server/src/user/select-account-cookie.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Copyright (c) 2021 Gitpod GmbH. All rights reserved. - * Licensed under the GNU Affero General Public License (AGPL). - * See License-AGPL.txt in the project root for license information. - */ - -import * as express from 'express'; -import { injectable, inject } from 'inversify'; -import { Env } from '../env'; - -@injectable() -export class SelectAccountCookie { - @inject(Env) protected readonly env: Env; - - set(res: express.Response, hints: object) { - if (res.headersSent) { - return; - } - res.cookie("SelectAccountCookie", JSON.stringify(hints), { - httpOnly: false, // we need this hint on frontend - domain: `${this.env.hostUrl.url.host}`, - maxAge: 5 * 60 * 1000 /* ms */ - }); - } - - unset(res: express.Response) { - if (res.headersSent) { - return; - } - res.clearCookie('SelectAccountCookie', { - path: "/", - domain: `.${this.env.hostUrl.url.host}` - }); - } -} \ No newline at end of file diff --git a/components/server/src/user/user-service.ts b/components/server/src/user/user-service.ts index 6a39f4916d1edc..42671ed8eac0ba 100644 --- a/components/server/src/user/user-service.ts +++ b/components/server/src/user/user-service.ts @@ -16,7 +16,7 @@ import { BlockedUserFilter } from "../auth/blocked-user-filter"; import * as uuidv4 from 'uuid/v4'; import { TermsProvider } from "../terms/terms-provider"; import { TokenService } from "./token-service"; -import { SelectAccountException } from "../auth/errors"; +import { EmailAddressAlreadyTakenException, SelectAccountException } from "../auth/errors"; import { SelectAccountPayload } from "@gitpod/gitpod-protocol/lib/auth"; export interface FindUserByIdentityStrResult { @@ -315,4 +315,18 @@ export class UserService { throw SelectAccountException.create(`User is trying to connect a provider identity twice.`, payload); } + async asserNoAccountWithEmail(email: string) { + const existingUser = (await this.userDb.findUsersByEmail(email))[0]; + if (!existingUser) { + // no user has this email address ==> OK + return; + } + + /* + * /!\ the given email address is used in another user account. + */ + + throw EmailAddressAlreadyTakenException.create(`Email address is already in use.`); + } + } \ No newline at end of file