Files
dspace-angular/src/app/core/auth/auth.service.ts
2025-03-23 22:33:27 +01:00

693 lines
21 KiB
TypeScript

import { HttpHeaders } from '@angular/common/http';
import {
Inject,
Injectable,
Optional,
} from '@angular/core';
import { Router } from '@angular/router';
import {
select,
Store,
} from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { CookieAttributes } from 'js-cookie';
import {
Observable,
of as observableOf,
} from 'rxjs';
import {
filter,
map,
startWith,
switchMap,
take,
} from 'rxjs/operators';
import { environment } from '../../../environments/environment';
import {
REQUEST,
RESPONSE,
} from '../../../express.tokens';
import { AppState } from '../../app.reducer';
import {
hasNoValue,
hasValue,
hasValueOperator,
isEmpty,
isNotEmpty,
isNotNull,
isNotUndefined,
} from '../../shared/empty.util';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { followLink } from '../../shared/utils/follow-link-config.model';
import {
buildPaginatedList,
PaginatedList,
} from '../data/paginated-list.model';
import { RemoteData } from '../data/remote-data';
import { HttpOptions } from '../dspace-rest/dspace-rest.service';
import { EPersonDataService } from '../eperson/eperson-data.service';
import { EPerson } from '../eperson/models/eperson.model';
import { Group } from '../eperson/models/group.model';
import { CookieService } from '../services/cookie.service';
import { HardRedirectService } from '../services/hard-redirect.service';
import { RouteService } from '../services/route.service';
import {
NativeWindowRef,
NativeWindowService,
} from '../services/window.service';
import {
getAllSucceededRemoteDataPayload,
getFirstCompletedRemoteData,
} from '../shared/operators';
import { PageInfo } from '../shared/page-info.model';
import { URLCombiner } from '../url-combiner/url-combiner';
import {
CheckAuthenticationTokenAction,
RefreshTokenAction,
ResetAuthenticationMessagesAction,
SetAuthCookieStatus,
SetRedirectUrlAction,
SetUserAsIdleAction,
UnsetUserAsIdleAction,
} from './auth.actions';
import { AuthRequestService } from './auth-request.service';
import { AuthMethod } from './models/auth.method';
import { AuthStatus } from './models/auth-status.model';
import {
AuthTokenInfo,
TOKENITEM,
} from './models/auth-token-info.model';
import {
getAuthenticatedUserId,
getAuthenticationToken,
getExternalAuthCookieStatus,
getRedirectUrl,
isAuthenticated,
isAuthenticatedLoaded,
isIdle,
isTokenRefreshing,
} from './selectors';
export const LOGIN_ROUTE = '/login';
export const LOGOUT_ROUTE = '/logout';
export const REDIRECT_COOKIE = 'dsRedirectUrl';
export const IMPERSONATING_COOKIE = 'dsImpersonatingEPerson';
/**
* The auth service.
*/
@Injectable({ providedIn: 'root' })
export class AuthService {
/**
* True if authenticated
* @type boolean
*/
protected _authenticated: boolean;
/**
* Timer to track time until token refresh
*/
private tokenRefreshTimer;
constructor(@Inject(REQUEST) protected req: any,
@Inject(NativeWindowService) protected _window: NativeWindowRef,
@Optional() @Inject(RESPONSE) private response: any,
protected authRequestService: AuthRequestService,
protected epersonService: EPersonDataService,
protected router: Router,
protected routeService: RouteService,
protected storage: CookieService,
protected store: Store<AppState>,
protected hardRedirectService: HardRedirectService,
private notificationService: NotificationsService,
private translateService: TranslateService,
) {
this.store.pipe(
// when this service is constructed the store is not fully initialized yet
filter((state: any) => state?.core?.auth !== undefined),
select(isAuthenticated),
startWith(false),
).subscribe((authenticated: boolean) => this._authenticated = authenticated);
}
/**
* Authenticate the user
*
* @param {string} user The user name
* @param {string} password The user's password
* @returns {Observable<User>} The authenticated user observable.
*/
public authenticate(user: string, password: string): Observable<AuthStatus> {
// Attempt authenticating the user using the supplied credentials.
const body = (`password=${encodeURIComponent(password)}&user=${encodeURIComponent(user)}`);
const options: HttpOptions = Object.create({});
let headers = new HttpHeaders();
headers = headers.append('Content-Type', 'application/x-www-form-urlencoded');
options.headers = headers;
return this.authRequestService.postToEndpoint('login', body, options).pipe(
map((rd: RemoteData<AuthStatus>) => {
if (hasValue(rd.payload) && rd.payload.authenticated) {
return rd.payload;
} else {
throw (new Error('auth.errors.invalid-user'));
}
}));
}
/**
* Checks if token is present into the request cookie
*/
public checkAuthenticationCookie(): Observable<AuthStatus> {
// Determine if the user has an existing auth session on the server
const options: HttpOptions = Object.create({});
let headers = new HttpHeaders();
headers = headers.append('Accept', 'application/json');
options.headers = headers;
options.withCredentials = true;
return this.authRequestService.getRequest('status', options).pipe(
map((rd: RemoteData<AuthStatus>) => Object.assign(new AuthStatus(), rd.payload)),
);
}
/**
* Determines if the user is authenticated
* @returns {Observable<boolean>}
*/
public isAuthenticated(): Observable<boolean> {
return this.store.pipe(select(isAuthenticated));
}
/**
* Determines if authentication is loaded
* @returns {Observable<boolean>}
*/
public isAuthenticationLoaded(): Observable<boolean> {
return this.store.pipe(select(isAuthenticatedLoaded));
}
/**
* Used to set the external authentication status when authenticating via an
* external authentication system (e.g. Shibboleth).
* @param external
*/
public setExternalAuthStatus(external: boolean) {
this.store.dispatch(new SetAuthCookieStatus(external));
}
/**
* Returns true if an external authentication system (e.g. Shibboleth) is being used
* for authentication. Returns false otherwise.
*/
public isExternalAuthentication(): Observable<boolean> {
return this.store.pipe(
select(getExternalAuthCookieStatus));
}
/**
* Returns the href link to authenticated user
* @returns {string}
*/
public authenticatedUser(token: AuthTokenInfo): Observable<string> {
// Determine if the user has an existing auth session on the server
const options: HttpOptions = Object.create({});
let headers = new HttpHeaders();
headers = headers.append('Accept', 'application/json');
headers = headers.append('Authorization', `Bearer ${token.accessToken}`);
options.headers = headers;
return this.authRequestService.getRequest('status', options).pipe(
map((rd: RemoteData<AuthStatus>) => {
const status = rd.payload;
if (hasValue(status) && status.authenticated) {
return status._links.eperson.href;
} else {
throw (new Error('Not authenticated'));
}
}));
}
/**
* Returns the authenticated user by href
* @returns {User}
*/
public retrieveAuthenticatedUserByHref(userHref: string): Observable<EPerson> {
return this.epersonService.findByHref(userHref).pipe(
getAllSucceededRemoteDataPayload(),
);
}
/**
* Returns the authenticated user by id
* @returns {User}
*/
public retrieveAuthenticatedUserById(userId: string): Observable<EPerson> {
return this.epersonService.findById(userId).pipe(
getAllSucceededRemoteDataPayload(),
);
}
/**
* Returns the authenticated user from the store
* @returns {User}
*/
public getAuthenticatedUserFromStore(): Observable<EPerson> {
return this.store.pipe(
select(getAuthenticatedUserId),
hasValueOperator(),
switchMap((id: string) => this.epersonService.findById(id)),
getAllSucceededRemoteDataPayload(),
);
}
/**
* Checks if token is present into browser storage and is valid.
*/
public checkAuthenticationToken() {
this.store.dispatch(new CheckAuthenticationTokenAction());
}
/**
* Return the special groups list embedded in the AuthStatus model
*/
public getSpecialGroupsFromAuthStatus(): Observable<RemoteData<PaginatedList<Group>>> {
return this.authRequestService.getRequest('status', null, followLink('specialGroups')).pipe(
getFirstCompletedRemoteData(),
switchMap((status: RemoteData<AuthStatus>) => {
if (status.hasSucceeded) {
return status.payload.specialGroups;
} else {
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), []));
}
}),
);
}
/**
* Checks if token is present into storage and is not expired
*/
public hasValidAuthenticationToken(): Observable<AuthTokenInfo> {
return this.store.pipe(
select(getAuthenticationToken),
take(1),
map((authTokenInfo: AuthTokenInfo) => {
// Retrieve authentication token info and check if is valid
const token = isNotEmpty(authTokenInfo) ? authTokenInfo : this.storage.get(TOKENITEM);
if (isNotEmpty(token) && token.hasOwnProperty('accessToken') && isNotEmpty(token.accessToken) && !this.isTokenExpired(token)) {
return token;
} else {
throw false;
}
}),
);
}
/**
* Checks if token is present into storage
*/
public refreshAuthenticationToken(token: AuthTokenInfo): Observable<AuthTokenInfo> {
const options: HttpOptions = Object.create({});
let headers = new HttpHeaders();
headers = headers.append('Accept', 'application/json');
if (token && token.accessToken) {
headers = headers.append('Authorization', `Bearer ${token.accessToken}`);
}
options.headers = headers;
options.withCredentials = true;
return this.authRequestService.postToEndpoint('login', {}, options).pipe(
map((rd: RemoteData<AuthStatus>) => {
const status = rd.payload;
if (hasValue(status) && status.authenticated) {
return status.token;
} else {
throw (new Error('Not authenticated'));
}
}));
}
/**
* Clear authentication errors
*/
public resetAuthenticationError(): void {
this.store.dispatch(new ResetAuthenticationMessagesAction());
}
/**
* Retrieve authentication methods available
* @returns {User}
*/
public retrieveAuthMethodsFromAuthStatus(status: AuthStatus): Observable<AuthMethod[]> {
let authMethods: AuthMethod[] = [];
if (isNotEmpty(status.authMethods)) {
authMethods = status.authMethods;
}
return observableOf(authMethods);
}
/**
* End session
* @returns {Observable<boolean>}
*/
public logout(): Observable<boolean> {
// Send a request that sign end the session
let headers = new HttpHeaders();
headers = headers.append('Content-Type', 'application/x-www-form-urlencoded');
const options: HttpOptions = Object.create({ headers, responseType: 'text' });
return this.authRequestService.postToEndpoint('logout', options).pipe(
map((rd: RemoteData<AuthStatus>) => {
const status = rd.payload;
if (hasValue(status) && !status.authenticated) {
return true;
} else {
throw (new Error('auth.errors.invalid-user'));
}
}));
}
/**
* Retrieve authentication token info and make authorization header
* @returns {string}
*/
public buildAuthHeader(token?: AuthTokenInfo): string {
if (isEmpty(token)) {
token = this.getToken();
}
return (this._authenticated && isNotNull(token)) ? `Bearer ${token.accessToken}` : '';
}
/**
* Get authentication token info
* @returns {AuthTokenInfo}
*/
public getToken(): AuthTokenInfo {
let token: AuthTokenInfo;
this.store.pipe(take(1), select(getAuthenticationToken))
.subscribe((authTokenInfo: AuthTokenInfo) => {
// Retrieve authentication token info and check if is valid
token = authTokenInfo || null;
});
return token;
}
/**
* Method that checks when the session token from store expires and refreshes it when needed
*/
public trackTokenExpiration(): void {
let token: AuthTokenInfo;
let currentlyRefreshingToken = false;
this.store.pipe(select(getAuthenticationToken)).subscribe((authTokenInfo: AuthTokenInfo) => {
// If new token is undefined and it wasn't previously => Refresh failed
if (currentlyRefreshingToken && token !== undefined && authTokenInfo === undefined) {
// Token refresh failed => Error notification => 10 second wait => Page reloads & user logged out
this.notificationService.error(this.translateService.get('auth.messages.token-refresh-failed'));
setTimeout(() => this.navigateToRedirectUrl(this.hardRedirectService.getCurrentRoute()), 10000);
currentlyRefreshingToken = false;
}
// If new token.expires is different => Refresh succeeded
if (currentlyRefreshingToken && authTokenInfo !== undefined && token.expires !== authTokenInfo.expires) {
currentlyRefreshingToken = false;
}
// Check if/when token needs to be refreshed
if (!currentlyRefreshingToken) {
token = authTokenInfo || null;
if (token !== undefined && token !== null) {
let timeLeftBeforeRefresh = token.expires - new Date().getTime() - environment.auth.rest.timeLeftBeforeTokenRefresh;
if (timeLeftBeforeRefresh < 0) {
timeLeftBeforeRefresh = 0;
}
if (hasValue(this.tokenRefreshTimer)) {
clearTimeout(this.tokenRefreshTimer);
}
this.tokenRefreshTimer = setTimeout(() => {
this.store.dispatch(new RefreshTokenAction(token));
currentlyRefreshingToken = true;
}, timeLeftBeforeRefresh);
}
}
});
}
/**
* Check if a token is next to be expired
* @returns {boolean}
*/
public isTokenExpiring(): Observable<boolean> {
return this.store.pipe(
select(isTokenRefreshing),
take(1),
map((isRefreshing: boolean) => {
if (this.isTokenExpired() || isRefreshing) {
return false;
} else {
const token = this.getToken();
return token.expires - (60 * 5 * 1000) < Date.now();
}
}),
);
}
/**
* Check if a token is expired
* @returns {boolean}
*/
public isTokenExpired(token?: AuthTokenInfo): boolean {
token = token || this.getToken();
return token && token.expires < Date.now();
}
/**
* Save authentication token info
*
* @param {AuthTokenInfo} token The token to save
* @returns {AuthTokenInfo}
*/
public storeToken(token: AuthTokenInfo) {
// Add 1 day to the current date
const expireDate = Date.now() + (1000 * 60 * 60 * 24);
// Set the cookie expire date
const expires = new Date(expireDate);
const options: CookieAttributes = { expires: expires };
// Save cookie with the token
return this.storage.set(TOKENITEM, token, options);
}
/**
* Remove authentication token info
*/
public removeToken() {
return this.storage.remove(TOKENITEM);
}
/**
* Replace authentication token info with a new one
*/
public replaceToken(token: AuthTokenInfo) {
this.removeToken();
return this.storeToken(token);
}
/**
* Redirect to the login route
*/
public redirectToLogin() {
this.router.navigate([LOGIN_ROUTE]);
}
/**
* Redirect to the login route when token has expired
*/
public redirectToLoginWhenTokenExpired() {
const redirectUrl = LOGIN_ROUTE + '?expired=true';
if (this._window.nativeWindow.location) {
// Hard redirect to login page, so that all state is definitely lost
this._window.nativeWindow.location.href = redirectUrl;
} else if (this.response) {
if (!this.response._headerSent) {
this.response.redirect(302, redirectUrl);
}
} else {
this.router.navigateByUrl(redirectUrl);
}
}
/**
* Perform a hard redirect to the URL
* @param redirectUrl
*/
public navigateToRedirectUrl(redirectUrl: string) {
// Don't do redirect if already on reload url
if (!hasValue(redirectUrl) || !redirectUrl.includes('reload/')) {
let url = `reload/${new Date().getTime()}`;
if (isNotEmpty(redirectUrl) && !redirectUrl.startsWith(LOGIN_ROUTE)) {
url += `?redirect=${encodeURIComponent(redirectUrl)}`;
}
this.hardRedirectService.redirect(url);
}
}
/**
* Refresh route navigated
*/
public refreshAfterLogout() {
this.navigateToRedirectUrl(undefined);
}
/**
* Get redirect url
*/
getRedirectUrl(): Observable<string> {
return this.store.pipe(
select(getRedirectUrl),
map((urlFromStore: string) => {
if (hasValue(urlFromStore)) {
return urlFromStore;
} else {
return this.storage.get(REDIRECT_COOKIE);
}
}),
);
}
/**
* Set redirect url
*/
setRedirectUrl(url: string) {
// Add 1 hour to the current date
const expireDate = Date.now() + (1000 * 60 * 60);
// Set the cookie expire date
const expires = new Date(expireDate);
const options: CookieAttributes = { expires: expires };
this.storage.set(REDIRECT_COOKIE, url, options);
this.store.dispatch(new SetRedirectUrlAction(isNotUndefined(url) ? url : ''));
}
/**
* Set the redirect url if the current one has not been set yet
* @param newRedirectUrl
*/
setRedirectUrlIfNotSet(newRedirectUrl: string) {
this.getRedirectUrl().pipe(
take(1))
.subscribe((currentRedirectUrl) => {
if (hasNoValue(currentRedirectUrl)) {
this.setRedirectUrl(newRedirectUrl);
}
});
}
/**
* Returns the external server redirect URL.
* @param origin - The origin route.
* @param redirectRoute - The redirect route.
* @param location - The location.
* @returns The external server redirect URL.
*/
getExternalServerRedirectUrl(origin: string, redirectRoute: string, location: string): string {
const correctRedirectUrl = new URLCombiner(origin, redirectRoute).toString();
let externalServerUrl = location;
const myRegexp = /\?redirectUrl=(.*)/g;
const match = myRegexp.exec(location);
const redirectUrlFromServer = (match && match[1]) ? match[1] : null;
// Check whether the current page is different from the redirect url received from rest
if (isNotNull(redirectUrlFromServer) && redirectUrlFromServer !== correctRedirectUrl) {
// change the redirect url with the current page url
const newRedirectUrl = `?redirectUrl=${correctRedirectUrl}`;
externalServerUrl = location.replace(/\?redirectUrl=(.*)/g, newRedirectUrl);
}
return externalServerUrl;
}
/**
* Clear redirect url
*/
clearRedirectUrl() {
this.store.dispatch(new SetRedirectUrlAction(undefined));
this.storage.remove(REDIRECT_COOKIE);
}
/**
* Start impersonating EPerson
* @param epersonId ID of the EPerson to impersonate
*/
impersonate(epersonId: string) {
this.storage.set(IMPERSONATING_COOKIE, epersonId);
this.refreshAfterLogout();
}
/**
* Stop impersonating EPerson
*/
stopImpersonating() {
this.storage.remove(IMPERSONATING_COOKIE);
}
/**
* Stop impersonating EPerson and refresh the store/ui
*/
stopImpersonatingAndRefresh() {
this.stopImpersonating();
this.refreshAfterLogout();
}
/**
* Get the ID of the EPerson we're currently impersonating
* Returns undefined if we're not impersonating anyone
*/
getImpersonateID(): string {
return this.storage.get(IMPERSONATING_COOKIE);
}
/**
* Whether or not we are currently impersonating an EPerson
*/
isImpersonating(): boolean {
return hasValue(this.getImpersonateID());
}
/**
* Whether or not we are currently impersonating a specific EPerson
* @param epersonId ID of the EPerson to check
*/
isImpersonatingUser(epersonId: string): boolean {
return this.getImpersonateID() === epersonId;
}
/**
* Get a short-lived token for appending to download urls of restricted files
* Returns null if the user isn't authenticated
*/
getShortlivedToken(): Observable<string> {
return this.isAuthenticated().pipe(
switchMap((authenticated) => authenticated ? this.authRequestService.getShortlivedToken() : observableOf(null)),
);
}
/**
* Determines if current user is idle
* @returns {Observable<boolean>}
*/
public isUserIdle(): Observable<boolean> {
return this.store.pipe(select(isIdle));
}
/**
* Set idle of auth state
* @returns {Observable<boolean>}
*/
public setIdle(idle: boolean): void {
if (idle) {
this.store.dispatch(new SetUserAsIdleAction());
} else {
this.store.dispatch(new UnsetUserAsIdleAction());
}
}
}