From f301b92dc3f2df68335ca4a9de6511c0b3452af4 Mon Sep 17 00:00:00 2001 From: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Date: Sat, 1 Aug 2020 08:42:24 -0500 Subject: [PATCH] [SSO] Merge feature/sso into master (#139) * [SSO] Reset Master Password (#134) * Initial commit of reset master password (sso) * Updated line length error * Updated import line again * Added trailing comma * restored reference data for RegisterRequest * Updated tracking boolean name // added success route update based on passed boolean * Added new API // reverted Register // deleted reset // added change pw and sso * Changed redirect URI to protected to override in sub-class * Updated api to setPassword // Updated request model name // Updated change password refs // Updated formatting * Encoded necessary parts of authorize url // Added default catch error message * Refactored methods inside change password base component // removed unnecesary query param for sso * [lint] Fixed error (#137) * Cleaned lint error * Fixed sso lint error --- src/abstractions/api.service.ts | 2 + .../components/change-password.component.ts | 157 ++++++++++++++++++ src/angular/components/sso.component.ts | 125 ++++++++++++++ .../components/two-factor.component.ts | 3 +- src/models/request/setPasswordRequest.ts | 4 + src/services/api.service.ts | 5 + 6 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 src/angular/components/change-password.component.ts create mode 100644 src/angular/components/sso.component.ts create mode 100644 src/models/request/setPasswordRequest.ts diff --git a/src/abstractions/api.service.ts b/src/abstractions/api.service.ts index b345e74fce..81a687b6b1 100644 --- a/src/abstractions/api.service.ts +++ b/src/abstractions/api.service.ts @@ -42,6 +42,7 @@ import { PreloginRequest } from '../models/request/preloginRequest'; import { RegisterRequest } from '../models/request/registerRequest'; import { SeatRequest } from '../models/request/seatRequest'; import { SelectionReadOnlyRequest } from '../models/request/selectionReadOnlyRequest'; +import { SetPasswordRequest } from '../models/request/setPasswordRequest'; import { StorageRequest } from '../models/request/storageRequest'; import { TaxInfoUpdateRequest } from '../models/request/taxInfoUpdateRequest'; import { TokenRequest } from '../models/request/tokenRequest'; @@ -125,6 +126,7 @@ export abstract class ApiService { postEmailToken: (request: EmailTokenRequest) => Promise; postEmail: (request: EmailRequest) => Promise; postPassword: (request: PasswordRequest) => Promise; + setPassword: (request: SetPasswordRequest) => Promise; postSecurityStamp: (request: PasswordVerificationRequest) => Promise; deleteAccount: (request: PasswordVerificationRequest) => Promise; getAccountRevisionDate: () => Promise; diff --git a/src/angular/components/change-password.component.ts b/src/angular/components/change-password.component.ts new file mode 100644 index 0000000000..830c0406d2 --- /dev/null +++ b/src/angular/components/change-password.component.ts @@ -0,0 +1,157 @@ +import { + OnInit, +} from '@angular/core'; + +import { + Router, +} from '@angular/router'; + +import { ApiService } from '../../abstractions/api.service'; +import { CipherService } from '../../abstractions/cipher.service'; +import { CryptoService } from '../../abstractions/crypto.service'; +import { FolderService } from '../../abstractions/folder.service'; +import { I18nService } from '../../abstractions/i18n.service'; +import { MessagingService } from '../../abstractions/messaging.service'; +import { PasswordGenerationService } from '../../abstractions/passwordGeneration.service'; +import { PlatformUtilsService } from '../../abstractions/platformUtils.service'; +import { PolicyService } from '../../abstractions/policy.service'; +import { SyncService } from '../../abstractions/sync.service'; +import { UserService } from '../../abstractions/user.service'; + +import { CipherString } from '../../models/domain/cipherString'; +import { MasterPasswordPolicyOptions } from '../../models/domain/masterPasswordPolicyOptions'; +import { SymmetricCryptoKey } from '../../models/domain/symmetricCryptoKey'; + +export class ChangePasswordComponent implements OnInit { + newMasterPassword: string; + confirmNewMasterPassword: string; + formPromise: Promise; + masterPasswordScore: number; + enforcedPolicyOptions: MasterPasswordPolicyOptions; + + private masterPasswordStrengthTimeout: any; + private email: string; + + constructor(protected apiService: ApiService, protected i18nService: I18nService, + protected cryptoService: CryptoService, protected messagingService: MessagingService, + protected userService: UserService, protected passwordGenerationService: PasswordGenerationService, + protected platformUtilsService: PlatformUtilsService, protected folderService: FolderService, + protected cipherService: CipherService, protected syncService: SyncService, + protected policyService: PolicyService, protected router: Router) { } + + async ngOnInit() { + this.email = await this.userService.getEmail(); + this.enforcedPolicyOptions = await this.policyService.getMasterPasswordPolicyOptions(); + } + + getPasswordScoreAlertDisplay() { + if (this.enforcedPolicyOptions == null) { + return ''; + } + + let str: string; + switch (this.enforcedPolicyOptions.minComplexity) { + case 4: + str = this.i18nService.t('strong'); + break; + case 3: + str = this.i18nService.t('good'); + break; + default: + str = this.i18nService.t('weak'); + break; + } + return str + ' (' + this.enforcedPolicyOptions.minComplexity + ')'; + } + + async submit() { + const hasEncKey = await this.cryptoService.hasEncKey(); + if (!hasEncKey) { + this.platformUtilsService.showToast('error', null, this.i18nService.t('updateKey')); + return; + } + + if (this.newMasterPassword == null || this.newMasterPassword === '') { + this.platformUtilsService.showToast('error', this.i18nService.t('errorOccurred'), + this.i18nService.t('masterPassRequired')); + return; + } + if (this.newMasterPassword.length < 8) { + this.platformUtilsService.showToast('error', this.i18nService.t('errorOccurred'), + this.i18nService.t('masterPassLength')); + return; + } + if (this.newMasterPassword !== this.confirmNewMasterPassword) { + this.platformUtilsService.showToast('error', this.i18nService.t('errorOccurred'), + this.i18nService.t('masterPassDoesntMatch')); + return; + } + + const strengthResult = this.passwordGenerationService.passwordStrength(this.newMasterPassword, + this.getPasswordStrengthUserInput()); + + if (this.enforcedPolicyOptions != null && + !this.policyService.evaluateMasterPassword( + strengthResult.score, + this.newMasterPassword, + this.enforcedPolicyOptions)) { + this.platformUtilsService.showToast('error', this.i18nService.t('errorOccurred'), + this.i18nService.t('masterPasswordPolicyRequirementsNotMet')); + return; + } + + if (strengthResult != null && strengthResult.score < 3) { + const result = await this.platformUtilsService.showDialog(this.i18nService.t('weakMasterPasswordDesc'), + this.i18nService.t('weakMasterPassword'), this.i18nService.t('yes'), this.i18nService.t('no'), + 'warning'); + if (!result) { + return; + } + } + + if (!await this.setupSubmitActions()) { + return; + } + + const email = await this.userService.getEmail(); + const kdf = await this.userService.getKdf(); + const kdfIterations = await this.userService.getKdfIterations(); + const newKey = await this.cryptoService.makeKey(this.newMasterPassword, email.trim().toLowerCase(), + kdf, kdfIterations); + const newMasterPasswordHash = await this.cryptoService.hashPassword(this.newMasterPassword, newKey); + const newEncKey = await this.cryptoService.remakeEncKey(newKey); + + await this.performSubmitActions(newMasterPasswordHash, newKey, newEncKey); + } + + async setupSubmitActions(): Promise { + // Override in sub-class + // Can be used for additional validation and/or other processes the should occur before changing passwords + return true; + } + + async performSubmitActions(newMasterPasswordHash: string, newKey: SymmetricCryptoKey, + newEncKey: [SymmetricCryptoKey, CipherString]) { + // Override in sub-class + } + + updatePasswordStrength() { + if (this.masterPasswordStrengthTimeout != null) { + clearTimeout(this.masterPasswordStrengthTimeout); + } + this.masterPasswordStrengthTimeout = setTimeout(() => { + const strengthResult = this.passwordGenerationService.passwordStrength(this.newMasterPassword, + this.getPasswordStrengthUserInput()); + this.masterPasswordScore = strengthResult == null ? null : strengthResult.score; + }, 300); + } + + private getPasswordStrengthUserInput() { + let userInput: string[] = []; + const atPosition = this.email.indexOf('@'); + if (atPosition > -1) { + userInput = userInput.concat(this.email.substr(0, atPosition).trim().toLowerCase().split(/[^A-Za-z0-9]/)); + } + return userInput; + } +} diff --git a/src/angular/components/sso.component.ts b/src/angular/components/sso.component.ts new file mode 100644 index 0000000000..777c17b6bb --- /dev/null +++ b/src/angular/components/sso.component.ts @@ -0,0 +1,125 @@ +import { + ActivatedRoute, + Router, +} from '@angular/router'; + +import { ApiService } from '../../abstractions/api.service'; +import { AuthService } from '../../abstractions/auth.service'; +import { CryptoFunctionService } from '../../abstractions/cryptoFunction.service'; +import { I18nService } from '../../abstractions/i18n.service'; +import { PasswordGenerationService } from '../../abstractions/passwordGeneration.service'; +import { PlatformUtilsService } from '../../abstractions/platformUtils.service'; +import { StateService } from '../../abstractions/state.service'; +import { StorageService } from '../../abstractions/storage.service'; + +import { ConstantsService } from '../../services/constants.service'; + +import { Utils } from '../../misc/utils'; + +import { AuthResult } from '../../models/domain/authResult'; + +export class SsoComponent { + identifier: string; + loggingIn = false; + + formPromise: Promise; + onSuccessfulLogin: () => Promise; + onSuccessfulLoginNavigate: () => Promise; + onSuccessfulLoginTwoFactorNavigate: () => Promise; + onSuccessfulLoginChangePasswordNavigate: () => Promise; + + protected twoFactorRoute = '2fa'; + protected successRoute = 'lock'; + protected changePasswordRoute = 'change-password'; + protected redirectUri: string; + + constructor(protected authService: AuthService, protected router: Router, + protected i18nService: I18nService, protected route: ActivatedRoute, + protected storageService: StorageService, protected stateService: StateService, + protected platformUtilsService: PlatformUtilsService, protected apiService: ApiService, + protected cryptoFunctionService: CryptoFunctionService, + protected passwordGenerationService: PasswordGenerationService) { } + + async ngOnInit() { + const queryParamsSub = this.route.queryParams.subscribe(async (qParams) => { + if (qParams.code != null && qParams.state != null) { + const codeVerifier = await this.storageService.get(ConstantsService.ssoCodeVerifierKey); + const state = await this.storageService.get(ConstantsService.ssoStateKey); + await this.storageService.remove(ConstantsService.ssoCodeVerifierKey); + await this.storageService.remove(ConstantsService.ssoStateKey); + if (qParams.code != null && codeVerifier != null && state != null && state === qParams.state) { + await this.logIn(qParams.code, codeVerifier); + } + } + if (queryParamsSub != null) { + queryParamsSub.unsubscribe(); + } + }); + } + + async submit() { + const passwordOptions: any = { + type: 'password', + length: 64, + uppercase: true, + lowercase: true, + 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); + + const authorizeUrl = this.apiService.identityBaseUrl + '/connect/authorize?' + + 'client_id=web&redirect_uri=' + encodeURIComponent(this.redirectUri) + '&' + + 'response_type=code&scope=api offline_access&' + + 'state=' + state + '&code_challenge=' + codeChallenge + '&' + + 'code_challenge_method=S256&response_mode=query&' + + 'domain_hint=' + encodeURIComponent(this.identifier); + this.platformUtilsService.launchUri(authorizeUrl, { sameWindow: true }); + } + + private async logIn(code: string, codeVerifier: string) { + this.loggingIn = true; + try { + this.formPromise = this.authService.logInSso(code, codeVerifier, this.redirectUri); + const response = await this.formPromise; + if (response.twoFactor) { + this.platformUtilsService.eventTrack('SSO Logged In To Two-step'); + if (this.onSuccessfulLoginTwoFactorNavigate != null) { + this.onSuccessfulLoginTwoFactorNavigate(); + } else { + this.router.navigate([this.twoFactorRoute], { + queryParams: { + resetMasterPassword: response.resetMasterPassword, + }, + }); + } + } else if (response.resetMasterPassword) { + this.platformUtilsService.eventTrack('SSO - routing to complete registration'); + if (this.onSuccessfulLoginChangePasswordNavigate != null) { + this.onSuccessfulLoginChangePasswordNavigate(); + } else { + this.router.navigate([this.changePasswordRoute]); + } + } else { + const disableFavicon = await this.storageService.get(ConstantsService.disableFaviconKey); + await this.stateService.save(ConstantsService.disableFaviconKey, !!disableFavicon); + if (this.onSuccessfulLogin != null) { + this.onSuccessfulLogin(); + } + this.platformUtilsService.eventTrack('SSO Logged In'); + if (this.onSuccessfulLoginNavigate != null) { + this.onSuccessfulLoginNavigate(); + } else { + this.router.navigate([this.successRoute]); + } + } + } catch { } + this.loggingIn = false; + } +} diff --git a/src/angular/components/two-factor.component.ts b/src/angular/components/two-factor.component.ts index 94a1e1553a..16a7d1032d 100644 --- a/src/angular/components/two-factor.component.ts +++ b/src/angular/components/two-factor.component.ts @@ -37,6 +37,7 @@ export class TwoFactorComponent implements OnInit, OnDestroy { twoFactorEmail: string = null; formPromise: Promise; emailPromise: Promise; + resetMasterPassword: boolean = false; onSuccessfulLogin: () => Promise; onSuccessfulLoginNavigate: () => Promise; @@ -59,7 +60,7 @@ export class TwoFactorComponent implements OnInit, OnDestroy { } if (this.authService.authingWithSso()) { - this.successRoute = 'lock'; + this.successRoute = this.resetMasterPassword ? 'reset-master-password' : 'lock'; } if (this.initU2f && this.win != null && this.u2fSupported) { diff --git a/src/models/request/setPasswordRequest.ts b/src/models/request/setPasswordRequest.ts new file mode 100644 index 0000000000..9593f50926 --- /dev/null +++ b/src/models/request/setPasswordRequest.ts @@ -0,0 +1,4 @@ +export class SetPasswordRequest { + newMasterPasswordHash: string; + key: string; +} diff --git a/src/services/api.service.ts b/src/services/api.service.ts index 8b9249429e..4999d059d2 100644 --- a/src/services/api.service.ts +++ b/src/services/api.service.ts @@ -46,6 +46,7 @@ import { PreloginRequest } from '../models/request/preloginRequest'; import { RegisterRequest } from '../models/request/registerRequest'; import { SeatRequest } from '../models/request/seatRequest'; import { SelectionReadOnlyRequest } from '../models/request/selectionReadOnlyRequest'; +import { SetPasswordRequest } from '../models/request/setPasswordRequest'; import { StorageRequest } from '../models/request/storageRequest'; import { TaxInfoUpdateRequest } from '../models/request/taxInfoUpdateRequest'; import { TokenRequest } from '../models/request/tokenRequest'; @@ -254,6 +255,10 @@ export class ApiService implements ApiServiceAbstraction { return this.send('POST', '/accounts/password', request, true, false); } + setPassword(request: SetPasswordRequest): Promise { + return this.send('POST', '/accounts/set-password', request, true, false); + } + postSecurityStamp(request: PasswordVerificationRequest): Promise { return this.send('POST', '/accounts/security-stamp', request, true, false); }