diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 610c10ccf5c6a61d2610e4be2b3fdefe9e51fcde..31a968fec3b241c1d54e3257898a92a6e781fe71 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -6,6 +6,7 @@ import { WelcomeRoutes } from './welcome/welcome.routes'; import {ServiceUnavailableRoutes} from './service-unavailable/service-unavailable.routes'; import {PageNotFoundComponent} from './shared/page-not-found/page-not-found.component'; import {LoginSuccessComponent} from './auth/login-success/login-success.component'; +import {LinkAccountComponent} from './welcome/link-account/link-account.component'; const appRoutes: Routes = [ ...WelcomeRoutes, @@ -13,6 +14,7 @@ const appRoutes: Routes = [ ...ServiceUnavailableRoutes, { path: 'notfound', component: PageNotFoundComponent }, { path: 'login-success', component: LoginSuccessComponent }, + { path: 'login-linking', component: LinkAccountComponent}, { path: '**', redirectTo: '/welcome' }, ]; diff --git a/src/app/auth/auth.service.spec.ts b/src/app/auth/auth.service.spec.ts index e9eef99417406df03337405ecd381adfb6f5a3c7..170ece28132bc78a75fd54ad8338c48914b82ea4 100644 --- a/src/app/auth/auth.service.spec.ts +++ b/src/app/auth/auth.service.spec.ts @@ -3,19 +3,18 @@ import {TestBed, waitForAsync} from '@angular/core/testing'; import {AuthService} from './auth.service'; import {AppConfigService, ConfigurationService} from '../service'; import {JwtHelperService} from '@auth0/angular-jwt'; -import {HttpClientTestingModule} from '@angular/common/http/testing'; +import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing'; import {Role, UserRole} from '../model/userrole'; import {ProfileService} from '../service/profile.service'; import {Observable, of} from 'rxjs'; -import { Configuration } from '../model/configuration'; -import { HttpHandler } from '@angular/common/http'; +import {Configuration} from '../model/configuration'; describe('Service: Auth', () => { let authService: AuthService; let appConfigServiceSpy: jasmine.SpyObj<AppConfigService>; let jwtHelperServiceSpy: jasmine.SpyObj<JwtHelperService>; let maintenanceServiceSpy: jasmine.SpyObj<ConfigurationService>; - + let httpMock: HttpTestingController; let store: any = {}; beforeEach(waitForAsync(() => { @@ -23,7 +22,8 @@ describe('Service: Auth', () => { config: { apiUrl: 'http://api.url', tokenName: 'token', - } + }, + getTestInstanceModalKey: () => 'testModalKey' }; const jwtSpy = jasmine.createSpyObj('JwtHelperService', ['decodeToken', 'isTokenExpired']); jwtSpy.decodeToken.and.returnValue({ @@ -41,19 +41,19 @@ describe('Service: Auth', () => { class MockConfigurationService { protected uri: string; - + constructor() { this.uri = 'http://localhost/api'; } - + public getApiUrl(): string { return 'http://localhost/api'; } - + public getConfiguration(): Observable<Configuration> { return of<Configuration>(); } - + public updateConfiguration(configuration: Configuration): Observable<any> { return of<Configuration>(); } @@ -84,6 +84,7 @@ describe('Service: Auth', () => { ], }); + httpMock = TestBed.inject(HttpTestingController) authService = TestBed.get(AuthService); authService.profile = [userRole, userRole2] appConfigServiceSpy = TestBed.get(AppConfigService); @@ -104,6 +105,10 @@ describe('Service: Auth', () => { }); })); + afterEach(() => { + httpMock.verify(); + store = {}; + }); it('should create service', () => { expect(authService).toBeTruthy(); @@ -180,8 +185,11 @@ describe('Service: Auth', () => { }); it('should remove token on logout', () => { + store['oidc-token'] = 'some-oidc-token'; authService.logout(); expect(store['token']).not.toBeDefined(); + const req = httpMock.expectOne('http://api.url/oidc/logout/some-oidc-token'); + req.flush({}); }); it('should be logged in when token is present and valid', () => { @@ -197,4 +205,58 @@ describe('Service: Auth', () => { expect(r).toEqual(false); }); + it('should store token and oidc token in localStorage', () => { + authService.storeToken('abc123'); + expect(store['token']).toEqual('abc123'); + + authService.storeOidcToken('oidc456'); + expect(store['oidc-token']).toEqual('oidc456'); + }); + + it('should remove roles from localStorage', () => { + store['rolesToken'] = 'some_roles'; + authService.removeRoles(); + expect(store['rolesToken']).toBeUndefined(); + }); + + it('should load and parse roles from localStorage', () => { + const roles = [{domainId: 1, role: Role.ROLE_USER, domainName: 'x'}]; + store['rolesToken'] = JSON.stringify(roles); + + const result = authService.loadRoles(); + expect(result.length).toEqual(1); + expect(result[0].role).toEqual(Role.ROLE_USER); + }); + + it('should assign loaded roles to profile', () => { + const roles = [{domainId: 2, role: Role.ROLE_DOMAIN_ADMIN, domainName: 'x'}]; + store['rolesToken'] = JSON.stringify(roles); + authService.loadAndSaveRoles(); + expect(authService.profile[0].role).toEqual(Role.ROLE_DOMAIN_ADMIN); + }); + it('should stringify and store roles', () => { + const roles = [new UserRole()]; + roles[0].domainId = 1; + roles[0].role = Role.ROLE_USER; + roles[0].domainName = 'dom1'; + + authService.storeRoles(roles); + expect(store['rolesToken']).toContain('ROLE_USER'); + }); + it('should get global role from token', () => { + const result = authService.getGlobalRole(); + expect(result).toContain('ROLE_SYSTEM_ADMIN'); + }); + + it('should handle login error with catchError', waitForAsync(() => { + authService.login('user', 'pass').subscribe({ + next: () => fail('Expected error'), + error: (err) => { + expect(err.status).toEqual(401); + } + }); + + const req = httpMock.expectOne('http://api.url/auth/basic/login'); + req.flush({ message: 'Invalid credentials' }, { status: 401, statusText: 'Unauthorized' }); + })); }); diff --git a/src/app/auth/auth.service.ts b/src/app/auth/auth.service.ts index a3426c27b2b23a95efaec88574ee9c9078b05f85..6156c8e479cfd788ed7808556017f30cb57bc40f 100644 --- a/src/app/auth/auth.service.ts +++ b/src/app/auth/auth.service.ts @@ -1,13 +1,11 @@ -import {BehaviorSubject, Observable, Subject, throwError as observableThrowError, of} from 'rxjs'; +import {BehaviorSubject, Observable, of, Subject, throwError as observableThrowError} from 'rxjs'; import {catchError, debounceTime, map} from 'rxjs/operators'; import {Injectable} from '@angular/core'; import {AppConfigService, ConfigurationService} from '../service'; import {JwtHelperService} from '@auth0/angular-jwt'; import {HttpClient, HttpHeaders} from '@angular/common/http'; -import {User} from '../model'; import {ProfileService} from '../service/profile.service'; import {Role, UserRole} from '../model/userrole'; -import {interval, Subscription} from 'rxjs'; export class DomainRoles { @@ -17,10 +15,6 @@ export class DomainRoles { ) { } - public getDomainId(): number { - return this.domainId; - } - public getRoles(): string[] { return this.roles; } @@ -183,15 +177,6 @@ export class AuthService { return this.jwtHelper.decodeToken(token).global_role; } - public getDomainsRoles() { - const token = this.getToken(); - if (token == null) { - return null; - } - return this.jwtHelper.decodeToken(token).roles; - - } - public getDomainRoles(): Map<number, DomainRoles> { const domainRolesMap: Map<number, DomainRoles> = new Map<number, DomainRoles>(); @@ -253,6 +238,49 @@ export class AuthService { return domainsWithRole; } + public oidcLinkingLogin(oidcToken: string, + email: string, + password: string, + uuid: string, + firstName: string, + lastName: string) { + const headers = new HttpHeaders({'Content-Type': 'application/json', 'Accept': 'application/json'}); + + return this.http.post(this.appConfig.config.apiUrl + '/oidc/link', + JSON.stringify( + { + 'oidcToken': oidcToken, + 'email': email, + 'password': password, + 'uuid': uuid, + 'firstName': firstName, + 'lastName': lastName, + } + ), + {headers: headers}).pipe( + debounceTime(1000), + map((res: Response) => { + const token = res && res['token']; + const oidcToken = res && res['oidcToken']; + if (token && oidcToken) { + this.storeToken(token); + this.storeOidcToken(oidcToken); + this.loginUsingSsoService = false; + this.isLoggedInSubject.next(true); + this.profileService.getRoles().subscribe(profile => { + this.profile = profile + this.storeRoles(profile); + return true; + }) + } else { + this.isLoggedInSubject.next(false); + return false; + } + } + ), + ) + } + public login(username: string, password: string): Observable<boolean> { // hack so test instance modal is shown onl after login localStorage.setItem(this.appConfig.getTestInstanceModalKey(), 'True'); @@ -306,49 +334,6 @@ export class AuthService { })); } - public propagateSSOLogin(userid: string): Observable<boolean> { - console.debug('propagateSSOLogin'); - console.debug('propagateSSOLogin ' + this.appConfig.config.apiUrl); - console.debug('propagateSSOLogin ' + this.appConfig.config.apiUrl + '/auth/sso/login'); - console.debug('propagateSSOLogin ' + userid); - // hack so test instance modal is shown onl after login - localStorage.setItem(this.appConfig.getTestInstanceModalKey(), 'True'); - - if (this.maintenance) { - this.isLoggedInSubject.next(false); - return of(false); - } - - const headers = new HttpHeaders({'Content-Type': 'application/json', 'Accept': 'application/json'}); - return this.http.post(this.appConfig.config.apiUrl + '/auth/sso/login', - JSON.stringify({'userid': userid}), {headers: headers}).pipe( - debounceTime(10000), - map((response: Response) => { - console.debug('SSO login response: ' + response); - // login successful if there's a jwt token in the response - const token = response && response['token']; - - if (token) { - this.storeToken(token); - console.debug('SSO AUTH | User: ' + this.getUsername()); - console.debug('SSO AUTH | Domains: ' + this.getDomains()); - console.debug('SSO AUTH | Roles: ' + this.getRoles()); - console.debug('SSO AUTH | DomainRoles: ' + this.getDomainRoles()); - this.loginUsingSsoService = true; - this.isLoggedInSubject.next(true); - return true; - } else { - // return false to indicate failed login - this.isLoggedInSubject.next(false); - return false; - } - }), - catchError((error) => { - console.error('SSO login error: ' + error.error['message']); - return observableThrowError(error); - })); - } - public logout(): void { const oidcToken = this.getOidcToken(); this.refresh = null; @@ -366,6 +351,11 @@ export class AuthService { } } + public oidcLogout(oidcToken: string): void { + this.http.get(this.appConfig.config.apiUrl + '/oidc/logout/' + oidcToken).subscribe(() => { + }) + } + public isLogged(): boolean { const token = this.getToken(); if (token == null) { @@ -374,11 +364,6 @@ export class AuthService { return (token ? !this.jwtHelper.isTokenExpired(token) : false); } - get isLoggedIn$(): Observable<boolean> { - return this.isLoggedInSubject.pipe( - debounceTime(100), // use debounceTime to aggregate multiple emissions https://rxjs.dev/api/operators/debounceTime - ); - } public getDomainIds(): number[] { return Array.from(new Set(this.profile.map(ur => ur.domainId))); diff --git a/src/app/welcome/link-account/link-account.component.css b/src/app/welcome/link-account/link-account.component.css new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/src/app/welcome/link-account/link-account.component.css @@ -0,0 +1 @@ + diff --git a/src/app/welcome/link-account/link-account.component.html b/src/app/welcome/link-account/link-account.component.html new file mode 100644 index 0000000000000000000000000000000000000000..1ab303ed37f4a43c7b9de3afd3519ec4b878ef37 --- /dev/null +++ b/src/app/welcome/link-account/link-account.component.html @@ -0,0 +1,51 @@ +<div style="display: flex; justify-content: center;"> + <div style=" +margin-top: 50px; + width: 60% +" class="panel panel-default"> + <div class="panel-heading">{{ 'ACCOUNT_LINKING.HEADER' | translate }}</div> + <div class="panel-body"> + <form *ngIf="user" + class="form-horizontal" #userDetailsForm="ngForm"> + <div> + <p> + {{ 'ACCOUNT_LINKING.INFO' | translate }} + </p> + </div> + <div class="form-group"> + <label class="col-sm-2 control-label">{{ 'USER_DETAILS.FIRST_NAME' | translate }}</label> + <div class="col-sm-10"> + <p class="form-control-static">{{ user.firstname }}</p> + </div> + </div> + + <div class="form-group"> + <label class="col-sm-2 control-label">{{ 'USER_DETAILS.LAST_NAME' | translate }}</label> + <div class="col-sm-10"> + <p class="form-control-static">{{ user.lastname }}</p> + </div> + </div> + + <div class="form-group"> + <label class="col-sm-2 control-label">{{ 'USER_DETAILS.EMAIL' | translate }}</label> + <div class="col-sm-10"> + <p class="form-control-static">{{ user.email }}</p> + </div> + </div> + + <div class="form-group"> + <label for="password" class="col-sm-2 control-label">{{ 'PASSWORD.PASSWORD' | translate }}</label> + <div class="col-sm-10"> + <input type="password" class="form-control" id="password" + name="password" [(ngModel)]="password"> + </div> + </div> + <button type="submit" class="btn btn-primary" + (click)="submit()">{{ 'ACCOUNT_LINKING.CONFIRM' | translate }} + </button> + + </form> + <br> + </div> + </div> +</div> diff --git a/src/app/welcome/link-account/link-account.component.spec.ts b/src/app/welcome/link-account/link-account.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..0b0a9f98c9b851dbd8f044e900df4b02595ca2ca --- /dev/null +++ b/src/app/welcome/link-account/link-account.component.spec.ts @@ -0,0 +1,57 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; + +import {LinkAccountComponent} from './link-account.component'; +import {ActivatedRoute} from '@angular/router'; +import {of} from 'rxjs'; +import {AuthService} from '../../auth/auth.service'; +import {TranslateFakeLoader, TranslateLoader, TranslateModule} from '@ngx-translate/core'; + +describe('LinkAccountComponent', () => { + let component: LinkAccountComponent; + let fixture: ComponentFixture<LinkAccountComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [LinkAccountComponent], + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateFakeLoader + } + })], + providers: [ + { + provide: ActivatedRoute, + useValue: { + queryParams: of({ + oidc_token: 'mocked.jwt.token' + }), + snapshot: { + paramMap: { + get: () => null + } + } + } + }, + { + provide: AuthService, + useValue: { + isLogged: () => true, + oidcLogout: jasmine.createSpy('oidcLogout'), + oidcLinkingLogin: jasmine.createSpy('oidcLinkingLogin').and.returnValue(of({})) + } + }, + + ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(LinkAccountComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/welcome/link-account/link-account.component.ts b/src/app/welcome/link-account/link-account.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..1f4f967c4fb0b2b058724e575469136354574555 --- /dev/null +++ b/src/app/welcome/link-account/link-account.component.ts @@ -0,0 +1,66 @@ +import {Component, OnDestroy, OnInit} from '@angular/core'; +import {User} from '../../model'; +import {ActivatedRoute, Router} from '@angular/router'; +import jwtDecode from 'jwt-decode'; +import {AuthService} from '../../auth/auth.service'; + + +@Component({ + selector: 'app-link-account', + templateUrl: './link-account.component.html', + styleUrl: './link-account.component.css' +}) +export class LinkAccountComponent implements OnInit, OnDestroy { + public user: User; + private token: string; + public password: string; + + constructor( + private readonly route: ActivatedRoute, + private readonly authService: AuthService, + private readonly router: Router + ) { + } + + ngOnDestroy() { + if (!this.authService.isLogged()) { + this.authService.oidcLogout(this.token) + } + } + + ngOnInit() { + this.route.queryParams.subscribe(param => { + this.token = param['oidc_token']; + const decoded: TokenPayload = jwtDecode<TokenPayload>(this.token); + this.user = new User(); + this.user.username = decoded.sub; + this.user.firstname = decoded.given_name; + this.user.lastname = decoded.family_name; + this.user.email = decoded.email; + }) + + } + + public submit(): void { + this.authService.oidcLinkingLogin( + this.token, + this.user.email, + this.password, + this.user.username, + this.user.firstname, + this.user.lastname, + ).subscribe( + () => { + this.router.navigate(['/']); + } + ) + } +} + + +interface TokenPayload { + sub: string; + email: string; + given_name: string; + family_name: string; +} diff --git a/src/app/welcome/login/login.component.ts b/src/app/welcome/login/login.component.ts index 62e1fc26688f7417969f4cd671b4a93317e49e2a..6971d77629a2e0592e8eb6347b02f56078c5d1c1 100644 --- a/src/app/welcome/login/login.component.ts +++ b/src/app/welcome/login/login.component.ts @@ -64,8 +64,11 @@ export class LoginComponent implements OnInit { ); } + // only for use in linking accounts public triggerOIDC() { - window.location.href = this.appConfig.getOidcUrl(); + if (this.configuration.maintenance) { + window.location.href = this.appConfig.getOidcUrl(); + } } public sendResetNotification() { diff --git a/src/app/welcome/welcome.module.ts b/src/app/welcome/welcome.module.ts index 08fc1c81e12392217f5c403431925659eea6afb8..e212c4f059d959724211aa423e911d38b087f067 100644 --- a/src/app/welcome/welcome.module.ts +++ b/src/app/welcome/welcome.module.ts @@ -20,38 +20,41 @@ import {TranslateModule} from '@ngx-translate/core'; import {PasswordResetComponent} from './passwordreset/password-reset.component'; import {PasswordStrengthMeterComponent} from 'angular-password-strength-meter'; import {PolicySubpageComponent} from './policy-subpage/policy-subpage.component'; +import {LinkAccountComponent} from './link-account/link-account.component'; @NgModule({ - declarations: [ - WelcomeComponent, - LoginComponent, - LogoutComponent, - RegistrationComponent, - ProfileComponent, - CompleteComponent, - TermsAcceptanceComponent, - PasswordResetComponent, - PolicySubpageComponent - ], - imports: [ - FormsModule, - ReactiveFormsModule, - CommonModule, - RouterModule, - SharedModule, - PipesModule, - AppMarketModule, - PasswordStrengthMeterComponent, - TranslateModule.forChild() - ], - exports: [ - WelcomeComponent - ], - providers: [ - RegistrationService, - UserService, - ChangelogService, - ContentDisplayService - ] + declarations: [ + WelcomeComponent, + LoginComponent, + LogoutComponent, + RegistrationComponent, + ProfileComponent, + CompleteComponent, + TermsAcceptanceComponent, + PasswordResetComponent, + PolicySubpageComponent, + LinkAccountComponent + ], + imports: [ + FormsModule, + ReactiveFormsModule, + CommonModule, + RouterModule, + SharedModule, + PipesModule, + AppMarketModule, + PasswordStrengthMeterComponent, + TranslateModule.forChild() + ], + exports: [ + WelcomeComponent + ], + providers: [ + RegistrationService, + UserService, + ChangelogService, + ContentDisplayService + ] }) -export class WelcomeModule {} +export class WelcomeModule { +}