NIFI-12968: Simplify login sequence (#8843)

* NIFI-12968:
- Remove usage of Access-Token-Expiration
- No longer attempt SPNEGO auth
- Leverage authentication configuration to drive log in/out URIs
- Remove Login/Logout servlet filters
- Remove usage of access configuration and access status
- Fixing broken unit tests

* NIFI-12968:
- Only rendering the user identity when the user is not anonymous.
- Fixing an issue where the fallback route would render when redirecting the user to an external SSO log in.
- Using the login supported flag to render the log in link.

* NIFI-12968:
- Addressing review feedback.

This closes #8843
This commit is contained in:
Matt Gilman 2024-05-21 08:42:19 -04:00 committed by GitHub
parent 3f9ef07e3c
commit 3a78575b9a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
61 changed files with 734 additions and 901 deletions

View File

@ -1,87 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.nifi.web.filter;
import org.apache.nifi.web.util.RequestUriBuilder;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.FilterConfig;
import jakarta.servlet.ServletContext;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URI;
/**
* Filter for determining appropriate login location.
*/
public class LoginFilter implements Filter {
private static final String OAUTH2_AUTHORIZATION_PATH = "/nifi-api/oauth2/authorization/consumer";
private static final String SAML2_AUTHENTICATE_FILTER_PATH = "/nifi-api/saml2/authenticate/consumer";
private static final String KNOX_REQUEST_PATH = "/nifi-api/access/knox/request";
private static final String NIFI_LOGIN_PATH = "/nf/";
private static final String NIFI_LOGIN_FRAGMENT = "/login";
private ServletContext servletContext;
@Override
public void init(FilterConfig filterConfig) throws ServletException {
servletContext = filterConfig.getServletContext();
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
final boolean supportsOidc = Boolean.parseBoolean(servletContext.getInitParameter("oidc-supported"));
final boolean supportsKnoxSso = Boolean.parseBoolean(servletContext.getInitParameter("knox-supported"));
final boolean supportsSAML = Boolean.parseBoolean(servletContext.getInitParameter("saml-supported"));
final HttpServletRequest httpServletRequest = (HttpServletRequest) request;
final RequestUriBuilder requestUriBuilder = RequestUriBuilder.fromHttpServletRequest(httpServletRequest);
if (supportsKnoxSso) {
final URI redirectUri = requestUriBuilder.path(KNOX_REQUEST_PATH).build();
sendRedirect(response, redirectUri);
} else if (supportsOidc) {
final URI redirectUri = requestUriBuilder.path(OAUTH2_AUTHORIZATION_PATH).build();
// Redirect to authorization URL defined in Spring Security OAuth2AuthorizationRequestRedirectFilter
sendRedirect(response, redirectUri);
} else if (supportsSAML) {
final URI redirectUri = requestUriBuilder.path(SAML2_AUTHENTICATE_FILTER_PATH).build();
// Redirect to request consumer URL defined in Spring Security OpenSamlAuthenticationRequestResolver.requestMatcher
sendRedirect(response, redirectUri);
} else {
final URI redirectUri = requestUriBuilder.path(NIFI_LOGIN_PATH).fragment(NIFI_LOGIN_FRAGMENT).build();
sendRedirect(response, redirectUri);
}
}
@Override
public void destroy() {
}
private void sendRedirect(final ServletResponse response, final URI redirectUri) throws IOException {
final HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.sendRedirect(redirectUri.toString());
}
}

View File

@ -1,91 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.nifi.web.filter;
import org.apache.nifi.web.util.RequestUriBuilder;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.FilterConfig;
import jakarta.servlet.ServletContext;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URI;
/**
* Filter for determining appropriate logout location.
*/
public class LogoutFilter implements Filter {
private static final String OIDC_LOGOUT_URL = "/nifi-api/access/oidc/logout";
private static final String SAML_LOCAL_LOGOUT_URL = "/nifi-api/access/saml/local-logout/request";
private static final String SAML_SINGLE_LOGOUT_URL = "/nifi-api/access/saml/single-logout/request";
private static final String KNOX_LOGOUT_URL = "/nifi-api/access/knox/logout";
private static final String LOGOUT_COMPLETE_URL = "/nifi-api/access/logout/complete";
private ServletContext servletContext;
@Override
public void init(FilterConfig filterConfig) throws ServletException {
servletContext = filterConfig.getServletContext();
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
final boolean supportsOidc = Boolean.parseBoolean(servletContext.getInitParameter("oidc-supported"));
final boolean supportsKnoxSso = Boolean.parseBoolean(servletContext.getInitParameter("knox-supported"));
final boolean supportsSaml = Boolean.parseBoolean(servletContext.getInitParameter("saml-supported"));
final boolean supportsSamlSingleLogout = Boolean.parseBoolean(servletContext.getInitParameter("saml-single-logout-supported"));
// NOTE: This filter runs in the web-ui module and is bound to /nifi/logout. Currently the front-end first makes an ajax call
// to issue a DELETE to /nifi-api/access/logout. After successful completion it sets the browser location to /nifi/logout
// which triggers this filter. Since this request was made from setting window.location, the JWT will never be sent which
// means there will be no logged in user or Authorization header when forwarding to any of the URLs below. Instead the
// /access/logout end-point sets a Cookie with a logout request identifier which can be used by the end-points below
// to retrieve information about the user logging out.
if (supportsOidc) {
sendRedirect(OIDC_LOGOUT_URL, request, response);
} else if (supportsKnoxSso) {
sendRedirect(KNOX_LOGOUT_URL, request, response);
} else if (supportsSaml) {
final String logoutUrl = supportsSamlSingleLogout ? SAML_SINGLE_LOGOUT_URL : SAML_LOCAL_LOGOUT_URL;
sendRedirect(logoutUrl, request, response);
} else {
sendRedirect(LOGOUT_COMPLETE_URL, request, response);
}
}
@Override
public void destroy() {
}
private void sendRedirect(final String logoutUrl, final ServletRequest request, final ServletResponse response) throws IOException {
final HttpServletRequest httpServletRequest = (HttpServletRequest) request;
final URI targetUri = RequestUriBuilder.fromHttpServletRequest(httpServletRequest).path(logoutUrl).build();
final HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.sendRedirect(targetUri.toString());
}
}

View File

@ -19,10 +19,15 @@ import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { authenticationGuard } from './service/guard/authentication.guard';
import { RouteNotFound } from './pages/route-not-found/feature/route-not-found.component';
import { checkLoginConfiguration } from './service/guard/login-configuration.guard';
import { LoginConfiguration } from './state/login-configuration';
const routes: Routes = [
{
path: 'login',
canMatch: [
checkLoginConfiguration((loginConfiguration: LoginConfiguration) => loginConfiguration.loginSupported)
],
loadChildren: () => import('./pages/login/feature/login.module').then((m) => m.LoginModule)
},
{

View File

@ -24,15 +24,14 @@ import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { environment } from './environments/environment';
import { HTTP_INTERCEPTORS, HttpClientModule, HttpClientXsrfModule } from '@angular/common/http';
import { HttpClientModule, HttpClientXsrfModule, provideHttpClient, withInterceptors } from '@angular/common/http';
import { NavigationActionTiming, RouterState, StoreRouterConnectingModule } from '@ngrx/router-store';
import { rootReducers } from './state';
import { CurrentUserEffects } from './state/current-user/current-user.effects';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { LoadingInterceptor } from './service/interceptors/loading.interceptor';
import { AuthInterceptor } from './service/interceptors/auth.interceptor';
import { authInterceptor } from './service/interceptors/auth.interceptor';
import { ExtensionTypesEffects } from './state/extension-types/extension-types.effects';
import { PollingInterceptor } from './service/interceptors/polling.interceptor';
import { pollingInterceptor } from './service/interceptors/polling.interceptor';
import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';
import { MatNativeDateModule } from '@angular/material/core';
import { AboutEffects } from './state/about/about.effects';
@ -47,6 +46,8 @@ import { MatSnackBarModule } from '@angular/material/snack-bar';
import { PipesModule } from './pipes/pipes.module';
import { DocumentationEffects } from './state/documentation/documentation.effects';
import { ClusterSummaryEffects } from './state/cluster-summary/cluster-summary.effects';
import { loadingInterceptor } from './service/interceptors/loading.interceptor';
import { LoginConfigurationEffects } from './state/login-configuration/login-configuration.effects';
@NgModule({
declarations: [AppComponent],
@ -70,6 +71,7 @@ import { ClusterSummaryEffects } from './state/cluster-summary/cluster-summary.e
ExtensionTypesEffects,
AboutEffects,
FlowConfigurationEffects,
LoginConfigurationEffects,
StatusHistoryEffects,
ControllerServiceStateEffects,
SystemDiagnosticsEffects,
@ -89,22 +91,8 @@ import { ClusterSummaryEffects } from './state/cluster-summary/cluster-summary.e
PipesModule
],
providers: [
{
provide: HTTP_INTERCEPTORS,
useClass: LoadingInterceptor,
multi: true
},
{
provide: HTTP_INTERCEPTORS,
useClass: AuthInterceptor,
multi: true
},
{
provide: HTTP_INTERCEPTORS,
useClass: PollingInterceptor,
multi: true
},
{ provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, useValue: { appearance: 'outline' } }
{ provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, useValue: { appearance: 'outline' } },
provideHttpClient(withInterceptors([authInterceptor, loadingInterceptor, pollingInterceptor]))
],
bootstrap: [AppComponent]
})

View File

@ -15,18 +15,23 @@
* limitations under the License.
*/
import { Component } from '@angular/core';
import { Component, OnDestroy } from '@angular/core';
import { Store } from '@ngrx/store';
import { selectFullScreenError } from '../../../state/error/error.selectors';
import { NiFiState } from '../../../state';
import { resetErrorState } from '../../../state/error/error.actions';
@Component({
selector: 'error',
templateUrl: './error.component.html',
styleUrls: ['./error.component.scss']
})
export class Error {
export class Error implements OnDestroy {
errorDetail$ = this.store.select(selectFullScreenError);
constructor(private store: Store<NiFiState>) {}
ngOnDestroy(): void {
this.store.dispatch(resetErrorState());
}
}

View File

@ -34,9 +34,7 @@
<div class="flex flex-col">
<div class="flex gap-x-2 items-center">
<div class="logo flex flex-col items-center" [style.background-color]="color">
<i
class="icon accent-color icon-processor p-2"
[style.color]="contrastColor"></i>
<i class="icon accent-color icon-processor p-2" [style.color]="contrastColor"></i>
</div>
<div class="flex flex-col flex-1">
<div class="context-name w-full">Processor Name</div>

View File

@ -36,6 +36,8 @@ import { selectCurrentUser } from '../../../../../state/current-user/current-use
import * as fromUser from '../../../../../state/current-user/current-user.reducer';
import { selectFlowConfiguration } from '../../../../../state/flow-configuration/flow-configuration.selectors';
import * as fromFlowConfiguration from '../../../../../state/flow-configuration/flow-configuration.reducer';
import { selectLoginConfiguration } from '../../../../../state/login-configuration/login-configuration.selectors';
import * as fromLoginConfiguration from '../../../../../state/login-configuration/login-configuration.reducer';
describe('HeaderComponent', () => {
let component: HeaderComponent;
@ -120,6 +122,10 @@ describe('HeaderComponent', () => {
{
selector: selectFlowConfiguration,
value: fromFlowConfiguration.initialState.flowConfiguration
},
{
selector: selectLoginConfiguration,
value: fromLoginConfiguration.initialState.loginConfiguration
}
]
})

View File

@ -19,7 +19,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { EditLabel } from './edit-label.component';
import { EditComponentDialogRequest } from '../../../../../state/flow';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { ComponentType } from '../../../../../../../state/shared';
import { provideMockStore } from '@ngrx/store/testing';
import { initialState } from '../../../../../state/flow/flow.reducer';
@ -79,6 +79,10 @@ describe('EditLabel', () => {
useValue: {
isDisconnectionAcknowledged: jest.fn()
}
},
{
provide: MatDialogRef,
useValue: null
}
]
});

View File

@ -20,7 +20,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CreatePort } from './create-port.component';
import { CreateComponentRequest } from '../../../../../state/flow';
import { ComponentType } from '../../../../../../../state/shared';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { provideMockStore } from '@ngrx/store/testing';
import { initialState } from '../../../../../state/flow/flow.reducer';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
@ -44,7 +44,14 @@ describe('CreatePort', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CreatePort, NoopAnimationsModule],
providers: [{ provide: MAT_DIALOG_DATA, useValue: data }, provideMockStore({ initialState })]
providers: [
{ provide: MAT_DIALOG_DATA, useValue: data },
provideMockStore({ initialState }),
{
provide: MatDialogRef,
useValue: null
}
]
});
fixture = TestBed.createComponent(CreatePort);
component = fixture.componentInstance;

