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 16, 2022
1 parent f55f109 commit f157d5d
Show file tree
Hide file tree
Showing 26 changed files with 359 additions and 18 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 @@ -206,6 +206,8 @@ export interface AdditionalUserData {
workspaceClasses?: WorkspaceClasses;
// additional user profile data
profile?: ProfileDetails;
// verification date
lastVerificationTime?: string;
}

// The format in which we store User Profiles in
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
9 changes: 9 additions & 0 deletions components/server/ee/src/billing/entitlement-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "@gitpod/gitpod-protocol";
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
import { inject, injectable } from "inversify";
import { VerificationService } from "../../../src/auth/verification-service";
import { EntitlementService, MayStartWorkspaceResult } from "../../../src/billing/entitlement-service";
import { Config } from "../../../src/config";
import { BillingModes } from "./billing-mode";
Expand All @@ -31,13 +32,21 @@ export class EntitlementServiceImpl implements EntitlementService {
@inject(EntitlementServiceChargebee) protected readonly chargebee: EntitlementServiceChargebee;
@inject(EntitlementServiceLicense) protected readonly license: EntitlementServiceLicense;
@inject(EntitlementServiceUBP) protected readonly ubp: EntitlementServiceUBP;
@inject(VerificationService) protected readonly verificationService: VerificationService;

async mayStartWorkspace(
user: User,
date: Date = new Date(),
runningInstances: Promise<WorkspaceInstance[]>,
): Promise<MayStartWorkspaceResult> {
try {
const verification = await this.verificationService.needsVerification(user);
if (verification) {
return {
mayStart: false,
needsVerification: true,
};
}
const billingMode = await this.billingModes.getBillingModeForUser(user, date);
let result;
switch (billingMode.mode) {
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 { VerificationService } from "../../src/auth/verification-service";
import { EntitlementService } from "../../src/billing/entitlement-service";
import { EntitlementServiceChargebee } from "./billing/entitlement-service-chargebee";
import { BillingModes, BillingModesImpl } from "./billing/billing-mode";
Expand Down Expand Up @@ -86,6 +87,7 @@ export const productionEEContainerModule = new ContainerModule((bind, unbind, is
bind(BitbucketAppSupport).toSelf().inSingletonScope();
bind(GitHubEnterpriseApp).toSelf().inSingletonScope();
bind(BitbucketServerApp).toSelf().inSingletonScope();
bind(VerificationService).toSelf().inSingletonScope();

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

Expand Down
3 changes: 3 additions & 0 deletions components/server/ee/src/workspace/gitpod-server-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,9 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
if (result.mayStart) {
return; // green light from entitlement service
}
if (!!result.needsVerification) {
throw new ResponseError(ErrorCodes.NEEDS_VERIFICATION, `Please verify your account.`);
}
if (!!result.oufOfCredits) {
throw new ResponseError(
ErrorCodes.NOT_ENOUGH_CREDIT,
Expand Down
1 change: 1 addition & 0 deletions components/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
"reflect-metadata": "^0.1.10",
"stripe": "^9.0.0",
"swot-js": "^1.0.3",
"twilio": "^3.78.0",
"uuid": "^8.3.2",
"vscode-ws-jsonrpc": "^0.2.0",
"ws": "^7.4.6"
Expand Down
3 changes: 3 additions & 0 deletions components/server/src/auth/rate-limiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ function getConfig(config: RateLimiterConfig): RateLimiterConfig {
getLoggedInUser: { group: "default", points: 1 },
getTerms: { group: "default", points: 1 },
updateLoggedInUser: { group: "default", points: 1 },
needsVerification: { group: "default", points: 1 },
sendPhoneNumberVerificationToken: { group: "default", points: 1 },
verifyPhoneNumberVerificationToken: { group: "default", points: 1 },
getAuthProviders: { group: "default", points: 1 },
getOwnAuthProviders: { group: "default", points: 1 },
updateOwnAuthProvider: { group: "default", points: 1 },
Expand Down
79 changes: 79 additions & 0 deletions components/server/src/auth/verification-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* 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 { User } from "@gitpod/gitpod-protocol";
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
import { inject, injectable, postConstruct } from "inversify";
import { Config } from "../config";
import { Twilio } from "twilio";
import { ServiceContext } from "twilio/lib/rest/verify/v2/service";
import { UserDB, WorkspaceDB } from "@gitpod/gitpod-db/lib";

@injectable()
export class VerificationService {
@inject(Config) protected config: Config;
@inject(WorkspaceDB) protected workspaceDB: WorkspaceDB;
@inject(UserDB) protected userDB: UserDB;

protected verifyService: ServiceContext;

@postConstruct()
protected initialize(): void {
if (this.config.twilioConfig) {
const client = new Twilio(this.config.twilioConfig.accountSID, this.config.twilioConfig.authToken);
this.verifyService = client.verify.v2.services(this.config.twilioConfig.serviceName);
}
}

public async needsVerification(user: User): Promise<boolean> {
if (!this.config.twilioConfig) {
return false;
}
if (!!user.additionalData?.lastVerificationTime) {
return false;
}
if (await this.isTrusted(user)) {
await this.markVerified(user);
return false;
}
return true;
}

public async markVerified(user: User): Promise<User> {
if (!user.additionalData) {
user.additionalData = {};
}
user.additionalData!.lastVerificationTime = new Date().toISOString();
await this.userDB.updateUserPartial(user);
return user;
}

private async isTrusted(user: User): Promise<boolean> {
// we only want to ask new users, so if a user has ever started workspaces before, we trust them.
// there might be a few false positives because all workspaces might have been deleted.
const workspaces = await this.workspaceDB.findWorkspacesByUser(user.id);
return workspaces.length !== 0;
}

public async sendVerificationToken(phoneNumber: string): Promise<void> {
if (!this.verifyService) {
throw new Error("No verification service configured.");
}
const verification = await this.verifyService.verifications.create({ to: phoneNumber, channel: "sms" });
log.info("Verification code sent", { phoneNumber, status: verification.status });
}

public async verifyVerificationToken(phoneNumber: string, oneTimePassword: string): Promise<boolean> {
if (!this.verifyService) {
throw new Error("No verification service configured.");
}
const verification_check = await this.verifyService.verificationChecks.create({
to: phoneNumber,
code: oneTimePassword,
});
return verification_check.status === "approved";
}
}
2 changes: 2 additions & 0 deletions components/server/src/billing/entitlement-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export interface MayStartWorkspaceResult {

oufOfCredits?: boolean;

needsVerification?: boolean;

/** Usage-Based Pricing: AttributionId of the CostCenter that reached it's spending limit */
spendingLimitReachedOnCostCenter?: AttributionId;
}
Expand Down
32 changes: 31 additions & 1 deletion components/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,29 @@ import * as fs from "fs";
import * as yaml from "js-yaml";
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
import { filePathTelepresenceAware } from "@gitpod/gitpod-protocol/lib/env";
import { env } from "process";
import { WorkspaceClasses, WorkspaceClassesConfig } from "./workspace/workspace-classes";

export const Config = Symbol("Config");
export type Config = Omit<
ConfigSerialized,
"hostUrl" | "chargebeeProviderOptionsFile" | "stripeSecretsFile" | "stripeConfigFile" | "licenseFile"
| "hostUrl"
| "chargebeeProviderOptionsFile"
| "stripeSecretsFile"
| "stripeConfigFile"
| "twilioConfigFile"
| "licenseFile"
> & {
hostUrl: GitpodHostUrl;
workspaceDefaults: WorkspaceDefaults;
chargebeeProviderOptions?: ChargebeeProviderOptions;
stripeSecrets?: { publishableKey: string; secretKey: string };
stripeConfig?: { usageProductPriceIds: { EUR: string; USD: string } };
twilioConfig?: {
serviceName: string;
accountSID: string;
authToken: string;
};
builtinAuthProvidersConfigured: boolean;
inactivityPeriodForRepos?: number;
};
Expand Down Expand Up @@ -178,6 +189,8 @@ export interface ConfigSerialized {
* Supported workspace classes
*/
workspaceClasses: WorkspaceClassesConfig;

twilioConfigFile?: string;
}

export namespace ConfigFile {
Expand Down Expand Up @@ -240,6 +253,22 @@ export namespace ConfigFile {
log.error("Could not load Stripe config", error);
}
}
let twilioConfig: Config["twilioConfig"];
log.info("loading twilio config");
if (!!env.TWILIO_CONFIG) {
try {
log.info("Using twilio config from env var");
twilioConfig = JSON.parse(env.TWILIO_CONFIG);
} catch (error) {
log.error("Could not load Twilio config", error);
}
} else if (config.twilioConfigFile) {
try {
twilioConfig = JSON.parse(fs.readFileSync(filePathTelepresenceAware(config.twilioConfigFile), "utf-8"));
} catch (error) {
log.warn("`twilioConfigFile` path configured but couldn't load it. verification will be disabled.");
}
}
let license = config.license;
const licenseFile = config.licenseFile;
if (licenseFile) {
Expand All @@ -262,6 +291,7 @@ export namespace ConfigFile {
chargebeeProviderOptions,
stripeSecrets,
stripeConfig,
twilioConfig,
license,
workspaceGarbageCollection: {
...config.workspaceGarbageCollection,
Expand Down
Loading

0 comments on commit f157d5d

Please sign in to comment.