Skip to content

Commit e3cc972

Browse files
committed
[login] first user must accept ToS
Signed-off-by: Alex Tugarev <[email protected]>
1 parent d1c0788 commit e3cc972

File tree

8 files changed

+112
-28
lines changed

8 files changed

+112
-28
lines changed

components/dashboard/src/components/tos/terms-of-service.tsx

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,13 @@ interface TermsOfServiceProps {
2020
}
2121
interface TermsOfServiceState {
2222
acceptsTos?: boolean;
23-
acceptsComs?: boolean;
2423
}
2524
export class TermsOfService extends React.Component<TermsOfServiceProps, TermsOfServiceState> {
2625

2726
constructor(props: TermsOfServiceProps) {
2827
super(props);
2928
this.state = {
3029
acceptsTos: false,
31-
acceptsComs: false
3230
};
3331
}
3432

@@ -62,25 +60,17 @@ export class TermsOfService extends React.Component<TermsOfServiceProps, TermsOf
6260
</Toolbar>
6361
</AppBar>
6462
<div className='content content-area'>
65-
<h1>Create account</h1>
63+
<h1>Before we proceed</h1>
6664
<form action={ gitpodHost.withApi({ pathname: '/tos/proceed' }).toString() } method="post" id="accept-tos-form">
6765
<div className="tos-checks">
68-
<p><label style={{ display: 'flex', alignItems: 'center' }}>
69-
<Checkbox
70-
value="true"
71-
name="agreeCOMS"
72-
checked={this.state.acceptsComs}
73-
onChange={() => this.setState({ acceptsComs: !this.state.acceptsComs })} />
74-
I wish to receive news and updates via email
75-
</label></p>
7666
<p><label style={{ display: 'flex', alignItems: 'center' }}>
7767
<Checkbox
7868
value="true"
7969
name="agreeTOS"
8070
checked={this.state.acceptsTos}
8171
onChange={() => this.setState({ acceptsTos: !this.state.acceptsTos })} />
8272
<span>
83-
I agree to the <a target="_blank" href="https://www.gitpod.io/terms/" rel="noopener">terms of service</a>
73+
I agree to the <a target="_blank" href="https://www.gitpod.io/self-hosted-terms/" rel="noopener">terms</a>
8474
</span>
8575
</label></p>
8676
</div>
@@ -92,7 +82,7 @@ export class TermsOfService extends React.Component<TermsOfServiceProps, TermsOf
9282
color={this.state.acceptsTos ? 'secondary' : 'primary'}
9383
onClick={this.submitForm.bind(this)}
9484
data-testid="submit">
95-
{ this.state.acceptsTos ? 'Create Free Account' : 'Please Accept the Terms of Service' }
85+
{ this.state.acceptsTos ? 'Continue' : 'Please Accept the Terms' }
9686
</ButtonWithProgress>
9787
</div>
9888
</form>

components/server/ee/src/user/user-service.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,17 @@ export class UserServiceEE extends UserService {
2323
}
2424

2525
async checkSignUp(params: CheckSignUpParams) {
26+
// 1. check the license
2627
const userCount = await this.userDb.getUserCount(true);
2728
if (!this.licenseEvaluator.hasEnoughSeats(userCount)) {
2829
// TODO: bail out to EE license flow
2930

3031
const msg = `Maximum number of users permitted by the license exceeded`;
3132
throw AuthException.create("Cannot sign up", msg, { userCount });
3233
}
34+
35+
// 2. check for ToS
36+
await super.checkSignUp(params);
3337
}
3438

3539
}

components/server/src/auth/auth-error-handler.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,31 @@
44
* See License-AGPL.txt in the project root for license information.
55
*/
66

7-
import { injectable } from "inversify";
7+
import { inject, injectable } from "inversify";
8+
import { Env } from "../env";
9+
import { TosNotAcceptedYetException } from "./errors";
810

911

1012
@injectable()
1113
export class AuthErrorHandler {
14+
@inject(Env) protected readonly env: Env;
1215

1316
async check(error: any): Promise<AuthErrorHandler.DidHandleResult | undefined> {
14-
// no-op
17+
if (TosNotAcceptedYetException.is(error)) {
18+
const { identity } = error;
19+
20+
const redirectToUrl = this.env.hostUrl.withApi({ pathname: '/tos' }).toString();
21+
return <AuthErrorHandler.DidHandleResult>{
22+
redirectToUrl,
23+
identity
24+
}
25+
}
1526
return undefined;
1627
}
1728
}
1829