View File

@ -19,7 +19,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { EditPort } from './edit-port.component';
import { EditComponentDialogRequest } from '../../../../../state/flow';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { ComponentType } from '../../../../../../../state/shared';
import { provideMockStore } from '@ngrx/store/testing';
import { initialState } from '../../../../../state/flow/flow.reducer';
@ -105,6 +105,10 @@ describe('EditPort', () => {
useValue: {
isDisconnectionAcknowledged: jest.fn()
}
},
{
provide: MatDialogRef,
useValue: null
}
]
});

View File

@ -19,7 +19,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CreateProcessGroup } from './create-process-group.component';
import { CreateProcessGroupDialogRequest } from '../../../../../state/flow';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { ComponentType } from '../../../../../../../state/shared';
import { provideMockStore } from '@ngrx/store/testing';
import { initialState } from '../../../../../state/flow/flow.reducer';
@ -156,7 +156,14 @@ describe('CreateProcessGroup', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CreateProcessGroup, NoopAnimationsModule],
providers: [{ provide: MAT_DIALOG_DATA, useValue: data }, provideMockStore({ initialState })]
providers: [
{ provide: MAT_DIALOG_DATA, useValue: data },
provideMockStore({ initialState }),
{
provide: MatDialogRef,
useValue: null
}
]
});
fixture = TestBed.createComponent(CreateProcessGroup);
component = fixture.componentInstance;

View File

@ -19,7 +19,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CreateProcessor } from './create-processor.component';
import { CreateProcessorDialogRequest } from '../../../../../state/flow';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { provideMockStore } from '@ngrx/store/testing';
import { initialState } from '../../../../../../../state/extension-types/extension-types.reducer';
import { ComponentType } from '../../../../../../../state/shared';
@ -60,7 +60,14 @@ describe('CreateProcessor', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CreateProcessor, NoopAnimationsModule],
providers: [{ provide: MAT_DIALOG_DATA, useValue: data }, provideMockStore({ initialState })]
providers: [
{ provide: MAT_DIALOG_DATA, useValue: data },
provideMockStore({ initialState }),
{
provide: MatDialogRef,
useValue: null
}
]
});
fixture = TestBed.createComponent(CreateProcessor);
component = fixture.componentInstance;

View File

@ -18,7 +18,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CreateRemoteProcessGroup } from './create-remote-process-group.component';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { ComponentType } from '../../../../../../../state/shared';
import { provideMockStore } from '@ngrx/store/testing';
import { initialState } from '../../../../../state/flow/flow.reducer';
@ -44,7 +44,14 @@ describe('CreateRemoteProcessGroup', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CreateRemoteProcessGroup, NoopAnimationsModule],
providers: [{ provide: MAT_DIALOG_DATA, useValue: data }, provideMockStore({ initialState })]
providers: [
{ provide: MAT_DIALOG_DATA, useValue: data },
provideMockStore({ initialState }),
{
provide: MatDialogRef,
useValue: null
}
]
});
fixture = TestBed.createComponent(CreateRemoteProcessGroup);
component = fixture.componentInstance;

View File

