Feature: two-factor authentication (#8012)

This commit is contained in:
shamoon
2024-11-18 10:34:46 -08:00
committed by GitHub
parent 6c3d6d562d
commit e94a92ed59
29 changed files with 1128 additions and 175 deletions

View File

@@ -343,6 +343,7 @@ describe('AppFrameComponent', () => {
component.editProfile()
expect(modalSpy).toHaveBeenCalledWith(ProfileEditDialogComponent, {
backdrop: 'static',
size: 'xl',
})
})

View File

@@ -136,6 +136,7 @@ export class AppFrameComponent
editProfile() {
this.modalService.open(ProfileEditDialogComponent, {
backdrop: 'static',
size: 'xl',
})
this.closeMenu()
}

View File

@@ -32,6 +32,20 @@
</div>
<pngx-input-select i18n-title title="Groups" [items]="groups" multiple="true" formControlName="groups"></pngx-input-select>
@if (object?.is_mfa_enabled && currentUserIsSuperUser) {
<label class="form-label" i18n>Two-factor Authentication</label>
<pngx-confirm-button
label="Disable Two-factor Authentication"
i18n-label
title="Disable Two-factor Authentication"
i18n-title
buttonClasses="btn-outline-danger btn-sm"
iconName="trash"
[disabled]="totpLoading"
(confirm)="deactivateTotp()">
</pngx-confirm-button>
}
</div>
<div class="col">
<pngx-permissions-select i18n-title title="Permissions" formControlName="user_permissions" [error]="error?.user_permissions" [inheritedPermissions]="inheritedPermissions"></pngx-permissions-select>

View File

@@ -7,7 +7,7 @@ import {
} from '@angular/forms'
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select'
import { of } from 'rxjs'
import { of, throwError } from 'rxjs'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { GroupService } from 'src/app/services/rest/group.service'
@@ -21,10 +21,15 @@ import { EditDialogMode } from '../edit-dialog.component'
import { UserEditDialogComponent } from './user-edit-dialog.component'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { ToastService } from 'src/app/services/toast.service'
import { UserService } from 'src/app/services/rest/user.service'
import { PermissionsService } from 'src/app/services/permissions.service'
describe('UserEditDialogComponent', () => {
let component: UserEditDialogComponent
let settingsService: SettingsService
let permissionsService: PermissionsService
let toastService: ToastService
let fixture: ComponentFixture<UserEditDialogComponent>
beforeEach(async () => {
@@ -71,6 +76,8 @@ describe('UserEditDialogComponent', () => {
fixture = TestBed.createComponent(UserEditDialogComponent)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = { id: 99, username: 'user99' }
permissionsService = TestBed.inject(PermissionsService)
toastService = TestBed.inject(ToastService)
component = fixture.componentInstance
fixture.detectChanges()
@@ -121,4 +128,38 @@ describe('UserEditDialogComponent', () => {
component.save()
expect(component.passwordIsSet).toBeTruthy()
})
it('should support deactivation of TOTP', () => {
component.object = { id: 99, username: 'user99' }
const deactivateSpy = jest.spyOn(
component['service'] as UserService,
'deactivateTotp'
)
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
deactivateSpy.mockReturnValueOnce(throwError(() => new Error('error')))
component.deactivateTotp()
expect(deactivateSpy).toHaveBeenCalled()
expect(toastErrorSpy).toHaveBeenCalled()
deactivateSpy.mockReturnValueOnce(of(false))
component.deactivateTotp()
expect(deactivateSpy).toHaveBeenCalled()
expect(toastErrorSpy).toHaveBeenCalled()
deactivateSpy.mockReturnValueOnce(of(true))
component.deactivateTotp()
expect(deactivateSpy).toHaveBeenCalled()
expect(toastInfoSpy).toHaveBeenCalled()
})
it('should check superuser status of current user', () => {
expect(component.currentUserIsSuperUser).toBeFalsy()
permissionsService.initialize([], {
id: 99,
username: 'user99',
is_superuser: true,
})
expect(component.currentUserIsSuperUser).toBeTruthy()
})
})

View File

@@ -5,9 +5,11 @@ import { first } from 'rxjs'
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { Group } from 'src/app/data/group'
import { User } from 'src/app/data/user'
import { PermissionsService } from 'src/app/services/permissions.service'
import { GroupService } from 'src/app/services/rest/group.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
@Component({
selector: 'pngx-user-edit-dialog',
@@ -20,12 +22,15 @@ export class UserEditDialogComponent
{
groups: Group[]
passwordIsSet: boolean = false
public totpLoading: boolean = false
constructor(
service: UserService,
activeModal: NgbActiveModal,
groupsService: GroupService,
settingsService: SettingsService
settingsService: SettingsService,
private toastService: ToastService,
private permissionsService: PermissionsService
) {
super(service, activeModal, service, settingsService)
@@ -87,4 +92,30 @@ export class UserEditDialogComponent
.length > 0
super.save()
}
get currentUserIsSuperUser(): boolean {
return this.permissionsService.isSuperUser()
}
deactivateTotp() {
this.totpLoading = true
;(this.service as UserService)
.deactivateTotp(this.object)
.pipe(first())
.subscribe({
next: (result) => {
this.totpLoading = false
if (result) {
this.toastService.showInfo($localize`Totp deactivated`)
this.object.is_mfa_enabled = false
} else {
this.toastService.showError($localize`Totp deactivation failed`)
}
},
error: (e) => {
this.totpLoading = false
this.toastService.showError($localize`Totp deactivation failed`, e)
},
})
}
}

View File

@@ -5,94 +5,179 @@
</button>
</div>
<div class="modal-body">
<pngx-input-text i18n-title title="Email" formControlName="email" (keyup)="onEmailKeyUp($event)" [error]="error?.email"></pngx-input-text>
<div ngbAccordion>
<div ngbAccordionItem="first" [collapsed]="!showEmailConfirm" class="border-0 bg-transparent">
<div ngbAccordionCollapse>
<div ngbAccordionBody class="p-0 pb-3">
<pngx-input-text i18n-title title="Confirm Email" formControlName="email_confirm" (keyup)="onEmailConfirmKeyUp($event)" autocomplete="email" [error]="error?.email_confirm"></pngx-input-text>
<div class="row">
<div class="col-12 col-md-6">
<pngx-input-text i18n-title title="Email" formControlName="email" (keyup)="onEmailKeyUp($event)" [error]="error?.email"></pngx-input-text>
<div ngbAccordion>
<div ngbAccordionItem="first" [collapsed]="!showEmailConfirm" class="border-0 bg-transparent">
<div ngbAccordionCollapse>
<div ngbAccordionBody class="p-0 pb-3">
<pngx-input-text i18n-title title="Confirm Email" formControlName="email_confirm" (keyup)="onEmailConfirmKeyUp($event)" autocomplete="email" [error]="error?.email_confirm"></pngx-input-text>
</div>
</div>
</div>
</div>
</div>
</div>
<pngx-input-password i18n-title title="Password" formControlName="password" (keyup)="onPasswordKeyUp($event)" [showReveal]="true" autocomplete="current-password" [error]="error?.password"></pngx-input-password>
<div ngbAccordion>
<div ngbAccordionItem="first" [collapsed]="!showPasswordConfirm" class="border-0 bg-transparent">
<div ngbAccordionCollapse>
<div ngbAccordionBody class="p-0 pb-3">
<pngx-input-password i18n-title title="Confirm Password" formControlName="password_confirm" (keyup)="onPasswordConfirmKeyUp($event)" autocomplete="new-password" [error]="error?.password_confirm"></pngx-input-password>
<pngx-input-password i18n-title title="Password" formControlName="password" (keyup)="onPasswordKeyUp($event)" [showReveal]="true" autocomplete="current-password" [error]="error?.password"></pngx-input-password>
<div ngbAccordion>
<div ngbAccordionItem="first" [collapsed]="!showPasswordConfirm" class="border-0 bg-transparent">
<div ngbAccordionCollapse>
<div ngbAccordionBody class="p-0 pb-3">
<pngx-input-password i18n-title title="Confirm Password" formControlName="password_confirm" (keyup)="onPasswordConfirmKeyUp($event)" autocomplete="new-password" [error]="error?.password_confirm"></pngx-input-password>
</div>
</div>
</div>
</div>
</div>
</div>
<pngx-input-text i18n-title title="First name" formControlName="first_name" [error]="error?.first_name"></pngx-input-text>
<pngx-input-text i18n-title title="Last name" formControlName="last_name" [error]="error?.first_name"></pngx-input-text>
<div class="mb-3">
<label class="form-label" i18n>API Auth Token</label>
<div class="position-relative">
<div class="input-group">
<input type="text" class="form-control" formControlName="auth_token" readonly>
<button type="button" class="btn btn-outline-secondary" (click)="copyAuthToken()" i18n-title title="Copy">
@if (!copied) {
<i-bs width="1em" height="1em" name="clipboard-fill"></i-bs>
}
@if (copied) {
<i-bs width="1em" height="1em" name="clipboard-check-fill"></i-bs>
}
<span class="visually-hidden" i18n>Copy</span>
</button>
<pngx-confirm-button
title="Regenerate auth token"
i18n-title
buttonClasses=" btn-outline-secondary"
iconName="arrow-repeat"
[disabled]="!hasUsablePassword"
(confirm)="generateAuthToken()">
</pngx-confirm-button>
</div>
<span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied" i18n>Copied!</span>
</div>
<div class="form-text text-muted text-end fst-italic" i18n>Warning: changing the token cannot be undone</div>
</div>
@if (socialAccounts?.length > 0) {
<pngx-input-text i18n-title title="First name" formControlName="first_name" [error]="error?.first_name"></pngx-input-text>
<pngx-input-text i18n-title title="Last name" formControlName="last_name" [error]="error?.first_name"></pngx-input-text>
<div class="mb-3">
<p i18n>Connected social accounts</p>
<ul class="list-group">
@for (account of socialAccounts; track account.id) {
<li class="list-group-item"
ngbPopover="Set a password before disconnecting social account."
i18n-ngbPopover
[disablePopover]="hasUsablePassword"
triggers="mouseenter:mouseleave">
{{account.name}} ({{account.provider}})
<label class="form-label" i18n>API Auth Token</label>
<div class="position-relative">
<div class="input-group">
<input type="text" class="form-control" formControlName="auth_token" readonly>
<button type="button" class="btn btn-outline-secondary" (click)="copyAuthToken()" i18n-title title="Copy">
@if (!copied) {
<i-bs width="1em" height="1em" name="clipboard-fill"></i-bs>
}
@if (copied) {
<i-bs width="1em" height="1em" name="clipboard-check-fill"></i-bs>
}
<span class="visually-hidden" i18n>Copy</span>
</button>
<pngx-confirm-button
label="Disconnect"
i18n-label
title="Disconnect {{ account.name }} social account"
title="Regenerate auth token"
i18n-title
buttonClasses="btn-outline-danger btn-sm ms-2 align-baseline"
iconName="trash"
buttonClasses=" btn-outline-secondary"
iconName="arrow-repeat"
[disabled]="!hasUsablePassword"
(confirm)="disconnectSocialAccount(account.id)">
(confirm)="generateAuthToken()">
</pngx-confirm-button>
</li>
}
</ul>
<div class="form-text text-muted text-end fst-italic" i18n>Warning: disconnecting social accounts cannot be undone</div>
</div>
}
@if (socialAccountProviders?.length > 0) {
<div class="mb-3">
<p i18n>Connect new social account</p>
<div class="list-group">
@for (provider of socialAccountProviders; track provider.name) {
<a class="list-group-item list-group-item-action text-primary d-flex align-items-center" href="{{ provider.login_url }}" rel="noopener noreferrer">
{{provider.name}}&nbsp;<i-bs class="pb-1 ps-1" name="box-arrow-up-right"></i-bs>
</a>
}
</div>
<span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied" i18n>Copied!</span>
</div>
<div class="form-text text-muted text-end fst-italic" i18n>Warning: changing the token cannot be undone</div>
</div>
</div>
}
</div>
<div class="col-12 col-md-6">
@if (socialAccounts?.length > 0) {
<div class="mb-3">
<p i18n>Connected social accounts</p>
<ul class="list-group">
@for (account of socialAccounts; track account.id) {
<li class="list-group-item"
ngbPopover="Set a password before disconnecting social account."
i18n-ngbPopover
[disablePopover]="hasUsablePassword"
triggers="mouseenter:mouseleave">
{{account.name}} ({{account.provider}})
<pngx-confirm-button
label="Disconnect"
i18n-label
title="Disconnect {{ account.name }} social account"
i18n-title
buttonClasses="btn-outline-danger btn-sm ms-2 align-baseline"
iconName="trash"
[disabled]="!hasUsablePassword"
(confirm)="disconnectSocialAccount(account.id)">
</pngx-confirm-button>
</li>
}
</ul>
<div class="form-text text-muted text-end fst-italic" i18n>Warning: disconnecting social accounts cannot be undone</div>
</div>
}
@if (socialAccountProviders?.length > 0) {
<div class="mb-3">
<p i18n>Connect new social account</p>
<div class="list-group">
@for (provider of socialAccountProviders; track provider.name) {
<a class="list-group-item list-group-item-action text-primary d-flex align-items-center" href="{{ provider.login_url }}" rel="noopener noreferrer">
{{provider.name}}&nbsp;<i-bs class="pb-1 ps-1" name="box-arrow-up-right"></i-bs>
</a>
}
</div>
</div>
}
@if (!isTotpEnabled) {
<div ngbAccordion>
<div ngbAccordionItem>
<h2 ngbAccordionHeader>
<button ngbAccordionButton (click)="gettotpSettings()" i18n>Two-factor Authentication</button>
</h2>
<div ngbAccordionCollapse>
<div ngbAccordionBody>
<ng-template>
@if (totpSettingsLoading) {
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
} @else if (totpSettings) {
<figure class="figure">
<div class="bg-white d-inline-block" [innerHTML]="totpSettings.qr_svg | safeHtml"></div>
<figcaption class="figure-caption text-end mt-2" i18n>Scan the QR code with your authenticator app and then enter the code below</figcaption>
</figure>
<p>
<ng-container i18n>Authenticator secret</ng-container>: <code>{{totpSettings.secret}}</code>.
<ng-container i18n>You can store this secret and use it to reinstall your authenticator app at a later time.</ng-container>
</p>
<div class="input-group mb-3">
<input type="text" class="form-control" formControlName="totp_code" placeholder="Code" i18n-placeholder>
<button type="button" class="btn btn-primary ml-auto" (click)="activateTotp()" [disabled]="totpLoading">
<ng-container i18n>Enable</ng-container>
@if (totpLoading) {
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
}
</button>
</div>
}
</ng-template>
</div>
</div>
</div>
</div>
} @else {
<label class="d-block mb-2" i18n>Two-factor Authentication</label>
@if (recoveryCodes) {
<div class="alert alert-warning" role="alert">
<i-bs name="exclamation-triangle"></i-bs>&nbsp;<ng-container i18n>Recovery codes will not be shown again, make sure to save them.</ng-container>
</div>
<div class="d-flex flex-row align-items-start mb-3">
<ul class="list-group w-50">
@for (code of recoveryCodes; track code; let i = $index) {
@if (i % 2 === 0) {
<li class="list-group-item d-flex justify-content-around align-items-center">
<code>{{code}}</code>
@if (recoveryCodes[i + 1]) {
<code>{{recoveryCodes[i + 1]}}</code>
}
</li>
}
}
</ul>
<button type="button" class="btn btn-sm btn-outline-secondary ms-2" (click)="copyRecoveryCodes()" i18n-title title="Copy">
@if (!codesCopied) {
<i-bs width="1em" height="1em" name="clipboard-fill"></i-bs>
&nbsp;<span i18n>Copy codes</span>
}
@if (codesCopied) {
<i-bs width="1em" height="1em" name="clipboard-check-fill" class="text-primary"></i-bs>
&nbsp;<span class="text-primary" i18n>Copied!</span>
}
</button>
</div>
}
<pngx-confirm-button
label="Disable Two-factor Authentication"
i18n-label
title="Disable Two-factor Authentication"
i18n-title
buttonClasses="btn-outline-danger btn-sm"
iconName="trash"
[disabled]="totpLoading"
(confirm)="deactivateTotp()">
</pngx-confirm-button>
}
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>

View File

@@ -294,4 +294,85 @@ describe('ProfileEditDialogComponent', () => {
expect(disconnectSpy).toHaveBeenCalled()
expect(component.socialAccounts).not.toContainEqual(socialAccount)
})
it('should get totp settings', () => {
const settings = {
url: 'http://localhost/',
qr_svg: 'svg',
secret: 'secret',
}
const getSpy = jest.spyOn(profileService, 'getTotpSettings')
const toastSpy = jest.spyOn(toastService, 'showError')
getSpy.mockReturnValueOnce(
throwError(() => new Error('failed to get settings'))
)
component.gettotpSettings()
expect(getSpy).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalled()
getSpy.mockReturnValue(of(settings))
component.gettotpSettings()
expect(getSpy).toHaveBeenCalled()
expect(component.totpSettings).toEqual(settings)
})
it('should activate totp', () => {
const activateSpy = jest.spyOn(profileService, 'activateTotp')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const error = new Error('failed to activate totp')
activateSpy.mockReturnValueOnce(throwError(() => error))
component.totpSettings = {
url: 'http://localhost/',
qr_svg: 'svg',
secret: 'secret',
}
component.form.get('totp_code').patchValue('123456')
component.activateTotp()
expect(activateSpy).toHaveBeenCalledWith(
component.totpSettings.secret,
component.form.get('totp_code').value
)
expect(toastErrorSpy).toHaveBeenCalled()
activateSpy.mockReturnValueOnce(of({ success: false, recovery_codes: [] }))
component.activateTotp()
expect(toastErrorSpy).toHaveBeenCalledWith('Error activating TOTP', error)
activateSpy.mockReturnValueOnce(
of({ success: true, recovery_codes: ['1', '2', '3'] })
)
component.activateTotp()
expect(toastInfoSpy).toHaveBeenCalled()
expect(component.isTotpEnabled).toBeTruthy()
expect(component.recoveryCodes).toEqual(['1', '2', '3'])
})
it('should deactivate totp', () => {
const deactivateSpy = jest.spyOn(profileService, 'deactivateTotp')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const error = new Error('failed to deactivate totp')
deactivateSpy.mockReturnValueOnce(throwError(() => error))
component.deactivateTotp()
expect(deactivateSpy).toHaveBeenCalled()
expect(toastErrorSpy).toHaveBeenCalled()
deactivateSpy.mockReturnValueOnce(of(false))
component.deactivateTotp()
expect(toastErrorSpy).toHaveBeenCalledWith('Error deactivating TOTP', error)
deactivateSpy.mockReturnValueOnce(of(true))
component.deactivateTotp()
expect(toastInfoSpy).toHaveBeenCalled()
expect(component.isTotpEnabled).toBeFalsy()
})
it('should copy recovery codes', fakeAsync(() => {
const copySpy = jest.spyOn(clipboard, 'copy')
component.recoveryCodes = ['1', '2', '3']
component.copyRecoveryCodes()
expect(copySpy).toHaveBeenCalledWith('1\n2\n3')
tick(3000)
}))
})

View File

@@ -2,7 +2,11 @@ import { Component, OnDestroy, OnInit } from '@angular/core'
import { FormControl, FormGroup } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { ProfileService } from 'src/app/services/profile.service'
import { SocialAccount, SocialAccountProvider } from 'src/app/data/user-profile'
import {
TotpSettings,
SocialAccount,
SocialAccountProvider,
} from 'src/app/data/user-profile'
import { ToastService } from 'src/app/services/toast.service'
import { Subject, takeUntil } from 'rxjs'
import { Clipboard } from '@angular/cdk/clipboard'
@@ -25,6 +29,7 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
first_name: new FormControl(''),
last_name: new FormControl(''),
auth_token: new FormControl(''),
totp_code: new FormControl(''),
})
private currentPassword: string
@@ -38,7 +43,14 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
private emailConfirm: string
public showEmailConfirm: boolean = false
public isTotpEnabled: boolean = false
public totpSettings: TotpSettings
public totpSettingsLoading: boolean = false
public totpLoading: boolean = false
public recoveryCodes: string[]
public copied: boolean = false
public codesCopied: boolean = false
public socialAccounts: SocialAccount[] = []
public socialAccountProviders: SocialAccountProvider[] = []
@@ -70,6 +82,7 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
this.onPasswordChange()
})
this.socialAccounts = profile.social_accounts
this.isTotpEnabled = profile.is_mfa_enabled
})
this.profileService
@@ -147,6 +160,7 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
const passwordChanged =
this.newPassword && this.currentPassword !== this.newPassword
const profile = Object.assign({}, this.form.value)
delete profile.totp_code
this.networkActive = true
this.profileService
.update(profile)
@@ -213,4 +227,81 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
},
})
}
public gettotpSettings(): void {
this.totpSettingsLoading = true
this.profileService
.getTotpSettings()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (totpSettings) => {
this.totpSettingsLoading = false
this.totpSettings = totpSettings
},
error: (error) => {
this.toastService.showError(
$localize`Error fetching TOTP settings`,
error
)
this.totpSettingsLoading = false
},
})
}
public activateTotp(): void {
this.totpLoading = true
this.form.get('totp_code').disable()
this.profileService
.activateTotp(this.totpSettings.secret, this.form.get('totp_code').value)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (activationResponse) => {
this.totpLoading = false
this.isTotpEnabled = activationResponse.success
this.recoveryCodes = activationResponse.recovery_codes
this.form.get('totp_code').enable()
if (activationResponse.success) {
this.toastService.showInfo($localize`TOTP activated successfully`)
} else {
this.toastService.showError($localize`Error activating TOTP`)
}
},
error: (error) => {
this.totpLoading = false
this.form.get('totp_code').enable()
this.toastService.showError($localize`Error activating TOTP`, error)
},
})
}
public deactivateTotp(): void {
this.totpLoading = true
this.profileService
.deactivateTotp()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (success) => {
this.totpLoading = false
this.isTotpEnabled = !success
this.recoveryCodes = null
if (success) {
this.toastService.showInfo($localize`TOTP deactivated successfully`)
} else {
this.toastService.showError($localize`Error deactivating TOTP`)
}
},
error: (error) => {
this.totpLoading = false
this.toastService.showError($localize`Error deactivating TOTP`, error)
},
})
}
public copyRecoveryCodes(): void {
this.clipboard.copy(this.recoveryCodes.join('\n'))
this.codesCopied = true
setTimeout(() => {
this.codesCopied = false
}, 3000)
}
}

