Adding permission handling to webapp

This commit is contained in:
Martin Stockhammer 2020-11-04 16:20:31 +01:00
parent e597490140
commit 2313e1cd86
17 changed files with 468 additions and 30 deletions

View File

@ -29,7 +29,7 @@
<div class="collapse navbar-collapse" id="navbarsDefault">
<div class="navbar-nav ml-auto">
<span *ngIf="auth.loggedIn" class="navbar-text border-right pr-2 mr-2">
<span *ngIf="auth.authenticated" class="navbar-text border-right pr-2 mr-2">
{{user.userInfo.fullName}}
</span>
<ul class="navbar-nav">
@ -38,12 +38,12 @@
<i class="fas fa-home mr-1"></i>{{ 'menu.home' |translate }}
</a>
</li>
<li *ngIf="!auth.loggedIn" class="nav-item active">
<li *ngIf="!auth.authenticated" class="nav-item active">
<a class="nav-link" routerLink="/login" data-toggle="modal" data-target="#loginModal">
<i class="fas fa-user mr-1"></i>{{'menu.login' | translate}}
</a>
</li>
<li *ngIf="auth.loggedIn" class="nav-item active">
<li *ngIf="auth.authenticated" class="nav-item active">
<a class="nav-link" routerLink="/logout" (click)="auth.logout()">
<i class="fas fa-user mr-1"></i>{{'menu.logout' | translate}}
</a>

View File