1930
export namespace AuthErrorHandler {
2031
export interface DidHandleResult {
21-
redirectToUrl: string;
32+
readonly redirectToUrl: string;
2233
}
2334
}

components/server/src/auth/errors.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export namespace TosNotAcceptedYetException {
1313
export function create(identity: Identity) {
1414
return Object.assign(new Error("TosNotAcceptedYetException"), { identity });
1515
}
16-
export function is(error: Error | null): error is TosNotAcceptedYetException {
16+
export function is(error: any): error is TosNotAcceptedYetException {
1717
return !!error && error.message === 'TosNotAcceptedYetException';
1818
}
1919
}

components/server/src/auth/generic-auth-provider.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,9 +274,13 @@ export class GenericAuthProvider implements AuthProvider {
274274

275275
const handledError = await this.authErrorHandler.check(err);
276276
if (handledError) {
277+
if (request.session) {
278+
await AuthBag.attach(request.session, { ...authBag, ...handledError});
279+
}
277280
const { redirectToUrl } = handledError;
278281
log.info(context, `(${strategyName}) Handled auth error. Redirecting to ${redirectToUrl}`, { ...logPayload, err });
279282
response.redirect(redirectToUrl);
283+
return;
280284
}
281285
if (err) {
282286
let message = 'Authorization failed. Please try again.';

components/server/src/express-util.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,18 @@ export function saveSession(reqOrSession: express.Request | Express.Session): Pr
110110
});
111111
});
112112
}
113+
export function destroySession(reqOrSession: express.Request | Express.Session): Promise<void> {
114+
const session = reqOrSession.session ? reqOrSession.session : reqOrSession;
115+
return new Promise<void>((resolve, reject) => {
116+
session.destroy((error: any) => {
117+
if (error) {
118+
reject(error);
119+
} else {
120+
resolve();
121+
}
122+
});
123+
});
124+
}
113125

114126
/**
115127
* https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For

components/server/src/user/user-controller.ts

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ import { WorkspacePortAuthorizationService } from "./workspace-port-auth-service
1919
import { parseWorkspaceIdFromHostname } from "@gitpod/gitpod-protocol/lib/util/parse-workspace-id";
2020
import { SessionHandlerProvider } from "../session-handler";
2121
import { URL } from 'url';
22-
import { saveSession, getRequestingClientInfo } from "../express-util";
23-
import { User } from "@gitpod/gitpod-protocol";
22+
import { saveSession, getRequestingClientInfo, destroySession } from "../express-util";
23+
import { Identity, User } from "@gitpod/gitpod-protocol";
2424
import { HostContextProvider } from "../auth/host-context-provider";
25+
import { AuthBag } from "../auth/auth-provider";
2526

2627
@injectable()
2728
export class UserController {
@@ -234,9 +235,71 @@ export class UserController {
234235

235236
res.sendStatus(403);
236237
});
238+
router.get("/tos", async (req, res, next) => {
239+
const clientInfo = getRequestingClientInfo(req);
240+
log.info({ sessionId: req.sessionID }, "(TOS) Redirecting to /tos. (Request to /login is expected next.)", { 'login-flow': true, clientInfo });
241+
res.redirect(this.env.hostUrl.with(() => ({ pathname: '/tos/' })).toString());
242+
});
243+
router.post("/tos/proceed", async (req, res, next) => {
244+
const clientInfo = getRequestingClientInfo(req);
245+
const dashboardUrl = this.env.hostUrl.asDashboard().toString();
246+
if (req.isAuthenticated() && User.is(req.user)) {
247+
res.redirect(dashboardUrl);
248+
return;
249+
}
250+
const authBag = AuthBag.get(req.session);
251+
if (!req.session || !authBag || authBag.requestType !== "authenticate" || !authBag.identity) {
252+
log.info({ sessionId: req.sessionID }, '(TOS) No identity.', { 'login-flow': true, session: req.session, clientInfo });
253+
res.redirect(this.getSorryUrl("Oops! Something went wrong in the previous step."));
254+
return;
255+
}
256+
const agreeTOS = req.body.agreeTOS;
257+
if (!agreeTOS) {
258+
/* The user did not accept our Terms of Service, thus we must not store any of their data.
259+
* For good measure we destroy the user session, so that any data we may have stored in there
260+
* gets removed from our session cache.
261+
*/
262+
log.info({ sessionId: req.sessionID }, '(TOS) User did NOT agree. Aborting sign-up.', { 'login-flow': true, clientInfo });
263+
try {
264+
await destroySession(req.session)
265+
} catch (error) {
266+
log.warn('(TOS) Unable to destroy session.', { error, 'login-flow': true, clientInfo });
267+
}
268+
res.redirect(dashboardUrl);
269+
return;
270+
}
271+
272+
// The user has accepted our Terms of Service. Create the identity/user in the database and repeat login.
273+
log.info({ sessionId: req.sessionID }, '(TOS) User did agree. Creating new user in database', { 'login-flow': true, clientInfo });
274+
275+
const identity = authBag.identity;
276+
await AuthBag.attach(req.session, { ...authBag, identity: undefined });
277+
try {
278+
await this.createUserAfterTosAgreement(identity, req.body);
279+
} catch (error) {
280+
log.error({ sessionId: req.sessionID }, '(TOS) Unable to create create the user in database.', error, { 'login-flow': true, error, clientInfo });
281+
res.redirect(this.getSorryUrl("Oops! Something went wrong during the login."));
282+
return;
283+
}
284+
285+
// Make sure, the session is stored
286+
try {
287+
await retryAsync(3, saveSession(req));
288+
} catch (error) {
289+
log.error({ sessionId: req.sessionID }, `(TOS) Login failed due to session save error; redirecting to /sorry`, { req, error, clientInfo });
290+
res.redirect(this.getSorryUrl("Login failed 🦄 Please try again"));
291+
return;
292+
}
293+
294+
// Continue with login after ToS
295+
res.redirect(authBag.returnToAfterTos);
296+
});
237297

