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:
parent
dcc7846138
commit
8a9e59094a
@ -1,4 +1,6 @@
|
||||
{
|
||||
"dev_flags": {},
|
||||
"flags": {}
|
||||
"flags": {
|
||||
"showPasswordless": true
|
||||
}
|
||||
}
|
||||
|
@ -5,5 +5,7 @@
|
||||
"base": "https://localhost:8080"
|
||||
}
|
||||
},
|
||||
"flags": {}
|
||||
"flags": {
|
||||
"showPasswordless": true
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
},
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
@ -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);
|
||||
};
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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() {
|
||||
|
@ -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),
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
|
@ -286,7 +286,8 @@ export class Main {
|
||||
this.environmentService,
|
||||
this.stateService,
|
||||
this.twoFactorService,
|
||||
this.i18nService
|
||||
this.i18nService,
|
||||
this.encryptService
|
||||
);
|
||||
|
||||
const lockedCallback = async () =>
|
||||
|
@ -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>
|
170
apps/desktop/src/app/accounts/login/login-approval.component.ts
Normal file
170
apps/desktop/src/app/accounts/login/login-approval.component.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
@ -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"]);
|
||||
}
|
||||
}
|
@ -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"
|
@ -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);
|
||||
}
|
||||
}
|
14
apps/desktop/src/app/accounts/login/login.module.ts
Normal file
14
apps/desktop/src/app/accounts/login/login.module.ts
Normal 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 {}
|
@ -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>
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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 },
|
||||
{
|
||||
|
@ -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$);
|
||||
|
@ -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],
|
||||
})
|
||||
|
@ -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"
|
||||
},
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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() {
|
||||
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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[] = [];
|
||||
|
210
libs/angular/src/components/login-with-device.component.ts
Normal file
210
libs/angular/src/components/login-with-device.component.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
@ -455,6 +455,7 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
|
||||
LogService,
|
||||
StateServiceAbstraction,
|
||||
AuthServiceAbstraction,
|
||||
MessagingServiceAbstraction,
|
||||
],
|
||||
},
|
||||
{
|
||||
|
@ -26,6 +26,7 @@ import { OrganizationSponsorshipCreateRequest } from "../models/request/organiza
|
||||
import { OrganizationSponsorshipRedeemRequest } from "../models/request/organization/organization-sponsorship-redeem.request";
|
||||
import { PasswordHintRequest } from "../models/request/password-hint.request";
|
||||
import { PasswordRequest } from "../models/request/password.request";
|
||||
import { PasswordlessAuthRequest } from "../models/request/passwordless-auth.request";
|
||||
import { PasswordlessCreateAuthRequest } from "../models/request/passwordless-create-auth.request";
|
||||
import { PaymentRequest } from "../models/request/payment.request";
|
||||
import { PreloginRequest } from "../models/request/prelogin.request";
|
||||
@ -204,6 +205,10 @@ export abstract class ApiService {
|
||||
//passwordless
|
||||
postAuthRequest: (request: PasswordlessCreateAuthRequest) => Promise<AuthRequestResponse>;
|
||||
getAuthResponse: (id: string, accessCode: string) => Promise<AuthRequestResponse>;
|
||||
getAuthRequest: (id: string) => Promise<AuthRequestResponse>;
|
||||
putAuthRequest: (id: string, request: PasswordlessAuthRequest) => Promise<AuthRequestResponse>;
|
||||
getAuthRequests: () => Promise<ListResponse<AuthRequestResponse>>;
|
||||
getLastAuthRequest: () => Promise<AuthRequestResponse>;
|
||||
|
||||
getUserBillingHistory: () => Promise<BillingHistoryResponse>;
|
||||
getUserBillingPayment: () => Promise<BillingPaymentResponse>;
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
} from "../models/domain/log-in-credentials";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
import { TokenTwoFactorRequest } from "../models/request/identity-token/token-two-factor.request";
|
||||
import { AuthRequestResponse } from "../models/response/auth-request.response";
|
||||
import { AuthRequestPushNotification } from "../models/response/notification.response";
|
||||
|
||||
export abstract class AuthService {
|
||||
@ -37,6 +38,10 @@ export abstract class AuthService {
|
||||
authingWithPasswordless: () => boolean;
|
||||
getAuthStatus: (userId?: string) => Promise<AuthenticationStatus>;
|
||||
authResponsePushNotifiction: (notification: AuthRequestPushNotification) => Promise<any>;
|
||||
|
||||
passwordlessLogin: (
|
||||
id: string,
|
||||
key: string,
|
||||
requestApproved: boolean
|
||||
) => Promise<AuthRequestResponse>;
|
||||
getPushNotifcationObs$: () => Observable<any>;
|
||||
}
|
||||
|
@ -34,4 +34,9 @@ export abstract class EnvironmentService {
|
||||
setUrls: (urls: Urls) => Promise<Urls>;
|
||||
getUrls: () => Urls;
|
||||
isCloud: () => boolean;
|
||||
/**
|
||||
* @remarks For desktop and browser use only.
|
||||
* For web, use PlatformUtilsService.isSelfHost()
|
||||
*/
|
||||
isSelfHosted: () => boolean;
|
||||
}
|
||||
|
@ -338,6 +338,8 @@ export abstract class StateService<T extends Account = Account> {
|
||||
setVaultTimeout: (value: number, options?: StorageOptions) => Promise<void>;
|
||||
getVaultTimeoutAction: (options?: StorageOptions) => Promise<string>;
|
||||
setVaultTimeoutAction: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getApproveLoginRequests: (options?: StorageOptions) => Promise<boolean>;
|
||||
setApproveLoginRequests: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getStateVersion: () => Promise<number>;
|
||||
setStateVersion: (value: number) => Promise<void>;
|
||||
getWindow: () => Promise<WindowState>;
|
||||
|
@ -2,6 +2,7 @@
|
||||
/* eslint-disable @typescript-eslint/ban-types */
|
||||
export type SharedFlags = {
|
||||
multithreadDecryption: boolean;
|
||||
showPasswordless?: boolean;
|
||||
};
|
||||
|
||||
// required to avoid linting errors when there are no flags
|
||||
|
@ -235,6 +235,7 @@ export class AccountSettings {
|
||||
vaultTimeout?: number;
|
||||
vaultTimeoutAction?: string = "lock";
|
||||
serverConfig?: ServerConfigData;
|
||||
approveLoginRequests?: boolean;
|
||||
avatarColor?: string;
|
||||
|
||||
static fromJSON(obj: Jsonify<AccountSettings>): AccountSettings {
|
||||
|
@ -0,0 +1,8 @@
|
||||
export class PasswordlessAuthRequest {
|
||||
constructor(
|
||||
readonly key: string,
|
||||
readonly masterPasswordHash: string,
|
||||
readonly deviceIdentifier: string,
|
||||
readonly requestApproved: boolean
|
||||
) {}
|
||||
}
|
@ -2,6 +2,8 @@ import { DeviceType } from "../../enums/deviceType";
|
||||
|
||||
import { BaseResponse } from "./base.response";
|
||||
|
||||
const RequestTimeOut = 60000 * 15; //15 Minutes
|
||||
|
||||
export class AuthRequestResponse extends BaseResponse {
|
||||
id: string;
|
||||
publicKey: string;
|
||||
@ -10,7 +12,11 @@ export class AuthRequestResponse extends BaseResponse {
|
||||
key: string;
|
||||
masterPasswordHash: string;
|
||||
creationDate: string;
|
||||
requestApproved: boolean;
|
||||
requestApproved?: boolean;
|
||||
requestFingerprint?: string;
|
||||
responseDate?: string;
|
||||
isAnswered: boolean;
|
||||
isExpired: boolean;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
@ -22,5 +28,32 @@ export class AuthRequestResponse extends BaseResponse {
|
||||
this.masterPasswordHash = this.getResponseProperty("MasterPasswordHash");
|
||||
this.creationDate = this.getResponseProperty("CreationDate");
|
||||
this.requestApproved = this.getResponseProperty("RequestApproved");
|
||||
this.requestFingerprint = this.getResponseProperty("RequestFingerprint");
|
||||
this.responseDate = this.getResponseProperty("ResponseDate");
|
||||
|
||||
const requestDate = new Date(this.creationDate);
|
||||
const requestDateUTC = Date.UTC(
|
||||
requestDate.getUTCFullYear(),
|
||||
requestDate.getUTCMonth(),
|
||||
requestDate.getDate(),
|
||||
requestDate.getUTCHours(),
|
||||
requestDate.getUTCMinutes(),
|
||||
requestDate.getUTCSeconds(),
|
||||
requestDate.getUTCMilliseconds()
|
||||
);
|
||||
|
||||
const dateNow = new Date(Date.now());
|
||||
const dateNowUTC = Date.UTC(
|
||||
dateNow.getUTCFullYear(),
|
||||
dateNow.getUTCMonth(),
|
||||
dateNow.getDate(),
|
||||
dateNow.getUTCHours(),
|
||||
dateNow.getUTCMinutes(),
|
||||
dateNow.getUTCSeconds(),
|
||||
dateNow.getUTCMilliseconds()
|
||||
);
|
||||
|
||||
this.isExpired = dateNowUTC - requestDateUTC >= RequestTimeOut;
|
||||
this.isAnswered = this.requestApproved != null && this.responseDate != null;
|
||||
}
|
||||
}
|
||||
|
@ -35,6 +35,7 @@ import { OrganizationSponsorshipCreateRequest } from "../models/request/organiza
|
||||
import { OrganizationSponsorshipRedeemRequest } from "../models/request/organization/organization-sponsorship-redeem.request";
|
||||
import { PasswordHintRequest } from "../models/request/password-hint.request";
|
||||
import { PasswordRequest } from "../models/request/password.request";
|
||||
import { PasswordlessAuthRequest } from "../models/request/passwordless-auth.request";
|
||||
import { PasswordlessCreateAuthRequest } from "../models/request/passwordless-create-auth.request";
|
||||
import { PaymentRequest } from "../models/request/payment.request";
|
||||
import { PreloginRequest } from "../models/request/prelogin.request";
|
||||
@ -266,6 +267,33 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
return new AuthRequestResponse(r);
|
||||
}
|
||||
|
||||
async getAuthRequest(id: string): Promise<AuthRequestResponse> {
|
||||
const path = `/auth-requests/${id}`;
|
||||
const r = await this.send("GET", path, null, true, true);
|
||||
return new AuthRequestResponse(r);
|
||||
}
|
||||
|
||||
async putAuthRequest(id: string, request: PasswordlessAuthRequest): Promise<AuthRequestResponse> {
|
||||
const path = `/auth-requests/${id}`;
|
||||
const r = await this.send("PUT", path, request, true, true);
|
||||
return new AuthRequestResponse(r);
|
||||
}
|
||||
|
||||
async getAuthRequests(): Promise<ListResponse<AuthRequestResponse>> {
|
||||
const path = `/auth-requests/`;
|
||||
const r = await this.send("GET", path, null, true, true);
|
||||
return new ListResponse(r, AuthRequestResponse);
|
||||
}
|
||||
|
||||
async getLastAuthRequest(): Promise<AuthRequestResponse> {
|
||||
const requests = await this.getAuthRequests();
|
||||
const activeRequests = requests.data.filter((m) => !m.isAnswered && !m.isExpired);
|
||||
const lastRequest = activeRequests.sort((a: AuthRequestResponse, b: AuthRequestResponse) =>
|
||||
a.creationDate.localeCompare(b.creationDate)
|
||||
)[activeRequests.length - 1];
|
||||
return lastRequest;
|
||||
}
|
||||
|
||||
// Account APIs
|
||||
|
||||
async getProfile(): Promise<ProfileResponse> {
|
||||
|
@ -4,6 +4,7 @@ import { ApiService } from "../abstractions/api.service";
|
||||
import { AppIdService } from "../abstractions/appId.service";
|
||||
import { AuthService as AuthServiceAbstraction } from "../abstractions/auth.service";
|
||||
import { CryptoService } from "../abstractions/crypto.service";
|
||||
import { EncryptService } from "../abstractions/encrypt.service";
|
||||
import { EnvironmentService } from "../abstractions/environment.service";
|
||||
import { I18nService } from "../abstractions/i18n.service";
|
||||
import { KeyConnectorService } from "../abstractions/keyConnector.service";
|
||||
@ -21,6 +22,7 @@ import { PasswordLogInStrategy } from "../misc/logInStrategies/passwordLogin.str
|
||||
import { PasswordlessLogInStrategy } from "../misc/logInStrategies/passwordlessLogin.strategy";
|
||||
import { SsoLogInStrategy } from "../misc/logInStrategies/ssoLogin.strategy";
|
||||
import { UserApiLogInStrategy } from "../misc/logInStrategies/user-api-login.strategy";
|
||||
import { Utils } from "../misc/utils";
|
||||
import { AuthResult } from "../models/domain/auth-result";
|
||||
import { KdfConfig } from "../models/domain/kdf-config";
|
||||
import {
|
||||
@ -31,7 +33,9 @@ import {
|
||||
} from "../models/domain/log-in-credentials";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
import { TokenTwoFactorRequest } from "../models/request/identity-token/token-two-factor.request";
|
||||
import { PasswordlessAuthRequest } from "../models/request/passwordless-auth.request";
|
||||
import { PreloginRequest } from "../models/request/prelogin.request";
|
||||
import { AuthRequestResponse } from "../models/response/auth-request.response";
|
||||
import { ErrorResponse } from "../models/response/error.response";
|
||||
import { AuthRequestPushNotification } from "../models/response/notification.response";
|
||||
|
||||
@ -88,7 +92,8 @@ export class AuthService implements AuthServiceAbstraction {
|
||||
protected environmentService: EnvironmentService,
|
||||
protected stateService: StateService,
|
||||
protected twoFactorService: TwoFactorService,
|
||||
protected i18nService: I18nService
|
||||
protected i18nService: I18nService,
|
||||
protected encryptService: EncryptService
|
||||
) {}
|
||||
|
||||
async logIn(
|
||||
@ -275,6 +280,31 @@ export class AuthService implements AuthServiceAbstraction {
|
||||
return this.pushNotificationSubject.asObservable();
|
||||
}
|
||||
|
||||
async passwordlessLogin(
|
||||
id: string,
|
||||
key: string,
|
||||
requestApproved: boolean
|
||||
): Promise<AuthRequestResponse> {
|
||||
const pubKey = Utils.fromB64ToArray(key);
|
||||
const encryptedKey = await this.cryptoService.rsaEncrypt(
|
||||
(
|
||||
await this.cryptoService.getKey()
|
||||
).encKey,
|
||||
pubKey.buffer
|
||||
);
|
||||
const encryptedMasterPassword = await this.cryptoService.rsaEncrypt(
|
||||
Utils.fromUtf8ToArray(await this.stateService.getKeyHash()),
|
||||
pubKey.buffer
|
||||
);
|
||||
const request = new PasswordlessAuthRequest(
|
||||
encryptedKey.encryptedString,
|
||||
encryptedMasterPassword.encryptedString,
|
||||
await this.appIdService.getAppId(),
|
||||
requestApproved
|
||||
);
|
||||
return await this.apiService.putAuthRequest(id, request);
|
||||
}
|
||||
|
||||
private saveState(
|
||||
strategy:
|
||||
| UserApiLogInStrategy
|
||||
|
@ -213,4 +213,13 @@ export class EnvironmentService implements EnvironmentServiceAbstraction {
|
||||
this.getApiUrl()
|
||||
);
|
||||
}
|
||||
|
||||
isSelfHosted(): boolean {
|
||||
return ![
|
||||
"http://vault.bitwarden.com",
|
||||
"https://vault.bitwarden.com",
|
||||
"http://vault.qa.bitwarden.pw",
|
||||
"https://vault.qa.bitwarden.pw",
|
||||
].includes(this.getWebVaultUrl());
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import { AppIdService } from "../abstractions/appId.service";
|
||||
import { AuthService } from "../abstractions/auth.service";
|
||||
import { EnvironmentService } from "../abstractions/environment.service";
|
||||
import { LogService } from "../abstractions/log.service";
|
||||
import { MessagingService } from "../abstractions/messaging.service";
|
||||
import { NotificationsService as NotificationsServiceAbstraction } from "../abstractions/notifications.service";
|
||||
import { StateService } from "../abstractions/state.service";
|
||||
import { AuthenticationStatus } from "../enums/authenticationStatus";
|
||||
@ -34,7 +35,8 @@ export class NotificationsService implements NotificationsServiceAbstraction {
|
||||
private logoutCallback: (expired: boolean) => Promise<void>,
|
||||
private logService: LogService,
|
||||
private stateService: StateService,
|
||||
private authService: AuthService
|
||||
private authService: AuthService,
|
||||
private messagingService: MessagingService
|
||||
) {
|
||||
this.environmentService.urls.subscribe(() => {
|
||||
if (!this.inited) {
|
||||
@ -183,6 +185,13 @@ export class NotificationsService implements NotificationsServiceAbstraction {
|
||||
case NotificationType.SyncSendDelete:
|
||||
await this.syncService.syncDeleteSend(notification.payload as SyncSendNotification);
|
||||
break;
|
||||
case NotificationType.AuthRequest:
|
||||
if (await this.stateService.getApproveLoginRequests()) {
|
||||
this.messagingService.send("openLoginApproval", {
|
||||
notificationId: notification.payload.id,
|
||||
});
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
@ -2266,6 +2266,24 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
async getApproveLoginRequests(options?: StorageOptions): Promise<boolean> {
|
||||
const approveLoginRequests = (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))
|
||||
)?.settings?.approveLoginRequests;
|
||||
return approveLoginRequests;
|
||||
}
|
||||
|
||||
async setApproveLoginRequests(value: boolean, options?: StorageOptions): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())
|
||||
);
|
||||
account.settings.approveLoginRequests = value;
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())
|
||||
);
|
||||
}
|
||||
|
||||
async getStateVersion(): Promise<number> {
|
||||
return (await this.getGlobals(await this.defaultOnDiskLocalOptions())).stateVersion ?? 1;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user