1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-02-24 02:41:54 +01: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:
Robyn MacCallum 2023-02-05 10:57:21 -05:00 committed by GitHub
parent dcc7846138
commit 8a9e59094a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 1281 additions and 211 deletions

View File

@ -1,4 +1,6 @@
{
"dev_flags": {},
"flags": {}
"flags": {
"showPasswordless": true
}
}

View File

@ -5,5 +5,7 @@
"base": "https://localhost:8080"
}
},
"flags": {}
"flags": {
"showPasswordless": true
}
}

View File

@ -1985,7 +1985,7 @@
"message": "Organization suspended."
},
"disabledOrganizationFilterError": {
"message" : "Items in suspended Organizations cannot be accessed. Contact your Organization owner for assistance."
"message": "Items in suspended Organizations cannot be accessed. Contact your Organization owner for assistance."
},
"cardBrandMir": {
"message": "Mir"
@ -2050,12 +2050,36 @@
"rememberEmail": {
"message": "Remember email"
},
"loginWithDevice": {
"message": "Log in with device"
},
"loginWithDeviceEnabledInfo": {
"message": "Log in with device must be set up in the settings of the Bitwarden app. Need another option?"
},
"fingerprintPhraseHeader": {
"message": "Fingerprint phrase"
},
"fingerprintMatchInfo": {
"message": "Please make sure your vault is unlocked and the Fingerprint phrase matches on the other device."
},
"resendNotification": {
"message": "Resend notification"
},
"viewAllLoginOptions": {
"message": "View all log in options"
},
"notificationSentDevice": {
"message": "A notification has been sent to your device."
},
"logInInitiated": {
"message": "Log in initiated"
},
"exposedMasterPassword": {
"message": "Exposed Master Password"
},
"exposedMasterPasswordDesc": {
"message": "Password found in a data breach. Use a unique password to protect your account. Are you sure you want to use an exposed password?"
},
},
"weakAndExposedMasterPassword": {
"message": "Weak and Exposed Master Password"
},

View File