@ -15,32 +15,22 @@
~ limitations under the License.
-->
<div class="login-background pt-24 pl-24 h-screen">
@if (access$ | async; as access) {
@if (access.status === 'pending' || access.status === 'loading') {
<div class="w-96">
<ngx-skeleton-loader count="3"></ngx-skeleton-loader>
</div>
} @else {
@if (access.error) {
<page-content [title]="access.error.title">
<div class="text-sm">{{ access.error.message }}</div>
@if (loading) {
<div class="splash h-screen p-20">
<div class="splash-img h-full flex items-center justify-center">
<mat-spinner color="warn"></mat-spinner>
</div>
</div>
} @else {
<div class="login-background pt-24 pl-24 h-screen">
@if (currentUserState$ | async; as userState) {
@if (userState.status === 'success') {
<page-content [title]="'Success'">
<div class="text-sm">Already logged in. Click home to return to canvas.</div>
</page-content>
} @else {
@if (access.accessStatus.status === 'ACTIVE') {
<page-content [title]="'Success'">
<div class="text-sm">{{ access.accessStatus.message }}</div>
</page-content>
} @else {
@if (access.accessConfig.supportsLogin) {
<login-form></login-form>
} @else {
<page-content [title]="'Access Denied'">
<div class="text-sm">This NiFi is not configured to support username/password logins.</div>
</page-content>
}
}
<login-form></login-form>
}
}
}
</div>
</div>
}

View File

@ -15,23 +15,36 @@
* limitations under the License.
*/
import { Component, OnInit } from '@angular/core';
import { Component } from '@angular/core';
import { Store } from '@ngrx/store';
import { LoginState } from '../state';
import { selectAccess } from '../state/access/access.selectors';
import { loadAccess } from '../state/access/access.actions';
import { selectCurrentUserState } from '../../../state/current-user/current-user.selectors';
import { take } from 'rxjs';
import { selectLoginConfiguration } from '../../../state/login-configuration/login-configuration.selectors';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { isDefinedAndNotNull } from '../../../state/shared';
@Component({
selector: 'login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.scss']
})
export class Login implements OnInit {
access$ = this.store.select(selectAccess);
export class Login {
currentUserState$ = this.store.select(selectCurrentUserState).pipe(take(1));
loginConfiguration = this.store.selectSignal(selectLoginConfiguration);
constructor(private store: Store<LoginState>) {}
loading: boolean = true;
ngOnInit(): void {
this.store.dispatch(loadAccess());
constructor(private store: Store<LoginState>) {
this.store
.select(selectLoginConfiguration)
.pipe(isDefinedAndNotNull(), takeUntilDestroyed())
.subscribe((loginConfiguration) => {
if (loginConfiguration.externalLoginRequired) {
window.location.href = loginConfiguration.loginUri;
} else {
this.loading = false;
}
});
}
}

View File

@ -30,6 +30,7 @@ import { AccessEffects } from '../state/access/access.effects';
import { LoginForm } from '../ui/login-form/login-form.component';
import { PageContent } from '../../../ui/common/page-content/page-content.component';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
@NgModule({
declarations: [Login, LoginForm],
@ -45,6 +46,7 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
MatInputModule,
MatButtonModule,
NgxSkeletonLoaderModule,
MatProgressSpinnerModule,
PageContent
]
})

View File

@ -16,21 +16,12 @@
*/
import { createAction, props } from '@ngrx/store';
import { AccessApiError, LoadAccessResponse, LoginRequest } from './index';
export const loadAccess = createAction('[Access] Load Access');
export const loadAccessSuccess = createAction(
'[Access] Load Access Success',
props<{ response: LoadAccessResponse }>()
);
export const accessApiError = createAction('[Access] Load Access Error', props<{ error: AccessApiError }>());
import { LoginRequest } from './index';
export const login = createAction('[Access] Login', props<{ request: LoginRequest }>());
export const loginFailure = createAction('[Access] Login Failure', props<{ failure: string }>());
export const loginSuccess = createAction('[Access] Login Success');
export const verifyAccess = createAction('[Access] Verify Access');
export const loginFailure = createAction('[Access] Login Failure', props<{ loginFailure: string }>());
export const verifyAccessSuccess = createAction('[Access] Verify Access Success');
export const resetLoginFailure = createAction('[Access] Reset Login Failure');

View File

@ -18,134 +18,75 @@
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import * as AccessActions from './access.actions';
import { catchError, combineLatest, from, map, of, switchMap, tap } from 'rxjs';
import { catchError, from, map, of, switchMap, tap } from 'rxjs';
import { AuthService } from '../../../../service/auth.service';
import { AuthStorage } from '../../../../service/auth-storage.service';
import { Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { OkDialog } from '../../../../ui/common/ok-dialog/ok-dialog.component';
import { MEDIUM_DIALOG } from '../../../../index';
import { ErrorHelper } from '../../../../service/error-helper.service';
import { HttpErrorResponse } from '@angular/common/http';
import { Store } from '@ngrx/store';
import { NiFiState } from '../../../../state';
import { resetLoginFailure } from './access.actions';
@Injectable()
export class AccessEffects {
constructor(
private actions$: Actions,
private store: Store<NiFiState>,
private authService: AuthService,
private authStorage: AuthStorage,
private router: Router,
private dialog: MatDialog,
private errorHelper: ErrorHelper
) {}
loadAccess$ = createEffect(() =>
this.actions$.pipe(
ofType(AccessActions.loadAccess),
switchMap(() =>
combineLatest([this.authService.accessConfig(), this.authService.accessStatus()]).pipe(
map(([accessConfig, accessStatus]) =>
AccessActions.loadAccessSuccess({
response: {
accessConfig: accessConfig.config,
accessStatus: accessStatus.accessStatus
}
})
),
catchError((errorResponse: HttpErrorResponse) =>
of(
AccessActions.accessApiError({
error: {
title: 'Unable to check Access Status',
message: this.errorHelper.getErrorString(errorResponse)
}
})
)
)
)
)
)
);
login$ = createEffect(() =>
this.actions$.pipe(
ofType(AccessActions.login),
map((action) => action.request),
switchMap((request) =>
from(this.authService.login(request.username, request.password)).pipe(
map((jwt) => {
const sessionExpiration: string | null = this.authService.getSessionExpiration(jwt);
if (sessionExpiration) {
this.authStorage.setToken(sessionExpiration);
}
return AccessActions.verifyAccess();
}),
map(() => AccessActions.loginSuccess()),
catchError((errorResponse: HttpErrorResponse) =>
of(AccessActions.loginFailure({ failure: this.errorHelper.getErrorString(errorResponse) }))
of(AccessActions.loginFailure({ loginFailure: this.errorHelper.getErrorString(errorResponse) }))
)
)
)
)
);
loginFailure$ = createEffect(
loginSuccess$ = createEffect(
() =>
this.actions$.pipe(
ofType(AccessActions.loginFailure),
map((action) => action.failure),
tap((failure) => {
this.dialog.open(OkDialog, {
...MEDIUM_DIALOG,
data: {
title: 'Login',
message: failure
}
});
})
),
{ dispatch: false }
);
verifyAccess$ = createEffect(() =>
this.actions$.pipe(
ofType(AccessActions.verifyAccess),
switchMap(() =>
from(this.authService.accessStatus()).pipe(
map((response) => {
if (response.accessStatus.status === 'ACTIVE') {
return AccessActions.verifyAccessSuccess();
} else {
return AccessActions.accessApiError({
error: {
title: 'Unable to log in',
message: response.accessStatus.message
}
});
}
}),
catchError((errorResponse: HttpErrorResponse) =>
of(
AccessActions.accessApiError({
error: {
title: 'Unable to log in',
message: this.errorHelper.getErrorString(errorResponse)
}
})
)
)
)
)
)
);
verifyAccessSuccess$ = createEffect(
() =>
this.actions$.pipe(
ofType(AccessActions.verifyAccessSuccess),
ofType(AccessActions.loginSuccess),
tap(() => {
this.router.navigate(['/']);
})
),
{ dispatch: false }
);
loginFailure$ = createEffect(
() =>
this.actions$.pipe(
ofType(AccessActions.loginFailure),
map((action) => action.loginFailure),
tap((loginFailure) => {
this.dialog
.open(OkDialog, {
...MEDIUM_DIALOG,
data: {
title: 'Login',
message: loginFailure
}
})
.afterClosed()
.subscribe(() => {
this.store.dispatch(resetLoginFailure());
});
})
),
{ dispatch: false }
);
}

View File

@ -16,42 +16,21 @@
*/
import { createReducer, on } from '@ngrx/store';
import { Access, AccessConfig, AccessStatus } from './index';
import { accessApiError, loadAccess, loadAccessSuccess } from './access.actions';
export const INITIAL_STATUS: AccessStatus = {
identity: '',
status: 'UNKNOWN',
message: ''
};
export const INITIAL_CONFIG: AccessConfig = {
supportsLogin: false
};
import { Access } from './index';
import { loginFailure, resetLoginFailure } from './access.actions';
export const initialState: Access = {
accessStatus: INITIAL_STATUS,
accessConfig: INITIAL_CONFIG,
error: null,
status: 'pending'
loginFailure: null
};
export const accessReducer = createReducer(
initialState,
on(loadAccess, (state) => ({
on(loginFailure, (state, { loginFailure }) => ({
...state,
status: 'loading' as const
loginFailure
})),
on(loadAccessSuccess, (state, { response }) => ({
on(resetLoginFailure, (state) => ({
...state,
accessStatus: response.accessStatus,
accessConfig: response.accessConfig,
error: null,
status: 'success' as const
})),
on(accessApiError, (state, { error }) => ({
...state,
error,
status: 'error' as const
loginFailure: null
}))
);

View File

@ -17,6 +17,8 @@
import { createSelector } from '@ngrx/store';
import { LoginState, selectLoginState } from '../index';
import { accessFeatureKey } from './index';
import { Access, accessFeatureKey } from './index';
export const selectAccess = createSelector(selectLoginState, (state: LoginState) => state[accessFeatureKey]);
export const selectLoginFailure = createSelector(selectAccess, (access: Access) => access.loginFailure);

View File

@ -22,29 +22,6 @@ export interface LoginRequest {
password: string;
}
export interface LoadAccessResponse {
accessStatus: AccessStatus;
accessConfig: AccessConfig;
}
export interface AccessStatus {
identity: string;
status: string;
message: string;
}
export interface AccessConfig {
supportsLogin: boolean;
}
export interface AccessApiError {
title: string;
message: string;
}
export interface Access {
accessStatus: AccessStatus;
accessConfig: AccessConfig;
error: AccessApiError | null;
status: 'pending' | 'loading' | 'error' | 'success';
loginFailure: string | null;
}

View File

@ -19,10 +19,10 @@
<div class="flex justify-between items-center">
<h3 class="primary-color">Log In</h3>
<div class="flex gap-x-2">
@if (hasToken()) {
@if (logoutSupported()) {
<a (click)="logout()">log out</a>
}
<a [routerLink]="['/']">home</a>
<a (click)="resetRoutedToFullScreenError()" [routerLink]="['/']">home</a>
</div>
</div>
<div>

View File

@ -27,6 +27,7 @@ import { RouterTestingModule } from '@angular/router/testing';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { currentUserFeatureKey } from '../../../../state/current-user';
describe('LoginForm', () => {
let component: LoginForm;
@ -45,7 +46,13 @@ describe('LoginForm', () => {
ReactiveFormsModule,
MatInputModule
],
providers: [provideMockStore({ initialState })]
providers: [
provideMockStore({
initialState: {
[currentUserFeatureKey]: initialState
}
})
]
});
fixture = TestBed.createComponent(LoginForm);
component = fixture.componentInstance;

View File

@ -17,11 +17,14 @@
import { Component } from '@angular/core';
import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms';
import { AuthStorage } from '../../../../service/auth-storage.service';
import { Store } from '@ngrx/store';
import { LoginState } from '../../state';
import { login } from '../../state/access/access.actions';
import { AuthService } from '../../../../service/auth.service';
import { selectLoginFailure } from '../../state/access/access.selectors';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { selectLogoutSupported } from '../../../../state/current-user/current-user.selectors';
import { NiFiState } from '../../../../state';
import { setRoutedToFullScreenError } from '../../../../state/error/error.actions';
import { logout } from '../../../../state/current-user/current-user.actions';
@Component({
selector: 'login-form',
@ -29,27 +32,36 @@ import { AuthService } from '../../../../service/auth.service';
styleUrls: ['./login-form.component.scss']
})
export class LoginForm {
logoutSupported = this.store.selectSignal(selectLogoutSupported);
loginForm: FormGroup;
constructor(
private formBuilder: FormBuilder,
private store: Store<LoginState>,
private authStorage: AuthStorage,
private authService: AuthService
private store: Store<NiFiState>
) {
// build the form
this.loginForm = this.formBuilder.group({
username: new FormControl('', Validators.required),
password: new FormControl('', Validators.required)
});
}
hasToken(): boolean {
return this.authStorage.hasToken();
this.store
.select(selectLoginFailure)
.pipe(takeUntilDestroyed())
.subscribe((loginFailure) => {
if (loginFailure) {
this.loginForm.get('password')?.setValue('');
}
});
}
logout(): void {
this.authService.logout();
this.store.dispatch(logout());
}
resetRoutedToFullScreenError(): void {
this.store.dispatch(setRoutedToFullScreenError({ routedToFullScreenError: false }));
}
login() {

View File

@ -21,6 +21,9 @@ import { RouteNotFound } from './route-not-found.component';
import { PageContent } from '../../../ui/common/page-content/page-content.component';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { provideMockStore } from '@ngrx/store/testing';
import { currentUserFeatureKey } from '../../../state/current-user';
import { initialState } from '../../../state/current-user/current-user.reducer';
describe('RouteNotFound', () => {
let component: RouteNotFound;
@ -29,7 +32,14 @@ describe('RouteNotFound', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [RouteNotFound],
imports: [PageContent, HttpClientTestingModule, RouterTestingModule]
imports: [PageContent, HttpClientTestingModule, RouterTestingModule],
providers: [
provideMockStore({
initialState: {
[currentUserFeatureKey]: initialState
}
})
]
}).compileComponents();
fixture = TestBed.createComponent(RouteNotFound);

View File

@ -20,7 +20,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UserAccessPolicies } from './user-access-policies.component';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { UserAccessPoliciesDialogRequest } from '../../../state/user-listing';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
describe('UserAccessPolicies', () => {
let component: UserAccessPolicies;
@ -53,7 +53,13 @@ describe('UserAccessPolicies', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [UserAccessPolicies, NoopAnimationsModule],
providers: [{ provide: MAT_DIALOG_DATA, useValue: data }]
providers: [
{ provide: MAT_DIALOG_DATA, useValue: data },
{
provide: MatDialogRef,
useValue: null
}
]
});
fixture = TestBed.createComponent(UserAccessPolicies);
component = fixture.componentInstance;

View File

@ -167,7 +167,8 @@ describe('UserTable', () => {
}
}
],
canVersionFlows: false
canVersionFlows: false,
logoutSupported: true
};
beforeEach(() => {

View File

@ -1,75 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class AuthStorage {
private static readonly TOKEN_ITEM_KEY: string = 'Access-Token-Expiration';
private static readonly REQUEST_TOKEN_PATTERN: RegExp = new RegExp('Request-Token=([^;]+)');
/**
* Get Request Token from document cookies
*
* @return Request Token string or null when not found
*/
public getRequestToken(): string | null {
const requestTokenMatcher = AuthStorage.REQUEST_TOKEN_PATTERN.exec(document.cookie);
if (requestTokenMatcher) {
return requestTokenMatcher[1];
}
return null;
}
/**
* Get Token from Session Storage
*
* @return Bearer Token string
*/
public getToken(): string | null {
return sessionStorage.getItem(AuthStorage.TOKEN_ITEM_KEY);
}
/**
* Has Token returns the status of whether Session Storage contains the Token
*
* @return Boolean status of whether Session Storage contains the Token
*/
public hasToken(): boolean {
return typeof this.getToken() === 'string';
}
/**
* Remove Token from Session Storage
*
*/
public removeToken(): void {
sessionStorage.removeItem(AuthStorage.TOKEN_ITEM_KEY);
}
/**
* Set Token in Session Storage
*
* @param token Token String
*/
public setToken(token: string): void {
sessionStorage.setItem(AuthStorage.TOKEN_ITEM_KEY, token);
}
}

View File

@ -16,33 +16,17 @@
*/
import { Injectable } from '@angular/core';
import { Observable, take } from 'rxjs';
import { Observable } from 'rxjs';
import { HttpClient, HttpParams } from '@angular/common/http';
import { AuthStorage } from './auth-storage.service';
@Injectable({ providedIn: 'root' })
export class AuthService {
private static readonly API: string = '../nifi-api';
constructor(
private httpClient: HttpClient,
private authStorage: AuthStorage
) {}
constructor(private httpClient: HttpClient) {}
public kerberos(): Observable<string> {
return this.httpClient.post<string>(`${AuthService.API}/access/kerberos`, null);
}
public ticketExpiration(): Observable<any> {
return this.httpClient.get(`${AuthService.API}/access/token/expiration`);
}
public accessConfig(): Observable<any> {
return this.httpClient.get(`${AuthService.API}/access/config`);
}
public accessStatus(): Observable<any> {
return this.httpClient.get(`${AuthService.API}/access`);
public getLoginConfiguration(): Observable<any> {
return this.httpClient.get(`${AuthService.API}/authentication/configuration`);
}
public login(username: string, password: string): Observable<string> {
@ -53,61 +37,7 @@ export class AuthService {
});
}
public logout(): void {
this.httpClient
.delete(`${AuthService.API}/access/logout`)
.pipe(take(1))
.subscribe(() => {
this.authStorage.removeToken();
window.location.href = './logout';
});
}
/**
* Extracts the subject from the specified jwt. If the jwt is not as expected
* an empty string is returned.
*
* @param {string} jwt
* @returns {string}
*/
public getJwtPayload(jwt: string): any {
if (jwt) {
const segments: string[] = jwt.split(/\./);
if (segments.length !== 3) {
return null;
}
const rawPayload: string = atob(segments[1]);
return JSON.parse(rawPayload);
}
return null;
}
/**
* Get Session Expiration from JSON Web Token Payload exp claim
*
* @param {string} jwt
* @return {string}
*/
public getSessionExpiration(jwt: string): string | null {
const jwtPayload = this.getJwtPayload(jwt);
if (jwtPayload) {
return jwtPayload['exp'];
}
return null;
}
/**
* Get Default Session Expiration based on current time plus 12 hours as seconds
*
* @return {string}
*/
public getDefaultExpiration(): string {
const now: Date = new Date();
const expiration: number = now.getTime() + 43200000;
const expirationSeconds: number = Math.round(expiration / 1000);
return expirationSeconds.toString();
public logout(): Observable<any> {
return this.httpClient.delete(`${AuthService.API}/access/logout`);
}
}

View File

@ -19,12 +19,9 @@ import { Injectable } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import * as ErrorActions from '../state/error/error.actions';
import { Action } from '@ngrx/store';
import { NiFiCommon } from './nifi-common.service';
@Injectable({ providedIn: 'root' })
export class ErrorHelper {
constructor(private nifiCommon: NiFiCommon) {}
fullScreenError(errorResponse: HttpErrorResponse, skipReplaceUrl?: boolean): Action {
let title: string;
let message: string;
@ -51,6 +48,8 @@ export class ErrorHelper {
if (errorResponse.status === 0 || !errorResponse.error) {
message =
'An error occurred communicating with NiFi. Please check the logs and fix any configuration issues before restarting.';
} else if (errorResponse.status === 401) {
message = 'Your session has expired. Please navigate home to log in again.';
} else {
message = this.getErrorString(errorResponse);
}

View File

@ -18,118 +18,88 @@
import { CanMatchFn } from '@angular/router';
import { inject } from '@angular/core';
import { AuthService } from '../auth.service';
import { AuthStorage } from '../auth-storage.service';
import { take } from 'rxjs';
import { catchError, from, map, of, switchMap, take, tap } from 'rxjs';
import { CurrentUserService } from '../current-user.service';
import { Store } from '@ngrx/store';
import { CurrentUserState } from '../../state/current-user';
import { loadCurrentUserSuccess } from '../../state/current-user/current-user.actions';
import { selectCurrentUserState } from '../../state/current-user/current-user.selectors';
import { HttpErrorResponse } from '@angular/common/http';
import { fullScreenError } from '../../state/error/error.actions';
import { ErrorHelper } from '../error-helper.service';
import { selectLoginConfiguration } from '../../state/login-configuration/login-configuration.selectors';
import { loadLoginConfigurationSuccess } from '../../state/login-configuration/login-configuration.actions';
export const authenticationGuard: CanMatchFn = () => {
const authStorage: AuthStorage = inject(AuthStorage);
const authService: AuthService = inject(AuthService);
const userService: CurrentUserService = inject(CurrentUserService);
const errorHelper: ErrorHelper = inject(ErrorHelper);
const store: Store<CurrentUserState> = inject(Store<CurrentUserState>);
const handleAuthentication: Promise<boolean> = new Promise((resolve) => {
if (authStorage.hasToken()) {
resolve(true);
} else {
authService
.kerberos()
.pipe(take(1))
.subscribe({
next: (jwt: string) => {
// Use Expiration from JWT for tracking authentication status
const sessionExpiration: string | null = authService.getSessionExpiration(jwt);
if (sessionExpiration) {
authStorage.setToken(sessionExpiration);
}
resolve(true);
},
error: () => {
authService
.ticketExpiration()
.pipe(take(1))
.subscribe({
next: (accessTokenExpirationEntity: any) => {
const accessTokenExpiration: any =
accessTokenExpirationEntity.accessTokenExpiration;
// Convert ISO 8601 string to session expiration in seconds
const expiration: number = Date.parse(accessTokenExpiration.expiration);
const expirationSeconds: number = expiration / 1000;
const sessionExpiration: number = Math.round(expirationSeconds);
authStorage.setToken(String(sessionExpiration));
resolve(true);
},
error: () => {
resolve(false);
const getAuthenticationConfig = store.select(selectLoginConfiguration).pipe(
take(1),
switchMap((loginConfiguration) => {
if (loginConfiguration) {
return of(loginConfiguration);
} else {
return from(authService.getLoginConfiguration()).pipe(
tap((response) => {
store.dispatch(
loadLoginConfigurationSuccess({
response: {
loginConfiguration: response.authenticationConfiguration
}
});
}
});
}
});
})
);
})
);
}
})
);
return new Promise<boolean>((resolve) => {
handleAuthentication.finally(() => {
store
.select(selectCurrentUserState)
.pipe(take(1))
.subscribe((userState) => {
return getAuthenticationConfig.pipe(
switchMap((authConfigResponse) => {
return store.select(selectCurrentUserState).pipe(
take(1),
switchMap((userState) => {
if (userState.status == 'success') {
resolve(true);
return of(true);
} else {
userService
.getUser()
.pipe(take(1))
.subscribe({
next: (response) => {
// store the loaded user
store.dispatch(
loadCurrentUserSuccess({
response: {
user: response
}
})
);
if (authStorage.hasToken()) {
resolve(true);
} else {
authService
.accessConfig()
.pipe(take(1))
.subscribe({
next: (response) => {
if (response.config.supportsLogin) {
// Set default expiration when authenticated to enable logout status
const expiration: string = authService.getDefaultExpiration();
authStorage.setToken(expiration);
}
resolve(true);
},
error: () => {
window.location.href = './login';
resolve(false);
}
});
}
},
error: (error) => {
// there is no anonymous access and we don't know this user - open the login page which handles login
if (error.status === 401) {
authStorage.removeToken();
window.location.href = './login';
}
resolve(false);
return from(userService.getUser()).pipe(
tap((response) => {
store.dispatch(
loadCurrentUserSuccess({
response: {
user: response
}
})
);
}),
map(() => true),
catchError((errorResponse: HttpErrorResponse) => {
if (errorResponse.status !== 401 || authConfigResponse.loginSupported) {
store.dispatch(errorHelper.fullScreenError(errorResponse));
}
});
return of(false);
})
);
}
});
});
});
})
);
}),
catchError(() => {
store.dispatch(
fullScreenError({
errorDetail: {
title: 'Unauthorized',
message:
'Unable to load authentication configuration. Please contact your system administrator.'
}
})
);
return of(false);
})
);
};

View File

@ -0,0 +1,78 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CanMatchFn } from '@angular/router';
import { inject } from '@angular/core';
import { catchError, map, of, switchMap, tap } from 'rxjs';
import { Store } from '@ngrx/store';
import { fullScreenError } from '../../state/error/error.actions';
import { HttpErrorResponse } from '@angular/common/http';
import { ErrorHelper } from '../error-helper.service';
import { selectLoginConfiguration } from '../../state/login-configuration/login-configuration.selectors';
import { AuthService } from '../auth.service';
import { loadLoginConfigurationSuccess } from '../../state/login-configuration/login-configuration.actions';
import { LoginConfiguration, LoginConfigurationState } from '../../state/login-configuration';
export const checkLoginConfiguration = (
loginConfigurationCheck: (loginConfiguration: LoginConfiguration) => boolean
): CanMatchFn => {
return () => {
const store: Store<LoginConfigurationState> = inject(Store<LoginConfigurationState>);
const authService: AuthService = inject(AuthService);
const errorHelper: ErrorHelper = inject(ErrorHelper);
return store.select(selectLoginConfiguration).pipe(
switchMap((loginConfiguration) => {
if (loginConfiguration) {
return of(loginConfiguration);
} else {
return authService.getLoginConfiguration().pipe(
tap((response) =>
store.dispatch(
loadLoginConfigurationSuccess({
response: {
loginConfiguration: response.authenticationConfiguration
}
})
)
)
);
}
}),
map((loginConfiguration) => {
if (loginConfigurationCheck(loginConfiguration)) {
return true;
}
store.dispatch(
fullScreenError({
skipReplaceUrl: true,
errorDetail: {
title: 'Unable to load',
message: 'Login configuration check failed'
}
})
);
return false;
}),
catchError((errorResponse: HttpErrorResponse) => {
store.dispatch(errorHelper.fullScreenError(errorResponse, true));
return of(false);
})
);
};
};

View File

@ -15,61 +15,58 @@
* limitations under the License.
*/
import { Injectable } from '@angular/core';
import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Observable, tap } from 'rxjs';
import { AuthStorage } from '../auth-storage.service';
import { inject } from '@angular/core';
import { HttpErrorResponse, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
import { catchError, map, take, combineLatest, tap } from 'rxjs';
import { Store } from '@ngrx/store';
import { NiFiState } from '../../state';
import { fullScreenError } from '../../state/error/error.actions';
import { NiFiCommon } from '../nifi-common.service';
import { fullScreenError, setRoutedToFullScreenError } from '../../state/error/error.actions';
import { selectCurrentUserState } from '../../state/current-user/current-user.selectors';
import { navigateToLogIn, resetCurrentUser } from '../../state/current-user/current-user.actions';
import { selectRoutedToFullScreenError } from '../../state/error/error.selectors';
import { selectLoginConfiguration } from '../../state/login-configuration/login-configuration.selectors';
@Injectable({
providedIn: 'root'
})
export class AuthInterceptor implements HttpInterceptor {
routedToFullScreenError = false;
export const authInterceptor: HttpInterceptorFn = (request: HttpRequest<unknown>, next: HttpHandlerFn) => {
const store: Store<NiFiState> = inject(Store<NiFiState>);
constructor(
private authStorage: AuthStorage,
private store: Store<NiFiState>,
private nifiCommon: NiFiCommon
) {}
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
return next.handle(request).pipe(
tap({
error: (errorResponse) => {
if (errorResponse instanceof HttpErrorResponse) {
if (errorResponse.status === 401) {
if (this.authStorage.hasToken()) {
this.routedToFullScreenError = true;
this.authStorage.removeToken();
let message: string = errorResponse.error;
if (this.nifiCommon.isBlank(message)) {
message = 'Your session has expired. Please navigate home to log in again.';
} else {
message += '. Please navigate home to log in again.';
}
this.store.dispatch(
fullScreenError({
errorDetail: {
title: 'Unauthorized',
message
}
})
);
} else if (!this.routedToFullScreenError) {
// the user has never logged in, redirect them to do so
window.location.href = './login';
}
return next(request).pipe(
catchError((errorResponse) => {
if (errorResponse instanceof HttpErrorResponse && errorResponse.status === 401) {
return combineLatest([
store.select(selectCurrentUserState).pipe(
take(1),
tap(() => store.dispatch(resetCurrentUser()))
),
store.select(selectLoginConfiguration).pipe(take(1)),
store.select(selectRoutedToFullScreenError).pipe(
take(1),
tap(() => store.dispatch(setRoutedToFullScreenError({ routedToFullScreenError: true })))
)
]).pipe(
map(([currentUserState, loginConfiguration, routedToFullScreenError]) => {
if (
currentUserState.status === 'pending' &&
loginConfiguration?.loginSupported &&
!routedToFullScreenError
) {
store.dispatch(navigateToLogIn());
} else {
store.dispatch(
fullScreenError({
errorDetail: {
title: 'Unauthorized',
message: 'Your session has expired. Please navigate home to log in again.'
}
})
);
}
}
}
})
);
}
}
throw errorResponse;
})
);
} else {
throw errorResponse;
}
})
);
};

View File

@ -15,20 +15,14 @@
* limitations under the License.
*/
import { Injectable } from '@angular/core';
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { finalize, Observable } from 'rxjs';
import { inject } from '@angular/core';
import { HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
import { finalize } from 'rxjs';
import { LoadingService } from '../loading.service';
@Injectable({
providedIn: 'root'
})
export class LoadingInterceptor implements HttpInterceptor {
constructor(private loadingService: LoadingService) {}
export const loadingInterceptor: HttpInterceptorFn = (request: HttpRequest<unknown>, next: HttpHandlerFn) => {
const loadingService: LoadingService = inject(LoadingService);
loadingService.set(true, request.url);
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
this.loadingService.set(true, request.url);
return next.handle(request).pipe(finalize(() => this.loadingService.set(false, request.url)));
}
}
return next(request).pipe(finalize(() => loadingService.set(false, request.url)));
};

View File

@ -15,32 +15,27 @@
* limitations under the License.
*/
import { Injectable } from '@angular/core';
import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Observable, tap } from 'rxjs';
import { inject } from '@angular/core';
import { HttpErrorResponse, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
import { tap } from 'rxjs';
import { NiFiState } from '../../state';
import { Store } from '@ngrx/store';
import { stopCurrentUserPolling } from '../../state/current-user/current-user.actions';
import { stopProcessGroupPolling } from '../../pages/flow-designer/state/flow/flow.actions';
import { stopClusterSummaryPolling } from '../../state/cluster-summary/cluster-summary.actions';
@Injectable({
providedIn: 'root'
})
export class PollingInterceptor implements HttpInterceptor {
constructor(private store: Store<NiFiState>) {}
export const pollingInterceptor: HttpInterceptorFn = (request: HttpRequest<unknown>, next: HttpHandlerFn) => {
const store: Store<NiFiState> = inject(Store<NiFiState>);
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
return next.handle(request).pipe(
tap({
error: (error) => {
if (error instanceof HttpErrorResponse && error.status === 0) {
this.store.dispatch(stopCurrentUserPolling());
this.store.dispatch(stopProcessGroupPolling());
this.store.dispatch(stopClusterSummaryPolling());
}
return next(request).pipe(
tap({
error: (error) => {
if (error instanceof HttpErrorResponse && error.status === 0) {
store.dispatch(stopCurrentUserPolling());
store.dispatch(stopProcessGroupPolling());
store.dispatch(stopClusterSummaryPolling());
}
})
);
}
}
}
})
);
};

View File

@ -28,3 +28,11 @@ export const loadCurrentUserSuccess = createAction(
export const startCurrentUserPolling = createAction('[Current User] Start Current User Polling');
export const stopCurrentUserPolling = createAction('[Current User] Stop Current User Polling');
export const resetCurrentUser = createAction('[Current User] Reset Current User');
export const navigateToLogIn = createAction('[Current User] Navigate To Log In');
export const logout = createAction('[Current User] Log Out');
export const navigateToLogOut = createAction('[Current User] Navigate To Log Out');

View File

@ -18,15 +18,25 @@
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import * as UserActions from './current-user.actions';
import { asyncScheduler, catchError, from, interval, map, of, switchMap, takeUntil } from 'rxjs';
import { asyncScheduler, catchError, from, interval, map, of, switchMap, takeUntil, tap } from 'rxjs';
import { CurrentUserService } from '../../service/current-user.service';
import { ErrorHelper } from '../../service/error-helper.service';
import { concatLatestFrom } from '@ngrx/operators';
import { Store } from '@ngrx/store';
import { NiFiState } from '../index';
import { selectLogoutUri } from '../login-configuration/login-configuration.selectors';
import { Router } from '@angular/router';
import { AuthService } from '../../service/auth.service';
import { HttpErrorResponse } from '@angular/common/http';
@Injectable()
export class CurrentUserEffects {
constructor(
private actions$: Actions,
private store: Store<NiFiState>,
private router: Router,
private userService: CurrentUserService,
private authService: AuthService,
private errorHelper: ErrorHelper
) {}
@ -61,4 +71,43 @@ export class CurrentUserEffects {
switchMap(() => of(UserActions.loadCurrentUser()))
)
);
navigateToLogIn$ = createEffect(
() =>
this.actions$.pipe(
ofType(UserActions.navigateToLogIn),
tap(() => {
this.router.navigate(['/login']);
})
),
{ dispatch: false }
);
logout$ = createEffect(() =>
this.actions$.pipe(
ofType(UserActions.logout),
switchMap(() =>
from(this.authService.logout()).pipe(
map(() => UserActions.navigateToLogOut()),
catchError((errorResponse: HttpErrorResponse) =>
of(this.errorHelper.fullScreenError(errorResponse))
)
)
)
)
);
navigateToLogOut$ = createEffect(
() =>
this.actions$.pipe(
ofType(UserActions.navigateToLogOut),
concatLatestFrom(() => this.store.select(selectLogoutUri)),
tap(([, logoutUri]) => {
if (logoutUri) {
window.location.href = logoutUri;
}
})
),
{ dispatch: false }
);
}

View File

@ -18,7 +18,7 @@
import { createReducer, on } from '@ngrx/store';
import { CurrentUserState } from './index';
import { Permissions } from '../shared';
import { loadCurrentUser, loadCurrentUserSuccess } from './current-user.actions';
import { loadCurrentUser, loadCurrentUserSuccess, resetCurrentUser } from './current-user.actions';
export const NO_PERMISSIONS: Permissions = {
canRead: false,
@ -30,6 +30,7 @@ export const initialState: CurrentUserState = {
identity: '',
anonymous: true,
canVersionFlows: false,
logoutSupported: false,
controllerPermissions: NO_PERMISSIONS,
countersPermissions: NO_PERMISSIONS,
parameterContextPermissions: NO_PERMISSIONS,
@ -53,5 +54,8 @@ export const currentUserReducer = createReducer(
...state,
user: response.user,
status: 'success' as const
})),
on(resetCurrentUser, () => ({
...initialState
}))
);

View File

@ -16,8 +16,13 @@
*/
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { currentUserFeatureKey, CurrentUserState } from './index';
import { CurrentUser, currentUserFeatureKey, CurrentUserState } from './index';
export const selectCurrentUserState = createFeatureSelector<CurrentUserState>(currentUserFeatureKey);
export const selectCurrentUser = createSelector(selectCurrentUserState, (state: CurrentUserState) => state.user);
export const selectLogoutSupported = createSelector(
selectCurrentUser,
(currentUser: CurrentUser) => currentUser.logoutSupported
);

View File

@ -32,6 +32,7 @@ export interface CurrentUser {
identity: string;
anonymous: boolean;
canVersionFlows: boolean;
logoutSupported: boolean;
provenancePermissions: Permissions;
countersPermissions: Permissions;
tenantsPermissions: Permissions;

View File

@ -30,3 +30,8 @@ export const addBannerError = createAction('[Error] Add Banner Error', props<{ e
export const clearBannerErrors = createAction('[Error] Clear Banner Errors');
export const resetErrorState = createAction('[Error] Reset Error State');
export const setRoutedToFullScreenError = createAction(
'[Error] Set Routed To Full Screen Error',
props<{ routedToFullScreenError: boolean }>()
);

View File

@ -17,12 +17,19 @@
import { createReducer, on } from '@ngrx/store';
import { ErrorState } from './index';
import { resetErrorState, fullScreenError, addBannerError, clearBannerErrors } from './error.actions';
import {
resetErrorState,
fullScreenError,
addBannerError,
clearBannerErrors,
setRoutedToFullScreenError
} from './error.actions';
import { produce } from 'immer';
export const initialState: ErrorState = {
bannerErrors: null,
fullScreenError: null
fullScreenError: null,
routedToFullScreenError: false
};
export const errorReducer = createReducer(
@ -44,6 +51,10 @@ export const errorReducer = createReducer(
...state,
bannerErrors: null
})),
on(setRoutedToFullScreenError, (state, { routedToFullScreenError }) => ({
...state,
routedToFullScreenError
})),
on(resetErrorState, () => ({
...initialState
}))

View File

@ -23,3 +23,8 @@ export const selectErrorState = createFeatureSelector<ErrorState>(errorFeatureKe
export const selectFullScreenError = createSelector(selectErrorState, (state: ErrorState) => state.fullScreenError);
export const selectBannerErrors = createSelector(selectErrorState, (state: ErrorState) => state.bannerErrors);
export const selectRoutedToFullScreenError = createSelector(
selectErrorState,
(state: ErrorState) => state.routedToFullScreenError
);

View File

@ -25,4 +25,5 @@ export interface ErrorDetail {
export interface ErrorState {
bannerErrors: string[] | null;
fullScreenError: ErrorDetail | null;
routedToFullScreenError: boolean;
}

View File

@ -39,6 +39,8 @@ import { documentationFeatureKey, DocumentationState } from './documentation';
import { documentationReducer } from './documentation/documentation.reducer';
import { clusterSummaryFeatureKey, ClusterSummaryState } from './cluster-summary';
import { clusterSummaryReducer } from './cluster-summary/cluster-summary.reducer';
import { loginConfigurationFeatureKey, LoginConfigurationState } from './login-configuration';
import { loginConfigurationReducer } from './login-configuration/login-configuration.reducer';
export interface NiFiState {
[DEFAULT_ROUTER_FEATURENAME]: RouterReducerState;
@ -47,6 +49,7 @@ export interface NiFiState {
[extensionTypesFeatureKey]: ExtensionTypesState;
[aboutFeatureKey]: AboutState;
[flowConfigurationFeatureKey]: FlowConfigurationState;
[loginConfigurationFeatureKey]: LoginConfigurationState;
[statusHistoryFeatureKey]: StatusHistoryState;
[controllerServiceStateFeatureKey]: ControllerServiceState;
[systemDiagnosticsFeatureKey]: SystemDiagnosticsState;
@ -62,6 +65,7 @@ export const rootReducers: ActionReducerMap<NiFiState> = {
[extensionTypesFeatureKey]: extensionTypesReducer,
[aboutFeatureKey]: aboutReducer,
[flowConfigurationFeatureKey]: flowConfigurationReducer,
[loginConfigurationFeatureKey]: loginConfigurationReducer,
[statusHistoryFeatureKey]: statusHistoryReducer,
[controllerServiceStateFeatureKey]: controllerServiceStateReducer,
[systemDiagnosticsFeatureKey]: systemDiagnosticsReducer,

View File

@ -15,19 +15,20 @@
* limitations under the License.
*/
import { TestBed } from '@angular/core/testing';
export const loginConfigurationFeatureKey = 'loginConfiguration';
import { LoadingInterceptor } from './loading.interceptor';
export interface LoadLoginConfigurationResponse {
loginConfiguration: LoginConfiguration;
}
describe('LoadingInterceptor', () => {
let service: LoadingInterceptor;
export interface LoginConfiguration {
loginSupported: boolean;
externalLoginRequired: boolean;
loginUri: string;
logoutUri: string;
}
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(LoadingInterceptor);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});
export interface LoginConfigurationState {
loginConfiguration: LoginConfiguration | null;
status: 'pending' | 'loading' | 'success';
}

View File

@ -15,19 +15,12 @@
* limitations under the License.
*/
import { TestBed } from '@angular/core/testing';
import { createAction, props } from '@ngrx/store';
import { LoadLoginConfigurationResponse } from './index';
import { AuthStorage } from './auth-storage.service';
export const loadLoginConfiguration = createAction('[Login Configuration] Load Login Configuration');
describe('AuthStorage', () => {
let service: AuthStorage;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(AuthStorage);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});
export const loadLoginConfigurationSuccess = createAction(
'[Login Configuration] Load Login Configuration Success',
props<{ response: LoadLoginConfigurationResponse }>()
);

View File

@ -0,0 +1,61 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import * as LoginConfigurationActions from './login-configuration.actions';
import { catchError, from, map, of, switchMap } from 'rxjs';
import * as ErrorActions from '../error/error.actions';
import { HttpErrorResponse } from '@angular/common/http';
import { ErrorHelper } from '../../service/error-helper.service';
import { AuthService } from '../../service/auth.service';
@Injectable()
export class LoginConfigurationEffects {
constructor(
private actions$: Actions,
private authService: AuthService,
private errorHelper: ErrorHelper
) {}
loadLoginConfiguration$ = createEffect(() =>
this.actions$.pipe(
ofType(LoginConfigurationActions.loadLoginConfiguration),
switchMap(() => {
return from(
this.authService.getLoginConfiguration().pipe(
map((response) =>
LoginConfigurationActions.loadLoginConfigurationSuccess({
response
})
),
catchError((errorResponse: HttpErrorResponse) =>
of(
ErrorActions.snackBarError({
error: this.errorHelper.getErrorString(
errorResponse,
'Failed to load Login Configuration.'
)
})
)
)
)
);
})
)
);
}

View File

@ -15,27 +15,24 @@
* limitations under the License.
*/
import { TestBed } from '@angular/core/testing';
import { createReducer, on } from '@ngrx/store';
import { LoginConfigurationState } from './index';
import { loadLoginConfiguration, loadLoginConfigurationSuccess } from './login-configuration.actions';
import { AuthInterceptor } from './auth.interceptor';
import { provideMockStore } from '@ngrx/store/testing';
import { initialState } from '../../state/error/error.reducer';
export const initialState: LoginConfigurationState = {
loginConfiguration: null,
status: 'pending'
};
describe('AuthInterceptor', () => {
let service: AuthInterceptor;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideMockStore({
initialState
})
]
});
service = TestBed.inject(AuthInterceptor);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});
export const loginConfigurationReducer = createReducer(
initialState,
on(loadLoginConfiguration, (state) => ({
...state,
status: 'loading' as const
})),
on(loadLoginConfigurationSuccess, (state, { response }) => ({
...state,
loginConfiguration: response.loginConfiguration,
status: 'success' as const
}))
);

View File

@ -15,23 +15,23 @@
* limitations under the License.
*/
import { TestBed } from '@angular/core/testing';
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { LoginConfiguration, LoginConfigurationState, loginConfigurationFeatureKey } from './index';
import { PollingInterceptor } from './polling.interceptor';
import { provideMockStore } from '@ngrx/store/testing';
import { initialState } from '../../state/current-user/current-user.reducer';
export const selectLoginConfigurationState =
createFeatureSelector<LoginConfigurationState>(loginConfigurationFeatureKey);
describe('PollingInterceptor', () => {
let service: PollingInterceptor;
export const selectLoginConfiguration = createSelector(
selectLoginConfigurationState,
(state: LoginConfigurationState) => state.loginConfiguration
);
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideMockStore({ initialState })]
});
service = TestBed.inject(PollingInterceptor);
});
export const selectLoginUri = createSelector(
selectLoginConfiguration,
(loginConfiguration: LoginConfiguration | null) => loginConfiguration?.loginUri
);
it('should be created', () => {
expect(service).toBeTruthy();
});
});
export const selectLogoutUri = createSelector(
selectLoginConfiguration,
(loginConfiguration: LoginConfiguration | null) => loginConfiguration?.logoutUri
);

View File

@ -29,6 +29,8 @@ import { selectClusterSummary } from '../../../state/cluster-summary/cluster-sum
import * as fromClusterSummary from '../../../state/cluster-summary/cluster-summary.reducer';
import { selectFlowConfiguration } from '../../../state/flow-configuration/flow-configuration.selectors';
import * as fromFlowConfiguration from '../../../state/flow-configuration/flow-configuration.reducer';
import { selectLoginConfiguration } from '../../../state/login-configuration/login-configuration.selectors';
import * as fromLoginConfiguration from '../../../state/login-configuration/login-configuration.reducer';
describe('AdvancedUi', () => {
let component: AdvancedUi;
@ -59,6 +61,10 @@ describe('AdvancedUi', () => {
{
selector: selectFlowConfiguration,
value: fromFlowConfiguration.initialState.flowConfiguration
},
{
selector: selectLoginConfiguration,
value: fromLoginConfiguration.initialState.loginConfiguration
}
]
})

View File

@ -19,6 +19,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ExtensionCreation } from './extension-creation.component';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatDialogRef } from '@angular/material/dialog';
describe('ExtensionCreation', () => {
let component: ExtensionCreation;
@ -26,7 +27,13 @@ describe('ExtensionCreation', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ExtensionCreation, NoopAnimationsModule]
imports: [ExtensionCreation, NoopAnimationsModule],
providers: [
{
provide: MatDialogRef,
useValue: null
}
]
});
fixture = TestBed.createComponent(ExtensionCreation);
component = fixture.componentInstance;

View File

@ -32,11 +32,13 @@
@if (currentUser(); as user) {
<div class="flex justify-between items-center gap-x-1">
<div class="flex flex-col justify-between items-end gap-y-1">
<div class="current-user">{{ user.identity }}</div>
@if (!user.anonymous) {
<div class="current-user">{{ user.identity }}</div>
}
@if (allowLogin(user)) {
<a href="#">log in</a>
}
@if (hasToken()) {
@if (user.logoutSupported) {
<a (click)="logout()">log out</a>
}
</div>

View File

@ -28,6 +28,8 @@ import { selectClusterSummary } from '../../../state/cluster-summary/cluster-sum
import * as fromClusterSummary from '../../../state/cluster-summary/cluster-summary.reducer';
import { selectFlowConfiguration } from '../../../state/flow-configuration/flow-configuration.selectors';
import * as fromFlowConfiguration from '../../../state/flow-configuration/flow-configuration.reducer';
import { selectLoginConfiguration } from '../../../state/login-configuration/login-configuration.selectors';
import * as fromLoginConfiguration from '../../../state/login-configuration/login-configuration.reducer';
describe('Navigation', () => {
let component: Navigation;
@ -51,6 +53,10 @@ describe('Navigation', () => {
{
selector: selectFlowConfiguration,
value: fromFlowConfiguration.initialState.flowConfiguration
},
{
selector: selectLoginConfiguration,
value: fromLoginConfiguration.initialState.loginConfiguration
}
]
})

View File

@ -23,8 +23,6 @@ import { MatMenuModule } from '@angular/material/menu';
import { getNodeStatusHistoryAndOpenDialog } from '../../../state/status-history/status-history.actions';
import { getSystemDiagnosticsAndOpenDialog } from '../../../state/system-diagnostics/system-diagnostics.actions';
import { Store } from '@ngrx/store';
import { AuthStorage } from '../../../service/auth-storage.service';
import { AuthService } from '../../../service/auth.service';
import { CurrentUser } from '../../../state/current-user';
import { RouterLink } from '@angular/router';
import { selectCurrentUser } from '../../../state/current-user/current-user.selectors';
@ -35,7 +33,11 @@ import { Storage } from '../../../service/storage.service';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { DARK_THEME, LIGHT_THEME, OS_SETTING, ThemingService } from '../../../service/theming.service';
import { loadFlowConfiguration } from '../../../state/flow-configuration/flow-configuration.actions';
import { startCurrentUserPolling, stopCurrentUserPolling } from '../../../state/current-user/current-user.actions';
import {
logout,
startCurrentUserPolling,
stopCurrentUserPolling
} from '../../../state/current-user/current-user.actions';
import { loadAbout, openAboutDialog } from '../../../state/about/about.actions';
import {
loadClusterSummary,
@ -43,6 +45,7 @@ import {
stopClusterSummaryPolling
} from '../../../state/cluster-summary/cluster-summary.actions';
import { selectClusterSummary } from '../../../state/cluster-summary/cluster-summary.selectors';
import { selectLoginConfiguration } from '../../../state/login-configuration/login-configuration.selectors';
@Component({
selector: 'navigation',
@ -69,12 +72,11 @@ export class Navigation implements OnInit, OnDestroy {
OS_SETTING: string = OS_SETTING;
currentUser = this.store.selectSignal(selectCurrentUser);
flowConfiguration = this.store.selectSignal(selectFlowConfiguration);
loginConfiguration = this.store.selectSignal(selectLoginConfiguration);
clusterSummary = this.store.selectSignal(selectClusterSummary);
constructor(
private store: Store<NiFiState>,
private authStorage: AuthStorage,
private authService: AuthService,
private storage: Storage,
private themingService: ThemingService
) {
@ -104,15 +106,16 @@ export class Navigation implements OnInit, OnDestroy {
}
allowLogin(user: CurrentUser): boolean {
return user.anonymous && location.protocol === 'https:';
}
hasToken(): boolean {
return this.authStorage.hasToken();
const loginConfig = this.loginConfiguration();
if (loginConfig) {
return user.anonymous && loginConfig.loginSupported;
} else {
return false;
}
}
logout(): void {
this.authService.logout();
this.store.dispatch(logout());
}
viewNodeStatusHistory(): void {

View File

@ -19,10 +19,10 @@
<div class="flex justify-between items-center gap-x-3">
<h3 class="primary-color whitespace-nowrap overflow-hidden text-ellipsis" [title]="title">{{ title }}</h3>
<div class="flex gap-x-3">
@if (hasToken()) {
@if (logoutSupported()) {
<a (click)="logout()" class="whitespace-nowrap">log out</a>
}
<a [routerLink]="['/']">home</a>
<a (click)="resetRoutedToFullScreenError()" [routerLink]="['/']">home</a>
</div>
</div>
<ng-content></ng-content>

View File

@ -21,6 +21,9 @@ import { PageContent } from './page-content.component';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { RouterModule } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { provideMockStore } from '@ngrx/store/testing';
import { currentUserFeatureKey } from '../../../state/current-user';
import { initialState } from '../../../state/current-user/current-user.reducer';
describe('PageContent', () => {
let component: PageContent;
@ -28,7 +31,14 @@ describe('PageContent', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [PageContent, HttpClientTestingModule, RouterModule, RouterTestingModule]
imports: [PageContent, HttpClientTestingModule, RouterModule, RouterTestingModule],
providers: [
provideMockStore({
initialState: {
[currentUserFeatureKey]: initialState
}
})
]
});
fixture = TestBed.createComponent(PageContent);
component = fixture.componentInstance;

View File

@ -16,9 +16,12 @@
*/
import { Component, Input } from '@angular/core';
import { AuthStorage } from '../../../service/auth-storage.service';
import { AuthService } from '../../../service/auth.service';
import { RouterLink } from '@angular/router';
import { selectLogoutSupported } from '../../../state/current-user/current-user.selectors';
import { Store } from '@ngrx/store';
import { NiFiState } from '../../../state';
import { setRoutedToFullScreenError } from '../../../state/error/error.actions';
import { logout } from '../../../state/current-user/current-user.actions';
@Component({
selector: 'page-content',
@ -30,16 +33,15 @@ import { RouterLink } from '@angular/router';
export class PageContent {
@Input() title = '';
constructor(
private authStorage: AuthStorage,
private authService: AuthService
) {}
logoutSupported = this.store.selectSignal(selectLogoutSupported);
constructor(private store: Store<NiFiState>) {}
resetRoutedToFullScreenError(): void {
this.store.dispatch(setRoutedToFullScreenError({ routedToFullScreenError: false }));
}
logout(): void {
this.authService.logout();
}
hasToken(): boolean {
return this.authStorage.hasToken();
this.store.dispatch(logout());
}
}

View File

@ -21,7 +21,7 @@ import { StatusHistory } from './status-history.component';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { provideMockStore } from '@ngrx/store/testing';
import { initialState } from '../../../state/extension-types/extension-types.reducer';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
describe('StatusHistory', () => {
let component: StatusHistory;
@ -30,7 +30,14 @@ describe('StatusHistory', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [{ provide: MAT_DIALOG_DATA, useValue: {} }, provideMockStore({ initialState })]
providers: [
{ provide: MAT_DIALOG_DATA, useValue: {} },
provideMockStore({ initialState }),
{
provide: MatDialogRef,
useValue: null
}
]
});
fixture = TestBed.createComponent(StatusHistory);
component = fixture.componentInstance;

View File

@ -20,6 +20,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SystemDiagnosticsDialog } from './system-diagnostics-dialog.component';
import { provideMockStore } from '@ngrx/store/testing';
import { initialSystemDiagnosticsState } from '../../../state/system-diagnostics/system-diagnostics.reducer';
import { MatDialogRef } from '@angular/material/dialog';
describe('SystemDiagnosticsDialog', () => {
let component: SystemDiagnosticsDialog;
@ -28,7 +29,13 @@ describe('SystemDiagnosticsDialog', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [SystemDiagnosticsDialog],
providers: [provideMockStore({ initialState: initialSystemDiagnosticsState })]
providers: [
provideMockStore({ initialState: initialSystemDiagnosticsState }),
{
provide: MatDialogRef,
useValue: null
}
]
});
fixture = TestBed.createComponent(SystemDiagnosticsDialog);
component = fixture.componentInstance;

View File

@ -19,47 +19,6 @@
version="6.0">
<display-name>nifi-web-frontend</display-name>
<!-- servlet to login page -->
<!-- <servlet>-->
<!-- <servlet-name>Login</servlet-name>-->
<!-- <jsp-file>/WEB-INF/pages/login.jsp</jsp-file>-->
<!-- </servlet>-->
<!-- <servlet-mapping>-->
<!-- <servlet-name>Login</servlet-name>-->
<!-- <url-pattern>/login</url-pattern>-->
<!-- </servlet-mapping>-->
<!-- servlet to logout page -->
<!-- <servlet>-->
<!-- <servlet-name>Logout</servlet-name>-->
<!-- <jsp-file>/WEB-INF/pages/logout.jsp</jsp-file>-->
<!-- </servlet>-->
<!-- <servlet-mapping>-->
<!-- <servlet-name>Logout</servlet-name>-->
<!-- <url-pattern>/logout-complete</url-pattern>-->
<!-- </servlet-mapping>-->
<!-- login filter -->
<filter>
<filter-name>LoginFilter</filter-name>
<filter-class>org.apache.nifi.web.filter.LoginFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>LoginFilter</filter-name>
<url-pattern>/login</url-pattern>
</filter-mapping>
<!-- logout filter -->
<filter>
<filter-name>LogoutFilter</filter-name>
<filter-class>org.apache.nifi.web.filter.LogoutFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>LogoutFilter</filter-name>
<url-pattern>/logout</url-pattern>
</filter-mapping>
<!-- catch all filter -->
<filter>
<filter-name>SanitizeContextPathFilter</filter-name>
<filter-class>org.apache.nifi.web.filter.SanitizeContextPathFilter</filter-class>