mirror of
https://github.com/bitwarden/browser
synced 2025-12-16 08:13:42 +00:00
feat(auth): [PM-8221] implement device verification for unknown devices
Add device verification flow that requires users to enter an OTP when logging in from an unrecognized device. This includes: - New device verification route and guard - Email OTP verification component - Authentication timeout handling PM-8221
This commit is contained in:
18
libs/auth/src/angular/icons/device-verification.icon.ts
Normal file
18
libs/auth/src/angular/icons/device-verification.icon.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { svgIcon } from "@bitwarden/components";
|
||||
|
||||
export const DeviceVerificationIcon = svgIcon`
|
||||
<svg viewBox="0 0 98 95" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path class="tw-stroke-art-primary" d="M12.1759 27.7453L2.54349 34.9329C1.57215 35.6577 1 36.7986 1 38.0105V89.6281C1 91.7489 2.71922 93.4681 4.84 93.4681H93.16C95.2808 93.4681 97 91.7489 97 89.6281V38.0276C97 36.806 96.4188 35.6574 95.4347 34.9338L85.6576 27.7453M61.8791 10.2622L50.9367 2.2168C49.5753 1.21588 47.7197 1.22245 46.3655 2.23297L35.6054 10.2622" stroke-width="1.92"/>
|
||||
<path class="tw-stroke-art-primary" d="M85.7661 45.4682V12.1542C85.7661 11.0938 84.9064 10.2342 83.8461 10.2342H14.1541C13.0937 10.2342 12.2341 11.0938 12.2341 12.1542V45.4682" stroke-width="1.92" stroke-linecap="round"/>
|
||||
<path class="tw-stroke-art-primary" d="M95.7335 92.1003L62.3151 61.2912C61.2514 60.3106 59.8576 59.7661 58.4109 59.7661H38.043C36.5571 59.7661 35.1286 60.3404 34.0562 61.3689L2.01148 92.1003" stroke-width="1.92"/>
|
||||
<line class="tw-stroke-art-primary" x1="96.157" y1="39.125" x2="61.0395" y2="60.0979" stroke-width="1.92" stroke-linecap="round"/>
|
||||
<path class="tw-stroke-art-primary" d="M1.84229 39.1248L36.673 59.7488" stroke-width="1.92" stroke-linecap="round"/>
|
||||
<rect class="tw-stroke-art-accent" x="23.0046" y="25.5344" width="51.925" height="17.4487" rx="8.72434" stroke-width="0.96"/>
|
||||
<circle class="tw-fill-art-accent" cx="30.2299" cy="34.2588" r="2.24846"/>
|
||||
<circle class="tw-fill-art-accent" cx="45.2196" cy="34.2587" r="2.24846"/>
|
||||
<circle class="tw-fill-art-accent" cx="60.2094" cy="34.2587" r="2.24846"/>
|
||||
<circle class="tw-fill-art-accent" cx="37.7248" cy="34.2587" r="2.24846"/>
|
||||
<circle class="tw-fill-art-accent" cx="52.7145" cy="34.2587" r="2.24846"/>
|
||||
<circle class="tw-fill-art-accent" cx="67.704" cy="34.2587" r="2.24846"/>
|
||||
</svg>
|
||||
`;
|
||||
@@ -12,3 +12,4 @@ export * from "./registration-lock-alt.icon";
|
||||
export * from "./registration-expired-link.icon";
|
||||
export * from "./sso-key.icon";
|
||||
export * from "./two-factor-timeout.icon";
|
||||
export * from "./device-verification.icon";
|
||||
|
||||
@@ -71,3 +71,6 @@ export * from "./self-hosted-env-config-dialog/self-hosted-env-config-dialog.com
|
||||
// login approval
|
||||
export * from "./login-approval/login-approval.component";
|
||||
export * from "./login-approval/default-login-approval-component.service";
|
||||
|
||||
// device verification
|
||||
export * from "./new-device-verification/new-device-verification.component";
|
||||
|
||||
@@ -275,6 +275,12 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect to device verification if this is an unknown device
|
||||
if (authResult.requiresDeviceVerification) {
|
||||
await this.router.navigate(["device-verification"]);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.loginSuccessHandlerService.run(authResult.userId);
|
||||
|
||||
if (authResult.forcePasswordReset != ForceSetPasswordReason.None) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
<bit-form-field class="!tw-mb-1">
|
||||
<bit-label>{{ "verificationCode" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="text"
|
||||
id="verificationCode"
|
||||
name="verificationCode"
|
||||
formControlName="code"
|
||||
appInputVerbatim
|
||||
/>
|
||||
</bit-form-field>
|
||||
|
||||
<button
|
||||
bitLink
|
||||
type="button"
|
||||
linkType="primary"
|
||||
(click)="resendOTP()"
|
||||
[disabled]="disableRequestOTP"
|
||||
class="tw-text-sm"
|
||||
>
|
||||
{{ "resendCode" | i18n }}
|
||||
</button>
|
||||
|
||||
<div class="tw-flex tw-mt-4">
|
||||
<button
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
[block]="true"
|
||||
[disabled]="formGroup.invalid"
|
||||
>
|
||||
{{ "continueLoggingIn" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,163 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||
import { Router } from "@angular/router";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import {
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
LinkModule,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { LoginEmailServiceAbstraction } from "../../common/abstractions/login-email.service";
|
||||
import { LoginStrategyServiceAbstraction } from "../../common/abstractions/login-strategy.service";
|
||||
import { PasswordLoginStrategy } from "../../common/login-strategies/password-login.strategy";
|
||||
|
||||
/**
|
||||
* Component for verifying a new device via a one-time password (OTP).
|
||||
*/
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "app-new-device-verification",
|
||||
templateUrl: "./new-device-verification.component.html",
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
AsyncActionsModule,
|
||||
JslibModule,
|
||||
ButtonModule,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
LinkModule,
|
||||
],
|
||||
})
|
||||
export class NewDeviceVerificationComponent implements OnInit, OnDestroy {
|
||||
formGroup = this.formBuilder.group({
|
||||
code: [
|
||||
"",
|
||||
{
|
||||
validators: [Validators.required],
|
||||
updateOn: "change",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
protected disableRequestOTP = false;
|
||||
private destroy$ = new Subject<void>();
|
||||
protected authenticationSessionTimeoutRoute = "/authentication-timeout";
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private formBuilder: FormBuilder,
|
||||
private passwordLoginStrategy: PasswordLoginStrategy,
|
||||
private apiService: ApiService,
|
||||
private loginStrategyService: LoginStrategyServiceAbstraction,
|
||||
private logService: LogService,
|
||||
private toastService: ToastService,
|
||||
private i18nService: I18nService,
|
||||
private syncService: SyncService,
|
||||
private loginEmailService: LoginEmailServiceAbstraction,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
// Redirect to timeout route if session expires
|
||||
this.loginStrategyService.authenticationSessionTimeout$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((expired) => {
|
||||
if (!expired) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
void this.router.navigate([this.authenticationSessionTimeoutRoute]);
|
||||
} catch (err) {
|
||||
this.logService.error(
|
||||
`Failed to navigate to ${this.authenticationSessionTimeoutRoute} route`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resends the OTP for device verification.
|
||||
*/
|
||||
async resendOTP() {
|
||||
this.disableRequestOTP = true;
|
||||
try {
|
||||
const email = await this.loginStrategyService.getEmail();
|
||||
const masterPasswordHash = await this.loginStrategyService.getMasterPasswordHash();
|
||||
|
||||
if (!email || !masterPasswordHash) {
|
||||
throw new Error("Missing email or master password hash");
|
||||
}
|
||||
|
||||
await this.apiService.send(
|
||||
"POST",
|
||||
"/accounts/resend-new-device-otp",
|
||||
{
|
||||
email: email,
|
||||
masterPasswordHash: masterPasswordHash,
|
||||
},
|
||||
false,
|
||||
false,
|
||||
);
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
} finally {
|
||||
this.disableRequestOTP = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits the OTP for device verification.
|
||||
*/
|
||||
submit = async (): Promise<void> => {
|
||||
const codeControl = this.formGroup.get("code");
|
||||
if (!codeControl || !codeControl.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const authResult = await this.loginStrategyService.logInNewDeviceVerification(
|
||||
codeControl.value,
|
||||
);
|
||||
|
||||
if (authResult.requiresTwoFactor) {
|
||||
await this.router.navigate(["/2fa"]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (authResult.forcePasswordReset) {
|
||||
await this.router.navigate(["/update-temp-password"]);
|
||||
return;
|
||||
}
|
||||
|
||||
this.loginEmailService.clearValues();
|
||||
|
||||
await this.syncService.fullSync(true);
|
||||
|
||||
// If verification succeeds, navigate to vault
|
||||
await this.router.navigate(["/vault"]);
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
const errorMessage =
|
||||
(e as any)?.response?.error_description ?? this.i18nService.t("errorOccurred");
|
||||
codeControl.setErrors({ serverError: { message: errorMessage } });
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user