@ -62,17 +62,17 @@ export class AppComponent implements OnInit, OnDestroy{
ngOnInit(): void {
let lang = this.user.userInfo.language;
if (lang==null) {
this.translate.use('en');
if (this.user.userInfo!=null && this.user.userInfo.language!=null ) {
this.translate.use(this.user.userInfo.language);
} else {
this.translate.use(lang);
this.translate.use('en');
}
// Subscribe to login event in authenticator to switch the language
this.auth.LoginEvent.subscribe(userInfo => {
if (userInfo.language != null) {
this.switchLang(userInfo.language);
}
// console.log("Permissions: " + JSON.stringify(this.user.permissions));
})
}

View File

@ -31,6 +31,7 @@ import { NotFoundComponent } from './modules/general/not-found/not-found.compone
import { SidemenuComponent } from './modules/general/sidemenu/sidemenu.component';
import {FormsModule, ReactiveFormsModule} from "@angular/forms";
import { LoginComponent } from './modules/general/login/login.component';
import { ViewPermissionDirective } from './directives/view-permission.directive';
@NgModule({
declarations: [
@ -41,6 +42,7 @@ import { LoginComponent } from './modules/general/login/login.component';
NotFoundComponent,
SidemenuComponent,
LoginComponent,
ViewPermissionDirective,
],
imports: [
BrowserModule,

View File

@ -0,0 +1,26 @@
/*
* 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 { ViewPermissionDirective } from './view-permission.directive';
describe('ViewPermissionDirective', () => {
it('should create an instance', () => {
const directive = new ViewPermissionDirective(null, null);
expect(directive).toBeTruthy();
});
});

View File

@ -0,0 +1,64 @@
/*
* 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 {Directive, ElementRef, Input, OnChanges, OnInit, Renderer2, SimpleChanges} from '@angular/core';
/**
* This directive can be used to render based on permissions
*/
@Directive({
selector: '[appViewPermission]'
})
export class ViewPermissionDirective implements OnInit, OnChanges {
@Input('appViewPermission') permission: boolean;
constructor(private renderer: Renderer2, private el: ElementRef) {
}
ngOnInit(): void {
// console.log("Init appViewPermission " + this.permission + " " + typeof (this.permission));
// this.togglePermission();
}
private togglePermission() {
if (this.permission) {
this.removeClass("d-none");
} else {
this.addClass("d-none");
}
}
addClass(className: string) {
// make sure you declare classname in your main style.css
this.renderer.addClass(this.el.nativeElement, className);
}
removeClass(className: string) {
this.renderer.removeClass(this.el.nativeElement, className);
}
ngOnChanges(changes: SimpleChanges): void {
if (changes.permission != null &&
(changes.permission.firstChange || changes.permission.currentValue != changes.permission.previousValue)) {
// console.debug("Changed " + JSON.stringify(changes));
this.togglePermission();
}
}
}

View File

@ -8,7 +8,6 @@
* 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

View File

@ -0,0 +1,25 @@
/*
* 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 { Operation } from './operation';
describe('Operation', () => {
it('should create an instance', () => {
expect(new Operation()).toBeTruthy();
});
});

View File

@ -0,0 +1,24 @@
/*
* 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.
*/
export class Operation {
name: string;
description: string;
descriptionKey: string;
permanent: boolean;
}

View File

@ -0,0 +1,25 @@
/*
* 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 { Permission } from './permission';
describe('Permission', () => {
it('should create an instance', () => {
expect(new Permission()).toBeTruthy();
});
});

View File

@ -0,0 +1,29 @@
/*
* 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 {Operation} from "./operation";
import {Resource} from "./resource";
export class Permission {
name: string;
description: string;
permanent: boolean;
descriptionKey: string;
operation: Operation;
resource: Resource;
}

View File

@ -0,0 +1,25 @@
/*
* 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 { Resource } from './resource';
describe('Resource', () => {
it('should create an instance', () => {
expect(new Resource()).toBeTruthy();
});
});

View File

@ -0,0 +1,23 @@
/*
* 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.
*/
export class Resource {
identifier: string;
permanent: boolean;
pattern: boolean;
}

View File

@ -28,7 +28,6 @@ export class UserInfo {
timestampAccountCreation:Date;
timestampLastLogin:Date;
timestampLastPasswordChange:Date;
assignedRoles:string[];
readOnly:boolean;
userManagerId:string;
validationToken:string;

View File

@ -18,19 +18,22 @@
-->
<nav class="nav flex-column nav-pills " role="tablist" aria-orientation="vertical">
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Artifacts</a>
<div [appViewPermission]="perms.menu.repo.section">
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true" >Artifacts</a>
<a class="nav-link active my-0 py-0" href="#" data-toggle="pill"
role="tab" aria-controls="v-pills-search" aria-selected="true">Search</a>
role="tab" aria-controls="v-pills-search" aria-selected="true" >Search</a>
<a class="nav-link my-0 py-0" href="#" data-toggle="pill"
role="tab" aria-controls="v-pills-browse" aria-selected="false">Browse</a>
<a class="nav-link my-0 py-0" href="#" data-toggle="pill"
role="tab" aria-controls="v-pills-browse" aria-selected="false">Upload Artifact</a>
role="tab" aria-controls="v-pills-browse" aria-selected="false"
[appViewPermission]="perms.menu.repo.upload">Upload Artifact</a>
</div>
<div [appViewPermission]="perms.menu.admin.section">
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true" data-toggle="pill"
role="tab" aria-controls="v-pills-home" aria-selected="false">Administration</a>
role="tab" aria-controls="v-pills-home" aria-selected="false" >Administration</a>
<a class="nav-link my-0 py-0" href="#" data-toggle="pill"
role="tab" aria-controls="v-pills-browse" aria-selected="false">Repository Groups</a>
<a class="nav-link my-0 py-0" href="#" data-toggle="pill"
@ -51,6 +54,8 @@
role="tab" aria-controls="v-pills-browse" aria-selected="false">UI Configuration</a>
<a class="nav-link my-0 py-0" href="#" data-toggle="pill"
role="tab" aria-controls="v-pills-browse" aria-selected="false">Reports</a>
</div>
<div [appViewPermission]="perms.menu.user.section">
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true" data-toggle="pill"
role="tab" aria-controls="v-pills-home" aria-selected="false">Users</a>
<a class="nav-link my-0 py-0" href="#" data-toggle="pill"
@ -59,6 +64,7 @@
role="tab" aria-controls="v-pills-browse" aria-selected="false">Roles</a>
<a class="nav-link my-0 py-0" href="#" data-toggle="pill"
role="tab" aria-controls="v-pills-browse" aria-selected="false">Users Runtime Configuration</a>
</div>
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true" data-toggle="pill"
role="tab" aria-controls="v-pills-home" aria-selected="false">Documentation</a>
<a class="nav-link my-0 py-0" href="#" data-toggle="pill"

View File

@ -17,6 +17,7 @@
* under the License.
*/
import { Component, OnInit } from '@angular/core';
import {UserService} from "../../../services/user.service";
@Component({
selector: 'app-sidemenu',
@ -25,7 +26,11 @@ import { Component, OnInit } from '@angular/core';
})
export class SidemenuComponent implements OnInit {
constructor() { }
perms;
constructor(private user: UserService) {
this.perms = user.uiPermissions;
}
ngOnInit(): void {
}

View File

@ -33,17 +33,22 @@ import {UserInfo} from "../model/user-info";
providedIn: 'root'
})
export class AuthenticationService {
loggedIn: boolean;
authenticated: boolean;
/**
* The LoginEvent is emitted, when a successful login happened. And the corresponding user info was retrieved.
*/
public LoginEvent: EventEmitter<UserInfo> = new EventEmitter<UserInfo>();
/**
* The LogoutEvent is emitted, when the user has been logged out.
*/
public LogoutEvent: EventEmitter<any> = new EventEmitter<any>();
constructor(private rest: ArchivaRequestService,
private userService: UserService) {
this.loggedIn = false;
this.authenticated = false;
this.restoreLoginData();
}
@ -55,13 +60,35 @@ export class AuthenticationService {
let expDate = new Date(expirationDate);
let currentDate = new Date();
if (currentDate < expDate) {
this.loggedIn = true
let observer = this.userService.retrieveUserInfo();
observer.subscribe(userInfo =>
observer.subscribe({
next: (userInfo: UserInfo) => {
if (userInfo != null) {
let permObserver = this.userService.retrievePermissionInfo();
permObserver.subscribe({
next: () => {
this.authenticated = true;
this.LoginEvent.emit(userInfo)
);
},
error: (err) => {
console.debug("Error retrieving perms: " + JSON.stringify(err));
}
}
)
}
},
error: (err: HttpErrorResponse) => {
console.debug("Error retrieving user info: " + JSON.stringify(err));
this.logout();
}
}
);
} else {
this.logout();
}
} else {
this.logout();
}
}
@ -94,9 +121,16 @@ export class AuthenticationService {
localStorage.setItem("token_expire", dt.toISOString());
}
let userObserver = this.userService.retrieveUserInfo();
this.loggedIn = true;
userObserver.subscribe(userInfo =>
this.LoginEvent.emit(userInfo));
this.authenticated = true;
userObserver.subscribe(userInfo => {
if (userInfo != null) {
let permObserver = this.userService.retrievePermissionInfo();
permObserver.subscribe((perms) => {
this.LoginEvent.emit(userInfo);
}
)
}
});
resultHandler("OK");
},
error: (err: HttpErrorResponse) => {
@ -104,7 +138,7 @@ export class AuthenticationService {
let result = err.error as ErrorResult
if (result.errorMessages != null) {
for (let msg of result.errorMessages) {
console.error('Observer got an error: ' + msg.errorKey)
console.debug('Observer got an error: ' + msg.errorKey)
}
resultHandler("ERROR", result.errorMessages);
} else {
@ -125,8 +159,9 @@ export class AuthenticationService {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
localStorage.removeItem("token_expire");
this.loggedIn = false;
this.authenticated = false;
this.userService.resetUser();
this.rest.resetToken();
this.LogoutEvent.emit();
}
}

View File

@ -16,23 +16,77 @@
* under the License.
*/
import {Injectable} from '@angular/core';
import {Injectable, OnDestroy, OnInit} from '@angular/core';
import {ArchivaRequestService} from "./archiva-request.service";
import {UserInfo} from '../model/user-info';
import {HttpErrorResponse} from "@angular/common/http";
import {ErrorResult} from "../model/error-result";
import {Observable} from "rxjs";
import {Permission} from '../model/permission';
@Injectable({
providedIn: 'root'
})
export class UserService {
export class UserService implements OnInit, OnDestroy {
userInfo: UserInfo;
permissions: Permission[];
guestPermissions: Permission[];
authenticated: boolean;
uiPermissionsDefault = {
'menu': {
'repo':{
'section':true,
'browse':true,
'search':true,
'upload':false
},
'admin':{
'section':false,
'config':false,
'status':false,
'reports':false
},
'user':{
'section':false,
'manage':false,
'roles':false,
'config':false
}
}
};
uiPermissions;
constructor(private rest: ArchivaRequestService) {
this.userInfo = new UserInfo()
this.userInfo = new UserInfo();
this.uiPermissions = {};
this.deepCopy(this.uiPermissionsDefault, this.uiPermissions);
}
ngOnDestroy(): void {
this.resetUser();
}
ngOnInit(): void {
this.userInfo.user_id = "guest";
this.loadPersistedUserInfo();
this.authenticated = false;
this.deepCopy(this.uiPermissionsDefault, this.uiPermissions);
if (this.guestPermissions == null) {
let observer = {
next: (permList: Permission[]) => {
this.guestPermissions = permList;
if (!this.authenticated) {
this.permissions = this.guestPermissions;
this.parsePermissions(this.permissions);
}
},
error: err => {
console.log("Could not retrieve permissions "+err);
}
}
this.retrievePermissionInfo().subscribe(observer);
}
}
/**
@ -53,16 +107,20 @@ export class UserService {
this.loadPersistedUserInfo();
}
this.persistUserInfo();
this.authenticated = true;
resultObserver.next(this.userInfo);
},
error: (err: HttpErrorResponse) => {
console.log("Error " + (JSON.stringify(err)));
let result = err.error as ErrorResult
if (result.errorMessages != null) {
if (result != null && result.errorMessages != null) {
for (let msg of result.errorMessages) {
console.error('Observer got an error: ' + msg.errorKey)
}
} else if (err.message != null) {
console.error("Bad response from user info call: " + err.message);
}
this.authenticated = false;
resultObserver.error();
},
complete: () => {
@ -74,6 +132,96 @@ export class UserService {
});
}
/**
* Retrieves the permission list from the REST service
*/
public retrievePermissionInfo(): Observable<Permission[]> {
return new Observable<Permission[]>((resultObserver) => {
let userName = this.authenticated ? "me" : "guest";
let infoObserver = this.rest.executeRestCall<Permission[]>("get", "redback", "users/" + userName + "/permissions", null);
let permissionObserver = {
next: (x: Permission[]) => {
this.permissions = x;
this.parsePermissions(x);
resultObserver.next(this.permissions);
},
error: (err: HttpErrorResponse) => {
console.log("Error " + (JSON.stringify(err)));
let result = err.error as ErrorResult
if (result.errorMessages != null) {
for (let msg of result.errorMessages) {
console.debug('Observer got an error: ' + msg.errorKey)
}
}
this.resetPermissions();
resultObserver.error(err);
},
complete: () => {
resultObserver.complete();
}
};
infoObserver.subscribe(permissionObserver);
});
}
resetPermissions() {
this.deepCopy(this.uiPermissionsDefault, this.uiPermissions);
}
parsePermissions(permissions: Permission[]) {
this.resetPermissions();
for ( let perm of permissions) {
// console.debug("Checking permission for op: " + perm.operation.name);
switch (perm.operation.name) {
case "archiva-manage-configuration": {
if (perm.resource.identifier=='*') {
this.uiPermissions.menu.admin.section = true;
this.uiPermissions.menu.admin.config = true;
this.uiPermissions.menu.admin.reports = true;
this.uiPermissions.menu.admin.status = true;
}
}
case "archiva-manage-users": {
if (perm.resource.identifier=='*') {
this.uiPermissions.menu.user.section = true;
this.uiPermissions.menu.user.config = true;
this.uiPermissions.menu.user.manage = true;
this.uiPermissions.menu.user.roles = true;
}
}
case "redback-configuration-edit": {
if (perm.resource.identifier=='*') {
this.uiPermissions.menu.user.section = true;
this.uiPermissions.menu.user.config = true;
}
}
case "archiva-upload-file": {
this.uiPermissions.menu.repo.upload = true;
}
}
}
console.log("New permissions: " + JSON.stringify(this.uiPermissions));
}
private deepCopy(src: Object, dst: Object) {
Object.keys(src).forEach((key, idx) => {
let srcEl = src[key];
if (typeof(srcEl)=='object' ) {
let dstEl;
if (!dst.hasOwnProperty(key)) {
dst[key] = {}
}
dstEl = dst[key];
this.deepCopy(srcEl, dstEl);
} else {
// console.debug("setting " + key + " = " + srcEl);
dst[key] = srcEl;
}
});
}
/**
* Stores user information persistent. Not the complete UserInfo object, only properties, that
* are needed.
@ -104,6 +252,9 @@ export class UserService {
*/
resetUser() {
this.userInfo = new UserInfo();
this.userInfo.user_id = "guest";
this.resetPermissions();
this.authenticated = false;
}
}