Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NAS-133624 / 25.10 / Support for single time user passwords in STIG mode #11427

Merged
merged 13 commits into from
Jan 30, 2025
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
Loading