From 7d49902eea45275d50c949beec32b3ab5b7db725 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Mon, 3 Aug 2020 15:24:26 -0400 Subject: [PATCH] SSO login for generic clients and CLI (#140) * sso * move break into try block * make client id dynamic * clientId is a string, DOH! * reject if port not available * lint fixes --- package-lock.json | 22 +++ package.json | 1 + src/angular/components/sso.component.ts | 30 +++- src/cli/commands/login.command.ts | 164 ++++++++++++++++----- src/services/passwordGeneration.service.ts | 3 +- 5 files changed, 175 insertions(+), 45 deletions(-) diff --git a/package-lock.json b/package-lock.json index b36e8bd6fb..e17bf0fcc9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4132,6 +4132,11 @@ } } }, + "is-docker": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.0.0.tgz", + "integrity": "sha512-pJEdRugimx4fBMra5z2/5iRdZ63OhYV0vr0Dwm5+xtW4D1FvRkB8hamMIhnWfyJeDdyr/aa7BDyNbtG38VxgoQ==" + }, "is-extendable": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", @@ -4265,6 +4270,14 @@ "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", "dev": true }, + "is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "requires": { + "is-docker": "^2.0.0" + } + }, "isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -6272,6 +6285,15 @@ "mimic-fn": "^1.0.0" } }, + "open": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/open/-/open-7.1.0.tgz", + "integrity": "sha512-lLPI5KgOwEYCDKXf4np7y1PBEkj7HYIyP2DY8mVDRnx0VIIu6bNrRB0R66TuO7Mack6EnTNLm4uvcl1UoklTpA==", + "requires": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + } + }, "opencollective": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/opencollective/-/opencollective-1.0.3.tgz", diff --git a/package.json b/package.json index 71bf68e843..72aec50c87 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "ngx-infinite-scroll": "7.0.1", "node-fetch": "2.2.0", "node-forge": "0.7.6", + "open": "7.1.0", "papaparse": "4.6.0", "rxjs": "6.3.3", "tldjs": "2.3.1", diff --git a/src/angular/components/sso.component.ts b/src/angular/components/sso.component.ts index 777c17b6bb..f2f4db3bf8 100644 --- a/src/angular/components/sso.component.ts +++ b/src/angular/components/sso.component.ts @@ -31,7 +31,10 @@ export class SsoComponent { protected twoFactorRoute = '2fa'; protected successRoute = 'lock'; protected changePasswordRoute = 'change-password'; + protected clientId: string; protected redirectUri: string; + protected state: string; + protected codeChallenge: string; constructor(protected authService: AuthService, protected router: Router, protected i18nService: I18nService, protected route: ActivatedRoute, @@ -50,6 +53,12 @@ export class SsoComponent { if (qParams.code != null && codeVerifier != null && state != null && state === qParams.state) { await this.logIn(qParams.code, codeVerifier); } + } else if (qParams.clientId != null && qParams.redirectUri != null && qParams.state != null && + qParams.codeChallenge != null) { + this.redirectUri = qParams.redirectUri; + this.state = qParams.state; + this.codeChallenge = qParams.codeChallenge; + this.clientId = qParams.clientId; } if (queryParamsSub != null) { queryParamsSub.unsubscribe(); @@ -66,16 +75,21 @@ export class SsoComponent { numbers: true, special: false, }; - const state = await this.passwordGenerationService.generatePassword(passwordOptions); - const codeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions); - const codeVerifierHash = await this.cryptoFunctionService.hash(codeVerifier, 'sha256'); - const codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash); - - await this.storageService.save(ConstantsService.ssoCodeVerifierKey, codeVerifier); - await this.storageService.save(ConstantsService.ssoStateKey, state); + let codeChallenge = this.codeChallenge; + let state = this.state; + if (codeChallenge == null) { + const codeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions); + const codeVerifierHash = await this.cryptoFunctionService.hash(codeVerifier, 'sha256'); + codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash); + await this.storageService.save(ConstantsService.ssoCodeVerifierKey, codeVerifier); + await this.storageService.save(ConstantsService.ssoStateKey, state); + } + if (state == null) { + state = await this.passwordGenerationService.generatePassword(passwordOptions); + } const authorizeUrl = this.apiService.identityBaseUrl + '/connect/authorize?' + - 'client_id=web&redirect_uri=' + encodeURIComponent(this.redirectUri) + '&' + + 'client_id=' + this.clientId + '&redirect_uri=' + encodeURIComponent(this.redirectUri) + '&' + 'response_type=code&scope=api offline_access&' + 'state=' + state + '&code_challenge=' + codeChallenge + '&' + 'code_challenge_method=S256&response_mode=query&' + diff --git a/src/cli/commands/login.command.ts b/src/cli/commands/login.command.ts index f900c0e0b1..6cde86933c 100644 --- a/src/cli/commands/login.command.ts +++ b/src/cli/commands/login.command.ts @@ -1,4 +1,5 @@ import * as program from 'commander'; +import * as http from 'http'; import * as inquirer from 'inquirer'; import { TwoFactorProviderType } from '../../enums/twoFactorProviderType'; @@ -8,55 +9,91 @@ import { TwoFactorEmailRequest } from '../../models/request/twoFactorEmailReques import { ApiService } from '../../abstractions/api.service'; import { AuthService } from '../../abstractions/auth.service'; +import { CryptoFunctionService } from '../../abstractions/cryptoFunction.service'; +import { EnvironmentService } from '../../abstractions/environment.service'; import { I18nService } from '../../abstractions/i18n.service'; +import { PasswordGenerationService } from '../../abstractions/passwordGeneration.service'; import { Response } from '../models/response'; import { MessageResponse } from '../models/response/messageResponse'; import { NodeUtils } from '../../misc/nodeUtils'; +import { Utils } from '../../misc/utils'; + +// tslint:disable-next-line +const open = require('open'); export class LoginCommand { protected validatedParams: () => Promise; protected success: () => Promise; + protected canInteract: boolean; + protected clientId: string; + + private ssoRedirectUri: string = null; constructor(protected authService: AuthService, protected apiService: ApiService, - protected i18nService: I18nService) { } + protected i18nService: I18nService, protected environmentService: EnvironmentService, + protected passwordGenerationService: PasswordGenerationService, + protected cryptoFunctionService: CryptoFunctionService) { } async run(email: string, password: string, cmd: program.Command) { - const canInteract = process.env.BW_NOINTERACTION !== 'true'; - if ((email == null || email === '') && canInteract) { - const answer: inquirer.Answers = await inquirer.createPromptModule({ output: process.stderr })({ - type: 'input', - name: 'email', - message: 'Email address:', - }); - email = answer.email; - } - if (email == null || email.trim() === '') { - return Response.badRequest('Email address is required.'); - } - if (email.indexOf('@') === -1) { - return Response.badRequest('Email address is invalid.'); - } + this.canInteract = process.env.BW_NOINTERACTION !== 'true'; - if (password == null || password === '') { - if (cmd.passwordfile) { - password = await NodeUtils.readFirstLine(cmd.passwordfile); - } else if (cmd.passwordenv && process.env[cmd.passwordenv]) { - password = process.env[cmd.passwordenv]; - } else if (canInteract) { - const answer: inquirer.Answers = await inquirer.createPromptModule({ output: process.stderr })({ - type: 'password', - name: 'password', - message: 'Master password:', - }); - password = answer.password; + let ssoCodeVerifier: string = null; + let ssoCode: string = null; + if (cmd.sso != null && this.canInteract) { + const passwordOptions: any = { + type: 'password', + length: 64, + uppercase: true, + lowercase: true, + numbers: true, + special: false, + }; + const state = await this.passwordGenerationService.generatePassword(passwordOptions); + ssoCodeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions); + const codeVerifierHash = await this.cryptoFunctionService.hash(ssoCodeVerifier, 'sha256'); + const codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash); + try { + ssoCode = await this.getSsoCode(codeChallenge, state); + } catch { + return Response.badRequest('Something went wrong. Try again.'); + } + } else { + if ((email == null || email === '') && this.canInteract) { + const answer: inquirer.Answers = await inquirer.createPromptModule({ output: process.stderr })({ + type: 'input', + name: 'email', + message: 'Email address:', + }); + email = answer.email; + } + if (email == null || email.trim() === '') { + return Response.badRequest('Email address is required.'); + } + if (email.indexOf('@') === -1) { + return Response.badRequest('Email address is invalid.'); } - } - if (password == null || password === '') { - return Response.badRequest('Master password is required.'); + if (password == null || password === '') { + if (cmd.passwordfile) { + password = await NodeUtils.readFirstLine(cmd.passwordfile); + } else if (cmd.passwordenv && process.env[cmd.passwordenv]) { + password = process.env[cmd.passwordenv]; + } else if (this.canInteract) { + const answer: inquirer.Answers = await inquirer.createPromptModule({ output: process.stderr })({ + type: 'password', + name: 'password', + message: 'Master password:', + }); + password = answer.password; + } + } + + if (password == null || password === '') { + return Response.badRequest('Master password is required.'); + } } let twoFactorToken: string = cmd.code; @@ -76,10 +113,20 @@ export class LoginCommand { let response: AuthResult = null; if (twoFactorToken != null && twoFactorMethod != null) { - response = await this.authService.logInComplete(email, password, twoFactorMethod, - twoFactorToken, false); + if (ssoCode != null && ssoCodeVerifier != null) { + response = await this.authService.logInSsoComplete(ssoCode, ssoCodeVerifier, this.ssoRedirectUri, + twoFactorMethod, twoFactorToken, false); + } else { + response = await this.authService.logInComplete(email, password, twoFactorMethod, + twoFactorToken, false); + } } else { - response = await this.authService.logIn(email, password); + if (ssoCode != null && ssoCodeVerifier != null) { + response = await this.authService.logInSso(ssoCode, ssoCodeVerifier, this.ssoRedirectUri); + + } else { + response = await this.authService.logIn(email, password); + } if (response.twoFactor) { let selectedProvider: any = null; const twoFactorProviders = this.authService.getSupportedTwoFactorProviders(null); @@ -98,7 +145,7 @@ export class LoginCommand { if (selectedProvider == null) { if (twoFactorProviders.length === 1) { selectedProvider = twoFactorProviders[0]; - } else if (canInteract) { + } else if (this.canInteract) { const options = twoFactorProviders.map((p) => p.name); options.push(new inquirer.Separator()); options.push('Cancel'); @@ -128,7 +175,7 @@ export class LoginCommand { } if (twoFactorToken == null) { - if (canInteract) { + if (this.canInteract) { const answer: inquirer.Answers = await inquirer.createPromptModule({ output: process.stderr })({ type: 'input', @@ -162,4 +209,49 @@ export class LoginCommand { return Response.error(e); } } + + private async getSsoCode(codeChallenge: string, state: string): Promise { + return new Promise((resolve, reject) => { + const callbackServer = http.createServer((req, res) => { + const urlString = 'http://localhost' + req.url; + const url = new URL(urlString); + const code = url.searchParams.get('code'); + const receivedState = url.searchParams.get('state'); + res.setHeader('Content-Type', 'text/html'); + if (code != null && receivedState != null && receivedState === state) { + res.writeHead(200); + res.end('Success | Bitwarden CLI' + + '

Successfully authenticated with the Bitwarden CLI

' + + '

You may now close this tab and return to the terminal.

' + + ''); + callbackServer.close(() => resolve(code)); + } else { + res.writeHead(400); + res.end('Failed | Bitwarden CLI' + + '

Something went wrong logging into the Bitwarden CLI

' + + '

You may now close this tab and return to the terminal.

' + + ''); + callbackServer.close(() => reject()); + } + }); + let foundPort = false; + const webUrl = this.environmentService.webVaultUrl == null ? 'https://vault.bitwarden.com' : + this.environmentService.webVaultUrl; + for (let port = 8065; port <= 8070; port++) { + try { + this.ssoRedirectUri = 'http://localhost:' + port; + callbackServer.listen(port, async () => { + await open(webUrl + '/#/sso?clientId=' + this.clientId + + '&redirectUri=' + encodeURIComponent(this.ssoRedirectUri) + + '&state=' + state + '&codeChallenge=' + codeChallenge); + }); + foundPort = true; + break; + } catch { } + } + if (!foundPort) { + reject(); + } + }); + } } diff --git a/src/services/passwordGeneration.service.ts b/src/services/passwordGeneration.service.ts index 4aff5a4223..fc83dcb0d5 100644 --- a/src/services/passwordGeneration.service.ts +++ b/src/services/passwordGeneration.service.ts @@ -261,7 +261,8 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr } async getPasswordGeneratorPolicyOptions(): Promise { - const policies: Policy[] = await this.policyService.getAll(PolicyType.PasswordGenerator); + const policies: Policy[] = this.policyService == null ? null : + await this.policyService.getAll(PolicyType.PasswordGenerator); let enforcedOptions: PasswordGeneratorPolicyOptions = null; if (policies == null || policies.length === 0) {