View File

@@ -17,4 +17,11 @@ export interface PaperlessUserProfile {
auth_token?: string
social_accounts?: SocialAccount[]
has_usable_password?: boolean
is_mfa_enabled?: boolean
}
export interface TotpSettings {
url: string
qr_svg: string
secret: string
}

View File

@@ -11,4 +11,5 @@ export interface User extends ObjectWithId {
groups?: number[] // Group[]
user_permissions?: string[]
inherited_permissions?: string[]
is_mfa_enabled?: boolean
}

View File

@@ -439,4 +439,25 @@ describe('PermissionsService', () => {
expect(permissionsService.isAdmin()).toBeFalsy()
})
it('correctly checks superuser status', () => {
permissionsService.initialize([], {
username: 'testuser',
last_name: 'User',
first_name: 'Test',
id: 1,
is_superuser: true,
})
expect(permissionsService.isSuperUser()).toBeTruthy()
permissionsService.initialize([], {
username: 'testuser',
last_name: 'User',
first_name: 'Test',
id: 1,
})
expect(permissionsService.isSuperUser()).toBeFalsy()
})
})

View File

@@ -56,6 +56,10 @@ export class PermissionsService {
return this.currentUser?.is_staff
}
public isSuperUser(): boolean {
return this.currentUser?.is_superuser
}
public currentUserOwnsObject(object: ObjectWithPermissions): boolean {
return (
!object ||

View File

@@ -72,4 +72,32 @@ describe('ProfileService', () => {
)
expect(req.request.method).toEqual('GET')
})
it('calls get totp settings endpoint', () => {
service.getTotpSettings().subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}profile/totp/`
)
expect(req.request.method).toEqual('GET')
})
it('calls activate totp endpoint', () => {
service.activateTotp('secret', 'code').subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}profile/totp/`
)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({
secret: 'secret',
code: 'code',
})
})
it('calls deactivate totp endpoint', () => {
service.deactivateTotp().subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}profile/totp/`
)
expect(req.request.method).toEqual('DELETE')
})
})

View File

@@ -2,6 +2,7 @@ import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { Observable } from 'rxjs'
import {
TotpSettings,
PaperlessUserProfile,
SocialAccountProvider,
} from '../data/user-profile'
@@ -47,4 +48,30 @@ export class ProfileService {
`${environment.apiBaseUrl}${this.endpoint}/social_account_providers/`
)
}
getTotpSettings(): Observable<TotpSettings> {
return this.http.get<TotpSettings>(
`${environment.apiBaseUrl}${this.endpoint}/totp/`
)
}
activateTotp(
totpSecret: string,
totpCode: string
): Observable<{ success: boolean; recovery_codes: string[] }> {
return this.http.post<{ success: boolean; recovery_codes: string[] }>(
`${environment.apiBaseUrl}${this.endpoint}/totp/`,
{
secret: totpSecret,
code: totpCode,
}
)
}
deactivateTotp(): Observable<boolean> {
return this.http.delete<boolean>(
`${environment.apiBaseUrl}${this.endpoint}/totp/`,
{}
)
}
}

View File

@@ -160,6 +160,18 @@ const user = {
commonAbstractNameFilterPaperlessServiceTests(endpoint, UserService)
describe('Additional service tests for UserService', () => {
beforeEach(() => {
// Dont need to setup again
httpTestingController = TestBed.inject(HttpTestingController)
service = TestBed.inject(UserService)
})
afterEach(() => {
subscription?.unsubscribe()
httpTestingController.verify()
})
it('should retain permissions on update', () => {
subscription = service.listAll().subscribe()
let req = httpTestingController.expectOne(
@@ -179,15 +191,11 @@ describe('Additional service tests for UserService', () => {
)
})
beforeEach(() => {
// Dont need to setup again
httpTestingController = TestBed.inject(HttpTestingController)
service = TestBed.inject(UserService)
})
afterEach(() => {
subscription?.unsubscribe()
httpTestingController.verify()
it('should deactivate totp', () => {
subscription = service.deactivateTotp(user).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/${user.id}/deactivate_totp/`
)
expect(req.request.method).toEqual('POST')
})
})

View File

@@ -5,6 +5,7 @@ import { User } from 'src/app/data/user'
import { PermissionsService } from '../permissions.service'
import { AbstractNameFilterService } from './abstract-name-filter-service'
const endpoint = 'users'
@Injectable({
providedIn: 'root',
})
@@ -13,7 +14,7 @@ export class UserService extends AbstractNameFilterService<User> {
http: HttpClient,
private permissionService: PermissionsService
) {
super(http, 'users')
super(http, endpoint)
}
update(o: User): Observable<User> {
@@ -31,4 +32,11 @@ export class UserService extends AbstractNameFilterService<User> {
})
)
}
deactivateTotp(u: User): Observable<boolean> {
return this.http.post<boolean>(
`${this.getResourceUrl(u.id, 'deactivate_totp')}`,
null
)
}
}