238298
return router;
239299
}
300+
protected async createUserAfterTosAgreement(identity: Identity, tosProceedParams: any) {
301+
await this.userService.createUserForIdentity(identity);
302+
}
240303

241304
protected getSorryUrl(message: string) {
242305
return this.env.hostUrl.with({ pathname: '/sorry', hash: message }).toString();
@@ -299,7 +362,7 @@ export class UserController {
299362

300363
if (!!contextUrlHost && authProvidersOnDashboard.find(a => a === contextUrlHost)) {
301364
req.query.host = contextUrlHost;
302-
log.debug({ sessionId: req.sessionID }, "Guessed auth provider from returnTo URL: " + contextUrlHost, { 'login-flow': true, query: req.query});
365+
log.debug({ sessionId: req.sessionID }, "Guessed auth provider from returnTo URL: " + contextUrlHost, { 'login-flow': true, query: req.query });
303366
return;
304367
}
305368
}

components/server/src/user/user-service.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ import { User, Identity, WorkspaceTimeoutDuration } from "@gitpod/gitpod-protoco
99
import { UserDB } from "@gitpod/gitpod-db/lib/user-db";
1010
import { HostContextProvider } from "../auth/host-context-provider";
1111
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
12-
import { TokenProvider } from "./token-provider";
1312
import { Env } from "../env";
1413
import { AuthProviderParams } from "../auth/auth-provider";
14+
import { TosNotAcceptedYetException } from "../auth/errors";
1515

1616
export interface FindUserByIdentityStrResult {
1717
user: User;
@@ -28,7 +28,6 @@ export interface CheckSignUpParams {
2828
export class UserService {
2929
@inject(UserDB) protected readonly userDb: UserDB;
3030
@inject(HostContextProvider) protected readonly hostContextProvider: HostContextProvider;
31-
@inject(TokenProvider) protected readonly tokenProvider: TokenProvider;
3231
@inject(Env) protected readonly env: Env;
3332

3433
/**
@@ -95,14 +94,9 @@ export class UserService {
9594
}
9695
protected handleNewUser(newUser: User) {
9796
if (this.env.blockNewUsers) {
98-
// By default we block all new users on staging as we don't expected that many new ones.
99-
// Any legitimate new user on gitpod-staging can talk to the team to get unblocked.
100-
// TODO Replace this with a more precise mechanism, maybe based on email domains
10197
newUser.blocked = true;
10298
}
103-
10499
if (this.env.makeNewUsersAdmin) {
105-
// In devstaging we want all users to become admins to make debugging easier.
106100
newUser.rolesOrPermissions = ['admin'];
107101
}
108102
}
@@ -117,6 +111,12 @@ export class UserService {
117111
}
118112

119113
async checkSignUp(params: CheckSignUpParams) {
120-
// no-op here
114+
const { identity, config } = params;
115+
if (config.requireTOS !== false) {
116+
const userCount = await this.userDb.getUserCount();
117+
if (userCount === 0) {
118+
throw TosNotAcceptedYetException.create(identity);
119+
}
120+
}
121121
}
122122
}

0 commit comments

Comments
 (0)