Browse Source

Redirect login. (#919)

pull/921/head
Sebastian Stehle 3 years ago
committed by GitHub
parent
commit
97073e03ae
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      frontend/src/app/framework/configurations.ts
  2. 23
      frontend/src/app/shared/guards/must-be-authenticated.guard.spec.ts
  3. 18
      frontend/src/app/shared/guards/must-be-authenticated.guard.ts
  4. 23
      frontend/src/app/shared/guards/must-be-not-authenticated.guard.spec.ts
  5. 19
      frontend/src/app/shared/guards/must-be-not-authenticated.guard.ts
  6. 12
      frontend/src/app/shared/interceptors/auth.interceptor.spec.ts
  7. 6
      frontend/src/app/shared/interceptors/auth.interceptor.ts
  8. 30
      frontend/src/app/shared/services/auth.service.ts
  9. 23
      frontend/src/app/shell/pages/home/home-page.component.ts
  10. 2
      frontend/src/app/shell/pages/internal/profile-menu.component.ts
  11. 6
      frontend/src/app/shell/pages/login/login-page.component.ts
  12. 6
      frontend/src/app/shell/pages/logout/logout-page.component.ts

2
frontend/src/app/framework/configurations.ts

@ -7,7 +7,7 @@
export class UIOptions { export class UIOptions {
constructor( constructor(
private readonly value: any, public readonly value: any,
) { ) {
} }

23
frontend/src/app/shared/guards/must-be-authenticated.guard.spec.ts

@ -5,6 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { Location } from '@angular/common';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { firstValueFrom, of } from 'rxjs'; import { firstValueFrom, of } from 'rxjs';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
@ -13,19 +14,25 @@ import { MustBeAuthenticatedGuard } from './must-be-authenticated.guard';
describe('MustBeAuthenticatedGuard', () => { describe('MustBeAuthenticatedGuard', () => {
let router: IMock<Router>; let router: IMock<Router>;
let location: IMock<Location>;
let authService: IMock<AuthService>; let authService: IMock<AuthService>;
const uiOptions = new UIOptions({ map: { type: 'OSM' } }); let authGuard: MustBeAuthenticatedGuard;
const uiOptionsRedirect = new UIOptions({ map: { type: 'OSM' }, redirectToLogin: true });
const uiOptions = new UIOptions({});
beforeEach(() => { beforeEach(() => {
location = Mock.ofType<Location>();
location.setup(x => x.path(true))
.returns(() => '/my-path');
router = Mock.ofType<Router>(); router = Mock.ofType<Router>();
authService = Mock.ofType<AuthService>(); authService = Mock.ofType<AuthService>();
authGuard = new MustBeAuthenticatedGuard(authService.object, location.object, router.object, uiOptions);
}); });
it('should navigate to default page if not authenticated', async () => { it('should navigate to default page if not authenticated', async () => {
const authGuard = new MustBeAuthenticatedGuard(uiOptions, authService.object, router.object);
authService.setup(x => x.userChanges) authService.setup(x => x.userChanges)
.returns(() => of(null)); .returns(() => of(null));
@ -33,12 +40,10 @@ describe('MustBeAuthenticatedGuard', () => {
expect(result).toBeFalsy(); expect(result).toBeFalsy();
router.verify(x => x.navigate(['']), Times.once()); router.verify(x => x.navigate([''], { queryParams: { redirectPath: '/my-path' } }), Times.once());
}); });
it('should return true if authenticated', async () => { it('should return true if authenticated', async () => {
const authGuard = new MustBeAuthenticatedGuard(uiOptions, authService.object, router.object);
authService.setup(x => x.userChanges) authService.setup(x => x.userChanges)
.returns(() => of(<any>{})); .returns(() => of(<any>{}));
@ -50,7 +55,7 @@ describe('MustBeAuthenticatedGuard', () => {
}); });
it('should login redirect if redirect enabled', async () => { it('should login redirect if redirect enabled', async () => {
const authGuard = new MustBeAuthenticatedGuard(uiOptionsRedirect, authService.object, router.object); uiOptions.value.redirectToLogin = true;
authService.setup(x => x.userChanges) authService.setup(x => x.userChanges)
.returns(() => of(null)); .returns(() => of(null));
@ -59,6 +64,6 @@ describe('MustBeAuthenticatedGuard', () => {
expect(result!).toBeFalsy(); expect(result!).toBeFalsy();
authService.verify(x => x.loginRedirect(), Times.once()); authService.verify(x => x.loginRedirect('/my-path'), Times.once());
}); });
}); });

18
frontend/src/app/shared/guards/must-be-authenticated.guard.ts

@ -5,6 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { Location } from '@angular/common';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { CanActivate, Router } from '@angular/router'; import { CanActivate, Router } from '@angular/router';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
@ -14,24 +15,27 @@ import { AuthService } from './../services/auth.service';
@Injectable() @Injectable()
export class MustBeAuthenticatedGuard implements CanActivate { export class MustBeAuthenticatedGuard implements CanActivate {
private readonly redirect: boolean; constructor(
constructor(uiOptions: UIOptions,
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly location: Location,
private readonly router: Router, private readonly router: Router,
private readonly uiOptions: UIOptions,
) { ) {
this.redirect = uiOptions.get('redirectToLogin');
} }
public canActivate(): Observable<boolean> { public canActivate(): Observable<boolean> {
const redirect = this.uiOptions.get('redirectToLogin');
return this.authService.userChanges.pipe( return this.authService.userChanges.pipe(
take(1), take(1),
tap(user => { tap(user => {
if (!user) { if (!user) {
if (this.redirect) { const redirectPath = this.location.path(true);
this.authService.loginRedirect();
if (redirect) {
this.authService.loginRedirect(redirectPath);
} else { } else {
this.router.navigate(['']); this.router.navigate([''], { queryParams: { redirectPath } });
} }
} }
}), }),

23
frontend/src/app/shared/guards/must-be-not-authenticated.guard.spec.ts

@ -5,6 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { Location } from '@angular/common';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { firstValueFrom, of } from 'rxjs'; import { firstValueFrom, of } from 'rxjs';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
@ -13,19 +14,25 @@ import { MustBeNotAuthenticatedGuard } from './must-be-not-authenticated.guard';
describe('MustBeNotAuthenticatedGuard', () => { describe('MustBeNotAuthenticatedGuard', () => {
let router: IMock<Router>; let router: IMock<Router>;
let location: IMock<Location>;
let authService: IMock<AuthService>; let authService: IMock<AuthService>;
const uiOptions = new UIOptions({ map: { type: 'OSM' } }); let authGuard: MustBeNotAuthenticatedGuard;
const uiOptionsRedirect = new UIOptions({ map: { type: 'OSM' }, redirectToLogin: true });
const uiOptions = new UIOptions({});
beforeEach(() => { beforeEach(() => {
location = Mock.ofType<Location>();
location.setup(x => x.path(true))
.returns(() => '/my-path');
router = Mock.ofType<Router>(); router = Mock.ofType<Router>();
authService = Mock.ofType<AuthService>(); authService = Mock.ofType<AuthService>();
authGuard = new MustBeNotAuthenticatedGuard(authService.object, location.object, router.object, uiOptions);
}); });
it('should navigate to app page if authenticated', async () => { it('should navigate to app page if authenticated', async () => {
const authGuard = new MustBeNotAuthenticatedGuard(uiOptions, authService.object, router.object);
authService.setup(x => x.userChanges) authService.setup(x => x.userChanges)
.returns(() => of(<any>{})); .returns(() => of(<any>{}));
@ -33,12 +40,10 @@ describe('MustBeNotAuthenticatedGuard', () => {
expect(result!).toBeFalsy(); expect(result!).toBeFalsy();
router.verify(x => x.navigate(['app']), Times.once()); router.verify(x => x.navigate(['app'], { queryParams: { redirectPath: '/my-path' } }), Times.once());
}); });
it('should return true if not authenticated', async () => { it('should return true if not authenticated', async () => {
const authGuard = new MustBeNotAuthenticatedGuard(uiOptions, authService.object, router.object);
authService.setup(x => x.userChanges) authService.setup(x => x.userChanges)
.returns(() => of(null)); .returns(() => of(null));
@ -50,7 +55,7 @@ describe('MustBeNotAuthenticatedGuard', () => {
}); });
it('should login redirect and return false if redirect enabled', async () => { it('should login redirect and return false if redirect enabled', async () => {
const authGuard = new MustBeNotAuthenticatedGuard(uiOptionsRedirect, authService.object, router.object); uiOptions.value.redirectToLogin = true;
authService.setup(x => x.userChanges) authService.setup(x => x.userChanges)
.returns(() => of(null)); .returns(() => of(null));
@ -59,6 +64,6 @@ describe('MustBeNotAuthenticatedGuard', () => {
expect(result).toBeFalsy(); expect(result).toBeFalsy();
authService.verify(x => x.loginRedirect(), Times.once()); authService.verify(x => x.loginRedirect('/my-path'), Times.once());
}); });
}); });

19
frontend/src/app/shared/guards/must-be-not-authenticated.guard.ts

@ -5,6 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { Location } from '@angular/common';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { CanActivate, Router } from '@angular/router'; import { CanActivate, Router } from '@angular/router';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
@ -14,25 +15,29 @@ import { AuthService } from './../services/auth.service';
@Injectable() @Injectable()
export class MustBeNotAuthenticatedGuard implements CanActivate { export class MustBeNotAuthenticatedGuard implements CanActivate {
private readonly redirect: boolean;
constructor(uiOptions: UIOptions, constructor(
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly location: Location,
private readonly router: Router, private readonly router: Router,
private readonly uiOptions: UIOptions,
) { ) {
this.redirect = uiOptions.get('redirectToLogin');
} }
public canActivate(): Observable<boolean> { public canActivate(): Observable<boolean> {
const redirect = this.uiOptions.get('redirectToLogin');
return this.authService.userChanges.pipe( return this.authService.userChanges.pipe(
take(1), take(1),
tap(user => { tap(user => {
if (this.redirect) { const redirectPath = this.location.path(true);
this.authService.loginRedirect();
if (redirect) {
this.authService.loginRedirect(redirectPath);
} else if (user) { } else if (user) {
this.router.navigate(['app']); this.router.navigate(['app'], { queryParams: { redirectPath } });
} }
}), }),
map(user => !user && !this.redirect)); map(user => !user && !redirect));
} }
} }

12
frontend/src/app/shared/interceptors/auth.interceptor.spec.ts

@ -7,6 +7,7 @@
/* eslint-disable deprecation/deprecation */ /* eslint-disable deprecation/deprecation */
import { Location } from '@angular/common';
import { HTTP_INTERCEPTORS, HttpClient, HttpHeaders } from '@angular/common/http'; import { HTTP_INTERCEPTORS, HttpClient, HttpHeaders } from '@angular/common/http';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { inject, TestBed } from '@angular/core/testing'; import { inject, TestBed } from '@angular/core/testing';
@ -19,9 +20,15 @@ import { AuthInterceptor } from './auth.interceptor';
describe('AuthInterceptor', () => { describe('AuthInterceptor', () => {
let authService: IMock<AuthService>; let authService: IMock<AuthService>;
let location: IMock<Location>;
let router: IMock<Router>; let router: IMock<Router>;
beforeEach(() => { beforeEach(() => {
location = Mock.ofType<Location>();
location.setup(x => x.path())
.returns(() => '/my-path');
authService = Mock.ofType(AuthService); authService = Mock.ofType(AuthService);
router = Mock.ofType<Router>(); router = Mock.ofType<Router>();
@ -32,6 +39,7 @@ describe('AuthInterceptor', () => {
], ],
providers: [ providers: [
{ provide: Router, useFactory: () => router.object }, { provide: Router, useFactory: () => router.object },
{ provide: Location, useFactory: () => location.object },
{ provide: AuthService, useValue: authService.object }, { provide: AuthService, useValue: authService.object },
{ provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') }, { provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') },
{ {
@ -100,7 +108,7 @@ describe('AuthInterceptor', () => {
expect().nothing(); expect().nothing();
authService.verify(x => x.logoutRedirect(), Times.once()); authService.verify(x => x.logoutRedirect('/my-path'), Times.once());
})); }));
const AUTH_ERRORS = [403]; const AUTH_ERRORS = [403];
@ -137,7 +145,7 @@ describe('AuthInterceptor', () => {
expect().nothing(); expect().nothing();
authService.verify(x => x.logoutRedirect(), Times.never()); authService.verify(x => x.logoutRedirect('/my-path'), Times.never());
})); }));
}); });
}); });

6
frontend/src/app/shared/interceptors/auth.interceptor.ts

@ -5,6 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { Location } from '@angular/common';
import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
@ -19,6 +20,7 @@ export class AuthInterceptor implements HttpInterceptor {
constructor(apiUrlConfig: ApiUrlConfig, constructor(apiUrlConfig: ApiUrlConfig,
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly location: Location,
private readonly router: Router, private readonly router: Router,
) { ) {
this.baseUrl = apiUrlConfig.buildUrl(''); this.baseUrl = apiUrlConfig.buildUrl('');
@ -50,7 +52,7 @@ export class AuthInterceptor implements HttpInterceptor {
if (error.status === 401 && renew) { if (error.status === 401 && renew) {
return this.authService.loginSilent().pipe( return this.authService.loginSilent().pipe(
catchError(() => { catchError(() => {
this.authService.logoutRedirect(); this.authService.logoutRedirect(this.location.path());
return EMPTY; return EMPTY;
}), }),
@ -58,7 +60,7 @@ export class AuthInterceptor implements HttpInterceptor {
} else if (error.status === 401 || error.status === 403) { } else if (error.status === 401 || error.status === 403) {
if (req.method === 'GET') { if (req.method === 'GET') {
if (error.status === 401) { if (error.status === 401) {
this.authService.logoutRedirect(); this.authService.logoutRedirect(this.location.path());
} else { } else {
this.router.navigate(['/forbidden'], { replaceUrl: true }); this.router.navigate(['/forbidden'], { replaceUrl: true });
} }

30
frontend/src/app/shared/services/auth.service.ts

@ -7,7 +7,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Log, User, UserManager, WebStorageStateStore } from 'oidc-client'; import { Log, User, UserManager, WebStorageStateStore } from 'oidc-client';
import { concat, Observable, Observer, of, ReplaySubject, throwError, TimeoutError } from 'rxjs'; import { concat, Observable, of, ReplaySubject, throwError, TimeoutError } from 'rxjs';
import { delay, mergeMap, retryWhen, take, timeout } from 'rxjs/operators'; import { delay, mergeMap, retryWhen, take, timeout } from 'rxjs/operators';
import { ApiUrlConfig, Types } from '@app/framework'; import { ApiUrlConfig, Types } from '@app/framework';
@ -124,19 +124,19 @@ export class AuthService {
this.checkState(this.userManager.getUser()); this.checkState(this.userManager.getUser());
} }
public logoutRedirect() { public logoutRedirect(redirectPath: string) {
this.userManager.signoutRedirect(); this.userManager.signoutRedirect({ state: { redirectPath } });
} }
public loginRedirect() { public loginRedirect(redirectPath: string) {
this.userManager.signinRedirect(); this.userManager.signinRedirect({ state: { redirectPath } });
} }
public logoutRedirectComplete(): Observable<any> { public logoutRedirectComplete(): Observable<string | undefined> {
return new Observable((observer: Observer<any>) => { return new Observable<string | undefined>(observer => {
this.userManager.signoutRedirectCallback() this.userManager.signoutRedirectCallback()
.then(x => { .then(x => {
observer.next(x); observer.next(x.state?.redirectPath);
observer.complete(); observer.complete();
}, err => { }, err => {
observer.error(err); observer.error(err);
@ -145,11 +145,11 @@ export class AuthService {
}); });
} }
public loginPopup(): Observable<Profile> { public loginPopup(redirectPath: string): Observable<string | undefined> {
return new Observable<Profile>(observer => { return new Observable<string | undefined>(observer => {
this.userManager.signinPopup() this.userManager.signinPopup({ state: { redirectPath } })
.then(x => { .then(x => {
observer.next(AuthService.createProfile(x)); observer.next(x.state?.redirectPath);
observer.complete(); observer.complete();
}, err => { }, err => {
observer.error(err); observer.error(err);
@ -158,11 +158,11 @@ export class AuthService {
}); });
} }
public loginRedirectComplete(): Observable<Profile> { public loginRedirectComplete(): Observable<string | undefined> {
return new Observable<Profile>(observer => { return new Observable<string | undefined>(observer => {
this.userManager.signinRedirectCallback() this.userManager.signinRedirectCallback()
.then(x => { .then(x => {
observer.next(AuthService.createProfile(x)); observer.next(x.state?.redirectPath);
observer.complete(); observer.complete();
}, err => { }, err => {
observer.error(err); observer.error(err);

23
frontend/src/app/shell/pages/home/home-page.component.ts

@ -5,8 +5,9 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { Location } from '@angular/common';
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { AuthService } from '@app/shared'; import { AuthService } from '@app/shared';
@Component({ @Component({
@ -19,18 +20,26 @@ export class HomePageComponent {
constructor( constructor(
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly location: Location,
private readonly route: ActivatedRoute,
private readonly router: Router, private readonly router: Router,
) { ) {
} }
public login() { public login() {
const redirectPath =
this.route.snapshot.queryParams.redirectPath ||
this.location.path();
if (this.isIE()) { if (this.isIE()) {
this.authService.loginRedirect(); this.authService.loginRedirect(redirectPath);
} else { } else {
this.authService.loginPopup() this.authService.loginPopup(redirectPath)
.subscribe({ .subscribe({
next: () => { next: path => {
this.router.navigate(['/app']); path ||= '/app';
this.router.navigateByUrl(path);
}, },
error: () => { error: () => {
this.showLoginError = true; this.showLoginError = true;
@ -40,8 +49,6 @@ export class HomePageComponent {
} }
public isIE() { public isIE() {
const isIE = !!navigator.userAgent.match(/Trident/g) || !!navigator.userAgent.match(/MSIE/g); return !!navigator.userAgent.match(/Trident/g) || !!navigator.userAgent.match(/MSIE/g);
return isIE;
} }
} }

2
frontend/src/app/shell/pages/internal/profile-menu.component.ts

@ -93,6 +93,6 @@ export class ProfileMenuComponent extends StatefulComponent<State> implements On
} }
public logout() { public logout() {
this.authService.logoutRedirect(); this.authService.logoutRedirect('/');
} }
} }

6
frontend/src/app/shell/pages/login/login-page.component.ts

@ -23,8 +23,10 @@ export class LoginPageComponent implements OnInit {
public ngOnInit() { public ngOnInit() {
this.authService.loginRedirectComplete() this.authService.loginRedirectComplete()
.subscribe({ .subscribe({
next: () => { next: path => {
this.router.navigate(['/app'], { replaceUrl: true }); path ||= '/app';
this.router.navigateByUrl(path, { replaceUrl: true });
}, },
error: () => { error: () => {
this.router.navigate(['/'], { replaceUrl: true }); this.router.navigate(['/'], { replaceUrl: true });

6
frontend/src/app/shell/pages/logout/logout-page.component.ts

@ -23,8 +23,10 @@ export class LogoutPageComponent implements OnInit {
public ngOnInit() { public ngOnInit() {
this.authService.logoutRedirectComplete() this.authService.logoutRedirectComplete()
.subscribe({ .subscribe({
next: () => { next: path => {
this.router.navigate(['/'], { replaceUrl: true }); path ||= '/';
this.router.navigateByUrl(path, { replaceUrl: true });
}, },
error: () => { error: () => {
this.router.navigate(['/'], { replaceUrl: true }); this.router.navigate(['/'], { replaceUrl: true });

Loading…
Cancel
Save