1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-13 14:53:33 +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:
Robyn MacCallum
2023-02-05 10:57:21 -05:00
committed by GitHub
parent dcc7846138
commit 8a9e59094a
50 changed files with 1281 additions and 211 deletions

View File

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

View File

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

View File

@@ -2050,6 +2050,30 @@
"rememberEmail": { "rememberEmail": {
"message": "Remember email" "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": { "exposedMasterPassword": {
"message": "Exposed Master Password" "message": "Exposed Master Password"
}, },

View File

@@ -374,7 +374,8 @@ export default class MainBackground {
this.environmentService, this.environmentService,
this.stateService, this.stateService,
this.twoFactorService, this.twoFactorService,
this.i18nService this.i18nService,
this.encryptService
); );
this.vaultTimeoutSettingsService = new VaultTimeoutSettingsService( this.vaultTimeoutSettingsService = new VaultTimeoutSettingsService(
@@ -460,7 +461,8 @@ export default class MainBackground {
logoutCallback, logoutCallback,
this.logService, this.logService,
this.stateService, this.stateService,
this.authService this.authService,
this.messagingService
); );
this.popupUtilsService = new PopupUtilsService(isPrivateMode); 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 { apiServiceFactory, ApiServiceInitOptions } from "./api-service.factory";
import { appIdServiceFactory } from "./app-id-service.factory"; import { appIdServiceFactory } from "./app-id-service.factory";
import { cryptoServiceFactory, CryptoServiceInitOptions } from "./crypto-service.factory"; import { cryptoServiceFactory, CryptoServiceInitOptions } from "./crypto-service.factory";
import { EncryptServiceInitOptions, encryptServiceFactory } from "./encrypt-service.factory";
import { import {
environmentServiceFactory, environmentServiceFactory,
EnvironmentServiceInitOptions, EnvironmentServiceInitOptions,
@@ -37,7 +38,8 @@ export type AuthServiceInitOptions = AuthServiceFactoyOptions &
EnvironmentServiceInitOptions & EnvironmentServiceInitOptions &
StateServiceInitOptions & StateServiceInitOptions &
TwoFactorServiceInitOptions & TwoFactorServiceInitOptions &
I18nServiceInitOptions; I18nServiceInitOptions &
EncryptServiceInitOptions;
export function authServiceFactory( export function authServiceFactory(
cache: { authService?: AbstractAuthService } & CachedServices, cache: { authService?: AbstractAuthService } & CachedServices,
@@ -60,7 +62,8 @@ export function authServiceFactory(
await environmentServiceFactory(cache, opts), await environmentServiceFactory(cache, opts),
await stateServiceFactory(cache, opts), await stateServiceFactory(cache, opts),
await twoFactorServiceFactory(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">
<div class="box-content-row box-content-row-flex" appBoxRow> <div class="box-content-row box-content-row-flex" appBoxRow>
<div class="row-main"> <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> <label for="masterPassword">{{ "masterPass" | i18n }}</label>
<input <input
id="masterPassword" id="masterPassword"
@@ -54,6 +61,11 @@
> >
<i class="bwi bwi-spinner bwi-lg bwi-spin" [hidden]="!form.loading" aria-hidden="true"></i> <i class="bwi bwi-spinner bwi-lg bwi-spin" [hidden]="!form.loading" aria-hidden="true"></i>
</button> </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"> <button type="button" (click)="launchSsoBrowser()" class="btn block">
<i class="bwi bwi-provider" aria-hidden="true"></i> {{ "enterpriseSingleSignOn" | i18n }} <i class="bwi bwi-provider" aria-hidden="true"></i> {{ "enterpriseSingleSignOn" | i18n }}
</button> </button>

View File

@@ -18,11 +18,14 @@ import { StateService } from "@bitwarden/common/abstractions/state.service";
import { Utils } from "@bitwarden/common/misc/utils"; import { Utils } from "@bitwarden/common/misc/utils";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { flagEnabled } from "../../flags";
@Component({ @Component({
selector: "app-login", selector: "app-login",
templateUrl: "login.component.html", templateUrl: "login.component.html",
}) })
export class LoginComponent extends BaseLoginComponent { export class LoginComponent extends BaseLoginComponent {
showPasswordless = false;
constructor( constructor(
apiService: ApiService, apiService: ApiService,
appIdService: AppIdService, appIdService: AppIdService,
@@ -64,6 +67,13 @@ export class LoginComponent extends BaseLoginComponent {
await syncService.fullSync(true); await syncService.fullSync(true);
}; };
super.successRoute = "/tabs/vault"; 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() { settings() {

View File

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

View File

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

View File

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

View File

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

View File

@@ -286,7 +286,8 @@ export class Main {
this.environmentService, this.environmentService,
this.stateService, this.stateService,
this.twoFactorService, this.twoFactorService,
this.i18nService this.i18nService,
this.encryptService
); );
const lockedCallback = async () => 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> <i class="bwi bwi-spinner bwi-spin" [hidden]="!form.loading" aria-hidden="true"></i>
</button> </button>
</div> </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"> <div class="buttons-row">
<button <button
type="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 { StateService } from "@bitwarden/common/abstractions/state.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { EnvironmentComponent } from "./environment.component"; import { EnvironmentComponent } from "../environment.component";
const BroadcasterSubscriptionId = "LoginComponent"; const BroadcasterSubscriptionId = "LoginComponent";
@@ -93,6 +93,7 @@ export class LoginComponent extends BaseLoginComponent implements OnDestroy {
async ngOnInit() { async ngOnInit() {
await super.ngOnInit(); await super.ngOnInit();
await this.checkSelfHosted();
this.broadcasterService.subscribe(BroadcasterSubscriptionId, async (message: any) => { this.broadcasterService.subscribe(BroadcasterSubscriptionId, async (message: any) => {
this.ngZone.run(() => { this.ngZone.run(() => {
switch (message.command) { switch (message.command) {
@@ -136,9 +137,10 @@ export class LoginComponent extends BaseLoginComponent implements OnDestroy {
this.showingModal = false; this.showingModal = false;
}); });
// eslint-disable-next-line rxjs-angular/prefer-takeuntil // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
childComponent.onSaved.subscribe(() => { childComponent.onSaved.subscribe(async () => {
modal.close(); modal.close();
await this.checkSelfHosted();
}); });
} }
@@ -175,4 +177,10 @@ export class LoginComponent extends BaseLoginComponent implements OnDestroy {
const email = this.loggedEmail; const email = this.loggedEmail;
document.getElementById(email == null || email === "" ? "email" : "masterPassword").focus(); 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> </label>
</div> </div>
</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> </ng-container>
</div> </div>
</div> </div>

View File

@@ -54,6 +54,7 @@ export class SettingsComponent implements OnInit {
openAtLogin: boolean; openAtLogin: boolean;
requireEnableTray = false; requireEnableTray = false;
showDuckDuckGoIntegrationOption = false; showDuckDuckGoIntegrationOption = false;
approveLoginRequests = false;
enableTrayText: string; enableTrayText: string;
enableTrayDescText: string; enableTrayDescText: string;
@@ -190,6 +191,7 @@ export class SettingsComponent implements OnInit {
const pinSet = await this.vaultTimeoutSettingsService.isPinLockSet(); const pinSet = await this.vaultTimeoutSettingsService.isPinLockSet();
this.pin = pinSet[0] || pinSet[1]; this.pin = pinSet[0] || pinSet[1];
this.approveLoginRequests = await this.stateService.getApproveLoginRequests();
// Account preferences // Account preferences
this.enableFavicons = !(await this.stateService.getDisableFavicon()); this.enableFavicons = !(await this.stateService.getDisableFavicon());
@@ -461,4 +463,8 @@ export class SettingsComponent implements OnInit {
this.enableBrowserIntegrationFingerprint 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 { AccessibilityCookieComponent } from "./accounts/accessibility-cookie.component";
import { HintComponent } from "./accounts/hint.component"; import { HintComponent } from "./accounts/hint.component";
import { LockComponent } from "./accounts/lock.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 { RegisterComponent } from "./accounts/register.component";
import { RemovePasswordComponent } from "./accounts/remove-password.component"; import { RemovePasswordComponent } from "./accounts/remove-password.component";
import { SetPasswordComponent } from "./accounts/set-password.component"; import { SetPasswordComponent } from "./accounts/set-password.component";
@@ -31,6 +32,10 @@ const routes: Routes = [
component: LoginComponent, component: LoginComponent,
canActivate: [LoginGuard], canActivate: [LoginGuard],
}, },
{
path: "login-with-device",
component: LoginWithDeviceComponent,
},
{ path: "2fa", component: TwoFactorComponent }, { path: "2fa", component: TwoFactorComponent },
{ path: "register", component: RegisterComponent }, { 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 { FolderAddEditComponent } from "../vault/app/vault/folder-add-edit.component";
import { DeleteAccountComponent } from "./accounts/delete-account.component"; import { DeleteAccountComponent } from "./accounts/delete-account.component";
import { LoginApprovalComponent } from "./accounts/login/login-approval.component";
import { SettingsComponent } from "./accounts/settings.component"; import { SettingsComponent } from "./accounts/settings.component";
import { ExportComponent } from "./vault/export.component"; import { ExportComponent } from "./vault/export.component";
import { GeneratorComponent } from "./vault/generator.component"; import { GeneratorComponent } from "./vault/generator.component";
@@ -70,6 +71,7 @@ const systemTimeoutOptions = {
<ng-template #appFolderAddEdit></ng-template> <ng-template #appFolderAddEdit></ng-template>
<ng-template #exportVault></ng-template> <ng-template #exportVault></ng-template>
<ng-template #appGenerator></ng-template> <ng-template #appGenerator></ng-template>
<ng-template #loginApproval></ng-template>
<app-header></app-header> <app-header></app-header>
<div id="container"> <div id="container">
<div class="loading" *ngIf="loading"> <div class="loading" *ngIf="loading">
@@ -90,6 +92,8 @@ export class AppComponent implements OnInit, OnDestroy {
folderAddEditModalRef: ViewContainerRef; folderAddEditModalRef: ViewContainerRef;
@ViewChild("appGenerator", { read: ViewContainerRef, static: true }) @ViewChild("appGenerator", { read: ViewContainerRef, static: true })
generatorModalRef: ViewContainerRef; generatorModalRef: ViewContainerRef;
@ViewChild("loginApproval", { read: ViewContainerRef, static: true })
loginApprovalModalRef: ViewContainerRef;
loading = false; loading = false;
@@ -359,6 +363,11 @@ export class AppComponent implements OnInit, OnDestroy {
case "systemIdle": case "systemIdle":
await this.checkForSystemTimeout(systemTimeoutOptions.onIdle); await this.checkForSystemTimeout(systemTimeoutOptions.onIdle);
break; 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() { private async updateAppMenu() {
let updateRequest: MenuUpdateRequest; let updateRequest: MenuUpdateRequest;
const stateAccounts = await firstValueFrom(this.stateService.accounts$); 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 { EnvironmentComponent } from "./accounts/environment.component";
import { HintComponent } from "./accounts/hint.component"; import { HintComponent } from "./accounts/hint.component";
import { LockComponent } from "./accounts/lock.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 { RegisterComponent } from "./accounts/register.component";
import { RemovePasswordComponent } from "./accounts/remove-password.component"; import { RemovePasswordComponent } from "./accounts/remove-password.component";
import { SetPasswordComponent } from "./accounts/set-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"; import { PasswordGeneratorHistoryComponent } from "./vault/password-generator-history.component";
@NgModule({ @NgModule({
imports: [SharedModule, AppRoutingModule, VaultFilterModule], imports: [SharedModule, AppRoutingModule, VaultFilterModule, LoginModule],
declarations: [ declarations: [
AccessibilityCookieComponent, AccessibilityCookieComponent,
AccountSwitcherComponent, AccountSwitcherComponent,
@@ -74,7 +75,6 @@ import { PasswordGeneratorHistoryComponent } from "./vault/password-generator-hi
HeaderComponent, HeaderComponent,
HintComponent, HintComponent,
LockComponent, LockComponent,
LoginComponent,
NavComponent, NavComponent,
GeneratorComponent, GeneratorComponent,
PasswordGeneratorHistoryComponent, PasswordGeneratorHistoryComponent,
@@ -100,6 +100,7 @@ import { PasswordGeneratorHistoryComponent } from "./vault/password-generator-hi
VaultTimeoutInputComponent, VaultTimeoutInputComponent,
ViewComponent, ViewComponent,
ViewCustomFieldsComponent, ViewCustomFieldsComponent,
LoginApprovalComponent,
], ],
bootstrap: [AppComponent], bootstrap: [AppComponent],
}) })

View File

@@ -2061,10 +2061,110 @@
"logInWithAnotherDevice": { "logInWithAnotherDevice": {
"message": "Log in with another device" "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": { "toggleCharacterCount": {
"message": "Toggle character count", "message": "Toggle character count",
"description": "'Character count' describes a feature that displays a number next to each character of the password." "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": { "exposedMasterPassword": {
"message": "Exposed Master Password" "message": "Exposed Master Password"
}, },

View File

@@ -1,6 +1,7 @@
@import "variables.scss"; @import "variables.scss";
#login-page, #login-page,
#login-with-device-page,
#lock-page, #lock-page,
#sso-page, #sso-page,
#set-password-page, #set-password-page,
@@ -187,7 +188,8 @@
} }
} }
#login-page { #login-page,
#login-with-device-page {
flex-direction: column; flex-direction: column;
justify-content: unset; justify-content: unset;
padding-top: 20px; 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-primary: darken($brand-primary, 8%);
$button-color-danger: darken($brand-danger, 10%); $button-color-danger: darken($brand-danger, 10%);
$code-color: #e83e8c;
$themes: ( $themes: (
light: ( light: (
textColor: $text-color, textColor: $text-color,
@@ -95,6 +97,7 @@ $themes: (
accountSwitcherTextColor: #ffffff, accountSwitcherTextColor: #ffffff,
svgSuffix: "-light.svg", svgSuffix: "-light.svg",
hrColor: #eeeeee, hrColor: #eeeeee,
codeColor: $code-color,
), ),
dark: ( dark: (
textColor: #ffffff, textColor: #ffffff,
@@ -150,6 +153,7 @@ $themes: (
accountSwitcherTextColor: #ffffff, accountSwitcherTextColor: #ffffff,
svgSuffix: "-dark.svg", svgSuffix: "-dark.svg",
hrColor: #a3a3a3, hrColor: #a3a3a3,
codeColor: $code-color,
), ),
nord: ( nord: (
textColor: $nord5, textColor: $nord5,
@@ -205,6 +209,7 @@ $themes: (
accountSwitcherTextColor: $nord5, accountSwitcherTextColor: $nord5,
svgSuffix: "-dark.svg", svgSuffix: "-dark.svg",
hrColor: $nord4, 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 { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
import { ThemeType } from "@bitwarden/common/enums/themeType"; import { ThemeType } from "@bitwarden/common/enums/themeType";
@@ -51,6 +53,21 @@ export class ElectronMainMessagingService implements MessagingService {
return await session.defaultSession.cookies.get(options); 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", () => { nativeTheme.on("updated", () => {
windowMain.win?.webContents.send( windowMain.win?.webContents.send(
"systemThemeUpdated", "systemThemeUpdated",

View File

@@ -13,6 +13,7 @@ import { first } from "rxjs/operators";
import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref"; import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref";
import { ModalService } from "@bitwarden/angular/services/modal.service"; import { ModalService } from "@bitwarden/angular/services/modal.service";
import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model"; 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 { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
@@ -98,7 +99,8 @@ export class VaultComponent implements OnInit, OnDestroy {
private totpService: TotpService, private totpService: TotpService,
private passwordRepromptService: PasswordRepromptService, private passwordRepromptService: PasswordRepromptService,
private stateService: StateService, private stateService: StateService,
private searchBarService: SearchBarService private searchBarService: SearchBarService,
private apiService: ApiService
) {} ) {}
async ngOnInit() { async ngOnInit() {
@@ -207,6 +209,16 @@ export class VaultComponent implements OnInit, OnDestroy {
this.searchBarService.setEnabled(true); this.searchBarService.setEnabled(true);
this.searchBarService.setPlaceholderText(this.i18nService.t("searchVault")); 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() { ngOnDestroy() {

View File

@@ -1,8 +1,7 @@
import { Component, OnDestroy, OnInit } from "@angular/core"; import { Component, OnDestroy, OnInit } from "@angular/core";
import { Router } from "@angular/router"; 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 { AnonymousHubService } from "@bitwarden/common/abstractions/anonymousHub.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AppIdService } from "@bitwarden/common/abstractions/appId.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 { PasswordGenerationService } from "@bitwarden/common/abstractions/passwordGeneration.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { ValidationService } from "@bitwarden/common/abstractions/validation.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"; import { StateService } from "../../core/state/state.service";
@@ -31,175 +23,42 @@ import { StateService } from "../../core/state/state.service";
templateUrl: "login-with-device.component.html", templateUrl: "login-with-device.component.html",
}) })
export class LoginWithDeviceComponent export class LoginWithDeviceComponent
extends CaptchaProtectedComponent extends BaseLoginWithDeviceComponent
implements OnInit, OnDestroy 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( constructor(
private router: Router, router: Router,
private cryptoService: CryptoService, cryptoService: CryptoService,
private cryptoFunctionService: CryptoFunctionService, cryptoFunctionService: CryptoFunctionService,
private appIdService: AppIdService, appIdService: AppIdService,
private passwordGenerationService: PasswordGenerationService, passwordGenerationService: PasswordGenerationService,
private apiService: ApiService, apiService: ApiService,
private authService: AuthService, authService: AuthService,
private logService: LogService, logService: LogService,
environmentService: EnvironmentService, environmentService: EnvironmentService,
i18nService: I18nService, i18nService: I18nService,
platformUtilsService: PlatformUtilsService, platformUtilsService: PlatformUtilsService,
private anonymousHubService: AnonymousHubService, anonymousHubService: AnonymousHubService,
private validationService: ValidationService, validationService: ValidationService,
private stateService: StateService, stateService: StateService,
private loginService: LoginService loginService: LoginService
) { ) {
super(environmentService, i18nService, platformUtilsService); super(
router,
const navigation = this.router.getCurrentNavigation(); cryptoService,
if (navigation) { cryptoFunctionService,
this.email = this.loginService.getEmail(); appIdService,
} passwordGenerationService,
apiService,
//gets signalR push notification authService,
this.authService logService,
.getPushNotifcationObs$() environmentService,
.pipe(takeUntil(this.destroy$)) i18nService,
.subscribe((id) => { platformUtilsService,
this.confirmResponse(id); anonymousHubService,
}); validationService,
} stateService,
loginService
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
); );
} }
} }

View File

@@ -209,18 +209,6 @@ export class LoginComponent extends BaseLoginComponent implements OnInit, OnDest
await super.submit(false); 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() { private getPasswordStrengthUserInput() {
const email = this.formGroup.value.email; const email = this.formGroup.value.email;
let userInput: string[] = []; 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>; onSuccessfulLoginNavigate: () => Promise<any>;
onSuccessfulLoginTwoFactorNavigate: () => Promise<any>; onSuccessfulLoginTwoFactorNavigate: () => Promise<any>;
onSuccessfulLoginForceResetNavigate: () => Promise<any>; onSuccessfulLoginForceResetNavigate: () => Promise<any>;
private selfHosted = false; selfHosted = false;
showLoginWithDevice: boolean; showLoginWithDevice: boolean;
validatedEmail = false; validatedEmail = false;
paramEmailSet = 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) { async launchSsoBrowser(clientId: string, ssoRedirectUri: string) {
await this.saveEmailSettings(); await this.saveEmailSettings();
// Generate necessary sso params // Generate necessary sso params
@@ -259,7 +271,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit
return `${error.controlName}${name}`; return `${error.controlName}${name}`;
} }
private async getLoginWithDevice(email: string) { async getLoginWithDevice(email: string) {
try { try {
const deviceIdentifier = await this.appIdService.getAppId(); const deviceIdentifier = await this.appIdService.getAppId();
const res = await this.apiService.getKnownDevice(email, deviceIdentifier); const res = await this.apiService.getKnownDevice(email, deviceIdentifier);

View File

@@ -455,6 +455,7 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
LogService, LogService,
StateServiceAbstraction, StateServiceAbstraction,
AuthServiceAbstraction, 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 { OrganizationSponsorshipRedeemRequest } from "../models/request/organization/organization-sponsorship-redeem.request";
import { PasswordHintRequest } from "../models/request/password-hint.request"; import { PasswordHintRequest } from "../models/request/password-hint.request";
import { PasswordRequest } from "../models/request/password.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 { PasswordlessCreateAuthRequest } from "../models/request/passwordless-create-auth.request";
import { PaymentRequest } from "../models/request/payment.request"; import { PaymentRequest } from "../models/request/payment.request";
import { PreloginRequest } from "../models/request/prelogin.request"; import { PreloginRequest } from "../models/request/prelogin.request";
@@ -204,6 +205,10 @@ export abstract class ApiService {
//passwordless //passwordless
postAuthRequest: (request: PasswordlessCreateAuthRequest) => Promise<AuthRequestResponse>; postAuthRequest: (request: PasswordlessCreateAuthRequest) => Promise<AuthRequestResponse>;
getAuthResponse: (id: string, accessCode: string) => 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>; getUserBillingHistory: () => Promise<BillingHistoryResponse>;
getUserBillingPayment: () => Promise<BillingPaymentResponse>; getUserBillingPayment: () => Promise<BillingPaymentResponse>;

View File

@@ -10,6 +10,7 @@ import {
} from "../models/domain/log-in-credentials"; } from "../models/domain/log-in-credentials";
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
import { TokenTwoFactorRequest } from "../models/request/identity-token/token-two-factor.request"; 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"; import { AuthRequestPushNotification } from "../models/response/notification.response";
export abstract class AuthService { export abstract class AuthService {
@@ -37,6 +38,10 @@ export abstract class AuthService {
authingWithPasswordless: () => boolean; authingWithPasswordless: () => boolean;
getAuthStatus: (userId?: string) => Promise<AuthenticationStatus>; getAuthStatus: (userId?: string) => Promise<AuthenticationStatus>;
authResponsePushNotifiction: (notification: AuthRequestPushNotification) => Promise<any>; authResponsePushNotifiction: (notification: AuthRequestPushNotification) => Promise<any>;
passwordlessLogin: (
id: string,
key: string,
requestApproved: boolean
) => Promise<AuthRequestResponse>;
getPushNotifcationObs$: () => Observable<any>; getPushNotifcationObs$: () => Observable<any>;
} }

View File

@@ -34,4 +34,9 @@ export abstract class EnvironmentService {
setUrls: (urls: Urls) => Promise<Urls>; setUrls: (urls: Urls) => Promise<Urls>;
getUrls: () => Urls; getUrls: () => Urls;
isCloud: () => boolean; 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>; setVaultTimeout: (value: number, options?: StorageOptions) => Promise<void>;
getVaultTimeoutAction: (options?: StorageOptions) => Promise<string>; getVaultTimeoutAction: (options?: StorageOptions) => Promise<string>;
setVaultTimeoutAction: (value: string, options?: StorageOptions) => Promise<void>; setVaultTimeoutAction: (value: string, options?: StorageOptions) => Promise<void>;
getApproveLoginRequests: (options?: StorageOptions) => Promise<boolean>;
setApproveLoginRequests: (value: boolean, options?: StorageOptions) => Promise<void>;
getStateVersion: () => Promise<number>; getStateVersion: () => Promise<number>;
setStateVersion: (value: number) => Promise<void>; setStateVersion: (value: number) => Promise<void>;
getWindow: () => Promise<WindowState>; getWindow: () => Promise<WindowState>;

View File

@@ -2,6 +2,7 @@
/* eslint-disable @typescript-eslint/ban-types */ /* eslint-disable @typescript-eslint/ban-types */
export type SharedFlags = { export type SharedFlags = {
multithreadDecryption: boolean; multithreadDecryption: boolean;
showPasswordless?: boolean;
}; };
// required to avoid linting errors when there are no flags // required to avoid linting errors when there are no flags

View File

@@ -235,6 +235,7 @@ export class AccountSettings {
vaultTimeout?: number; vaultTimeout?: number;
vaultTimeoutAction?: string = "lock"; vaultTimeoutAction?: string = "lock";
serverConfig?: ServerConfigData; serverConfig?: ServerConfigData;
approveLoginRequests?: boolean;
avatarColor?: string; avatarColor?: string;
static fromJSON(obj: Jsonify<AccountSettings>): AccountSettings { 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"; import { BaseResponse } from "./base.response";
const RequestTimeOut = 60000 * 15; //15 Minutes
export class AuthRequestResponse extends BaseResponse { export class AuthRequestResponse extends BaseResponse {
id: string; id: string;
publicKey: string; publicKey: string;
@@ -10,7 +12,11 @@ export class AuthRequestResponse extends BaseResponse {
key: string; key: string;
masterPasswordHash: string; masterPasswordHash: string;
creationDate: string; creationDate: string;
requestApproved: boolean; requestApproved?: boolean;
requestFingerprint?: string;
responseDate?: string;
isAnswered: boolean;
isExpired: boolean;
constructor(response: any) { constructor(response: any) {
super(response); super(response);
@@ -22,5 +28,32 @@ export class AuthRequestResponse extends BaseResponse {
this.masterPasswordHash = this.getResponseProperty("MasterPasswordHash"); this.masterPasswordHash = this.getResponseProperty("MasterPasswordHash");
this.creationDate = this.getResponseProperty("CreationDate"); this.creationDate = this.getResponseProperty("CreationDate");
this.requestApproved = this.getResponseProperty("RequestApproved"); 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 { OrganizationSponsorshipRedeemRequest } from "../models/request/organization/organization-sponsorship-redeem.request";
import { PasswordHintRequest } from "../models/request/password-hint.request"; import { PasswordHintRequest } from "../models/request/password-hint.request";
import { PasswordRequest } from "../models/request/password.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 { PasswordlessCreateAuthRequest } from "../models/request/passwordless-create-auth.request";
import { PaymentRequest } from "../models/request/payment.request"; import { PaymentRequest } from "../models/request/payment.request";
import { PreloginRequest } from "../models/request/prelogin.request"; import { PreloginRequest } from "../models/request/prelogin.request";
@@ -266,6 +267,33 @@ export class ApiService implements ApiServiceAbstraction {
return new AuthRequestResponse(r); 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 // Account APIs
async getProfile(): Promise<ProfileResponse> { async getProfile(): Promise<ProfileResponse> {

View File

@@ -4,6 +4,7 @@ import { ApiService } from "../abstractions/api.service";
import { AppIdService } from "../abstractions/appId.service"; import { AppIdService } from "../abstractions/appId.service";
import { AuthService as AuthServiceAbstraction } from "../abstractions/auth.service"; import { AuthService as AuthServiceAbstraction } from "../abstractions/auth.service";
import { CryptoService } from "../abstractions/crypto.service"; import { CryptoService } from "../abstractions/crypto.service";
import { EncryptService } from "../abstractions/encrypt.service";
import { EnvironmentService } from "../abstractions/environment.service"; import { EnvironmentService } from "../abstractions/environment.service";
import { I18nService } from "../abstractions/i18n.service"; import { I18nService } from "../abstractions/i18n.service";
import { KeyConnectorService } from "../abstractions/keyConnector.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 { PasswordlessLogInStrategy } from "../misc/logInStrategies/passwordlessLogin.strategy";
import { SsoLogInStrategy } from "../misc/logInStrategies/ssoLogin.strategy"; import { SsoLogInStrategy } from "../misc/logInStrategies/ssoLogin.strategy";
import { UserApiLogInStrategy } from "../misc/logInStrategies/user-api-login.strategy"; import { UserApiLogInStrategy } from "../misc/logInStrategies/user-api-login.strategy";
import { Utils } from "../misc/utils";
import { AuthResult } from "../models/domain/auth-result"; import { AuthResult } from "../models/domain/auth-result";
import { KdfConfig } from "../models/domain/kdf-config"; import { KdfConfig } from "../models/domain/kdf-config";
import { import {
@@ -31,7 +33,9 @@ import {
} from "../models/domain/log-in-credentials"; } from "../models/domain/log-in-credentials";
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
import { TokenTwoFactorRequest } from "../models/request/identity-token/token-two-factor.request"; 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 { PreloginRequest } from "../models/request/prelogin.request";
import { AuthRequestResponse } from "../models/response/auth-request.response";
import { ErrorResponse } from "../models/response/error.response"; import { ErrorResponse } from "../models/response/error.response";
import { AuthRequestPushNotification } from "../models/response/notification.response"; import { AuthRequestPushNotification } from "../models/response/notification.response";
@@ -88,7 +92,8 @@ export class AuthService implements AuthServiceAbstraction {
protected environmentService: EnvironmentService, protected environmentService: EnvironmentService,
protected stateService: StateService, protected stateService: StateService,
protected twoFactorService: TwoFactorService, protected twoFactorService: TwoFactorService,
protected i18nService: I18nService protected i18nService: I18nService,
protected encryptService: EncryptService
) {} ) {}
async logIn( async logIn(
@@ -275,6 +280,31 @@ export class AuthService implements AuthServiceAbstraction {
return this.pushNotificationSubject.asObservable(); 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( private saveState(
strategy: strategy:
| UserApiLogInStrategy | UserApiLogInStrategy

View File

@@ -213,4 +213,13 @@ export class EnvironmentService implements EnvironmentServiceAbstraction {
this.getApiUrl() 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 { AuthService } from "../abstractions/auth.service";
import { EnvironmentService } from "../abstractions/environment.service"; import { EnvironmentService } from "../abstractions/environment.service";
import { LogService } from "../abstractions/log.service"; import { LogService } from "../abstractions/log.service";
import { MessagingService } from "../abstractions/messaging.service";
import { NotificationsService as NotificationsServiceAbstraction } from "../abstractions/notifications.service"; import { NotificationsService as NotificationsServiceAbstraction } from "../abstractions/notifications.service";
import { StateService } from "../abstractions/state.service"; import { StateService } from "../abstractions/state.service";
import { AuthenticationStatus } from "../enums/authenticationStatus"; import { AuthenticationStatus } from "../enums/authenticationStatus";
@@ -34,7 +35,8 @@ export class NotificationsService implements NotificationsServiceAbstraction {
private logoutCallback: (expired: boolean) => Promise<void>, private logoutCallback: (expired: boolean) => Promise<void>,
private logService: LogService, private logService: LogService,
private stateService: StateService, private stateService: StateService,
private authService: AuthService private authService: AuthService,
private messagingService: MessagingService
) { ) {
this.environmentService.urls.subscribe(() => { this.environmentService.urls.subscribe(() => {
if (!this.inited) { if (!this.inited) {
@@ -183,6 +185,13 @@ export class NotificationsService implements NotificationsServiceAbstraction {
case NotificationType.SyncSendDelete: case NotificationType.SyncSendDelete:
await this.syncService.syncDeleteSend(notification.payload as SyncSendNotification); await this.syncService.syncDeleteSend(notification.payload as SyncSendNotification);
break; break;
case NotificationType.AuthRequest:
if (await this.stateService.getApproveLoginRequests()) {
this.messagingService.send("openLoginApproval", {
notificationId: notification.payload.id,
});
}
break;
default: default:
break; 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> { async getStateVersion(): Promise<number> {
return (await this.getGlobals(await this.defaultOnDiskLocalOptions())).stateVersion ?? 1; return (await this.getGlobals(await this.defaultOnDiskLocalOptions())).stateVersion ?? 1;
} }