@ -374,7 +374,8 @@ export default class MainBackground {
this.environmentService,
this.stateService,
this.twoFactorService,
this.i18nService
this.i18nService,
this.encryptService
);
this.vaultTimeoutSettingsService = new VaultTimeoutSettingsService(
@ -460,7 +461,8 @@ export default class MainBackground {
logoutCallback,
this.logService,
this.stateService,
this.authService
this.authService,
this.messagingService
);
this.popupUtilsService = new PopupUtilsService(isPrivateMode);

View File

@ -4,6 +4,7 @@ import { AuthService } from "@bitwarden/common/services/auth.service";
import { apiServiceFactory, ApiServiceInitOptions } from "./api-service.factory";
import { appIdServiceFactory } from "./app-id-service.factory";
import { cryptoServiceFactory, CryptoServiceInitOptions } from "./crypto-service.factory";
import { EncryptServiceInitOptions, encryptServiceFactory } from "./encrypt-service.factory";
import {
environmentServiceFactory,
EnvironmentServiceInitOptions,
@ -37,7 +38,8 @@ export type AuthServiceInitOptions = AuthServiceFactoyOptions &
EnvironmentServiceInitOptions &
StateServiceInitOptions &
TwoFactorServiceInitOptions &
I18nServiceInitOptions;
I18nServiceInitOptions &
EncryptServiceInitOptions;
export function authServiceFactory(
cache: { authService?: AbstractAuthService } & CachedServices,
@ -60,7 +62,8 @@ export function authServiceFactory(
await environmentServiceFactory(cache, opts),
await stateServiceFactory(cache, opts),
await twoFactorServiceFactory(cache, opts),
await i18nServiceFactory(cache, opts)
await i18nServiceFactory(cache, opts),
await encryptServiceFactory(cache, opts)
)
);
}

View File

@ -0,0 +1,36 @@
<div class="login-with-device">
<header>
<h1 class="login-center">
<span class="title">{{ "logIn" | i18n }}</span>
</h1>
</header>
<div class="content login-page">
<div>
<p class="lead">{{ "logInInitiated" | i18n }}</p>
<div>
<p>{{ "notificationSentDevice" | i18n }}</p>
<p>
{{ "fingerprintMatchInfo" | i18n }}
</p>
</div>
<div>
<b class="fingerprint-phrase-header">{{ "fingerprintPhraseHeader" | i18n }}</b>
<p class="fingerprint-text">
<code>{{ passwordlessRequest?.fingerprintPhrase }}</code>
</p>
</div>
<div class="resend-notification" *ngIf="showResendNotification">
<a (click)="startPasswordlessLogin()">{{ "resendNotification" | i18n }}</a>
</div>
<div class="footer">
{{ "loginWithDeviceEnabledInfo" | i18n }}
<a routerLink="/login">{{ "viewAllLoginOptions" | i18n }}</a>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,68 @@
import { Component, OnDestroy, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { LoginWithDeviceComponent as BaseLoginWithDeviceComponent } from "@bitwarden/angular/components/login-with-device.component";
import { AnonymousHubService } from "@bitwarden/common/abstractions/anonymousHub.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AppIdService } from "@bitwarden/common/abstractions/appId.service";
import { AuthService } from "@bitwarden/common/abstractions/auth.service";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service";
import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { LoginService } from "@bitwarden/common/abstractions/login.service";
import { PasswordGenerationService } from "@bitwarden/common/abstractions/passwordGeneration.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { StateService } from "@bitwarden/common/abstractions/state.service";
import { ValidationService } from "@bitwarden/common/abstractions/validation.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
@Component({
selector: "app-login-with-device",
templateUrl: "login-with-device.component.html",
})
export class LoginWithDeviceComponent
extends BaseLoginWithDeviceComponent
implements OnInit, OnDestroy
{
constructor(
router: Router,
cryptoService: CryptoService,
cryptoFunctionService: CryptoFunctionService,
appIdService: AppIdService,
passwordGenerationService: PasswordGenerationService,
apiService: ApiService,
authService: AuthService,
logService: LogService,
environmentService: EnvironmentService,
i18nService: I18nService,
platformUtilsService: PlatformUtilsService,
anonymousHubService: AnonymousHubService,
validationService: ValidationService,
stateService: StateService,
loginService: LoginService,
syncService: SyncService
) {
super(
router,
cryptoService,
cryptoFunctionService,
appIdService,
passwordGenerationService,
apiService,
authService,
logService,
environmentService,
i18nService,
platformUtilsService,
anonymousHubService,
validationService,
stateService,
loginService
);
super.onSuccessfulLogin = async () => {
await syncService.fullSync(true);
};
}
}

View File

@ -9,6 +9,13 @@
<div class="box-content">
<div class="box-content-row box-content-row-flex" appBoxRow>
<div class="row-main">
<input id="email" type="text" formControlName="email" [hidden]="true" />
<input
id="rememberEmail"
type="checkbox"
formControlName="rememberEmail"
[hidden]="true"
/>
<label for="masterPassword">{{ "masterPass" | i18n }}</label>
<input
id="masterPassword"
@ -54,6 +61,11 @@
>
<i class="bwi bwi-spinner bwi-lg bwi-spin" [hidden]="!form.loading" aria-hidden="true"></i>
</button>
<div class="tw-mb-3" *ngIf="showLoginWithDevice && showPasswordless">
<button type="button" class="btn block" (click)="startPasswordlessLogin()">
<span> <i class="bwi bwi-mobile"></i> {{ "loginWithDevice" | i18n }} </span>
</button>
</div>
<button type="button" (click)="launchSsoBrowser()" class="btn block">
<i class="bwi bwi-provider" aria-hidden="true"></i> {{ "enterpriseSingleSignOn" | i18n }}
</button>

View File

@ -18,11 +18,14 @@ import { StateService } from "@bitwarden/common/abstractions/state.service";
import { Utils } from "@bitwarden/common/misc/utils";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { flagEnabled } from "../../flags";
@Component({
selector: "app-login",
templateUrl: "login.component.html",
})
export class LoginComponent extends BaseLoginComponent {
showPasswordless = false;
constructor(
apiService: ApiService,
appIdService: AppIdService,
@ -64,6 +67,13 @@ export class LoginComponent extends BaseLoginComponent {
await syncService.fullSync(true);
};
super.successRoute = "/tabs/vault";
this.showPasswordless = flagEnabled("showPasswordless");
if (this.showPasswordless) {
this.formGroup.controls.email.setValue(this.loginService.getEmail());
this.formGroup.controls.rememberEmail.setValue(this.loginService.getRememberEmail());
this.validateEmail();
}
}
settings() {

View File

@ -120,7 +120,7 @@ export const routerTransition = trigger("routerTransition", [
transition("login => home", outSlideDown),
transition("login => hint", inSlideUp),
transition("login => tabs, login => 2fa", inSlideLeft),
transition("login => tabs, login => 2fa, login => login-with-device", inSlideLeft),
transition("hint => login, register => home, environment => home", outSlideDown),
@ -129,6 +129,9 @@ export const routerTransition = trigger("routerTransition", [
transition("2fa-options => 2fa", outSlideDown),
transition("2fa => tabs", inSlideLeft),
transition("login-with-device => tabs, login-with-device => 2fa", inSlideLeft),
transition("login-with-device => login", outSlideRight),
transition(tabsToCiphers, inSlideLeft),
transition(ciphersToTabs, outSlideRight),

View File

@ -18,6 +18,7 @@ import { EnvironmentComponent } from "./accounts/environment.component";
import { HintComponent } from "./accounts/hint.component";
import { HomeComponent } from "./accounts/home.component";
import { LockComponent } from "./accounts/lock.component";
import { LoginWithDeviceComponent } from "./accounts/login-with-device.component";
import { LoginComponent } from "./accounts/login.component";
import { RegisterComponent } from "./accounts/register.component";
import { RemovePasswordComponent } from "./accounts/remove-password.component";
@ -67,6 +68,12 @@ const routes: Routes = [
canActivate: [UnauthGuard],
data: { state: "login" },
},
{
path: "login-with-device",
component: LoginWithDeviceComponent,
canActivate: [UnauthGuard],
data: { state: "login-with-device" },
},
{
path: "lock",
component: LockComponent,

View File

@ -39,6 +39,7 @@ import { EnvironmentComponent } from "./accounts/environment.component";
import { HintComponent } from "./accounts/hint.component";
import { HomeComponent } from "./accounts/home.component";
import { LockComponent } from "./accounts/lock.component";
import { LoginWithDeviceComponent } from "./accounts/login-with-device.component";
import { LoginComponent } from "./accounts/login.component";
import { RegisterComponent } from "./accounts/register.component";
import { RemovePasswordComponent } from "./accounts/remove-password.component";
@ -117,6 +118,7 @@ import { TabsComponent } from "./tabs.component";
HomeComponent,
LockComponent,
LoginComponent,
LoginWithDeviceComponent,
OptionsComponent,
GeneratorComponent,
PasswordGeneratorHistoryComponent,

View File

@ -622,3 +622,35 @@ main {
position: relative;
}
}
.login-with-device {
.fingerprint-phrase-header {
padding-top: 1rem;
display: block;
}
@include themify($themes) {
.fingerprint-text {
color: themed("codeColor");
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
monospace;
padding: 1rem 0;
}
}
.resend-notification {
padding-bottom: 1rem;
a {
cursor: pointer;
}
}
.footer {
padding-top: 1rem;
a {
padding-top: 1rem;
display: block;
}
}
}

View File

@ -122,6 +122,7 @@ $themes: (
// light has no hover so use same color
webkitCalendarPickerHoverFilter: invert(46%) sepia(69%) saturate(6397%) hue-rotate(211deg)
brightness(85%) contrast(103%),
codeColor: #e83e8c,
),
dark: (
textColor: #ffffff,
@ -183,6 +184,7 @@ $themes: (
hue-rotate(184deg) brightness(87%) contrast(93%),
webkitCalendarPickerHoverFilter: brightness(0) saturate(100%) invert(100%) sepia(0%)
saturate(0%) hue-rotate(93deg) brightness(103%) contrast(103%),
codeColor: #e83e8c,
),
nord: (
textColor: $nord5,
@ -244,6 +246,7 @@ $themes: (
// has no hover so use same color
webkitCalendarPickerHoverFilter: brightness(0) saturate(100%) invert(94%) sepia(5%)
saturate(454%) hue-rotate(185deg) brightness(93%) contrast(96%),
codeColor: #e83e8c,
),
solarizedDark: (
textColor: $solarizedDarkBase2,
@ -304,6 +307,7 @@ $themes: (
hue-rotate(138deg) brightness(92%) contrast(90%),
webkitCalendarPickerHoverFilter: brightness(0) saturate(100%) invert(94%) sepia(10%)
saturate(462%) hue-rotate(345deg) brightness(103%) contrast(87%),
codeColor: #e83e8c,
),
);

View File

@ -286,7 +286,8 @@ export class Main {
this.environmentService,
this.stateService,
this.twoFactorService,
this.i18nService
this.i18nService,
this.encryptService
);
const lockedCallback = async () =>

View File

@ -0,0 +1,43 @@
<div class="modal fade" role="dialog" aria-modal="true" aria-labelledby="loginApprovalTitle">
<div class="modal-dialog modal-md" role="document">
<div id="login-approval-page" class="modal-content">
<div class="section-title">
<p style="text-transform: uppercase">{{ "areYouTryingtoLogin" | i18n }}</p>
</div>
<div class="content">
<div class="section">
<h4>{{ "logInAttemptBy" | i18n: email }}</h4>
</div>
<div class="section">
<h4 class="label">{{ "fingerprintPhraseHeader" | i18n }}</h4>
<code>{{ authRequestResponse?.requestFingerprint }}</code>
</div>
<div class="section">
<h4 class="label">{{ "deviceType" | i18n }}</h4>
<p>{{ authRequestResponse?.requestDeviceType }}</p>
</div>
<div class="section">
<h4 class="label">{{ "ipAddress" | i18n }}</h4>
<p>{{ authRequestResponse?.requestIpAddress }}</p>
</div>
<div class="section">
<h4 class="label">{{ "time" | i18n }}</h4>
<p>{{ requestTimeText }}</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="primary" (click)="approveLogin(true, true)">
{{ "confirmLogIn" | i18n }}
</button>
<button type="button" (click)="approveLogin(false, true)">
{{ "denyLogIn" | i18n }}
</button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,170 @@
import { Component, OnInit, OnDestroy } from "@angular/core";
import { ipcRenderer } from "electron";
import { Subject } from "rxjs";
import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref";
import { ModalConfig } from "@bitwarden/angular/services/modal.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AppIdService } from "@bitwarden/common/abstractions/appId.service";
import { AuthService } from "@bitwarden/common/abstractions/auth.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { StateService } from "@bitwarden/common/abstractions/state.service";
import { AuthRequestResponse } from "@bitwarden/common/models/response/auth-request.response";
const RequestTimeOut = 60000 * 15; //15 Minutes
const RequestTimeUpdate = 60000 * 5; //5 Minutes
@Component({
selector: "login-approval",
templateUrl: "login-approval.component.html",
})
export class LoginApprovalComponent implements OnInit, OnDestroy {
notificationId: string;
private destroy$ = new Subject<void>();
email: string;
authRequestResponse: AuthRequestResponse;
interval: NodeJS.Timer;
requestTimeText: string;
dismissModal: boolean;
constructor(
protected stateService: StateService,
protected platformUtilsService: PlatformUtilsService,
protected i18nService: I18nService,
protected apiService: ApiService,
protected authService: AuthService,
protected appIdService: AppIdService,
private modalRef: ModalRef,
config: ModalConfig
) {
this.notificationId = config.data.notificationId;
this.dismissModal = true;
this.modalRef.onClosed
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
.subscribe(() => {
if (this.dismissModal) {
this.approveLogin(false, false);
}
});
}
ngOnDestroy(): void {
clearInterval(this.interval);
this.destroy$.next();
this.destroy$.complete();
}
async ngOnInit() {
if (this.notificationId != null) {
this.authRequestResponse = await this.apiService.getAuthRequest(this.notificationId);
this.email = await this.stateService.getEmail();
this.updateTimeText();
this.interval = setInterval(() => {
this.updateTimeText();
}, RequestTimeUpdate);
const isVisible = await ipcRenderer.invoke("windowVisible");
if (!isVisible) {
await ipcRenderer.invoke("loginRequest", {
alertTitle: this.i18nService.t("logInRequested"),
alertBody: this.i18nService.t("confirmLoginAtemptForMail", this.email),
buttonText: this.i18nService.t("close"),
});
}
}
}
async approveLogin(approveLogin: boolean, approveDenyButtonClicked: boolean) {
clearInterval(this.interval);
this.dismissModal = !approveDenyButtonClicked;
if (approveDenyButtonClicked) {
this.modalRef.close();
}
this.authRequestResponse = await this.apiService.getAuthRequest(this.notificationId);
if (this.authRequestResponse.requestApproved || this.authRequestResponse.responseDate != null) {
this.platformUtilsService.showToast(
"info",
null,
this.i18nService.t("thisRequestIsNoLongerValid")
);
} else {
const loginResponse = await this.authService.passwordlessLogin(
this.authRequestResponse.id,
this.authRequestResponse.publicKey,
approveLogin
);
this.showResultToast(loginResponse);
}
}
showResultToast(loginResponse: AuthRequestResponse) {
if (loginResponse.requestApproved) {
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t(
"logInConfirmedForEmailOnDevice",
this.email,
loginResponse.requestDeviceType
)
);
} else {
this.platformUtilsService.showToast(
"info",
null,
this.i18nService.t("youDeniedALogInAttemptFromAnotherDevice")
);
}
}
updateTimeText() {
const requestDate = new Date(this.authRequestResponse.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()
);
const diffInMinutes = dateNowUTC - requestDateUTC;
if (diffInMinutes <= RequestTimeUpdate) {
this.requestTimeText = this.i18nService.t("justNow");
} else if (diffInMinutes < RequestTimeOut) {
this.requestTimeText = this.i18nService.t(
"requestedXMinutesAgo",
(diffInMinutes / 60000).toFixed()
);
} else {
clearInterval(this.interval);
this.modalRef.close();
this.platformUtilsService.showToast(
"info",
null,
this.i18nService.t("loginRequestHasAlreadyExpired")
);
}
}
}

View File

@ -0,0 +1,52 @@
<div id="login-with-device-page">
<div class="login-header">
<button
type="button"
appStopClick
(click)="settings()"
class="environment-urls-settings-icon"
attr.aria-label="{{ 'settings' | i18n }}"
>
<i class="bwi bwi-cog bwi-lg" aria-hidden="true"></i>
{{ "settings" | i18n }}
</button>
</div>
<div id="content" class="content">
<img class="logo-image" alt="Bitwarden" />
<p class="lead text-center">{{ "logInInitiated" | i18n }}</p>
<div class="box last">
<div class="box-content">
<div class="box-content-row" appBoxRow>
<div class="section">
<p class="section">{{ "notificationSentDevice" | i18n }}</p>
<p>
{{ "fingerprintMatchInfo" | i18n }}
</p>
</div>
<div class="fingerprint section">
<h4>{{ "fingerprintPhraseHeader" | i18n }}</h4>
<code>{{ passwordlessRequest?.fingerprintPhrase }}</code>
</div>
<div class="section" *ngIf="showResendNotification">
<a [routerLink]="[]" disabled="true" (click)="startPasswordlessLogin()">{{
"resendNotification" | i18n
}}</a>
</div>
<div class="sub-options another-method">
<p class="no-margin description-text">
{{ "needAnotherOption" | i18n }}
<a type="button" class="text text-primary" (click)="goToLogin()">
{{ "viewAllLoginOptions" | i18n }}
</a>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<ng-template #environment></ng-template>

View File

@ -0,0 +1,106 @@
import { Component, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core";
import { Router } from "@angular/router";
import { LoginWithDeviceComponent as BaseLoginWithDeviceComponent } from "@bitwarden/angular/components/login-with-device.component";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { AnonymousHubService } from "@bitwarden/common/abstractions/anonymousHub.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AppIdService } from "@bitwarden/common/abstractions/appId.service";
import { AuthService } from "@bitwarden/common/abstractions/auth.service";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service";
import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { LoginService } from "@bitwarden/common/abstractions/login.service";
import { PasswordGenerationService } from "@bitwarden/common/abstractions/passwordGeneration.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { StateService } from "@bitwarden/common/abstractions/state.service";
import { ValidationService } from "@bitwarden/common/abstractions/validation.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { EnvironmentComponent } from "../environment.component";
@Component({
selector: "app-login-with-device",
templateUrl: "login-with-device.component.html",
})
export class LoginWithDeviceComponent
extends BaseLoginWithDeviceComponent
implements OnInit, OnDestroy
{
@ViewChild("environment", { read: ViewContainerRef, static: true })
environmentModal: ViewContainerRef;
showingModal = false;
constructor(
protected router: Router,
cryptoService: CryptoService,
cryptoFunctionService: CryptoFunctionService,
appIdService: AppIdService,
passwordGenerationService: PasswordGenerationService,
apiService: ApiService,
authService: AuthService,
logService: LogService,
environmentService: EnvironmentService,
i18nService: I18nService,
platformUtilsService: PlatformUtilsService,
anonymousHubService: AnonymousHubService,
validationService: ValidationService,
private modalService: ModalService,
syncService: SyncService,
stateService: StateService,
loginService: LoginService
) {
super(
router,
cryptoService,
cryptoFunctionService,
appIdService,
passwordGenerationService,
apiService,
authService,
logService,
environmentService,
i18nService,
platformUtilsService,
anonymousHubService,
validationService,
stateService,
loginService
);
super.onSuccessfulLogin = () => {
return syncService.fullSync(true);
};
}
async settings() {
const [modal, childComponent] = await this.modalService.openViewRef(
EnvironmentComponent,
this.environmentModal
);
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
modal.onShown.subscribe(() => {
this.showingModal = true;
});
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
modal.onClosed.subscribe(() => {
this.showingModal = false;
});
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
childComponent.onSaved.subscribe(() => {
modal.close();
});
}
ngOnDestroy(): void {
super.ngOnDestroy();
}
goToLogin() {
this.router.navigate(["/login"]);
}
}

View File

@ -126,6 +126,12 @@
<i class="bwi bwi-spinner bwi-spin" [hidden]="!form.loading" aria-hidden="true"></i>
</button>
</div>
<div class="buttons-row" *ngIf="showLoginWithDevice">
<button type="button" class="btn block" (click)="startPasswordlessLogin()">
<i class="bwi bwi-mobile" aria-hidden="true"></i>
{{ "logInWithAnotherDevice" | i18n }}
</button>
</div>
<div class="buttons-row">
<button
type="button"

View File

@ -20,7 +20,7 @@ import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUti
import { StateService } from "@bitwarden/common/abstractions/state.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { EnvironmentComponent } from "./environment.component";
import { EnvironmentComponent } from "../environment.component";
const BroadcasterSubscriptionId = "LoginComponent";
@ -93,6 +93,7 @@ export class LoginComponent extends BaseLoginComponent implements OnDestroy {
async ngOnInit() {
await super.ngOnInit();
await this.checkSelfHosted();
this.broadcasterService.subscribe(BroadcasterSubscriptionId, async (message: any) => {
this.ngZone.run(() => {
switch (message.command) {
@ -136,9 +137,10 @@ export class LoginComponent extends BaseLoginComponent implements OnDestroy {
this.showingModal = false;
});
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
childComponent.onSaved.subscribe(() => {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
childComponent.onSaved.subscribe(async () => {
modal.close();
await this.checkSelfHosted();
});
}
@ -175,4 +177,10 @@ export class LoginComponent extends BaseLoginComponent implements OnDestroy {
const email = this.loggedEmail;
document.getElementById(email == null || email === "" ? "email" : "masterPassword").focus();
}
private async checkSelfHosted() {
this.selfHosted = this.environmentService.isSelfHosted();
await this.getLoginWithDevice(this.loggedEmail);
}
}

View File

@ -0,0 +1,14 @@
import { NgModule } from "@angular/core";
import { RouterModule } from "@angular/router";
import { SharedModule } from "../../shared/shared.module";
import { LoginWithDeviceComponent } from "./login-with-device.component";
import { LoginComponent } from "./login.component";
@NgModule({
imports: [SharedModule, RouterModule],
declarations: [LoginComponent, LoginWithDeviceComponent],
exports: [LoginComponent, LoginWithDeviceComponent],
})
export class LoginModule {}

View File

@ -114,6 +114,21 @@
</label>
</div>
</div>
<div class="form-group">
<div class="checkbox">
<label for="approveLoginRequests">
<input
id="approveLoginRequests"
type="checkbox"
name="approveLoginRequests"
[(ngModel)]="approveLoginRequests"
(change)="updateApproveLoginRequests()"
/>
{{ "approveLoginRequests" | i18n }}
</label>
</div>
<small class="help-block">{{ "approveLoginRequestDesc" | i18n }}</small>
</div>
</ng-container>
</div>
</div>

View File

@ -54,6 +54,7 @@ export class SettingsComponent implements OnInit {
openAtLogin: boolean;
requireEnableTray = false;
showDuckDuckGoIntegrationOption = false;
approveLoginRequests = false;
enableTrayText: string;
enableTrayDescText: string;
@ -190,6 +191,7 @@ export class SettingsComponent implements OnInit {
const pinSet = await this.vaultTimeoutSettingsService.isPinLockSet();
this.pin = pinSet[0] || pinSet[1];
this.approveLoginRequests = await this.stateService.getApproveLoginRequests();
// Account preferences
this.enableFavicons = !(await this.stateService.getDisableFavicon());
@ -461,4 +463,8 @@ export class SettingsComponent implements OnInit {
this.enableBrowserIntegrationFingerprint
);
}
async updateApproveLoginRequests() {
await this.stateService.setApproveLoginRequests(this.approveLoginRequests);
}
}

View File

@ -9,7 +9,8 @@ import { VaultComponent } from "../vault/app/vault/vault.component";
import { AccessibilityCookieComponent } from "./accounts/accessibility-cookie.component";
import { HintComponent } from "./accounts/hint.component";
import { LockComponent } from "./accounts/lock.component";
import { LoginComponent } from "./accounts/login.component";
import { LoginWithDeviceComponent } from "./accounts/login/login-with-device.component";
import { LoginComponent } from "./accounts/login/login.component";
import { RegisterComponent } from "./accounts/register.component";
import { RemovePasswordComponent } from "./accounts/remove-password.component";
import { SetPasswordComponent } from "./accounts/set-password.component";
@ -31,6 +32,10 @@ const routes: Routes = [
component: LoginComponent,
canActivate: [LoginGuard],
},
{
path: "login-with-device",
component: LoginWithDeviceComponent,
},
{ path: "2fa", component: TwoFactorComponent },
{ path: "register", component: RegisterComponent },
{

View File

@ -45,6 +45,7 @@ import { PremiumComponent } from "../vault/app/accounts/premium.component";
import { FolderAddEditComponent } from "../vault/app/vault/folder-add-edit.component";
import { DeleteAccountComponent } from "./accounts/delete-account.component";
import { LoginApprovalComponent } from "./accounts/login/login-approval.component";
import { SettingsComponent } from "./accounts/settings.component";
import { ExportComponent } from "./vault/export.component";
import { GeneratorComponent } from "./vault/generator.component";
@ -70,6 +71,7 @@ const systemTimeoutOptions = {
<ng-template #appFolderAddEdit></ng-template>
<ng-template #exportVault></ng-template>
<ng-template #appGenerator></ng-template>
<ng-template #loginApproval></ng-template>
<app-header></app-header>
<div id="container">
<div class="loading" *ngIf="loading">
@ -90,6 +92,8 @@ export class AppComponent implements OnInit, OnDestroy {
folderAddEditModalRef: ViewContainerRef;
@ViewChild("appGenerator", { read: ViewContainerRef, static: true })
generatorModalRef: ViewContainerRef;
@ViewChild("loginApproval", { read: ViewContainerRef, static: true })
loginApprovalModalRef: ViewContainerRef;
loading = false;
@ -359,6 +363,11 @@ export class AppComponent implements OnInit, OnDestroy {
case "systemIdle":
await this.checkForSystemTimeout(systemTimeoutOptions.onIdle);
break;
case "openLoginApproval":
if (message.notificationId != null) {
await this.openLoginApproval(message.notificationId);
}
break;
}
});
});
@ -427,6 +436,19 @@ export class AppComponent implements OnInit, OnDestroy {
});
}
async openLoginApproval(notificationId: string) {
this.modalService.closeAll();
this.modal = await this.modalService.open(LoginApprovalComponent, {
data: { notificationId: notificationId },
});
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
this.modal.onClosed.subscribe(() => {
this.modal = null;
});
}
private async updateAppMenu() {
let updateRequest: MenuUpdateRequest;
const stateAccounts = await firstValueFrom(this.stateService.accounts$);

View File

@ -27,7 +27,8 @@ import { DeleteAccountComponent } from "./accounts/delete-account.component";
import { EnvironmentComponent } from "./accounts/environment.component";
import { HintComponent } from "./accounts/hint.component";
import { LockComponent } from "./accounts/lock.component";
import { LoginComponent } from "./accounts/login.component";
import { LoginApprovalComponent } from "./accounts/login/login-approval.component";
import { LoginModule } from "./accounts/login/login.module";
import { RegisterComponent } from "./accounts/register.component";
import { RemovePasswordComponent } from "./accounts/remove-password.component";
import { SetPasswordComponent } from "./accounts/set-password.component";
@ -55,7 +56,7 @@ import { GeneratorComponent } from "./vault/generator.component";
import { PasswordGeneratorHistoryComponent } from "./vault/password-generator-history.component";
@NgModule({
imports: [SharedModule, AppRoutingModule, VaultFilterModule],
imports: [SharedModule, AppRoutingModule, VaultFilterModule, LoginModule],
declarations: [
AccessibilityCookieComponent,
AccountSwitcherComponent,
@ -74,7 +75,6 @@ import { PasswordGeneratorHistoryComponent } from "./vault/password-generator-hi
HeaderComponent,
HintComponent,
LockComponent,
LoginComponent,
NavComponent,
GeneratorComponent,
PasswordGeneratorHistoryComponent,
@ -100,6 +100,7 @@ import { PasswordGeneratorHistoryComponent } from "./vault/password-generator-hi
VaultTimeoutInputComponent,
ViewComponent,
ViewCustomFieldsComponent,
LoginApprovalComponent,
],
bootstrap: [AppComponent],
})

View File

@ -2022,8 +2022,8 @@
"organizationIsDisabled": {
"message": "Organization suspended"
},
"disabledOrganizationFilterError" : {
"message" : "Items in suspended organizations cannot be accessed. Contact your organization owner for assistance."
"disabledOrganizationFilterError": {
"message": "Items in suspended organizations cannot be accessed. Contact your organization owner for assistance."
},
"neverLockWarning": {
"message": "Are you sure you want to use the \"Never\" option? Setting your lock options to \"Never\" stores your vault's encryption key on your device. If you use this option you should ensure that you keep your device properly protected."
@ -2061,10 +2061,110 @@
"logInWithAnotherDevice": {
"message": "Log in with another device"
},
"logInInitiated": {
"message": "Log in initiated"
},
"notificationSentDevice": {
"message": "A notification has been sent to your device."
},
"fingerprintMatchInfo": {
"message": "Please make sure your vault is unlocked and Fingerprint phrase matches the other device."
},
"fingerprintPhraseHeader": {
"message": "Fingerprint phrase"
},
"needAnotherOption": {
"message": "Log in with device must be set up in the settings of the Bitwarden app. Need another option?"
},
"viewAllLoginOptions": {
"message": "View all login options"
},
"resendNotification": {
"message": "Resend notification"
},
"toggleCharacterCount": {
"message": "Toggle character count",
"description": "'Character count' describes a feature that displays a number next to each character of the password."
},
"areYouTryingtoLogin": {
"message": "Are you trying to log in?"
},
"logInAttemptBy": {
"message": "Login attempt by $EMAIL$",
"placeholders": {
"email": {
"content": "$1",
"example": "name@example.com"
}
}
},
"deviceType": {
"message": "Device Type"
},
"ipAddress": {
"message": "IP Address"
},
"time": {
"message": "Time"
},
"confirmLogIn": {
"message": "Confirm login"
},
"denyLogIn": {
"message": "Deny login"
},
"approveLoginRequests": {
"message": "Approve login requests"
},
"logInConfirmedForEmailOnDevice": {
"message": "Login confirmed for $EMAIL$ on $DEVICE$",
"placeholders": {
"email": {
"content": "$1",
"example": "name@example.com"
},
"device": {
"content": "$2",
"example": "iOS"
}
}
},
"youDeniedALogInAttemptFromAnotherDevice": {
"message": "You denied a login attempt from another device. If this really was you, try to log in with the device again."
},
"justNow": {
"message": "Just now"
},
"requestedXMinutesAgo": {
"message": "Requested $MINUTES$ minutes ago",
"placeholders": {
"minutes": {
"content": "$1",
"example": "5"
}
}
},
"loginRequestHasAlreadyExpired": {
"message": "Login request has already expired."
},
"thisRequestIsNoLongerValid": {
"message": "This request is no longer valid."
},
"approveLoginRequestDesc": {
"message": "Use this device to approve login requests made from other devices."
},
"confirmLoginAtemptForMail": {
"message": "Confirm login attempt for $EMAIL$",
"placeholders": {
"email": {
"content": "$1",
"example": "name@example.com"
}
}
},
"logInRequested": {
"message": "Log in requested"
},
"exposedMasterPassword": {
"message": "Exposed Master Password"
},

View File

@ -1,6 +1,7 @@
@import "variables.scss";
#login-page,
#login-with-device-page,
#lock-page,
#sso-page,
#set-password-page,
@ -187,7 +188,8 @@
}
}
#login-page {
#login-page,
#login-with-device-page {
flex-direction: column;
justify-content: unset;
padding-top: 20px;
@ -216,3 +218,57 @@
}
}
}
#login-with-device-page {
.content {
display: block;
width: 350px !important;
.fingerprint {
margin: auto;
width: 315px;
.fingerpint-header {
padding-left: 15px;
}
}
.section {
margin-bottom: 30px;
}
.another-method {
display: flex;
margin: auto;
.description-text {
padding-right: 5px;
}
}
code {
@include themify($themes) {
color: themed("codeColor");
}
}
}
}
#login-approval-page {
.section-title {
padding: 20px;
}
.content {
padding: 16px;
.section {
margin-bottom: 30px;
code {
@include themify($themes) {
color: themed("codeColor");
}
}
h4.label {
font-weight: bold;
}
}
}
}

View File

@ -40,6 +40,8 @@ $button-color: lighten($text-color, 40%);
$button-color-primary: darken($brand-primary, 8%);
$button-color-danger: darken($brand-danger, 10%);
$code-color: #e83e8c;
$themes: (
light: (
textColor: $text-color,
@ -95,6 +97,7 @@ $themes: (
accountSwitcherTextColor: #ffffff,
svgSuffix: "-light.svg",
hrColor: #eeeeee,
codeColor: $code-color,
),
dark: (
textColor: #ffffff,
@ -150,6 +153,7 @@ $themes: (
accountSwitcherTextColor: #ffffff,
svgSuffix: "-dark.svg",
hrColor: #a3a3a3,
codeColor: $code-color,
),
nord: (
textColor: $nord5,
@ -205,6 +209,7 @@ $themes: (
accountSwitcherTextColor: $nord5,
svgSuffix: "-dark.svg",
hrColor: $nord4,
codeColor: $code-color,
),
);

View File

@ -1,4 +1,6 @@
import { app, dialog, ipcMain, Menu, MenuItem, nativeTheme, session } from "electron";
import * as path from "path";
import { app, dialog, ipcMain, Menu, MenuItem, nativeTheme, session, Notification } from "electron";
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
import { ThemeType } from "@bitwarden/common/enums/themeType";
@ -51,6 +53,21 @@ export class ElectronMainMessagingService implements MessagingService {
return await session.defaultSession.cookies.get(options);
});
ipcMain.handle("loginRequest", async (event, options) => {
const alert = new Notification({
title: options.alertTitle,
body: options.alertBody,
closeButtonText: options.buttonText,
icon: path.join(__dirname, "images/icon.png"),
});
alert.addListener("click", () => {
this.windowMain.win.show();
});
alert.show();
});
nativeTheme.on("updated", () => {
windowMain.win?.webContents.send(
"systemThemeUpdated",

View File

@ -13,6 +13,7 @@ import { first } from "rxjs/operators";
import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
@ -98,7 +99,8 @@ export class VaultComponent implements OnInit, OnDestroy {
private totpService: TotpService,
private passwordRepromptService: PasswordRepromptService,
private stateService: StateService,
private searchBarService: SearchBarService
private searchBarService: SearchBarService,
private apiService: ApiService
) {}
async ngOnInit() {
@ -207,6 +209,16 @@ export class VaultComponent implements OnInit, OnDestroy {
this.searchBarService.setEnabled(true);
this.searchBarService.setPlaceholderText(this.i18nService.t("searchVault"));
const approveLoginRequests = await this.stateService.getApproveLoginRequests();
if (approveLoginRequests) {
const authRequest = await this.apiService.getLastAuthRequest();
if (authRequest != null) {
this.messagingService.send("openLoginApproval", {
notificationId: authRequest.id,
});
}
}
}
ngOnDestroy() {

View File

@ -1,8 +1,7 @@
import { Component, OnDestroy, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { Subject, takeUntil } from "rxjs";
import { CaptchaProtectedComponent } from "@bitwarden/angular/components/captchaProtected.component";
import { LoginWithDeviceComponent as BaseLoginWithDeviceComponent } from "@bitwarden/angular/components/login-with-device.component";
import { AnonymousHubService } from "@bitwarden/common/abstractions/anonymousHub.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AppIdService } from "@bitwarden/common/abstractions/appId.service";
@ -16,13 +15,6 @@ import { LoginService } from "@bitwarden/common/abstractions/login.service";
import { PasswordGenerationService } from "@bitwarden/common/abstractions/passwordGeneration.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { ValidationService } from "@bitwarden/common/abstractions/validation.service";
import { AuthRequestType } from "@bitwarden/common/enums/authRequestType";
import { Utils } from "@bitwarden/common/misc/utils";
import { PasswordlessLogInCredentials } from "@bitwarden/common/models/domain/log-in-credentials";
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key";
import { PasswordlessCreateAuthRequest } from "@bitwarden/common/models/request/passwordless-create-auth.request";
import { AuthRequestResponse } from "@bitwarden/common/models/response/auth-request.response";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { StateService } from "../../core/state/state.service";
@ -31,175 +23,42 @@ import { StateService } from "../../core/state/state.service";
templateUrl: "login-with-device.component.html",
})
export class LoginWithDeviceComponent
extends CaptchaProtectedComponent
extends BaseLoginWithDeviceComponent
implements OnInit, OnDestroy
{
private destroy$ = new Subject<void>();
email: string;
showResendNotification = false;
passwordlessRequest: PasswordlessCreateAuthRequest;
onSuccessfulLoginTwoFactorNavigate: () => Promise<any>;
onSuccessfulLogin: () => Promise<any>;
onSuccessfulLoginNavigate: () => Promise<any>;
onSuccessfulLoginForceResetNavigate: () => Promise<any>;
protected twoFactorRoute = "2fa";
protected successRoute = "vault";
protected forcePasswordResetRoute = "update-temp-password";
private authRequestKeyPair: [publicKey: ArrayBuffer, privateKey: ArrayBuffer];
constructor(
private router: Router,
private cryptoService: CryptoService,
private cryptoFunctionService: CryptoFunctionService,
private appIdService: AppIdService,
private passwordGenerationService: PasswordGenerationService,
private apiService: ApiService,
private authService: AuthService,
private logService: LogService,
router: Router,
cryptoService: CryptoService,
cryptoFunctionService: CryptoFunctionService,
appIdService: AppIdService,
passwordGenerationService: PasswordGenerationService,
apiService: ApiService,
authService: AuthService,
logService: LogService,
environmentService: EnvironmentService,
i18nService: I18nService,
platformUtilsService: PlatformUtilsService,
private anonymousHubService: AnonymousHubService,
private validationService: ValidationService,
private stateService: StateService,
private loginService: LoginService
anonymousHubService: AnonymousHubService,
validationService: ValidationService,
stateService: StateService,
loginService: LoginService
) {
super(environmentService, i18nService, platformUtilsService);
const navigation = this.router.getCurrentNavigation();
if (navigation) {
this.email = this.loginService.getEmail();
}
//gets signalR push notification
this.authService
.getPushNotifcationObs$()
.pipe(takeUntil(this.destroy$))
.subscribe((id) => {
this.confirmResponse(id);
});
}
async ngOnInit() {
if (!this.email) {
this.router.navigate(["/login"]);
return;
}
this.startPasswordlessLogin();
}
async startPasswordlessLogin() {
this.showResendNotification = false;
try {
await this.buildAuthRequest();
const reqResponse = await this.apiService.postAuthRequest(this.passwordlessRequest);
if (reqResponse.id) {
this.anonymousHubService.createHubConnection(reqResponse.id);
}
} catch (e) {
this.logService.error(e);
}
setTimeout(() => {
this.showResendNotification = true;
}, 12000);
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
this.anonymousHubService.stopHubConnection();
}
private async confirmResponse(requestId: string) {
try {
const response = await this.apiService.getAuthResponse(
requestId,
this.passwordlessRequest.accessCode
);
if (!response.requestApproved) {
return;
}
const credentials = await this.buildLoginCredntials(requestId, response);
const loginResponse = await this.authService.logIn(credentials);
if (loginResponse.requiresTwoFactor) {
if (this.onSuccessfulLoginTwoFactorNavigate != null) {
this.onSuccessfulLoginTwoFactorNavigate();
} else {
this.router.navigate([this.twoFactorRoute]);
}
} else if (loginResponse.forcePasswordReset) {
if (this.onSuccessfulLoginForceResetNavigate != null) {
this.onSuccessfulLoginForceResetNavigate();
} else {
this.router.navigate([this.forcePasswordResetRoute]);
}
} else {
await this.loginService.saveEmailSettings();
if (this.onSuccessfulLogin != null) {
this.onSuccessfulLogin();
}
if (this.onSuccessfulLoginNavigate != null) {
this.onSuccessfulLoginNavigate();
} else {
this.router.navigate([this.successRoute]);
}
}
} catch (error) {
if (error instanceof ErrorResponse) {
this.router.navigate(["/login"]);
this.validationService.showError(error);
return;
}
this.logService.error(error);
}
}
private async buildAuthRequest() {
this.authRequestKeyPair = await this.cryptoFunctionService.rsaGenerateKeyPair(2048);
const fingerprint = await (
await this.cryptoService.getFingerprint(this.email, this.authRequestKeyPair[0])
).join("-");
const deviceIdentifier = await this.appIdService.getAppId();
const publicKey = Utils.fromBufferToB64(this.authRequestKeyPair[0]);
const accessCode = await this.passwordGenerationService.generatePassword({ length: 25 });
this.passwordlessRequest = new PasswordlessCreateAuthRequest(
this.email,
deviceIdentifier,
publicKey,
AuthRequestType.AuthenticateAndUnlock,
accessCode,
fingerprint
);
}
private async buildLoginCredntials(
requestId: string,
response: AuthRequestResponse
): Promise<PasswordlessLogInCredentials> {
const decKey = await this.cryptoService.rsaDecrypt(response.key, this.authRequestKeyPair[1]);
const decMasterPasswordHash = await this.cryptoService.rsaDecrypt(
response.masterPasswordHash,
this.authRequestKeyPair[1]
);
const key = new SymmetricCryptoKey(decKey);
const localHashedPassword = Utils.fromBufferToUtf8(decMasterPasswordHash);
return new PasswordlessLogInCredentials(
this.email,
this.passwordlessRequest.accessCode,
requestId,
key,
localHashedPassword
super(
router,
cryptoService,
cryptoFunctionService,
appIdService,
passwordGenerationService,
apiService,
authService,
logService,
environmentService,
i18nService,
platformUtilsService,
anonymousHubService,
validationService,
stateService,
loginService
);
}
}

View File

@ -209,18 +209,6 @@ export class LoginComponent extends BaseLoginComponent implements OnInit, OnDest
await super.submit(false);
}
async startPasswordlessLogin() {
this.formGroup.get("masterPassword")?.clearValidators();
this.formGroup.get("masterPassword")?.updateValueAndValidity();
if (!this.formGroup.valid) {
return;
}
this.setFormValues();
this.router.navigate(["/login-with-device"]);
}
private getPasswordStrengthUserInput() {
const email = this.formGroup.value.email;
let userInput: string[] = [];

View File

@ -0,0 +1,210 @@
import { Directive, OnDestroy, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { Subject, takeUntil } from "rxjs";
import { AnonymousHubService } from "@bitwarden/common/abstractions/anonymousHub.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AppIdService } from "@bitwarden/common/abstractions/appId.service";
import { AuthService } from "@bitwarden/common/abstractions/auth.service";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service";
import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { LoginService } from "@bitwarden/common/abstractions/login.service";
import { PasswordGenerationService } from "@bitwarden/common/abstractions/passwordGeneration.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { StateService } from "@bitwarden/common/abstractions/state.service";
import { ValidationService } from "@bitwarden/common/abstractions/validation.service";
import { AuthRequestType } from "@bitwarden/common/enums/authRequestType";
import { Utils } from "@bitwarden/common/misc/utils";
import { PasswordlessLogInCredentials } from "@bitwarden/common/models/domain/log-in-credentials";
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key";
import { PasswordlessCreateAuthRequest } from "@bitwarden/common/models/request/passwordless-create-auth.request";
import { AuthRequestResponse } from "@bitwarden/common/models/response/auth-request.response";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { CaptchaProtectedComponent } from "./captchaProtected.component";
@Directive()
export class LoginWithDeviceComponent
extends CaptchaProtectedComponent
implements OnInit, OnDestroy
{
private destroy$ = new Subject<void>();
email: string;
showResendNotification = false;
passwordlessRequest: PasswordlessCreateAuthRequest;
onSuccessfulLoginTwoFactorNavigate: () => Promise<any>;
onSuccessfulLogin: () => Promise<any>;
onSuccessfulLoginNavigate: () => Promise<any>;
onSuccessfulLoginForceResetNavigate: () => Promise<any>;
protected twoFactorRoute = "2fa";
protected successRoute = "vault";
protected forcePasswordResetRoute = "update-temp-password";
private resendTimeout = 12000;
private authRequestKeyPair: [publicKey: ArrayBuffer, privateKey: ArrayBuffer];
constructor(
protected router: Router,
private cryptoService: CryptoService,
private cryptoFunctionService: CryptoFunctionService,
private appIdService: AppIdService,
private passwordGenerationService: PasswordGenerationService,
private apiService: ApiService,
private authService: AuthService,
private logService: LogService,
environmentService: EnvironmentService,
i18nService: I18nService,
platformUtilsService: PlatformUtilsService,
private anonymousHubService: AnonymousHubService,
private validationService: ValidationService,
private stateService: StateService,
private loginService: LoginService
) {
super(environmentService, i18nService, platformUtilsService);
const navigation = this.router.getCurrentNavigation();
if (navigation) {
this.email = navigation.extras?.state?.email;
}
//gets signalR push notification
this.authService
.getPushNotifcationObs$()
.pipe(takeUntil(this.destroy$))
.subscribe((id) => {
this.confirmResponse(id);
});
}
async ngOnInit() {
if (!this.email) {
this.router.navigate(["/login"]);
return;
}
this.startPasswordlessLogin();
}
async startPasswordlessLogin() {
this.showResendNotification = false;
try {
await this.buildAuthRequest();
const reqResponse = await this.apiService.postAuthRequest(this.passwordlessRequest);
if (reqResponse.id) {
this.anonymousHubService.createHubConnection(reqResponse.id);
}
} catch (e) {
this.logService.error(e);
}
setTimeout(() => {
this.showResendNotification = true;
}, this.resendTimeout);
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
this.anonymousHubService.stopHubConnection();
}
private async confirmResponse(requestId: string) {
try {
const response = await this.apiService.getAuthResponse(
requestId,
this.passwordlessRequest.accessCode
);
if (!response.requestApproved) {
return;
}
const credentials = await this.buildLoginCredntials(requestId, response);
const loginResponse = await this.authService.logIn(credentials);
if (loginResponse.requiresTwoFactor) {
if (this.onSuccessfulLoginTwoFactorNavigate != null) {
this.onSuccessfulLoginTwoFactorNavigate();
} else {
this.router.navigate([this.twoFactorRoute]);
}
} else if (loginResponse.forcePasswordReset) {
if (this.onSuccessfulLoginForceResetNavigate != null) {
this.onSuccessfulLoginForceResetNavigate();
} else {
this.router.navigate([this.forcePasswordResetRoute]);
}
} else {
await this.setRememberEmailValues();
if (this.onSuccessfulLogin != null) {
this.onSuccessfulLogin();
}
if (this.onSuccessfulLoginNavigate != null) {
this.onSuccessfulLoginNavigate();
} else {
this.router.navigate([this.successRoute]);
}
}
} catch (error) {
if (error instanceof ErrorResponse) {
this.router.navigate(["/login"]);
this.validationService.showError(error);
return;
}
this.logService.error(error);
}
}
async setRememberEmailValues() {
const rememberEmail = this.loginService.getRememberEmail();
const rememberedEmail = this.loginService.getEmail();
await this.stateService.setRememberedEmail(rememberEmail ? rememberedEmail : null);
this.loginService.clearValues();
}
private async buildAuthRequest() {
this.authRequestKeyPair = await this.cryptoFunctionService.rsaGenerateKeyPair(2048);
const fingerprint = await (
await this.cryptoService.getFingerprint(this.email, this.authRequestKeyPair[0])
).join("-");
const deviceIdentifier = await this.appIdService.getAppId();
const publicKey = Utils.fromBufferToB64(this.authRequestKeyPair[0]);
const accessCode = await this.passwordGenerationService.generatePassword({ length: 25 });
this.passwordlessRequest = new PasswordlessCreateAuthRequest(
this.email,
deviceIdentifier,
publicKey,
AuthRequestType.AuthenticateAndUnlock,
accessCode,
fingerprint
);
}
private async buildLoginCredntials(
requestId: string,
response: AuthRequestResponse
): Promise<PasswordlessLogInCredentials> {
const decKey = await this.cryptoService.rsaDecrypt(response.key, this.authRequestKeyPair[1]);
const decMasterPasswordHash = await this.cryptoService.rsaDecrypt(
response.masterPasswordHash,
this.authRequestKeyPair[1]
);
const key = new SymmetricCryptoKey(decKey);
const localHashedPassword = Utils.fromBufferToUtf8(decMasterPasswordHash);
return new PasswordlessLogInCredentials(
this.email,
this.passwordlessRequest.accessCode,
requestId,
key,
localHashedPassword
);
}
}

View File

@ -32,7 +32,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit
onSuccessfulLoginNavigate: () => Promise<any>;
onSuccessfulLoginTwoFactorNavigate: () => Promise<any>;
onSuccessfulLoginForceResetNavigate: () => Promise<any>;
private selfHosted = false;
selfHosted = false;
showLoginWithDevice: boolean;
validatedEmail = false;
paramEmailSet = false;
@ -176,6 +176,18 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit
}
}
async startPasswordlessLogin() {
this.formGroup.get("masterPassword")?.clearValidators();
this.formGroup.get("masterPassword")?.updateValueAndValidity();
if (!this.formGroup.valid) {
return;
}
const email = this.formGroup.get("email").value;
this.router.navigate(["/login-with-device"], { state: { email: email } });
}
async launchSsoBrowser(clientId: string, ssoRedirectUri: string) {
await this.saveEmailSettings();
// Generate necessary sso params
@ -259,7 +271,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit
return `${error.controlName}${name}`;
}
private async getLoginWithDevice(email: string) {
async getLoginWithDevice(email: string) {
try {
const deviceIdentifier = await this.appIdService.getAppId();
const res = await this.apiService.getKnownDevice(email, deviceIdentifier);

View File

@ -455,6 +455,7 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
LogService,
StateServiceAbstraction,
AuthServiceAbstraction,
MessagingServiceAbstraction,
],
},
{

View File

@ -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>;

View File

@ -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>;
}

View File

@ -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;
}

View File

@ -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>;

View File

@ -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

View File

@ -235,6 +235,7 @@ export class AccountSettings {
vaultTimeout?: number;
vaultTimeoutAction?: string = "lock";
serverConfig?: ServerConfigData;
approveLoginRequests?: boolean;
avatarColor?: string;
static fromJSON(obj: Jsonify<AccountSettings>): AccountSettings {

View File

@ -0,0 +1,8 @@
export class PasswordlessAuthRequest {
constructor(
readonly key: string,
readonly masterPasswordHash: string,
readonly deviceIdentifier: string,
readonly requestApproved: boolean
) {}
}

View File

@ -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;
}
}

View File

@ -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> {

View File

@ -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

View File

@ -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());
}
}

View File

@ -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;
}

View File

@ -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;
}