diff --git a/angular/src/components/login.component.ts b/angular/src/components/login.component.ts index e860ca9c15..f719267191 100644 --- a/angular/src/components/login.component.ts +++ b/angular/src/components/login.component.ts @@ -5,6 +5,7 @@ import { Router } from "@angular/router"; import { take } from "rxjs/operators"; import { AuthResult } from "jslib-common/models/domain/authResult"; +import { PasswordLogInCredentials } from "jslib-common/models/domain/logInCredentials"; import { AuthService } from "jslib-common/abstractions/auth.service"; import { CryptoFunctionService } from "jslib-common/abstractions/cryptoFunction.service"; @@ -96,7 +97,13 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit } try { - this.formPromise = this.authService.logIn(this.email, this.masterPassword, this.captchaToken); + const credentials = new PasswordLogInCredentials( + this.email, + this.masterPassword, + this.captchaToken, + null + ); + this.formPromise = this.authService.logIn(credentials); const response = await this.formPromise; if (this.rememberEmail || this.alwaysRememberEmail) { await this.stateService.setRememberedEmail(this.email); @@ -105,7 +112,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit } if (this.handleCaptchaRequired(response)) { return; - } else if (response.twoFactor) { + } else if (response.requiresTwoFactor) { if (this.onSuccessfulLoginTwoFactorNavigate != null) { this.onSuccessfulLoginTwoFactorNavigate(); } else { diff --git a/angular/src/components/sso.component.ts b/angular/src/components/sso.component.ts index 649f298cab..06d0d0377c 100644 --- a/angular/src/components/sso.component.ts +++ b/angular/src/components/sso.component.ts @@ -16,6 +16,7 @@ import { StateService } from "jslib-common/abstractions/state.service"; import { Utils } from "jslib-common/misc/utils"; import { AuthResult } from "jslib-common/models/domain/authResult"; +import { SsoLogInCredentials } from "jslib-common/models/domain/logInCredentials"; @Directive() export class SsoComponent { @@ -171,14 +172,15 @@ export class SsoComponent { private async logIn(code: string, codeVerifier: string, orgIdFromState: string) { this.loggingIn = true; try { - this.formPromise = this.authService.logInSso( + const credentials = new SsoLogInCredentials( code, codeVerifier, this.redirectUri, orgIdFromState ); + this.formPromise = this.authService.logIn(credentials); const response = await this.formPromise; - if (response.twoFactor) { + if (response.requiresTwoFactor) { if (this.onSuccessfulLoginTwoFactorNavigate != null) { this.onSuccessfulLoginTwoFactorNavigate(); } else { diff --git a/angular/src/components/two-factor-options.component.ts b/angular/src/components/two-factor-options.component.ts index 4909757f43..ddf3d2f3de 100644 --- a/angular/src/components/two-factor-options.component.ts +++ b/angular/src/components/two-factor-options.component.ts @@ -6,6 +6,7 @@ import { TwoFactorProviderType } from "jslib-common/enums/twoFactorProviderType" import { AuthService } from "jslib-common/abstractions/auth.service"; import { I18nService } from "jslib-common/abstractions/i18n.service"; import { PlatformUtilsService } from "jslib-common/abstractions/platformUtils.service"; +import { TwoFactorService } from "jslib-common/abstractions/twoFactor.service"; @Directive() export class TwoFactorOptionsComponent implements OnInit { @@ -15,7 +16,7 @@ export class TwoFactorOptionsComponent implements OnInit { providers: any[] = []; constructor( - protected authService: AuthService, + protected twoFactorService: TwoFactorService, protected router: Router, protected i18nService: I18nService, protected platformUtilsService: PlatformUtilsService, @@ -23,7 +24,7 @@ export class TwoFactorOptionsComponent implements OnInit { ) {} ngOnInit() { - this.providers = this.authService.getSupportedTwoFactorProviders(this.win); + this.providers = this.twoFactorService.getSupportedProviders(this.win); } choose(p: any) { diff --git a/angular/src/components/two-factor.component.ts b/angular/src/components/two-factor.component.ts index f3813a9664..16a5873a7b 100644 --- a/angular/src/components/two-factor.component.ts +++ b/angular/src/components/two-factor.component.ts @@ -18,9 +18,10 @@ import { LogService } from "jslib-common/abstractions/log.service"; import { PlatformUtilsService } from "jslib-common/abstractions/platformUtils.service"; import { StateService } from "jslib-common/abstractions/state.service"; -import { TwoFactorProviders } from "jslib-common/services/auth.service"; +import { TwoFactorProviders } from "jslib-common/services/twoFactor.service"; import * as DuoWebSDK from "duo_web_sdk"; +import { TwoFactorService } from "jslib-common/abstractions/twoFactor.service"; import { WebAuthnIFrame } from "jslib-common/misc/webauthn_iframe"; @Directive() @@ -59,13 +60,14 @@ export class TwoFactorComponent implements OnInit, OnDestroy { protected environmentService: EnvironmentService, protected stateService: StateService, protected route: ActivatedRoute, - protected logService: LogService + protected logService: LogService, + protected twoFactorService: TwoFactorService ) { this.webAuthnSupported = this.platformUtilsService.supportsWebAuthn(win); } async ngOnInit() { - if (!this.authing || this.authService.twoFactorProvidersData == null) { + if (!this.authing || this.twoFactorService.getProviders() == null) { this.router.navigate([this.loginRoute]); return; } @@ -103,9 +105,7 @@ export class TwoFactorComponent implements OnInit, OnDestroy { ); } - this.selectedProviderType = this.authService.getDefaultTwoFactorProvider( - this.webAuthnSupported - ); + this.selectedProviderType = this.twoFactorService.getDefaultProvider(this.webAuthnSupported); await this.init(); } @@ -122,7 +122,7 @@ export class TwoFactorComponent implements OnInit, OnDestroy { this.cleanupWebAuthn(); this.title = (TwoFactorProviders as any)[this.selectedProviderType].name; - const providerData = this.authService.twoFactorProvidersData.get(this.selectedProviderType); + const providerData = this.twoFactorService.getProviders().get(this.selectedProviderType); switch (this.selectedProviderType) { case TwoFactorProviderType.WebAuthn: if (!this.webAuthnNewTab) { @@ -150,7 +150,7 @@ export class TwoFactorComponent implements OnInit, OnDestroy { break; case TwoFactorProviderType.Email: this.twoFactorEmail = providerData.Email; - if (this.authService.twoFactorProvidersData.size > 1) { + if (this.twoFactorService.getProviders().size > 1) { await this.sendEmail(false); } break; @@ -192,11 +192,11 @@ export class TwoFactorComponent implements OnInit, OnDestroy { } async doSubmit() { - this.formPromise = this.authService.logInTwoFactor( - this.selectedProviderType, - this.token, - this.remember - ); + this.formPromise = this.authService.logInTwoFactor({ + provider: this.selectedProviderType, + token: this.token, + remember: this.remember, + }); const response: AuthResult = await this.formPromise; const disableFavicon = await this.stateService.getDisableFavicon(); await this.stateService.setDisableFavicon(!!disableFavicon); @@ -250,7 +250,7 @@ export class TwoFactorComponent implements OnInit, OnDestroy { } authWebAuthn() { - const providerData = this.authService.twoFactorProvidersData.get(this.selectedProviderType); + const providerData = this.twoFactorService.getProviders().get(this.selectedProviderType); if (!this.webAuthnSupported || this.webAuthn == null) { return; diff --git a/angular/src/services/jslib-services.module.ts b/angular/src/services/jslib-services.module.ts index a08bce2410..ec6261cf97 100644 --- a/angular/src/services/jslib-services.module.ts +++ b/angular/src/services/jslib-services.module.ts @@ -27,6 +27,7 @@ import { StateMigrationService } from "jslib-common/services/stateMigration.serv import { SyncService } from "jslib-common/services/sync.service"; import { TokenService } from "jslib-common/services/token.service"; import { TotpService } from "jslib-common/services/totp.service"; +import { TwoFactorService } from "jslib-common/services/twoFactor.service"; import { UserVerificationService } from "jslib-common/services/userVerification.service"; import { VaultTimeoutService } from "jslib-common/services/vaultTimeout.service"; import { WebCryptoFunctionService } from "jslib-common/services/webCryptoFunction.service"; @@ -65,6 +66,7 @@ import { StorageService as StorageServiceAbstraction } from "jslib-common/abstra import { SyncService as SyncServiceAbstraction } from "jslib-common/abstractions/sync.service"; import { TokenService as TokenServiceAbstraction } from "jslib-common/abstractions/token.service"; import { TotpService as TotpServiceAbstraction } from "jslib-common/abstractions/totp.service"; +import { TwoFactorService as TwoFactorServiceAbstraction } from "jslib-common/abstractions/twoFactor.service"; import { UserVerificationService as UserVerificationServiceAbstraction } from "jslib-common/abstractions/userVerification.service"; import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "jslib-common/abstractions/vaultTimeout.service"; @@ -114,15 +116,13 @@ import { StateFactory } from "jslib-common/factories/stateFactory"; ApiServiceAbstraction, TokenServiceAbstraction, AppIdServiceAbstraction, - I18nServiceAbstraction, PlatformUtilsServiceAbstraction, MessagingServiceAbstraction, - VaultTimeoutServiceAbstraction, LogService, - CryptoFunctionServiceAbstraction, KeyConnectorServiceAbstraction, EnvironmentServiceAbstraction, StateServiceAbstraction, + TwoFactorServiceAbstraction, ], }, { @@ -455,6 +455,7 @@ import { StateFactory } from "jslib-common/factories/stateFactory"; TokenServiceAbstraction, LogService, OrganizationServiceAbstraction, + CryptoFunctionServiceAbstraction, ], }, { @@ -473,6 +474,11 @@ import { StateFactory } from "jslib-common/factories/stateFactory"; useClass: ProviderService, deps: [StateServiceAbstraction], }, + { + provide: TwoFactorServiceAbstraction, + useClass: TwoFactorService, + deps: [I18nServiceAbstraction, PlatformUtilsServiceAbstraction], + }, ], }) export class JslibServicesModule {} diff --git a/common/src/abstractions/api.service.ts b/common/src/abstractions/api.service.ts index 4ef3c1a93c..a23b311bbf 100644 --- a/common/src/abstractions/api.service.ts +++ b/common/src/abstractions/api.service.ts @@ -75,7 +75,6 @@ import { SendRequest } from "../models/request/sendRequest"; import { SetPasswordRequest } from "../models/request/setPasswordRequest"; import { StorageRequest } from "../models/request/storageRequest"; import { TaxInfoUpdateRequest } from "../models/request/taxInfoUpdateRequest"; -import { TokenRequest } from "../models/request/tokenRequest"; import { TwoFactorEmailRequest } from "../models/request/twoFactorEmailRequest"; import { TwoFactorProviderRequest } from "../models/request/twoFactorProviderRequest"; import { TwoFactorRecoveryRequest } from "../models/request/twoFactorRecoveryRequest"; @@ -93,6 +92,10 @@ import { VerifyBankRequest } from "../models/request/verifyBankRequest"; import { VerifyDeleteRecoverRequest } from "../models/request/verifyDeleteRecoverRequest"; import { VerifyEmailRequest } from "../models/request/verifyEmailRequest"; +import { ApiTokenRequest } from "../models/request/identityToken/apiTokenRequest"; +import { PasswordTokenRequest } from "../models/request/identityToken/passwordTokenRequest"; +import { SsoTokenRequest } from "../models/request/identityToken/ssoTokenRequest"; + import { ApiKeyResponse } from "../models/response/apiKeyResponse"; import { AttachmentResponse } from "../models/response/attachmentResponse"; import { AttachmentUploadDataResponse } from "../models/response/attachmentUploadDataResponse"; @@ -171,7 +174,7 @@ import { SendAccessView } from "../models/view/sendAccessView"; export abstract class ApiService { postIdentityToken: ( - request: TokenRequest + request: PasswordTokenRequest | SsoTokenRequest | ApiTokenRequest ) => Promise; refreshIdentityToken: () => Promise; diff --git a/common/src/abstractions/auth.service.ts b/common/src/abstractions/auth.service.ts index 10dff3a87d..ec36acc8bb 100644 --- a/common/src/abstractions/auth.service.ts +++ b/common/src/abstractions/auth.service.ts @@ -1,58 +1,21 @@ -import { TwoFactorProviderType } from "../enums/twoFactorProviderType"; - import { AuthResult } from "../models/domain/authResult"; +import { + ApiLogInCredentials, + PasswordLogInCredentials, + SsoLogInCredentials, +} from "../models/domain/logInCredentials"; import { SymmetricCryptoKey } from "../models/domain/symmetricCryptoKey"; -export abstract class AuthService { - email: string; - masterPasswordHash: string; - code: string; - codeVerifier: string; - ssoRedirectUrl: string; - clientId: string; - clientSecret: string; - twoFactorProvidersData: Map; - selectedTwoFactorProviderType: TwoFactorProviderType; +import { TokenRequestTwoFactor } from "../models/request/identityToken/tokenRequest"; - logIn: (email: string, masterPassword: string, captchaToken?: string) => Promise; - logInSso: ( - code: string, - codeVerifier: string, - redirectUrl: string, - orgId: string - ) => Promise; - logInApiKey: (clientId: string, clientSecret: string) => Promise; - logInTwoFactor: ( - twoFactorProvider: TwoFactorProviderType, - twoFactorToken: string, - remember?: boolean - ) => Promise; - logInComplete: ( - email: string, - masterPassword: string, - twoFactorProvider: TwoFactorProviderType, - twoFactorToken: string, - remember?: boolean, - captchaToken?: string - ) => Promise; - logInSsoComplete: ( - code: string, - codeVerifier: string, - redirectUrl: string, - twoFactorProvider: TwoFactorProviderType, - twoFactorToken: string, - remember?: boolean - ) => Promise; - logInApiKeyComplete: ( - clientId: string, - clientSecret: string, - twoFactorProvider: TwoFactorProviderType, - twoFactorToken: string, - remember?: boolean +export abstract class AuthService { + masterPasswordHash: string; + email: string; + logIn: ( + credentials: ApiLogInCredentials | PasswordLogInCredentials | SsoLogInCredentials ) => Promise; + logInTwoFactor: (twoFactor: TokenRequestTwoFactor) => Promise; logOut: (callback: Function) => void; - getSupportedTwoFactorProviders: (win: Window) => any[]; - getDefaultTwoFactorProvider: (webAuthnSupported: boolean) => TwoFactorProviderType; makePreloginKey: (masterPassword: string, email: string) => Promise; authingWithApiKey: () => boolean; authingWithSso: () => boolean; diff --git a/common/src/abstractions/keyConnector.service.ts b/common/src/abstractions/keyConnector.service.ts index ca57bbf6bc..5b1ca3a36e 100644 --- a/common/src/abstractions/keyConnector.service.ts +++ b/common/src/abstractions/keyConnector.service.ts @@ -1,11 +1,17 @@ import { Organization } from "../models/domain/organization"; +import { IdentityTokenResponse } from "../models/response/identityTokenResponse"; + export abstract class KeyConnectorService { getAndSetKey: (url?: string) => Promise; getManagingOrganization: () => Promise; getUsesKeyConnector: () => Promise; migrateUser: () => Promise; userNeedsMigration: () => Promise; + convertNewSsoUserToKeyConnector: ( + tokenResponse: IdentityTokenResponse, + orgId: string + ) => Promise; setUsesKeyConnector: (enabled: boolean) => Promise; setConvertAccountRequired: (status: boolean) => Promise; getConvertAccountRequired: () => Promise; diff --git a/common/src/abstractions/token.service.ts b/common/src/abstractions/token.service.ts index 6006ba10dd..89aec53601 100644 --- a/common/src/abstractions/token.service.ts +++ b/common/src/abstractions/token.service.ts @@ -1,3 +1,5 @@ +import { IdentityTokenResponse } from "../models/response/identityTokenResponse"; + export abstract class TokenService { setTokens: ( accessToken: string, @@ -13,9 +15,9 @@ export abstract class TokenService { setClientSecret: (clientSecret: string) => Promise; getClientSecret: () => Promise; toggleTokens: () => Promise; - setTwoFactorToken: (token: string, email: string) => Promise; - getTwoFactorToken: (email: string) => Promise; - clearTwoFactorToken: (email: string) => Promise; + setTwoFactorToken: (tokenResponse: IdentityTokenResponse) => Promise; + getTwoFactorToken: () => Promise; + clearTwoFactorToken: () => Promise; clearToken: (userId?: string) => Promise; decodeToken: (token?: string) => any; getTokenExpirationDate: () => Promise; diff --git a/common/src/abstractions/twoFactor.service.ts b/common/src/abstractions/twoFactor.service.ts new file mode 100644 index 0000000000..071f6e7e6e --- /dev/null +++ b/common/src/abstractions/twoFactor.service.ts @@ -0,0 +1,24 @@ +import { TwoFactorProviderType } from "../enums/twoFactorProviderType"; + +import { IdentityTwoFactorResponse } from "../models/response/identityTwoFactorResponse"; + +export interface TwoFactorProviderDetails { + type: TwoFactorProviderType; + name: string; + description: string; + priority: number; + sort: number; + premium: boolean; +} + +export abstract class TwoFactorService { + init: () => void; + getSupportedProviders: (win: Window) => TwoFactorProviderDetails[]; + getDefaultProvider: (webAuthnSupported: boolean) => TwoFactorProviderType; + setSelectedProvider: (type: TwoFactorProviderType) => void; + clearSelectedProvider: () => void; + + setProviders: (response: IdentityTwoFactorResponse) => void; + clearProviders: () => void; + getProviders: () => Map; +} diff --git a/common/src/misc/logInStrategies/apiLogin.strategy.ts b/common/src/misc/logInStrategies/apiLogin.strategy.ts new file mode 100644 index 0000000000..65463cc0ea --- /dev/null +++ b/common/src/misc/logInStrategies/apiLogin.strategy.ts @@ -0,0 +1,73 @@ +import { LogInStrategy } from "./logIn.strategy"; + +import { ApiService } from "../../abstractions/api.service"; +import { AppIdService } from "../../abstractions/appId.service"; +import { CryptoService } from "../../abstractions/crypto.service"; +import { EnvironmentService } from "../../abstractions/environment.service"; +import { KeyConnectorService } from "../../abstractions/keyConnector.service"; +import { LogService } from "../../abstractions/log.service"; +import { MessagingService } from "../../abstractions/messaging.service"; +import { PlatformUtilsService } from "../../abstractions/platformUtils.service"; +import { StateService } from "../../abstractions/state.service"; +import { TokenService } from "../../abstractions/token.service"; +import { TwoFactorService } from "../../abstractions/twoFactor.service"; + +import { ApiTokenRequest } from "../../models/request/identityToken/apiTokenRequest"; + +import { IdentityTokenResponse } from "../../models/response/identityTokenResponse"; + +import { ApiLogInCredentials } from "../../models/domain/logInCredentials"; + +export class ApiLogInStrategy extends LogInStrategy { + tokenRequest: ApiTokenRequest; + + constructor( + cryptoService: CryptoService, + apiService: ApiService, + tokenService: TokenService, + appIdService: AppIdService, + platformUtilsService: PlatformUtilsService, + messagingService: MessagingService, + logService: LogService, + stateService: StateService, + twoFactorService: TwoFactorService, + private environmentService: EnvironmentService, + private keyConnectorService: KeyConnectorService + ) { + super( + cryptoService, + apiService, + tokenService, + appIdService, + platformUtilsService, + messagingService, + logService, + stateService, + twoFactorService + ); + } + + async onSuccessfulLogin(tokenResponse: IdentityTokenResponse) { + if (tokenResponse.apiUseKeyConnector) { + const keyConnectorUrl = this.environmentService.getKeyConnectorUrl(); + await this.keyConnectorService.getAndSetKey(keyConnectorUrl); + } + } + + async logIn(credentials: ApiLogInCredentials) { + this.tokenRequest = new ApiTokenRequest( + credentials.clientId, + credentials.clientSecret, + await this.buildTwoFactor(), + await this.buildDeviceRequest() + ); + + return this.startLogIn(); + } + + protected async saveAccountInformation(tokenResponse: IdentityTokenResponse) { + await super.saveAccountInformation(tokenResponse); + await this.stateService.setApiKeyClientId(this.tokenRequest.clientId); + await this.stateService.setApiKeyClientSecret(this.tokenRequest.clientSecret); + } +} diff --git a/common/src/misc/logInStrategies/logIn.strategy.ts b/common/src/misc/logInStrategies/logIn.strategy.ts new file mode 100644 index 0000000000..9cb18aac8a --- /dev/null +++ b/common/src/misc/logInStrategies/logIn.strategy.ts @@ -0,0 +1,177 @@ +import { TwoFactorProviderType } from "../../enums/twoFactorProviderType"; + +import { Account, AccountProfile, AccountTokens } from "../../models/domain/account"; +import { AuthResult } from "../../models/domain/authResult"; +import { + ApiLogInCredentials, + PasswordLogInCredentials, + SsoLogInCredentials, +} from "../../models/domain/logInCredentials"; + +import { DeviceRequest } from "../../models/request/deviceRequest"; +import { ApiTokenRequest } from "../../models/request/identityToken/apiTokenRequest"; +import { PasswordTokenRequest } from "../../models/request/identityToken/passwordTokenRequest"; +import { SsoTokenRequest } from "../../models/request/identityToken/ssoTokenRequest"; +import { TokenRequestTwoFactor } from "../../models/request/identityToken/tokenRequest"; +import { KeysRequest } from "../../models/request/keysRequest"; + +import { IdentityCaptchaResponse } from "../../models/response/identityCaptchaResponse"; +import { IdentityTokenResponse } from "../../models/response/identityTokenResponse"; +import { IdentityTwoFactorResponse } from "../../models/response/identityTwoFactorResponse"; + +import { ApiService } from "../../abstractions/api.service"; +import { AppIdService } from "../../abstractions/appId.service"; +import { CryptoService } from "../../abstractions/crypto.service"; +import { LogService } from "../../abstractions/log.service"; +import { MessagingService } from "../../abstractions/messaging.service"; +import { PlatformUtilsService } from "../../abstractions/platformUtils.service"; +import { StateService } from "../../abstractions/state.service"; +import { TokenService } from "../../abstractions/token.service"; +import { TwoFactorService } from "../../abstractions/twoFactor.service"; + +export abstract class LogInStrategy { + protected abstract tokenRequest: ApiTokenRequest | PasswordTokenRequest | SsoTokenRequest; + + constructor( + protected cryptoService: CryptoService, + protected apiService: ApiService, + protected tokenService: TokenService, + protected appIdService: AppIdService, + protected platformUtilsService: PlatformUtilsService, + protected messagingService: MessagingService, + protected logService: LogService, + protected stateService: StateService, + protected twoFactorService: TwoFactorService + ) {} + + abstract logIn( + credentials: ApiLogInCredentials | PasswordLogInCredentials | SsoLogInCredentials + ): Promise; + + async logInTwoFactor(twoFactor: TokenRequestTwoFactor): Promise { + this.tokenRequest.setTwoFactor(twoFactor); + return this.startLogIn(); + } + + protected async startLogIn(): Promise { + this.twoFactorService.clearSelectedProvider(); + + const response = await this.apiService.postIdentityToken(this.tokenRequest); + + if (response instanceof IdentityTwoFactorResponse) { + return this.processTwoFactorResponse(response); + } else if (response instanceof IdentityCaptchaResponse) { + return this.processCaptchaResponse(response); + } else if (response instanceof IdentityTokenResponse) { + return this.processTokenResponse(response); + } + + throw new Error("Invalid response object."); + } + + protected onSuccessfulLogin(response: IdentityTokenResponse): Promise { + // Implemented in subclass if required + return null; + } + + protected async buildDeviceRequest() { + const appId = await this.appIdService.getAppId(); + return new DeviceRequest(appId, this.platformUtilsService); + } + + protected async buildTwoFactor(userProvidedTwoFactor?: TokenRequestTwoFactor) { + if (userProvidedTwoFactor != null) { + return userProvidedTwoFactor; + } + + const storedTwoFactorToken = await this.tokenService.getTwoFactorToken(); + if (storedTwoFactorToken != null) { + return { + token: storedTwoFactorToken, + provider: TwoFactorProviderType.Remember, + remember: false, + }; + } + + return { + token: null, + provider: null, + remember: false, + }; + } + + protected async saveAccountInformation(tokenResponse: IdentityTokenResponse) { + const accountInformation = await this.tokenService.decodeToken(tokenResponse.accessToken); + await this.stateService.addAccount( + new Account({ + profile: { + ...new AccountProfile(), + ...{ + userId: accountInformation.sub, + email: accountInformation.email, + hasPremiumPersonally: accountInformation.premium, + kdfIterations: tokenResponse.kdfIterations, + kdfType: tokenResponse.kdf, + }, + }, + tokens: { + ...new AccountTokens(), + ...{ + accessToken: tokenResponse.accessToken, + refreshToken: tokenResponse.refreshToken, + }, + }, + }) + ); + } + + protected async processTokenResponse(response: IdentityTokenResponse): Promise { + const result = new AuthResult(); + result.resetMasterPassword = response.resetMasterPassword; + result.forcePasswordReset = response.forcePasswordReset; + + await this.saveAccountInformation(response); + + if (response.twoFactorToken != null) { + await this.tokenService.setTwoFactorToken(response); + } + + const newSsoUser = response.key == null; + if (!newSsoUser) { + await this.cryptoService.setEncKey(response.key); + await this.cryptoService.setEncPrivateKey( + response.privateKey ?? (await this.createKeyPairForOldAccount()) + ); + } + + await this.onSuccessfulLogin(response); + + await this.stateService.setBiometricLocked(false); + this.messagingService.send("loggedIn"); + + return result; + } + + private async processTwoFactorResponse(response: IdentityTwoFactorResponse): Promise { + const result = new AuthResult(); + result.twoFactorProviders = response.twoFactorProviders2; + this.twoFactorService.setProviders(response); + return result; + } + + private async processCaptchaResponse(response: IdentityCaptchaResponse): Promise { + const result = new AuthResult(); + result.captchaSiteKey = response.siteKey; + return result; + } + + private async createKeyPairForOldAccount() { + try { + const [publicKey, privateKey] = await this.cryptoService.makeKeyPair(); + await this.apiService.postAccountKeys(new KeysRequest(publicKey, privateKey.encryptedString)); + return privateKey.encryptedString; + } catch (e) { + this.logService.error(e); + } + } +} diff --git a/common/src/misc/logInStrategies/passwordLogin.strategy.ts b/common/src/misc/logInStrategies/passwordLogin.strategy.ts new file mode 100644 index 0000000000..cdbe3bdcf5 --- /dev/null +++ b/common/src/misc/logInStrategies/passwordLogin.strategy.ts @@ -0,0 +1,88 @@ +import { LogInStrategy } from "./logIn.strategy"; + +import { PasswordTokenRequest } from "../../models/request/identityToken/passwordTokenRequest"; + +import { ApiService } from "../../abstractions/api.service"; +import { AppIdService } from "../../abstractions/appId.service"; +import { AuthService } from "../../abstractions/auth.service"; +import { CryptoService } from "../../abstractions/crypto.service"; +import { LogService } from "../../abstractions/log.service"; +import { MessagingService } from "../../abstractions/messaging.service"; +import { PlatformUtilsService } from "../../abstractions/platformUtils.service"; +import { StateService } from "../../abstractions/state.service"; +import { TokenService } from "../../abstractions/token.service"; +import { TwoFactorService } from "../../abstractions/twoFactor.service"; + +import { PasswordLogInCredentials } from "../../models/domain/logInCredentials"; +import { SymmetricCryptoKey } from "../../models/domain/symmetricCryptoKey"; + +import { HashPurpose } from "../../enums/hashPurpose"; + +export class PasswordLogInStrategy extends LogInStrategy { + get email() { + return this.tokenRequest.email; + } + + get masterPasswordHash() { + return this.tokenRequest.masterPasswordHash; + } + + tokenRequest: PasswordTokenRequest; + + private localHashedPassword: string; + private key: SymmetricCryptoKey; + + constructor( + cryptoService: CryptoService, + apiService: ApiService, + tokenService: TokenService, + appIdService: AppIdService, + platformUtilsService: PlatformUtilsService, + messagingService: MessagingService, + logService: LogService, + stateService: StateService, + twoFactorService: TwoFactorService, + private authService: AuthService + ) { + super( + cryptoService, + apiService, + tokenService, + appIdService, + platformUtilsService, + messagingService, + logService, + stateService, + twoFactorService + ); + } + + async onSuccessfulLogin() { + await this.cryptoService.setKey(this.key); + await this.cryptoService.setKeyHash(this.localHashedPassword); + } + + async logIn(credentials: PasswordLogInCredentials) { + const { email, masterPassword, captchaToken, twoFactor } = credentials; + + this.key = await this.authService.makePreloginKey(masterPassword, email); + + // Hash the password early (before authentication) so we don't persist it in memory in plaintext + this.localHashedPassword = await this.cryptoService.hashPassword( + masterPassword, + this.key, + HashPurpose.LocalAuthorization + ); + const hashedPassword = await this.cryptoService.hashPassword(masterPassword, this.key); + + this.tokenRequest = new PasswordTokenRequest( + email, + hashedPassword, + captchaToken, + await this.buildTwoFactor(twoFactor), + await this.buildDeviceRequest() + ); + + return this.startLogIn(); + } +} diff --git a/common/src/misc/logInStrategies/ssoLogin.strategy.ts b/common/src/misc/logInStrategies/ssoLogin.strategy.ts new file mode 100644 index 0000000000..d946764d36 --- /dev/null +++ b/common/src/misc/logInStrategies/ssoLogin.strategy.ts @@ -0,0 +1,73 @@ +import { LogInStrategy } from "./logIn.strategy"; + +import { ApiService } from "../../abstractions/api.service"; +import { AppIdService } from "../../abstractions/appId.service"; +import { CryptoService } from "../../abstractions/crypto.service"; +import { KeyConnectorService } from "../../abstractions/keyConnector.service"; +import { LogService } from "../../abstractions/log.service"; +import { MessagingService } from "../../abstractions/messaging.service"; +import { PlatformUtilsService } from "../../abstractions/platformUtils.service"; +import { StateService } from "../../abstractions/state.service"; +import { TokenService } from "../../abstractions/token.service"; +import { TwoFactorService } from "../../abstractions/twoFactor.service"; + +import { SsoLogInCredentials } from "../../models/domain/logInCredentials"; + +import { SsoTokenRequest } from "../../models/request/identityToken/ssoTokenRequest"; + +import { IdentityTokenResponse } from "../../models/response/identityTokenResponse"; + +export class SsoLogInStrategy extends LogInStrategy { + tokenRequest: SsoTokenRequest; + orgId: string; + + constructor( + cryptoService: CryptoService, + apiService: ApiService, + tokenService: TokenService, + appIdService: AppIdService, + platformUtilsService: PlatformUtilsService, + messagingService: MessagingService, + logService: LogService, + stateService: StateService, + twoFactorService: TwoFactorService, + private keyConnectorService: KeyConnectorService + ) { + super( + cryptoService, + apiService, + tokenService, + appIdService, + platformUtilsService, + messagingService, + logService, + stateService, + twoFactorService + ); + } + + async onSuccessfulLogin(tokenResponse: IdentityTokenResponse) { + const newSsoUser = tokenResponse.key == null; + + if (tokenResponse.keyConnectorUrl != null) { + if (!newSsoUser) { + await this.keyConnectorService.getAndSetKey(tokenResponse.keyConnectorUrl); + } else { + await this.keyConnectorService.convertNewSsoUserToKeyConnector(tokenResponse, this.orgId); + } + } + } + + async logIn(credentials: SsoLogInCredentials) { + this.orgId = credentials.orgId; + this.tokenRequest = new SsoTokenRequest( + credentials.code, + credentials.codeVerifier, + credentials.redirectUrl, + await this.buildTwoFactor(credentials.twoFactor), + await this.buildDeviceRequest() + ); + + return this.startLogIn(); + } +} diff --git a/common/src/models/domain/authResult.ts b/common/src/models/domain/authResult.ts index eadad50f77..bba3f7f300 100644 --- a/common/src/models/domain/authResult.ts +++ b/common/src/models/domain/authResult.ts @@ -1,9 +1,18 @@ import { TwoFactorProviderType } from "../../enums/twoFactorProviderType"; +import { Utils } from "../../misc/utils"; + export class AuthResult { - twoFactor: boolean = false; captchaSiteKey: string = ""; resetMasterPassword: boolean = false; forcePasswordReset: boolean = false; twoFactorProviders: Map = null; + + get requiresCaptcha() { + return !Utils.isNullOrWhitespace(this.captchaSiteKey); + } + + get requiresTwoFactor() { + return this.twoFactorProviders != null; + } } diff --git a/common/src/models/domain/logInCredentials.ts b/common/src/models/domain/logInCredentials.ts new file mode 100644 index 0000000000..d53da02ee0 --- /dev/null +++ b/common/src/models/domain/logInCredentials.ts @@ -0,0 +1,24 @@ +import { TokenRequestTwoFactor } from "../request/identityToken/tokenRequest"; + +export class PasswordLogInCredentials { + constructor( + public email: string, + public masterPassword: string, + public captchaToken?: string, + public twoFactor?: TokenRequestTwoFactor + ) {} +} + +export class SsoLogInCredentials { + constructor( + public code: string, + public codeVerifier: string, + public redirectUrl: string, + public orgId: string, + public twoFactor?: TokenRequestTwoFactor + ) {} +} + +export class ApiLogInCredentials { + constructor(public clientId: string, public clientSecret: string) {} +} diff --git a/common/src/models/request/identityToken/apiTokenRequest.ts b/common/src/models/request/identityToken/apiTokenRequest.ts new file mode 100644 index 0000000000..b8f2c21fbd --- /dev/null +++ b/common/src/models/request/identityToken/apiTokenRequest.ts @@ -0,0 +1,24 @@ +import { TokenRequest, TokenRequestTwoFactor } from "./tokenRequest"; + +import { DeviceRequest } from "../deviceRequest"; + +export class ApiTokenRequest extends TokenRequest { + constructor( + public clientId: string, + public clientSecret: string, + protected twoFactor: TokenRequestTwoFactor, + device?: DeviceRequest + ) { + super(twoFactor, device); + } + + toIdentityToken() { + const obj = super.toIdentityToken(this.clientId); + + obj.scope = this.clientId.startsWith("organization") ? "api.organization" : "api"; + obj.grant_type = "client_credentials"; + obj.client_secret = this.clientSecret; + + return obj; + } +} diff --git a/common/src/models/request/identityToken/passwordTokenRequest.ts b/common/src/models/request/identityToken/passwordTokenRequest.ts new file mode 100644 index 0000000000..a1d8466ba6 --- /dev/null +++ b/common/src/models/request/identityToken/passwordTokenRequest.ts @@ -0,0 +1,36 @@ +import { TokenRequest, TokenRequestTwoFactor } from "./tokenRequest"; + +import { CaptchaProtectedRequest } from "../captchaProtectedRequest"; +import { DeviceRequest } from "../deviceRequest"; + +import { Utils } from "../../../misc/utils"; + +export class PasswordTokenRequest extends TokenRequest implements CaptchaProtectedRequest { + constructor( + public email: string, + public masterPasswordHash: string, + public captchaResponse: string, + protected twoFactor: TokenRequestTwoFactor, + device?: DeviceRequest + ) { + super(twoFactor, device); + } + + toIdentityToken(clientId: string) { + const obj = super.toIdentityToken(clientId); + + obj.grant_type = "password"; + obj.username = this.email; + obj.password = this.masterPasswordHash; + + if (this.captchaResponse != null) { + obj.captchaResponse = this.captchaResponse; + } + + return obj; + } + + alterIdentityTokenHeaders(headers: Headers) { + headers.set("Auth-Email", Utils.fromUtf8ToUrlB64(this.email)); + } +} diff --git a/common/src/models/request/identityToken/ssoTokenRequest.ts b/common/src/models/request/identityToken/ssoTokenRequest.ts new file mode 100644 index 0000000000..009b8cf843 --- /dev/null +++ b/common/src/models/request/identityToken/ssoTokenRequest.ts @@ -0,0 +1,26 @@ +import { TokenRequest, TokenRequestTwoFactor } from "./tokenRequest"; + +import { DeviceRequest } from "../deviceRequest"; + +export class SsoTokenRequest extends TokenRequest { + constructor( + public code: string, + public codeVerifier: string, + public redirectUri: string, + protected twoFactor: TokenRequestTwoFactor, + device?: DeviceRequest + ) { + super(twoFactor, device); + } + + toIdentityToken(clientId: string) { + const obj = super.toIdentityToken(clientId); + + obj.grant_type = "authorization_code"; + obj.code = this.code; + obj.code_verifier = this.codeVerifier; + obj.redirect_uri = this.redirectUri; + + return obj; + } +} diff --git a/common/src/models/request/identityToken/tokenRequest.ts b/common/src/models/request/identityToken/tokenRequest.ts new file mode 100644 index 0000000000..4e193cc039 --- /dev/null +++ b/common/src/models/request/identityToken/tokenRequest.ts @@ -0,0 +1,48 @@ +import { TwoFactorProviderType } from "../../../enums/twoFactorProviderType"; + +import { DeviceRequest } from "../deviceRequest"; + +export interface TokenRequestTwoFactor { + provider: TwoFactorProviderType; + token: string; + remember: boolean; +} + +export abstract class TokenRequest { + protected device?: DeviceRequest; + + constructor(protected twoFactor: TokenRequestTwoFactor, device?: DeviceRequest) { + this.device = device != null ? device : null; + } + + alterIdentityTokenHeaders(headers: Headers) { + // Implemented in subclass if required + } + + setTwoFactor(twoFactor: TokenRequestTwoFactor) { + this.twoFactor = twoFactor; + } + + protected toIdentityToken(clientId: string) { + const obj: any = { + scope: "api offline_access", + client_id: clientId, + }; + + if (this.device) { + obj.deviceType = this.device.type; + obj.deviceIdentifier = this.device.identifier; + obj.deviceName = this.device.name; + // no push tokens for browser apps yet + // obj.devicePushToken = this.device.pushToken; + } + + if (this.twoFactor.token && this.twoFactor.provider != null) { + obj.twoFactorToken = this.twoFactor.token; + obj.twoFactorProvider = this.twoFactor.provider; + obj.twoFactorRemember = this.twoFactor.remember ? "1" : "0"; + } + + return obj; + } +} diff --git a/common/src/models/request/tokenRequest.ts b/common/src/models/request/tokenRequest.ts deleted file mode 100644 index 43e344ae91..0000000000 --- a/common/src/models/request/tokenRequest.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { TwoFactorProviderType } from "../../enums/twoFactorProviderType"; - -import { CaptchaProtectedRequest } from "./captchaProtectedRequest"; -import { DeviceRequest } from "./deviceRequest"; - -import { Utils } from "../../misc/utils"; - -export class TokenRequest implements CaptchaProtectedRequest { - email: string; - masterPasswordHash: string; - code: string; - codeVerifier: string; - redirectUri: string; - clientId: string; - clientSecret: string; - device?: DeviceRequest; - - constructor( - credentials: string[], - codes: string[], - clientIdClientSecret: string[], - public provider: TwoFactorProviderType, - public token: string, - public remember: boolean, - public captchaResponse: string, - device?: DeviceRequest - ) { - if (credentials != null && credentials.length > 1) { - this.email = credentials[0]; - this.masterPasswordHash = credentials[1]; - } else if (codes != null && codes.length > 2) { - this.code = codes[0]; - this.codeVerifier = codes[1]; - this.redirectUri = codes[2]; - } else if (clientIdClientSecret != null && clientIdClientSecret.length > 1) { - this.clientId = clientIdClientSecret[0]; - this.clientSecret = clientIdClientSecret[1]; - } - this.device = device != null ? device : null; - } - - toIdentityToken(clientId: string) { - const obj: any = { - scope: "api offline_access", - client_id: clientId, - }; - - if (this.clientSecret != null) { - obj.scope = clientId.startsWith("organization") ? "api.organization" : "api"; - obj.grant_type = "client_credentials"; - obj.client_secret = this.clientSecret; - } else if (this.masterPasswordHash != null && this.email != null) { - obj.grant_type = "password"; - obj.username = this.email; - obj.password = this.masterPasswordHash; - } else if (this.code != null && this.codeVerifier != null && this.redirectUri != null) { - obj.grant_type = "authorization_code"; - obj.code = this.code; - obj.code_verifier = this.codeVerifier; - obj.redirect_uri = this.redirectUri; - } else { - throw new Error("must provide credentials or codes"); - } - - if (this.device) { - obj.deviceType = this.device.type; - obj.deviceIdentifier = this.device.identifier; - obj.deviceName = this.device.name; - // no push tokens for browser apps yet - // obj.devicePushToken = this.device.pushToken; - } - - if (this.token && this.provider != null) { - obj.twoFactorToken = this.token; - obj.twoFactorProvider = this.provider; - obj.twoFactorRemember = this.remember ? "1" : "0"; - } - - if (this.captchaResponse != null) { - obj.captchaResponse = this.captchaResponse; - } - - return obj; - } - - alterIdentityTokenHeaders(headers: Headers) { - if (this.clientSecret == null && this.masterPasswordHash != null && this.email != null) { - headers.set("Auth-Email", Utils.fromUtf8ToUrlB64(this.email)); - } - } -} diff --git a/common/src/services/api.service.ts b/common/src/services/api.service.ts index 16b94b091c..ba2dd046ab 100644 --- a/common/src/services/api.service.ts +++ b/common/src/services/api.service.ts @@ -28,6 +28,9 @@ import { EventRequest } from "../models/request/eventRequest"; import { FolderRequest } from "../models/request/folderRequest"; import { GroupRequest } from "../models/request/groupRequest"; import { IapCheckRequest } from "../models/request/iapCheckRequest"; +import { ApiTokenRequest } from "../models/request/identityToken/apiTokenRequest"; +import { PasswordTokenRequest } from "../models/request/identityToken/passwordTokenRequest"; +import { SsoTokenRequest } from "../models/request/identityToken/ssoTokenRequest"; import { ImportCiphersRequest } from "../models/request/importCiphersRequest"; import { ImportDirectoryRequest } from "../models/request/importDirectoryRequest"; import { ImportOrganizationCiphersRequest } from "../models/request/importOrganizationCiphersRequest"; @@ -76,7 +79,6 @@ import { SendRequest } from "../models/request/sendRequest"; import { SetPasswordRequest } from "../models/request/setPasswordRequest"; import { StorageRequest } from "../models/request/storageRequest"; import { TaxInfoUpdateRequest } from "../models/request/taxInfoUpdateRequest"; -import { TokenRequest } from "../models/request/tokenRequest"; import { TwoFactorEmailRequest } from "../models/request/twoFactorEmailRequest"; import { TwoFactorProviderRequest } from "../models/request/twoFactorProviderRequest"; import { TwoFactorRecoveryRequest } from "../models/request/twoFactorRecoveryRequest"; @@ -208,7 +210,7 @@ export class ApiService implements ApiServiceAbstraction { // Auth APIs async postIdentityToken( - request: TokenRequest + request: ApiTokenRequest | PasswordTokenRequest | SsoTokenRequest ): Promise { const headers = new Headers({ "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", @@ -219,11 +221,15 @@ export class ApiService implements ApiServiceAbstraction { headers.set("User-Agent", this.customUserAgent); } request.alterIdentityTokenHeaders(headers); + + const identityToken = + request instanceof ApiTokenRequest + ? request.toIdentityToken() + : request.toIdentityToken(this.platformUtilsService.identityClientId); + const response = await this.fetch( new Request(this.environmentService.getIdentityUrl() + "/connect/token", { - body: this.qsStringify( - request.toIdentityToken(request.clientId ?? this.platformUtilsService.identityClientId) - ), + body: this.qsStringify(identityToken), credentials: this.getCredentials(), cache: "no-store", headers: headers, @@ -244,7 +250,7 @@ export class ApiService implements ApiServiceAbstraction { responseJson.TwoFactorProviders2 && Object.keys(responseJson.TwoFactorProviders2).length ) { - await this.tokenService.clearTwoFactorToken(request.email); + await this.tokenService.clearTwoFactorToken(); return new IdentityTwoFactorResponse(responseJson); } else if ( response.status === 400 && diff --git a/common/src/services/auth.service.ts b/common/src/services/auth.service.ts index 3d7506d38a..93f85c4d66 100644 --- a/common/src/services/auth.service.ts +++ b/common/src/services/auth.service.ts @@ -1,332 +1,125 @@ -import { HashPurpose } from "../enums/hashPurpose"; import { KdfType } from "../enums/kdfType"; -import { TwoFactorProviderType } from "../enums/twoFactorProviderType"; -import { - Account, - AccountData, - AccountKeys, - AccountProfile, - AccountTokens, -} from "../models/domain/account"; +import { ApiLogInStrategy } from "../misc/logInStrategies/apiLogin.strategy"; +import { PasswordLogInStrategy } from "../misc/logInStrategies/passwordLogin.strategy"; +import { SsoLogInStrategy } from "../misc/logInStrategies/ssoLogin.strategy"; import { AuthResult } from "../models/domain/authResult"; +import { + ApiLogInCredentials, + PasswordLogInCredentials, + SsoLogInCredentials, +} from "../models/domain/logInCredentials"; import { SymmetricCryptoKey } from "../models/domain/symmetricCryptoKey"; -import { SetKeyConnectorKeyRequest } from "../models/request/account/setKeyConnectorKeyRequest"; -import { DeviceRequest } from "../models/request/deviceRequest"; -import { KeyConnectorUserKeyRequest } from "../models/request/keyConnectorUserKeyRequest"; -import { KeysRequest } from "../models/request/keysRequest"; import { PreloginRequest } from "../models/request/preloginRequest"; -import { TokenRequest } from "../models/request/tokenRequest"; -import { IdentityTokenResponse } from "../models/response/identityTokenResponse"; -import { IdentityTwoFactorResponse } from "../models/response/identityTwoFactorResponse"; +import { TokenRequestTwoFactor } from "../models/request/identityToken/tokenRequest"; import { ApiService } from "../abstractions/api.service"; import { AppIdService } from "../abstractions/appId.service"; import { AuthService as AuthServiceAbstraction } from "../abstractions/auth.service"; import { CryptoService } from "../abstractions/crypto.service"; -import { CryptoFunctionService } from "../abstractions/cryptoFunction.service"; import { EnvironmentService } from "../abstractions/environment.service"; -import { I18nService } from "../abstractions/i18n.service"; import { KeyConnectorService } from "../abstractions/keyConnector.service"; import { LogService } from "../abstractions/log.service"; import { MessagingService } from "../abstractions/messaging.service"; import { PlatformUtilsService } from "../abstractions/platformUtils.service"; import { StateService } from "../abstractions/state.service"; import { TokenService } from "../abstractions/token.service"; -import { VaultTimeoutService } from "../abstractions/vaultTimeout.service"; - -import { Utils } from "../misc/utils"; - -export const TwoFactorProviders = { - [TwoFactorProviderType.Authenticator]: { - type: TwoFactorProviderType.Authenticator, - name: null as string, - description: null as string, - priority: 1, - sort: 1, - premium: false, - }, - [TwoFactorProviderType.Yubikey]: { - type: TwoFactorProviderType.Yubikey, - name: null as string, - description: null as string, - priority: 3, - sort: 2, - premium: true, - }, - [TwoFactorProviderType.Duo]: { - type: TwoFactorProviderType.Duo, - name: "Duo", - description: null as string, - priority: 2, - sort: 3, - premium: true, - }, - [TwoFactorProviderType.OrganizationDuo]: { - type: TwoFactorProviderType.OrganizationDuo, - name: "Duo (Organization)", - description: null as string, - priority: 10, - sort: 4, - premium: false, - }, - [TwoFactorProviderType.Email]: { - type: TwoFactorProviderType.Email, - name: null as string, - description: null as string, - priority: 0, - sort: 6, - premium: false, - }, - [TwoFactorProviderType.WebAuthn]: { - type: TwoFactorProviderType.WebAuthn, - name: null as string, - description: null as string, - priority: 4, - sort: 5, - premium: true, - }, -}; +import { TwoFactorService } from "../abstractions/twoFactor.service"; export class AuthService implements AuthServiceAbstraction { - email: string; - masterPasswordHash: string; - localMasterPasswordHash: string; - code: string; - codeVerifier: string; - ssoRedirectUrl: string; - clientId: string; - clientSecret: string; - twoFactorProvidersData: Map; - selectedTwoFactorProviderType: TwoFactorProviderType = null; - captchaToken: string; + get email(): string { + return this.logInStrategy instanceof PasswordLogInStrategy ? this.logInStrategy.email : null; + } - private key: SymmetricCryptoKey; + get masterPasswordHash(): string { + return this.logInStrategy instanceof PasswordLogInStrategy + ? this.logInStrategy.masterPasswordHash + : null; + } + + private logInStrategy: ApiLogInStrategy | PasswordLogInStrategy | SsoLogInStrategy; constructor( - private cryptoService: CryptoService, + protected cryptoService: CryptoService, protected apiService: ApiService, protected tokenService: TokenService, protected appIdService: AppIdService, - private i18nService: I18nService, protected platformUtilsService: PlatformUtilsService, - private messagingService: MessagingService, - private vaultTimeoutService: VaultTimeoutService, - private logService: LogService, - protected cryptoFunctionService: CryptoFunctionService, - private keyConnectorService: KeyConnectorService, + protected messagingService: MessagingService, + protected logService: LogService, + protected keyConnectorService: KeyConnectorService, protected environmentService: EnvironmentService, protected stateService: StateService, - private setCryptoKeys = true + protected twoFactorService: TwoFactorService ) {} - init() { - TwoFactorProviders[TwoFactorProviderType.Email].name = this.i18nService.t("emailTitle"); - TwoFactorProviders[TwoFactorProviderType.Email].description = this.i18nService.t("emailDesc"); - - TwoFactorProviders[TwoFactorProviderType.Authenticator].name = - this.i18nService.t("authenticatorAppTitle"); - TwoFactorProviders[TwoFactorProviderType.Authenticator].description = - this.i18nService.t("authenticatorAppDesc"); - - TwoFactorProviders[TwoFactorProviderType.Duo].description = this.i18nService.t("duoDesc"); - - TwoFactorProviders[TwoFactorProviderType.OrganizationDuo].name = - "Duo (" + this.i18nService.t("organization") + ")"; - TwoFactorProviders[TwoFactorProviderType.OrganizationDuo].description = - this.i18nService.t("duoOrganizationDesc"); - - TwoFactorProviders[TwoFactorProviderType.WebAuthn].name = this.i18nService.t("webAuthnTitle"); - TwoFactorProviders[TwoFactorProviderType.WebAuthn].description = - this.i18nService.t("webAuthnDesc"); - - TwoFactorProviders[TwoFactorProviderType.Yubikey].name = this.i18nService.t("yubiKeyTitle"); - TwoFactorProviders[TwoFactorProviderType.Yubikey].description = - this.i18nService.t("yubiKeyDesc"); - } - - async logIn(email: string, masterPassword: string, captchaToken?: string): Promise { - this.selectedTwoFactorProviderType = null; - const key = await this.makePreloginKey(masterPassword, email); - const hashedPassword = await this.cryptoService.hashPassword(masterPassword, key); - const localHashedPassword = await this.cryptoService.hashPassword( - masterPassword, - key, - HashPurpose.LocalAuthorization - ); - return await this.logInHelper( - email, - hashedPassword, - localHashedPassword, - null, - null, - null, - null, - null, - key, - null, - null, - null, - captchaToken, - null - ); - } - - async logInSso( - code: string, - codeVerifier: string, - redirectUrl: string, - orgId: string + async logIn( + credentials: ApiLogInCredentials | PasswordLogInCredentials | SsoLogInCredentials ): Promise { - this.selectedTwoFactorProviderType = null; - return await this.logInHelper( - null, - null, - null, - code, - codeVerifier, - redirectUrl, - null, - null, - null, - null, - null, - null, - null, - orgId - ); + this.clearState(); + + let result: AuthResult; + let strategy: ApiLogInStrategy | PasswordLogInStrategy | SsoLogInStrategy; + + if (credentials instanceof PasswordLogInCredentials) { + strategy = new PasswordLogInStrategy( + this.cryptoService, + this.apiService, + this.tokenService, + this.appIdService, + this.platformUtilsService, + this.messagingService, + this.logService, + this.stateService, + this.twoFactorService, + this + ); + result = await strategy.logIn(credentials); + } else if (credentials instanceof SsoLogInCredentials) { + strategy = new SsoLogInStrategy( + this.cryptoService, + this.apiService, + this.tokenService, + this.appIdService, + this.platformUtilsService, + this.messagingService, + this.logService, + this.stateService, + this.twoFactorService, + this.keyConnectorService + ); + result = await strategy.logIn(credentials); + } else if (credentials instanceof ApiLogInCredentials) { + strategy = new ApiLogInStrategy( + this.cryptoService, + this.apiService, + this.tokenService, + this.appIdService, + this.platformUtilsService, + this.messagingService, + this.logService, + this.stateService, + this.twoFactorService, + this.environmentService, + this.keyConnectorService + ); + result = await strategy.logIn(credentials); + } + + if (result?.requiresTwoFactor) { + this.saveState(strategy); + } + return result; } - async logInApiKey(clientId: string, clientSecret: string): Promise { - this.selectedTwoFactorProviderType = null; - return await this.logInHelper( - null, - null, - null, - null, - null, - null, - clientId, - clientSecret, - null, - null, - null, - null, - null, - null - ); - } - - async logInTwoFactor( - twoFactorProvider: TwoFactorProviderType, - twoFactorToken: string, - remember?: boolean - ): Promise { - return await this.logInHelper( - this.email, - this.masterPasswordHash, - this.localMasterPasswordHash, - this.code, - this.codeVerifier, - this.ssoRedirectUrl, - this.clientId, - this.clientSecret, - this.key, - twoFactorProvider, - twoFactorToken, - remember, - this.captchaToken, - null - ); - } - - async logInComplete( - email: string, - masterPassword: string, - twoFactorProvider: TwoFactorProviderType, - twoFactorToken: string, - remember?: boolean, - captchaToken?: string - ): Promise { - this.selectedTwoFactorProviderType = null; - const key = await this.makePreloginKey(masterPassword, email); - const hashedPassword = await this.cryptoService.hashPassword(masterPassword, key); - const localHashedPassword = await this.cryptoService.hashPassword( - masterPassword, - key, - HashPurpose.LocalAuthorization - ); - return await this.logInHelper( - email, - hashedPassword, - localHashedPassword, - null, - null, - null, - null, - null, - key, - twoFactorProvider, - twoFactorToken, - remember, - captchaToken, - null - ); - } - - async logInSsoComplete( - code: string, - codeVerifier: string, - redirectUrl: string, - twoFactorProvider: TwoFactorProviderType, - twoFactorToken: string, - remember?: boolean - ): Promise { - this.selectedTwoFactorProviderType = null; - return await this.logInHelper( - null, - null, - null, - code, - codeVerifier, - redirectUrl, - null, - null, - null, - twoFactorProvider, - twoFactorToken, - remember, - null, - null - ); - } - - async logInApiKeyComplete( - clientId: string, - clientSecret: string, - twoFactorProvider: TwoFactorProviderType, - twoFactorToken: string, - remember?: boolean - ): Promise { - this.selectedTwoFactorProviderType = null; - return await this.logInHelper( - null, - null, - null, - null, - null, - null, - clientId, - clientSecret, - null, - twoFactorProvider, - twoFactorToken, - remember, - null, - null - ); + async logInTwoFactor(twoFactor: TokenRequestTwoFactor): Promise { + try { + return await this.logInStrategy.logInTwoFactor(twoFactor); + } finally { + this.clearState(); + } } logOut(callback: Function) { @@ -334,75 +127,16 @@ export class AuthService implements AuthServiceAbstraction { this.messagingService.send("loggedOut"); } - getSupportedTwoFactorProviders(win: Window): any[] { - const providers: any[] = []; - if (this.twoFactorProvidersData == null) { - return providers; - } - - if ( - this.twoFactorProvidersData.has(TwoFactorProviderType.OrganizationDuo) && - this.platformUtilsService.supportsDuo() - ) { - providers.push(TwoFactorProviders[TwoFactorProviderType.OrganizationDuo]); - } - - if (this.twoFactorProvidersData.has(TwoFactorProviderType.Authenticator)) { - providers.push(TwoFactorProviders[TwoFactorProviderType.Authenticator]); - } - - if (this.twoFactorProvidersData.has(TwoFactorProviderType.Yubikey)) { - providers.push(TwoFactorProviders[TwoFactorProviderType.Yubikey]); - } - - if ( - this.twoFactorProvidersData.has(TwoFactorProviderType.Duo) && - this.platformUtilsService.supportsDuo() - ) { - providers.push(TwoFactorProviders[TwoFactorProviderType.Duo]); - } - - if ( - this.twoFactorProvidersData.has(TwoFactorProviderType.WebAuthn) && - this.platformUtilsService.supportsWebAuthn(win) - ) { - providers.push(TwoFactorProviders[TwoFactorProviderType.WebAuthn]); - } - - if (this.twoFactorProvidersData.has(TwoFactorProviderType.Email)) { - providers.push(TwoFactorProviders[TwoFactorProviderType.Email]); - } - - return providers; + authingWithApiKey(): boolean { + return this.logInStrategy instanceof ApiLogInStrategy; } - getDefaultTwoFactorProvider(webAuthnSupported: boolean): TwoFactorProviderType { - if (this.twoFactorProvidersData == null) { - return null; - } + authingWithSso(): boolean { + return this.logInStrategy instanceof SsoLogInStrategy; + } - if ( - this.selectedTwoFactorProviderType != null && - this.twoFactorProvidersData.has(this.selectedTwoFactorProviderType) - ) { - return this.selectedTwoFactorProviderType; - } - - let providerType: TwoFactorProviderType = null; - let providerPriority = -1; - this.twoFactorProvidersData.forEach((_value, type) => { - const provider = (TwoFactorProviders as any)[type]; - if (provider != null && provider.priority > providerPriority) { - if (type === TwoFactorProviderType.WebAuthn && !webAuthnSupported) { - return; - } - - providerType = type; - providerPriority = provider.priority; - } - }); - - return providerType; + authingWithPassword(): boolean { + return this.logInStrategy instanceof PasswordLogInStrategy; } async makePreloginKey(masterPassword: string, email: string): Promise { @@ -423,249 +157,11 @@ export class AuthService implements AuthServiceAbstraction { return this.cryptoService.makeKey(masterPassword, email, kdf, kdfIterations); } - authingWithApiKey(): boolean { - return this.clientId != null && this.clientSecret != null; + private saveState(strategy: ApiLogInStrategy | PasswordLogInStrategy | SsoLogInStrategy) { + this.logInStrategy = strategy; } - authingWithSso(): boolean { - return this.code != null && this.codeVerifier != null && this.ssoRedirectUrl != null; - } - - authingWithPassword(): boolean { - return this.email != null && this.masterPasswordHash != null; - } - - private async logInHelper( - email: string, - hashedPassword: string, - localHashedPassword: string, - code: string, - codeVerifier: string, - redirectUrl: string, - clientId: string, - clientSecret: string, - key: SymmetricCryptoKey, - twoFactorProvider?: TwoFactorProviderType, - twoFactorToken?: string, - remember?: boolean, - captchaToken?: string, - orgId?: string - ): Promise { - const storedTwoFactorToken = await this.tokenService.getTwoFactorToken(email); - const appId = await this.appIdService.getAppId(); - const deviceRequest = new DeviceRequest(appId, this.platformUtilsService); - - let emailPassword: string[] = []; - let codeCodeVerifier: string[] = []; - let clientIdClientSecret: [string, string] = [null, null]; - - if (email != null && hashedPassword != null) { - emailPassword = [email, hashedPassword]; - } else { - emailPassword = null; - } - if (code != null && codeVerifier != null && redirectUrl != null) { - codeCodeVerifier = [code, codeVerifier, redirectUrl]; - } else { - codeCodeVerifier = null; - } - if (clientId != null && clientSecret != null) { - clientIdClientSecret = [clientId, clientSecret]; - } else { - clientIdClientSecret = null; - } - - let request: TokenRequest; - if (twoFactorToken != null && twoFactorProvider != null) { - request = new TokenRequest( - emailPassword, - codeCodeVerifier, - clientIdClientSecret, - twoFactorProvider, - twoFactorToken, - remember, - captchaToken, - deviceRequest - ); - } else if (storedTwoFactorToken != null) { - request = new TokenRequest( - emailPassword, - codeCodeVerifier, - clientIdClientSecret, - TwoFactorProviderType.Remember, - storedTwoFactorToken, - false, - captchaToken, - deviceRequest - ); - } else { - request = new TokenRequest( - emailPassword, - codeCodeVerifier, - clientIdClientSecret, - null, - null, - false, - captchaToken, - deviceRequest - ); - } - - const response = await this.apiService.postIdentityToken(request); - - this.clearState(); - const result = new AuthResult(); - result.captchaSiteKey = (response as any).siteKey; - if (!!result.captchaSiteKey) { - return result; - } - result.twoFactor = !!(response as any).twoFactorProviders2; - - if (result.twoFactor) { - // two factor required - this.email = email; - this.masterPasswordHash = hashedPassword; - this.localMasterPasswordHash = localHashedPassword; - this.code = code; - this.codeVerifier = codeVerifier; - this.ssoRedirectUrl = redirectUrl; - this.clientId = clientId; - this.clientSecret = clientSecret; - this.key = this.setCryptoKeys ? key : null; - const twoFactorResponse = response as IdentityTwoFactorResponse; - this.twoFactorProvidersData = twoFactorResponse.twoFactorProviders2; - result.twoFactorProviders = twoFactorResponse.twoFactorProviders2; - this.captchaToken = twoFactorResponse.captchaToken; - return result; - } - - const tokenResponse = response as IdentityTokenResponse; - result.resetMasterPassword = tokenResponse.resetMasterPassword; - result.forcePasswordReset = tokenResponse.forcePasswordReset; - - const accountInformation = await this.tokenService.decodeToken(tokenResponse.accessToken); - await this.stateService.addAccount( - new Account({ - profile: { - ...new AccountProfile(), - ...{ - userId: accountInformation.sub, - email: accountInformation.email, - apiKeyClientId: clientId, - hasPremiumPersonally: accountInformation.premium, - kdfIterations: tokenResponse.kdfIterations, - kdfType: tokenResponse.kdf, - }, - }, - keys: { - ...new AccountKeys(), - ...{ - apiKeyClientSecret: clientSecret, - }, - }, - tokens: { - ...new AccountTokens(), - ...{ - accessToken: tokenResponse.accessToken, - refreshToken: tokenResponse.refreshToken, - }, - }, - }) - ); - - if (tokenResponse.twoFactorToken != null) { - await this.tokenService.setTwoFactorToken(tokenResponse.twoFactorToken, email); - } - - if (this.setCryptoKeys) { - if (key != null) { - await this.cryptoService.setKey(key); - } - if (localHashedPassword != null) { - await this.cryptoService.setKeyHash(localHashedPassword); - } - - // Skip this step during SSO new user flow. No key is returned from server. - if (code == null || tokenResponse.key != null) { - if (tokenResponse.keyConnectorUrl != null) { - await this.keyConnectorService.getAndSetKey(tokenResponse.keyConnectorUrl); - } else if (tokenResponse.apiUseKeyConnector) { - const keyConnectorUrl = this.environmentService.getKeyConnectorUrl(); - await this.keyConnectorService.getAndSetKey(keyConnectorUrl); - } - - await this.cryptoService.setEncKey(tokenResponse.key); - - // User doesn't have a key pair yet (old account), let's generate one for them - if (tokenResponse.privateKey == null) { - try { - const keyPair = await this.cryptoService.makeKeyPair(); - await this.apiService.postAccountKeys( - new KeysRequest(keyPair[0], keyPair[1].encryptedString) - ); - tokenResponse.privateKey = keyPair[1].encryptedString; - } catch (e) { - this.logService.error(e); - } - } - - await this.cryptoService.setEncPrivateKey(tokenResponse.privateKey); - } else if (tokenResponse.keyConnectorUrl != null) { - const password = await this.cryptoFunctionService.randomBytes(64); - - const k = await this.cryptoService.makeKey( - Utils.fromBufferToB64(password), - await this.tokenService.getEmail(), - tokenResponse.kdf, - tokenResponse.kdfIterations - ); - const keyConnectorRequest = new KeyConnectorUserKeyRequest(k.encKeyB64); - await this.cryptoService.setKey(k); - - const encKey = await this.cryptoService.makeEncKey(k); - await this.cryptoService.setEncKey(encKey[1].encryptedString); - - const [pubKey, privKey] = await this.cryptoService.makeKeyPair(); - - try { - await this.apiService.postUserKeyToKeyConnector( - tokenResponse.keyConnectorUrl, - keyConnectorRequest - ); - } catch (e) { - throw new Error("Unable to reach key connector"); - } - - const keys = new KeysRequest(pubKey, privKey.encryptedString); - const setPasswordRequest = new SetKeyConnectorKeyRequest( - encKey[1].encryptedString, - tokenResponse.kdf, - tokenResponse.kdfIterations, - orgId, - keys - ); - await this.apiService.postSetKeyConnectorKey(setPasswordRequest); - } - } - - if (this.vaultTimeoutService != null) { - await this.stateService.setBiometricLocked(false); - } - this.messagingService.send("loggedIn"); - return result; - } - - private clearState(): void { - this.key = null; - this.email = null; - this.masterPasswordHash = null; - this.localMasterPasswordHash = null; - this.code = null; - this.codeVerifier = null; - this.ssoRedirectUrl = null; - this.clientId = null; - this.clientSecret = null; - this.twoFactorProvidersData = null; - this.selectedTwoFactorProviderType = null; + private clearState() { + this.logInStrategy = null; } } diff --git a/common/src/services/keyConnector.service.ts b/common/src/services/keyConnector.service.ts index 7296d9b1f4..847f4862df 100644 --- a/common/src/services/keyConnector.service.ts +++ b/common/src/services/keyConnector.service.ts @@ -1,5 +1,6 @@ import { ApiService } from "../abstractions/api.service"; import { CryptoService } from "../abstractions/crypto.service"; +import { CryptoFunctionService } from "../abstractions/cryptoFunction.service"; import { KeyConnectorService as KeyConnectorServiceAbstraction } from "../abstractions/keyConnector.service"; import { LogService } from "../abstractions/log.service"; import { OrganizationService } from "../abstractions/organization.service"; @@ -12,7 +13,11 @@ import { Utils } from "../misc/utils"; import { SymmetricCryptoKey } from "../models/domain/symmetricCryptoKey"; +import { SetKeyConnectorKeyRequest } from "../models/request/account/setKeyConnectorKeyRequest"; import { KeyConnectorUserKeyRequest } from "../models/request/keyConnectorUserKeyRequest"; +import { KeysRequest } from "../models/request/keysRequest"; + +import { IdentityTokenResponse } from "../models/response/identityTokenResponse"; export class KeyConnectorService implements KeyConnectorServiceAbstraction { constructor( @@ -21,7 +26,8 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { private apiService: ApiService, private tokenService: TokenService, private logService: LogService, - private organizationService: OrganizationService + private organizationService: OrganizationService, + private cryptoFunctionService: CryptoFunctionService ) {} setUsesKeyConnector(usesKeyConnector: boolean) { @@ -80,6 +86,41 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { ); } + async convertNewSsoUserToKeyConnector(tokenResponse: IdentityTokenResponse, orgId: string) { + const { kdf, kdfIterations, keyConnectorUrl } = tokenResponse; + const password = await this.cryptoFunctionService.randomBytes(64); + + const k = await this.cryptoService.makeKey( + Utils.fromBufferToB64(password), + await this.tokenService.getEmail(), + kdf, + kdfIterations + ); + const keyConnectorRequest = new KeyConnectorUserKeyRequest(k.encKeyB64); + await this.cryptoService.setKey(k); + + const encKey = await this.cryptoService.makeEncKey(k); + await this.cryptoService.setEncKey(encKey[1].encryptedString); + + const [pubKey, privKey] = await this.cryptoService.makeKeyPair(); + + try { + await this.apiService.postUserKeyToKeyConnector(keyConnectorUrl, keyConnectorRequest); + } catch (e) { + throw new Error("Unable to reach key connector"); + } + + const keys = new KeysRequest(pubKey, privKey.encryptedString); + const setPasswordRequest = new SetKeyConnectorKeyRequest( + encKey[1].encryptedString, + kdf, + kdfIterations, + orgId, + keys + ); + await this.apiService.postSetKeyConnectorKey(setPasswordRequest); + } + async setConvertAccountRequired(status: boolean) { await this.stateService.setConvertAccountToKeyConnector(status); } diff --git a/common/src/services/token.service.ts b/common/src/services/token.service.ts index bad5ce620b..052d67ae15 100644 --- a/common/src/services/token.service.ts +++ b/common/src/services/token.service.ts @@ -3,6 +3,8 @@ import { TokenService as TokenServiceAbstraction } from "../abstractions/token.s import { Utils } from "../misc/utils"; +import { IdentityTokenResponse } from "../models/response/identityTokenResponse"; + export class TokenService implements TokenServiceAbstraction { constructor(private stateService: StateService) {} @@ -79,8 +81,8 @@ export class TokenService implements TokenServiceAbstraction { await this.setClientSecret(clientSecret); } - async setTwoFactorToken(token: string): Promise { - return await this.stateService.setTwoFactorToken(token); + async setTwoFactorToken(tokenResponse: IdentityTokenResponse): Promise { + return await this.stateService.setTwoFactorToken(tokenResponse.twoFactorToken); } async getTwoFactorToken(): Promise { diff --git a/common/src/services/twoFactor.service.ts b/common/src/services/twoFactor.service.ts new file mode 100644 index 0000000000..facd85105a --- /dev/null +++ b/common/src/services/twoFactor.service.ts @@ -0,0 +1,188 @@ +import { I18nService } from "../abstractions/i18n.service"; +import { PlatformUtilsService } from "../abstractions/platformUtils.service"; +import { + TwoFactorProviderDetails, + TwoFactorService as TwoFactorServiceAbstraction, +} from "../abstractions/twoFactor.service"; + +import { TwoFactorProviderType } from "../enums/twoFactorProviderType"; + +import { IdentityTwoFactorResponse } from "../models/response/identityTwoFactorResponse"; + +export const TwoFactorProviders: Partial> = + { + [TwoFactorProviderType.Authenticator]: { + type: TwoFactorProviderType.Authenticator, + name: null as string, + description: null as string, + priority: 1, + sort: 1, + premium: false, + }, + [TwoFactorProviderType.Yubikey]: { + type: TwoFactorProviderType.Yubikey, + name: null as string, + description: null as string, + priority: 3, + sort: 2, + premium: true, + }, + [TwoFactorProviderType.Duo]: { + type: TwoFactorProviderType.Duo, + name: "Duo", + description: null as string, + priority: 2, + sort: 3, + premium: true, + }, + [TwoFactorProviderType.OrganizationDuo]: { + type: TwoFactorProviderType.OrganizationDuo, + name: "Duo (Organization)", + description: null as string, + priority: 10, + sort: 4, + premium: false, + }, + [TwoFactorProviderType.Email]: { + type: TwoFactorProviderType.Email, + name: null as string, + description: null as string, + priority: 0, + sort: 6, + premium: false, + }, + [TwoFactorProviderType.WebAuthn]: { + type: TwoFactorProviderType.WebAuthn, + name: null as string, + description: null as string, + priority: 4, + sort: 5, + premium: true, + }, + }; + +export class TwoFactorService implements TwoFactorServiceAbstraction { + private twoFactorProvidersData: Map; + private selectedTwoFactorProviderType: TwoFactorProviderType = null; + + constructor( + private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService + ) {} + + init() { + TwoFactorProviders[TwoFactorProviderType.Email].name = this.i18nService.t("emailTitle"); + TwoFactorProviders[TwoFactorProviderType.Email].description = this.i18nService.t("emailDesc"); + + TwoFactorProviders[TwoFactorProviderType.Authenticator].name = + this.i18nService.t("authenticatorAppTitle"); + TwoFactorProviders[TwoFactorProviderType.Authenticator].description = + this.i18nService.t("authenticatorAppDesc"); + + TwoFactorProviders[TwoFactorProviderType.Duo].description = this.i18nService.t("duoDesc"); + + TwoFactorProviders[TwoFactorProviderType.OrganizationDuo].name = + "Duo (" + this.i18nService.t("organization") + ")"; + TwoFactorProviders[TwoFactorProviderType.OrganizationDuo].description = + this.i18nService.t("duoOrganizationDesc"); + + TwoFactorProviders[TwoFactorProviderType.WebAuthn].name = this.i18nService.t("webAuthnTitle"); + TwoFactorProviders[TwoFactorProviderType.WebAuthn].description = + this.i18nService.t("webAuthnDesc"); + + TwoFactorProviders[TwoFactorProviderType.Yubikey].name = this.i18nService.t("yubiKeyTitle"); + TwoFactorProviders[TwoFactorProviderType.Yubikey].description = + this.i18nService.t("yubiKeyDesc"); + } + + getSupportedProviders(win: Window): TwoFactorProviderDetails[] { + const providers: any[] = []; + if (this.twoFactorProvidersData == null) { + return providers; + } + + if ( + this.twoFactorProvidersData.has(TwoFactorProviderType.OrganizationDuo) && + this.platformUtilsService.supportsDuo() + ) { + providers.push(TwoFactorProviders[TwoFactorProviderType.OrganizationDuo]); + } + + if (this.twoFactorProvidersData.has(TwoFactorProviderType.Authenticator)) { + providers.push(TwoFactorProviders[TwoFactorProviderType.Authenticator]); + } + + if (this.twoFactorProvidersData.has(TwoFactorProviderType.Yubikey)) { + providers.push(TwoFactorProviders[TwoFactorProviderType.Yubikey]); + } + + if ( + this.twoFactorProvidersData.has(TwoFactorProviderType.Duo) && + this.platformUtilsService.supportsDuo() + ) { + providers.push(TwoFactorProviders[TwoFactorProviderType.Duo]); + } + + if ( + this.twoFactorProvidersData.has(TwoFactorProviderType.WebAuthn) && + this.platformUtilsService.supportsWebAuthn(win) + ) { + providers.push(TwoFactorProviders[TwoFactorProviderType.WebAuthn]); + } + + if (this.twoFactorProvidersData.has(TwoFactorProviderType.Email)) { + providers.push(TwoFactorProviders[TwoFactorProviderType.Email]); + } + + return providers; + } + + getDefaultProvider(webAuthnSupported: boolean): TwoFactorProviderType { + if (this.twoFactorProvidersData == null) { + return null; + } + + if ( + this.selectedTwoFactorProviderType != null && + this.twoFactorProvidersData.has(this.selectedTwoFactorProviderType) + ) { + return this.selectedTwoFactorProviderType; + } + + let providerType: TwoFactorProviderType = null; + let providerPriority = -1; + this.twoFactorProvidersData.forEach((_value, type) => { + const provider = (TwoFactorProviders as any)[type]; + if (provider != null && provider.priority > providerPriority) { + if (type === TwoFactorProviderType.WebAuthn && !webAuthnSupported) { + return; + } + + providerType = type; + providerPriority = provider.priority; + } + }); + + return providerType; + } + + setSelectedProvider(type: TwoFactorProviderType) { + this.selectedTwoFactorProviderType = type; + } + + clearSelectedProvider() { + this.selectedTwoFactorProviderType = null; + } + + setProviders(response: IdentityTwoFactorResponse) { + this.twoFactorProvidersData = response.twoFactorProviders2; + } + + clearProviders() { + this.twoFactorProvidersData = null; + } + + getProviders() { + return this.twoFactorProvidersData; + } +} diff --git a/node/src/cli/commands/login.command.ts b/node/src/cli/commands/login.command.ts index 8dfac8ec85..77eab11e48 100644 --- a/node/src/cli/commands/login.command.ts +++ b/node/src/cli/commands/login.command.ts @@ -5,6 +5,11 @@ import * as inquirer from "inquirer"; import { TwoFactorProviderType } from "jslib-common/enums/twoFactorProviderType"; import { AuthResult } from "jslib-common/models/domain/authResult"; +import { + ApiLogInCredentials, + PasswordLogInCredentials, + SsoLogInCredentials, +} from "jslib-common/models/domain/logInCredentials"; import { TwoFactorEmailRequest } from "jslib-common/models/request/twoFactorEmailRequest"; import { ErrorResponse } from "jslib-common/models/response/errorResponse"; @@ -18,6 +23,7 @@ import { PasswordGenerationService } from "jslib-common/abstractions/passwordGen import { PlatformUtilsService } from "jslib-common/abstractions/platformUtils.service"; import { PolicyService } from "jslib-common/abstractions/policy.service"; import { StateService } from "jslib-common/abstractions/state.service"; +import { TwoFactorService } from "jslib-common/abstractions/twoFactor.service"; import { Response } from "../models/response"; @@ -28,6 +34,8 @@ import { MessageResponse } from "../models/response/messageResponse"; import { NodeUtils } from "jslib-common/misc/nodeUtils"; import { Utils } from "jslib-common/misc/utils"; +import Separator from "inquirer/lib/objects/separator"; + // tslint:disable-next-line const open = require("open"); @@ -53,6 +61,7 @@ export class LoginCommand { protected stateService: StateService, protected cryptoService: CryptoService, protected policyService: PolicyService, + protected twoFactorService: TwoFactorService, clientId: string ) { this.clientId = clientId; @@ -143,163 +152,146 @@ export class LoginCommand { return Response.error("Invalid two-step login method."); } + const twoFactor = + twoFactorToken == null + ? null + : { + provider: twoFactorMethod, + token: twoFactorToken, + remember: false, + }; + try { if (this.validatedParams != null) { await this.validatedParams(); } let response: AuthResult = null; - if (twoFactorToken != null && twoFactorMethod != null) { - if (clientId != null && clientSecret != null) { - response = await this.authService.logInApiKeyComplete( - clientId, - clientSecret, - twoFactorMethod, - twoFactorToken, - false - ); - } else if (ssoCode != null && ssoCodeVerifier != null) { - response = await this.authService.logInSsoComplete( + if (clientId != null && clientSecret != null) { + response = await this.authService.logIn(new ApiLogInCredentials(clientId, clientSecret)); + } else if (ssoCode != null && ssoCodeVerifier != null) { + response = await this.authService.logIn( + new SsoLogInCredentials( ssoCode, ssoCodeVerifier, this.ssoRedirectUri, - twoFactorMethod, - twoFactorToken, - false - ); - } else { - response = await this.authService.logInComplete( - email, - password, - twoFactorMethod, - twoFactorToken, - false, - this.clientSecret - ); - } + orgIdentifier, + twoFactor + ) + ); } else { - if (clientId != null && clientSecret != null) { - response = await this.authService.logInApiKey(clientId, clientSecret); - } else if (ssoCode != null && ssoCodeVerifier != null) { - response = await this.authService.logInSso( - ssoCode, - ssoCodeVerifier, - this.ssoRedirectUri, - orgIdentifier + response = await this.authService.logIn( + new PasswordLogInCredentials(email, password, null, twoFactor) + ); + } + if (response.captchaSiteKey) { + const badCaptcha = Response.badRequest( + "Your authentication request appears to be coming from a bot\n" + + "Please use your API key to validate this request and ensure BW_CLIENTSECRET is correct, if set.\n" + + "(https://bitwarden.com/help/article/cli-auth-challenges)" + ); + + try { + const captchaClientSecret = await this.apiClientSecret(true); + if (Utils.isNullOrWhitespace(captchaClientSecret)) { + return badCaptcha; + } + + const secondResponse = await this.authService.logIn( + new PasswordLogInCredentials(email, password, captchaClientSecret, { + provider: twoFactorMethod, + token: twoFactorToken, + remember: false, + }) ); - } else { - response = await this.authService.logIn(email, password); - } - if (response.captchaSiteKey) { - const badCaptcha = Response.badRequest( - "Your authentication request appears to be coming from a bot\n" + - "Please use your API key to validate this request and ensure BW_CLIENTSECRET is correct, if set.\n" + - "(https://bitwarden.com/help/article/cli-auth-challenges)" - ); - - try { - const captchaClientSecret = await this.apiClientSecret(true); - if (Utils.isNullOrWhitespace(captchaClientSecret)) { - return badCaptcha; - } - - const secondResponse = await this.authService.logInComplete( - email, - password, - twoFactorMethod, - twoFactorToken, - false, - captchaClientSecret - ); - response = secondResponse; - } catch (e) { - if ( - (e instanceof ErrorResponse || e.constructor.name === "ErrorResponse") && - (e as ErrorResponse).message.includes("Captcha is invalid") - ) { - return badCaptcha; - } else { - throw e; - } - } - } - if (response.twoFactor) { - let selectedProvider: any = null; - const twoFactorProviders = this.authService.getSupportedTwoFactorProviders(null); - if (twoFactorProviders.length === 0) { - return Response.badRequest("No providers available for this client."); - } - - if (twoFactorMethod != null) { - try { - selectedProvider = twoFactorProviders.filter((p) => p.type === twoFactorMethod)[0]; - } catch (e) { - return Response.error("Invalid two-step login method."); - } - } - - if (selectedProvider == null) { - if (twoFactorProviders.length === 1) { - selectedProvider = twoFactorProviders[0]; - } else if (this.canInteract) { - const twoFactorOptions = twoFactorProviders.map((p) => p.name); - twoFactorOptions.push(new inquirer.Separator()); - twoFactorOptions.push("Cancel"); - const answer: inquirer.Answers = await inquirer.createPromptModule({ - output: process.stderr, - })({ - type: "list", - name: "method", - message: "Two-step login method:", - choices: twoFactorOptions, - }); - const i = twoFactorOptions.indexOf(answer.method); - if (i === twoFactorOptions.length - 1) { - return Response.error("Login failed."); - } - selectedProvider = twoFactorProviders[i]; - } - if (selectedProvider == null) { - return Response.error("Login failed. No provider selected."); - } - } - + response = secondResponse; + } catch (e) { if ( - twoFactorToken == null && - response.twoFactorProviders.size > 1 && - selectedProvider.type === TwoFactorProviderType.Email + (e instanceof ErrorResponse || e.constructor.name === "ErrorResponse") && + (e as ErrorResponse).message.includes("Captcha is invalid") ) { - const emailReq = new TwoFactorEmailRequest(); - emailReq.email = this.authService.email; - emailReq.masterPasswordHash = this.authService.masterPasswordHash; - await this.apiService.postTwoFactorEmail(emailReq); + return badCaptcha; + } else { + throw e; } - - if (twoFactorToken == null) { - if (this.canInteract) { - const answer: inquirer.Answers = await inquirer.createPromptModule({ - output: process.stderr, - })({ - type: "input", - name: "token", - message: "Two-step login code:", - }); - twoFactorToken = answer.token; - } - if (twoFactorToken == null || twoFactorToken === "") { - return Response.badRequest("Code is required."); - } - } - - response = await this.authService.logInTwoFactor( - selectedProvider.type, - twoFactorToken, - false - ); } } + if (response.requiresTwoFactor) { + let selectedProvider: any = null; + const twoFactorProviders = this.twoFactorService.getSupportedProviders(null); + if (twoFactorProviders.length === 0) { + return Response.badRequest("No providers available for this client."); + } - if (response.twoFactor) { + if (twoFactorMethod != null) { + try { + selectedProvider = twoFactorProviders.filter((p) => p.type === twoFactorMethod)[0]; + } catch (e) { + return Response.error("Invalid two-step login method."); + } + } + + if (selectedProvider == null) { + if (twoFactorProviders.length === 1) { + selectedProvider = twoFactorProviders[0]; + } else if (this.canInteract) { + const twoFactorOptions: (string | Separator)[] = twoFactorProviders.map((p) => p.name); + twoFactorOptions.push(new inquirer.Separator()); + twoFactorOptions.push("Cancel"); + const answer: inquirer.Answers = await inquirer.createPromptModule({ + output: process.stderr, + })({ + type: "list", + name: "method", + message: "Two-step login method:", + choices: twoFactorOptions, + }); + const i = twoFactorOptions.indexOf(answer.method); + if (i === twoFactorOptions.length - 1) { + return Response.error("Login failed."); + } + selectedProvider = twoFactorProviders[i]; + } + if (selectedProvider == null) { + return Response.error("Login failed. No provider selected."); + } + } + + if ( + twoFactorToken == null && + response.twoFactorProviders.size > 1 && + selectedProvider.type === TwoFactorProviderType.Email + ) { + const emailReq = new TwoFactorEmailRequest(); + emailReq.email = this.authService.email; + emailReq.masterPasswordHash = this.authService.masterPasswordHash; + await this.apiService.postTwoFactorEmail(emailReq); + } + + if (twoFactorToken == null) { + if (this.canInteract) { + const answer: inquirer.Answers = await inquirer.createPromptModule({ + output: process.stderr, + })({ + type: "input", + name: "token", + message: "Two-step login code:", + }); + twoFactorToken = answer.token; + } + if (twoFactorToken == null || twoFactorToken === "") { + return Response.badRequest("Code is required."); + } + } + + response = await this.authService.logInTwoFactor({ + provider: selectedProvider.type, + token: twoFactorToken, + remember: false, + }); + } + + if (response.requiresTwoFactor) { return Response.error("Login failed."); } diff --git a/spec/common/misc/logInStrategies/apiLogIn.strategy.spec.ts b/spec/common/misc/logInStrategies/apiLogIn.strategy.spec.ts new file mode 100644 index 0000000000..6ce8b4c9e2 --- /dev/null +++ b/spec/common/misc/logInStrategies/apiLogIn.strategy.spec.ts @@ -0,0 +1,116 @@ +import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; + +import { ApiService } from "jslib-common/abstractions/api.service"; +import { AppIdService } from "jslib-common/abstractions/appId.service"; +import { CryptoService } from "jslib-common/abstractions/crypto.service"; +import { EnvironmentService } from "jslib-common/abstractions/environment.service"; +import { KeyConnectorService } from "jslib-common/abstractions/keyConnector.service"; +import { LogService } from "jslib-common/abstractions/log.service"; +import { MessagingService } from "jslib-common/abstractions/messaging.service"; +import { PlatformUtilsService } from "jslib-common/abstractions/platformUtils.service"; +import { StateService } from "jslib-common/abstractions/state.service"; +import { TokenService } from "jslib-common/abstractions/token.service"; +import { TwoFactorService } from "jslib-common/abstractions/twoFactor.service"; + +import { ApiLogInStrategy } from "jslib-common/misc/logInStrategies/apiLogin.strategy"; +import { Utils } from "jslib-common/misc/utils"; + +import { ApiLogInCredentials } from "jslib-common/models/domain/logInCredentials"; + +import { identityTokenResponseFactory } from "./logIn.strategy.spec"; + +describe("ApiLogInStrategy", () => { + let cryptoService: SubstituteOf; + let apiService: SubstituteOf; + let tokenService: SubstituteOf; + let appIdService: SubstituteOf; + let platformUtilsService: SubstituteOf; + let messagingService: SubstituteOf; + let logService: SubstituteOf; + let environmentService: SubstituteOf; + let keyConnectorService: SubstituteOf; + let stateService: SubstituteOf; + let twoFactorService: SubstituteOf; + + let apiLogInStrategy: ApiLogInStrategy; + let credentials: ApiLogInCredentials; + + const deviceId = Utils.newGuid(); + const keyConnectorUrl = "KEY_CONNECTOR_URL"; + const apiClientId = "API_CLIENT_ID"; + const apiClientSecret = "API_CLIENT_SECRET"; + + beforeEach(async () => { + cryptoService = Substitute.for(); + apiService = Substitute.for(); + tokenService = Substitute.for(); + appIdService = Substitute.for(); + platformUtilsService = Substitute.for(); + messagingService = Substitute.for(); + logService = Substitute.for(); + environmentService = Substitute.for(); + stateService = Substitute.for(); + keyConnectorService = Substitute.for(); + twoFactorService = Substitute.for(); + + appIdService.getAppId().resolves(deviceId); + tokenService.getTwoFactorToken().resolves(null); + + apiLogInStrategy = new ApiLogInStrategy( + cryptoService, + apiService, + tokenService, + appIdService, + platformUtilsService, + messagingService, + logService, + stateService, + twoFactorService, + environmentService, + keyConnectorService + ); + + credentials = new ApiLogInCredentials(apiClientId, apiClientSecret); + }); + + it("sends api key credentials to the server", async () => { + apiService.postIdentityToken(Arg.any()).resolves(identityTokenResponseFactory()); + await apiLogInStrategy.logIn(credentials); + + apiService.received(1).postIdentityToken( + Arg.is((actual) => { + const apiTokenRequest = actual as any; + return ( + apiTokenRequest.clientId === apiClientId && + apiTokenRequest.clientSecret === apiClientSecret && + apiTokenRequest.device.identifier === deviceId && + apiTokenRequest.twoFactor.provider == null && + apiTokenRequest.twoFactor.token == null && + apiTokenRequest.captchaResponse == null + ); + }) + ); + }); + + it("sets the local environment after a successful login", async () => { + apiService.postIdentityToken(Arg.any()).resolves(identityTokenResponseFactory()); + + await apiLogInStrategy.logIn(credentials); + + stateService.received(1).setApiKeyClientId(apiClientId); + stateService.received(1).setApiKeyClientSecret(apiClientSecret); + stateService.received(1).addAccount(Arg.any()); + }); + + it("gets and sets the Key Connector key from environmentUrl", async () => { + const tokenResponse = identityTokenResponseFactory(); + tokenResponse.apiUseKeyConnector = true; + + apiService.postIdentityToken(Arg.any()).resolves(tokenResponse); + environmentService.getKeyConnectorUrl().returns(keyConnectorUrl); + + await apiLogInStrategy.logIn(credentials); + + keyConnectorService.received(1).getAndSetKey(keyConnectorUrl); + }); +}); diff --git a/spec/common/misc/logInStrategies/logIn.strategy.spec.ts b/spec/common/misc/logInStrategies/logIn.strategy.spec.ts new file mode 100644 index 0000000000..c3d97f3dbd --- /dev/null +++ b/spec/common/misc/logInStrategies/logIn.strategy.spec.ts @@ -0,0 +1,293 @@ +import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; + +import { ApiService } from "jslib-common/abstractions/api.service"; +import { AppIdService } from "jslib-common/abstractions/appId.service"; +import { AuthService } from "jslib-common/abstractions/auth.service"; +import { CryptoService } from "jslib-common/abstractions/crypto.service"; +import { LogService } from "jslib-common/abstractions/log.service"; +import { MessagingService } from "jslib-common/abstractions/messaging.service"; +import { PlatformUtilsService } from "jslib-common/abstractions/platformUtils.service"; +import { StateService } from "jslib-common/abstractions/state.service"; +import { TokenService } from "jslib-common/abstractions/token.service"; +import { TwoFactorService } from "jslib-common/abstractions/twoFactor.service"; + +import { PasswordLogInStrategy } from "jslib-common/misc/logInStrategies/passwordLogin.strategy"; +import { Utils } from "jslib-common/misc/utils"; + +import { Account, AccountProfile, AccountTokens } from "jslib-common/models/domain/account"; +import { AuthResult } from "jslib-common/models/domain/authResult"; +import { EncString } from "jslib-common/models/domain/encString"; +import { PasswordLogInCredentials } from "jslib-common/models/domain/logInCredentials"; + +import { PasswordTokenRequest } from "jslib-common/models/request/identityToken/passwordTokenRequest"; + +import { IdentityCaptchaResponse } from "jslib-common/models/response/identityCaptchaResponse"; +import { IdentityTokenResponse } from "jslib-common/models/response/identityTokenResponse"; +import { IdentityTwoFactorResponse } from "jslib-common/models/response/identityTwoFactorResponse"; + +import { TwoFactorProviderType } from "jslib-common/enums/twoFactorProviderType"; + +const email = "hello@world.com"; +const masterPassword = "password"; + +const deviceId = Utils.newGuid(); +const accessToken = "ACCESS_TOKEN"; +const refreshToken = "REFRESH_TOKEN"; +const encKey = "ENC_KEY"; +const privateKey = "PRIVATE_KEY"; +const captchaSiteKey = "CAPTCHA_SITE_KEY"; +const kdf = 0; +const kdfIterations = 10000; +const userId = Utils.newGuid(); +const masterPasswordHash = "MASTER_PASSWORD_HASH"; + +const decodedToken = { + sub: userId, + email: email, + premium: false, +}; + +const twoFactorProviderType = TwoFactorProviderType.Authenticator; +const twoFactorToken = "TWO_FACTOR_TOKEN"; +const twoFactorRemember = true; + +export function identityTokenResponseFactory() { + return new IdentityTokenResponse({ + ForcePasswordReset: false, + Kdf: kdf, + KdfIterations: kdfIterations, + Key: encKey, + PrivateKey: privateKey, + ResetMasterPassword: false, + access_token: accessToken, + expires_in: 3600, + refresh_token: refreshToken, + scope: "api offline_access", + token_type: "Bearer", + }); +} + +describe("LogInStrategy", () => { + let cryptoService: SubstituteOf; + let apiService: SubstituteOf; + let tokenService: SubstituteOf; + let appIdService: SubstituteOf; + let platformUtilsService: SubstituteOf; + let messagingService: SubstituteOf; + let logService: SubstituteOf; + let stateService: SubstituteOf; + let twoFactorService: SubstituteOf; + let authService: SubstituteOf; + + let passwordLogInStrategy: PasswordLogInStrategy; + let credentials: PasswordLogInCredentials; + + beforeEach(async () => { + cryptoService = Substitute.for(); + apiService = Substitute.for(); + tokenService = Substitute.for(); + appIdService = Substitute.for(); + platformUtilsService = Substitute.for(); + messagingService = Substitute.for(); + logService = Substitute.for(); + stateService = Substitute.for(); + twoFactorService = Substitute.for(); + authService = Substitute.for(); + + appIdService.getAppId().resolves(deviceId); + + // The base class is abstract so we test it via PasswordLogInStrategy + passwordLogInStrategy = new PasswordLogInStrategy( + cryptoService, + apiService, + tokenService, + appIdService, + platformUtilsService, + messagingService, + logService, + stateService, + twoFactorService, + authService + ); + credentials = new PasswordLogInCredentials(email, masterPassword); + }); + + describe("base class", () => { + it("sets the local environment after a successful login", async () => { + apiService.postIdentityToken(Arg.any()).resolves(identityTokenResponseFactory()); + tokenService.decodeToken(accessToken).resolves(decodedToken); + + await passwordLogInStrategy.logIn(credentials); + + stateService.received(1).addAccount( + new Account({ + profile: { + ...new AccountProfile(), + ...{ + userId: userId, + email: email, + hasPremiumPersonally: false, + kdfIterations: kdfIterations, + kdfType: kdf, + }, + }, + tokens: { + ...new AccountTokens(), + ...{ + accessToken: accessToken, + refreshToken: refreshToken, + }, + }, + }) + ); + cryptoService.received(1).setEncKey(encKey); + cryptoService.received(1).setEncPrivateKey(privateKey); + + stateService.received(1).setBiometricLocked(false); + messagingService.received(1).send("loggedIn"); + }); + + it("builds AuthResult", async () => { + const tokenResponse = identityTokenResponseFactory(); + tokenResponse.forcePasswordReset = true; + tokenResponse.resetMasterPassword = true; + + apiService.postIdentityToken(Arg.any()).resolves(tokenResponse); + + const result = await passwordLogInStrategy.logIn(credentials); + + const expected = new AuthResult(); + expected.forcePasswordReset = true; + expected.resetMasterPassword = true; + expected.twoFactorProviders = null; + expected.captchaSiteKey = ""; + expect(result).toEqual(expected); + }); + + it("rejects login if CAPTCHA is required", async () => { + // Sample CAPTCHA response + const tokenResponse = new IdentityCaptchaResponse({ + error: "invalid_grant", + error_description: "Captcha required.", + HCaptcha_SiteKey: captchaSiteKey, + }); + + apiService.postIdentityToken(Arg.any()).resolves(tokenResponse); + + const result = await passwordLogInStrategy.logIn(credentials); + + stateService.didNotReceive().addAccount(Arg.any()); + messagingService.didNotReceive().send(Arg.any()); + + const expected = new AuthResult(); + expected.captchaSiteKey = captchaSiteKey; + expect(result).toEqual(expected); + }); + + it("makes a new public and private key for an old account", async () => { + const tokenResponse = identityTokenResponseFactory(); + tokenResponse.privateKey = null; + cryptoService.makeKeyPair(Arg.any()).resolves(["PUBLIC_KEY", new EncString("PRIVATE_KEY")]); + + apiService.postIdentityToken(Arg.any()).resolves(tokenResponse); + + await passwordLogInStrategy.logIn(credentials); + + apiService.received(1).postAccountKeys(Arg.any()); + }); + }); + + describe("Two-factor authentication", () => { + it("rejects login if 2FA is required", async () => { + // Sample response where TOTP 2FA required + const tokenResponse = new IdentityTwoFactorResponse({ + TwoFactorProviders: ["0"], + TwoFactorProviders2: { 0: null }, + error: "invalid_grant", + error_description: "Two factor required.", + }); + + apiService.postIdentityToken(Arg.any()).resolves(tokenResponse); + + const result = await passwordLogInStrategy.logIn(credentials); + + stateService.didNotReceive().addAccount(Arg.any()); + messagingService.didNotReceive().send(Arg.any()); + + const expected = new AuthResult(); + expected.twoFactorProviders = new Map(); + expected.twoFactorProviders.set(0, null); + expect(result).toEqual(expected); + }); + + it("sends stored 2FA token to server", async () => { + tokenService.getTwoFactorToken().resolves(twoFactorToken); + apiService.postIdentityToken(Arg.any()).resolves(identityTokenResponseFactory()); + + await passwordLogInStrategy.logIn(credentials); + + apiService.received(1).postIdentityToken( + Arg.is((actual) => { + const passwordTokenRequest = actual as any; + return ( + passwordTokenRequest.twoFactor.provider === TwoFactorProviderType.Remember && + passwordTokenRequest.twoFactor.token === twoFactorToken && + passwordTokenRequest.twoFactor.remember === false + ); + }) + ); + }); + + it("sends 2FA token provided by user to server (single step)", async () => { + // This occurs if the user enters the 2FA code as an argument in the CLI + apiService.postIdentityToken(Arg.any()).resolves(identityTokenResponseFactory()); + credentials.twoFactor = { + provider: twoFactorProviderType, + token: twoFactorToken, + remember: twoFactorRemember, + }; + + await passwordLogInStrategy.logIn(credentials); + + apiService.received(1).postIdentityToken( + Arg.is((actual) => { + const passwordTokenRequest = actual as any; + return ( + passwordTokenRequest.twoFactor.provider === twoFactorProviderType && + passwordTokenRequest.twoFactor.token === twoFactorToken && + passwordTokenRequest.twoFactor.remember === twoFactorRemember + ); + }) + ); + }); + + it("sends 2FA token provided by user to server (two-step)", async () => { + // Simulate a partially completed login + passwordLogInStrategy.tokenRequest = new PasswordTokenRequest( + email, + masterPasswordHash, + null, + null + ); + + apiService.postIdentityToken(Arg.any()).resolves(identityTokenResponseFactory()); + + await passwordLogInStrategy.logInTwoFactor({ + provider: twoFactorProviderType, + token: twoFactorToken, + remember: twoFactorRemember, + }); + + apiService.received(1).postIdentityToken( + Arg.is((actual) => { + const passwordTokenRequest = actual as any; + return ( + passwordTokenRequest.twoFactor.provider === twoFactorProviderType && + passwordTokenRequest.twoFactor.token === twoFactorToken && + passwordTokenRequest.twoFactor.remember === twoFactorRemember + ); + }) + ); + }); + }); +}); diff --git a/spec/common/misc/logInStrategies/passwordLogIn.strategy.spec.ts b/spec/common/misc/logInStrategies/passwordLogIn.strategy.spec.ts new file mode 100644 index 0000000000..4d4aa1dda9 --- /dev/null +++ b/spec/common/misc/logInStrategies/passwordLogIn.strategy.spec.ts @@ -0,0 +1,113 @@ +import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; + +import { ApiService } from "jslib-common/abstractions/api.service"; +import { AppIdService } from "jslib-common/abstractions/appId.service"; +import { AuthService } from "jslib-common/abstractions/auth.service"; +import { CryptoService } from "jslib-common/abstractions/crypto.service"; +import { LogService } from "jslib-common/abstractions/log.service"; +import { MessagingService } from "jslib-common/abstractions/messaging.service"; +import { PlatformUtilsService } from "jslib-common/abstractions/platformUtils.service"; +import { StateService } from "jslib-common/abstractions/state.service"; +import { TokenService } from "jslib-common/abstractions/token.service"; +import { TwoFactorService } from "jslib-common/abstractions/twoFactor.service"; + +import { PasswordLogInStrategy } from "jslib-common/misc/logInStrategies/passwordLogin.strategy"; +import { Utils } from "jslib-common/misc/utils"; + +import { PasswordLogInCredentials } from "jslib-common/models/domain/logInCredentials"; +import { SymmetricCryptoKey } from "jslib-common/models/domain/symmetricCryptoKey"; + +import { HashPurpose } from "jslib-common/enums/hashPurpose"; + +import { identityTokenResponseFactory } from "./logIn.strategy.spec"; + +const email = "hello@world.com"; +const masterPassword = "password"; +const hashedPassword = "HASHED_PASSWORD"; +const localHashedPassword = "LOCAL_HASHED_PASSWORD"; +const preloginKey = new SymmetricCryptoKey( + Utils.fromB64ToArray( + "N2KWjlLpfi5uHjv+YcfUKIpZ1l+W+6HRensmIqD+BFYBf6N/dvFpJfWwYnVBdgFCK2tJTAIMLhqzIQQEUmGFgg==" + ) +); +const deviceId = Utils.newGuid(); + +describe("PasswordLogInStrategy", () => { + let cryptoService: SubstituteOf; + let apiService: SubstituteOf; + let tokenService: SubstituteOf; + let appIdService: SubstituteOf; + let platformUtilsService: SubstituteOf; + let messagingService: SubstituteOf; + let logService: SubstituteOf; + let stateService: SubstituteOf; + let twoFactorService: SubstituteOf; + let authService: SubstituteOf; + + let passwordLogInStrategy: PasswordLogInStrategy; + let credentials: PasswordLogInCredentials; + + beforeEach(async () => { + cryptoService = Substitute.for(); + apiService = Substitute.for(); + tokenService = Substitute.for(); + appIdService = Substitute.for(); + platformUtilsService = Substitute.for(); + messagingService = Substitute.for(); + logService = Substitute.for(); + stateService = Substitute.for(); + twoFactorService = Substitute.for(); + authService = Substitute.for(); + + appIdService.getAppId().resolves(deviceId); + tokenService.getTwoFactorToken().resolves(null); + + authService.makePreloginKey(Arg.any(), Arg.any()).resolves(preloginKey); + + cryptoService.hashPassword(masterPassword, Arg.any()).resolves(hashedPassword); + cryptoService + .hashPassword(masterPassword, Arg.any(), HashPurpose.LocalAuthorization) + .resolves(localHashedPassword); + + passwordLogInStrategy = new PasswordLogInStrategy( + cryptoService, + apiService, + tokenService, + appIdService, + platformUtilsService, + messagingService, + logService, + stateService, + twoFactorService, + authService + ); + credentials = new PasswordLogInCredentials(email, masterPassword); + + apiService.postIdentityToken(Arg.any()).resolves(identityTokenResponseFactory()); + }); + + it("sends master password credentials to the server", async () => { + await passwordLogInStrategy.logIn(credentials); + + apiService.received(1).postIdentityToken( + Arg.is((actual) => { + const passwordTokenRequest = actual as any; // Need to access private fields + return ( + passwordTokenRequest.email === email && + passwordTokenRequest.masterPasswordHash === hashedPassword && + passwordTokenRequest.device.identifier === deviceId && + passwordTokenRequest.twoFactor.provider == null && + passwordTokenRequest.twoFactor.token == null && + passwordTokenRequest.captchaResponse == null + ); + }) + ); + }); + + it("sets the local environment after a successful login", async () => { + await passwordLogInStrategy.logIn(credentials); + + cryptoService.received(1).setKey(preloginKey); + cryptoService.received(1).setKeyHash(localHashedPassword); + }); +}); diff --git a/spec/common/misc/logInStrategies/ssoLogIn.strategy.spec.ts b/spec/common/misc/logInStrategies/ssoLogIn.strategy.spec.ts new file mode 100644 index 0000000000..fdb20330a4 --- /dev/null +++ b/spec/common/misc/logInStrategies/ssoLogIn.strategy.spec.ts @@ -0,0 +1,130 @@ +import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; + +import { ApiService } from "jslib-common/abstractions/api.service"; +import { AppIdService } from "jslib-common/abstractions/appId.service"; +import { CryptoService } from "jslib-common/abstractions/crypto.service"; +import { KeyConnectorService } from "jslib-common/abstractions/keyConnector.service"; +import { LogService } from "jslib-common/abstractions/log.service"; +import { MessagingService } from "jslib-common/abstractions/messaging.service"; +import { PlatformUtilsService } from "jslib-common/abstractions/platformUtils.service"; +import { StateService } from "jslib-common/abstractions/state.service"; +import { TokenService } from "jslib-common/abstractions/token.service"; + +import { SsoLogInStrategy } from "jslib-common/misc/logInStrategies/ssoLogin.strategy"; +import { Utils } from "jslib-common/misc/utils"; + +import { TwoFactorService } from "jslib-common/abstractions/twoFactor.service"; + +import { identityTokenResponseFactory } from "./logIn.strategy.spec"; + +import { SsoLogInCredentials } from "jslib-common/models/domain/logInCredentials"; + +describe("SsoLogInStrategy", () => { + let cryptoService: SubstituteOf; + let apiService: SubstituteOf; + let tokenService: SubstituteOf; + let appIdService: SubstituteOf; + let platformUtilsService: SubstituteOf; + let messagingService: SubstituteOf; + let logService: SubstituteOf; + let keyConnectorService: SubstituteOf; + let stateService: SubstituteOf; + let twoFactorService: SubstituteOf; + + let ssoLogInStrategy: SsoLogInStrategy; + let credentials: SsoLogInCredentials; + + const deviceId = Utils.newGuid(); + const encKey = "ENC_KEY"; + const privateKey = "PRIVATE_KEY"; + const keyConnectorUrl = "KEY_CONNECTOR_URL"; + + const ssoCode = "SSO_CODE"; + const ssoCodeVerifier = "SSO_CODE_VERIFIER"; + const ssoRedirectUrl = "SSO_REDIRECT_URL"; + const ssoOrgId = "SSO_ORG_ID"; + + beforeEach(async () => { + cryptoService = Substitute.for(); + apiService = Substitute.for(); + tokenService = Substitute.for(); + appIdService = Substitute.for(); + platformUtilsService = Substitute.for(); + messagingService = Substitute.for(); + logService = Substitute.for(); + stateService = Substitute.for(); + keyConnectorService = Substitute.for(); + twoFactorService = Substitute.for(); + + tokenService.getTwoFactorToken().resolves(null); + appIdService.getAppId().resolves(deviceId); + + ssoLogInStrategy = new SsoLogInStrategy( + cryptoService, + apiService, + tokenService, + appIdService, + platformUtilsService, + messagingService, + logService, + stateService, + twoFactorService, + keyConnectorService + ); + credentials = new SsoLogInCredentials(ssoCode, ssoCodeVerifier, ssoRedirectUrl, ssoOrgId); + }); + + it("sends SSO information to server", async () => { + apiService.postIdentityToken(Arg.any()).resolves(identityTokenResponseFactory()); + + await ssoLogInStrategy.logIn(credentials); + + apiService.received(1).postIdentityToken( + Arg.is((actual) => { + const ssoTokenRequest = actual as any; + return ( + ssoTokenRequest.code === ssoCode && + ssoTokenRequest.codeVerifier === ssoCodeVerifier && + ssoTokenRequest.redirectUri === ssoRedirectUrl && + ssoTokenRequest.device.identifier === deviceId && + ssoTokenRequest.twoFactor.provider == null && + ssoTokenRequest.twoFactor.token == null + ); + }) + ); + }); + + it("does not set keys for new SSO user flow", async () => { + const tokenResponse = identityTokenResponseFactory(); + tokenResponse.key = null; + apiService.postIdentityToken(Arg.any()).resolves(tokenResponse); + + await ssoLogInStrategy.logIn(credentials); + + cryptoService.didNotReceive().setEncPrivateKey(privateKey); + cryptoService.didNotReceive().setEncKey(encKey); + }); + + it("gets and sets KeyConnector key for enrolled user", async () => { + const tokenResponse = identityTokenResponseFactory(); + tokenResponse.keyConnectorUrl = keyConnectorUrl; + + apiService.postIdentityToken(Arg.any()).resolves(tokenResponse); + + await ssoLogInStrategy.logIn(credentials); + + keyConnectorService.received(1).getAndSetKey(keyConnectorUrl); + }); + + it("converts new SSO user to Key Connector on first login", async () => { + const tokenResponse = identityTokenResponseFactory(); + tokenResponse.keyConnectorUrl = keyConnectorUrl; + tokenResponse.key = null; + + apiService.postIdentityToken(Arg.any()).resolves(tokenResponse); + + await ssoLogInStrategy.logIn(credentials); + + keyConnectorService.received(1).convertNewSsoUserToKeyConnector(tokenResponse, ssoOrgId); + }); +});