Skip to content

Commit cb6fefd

Browse files
committed
Add phone verification
1 parent 074eced commit cb6fefd

File tree

23 files changed

+573
-49
lines changed

23 files changed

+573
-49
lines changed

components/dashboard/src/start/StartPage.tsx

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

7+
import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
78
import { useEffect } from "react";
89
import Alert from "../components/Alert";
910
import gitpodIconUA from "../icons/gitpod.svg";
1011
import { gitpodHostUrl } from "../service/service";
12+
import { VerifyModal } from "./VerifyModal";
1113

1214
export enum StartPhase {
1315
Checking = 0,
@@ -106,6 +108,7 @@ export function StartPage(props: StartPageProps) {
106108
{typeof phase === "number" && phase < StartPhase.IdeReady && (
107109
<ProgressBar phase={phase} error={!!error} />
108110
)}
111+
{error && error.code === ErrorCodes.NEEDS_VERIFICATION && <VerifyModal />}
109112
{error && <StartError error={error} />}
110113
{props.children}
111114
{props.showLatestIdeWarning && (
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License-AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { useState } from "react";
8+
import Alert from "../components/Alert";
9+
import Modal from "../components/Modal";
10+
11+
interface VerifyModalState {
12+
phoneNumber?: string;
13+
sent?: Date;
14+
token?: string;
15+
}
16+
17+
export function VerifyModal() {
18+
const [state, setState] = useState<VerifyModalState>({});
19+
const sendCode = () => {};
20+
const isValidPhoneNumber = () => {
21+
return !!state.phoneNumber && /\+[0-9]{5,}/.test(state.phoneNumber);
22+
};
23+
return (
24+
<Modal
25+
onClose={() => {}}
26+
onEnter={() => false}
27+
title="User Validation Required"
28+
buttons={
29+
<div>
30+
<button className="ml-2" disabled={!isValidPhoneNumber()} onClick={sendCode}>
31+
Send Code via SMS
32+
</button>
33+
</div>
34+
}
35+
visible={true}
36+
>
37+
<Alert type="warning" className="mt-2">
38+
To try Gitpod for free you'll need to validate your account with your phone number. This is required to
39+
discourage and reduce abuse on Gitpod infrastructure.
40+
</Alert>
41+
<div className="border-t border-gray-200 dark:border-gray-800 mt-2 -mx-6 px-6 pt-4">
42+
Enter your mobile phone number to verify your account.
43+
</div>
44+
<div className="mt-4">
45+
<h4>Mobile Phone Number</h4>
46+
<input
47+
type="text"
48+
placeholder="+123242345345"
49+
value={state.phoneNumber}
50+
onChange={(v) => {
51+
setState({
52+
...state,
53+
phoneNumber: v.currentTarget.value,
54+
});
55+
}}
56+
/>
57+
</div>
58+
<div className="mt-4">
59+
<h4>Verification Code</h4>
60+
<input
61+
className="w-full"
62+
type="text"
63+
placeholder="enter token send via sms"
64+
value={state.token}
65+
onChange={(v) => {
66+
setState({
67+
...state,
68+
token: v.currentTarget.value,
69+
});
70+
}}
71+
/>
72+
</div>
73+
</Modal>
74+
);
75+
}

components/gitpod-protocol/src/gitpod-service.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer,
8585
getLoggedInUser(): Promise<User>;
8686
getTerms(): Promise<Terms>;
8787
updateLoggedInUser(user: Partial<User>): Promise<User>;
88+
needsVerification(): Promise<boolean>;
89+
sendPhoneNumberVerificationToken(phoneNumber: string): Promise<void>;
90+
verifyPhoneNumberVerificationToken(phoneNumber: string, token: string): Promise<boolean>;
8891
getAuthProviders(): Promise<AuthProviderInfo[]>;
8992
getOwnAuthProviders(): Promise<AuthProviderEntry[]>;
9093
updateOwnAuthProvider(params: GitpodServer.UpdateOwnAuthProviderParams): Promise<AuthProviderEntry>;

components/gitpod-protocol/src/messaging/error.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ export namespace ErrorCodes {
2626
// 410 No User
2727
export const SETUP_REQUIRED = 410;
2828

29+
// 411 No User
30+
export const NEEDS_VERIFICATION = 411;
31+
2932
// 429 Too Many Requests
3033
export const TOO_MANY_REQUESTS = 429;
3134

components/gitpod-protocol/src/protocol.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,8 @@ export interface ProfileDetails {
216216
companyName?: string;
217217
// the user's email
218218
emailAddress?: string;
219+
// verified
220+
verifiedPhoneNumber?: string;
219221
}
220222

221223
export interface EmailNotificationSettings {

components/gitpod-protocol/src/util/logging.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
const inspect: (object: any) => string = require("util").inspect; // undefined in frontend
88

9-
let plainLogging: boolean = false; // set to true during development to get non JSON output
9+
let plainLogging: boolean = true; // set to true during development to get non JSON output
1010
let jsonLogging: boolean = false;
1111
let component: string | undefined;
1212
let version: string | undefined;

components/server/ee/src/container-module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import { SnapshotService } from "./workspace/snapshot-service";
5959
import { BitbucketAppSupport } from "./bitbucket/bitbucket-app-support";
6060
import { UserCounter } from "./user/user-counter";
6161
import { BitbucketServerApp } from "./prebuilds/bitbucket-server-app";
62+
import { PhoneVerificationService } from "../../src/auth/phone-verification-service";
6263
import { EntitlementService } from "../../src/billing/entitlement-service";
6364
import { EntitlementServiceChargebee } from "./billing/entitlement-service-chargebee";
6465
import { BillingModes, BillingModesImpl } from "./billing/billing-mode";
@@ -84,6 +85,7 @@ export const productionEEContainerModule = new ContainerModule((bind, unbind, is
8485
bind(BitbucketAppSupport).toSelf().inSingletonScope();
8586
bind(GitHubEnterpriseApp).toSelf().inSingletonScope();
8687
bind(BitbucketServerApp).toSelf().inSingletonScope();
88+
bind(PhoneVerificationService).toSelf().inSingletonScope();
8789

8890
bind(UserCounter).toSelf().inSingletonScope();
8991

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

Lines changed: 213 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,28 @@
77
import { inject, injectable } from "inversify";
88
import { TeamDB, TeamSubscription2DB, TeamSubscriptionDB, UserDB } from "@gitpod/gitpod-db/lib";
99
import { TokenProvider } from "../../../src/user/token-provider";
10-
import { User } from "@gitpod/gitpod-protocol";
10+
import {
11+
User,
12+
WorkspaceInstance,
13+
WorkspaceTimeoutDuration,
14+
WORKSPACE_TIMEOUT_DEFAULT_LONG,
15+
WORKSPACE_TIMEOUT_DEFAULT_SHORT,
16+
} from "@gitpod/gitpod-protocol";
1117
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
12-
import { SubscriptionService } from "@gitpod/gitpod-payment-endpoint/lib/accounting";
13-
import { AccountStatementProvider } from "./account-statement-provider";
18+
import { Accounting, SubscriptionService } from "@gitpod/gitpod-payment-endpoint/lib/accounting";
19+
import { AccountStatementProvider, CachedAccountStatement } from "./account-statement-provider";
1420
import { EMailDomainService } from "../auth/email-domain-service";
1521
import fetch from "node-fetch";
1622
import { Config } from "../../../src/config";
23+
import { PhoneVerificationService } from "../../../src/auth/phone-verification-service";
24+
import { MAX_PARALLEL_WORKSPACES, Plans } from "@gitpod/gitpod-protocol/lib/plans";
25+
import { RemainingHours } from "@gitpod/gitpod-protocol/lib/accounting-protocol";
26+
import { millisecondsToHours } from "@gitpod/gitpod-protocol/lib/util/timeutil";
1727

1828
export interface MayStartWorkspaceResult {
1929
hitParallelWorkspaceLimit?: HitParallelWorkspaceLimit;
2030
enoughCredits: boolean;
31+
needsPhoneNumberVerification?: boolean;
2132
}
2233

2334
export interface HitParallelWorkspaceLimit {
@@ -48,6 +59,7 @@ export class EligibilityService {
4859
@inject(AccountStatementProvider) protected readonly accountStatementProvider: AccountStatementProvider;
4960
@inject(TeamSubscriptionDB) protected readonly teamSubscriptionDb: TeamSubscriptionDB;
5061
@inject(TeamSubscription2DB) protected readonly teamSubscription2Db: TeamSubscription2DB;
62+
@inject(PhoneVerificationService) protected readonly phoneVerificationService: PhoneVerificationService;
5163

5264
/**
5365
* Whether the given user is recognized as a student within Gitpod
@@ -122,6 +134,204 @@ export class EligibilityService {
122134
return { student: false, faculty: false };
123135
}
124136

137+
/**
138+
* Whether a user is allowed to start a workspace
139+
* !!! This is executed on the hot path of workspace startup, be careful with async when changing !!!
140+
* @param user
141+
* @param date now
142+
* @param runningInstances
143+
*/
144+
async mayStartWorkspace(
145+
user: User,
146+
date: Date,
147+
runningInstances: Promise<WorkspaceInstance[]>,
148+
): Promise<MayStartWorkspaceResult> {
149+
const hasHitParallelWorkspaceLimit = async (): Promise<HitParallelWorkspaceLimit | undefined> => {
150+
const max = await this.getMaxParallelWorkspaces(user);
151+
const instances = (await runningInstances).filter((i) => i.status.phase !== "preparing");
152+
const current = instances.length; // >= parallelWorkspaceAllowance;
153+
if (current >= max) {
154+
return {
155+
current,
156+
max,
157+
};
158+
} else {
159+
return undefined;
160+
}
161+
};
162+
const [enoughCredits, hitParallelWorkspaceLimit, needsPhoneNumberVerification] = await Promise.all([
163+
!this.config.enablePayment || this.checkEnoughCreditForWorkspaceStart(user.id, date, runningInstances),
164+
hasHitParallelWorkspaceLimit(),
165+
this.phoneVerificationService.needsVerification(user),
166+
]);
167+
168+
return {
169+
enoughCredits: !!enoughCredits,
170+
hitParallelWorkspaceLimit,
171+
needsPhoneNumberVerification,
172+
};
173+
}
174+
175+
/**
176+
* Returns the maximum number of parallel workspaces a user can run at the same time.
177+
* @param user
178+
* @param date The date for which we want to know whether the user is allowed to set a timeout (depends on active subscription)
179+
*/
180+
protected async getMaxParallelWorkspaces(user: User, date: Date = new Date()): Promise<number> {
181+
// if payment is not enabled users can start as many parallel workspaces as they want
182+
if (!this.config.enablePayment) {
183+
return MAX_PARALLEL_WORKSPACES;
184+
}
185+
186+
const subscriptions = await this.subscriptionService.getNotYetCancelledSubscriptions(user, date.toISOString());
187+
return subscriptions.map((s) => Plans.getParallelWorkspacesById(s.planId)).reduce((p, v) => Math.max(p, v));
188+
}
189+
190+
protected async checkEnoughCreditForWorkspaceStart(
191+
userId: string,
192+
date: Date,
193+
runningInstances: Promise<WorkspaceInstance[]>,
194+
): Promise<boolean> {
195+
// As retrieving a full AccountStatement is expensive we want to cache it as much as possible.
196+
const cachedAccountStatement = this.accountStatementProvider.getCachedStatement();
197+
const lowerBound = this.getRemainingUsageHoursLowerBound(cachedAccountStatement, date.toISOString());
198+
if (lowerBound && (lowerBound === "unlimited" || lowerBound > Accounting.MINIMUM_CREDIT_FOR_OPEN_IN_HOURS)) {
199+
return true;
200+
}
201+
202+
const remainingUsageHours = await this.accountStatementProvider.getRemainingUsageHours(
203+
userId,
204+
date.toISOString(),
205+
runningInstances,
206+
);
207+
return remainingUsageHours > Accounting.MINIMUM_CREDIT_FOR_OPEN_IN_HOURS;
208+
}
209+
210+
/**
211+
* Tries to calculate the lower bound of remaining usage hours based on cached AccountStatements
212+
* with the goal to improve workspace startup times.
213+
*/
214+
protected getRemainingUsageHoursLowerBound(
215+
cachedStatement: CachedAccountStatement | undefined,
216+
date: string,
217+
): RemainingHours | undefined {
218+
if (!cachedStatement) {
219+
return undefined;
220+
}
221+
if (cachedStatement.remainingHours === "unlimited") {
222+
return "unlimited";
223+
}
224+
225+
const diffInMillis = new Date(cachedStatement.endDate).getTime() - new Date(date).getTime();
226+
const maxPossibleUsage = millisecondsToHours(diffInMillis) * MAX_PARALLEL_WORKSPACES;
227+
return cachedStatement.remainingHours - maxPossibleUsage;
228+
}
229+
230+
/**
231+
* A user may set the workspace timeout if they have a professional subscription
232+
* @param user
233+
* @param date The date for which we want to know whether the user is allowed to set a timeout (depends on active subscription)
234+
*/
235+
async maySetTimeout(user: User, date: Date = new Date()): Promise<boolean> {
236+
if (!this.config.enablePayment) {
237+
// when payment is disabled users can do everything
238+
return true;
239+
}
240+
241+
const subscriptions = await this.subscriptionService.getNotYetCancelledSubscriptions(user, date.toISOString());
242+
const eligblePlans = [
243+
Plans.PROFESSIONAL_EUR,
244+
Plans.PROFESSIONAL_USD,
245+
Plans.PROFESSIONAL_STUDENT_EUR,
246+
Plans.PROFESSIONAL_STUDENT_USD,
247+
Plans.TEAM_PROFESSIONAL_EUR,
248+
Plans.TEAM_PROFESSIONAL_USD,
249+
Plans.TEAM_PROFESSIONAL_STUDENT_EUR,
250+
Plans.TEAM_PROFESSIONAL_STUDENT_USD,
251+
].map((p) => p.chargebeeId);
252+
253+
return subscriptions.filter((s) => eligblePlans.includes(s.planId!)).length > 0;
254+
}
255+
256+
/**
257+
* Returns the default workspace timeout for the given user at a given point in time
258+
* @param user
259+
* @param date The date for which we want to know the default workspace timeout (depends on active subscription)
260+
*/
261+
async getDefaultWorkspaceTimeout(user: User, date: Date = new Date()): Promise<WorkspaceTimeoutDuration> {
262+
if (await this.maySetTimeout(user, date)) {
263+
return WORKSPACE_TIMEOUT_DEFAULT_LONG;
264+
} else {
265+
return WORKSPACE_TIMEOUT_DEFAULT_SHORT;
266+
}
267+
}
268+
269+
/**
270+
* Returns true if the user is never subject to CPU limiting
271+
*/
272+
async hasFixedWorkspaceResources(user: User, date: Date = new Date()): Promise<boolean> {
273+
const subscriptions = await this.subscriptionService.getNotYetCancelledSubscriptions(user, date.toISOString());
274+
const eligblePlans = [
275+
Plans.PROFESSIONAL_EUR,
276+
Plans.PROFESSIONAL_USD,
277+
Plans.TEAM_PROFESSIONAL_EUR,
278+
Plans.TEAM_PROFESSIONAL_USD,
279+
].map((p) => p.chargebeeId);
280+
281+
return subscriptions.filter((s) => eligblePlans.includes(s.planId!)).length > 0;
282+
}
283+
284+
/**
285+
* Returns true if the user ought to land on a workspace cluster that provides more resources
286+
* compared to the default case.
287+
*/
288+
async userGetsMoreResources(user: User): Promise<boolean> {
289+
if (!this.config.enablePayment) {
290+
// when payment is disabled users can do everything
291+
return true;
292+
}
293+
294+
const subscriptions = await this.subscriptionService.getNotYetCancelledSubscriptions(
295+
user,
296+
new Date().toISOString(),
297+
);
298+
const eligiblePlans = [Plans.TEAM_PROFESSIONAL_EUR, Plans.TEAM_PROFESSIONAL_USD].map((p) => p.chargebeeId);
299+
300+
const relevantSubscriptions = subscriptions.filter((s) => eligiblePlans.includes(s.planId!));
301+
if (relevantSubscriptions.length === 0) {
302+
// user has no subscription that grants "more resources"
303+
return false;
304+
}
305+
306+
// some TeamSubscriptions are marked with 'excludeFromMoreResources' to convey that those are _not_ receiving more resources
307+
const excludeFromMoreResources = await Promise.all(
308+
relevantSubscriptions.map(async (s): Promise<boolean> => {
309+
if (s.teamMembershipId) {
310+
const team = await this.teamDb.findTeamByMembershipId(s.teamMembershipId);
311+
if (!team) {
312+
return true;
313+
}
314+
const ts2 = await this.teamSubscription2Db.findForTeam(team.id, new Date().toISOString());
315+
if (!ts2) {
316+
return true;
317+
}
318+
return ts2.excludeFromMoreResources;
319+
}
320+
if (!s.teamSubscriptionSlotId) {
321+
return false;
322+
}
323+
const ts = await this.teamSubscriptionDb.findTeamSubscriptionBySlotId(s.teamSubscriptionSlotId);
324+
return !!ts?.excludeFromMoreResources;
325+
}),
326+
);
327+
if (excludeFromMoreResources.every((b) => b)) {
328+
// if all TS the user is part of are marked this way, we deny that privilege
329+
return false;
330+
}
331+
332+
return true;
333+
}
334+
125335
protected async getUser(user: User | string): Promise<User> {
126336
if (typeof user === "string") {
127337
const realUser = await this.userDb.findUserById(user);

0 commit comments

Comments
 (0)