Skip to content

Commit

Permalink
Add phone verification
Browse files Browse the repository at this point in the history
  • Loading branch information
svenefftinge committed Aug 9, 2022
1 parent 074eced commit cb6fefd
Show file tree
Hide file tree
Showing 23 changed files with 573 additions and 49 deletions.
3 changes: 3 additions & 0 deletions components/dashboard/src/start/StartPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
* See License-AGPL.txt in the project root for license information.
*/

import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
import { useEffect } from "react";
import Alert from "../components/Alert";
import gitpodIconUA from "../icons/gitpod.svg";
import { gitpodHostUrl } from "../service/service";
import { VerifyModal } from "./VerifyModal";

export enum StartPhase {
Checking = 0,
Expand Down Expand Up @@ -106,6 +108,7 @@ export function StartPage(props: StartPageProps) {
{typeof phase === "number" && phase < StartPhase.IdeReady && (
<ProgressBar phase={phase} error={!!error} />
)}
{error && error.code === ErrorCodes.NEEDS_VERIFICATION && <VerifyModal />}
{error && <StartError error={error} />}
{props.children}
{props.showLatestIdeWarning && (
Expand Down
75 changes: 75 additions & 0 deletions components/dashboard/src/start/VerifyModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Copyright (c) 2022 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 { useState } from "react";
import Alert from "../components/Alert";
import Modal from "../components/Modal";

interface VerifyModalState {
phoneNumber?: string;
sent?: Date;
token?: string;
}

export function VerifyModal() {
const [state, setState] = useState<VerifyModalState>({});
const sendCode = () => {};
const isValidPhoneNumber = () => {
return !!state.phoneNumber && /\+[0-9]{5,}/.test(state.phoneNumber);
};
return (
<Modal
onClose={() => {}}
onEnter={() => false}
title="User Validation Required"
buttons={
<div>
<button className="ml-2" disabled={!isValidPhoneNumber()} onClick={sendCode}>
Send Code via SMS
</button>
</div>
}
visible={true}
>
<Alert type="warning" className="mt-2">
To try Gitpod for free you'll need to validate your account with your phone number. This is required to
discourage and reduce abuse on Gitpod infrastructure.
</Alert>
<div className="border-t border-gray-200 dark:border-gray-800 mt-2 -mx-6 px-6 pt-4">
Enter your mobile phone number to verify your account.
</div>
<div className="mt-4">
<h4>Mobile Phone Number</h4>
<input
type="text"
placeholder="+123242345345"
value={state.phoneNumber}
onChange={(v) => {
setState({
...state,
phoneNumber: v.currentTarget.value,
});
}}
/>
</div>
<div className="mt-4">
<h4>Verification Code</h4>
<input
className="w-full"
type="text"
placeholder="enter token send via sms"
value={state.token}
onChange={(v) => {
setState({
...state,
token: v.currentTarget.value,
});
}}
/>
</div>
</Modal>
);
}
3 changes: 3 additions & 0 deletions components/gitpod-protocol/src/gitpod-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer,
getLoggedInUser(): Promise<User>;
getTerms(): Promise<Terms>;
updateLoggedInUser(user: Partial<User>): Promise<User>;
needsVerification(): Promise<boolean>;
sendPhoneNumberVerificationToken(phoneNumber: string): Promise<void>;
verifyPhoneNumberVerificationToken(phoneNumber: string, token: string): Promise<boolean>;
getAuthProviders(): Promise<AuthProviderInfo[]>;
getOwnAuthProviders(): Promise<AuthProviderEntry[]>;
updateOwnAuthProvider(params: GitpodServer.UpdateOwnAuthProviderParams): Promise<AuthProviderEntry>;
Expand Down
3 changes: 3 additions & 0 deletions components/gitpod-protocol/src/messaging/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ export namespace ErrorCodes {
// 410 No User
export const SETUP_REQUIRED = 410;

// 411 No User
export const NEEDS_VERIFICATION = 411;

// 429 Too Many Requests
export const TOO_MANY_REQUESTS = 429;

Expand Down
2 changes: 2 additions & 0 deletions components/gitpod-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,8 @@ export interface ProfileDetails {
companyName?: string;
// the user's email
emailAddress?: string;
// verified
verifiedPhoneNumber?: string;
}

export interface EmailNotificationSettings {
Expand Down
2 changes: 1 addition & 1 deletion components/gitpod-protocol/src/util/logging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

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

let plainLogging: boolean = false; // set to true during development to get non JSON output
let plainLogging: boolean = true; // set to true during development to get non JSON output
let jsonLogging: boolean = false;
let component: string | undefined;
let version: string | undefined;
Expand Down
2 changes: 2 additions & 0 deletions components/server/ee/src/container-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import { SnapshotService } from "./workspace/snapshot-service";
import { BitbucketAppSupport } from "./bitbucket/bitbucket-app-support";
import { UserCounter } from "./user/user-counter";
import { BitbucketServerApp } from "./prebuilds/bitbucket-server-app";
import { PhoneVerificationService } from "../../src/auth/phone-verification-service";
import { EntitlementService } from "../../src/billing/entitlement-service";
import { EntitlementServiceChargebee } from "./billing/entitlement-service-chargebee";
import { BillingModes, BillingModesImpl } from "./billing/billing-mode";
Expand All @@ -84,6 +85,7 @@ export const productionEEContainerModule = new ContainerModule((bind, unbind, is
bind(BitbucketAppSupport).toSelf().inSingletonScope();
bind(GitHubEnterpriseApp).toSelf().inSingletonScope();
bind(BitbucketServerApp).toSelf().inSingletonScope();
bind(PhoneVerificationService).toSelf().inSingletonScope();

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

Expand Down
216 changes: 213 additions & 3 deletions components/server/ee/src/user/eligibility-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,28 @@
import { inject, injectable } from "inversify";
import { TeamDB, TeamSubscription2DB, TeamSubscriptionDB, UserDB } from "@gitpod/gitpod-db/lib";
import { TokenProvider } from "../../../src/user/token-provider";
import { User } from "@gitpod/gitpod-protocol";
import {
User,
WorkspaceInstance,
WorkspaceTimeoutDuration,
WORKSPACE_TIMEOUT_DEFAULT_LONG,
WORKSPACE_TIMEOUT_DEFAULT_SHORT,
} from "@gitpod/gitpod-protocol";
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
import { SubscriptionService } from "@gitpod/gitpod-payment-endpoint/lib/accounting";
import { AccountStatementProvider } from "./account-statement-provider";
import { Accounting, SubscriptionService } from "@gitpod/gitpod-payment-endpoint/lib/accounting";
import { AccountStatementProvider, CachedAccountStatement } from "./account-statement-provider";
import { EMailDomainService } from "../auth/email-domain-service";
import fetch from "node-fetch";
import { Config } from "../../../src/config";
import { PhoneVerificationService } from "../../../src/auth/phone-verification-service";
import { MAX_PARALLEL_WORKSPACES, Plans } from "@gitpod/gitpod-protocol/lib/plans";
import { RemainingHours } from "@gitpod/gitpod-protocol/lib/accounting-protocol";
import { millisecondsToHours } from "@gitpod/gitpod-protocol/lib/util/timeutil";

export interface MayStartWorkspaceResult {
hitParallelWorkspaceLimit?: HitParallelWorkspaceLimit;
enoughCredits: boolean;
needsPhoneNumberVerification?: boolean;
}

export interface HitParallelWorkspaceLimit {
Expand Down Expand Up @@ -48,6 +59,7 @@ export class EligibilityService {
@inject(AccountStatementProvider) protected readonly accountStatementProvider: AccountStatementProvider;
@inject(TeamSubscriptionDB) protected readonly teamSubscriptionDb: TeamSubscriptionDB;
@inject(TeamSubscription2DB) protected readonly teamSubscription2Db: TeamSubscription2DB;
@inject(PhoneVerificationService) protected readonly phoneVerificationService: PhoneVerificationService;

/**
* Whether the given user is recognized as a student within Gitpod
Expand Down Expand Up @@ -122,6 +134,204 @@ export class EligibilityService {
return { student: false, faculty: false };
}

/**
* Whether a user is allowed to start a workspace
* !!! This is executed on the hot path of workspace startup, be careful with async when changing !!!
* @param user
* @param date now
* @param runningInstances
*/
async mayStartWorkspace(
user: User,
date: Date,
runningInstances: Promise<WorkspaceInstance[]>,
): Promise<MayStartWorkspaceResult> {
const hasHitParallelWorkspaceLimit = async (): Promise<HitParallelWorkspaceLimit | undefined> => {
const max = await this.getMaxParallelWorkspaces(user);
const instances = (await runningInstances).filter((i) => i.status.phase !== "preparing");
const current = instances.length; // >= parallelWorkspaceAllowance;
if (current >= max) {
return {
current,
max,
};
} else {
return undefined;
}
};
const [enoughCredits, hitParallelWorkspaceLimit, needsPhoneNumberVerification] = await Promise.all([
!this.config.enablePayment || this.checkEnoughCreditForWorkspaceStart(user.id, date, runningInstances),
hasHitParallelWorkspaceLimit(),
this.phoneVerificationService.needsVerification(user),
]);

return {
enoughCredits: !!enoughCredits,
hitParallelWorkspaceLimit,
needsPhoneNumberVerification,
};
}

/**
* Returns the maximum number of parallel workspaces a user can run at the same time.
* @param user
* @param date The date for which we want to know whether the user is allowed to set a timeout (depends on active subscription)
*/
protected async getMaxParallelWorkspaces(user: User, date: Date = new Date()): Promise<number> {
// if payment is not enabled users can start as many parallel workspaces as they want
if (!this.config.enablePayment) {
return MAX_PARALLEL_WORKSPACES;
}

const subscriptions = await this.subscriptionService.getNotYetCancelledSubscriptions(user, date.toISOString());
return subscriptions.map((s) => Plans.getParallelWorkspacesById(s.planId)).reduce((p, v) => Math.max(p, v));
}

protected async checkEnoughCreditForWorkspaceStart(
userId: string,
date: Date,
runningInstances: Promise<WorkspaceInstance[]>,
): Promise<boolean> {
// As retrieving a full AccountStatement is expensive we want to cache it as much as possible.
const cachedAccountStatement = this.accountStatementProvider.getCachedStatement();
const lowerBound = this.getRemainingUsageHoursLowerBound(cachedAccountStatement, date.toISOString());
if (lowerBound && (lowerBound === "unlimited" || lowerBound > Accounting.MINIMUM_CREDIT_FOR_OPEN_IN_HOURS)) {
return true;
}

const remainingUsageHours = await this.accountStatementProvider.getRemainingUsageHours(
userId,
date.toISOString(),
runningInstances,
);
return remainingUsageHours > Accounting.MINIMUM_CREDIT_FOR_OPEN_IN_HOURS;
}

/**
* Tries to calculate the lower bound of remaining usage hours based on cached AccountStatements
* with the goal to improve workspace startup times.
*/
protected getRemainingUsageHoursLowerBound(
cachedStatement: CachedAccountStatement | undefined,
date: string,
): RemainingHours | undefined {
if (!cachedStatement) {
return undefined;
}
if (cachedStatement.remainingHours === "unlimited") {
return "unlimited";
}

const diffInMillis = new Date(cachedStatement.endDate).getTime() - new Date(date).getTime();
const maxPossibleUsage = millisecondsToHours(diffInMillis) * MAX_PARALLEL_WORKSPACES;
return cachedStatement.remainingHours - maxPossibleUsage;
}

/**
* A user may set the workspace timeout if they have a professional subscription
* @param user
* @param date The date for which we want to know whether the user is allowed to set a timeout (depends on active subscription)
*/
async maySetTimeout(user: User, date: Date = new Date()): Promise<boolean> {
if (!this.config.enablePayment) {
// when payment is disabled users can do everything
return true;
}

const subscriptions = await this.subscriptionService.getNotYetCancelledSubscriptions(user, date.toISOString());
const eligblePlans = [
Plans.PROFESSIONAL_EUR,
Plans.PROFESSIONAL_USD,
Plans.PROFESSIONAL_STUDENT_EUR,
Plans.PROFESSIONAL_STUDENT_USD,
Plans.TEAM_PROFESSIONAL_EUR,
Plans.TEAM_PROFESSIONAL_USD,
Plans.TEAM_PROFESSIONAL_STUDENT_EUR,
Plans.TEAM_PROFESSIONAL_STUDENT_USD,
].map((p) => p.chargebeeId);

return subscriptions.filter((s) => eligblePlans.includes(s.planId!)).length > 0;
}

/**
* Returns the default workspace timeout for the given user at a given point in time
* @param user
* @param date The date for which we want to know the default workspace timeout (depends on active subscription)
*/
async getDefaultWorkspaceTimeout(user: User, date: Date = new Date()): Promise<WorkspaceTimeoutDuration> {
if (await this.maySetTimeout(user, date)) {
return WORKSPACE_TIMEOUT_DEFAULT_LONG;
} else {
return WORKSPACE_TIMEOUT_DEFAULT_SHORT;
}
}

/**
* Returns true if the user is never subject to CPU limiting
*/
async hasFixedWorkspaceResources(user: User, date: Date = new Date()): Promise<boolean> {
const subscriptions = await this.subscriptionService.getNotYetCancelledSubscriptions(user, date.toISOString());
const eligblePlans = [
Plans.PROFESSIONAL_EUR,
Plans.PROFESSIONAL_USD,
Plans.TEAM_PROFESSIONAL_EUR,
Plans.TEAM_PROFESSIONAL_USD,
].map((p) => p.chargebeeId);

return subscriptions.filter((s) => eligblePlans.includes(s.planId!)).length > 0;
}

/**
* Returns true if the user ought to land on a workspace cluster that provides more resources
* compared to the default case.
*/
async userGetsMoreResources(user: User): Promise<boolean> {
if (!this.config.enablePayment) {
// when payment is disabled users can do everything
return true;
}

const subscriptions = await this.subscriptionService.getNotYetCancelledSubscriptions(
user,
new Date().toISOString(),
);
const eligiblePlans = [Plans.TEAM_PROFESSIONAL_EUR, Plans.TEAM_PROFESSIONAL_USD].map((p) => p.chargebeeId);

const relevantSubscriptions = subscriptions.filter((s) => eligiblePlans.includes(s.planId!));
if (relevantSubscriptions.length === 0) {
// user has no subscription that grants "more resources"
return false;
}

// some TeamSubscriptions are marked with 'excludeFromMoreResources' to convey that those are _not_ receiving more resources
const excludeFromMoreResources = await Promise.all(
relevantSubscriptions.map(async (s): Promise<boolean> => {
if (s.teamMembershipId) {
const team = await this.teamDb.findTeamByMembershipId(s.teamMembershipId);
if (!team) {
return true;
}
const ts2 = await this.teamSubscription2Db.findForTeam(team.id, new Date().toISOString());
if (!ts2) {
return true;
}
return ts2.excludeFromMoreResources;
}
if (!s.teamSubscriptionSlotId) {
return false;
}
const ts = await this.teamSubscriptionDb.findTeamSubscriptionBySlotId(s.teamSubscriptionSlotId);
return !!ts?.excludeFromMoreResources;
}),
);
if (excludeFromMoreResources.every((b) => b)) {
// if all TS the user is part of are marked this way, we deny that privilege
return false;
}

return true;
}

protected async getUser(user: User | string): Promise<User> {
if (typeof user === "string") {
const realUser = await this.userDb.findUserById(user);
Expand Down
Loading

0 comments on commit cb6fefd

Please sign in to comment.