Skip to content

Commit

Permalink
NAS-133624 / 25.10 / Support for single time user passwords in STIG m…
Browse files Browse the repository at this point in the history
…ode (#11427)

* NAS-133624: Support for single time user passwords in STIG mode

* NAS-133624: Support for single time user passwords in STIG mode

* NAS-133624: PR update

* NAS-133624: PR update

* NAS-133624: PR update

* NAS-133624: PR update

* NAS-133624: PR Update

* NAS-133624: PR update

* NAS-133624: PR update
  • Loading branch information
AlexKarpov98 authored Jan 30, 2025
1 parent 50166c9 commit 33d4cdf
Show file tree
Hide file tree
Showing 122 changed files with 2,600 additions and 389 deletions.
1 change: 1 addition & 0 deletions src/app/enums/account-attribute.enum.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export enum AccountAttribute {
Local = 'LOCAL',
SysAdmin = 'SYS_ADMIN',
Otpw = 'OTPW',
}
4 changes: 4 additions & 0 deletions src/app/helptext/account/user-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,15 @@ export const helptextUsers = {
<i>No:</i> Requires adding a <b>Password</b> to the account. The \
account can use the saved <b>Password</b> to authenticate with \
password-based services.'),
user_form_auth_one_time_pw_tooltip: T('Temporary password will be generated and shown to you once form is saved. \
<br><br>This password is only valid for one login within 24 hours and does not persist across reboots. \
<br><br>User will be encouraged to choose their own password after they login for the first time.'),
user_form_shell_tooltip: T('Select the shell to use for local and SSH logins.'),
user_form_lockuser_tooltip: T('Prevent the user from logging in or \
using password-based services until this option is unset. Locking an \
account is only possible when <b>Disable Password</b> is <i>No</i> and \
a <b>Password</b> has been created for the account.'),
user_form_smb_tooltip: T('Set to allow user to authenticate to Samba shares.'),
smbBuiltin: T('Cannot be enabled for built-in users.'),
smbStig: T('Local user accounts using NTLM authentication are not permitted when TrueNAS is running in an enhanced security mode.'),
};
5 changes: 3 additions & 2 deletions src/app/interfaces/api/api-call-directory.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ export interface ApiCallDirectory {

// Auth
'auth.generate_token': { params: void; response: string };
'auth.generate_onetime_password': { params: [{ username: string }]; response: string };
'auth.login_ex': { params: [LoginExQuery]; response: LoginExResponse };
'auth.login_ex_continue': { params: [LoginExOtpTokenQuery]; response: LoginExResponse };
'auth.logout': { params: void; response: void };
Expand Down Expand Up @@ -860,7 +861,8 @@ export interface ApiCallDirectory {
'ups.update': { params: [UpsConfigUpdate]; response: UpsConfig };

// User
'user.create': { params: [UserUpdate]; response: number };
'user.create': { params: [UserUpdate]; response: User };
'user.update': { params: [id: number, update: UserUpdate]; response: User };
'user.delete': { params: DeleteUserParams; response: number };
'user.get_next_uid': { params: void; response: number };
'user.get_user_obj': { params: [{ username?: string; uid?: number }]; response: DsUncachedUser };
Expand All @@ -870,7 +872,6 @@ export interface ApiCallDirectory {
'user.set_password': { params: [SetPasswordParams]; response: void };
'user.setup_local_administrator': { params: [userName: string, password: string, ec2?: { instance_id: string }]; response: void };
'user.shell_choices': { params: [ids: number[]]; response: Choices };
'user.update': { params: [id: number, update: UserUpdate]; response: number };

// Virt
'virt.instance.query': { params: QueryParams<VirtualizationInstance>; response: VirtualizationInstance[] };
Expand Down
1 change: 1 addition & 0 deletions src/app/interfaces/user.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export interface UserUpdate {
full_name?: string;
email?: string;
password?: string;
random_password?: boolean | null;
password_disabled?: boolean;
locked?: boolean;
smb?: boolean;
Expand Down
7 changes: 7 additions & 0 deletions src/app/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { adminUiInitialized } from 'app/store/admin-panel/admin.actions';
export class AuthService {
@LocalStorage() private token: string | undefined | null;
protected loggedInUser$ = new BehaviorSubject<LoggedInUser | null>(null);
wasOneTimePasswordChanged$ = new BehaviorSubject<boolean>(false);

/**
* This is 10 seconds less than 300 seconds which is the default life
Expand All @@ -61,6 +62,11 @@ export class AuthService {
readonly user$ = this.loggedInUser$.asObservable();
readonly isTokenAllowed$ = new BehaviorSubject<boolean>(false);

isOtpwUser$: Observable<boolean> = this.user$.pipe(
filter(Boolean),
map((user) => user.account_attributes.includes(AccountAttribute.Otpw)),
);

/**
* Special case that only matches root and admin users.
*/
Expand Down Expand Up @@ -184,6 +190,7 @@ export class AuthService {
return this.api.call('auth.logout').pipe(
tap(() => {
this.clearAuthToken();
this.wasOneTimePasswordChanged$.next(false);
this.api.clearSubscriptions();
this.wsStatus.setLoginStatus(false);
}),
Expand Down
32 changes: 32 additions & 0 deletions src/app/modules/auth/two-factor-guard.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { SpectatorService, createServiceFactory, mockProvider } from '@ngneat/spectator/jest';
import { BehaviorSubject, firstValueFrom, of } from 'rxjs';
import { GlobalTwoFactorConfig, UserTwoFactorConfig } from 'app/interfaces/two-factor-config.interface';
import { AuthService } from 'app/modules/auth/auth.service';
import { TwoFactorGuardService } from 'app/modules/auth/two-factor-guard.service';
import { DialogService } from 'app/modules/dialog/dialog.service';
import { FirstLoginDialogComponent } from 'app/pages/credentials/users/first-login-dialog/first-login-dialog.component';
import { WebSocketStatusService } from 'app/services/websocket-status.service';

describe('TwoFactorGuardService', () => {
Expand All @@ -14,6 +16,8 @@ describe('TwoFactorGuardService', () => {
const userTwoFactorConfig$ = new BehaviorSubject<UserTwoFactorConfig | null>(null);
const getGlobalTwoFactorConfig = jest.fn(() => of(null as GlobalTwoFactorConfig | null));
const hasRole$ = new BehaviorSubject(false);
const isOtpwUser$ = new BehaviorSubject(false);
const wasOneTimePasswordChanged$ = new BehaviorSubject(false);

const createService = createServiceFactory({
service: TwoFactorGuardService,
Expand All @@ -26,6 +30,13 @@ describe('TwoFactorGuardService', () => {
userTwoFactorConfig$,
getGlobalTwoFactorConfig,
hasRole: jest.fn(() => hasRole$),
isOtpwUser$,
wasOneTimePasswordChanged$,
}),
mockProvider(MatDialog, {
open: jest.fn(() => ({
afterClosed: () => of(true),
})),
}),
mockProvider(DialogService, {
fullScreenDialog: jest.fn(() => of(undefined)),
Expand Down Expand Up @@ -98,4 +109,25 @@ describe('TwoFactorGuardService', () => {
expect(spectator.inject(DialogService).fullScreenDialog).toHaveBeenCalled();
expect(spectator.inject(Router).navigate).toHaveBeenCalledWith(['/two-factor-auth']);
});

it('handles STIG first login for user to proceed with changing one-time password and setting up 2FA', async () => {
isAuthenticated$.next(true);
getGlobalTwoFactorConfig.mockReturnValue(of({ enabled: true } as GlobalTwoFactorConfig));
userTwoFactorConfig$.next({ secret_configured: false } as UserTwoFactorConfig);
isOtpwUser$.next(true);

const isAllowed = await firstValueFrom(
spectator.service.canActivateChild({} as ActivatedRouteSnapshot, { url: '/dashboard' } as RouterStateSnapshot),
);
expect(isAllowed).toBe(true);

expect(spectator.inject(MatDialog).open).toHaveBeenCalledWith(FirstLoginDialogComponent, {
maxWidth: '100vw',
maxHeight: '100vh',
height: '100%',
width: '100%',
panelClass: 'full-screen-modal',
disableClose: true,
});
});
});
27 changes: 26 additions & 1 deletion src/app/modules/auth/two-factor-guard.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Injectable } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import {
ActivatedRouteSnapshot, RouterStateSnapshot, Router, CanActivateChild,
} from '@angular/router';
Expand All @@ -10,6 +11,7 @@ import {
import { Role } from 'app/enums/role.enum';
import { AuthService } from 'app/modules/auth/auth.service';
import { DialogService } from 'app/modules/dialog/dialog.service';
import { FirstLoginDialogComponent } from 'app/pages/credentials/users/first-login-dialog/first-login-dialog.component';
import { WebSocketStatusService } from 'app/services/websocket-status.service';

@UntilDestroy()
Expand All @@ -23,6 +25,7 @@ export class TwoFactorGuardService implements CanActivateChild {
private wsStatus: WebSocketStatusService,
private dialogService: DialogService,
private translate: TranslateService,
private matDialog: MatDialog,
) { }

canActivateChild(_: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
Expand All @@ -42,8 +45,17 @@ export class TwoFactorGuardService implements CanActivateChild {
this.authService.userTwoFactorConfig$.pipe(take(1)),
this.authService.getGlobalTwoFactorConfig(),
this.authService.hasRole([Role.FullAdmin]).pipe(take(1)),
this.authService.isOtpwUser$.pipe(take(1)),
this.authService.wasOneTimePasswordChanged$.asObservable().pipe(take(1)),
]).pipe(
switchMap(([userConfig, globalConfig, isFullAdmin]) => {
switchMap(([userConfig, globalConfig, isFullAdmin, isOtpwUser, wasOneTimePasswordChanged]) => {
if (
((isOtpwUser && !wasOneTimePasswordChanged) || (isOtpwUser && !userConfig.secret_configured))
&& globalConfig.enabled
) {
return this.showFirstLoginDialog();
}

if (!globalConfig.enabled || userConfig.secret_configured || state.url.endsWith('/two-factor-auth')) {
return of(true);
}
Expand All @@ -70,4 +82,17 @@ export class TwoFactorGuardService implements CanActivateChild {
}),
);
}

private showFirstLoginDialog(): Observable<boolean> {
const dialogRef = this.matDialog.open(FirstLoginDialogComponent, {
maxWidth: '100vw',
maxHeight: '100vh',
height: '100%',
width: '100%',
panelClass: 'full-screen-modal',
disableClose: true,
});

return dialogRef.afterClosed();
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
@import 'scss-imports/variables';

:host {
color: var(--fg1);
display: block;
font-size: 12px;

margin-bottom: 19px;
margin-top: 12px;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,42 +1,15 @@
<h1 matDialogTitle>{{ 'Change Password' | translate }}</h1>
<form class="ix-form-container" [formGroup]="form" (submit)="onSubmit()">
@if (!(isFullAdminUser$ | async)) {
<ix-input
formControlName="old_password"
type="password"
[label]="'Current Password' | translate"
[required]="true"
></ix-input>
}

<ix-input
formControlName="new_password"
type="password"
[label]="'New Password' | translate"
[required]="true"
[tooltip]="tooltips.password | translate"
></ix-input>

<ix-input
formControlName="passwordConfirmation"
type="password"
[label]="'Confirm Password' | translate"
[required]="true"
></ix-input>

<ix-form-actions>
<button mat-button type="button" matDialogClose ixTest="cancel">
{{ 'Cancel' | translate }}
</button>

<button
mat-button
type="submit"
color="primary"
ixTest="save"
[disabled]="form.invalid"
>
{{ 'Save' | translate }}
</button>
</ix-form-actions>
</form>
<button
mat-icon-button
mat-dialog-close
class="close-change-password-dialog"
ixTest="close-change-password-dialog"
[aria-label]="'Close Change Password Dialog' | translate"
>
<ix-icon name="clear"></ix-icon>
</button>

<ix-change-password-form
(passwordUpdated)="dialogRef.close(true)"
></ix-change-password-form>
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,8 @@
min-width: 380px;
}

.close-change-password-dialog {
position: absolute;
right: 16px;
top: 26px;
}
Original file line number Diff line number Diff line change
@@ -1,92 +1,28 @@
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonHarness } from '@angular/material/button/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { MatDialogRef } from '@angular/material/dialog';
import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest';
import { throwError } from 'rxjs';
import { MockAuthService } from 'app/core/testing/classes/mock-auth.service';
import { mockCall, mockApi } from 'app/core/testing/utils/mock-api.utils';
import { mockAuth } from 'app/core/testing/utils/mock-auth.utils';
import { Role } from 'app/enums/role.enum';
import { FormErrorHandlerService } from 'app/modules/forms/ix-forms/services/form-error-handler.service';
import { IxFormHarness } from 'app/modules/forms/ix-forms/testing/ix-form.harness';
import { ChangePasswordDialogComponent } from 'app/modules/layout/topbar/change-password-dialog/change-password-dialog.component';
import { ApiService } from 'app/modules/websocket/api.service';
import { ChangePasswordFormComponent } from 'app/modules/layout/topbar/change-password-dialog/change-password-form/change-password-form.component';

describe('ChangePasswordDialogComponent', () => {
let spectator: Spectator<ChangePasswordDialogComponent>;
let loader: HarnessLoader;
let api: ApiService;

const createComponent = createComponentFactory({
component: ChangePasswordDialogComponent,
imports: [
ReactiveFormsModule,
],
providers: [
mockApi([
mockCall('user.set_password'),
]),
mockProvider(FormErrorHandlerService),
mockProvider(MatDialogRef),
mockAuth(),
],
});

beforeEach(() => {
spectator = createComponent();
loader = TestbedHarnessEnvironment.loader(spectator.fixture);
api = spectator.inject(ApiService);
});

it('does not show current password field for full admin', async () => {
const authMock = spectator.inject(MockAuthService);
authMock.setRoles([Role.FullAdmin]);

const form = await loader.getHarness(IxFormHarness);
expect(await form.getControl('Current Password')).toBeUndefined();
});

it('checks current password, updates to new password and closes the dialog when form is saved', async () => {
const authMock = spectator.inject(MockAuthService);
authMock.setRoles([Role.ReadonlyAdmin]);

const form = await loader.getHarness(IxFormHarness);
await form.fillForm({
'Current Password': 'correct',
'New Password': '123456',
'Confirm Password': '123456',
});

const saveButton = await loader.getHarness(MatButtonHarness.with({ text: 'Save' }));
await saveButton.click();

expect(api.call).toHaveBeenCalledWith('user.set_password', [{
old_password: 'correct',
new_password: '123456',
username: 'root',
}]);
expect(spectator.inject(MatDialogRef).close).toHaveBeenCalled();
});

it('shows error if any happened during password change request', async () => {
const authMock = spectator.inject(MockAuthService);
authMock.setRoles([Role.ReadonlyAdmin]);

const error = new Error('error');
jest.spyOn(api, 'call').mockReturnValue(throwError(() => error));

const form = await loader.getHarness(IxFormHarness);
await form.fillForm({
'Current Password': 'incorrect',
'New Password': '123456',
'Confirm Password': '123456',
});

const saveButton = await loader.getHarness(MatButtonHarness.with({ text: 'Save' }));
await saveButton.click();

expect(spectator.inject(FormErrorHandlerService).handleValidationErrors)
.toHaveBeenCalledWith(error, expect.any(FormGroup));
it('renders the change password form', () => {
const formComponent = spectator.query(ChangePasswordFormComponent);
expect(formComponent).toBeTruthy();
});
});
Loading

0 comments on commit 33d4cdf

Please sign in to comment.