mirror of
https://github.com/bitwarden/browser
synced 2025-12-16 00:03:56 +00:00
Login Flows (#4411)
* [SG-171] Login with a device request: Desktop (#3999) * Move LoginWithDeviceComponent to libs * Create login module * Remove login component from previous location * Move startPasswordlessLogin method to base class * Register route for login with device component * Add new localizations * Add Login with Device page styles * Add desktop login with device component * Spacing fix * Add content box around page * Update wording of helper text * Make resend timeout a class variable * SG-173 - Login device approval desktop (#4232) * SG-173 Implemented UI and login for login approval request * SG-173 - Show login approval after login * SG-173 Fetch login requests if the setting is true * SG-173 Add subheading to new setting * SG-173 Handle modal dismiss denying login request * SG-173 Fix pr comments * SG-173 Implemented desktop alerts * SG-173 Replicated behaviour of openViewRef * SG-173 Fixed previous commit * SG-173 PR fix * SG-173 Fix PR comment * SG-173 Added missing service injection * SG-173 Added logo to notifications * SG-173 Fix PR comments * [SG-910] Override self hosted check for desktop (#4405) * Override base component self hosted check * Add selfhost check to environment service * [SG-170] Login with Device Request - Browser (#4198) * work: ui stuff * fix: use parent * fix: words * [SG-987] [SG-988] [SG-989] Fix passwordless login request (#4573) * SG-987 Fix notification text and button options * SG-988 Fix approval and decline confirmation toasts * SG-989 Fix methods called * SG-988 Undo previous commit * [SG-1034] [Defect] - Vault is empty upon login confirmation (#4646) * fix: sync after login * undo: whoops --------- Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com> Co-authored-by: Brandon Maharaj <bmaharaj@bitwarden.com> Co-authored-by: Todd Martin <106564991+trmartin4@users.noreply.github.com>
This commit is contained in:
@@ -26,6 +26,7 @@ import { OrganizationSponsorshipCreateRequest } from "../models/request/organiza
|
||||
import { OrganizationSponsorshipRedeemRequest } from "../models/request/organization/organization-sponsorship-redeem.request";
|
||||
import { PasswordHintRequest } from "../models/request/password-hint.request";
|
||||
import { PasswordRequest } from "../models/request/password.request";
|
||||
import { PasswordlessAuthRequest } from "../models/request/passwordless-auth.request";
|
||||
import { PasswordlessCreateAuthRequest } from "../models/request/passwordless-create-auth.request";
|
||||
import { PaymentRequest } from "../models/request/payment.request";
|
||||
import { PreloginRequest } from "../models/request/prelogin.request";
|
||||
@@ -204,6 +205,10 @@ export abstract class ApiService {
|
||||
//passwordless
|
||||
postAuthRequest: (request: PasswordlessCreateAuthRequest) => Promise<AuthRequestResponse>;
|
||||
getAuthResponse: (id: string, accessCode: string) => Promise<AuthRequestResponse>;
|
||||
getAuthRequest: (id: string) => Promise<AuthRequestResponse>;
|
||||
putAuthRequest: (id: string, request: PasswordlessAuthRequest) => Promise<AuthRequestResponse>;
|
||||
getAuthRequests: () => Promise<ListResponse<AuthRequestResponse>>;
|
||||
getLastAuthRequest: () => Promise<AuthRequestResponse>;
|
||||
|
||||
getUserBillingHistory: () => Promise<BillingHistoryResponse>;
|
||||
getUserBillingPayment: () => Promise<BillingPaymentResponse>;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "../models/domain/log-in-credentials";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
import { TokenTwoFactorRequest } from "../models/request/identity-token/token-two-factor.request";
|
||||
import { AuthRequestResponse } from "../models/response/auth-request.response";
|
||||
import { AuthRequestPushNotification } from "../models/response/notification.response";
|
||||
|
||||
export abstract class AuthService {
|
||||
@@ -37,6 +38,10 @@ export abstract class AuthService {
|
||||
authingWithPasswordless: () => boolean;
|
||||
getAuthStatus: (userId?: string) => Promise<AuthenticationStatus>;
|
||||
authResponsePushNotifiction: (notification: AuthRequestPushNotification) => Promise<any>;
|
||||
|
||||
passwordlessLogin: (
|
||||
id: string,
|
||||
key: string,
|
||||
requestApproved: boolean
|
||||
) => Promise<AuthRequestResponse>;
|
||||
getPushNotifcationObs$: () => Observable<any>;
|
||||
}
|
||||
|
||||
@@ -34,4 +34,9 @@ export abstract class EnvironmentService {
|
||||
setUrls: (urls: Urls) => Promise<Urls>;
|
||||
getUrls: () => Urls;
|
||||
isCloud: () => boolean;
|
||||
/**
|
||||
* @remarks For desktop and browser use only.
|
||||
* For web, use PlatformUtilsService.isSelfHost()
|
||||
*/
|
||||
isSelfHosted: () => boolean;
|
||||
}
|
||||
|
||||
@@ -338,6 +338,8 @@ export abstract class StateService<T extends Account = Account> {
|
||||
setVaultTimeout: (value: number, options?: StorageOptions) => Promise<void>;
|
||||
getVaultTimeoutAction: (options?: StorageOptions) => Promise<string>;
|
||||
setVaultTimeoutAction: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getApproveLoginRequests: (options?: StorageOptions) => Promise<boolean>;
|
||||
setApproveLoginRequests: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getStateVersion: () => Promise<number>;
|
||||
setStateVersion: (value: number) => Promise<void>;
|
||||
getWindow: () => Promise<WindowState>;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
/* eslint-disable @typescript-eslint/ban-types */
|
||||
export type SharedFlags = {
|
||||
multithreadDecryption: boolean;
|
||||
showPasswordless?: boolean;
|
||||
};
|
||||
|
||||
// required to avoid linting errors when there are no flags
|
||||
|
||||
@@ -235,6 +235,7 @@ export class AccountSettings {
|
||||
vaultTimeout?: number;
|
||||
vaultTimeoutAction?: string = "lock";
|
||||
serverConfig?: ServerConfigData;
|
||||
approveLoginRequests?: boolean;
|
||||
avatarColor?: string;
|
||||
|
||||
static fromJSON(obj: Jsonify<AccountSettings>): AccountSettings {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export class PasswordlessAuthRequest {
|
||||
constructor(
|
||||
readonly key: string,
|
||||
readonly masterPasswordHash: string,
|
||||
readonly deviceIdentifier: string,
|
||||
readonly requestApproved: boolean
|
||||
) {}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { DeviceType } from "../../enums/deviceType";
|
||||
|
||||
import { BaseResponse } from "./base.response";
|
||||
|
||||
const RequestTimeOut = 60000 * 15; //15 Minutes
|
||||
|
||||
export class AuthRequestResponse extends BaseResponse {
|
||||
id: string;
|
||||
publicKey: string;
|
||||
@@ -10,7 +12,11 @@ export class AuthRequestResponse extends BaseResponse {
|
||||
key: string;
|
||||
masterPasswordHash: string;
|
||||
creationDate: string;
|
||||
requestApproved: boolean;
|
||||
requestApproved?: boolean;
|
||||
requestFingerprint?: string;
|
||||
responseDate?: string;
|
||||
isAnswered: boolean;
|
||||
isExpired: boolean;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
@@ -22,5 +28,32 @@ export class AuthRequestResponse extends BaseResponse {
|
||||
this.masterPasswordHash = this.getResponseProperty("MasterPasswordHash");
|
||||
this.creationDate = this.getResponseProperty("CreationDate");
|
||||
this.requestApproved = this.getResponseProperty("RequestApproved");
|
||||
this.requestFingerprint = this.getResponseProperty("RequestFingerprint");
|
||||
this.responseDate = this.getResponseProperty("ResponseDate");
|
||||
|
||||
const requestDate = new Date(this.creationDate);
|
||||
const requestDateUTC = Date.UTC(
|
||||
requestDate.getUTCFullYear(),
|
||||
requestDate.getUTCMonth(),
|
||||
requestDate.getDate(),
|
||||
requestDate.getUTCHours(),
|
||||
requestDate.getUTCMinutes(),
|
||||
requestDate.getUTCSeconds(),
|
||||
requestDate.getUTCMilliseconds()
|
||||
);
|
||||
|
||||
const dateNow = new Date(Date.now());
|
||||
const dateNowUTC = Date.UTC(
|
||||
dateNow.getUTCFullYear(),
|
||||
dateNow.getUTCMonth(),
|
||||
dateNow.getDate(),
|
||||
dateNow.getUTCHours(),
|
||||
dateNow.getUTCMinutes(),
|
||||
dateNow.getUTCSeconds(),
|
||||
dateNow.getUTCMilliseconds()
|
||||
);
|
||||
|
||||
this.isExpired = dateNowUTC - requestDateUTC >= RequestTimeOut;
|
||||
this.isAnswered = this.requestApproved != null && this.responseDate != null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import { OrganizationSponsorshipCreateRequest } from "../models/request/organiza
|
||||
import { OrganizationSponsorshipRedeemRequest } from "../models/request/organization/organization-sponsorship-redeem.request";
|
||||
import { PasswordHintRequest } from "../models/request/password-hint.request";
|
||||
import { PasswordRequest } from "../models/request/password.request";
|
||||
import { PasswordlessAuthRequest } from "../models/request/passwordless-auth.request";
|
||||
import { PasswordlessCreateAuthRequest } from "../models/request/passwordless-create-auth.request";
|
||||
import { PaymentRequest } from "../models/request/payment.request";
|
||||
import { PreloginRequest } from "../models/request/prelogin.request";
|
||||
@@ -266,6 +267,33 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
return new AuthRequestResponse(r);
|
||||
}
|
||||
|
||||
async getAuthRequest(id: string): Promise<AuthRequestResponse> {
|
||||
const path = `/auth-requests/${id}`;
|
||||
const r = await this.send("GET", path, null, true, true);
|
||||
return new AuthRequestResponse(r);
|
||||
}
|
||||
|
||||
async putAuthRequest(id: string, request: PasswordlessAuthRequest): Promise<AuthRequestResponse> {
|
||||
const path = `/auth-requests/${id}`;
|
||||
const r = await this.send("PUT", path, request, true, true);
|
||||
return new AuthRequestResponse(r);
|
||||
}
|
||||
|
||||
async getAuthRequests(): Promise<ListResponse<AuthRequestResponse>> {
|
||||
const path = `/auth-requests/`;
|
||||
const r = await this.send("GET", path, null, true, true);
|
||||
return new ListResponse(r, AuthRequestResponse);
|
||||
}
|
||||
|
||||
async getLastAuthRequest(): Promise<AuthRequestResponse> {
|
||||
const requests = await this.getAuthRequests();
|
||||
const activeRequests = requests.data.filter((m) => !m.isAnswered && !m.isExpired);
|
||||
const lastRequest = activeRequests.sort((a: AuthRequestResponse, b: AuthRequestResponse) =>
|
||||
a.creationDate.localeCompare(b.creationDate)
|
||||
)[activeRequests.length - 1];
|
||||
return lastRequest;
|
||||
}
|
||||
|
||||
// Account APIs
|
||||
|
||||
async getProfile(): Promise<ProfileResponse> {
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 { EncryptService } from "../abstractions/encrypt.service";
|
||||
import { EnvironmentService } from "../abstractions/environment.service";
|
||||
import { I18nService } from "../abstractions/i18n.service";
|
||||
import { KeyConnectorService } from "../abstractions/keyConnector.service";
|
||||
@@ -21,6 +22,7 @@ import { PasswordLogInStrategy } from "../misc/logInStrategies/passwordLogin.str
|
||||
import { PasswordlessLogInStrategy } from "../misc/logInStrategies/passwordlessLogin.strategy";
|
||||
import { SsoLogInStrategy } from "../misc/logInStrategies/ssoLogin.strategy";
|
||||
import { UserApiLogInStrategy } from "../misc/logInStrategies/user-api-login.strategy";
|
||||
import { Utils } from "../misc/utils";
|
||||
import { AuthResult } from "../models/domain/auth-result";
|
||||
import { KdfConfig } from "../models/domain/kdf-config";
|
||||
import {
|
||||
@@ -31,7 +33,9 @@ import {
|
||||
} from "../models/domain/log-in-credentials";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
import { TokenTwoFactorRequest } from "../models/request/identity-token/token-two-factor.request";
|
||||
import { PasswordlessAuthRequest } from "../models/request/passwordless-auth.request";
|
||||
import { PreloginRequest } from "../models/request/prelogin.request";
|
||||
import { AuthRequestResponse } from "../models/response/auth-request.response";
|
||||
import { ErrorResponse } from "../models/response/error.response";
|
||||
import { AuthRequestPushNotification } from "../models/response/notification.response";
|
||||
|
||||
@@ -88,7 +92,8 @@ export class AuthService implements AuthServiceAbstraction {
|
||||
protected environmentService: EnvironmentService,
|
||||
protected stateService: StateService,
|
||||
protected twoFactorService: TwoFactorService,
|
||||
protected i18nService: I18nService
|
||||
protected i18nService: I18nService,
|
||||
protected encryptService: EncryptService
|
||||
) {}
|
||||
|
||||
async logIn(
|
||||
@@ -275,6 +280,31 @@ export class AuthService implements AuthServiceAbstraction {
|
||||
return this.pushNotificationSubject.asObservable();
|
||||
}
|
||||
|
||||
async passwordlessLogin(
|
||||
id: string,
|
||||
key: string,
|
||||
requestApproved: boolean
|
||||
): Promise<AuthRequestResponse> {
|
||||
const pubKey = Utils.fromB64ToArray(key);
|
||||
const encryptedKey = await this.cryptoService.rsaEncrypt(
|
||||
(
|
||||
await this.cryptoService.getKey()
|
||||
).encKey,
|
||||
pubKey.buffer
|
||||
);
|
||||
const encryptedMasterPassword = await this.cryptoService.rsaEncrypt(
|
||||
Utils.fromUtf8ToArray(await this.stateService.getKeyHash()),
|
||||
pubKey.buffer
|
||||
);
|
||||
const request = new PasswordlessAuthRequest(
|
||||
encryptedKey.encryptedString,
|
||||
encryptedMasterPassword.encryptedString,
|
||||
await this.appIdService.getAppId(),
|
||||
requestApproved
|
||||
);
|
||||
return await this.apiService.putAuthRequest(id, request);
|
||||
}
|
||||
|
||||
private saveState(
|
||||
strategy:
|
||||
| UserApiLogInStrategy
|
||||
|
||||
@@ -213,4 +213,13 @@ export class EnvironmentService implements EnvironmentServiceAbstraction {
|
||||
this.getApiUrl()
|
||||
);
|
||||
}
|
||||
|
||||
isSelfHosted(): boolean {
|
||||
return ![
|
||||
"http://vault.bitwarden.com",
|
||||
"https://vault.bitwarden.com",
|
||||
"http://vault.qa.bitwarden.pw",
|
||||
"https://vault.qa.bitwarden.pw",
|
||||
].includes(this.getWebVaultUrl());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AppIdService } from "../abstractions/appId.service";
|
||||
import { AuthService } from "../abstractions/auth.service";
|
||||
import { EnvironmentService } from "../abstractions/environment.service";
|
||||
import { LogService } from "../abstractions/log.service";
|
||||
import { MessagingService } from "../abstractions/messaging.service";
|
||||
import { NotificationsService as NotificationsServiceAbstraction } from "../abstractions/notifications.service";
|
||||
import { StateService } from "../abstractions/state.service";
|
||||
import { AuthenticationStatus } from "../enums/authenticationStatus";
|
||||
@@ -34,7 +35,8 @@ export class NotificationsService implements NotificationsServiceAbstraction {
|
||||
private logoutCallback: (expired: boolean) => Promise<void>,
|
||||
private logService: LogService,
|
||||
private stateService: StateService,
|
||||
private authService: AuthService
|
||||
private authService: AuthService,
|
||||
private messagingService: MessagingService
|
||||
) {
|
||||
this.environmentService.urls.subscribe(() => {
|
||||
if (!this.inited) {
|
||||
@@ -183,6 +185,13 @@ export class NotificationsService implements NotificationsServiceAbstraction {
|
||||
case NotificationType.SyncSendDelete:
|
||||
await this.syncService.syncDeleteSend(notification.payload as SyncSendNotification);
|
||||
break;
|
||||
case NotificationType.AuthRequest:
|
||||
if (await this.stateService.getApproveLoginRequests()) {
|
||||
this.messagingService.send("openLoginApproval", {
|
||||
notificationId: notification.payload.id,
|
||||
});
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -2266,6 +2266,24 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
async getApproveLoginRequests(options?: StorageOptions): Promise<boolean> {
|
||||
const approveLoginRequests = (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))
|
||||
)?.settings?.approveLoginRequests;
|
||||
return approveLoginRequests;
|
||||
}
|
||||
|
||||
async setApproveLoginRequests(value: boolean, options?: StorageOptions): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())
|
||||
);
|
||||
account.settings.approveLoginRequests = value;
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())
|
||||
);
|
||||
}
|
||||
|
||||
async getStateVersion(): Promise<number> {
|
||||
return (await this.getGlobals(await this.defaultOnDiskLocalOptions())).stateVersion ?? 1;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user