Added Keycloak and WebsecurityConfig and improved script management

This commit is contained in:
Marcel Haag 2021-04-16 13:35:48 +02:00
parent cccb3c5b3e
commit 4fb3d82fee
63 changed files with 2647 additions and 165 deletions

35
c4po.sh
View File

@ -1,6 +1,12 @@
#!/bin/bash
baseDir=$(pwd)"/"
echo"
docker_reg="c4po.io"
baseDir=$(pwd)
composeKeycloak=$baseDir"/security-c4po-cfg/kc/docker-compose.keycloak.yml"
composeFrontend=$baseDir"/security-c4po-cfg/frontend/docker-compose.frontend.yml"
composeBackend=$baseDir"/security-c4po-cfg/backend/docker-compose.backend.yml"
echo -e "
_______ _______ _______ _ _ ______ _____ _______ __ __
|______ |______ | | | |_____/ | | \_/
______| |______ |_____ |_____| | \_ __|__ | | _/_/_/ _/ _/ _/_/_/ _/_/
@ -8,6 +14,25 @@ ______| |______ |_____ |_____| | \_ __|__ | | _/_/_/ _/
_/ _/_/_/_/ _/_/_/ _/ _/
_/ _/ _/ _/ _/
_/_/_/ _/ _/ _/_/
"
#docker-compose up --build
docker-compose up
\n"
echo "-------------CLEAN UP Container---------------"
echo -e "\n"
#docker rm -f security-c4po-keycloak
#docker rm -f security-c4po-postgres-keycloak
docker rm -f security-c4po-api
docker rm -f security-c4po-angular
echo -e "\n"
echo "-----------------Start Build------------------"
echo -e "\n"
echo " - Backend: "
docker-compose -f ${composeBackend} build
echo -e "\n"
echo " - Frontend: "
docker-compose -f ${composeFrontend} build
echo -e "\n"
echo "------------Start Docker Container------------"
echo -e "\n"
docker-compose -f ${composeKeycloak} -f ${composeBackend} -f ${composeFrontend} up

View File

@ -1,24 +0,0 @@
version: '3.1'
services:
api:
build: './security-c4po-api'
image: security-c4po-api:latest
container_name: security-c4po-api
deploy:
resources:
limits:
memory: "1G"
ports:
- '8443:8443'
angular:
build: './security-c4po-angular'
image: security-c4po-angular:latest
container_name: security-c4po-angular
deploy:
resources:
limits:
memory: "1G"
ports:
- '4200:4200'

View File

@ -8610,6 +8610,11 @@
"supports-color": "^7.0.0"
}
},
"js-sha256": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.9.0.tgz",
"integrity": "sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA=="
},
"js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -8782,6 +8787,11 @@
"set-immediate-shim": "~1.0.1"
}
},
"jwt-decode": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
},
"karma-source-map-support": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/karma-source-map-support/-/karma-source-map-support-1.4.0.tgz",
@ -8791,6 +8801,30 @@
"source-map-support": "^0.5.5"
}
},
"keycloak-angular": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/keycloak-angular/-/keycloak-angular-8.1.0.tgz",
"integrity": "sha512-FNIZBVKI3QNw0ucHnSjDDe8859WT6NtVlsKtCvJzAS9mFiYCDFDT9cRWt9On2aFu39rGyBEBNbpsTE1Mso48NQ==",
"requires": {
"tslib": "^2.0.0"
}
},
"keycloak-js": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-13.0.0.tgz",
"integrity": "sha512-XMbppXjkkFmt88vR8jrxH32dz/dmFETDObD6NzLAT3HgpC9Thi6LSEUm7XsROq4z+2i/qLwlWTHbgLXj6LxBrg==",
"requires": {
"base64-js": "1.3.1",
"js-sha256": "0.9.0"
},
"dependencies": {
"base64-js": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz",
"integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g=="
}
}
},
"killable": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz",

View File

@ -35,6 +35,9 @@
"@ngxs/store": "^3.7.0",
"eva-icons": "^1.1.3",
"i18n-iso-countries": "^6.2.2",
"jwt-decode": "^3.1.2",
"keycloak-angular": "^8.1.0",
"keycloak-js": "^13.0.0",
"moment": "^2.29.1",
"moment-timezone": "latest",
"ngx-moment": "^5.0.0",

View File

@ -2,10 +2,8 @@ import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import {HomeComponent} from './home/home.component';
import {AuthGuardService} from '../shared/guards/auth-guard.service';
import {LoginGuardService} from '../shared/guards/login-guard.service';
export const START_PAGE = 'home';
export const FALLBACK_PAGE = 'home';
const routes: Routes = [
{
@ -18,11 +16,12 @@ const routes: Routes = [
loadChildren: () => import('./dashboard').then(mod => mod.DashboardModule),
canActivate: [AuthGuardService]
},
{
// ToDo: Exchange default Keycloak login with self made login
/*{
path: 'login',
loadChildren: () => import('./login').then(mod => mod.LoginModule),
canActivate: [LoginGuardService]
},
},*/
{path: '**', redirectTo: START_PAGE},
{path: '', redirectTo: START_PAGE, pathMatch: 'full'},
];

View File

@ -11,6 +11,7 @@ import {HttpClientTestingModule} from '@angular/common/http/testing';
import {SessionState} from '../shared/stores/session-state/session-state';
import {NgxsModule} from '@ngxs/store';
import {HeaderModule} from './header/header.module';
import {KeycloakService} from 'keycloak-angular';
describe('AppComponent', () => {
beforeEach(async () => {
@ -32,6 +33,9 @@ describe('AppComponent', () => {
NgxsModule.forRoot([SessionState]),
HttpClientTestingModule
],
providers: [
KeycloakService
],
declarations: [
AppComponent
],

View File

@ -1,4 +1,4 @@
import {NgModule} from '@angular/core';
import {APP_INITIALIZER, NgModule} from '@angular/core';
import {BrowserModule} from '@angular/platform-browser';
import {AppRoutingModule} from './app-routing.module';
import {AppComponent} from './app.component';
@ -23,6 +23,8 @@ import {NotificationService} from '../shared/services/notification.service';
import {ThemeModule} from '@assets/@theme/theme.module';
import {HeaderModule} from './header/header.module';
import {HomeModule} from './home/home.module';
import {KeycloakService} from 'keycloak-angular';
import {httpInterceptorProviders} from '../shared/interceptors';
@NgModule({
declarations: [
@ -55,9 +57,19 @@ import {HomeModule} from './home/home.module';
],
providers: [
HttpClient,
{
provide: APP_INITIALIZER,
useFactory: initializer,
multi: true,
deps: [KeycloakService]
},
KeycloakService,
httpInterceptorProviders,
NotificationService
],
bootstrap: [AppComponent]
bootstrap: [
AppComponent
]
})
export class AppModule {
constructor(library: FaIconLibrary, faConfig: FaConfig) {
@ -65,3 +77,30 @@ export class AppModule {
faConfig.defaultPrefix = 'fas';
}
}
export function initializer(keycloak: KeycloakService): () => Promise<any> {
return async (): Promise<any> => {
try {
await keycloak.init({
config: {
url: environment.keycloakURL,
realm: environment.keycloakrealm,
clientId: environment.keycloakclientId
},
initOptions: {
onLoad: 'login-required',
checkLoginIframe: false,
// flow: 'implicit'
},
loadUserProfileAtStartUp: false,
enableBearerInterceptor: true,
bearerExcludedUrls: [
'/assets',
'/clients/public'
]
});
} catch (error) {
// console.error(error);
}
};
}

View File

@ -8,7 +8,9 @@ describe('DashboardComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ DashboardComponent ]
declarations: [
DashboardComponent
]
})
.compileComponents();
});

View File

@ -3,6 +3,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HomeComponent } from './home.component';
import {NbButtonModule, NbCardModule} from '@nebular/theme';
import {HttpClientTestingModule} from '@angular/common/http/testing';
import {KeycloakService} from 'keycloak-angular';
describe('HomeComponent', () => {
let component: HomeComponent;
@ -17,6 +18,9 @@ describe('HomeComponent', () => {
HttpClientTestingModule,
NbCardModule,
NbButtonModule
],
providers : [
KeycloakService
]
})
.compileComponents();

View File

@ -23,6 +23,7 @@ import {CommonModule} from '@angular/common';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {NotificationService} from '../../shared/services/notification.service';
import {NotificationServiceMock} from '../../shared/services/notification.service.mock';
import {KeycloakService} from 'keycloak-angular';
const DESIRED_STORE_STATE_SESSION: SessionStateModel = {
userAccount: {
@ -68,6 +69,7 @@ describe('LoginComponent', () => {
LoginComponent
],
providers: [
KeycloakService,
{provide: NotificationService, useValue: new NotificationServiceMock()}
]
})

View File

@ -10,12 +10,14 @@ import {UpdateIsAuthenticated, UpdateUser} from '../../shared/stores/session-sta
import {GlobalTitlesVariables} from '../../shared/config/global-variables';
import {HttpClient} from '@angular/common/http';
import {FieldStatus} from '../../shared/models/form-field-status.model';
import {KeycloakService} from 'keycloak-angular';
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.scss']
})
// ToDo: Exchange default Keycloak login with self made login
export class LoginComponent implements OnInit, OnDestroy {
readonly MIN_LENGTH: number = 2;
readonly SECURITYC4PO_TITLE = GlobalTitlesVariables.SECURITYC4PO_TITLE;
@ -41,7 +43,8 @@ export class LoginComponent implements OnInit, OnDestroy {
private router: Router,
private store: Store,
private readonly httpClient: HttpClient,
private notificationService: NotificationService) {
private notificationService: NotificationService,
protected keycloakService: KeycloakService) {
}
ngOnInit(): void {
@ -65,8 +68,9 @@ export class LoginComponent implements OnInit, OnDestroy {
login(): void {
const username = this.loginUsernameCtrl.value;
const password = this.loginPasswordCtrl.value;
if (username === DefaultUser.username
&& password === DefaultUser.password) {
// ToDo: Should be handled in Guards
this.keycloakService.login({});
if (true) {
// ToDo: Should be handled in Guards
this.store.dispatch(new UpdateIsAuthenticated(true));
this.store.dispatch(new UpdateUser(this.user, true));
@ -120,10 +124,3 @@ export class LoginComponent implements OnInit, OnDestroy {
export interface Version {
version: string;
}
export enum DefaultUser {
username = 'ttt',
password = 'Test1234!'
}

View File

@ -1,3 +1,11 @@
export const environment = {
production: true
production: true,
// keycloak
keycloakURL: 'http://localhost:8888/auth',
keycloakrealm: 'c4po_realm_local',
keycloakclientId: 'c4po_local',
// backend service
apiEndpoint: 'http://localhost:8443',
};

View File

@ -5,6 +5,13 @@
export const environment = {
stage: 'n/a',
production: false,
// keycloak
keycloakURL: 'http://localhost:8888/auth',
keycloakrealm: 'c4po_realm_local',
keycloakclientId: 'c4po_local',
// backend service
apiEndpoint: 'http://localhost:8443',
};

View File

@ -4,45 +4,42 @@ import {Store} from '@ngxs/store';
import {Observable, of} from 'rxjs';
import {SessionState} from '../stores/session-state/session-state';
import {catchError, map} from 'rxjs/operators';
import {KeycloakAuthGuard, KeycloakService} from 'keycloak-angular';
import {UpdateIsAuthenticated, UpdateUser} from '../stores/session-state/session-state.actions';
import {User} from '../models/user.model';
@Injectable({
providedIn: 'root'
})
export class AuthGuardService implements CanActivate {
export class AuthGuardService extends KeycloakAuthGuard implements CanActivate {
constructor(
private readonly router: Router,
public readonly router: Router,
protected keycloakService: KeycloakService,
private readonly store: Store) {
super(router, keycloakService);
}
canActivate(
next: ActivatedRouteSnapshot,
state: RouterStateSnapshot): Observable<boolean> {
return this.isAuthenticated()
.pipe(
map((canAccess: boolean) => {
if (canAccess) {
return canAccess;
} else {
this.router.navigate(['/login']);
return false;
}
})
);
}
isAccessAllowed(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise<boolean> {
return new Promise((resolve, reject) => {
if (!this.authenticated) {
this.keycloakAngular.login()
.catch(e => console.error(e));
return reject(false);
}
/**
* @return boolean
*/
private isAuthenticated(): Observable<boolean> {
// ToDo: Should check from Authentication Provider
return of(this.store.selectSnapshot(SessionState.isAuthenticated))
.pipe(
map((isLoggedIn: boolean) => {
return isLoggedIn;
}),
catchError(() => {
return of(false);
})
);
const requiredRoles: string[] = route.data.roles;
if (!requiredRoles || requiredRoles.length === 0) {
this.store.dispatch(new UpdateIsAuthenticated(true));
this.store.dispatch(new UpdateUser(route.data.user, true));
return resolve(true);
} else {
if (!this.roles || this.roles.length === 0) {
this.store.dispatch(new UpdateIsAuthenticated(false));
this.store.dispatch(new UpdateUser(null, true));
resolve(false);
}
resolve(requiredRoles.every(role => this.roles.indexOf(role) > -1));
}
});
}
}

View File

@ -8,6 +8,7 @@ import {SessionState} from '../stores/session-state/session-state';
import {TranslateLoader, TranslateModule} from '@ngx-translate/core';
import {HttpLoaderFactory} from '../../app/common-app.module';
import {HttpClient} from '@angular/common/http';
import {KeycloakService} from 'keycloak-angular';
describe('LoginGuardService', () => {
let service: LoginGuardService;
@ -27,6 +28,7 @@ describe('LoginGuardService', () => {
NgxsModule.forRoot([SessionState])
],
providers: [
KeycloakService
]
});
service = TestBed.inject(LoginGuardService);

View File

@ -4,18 +4,45 @@ import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot} from '
import {Observable, of} from 'rxjs';
import {catchError, map, tap} from 'rxjs/operators';
import {SessionState} from '../stores/session-state/session-state';
import {KeycloakAuthGuard, KeycloakService} from 'keycloak-angular';
import {UpdateIsAuthenticated, UpdateUser} from '../stores/session-state/session-state.actions';
@Injectable({
providedIn: 'root'
})
export class LoginGuardService implements CanActivate {
export class LoginGuardService extends KeycloakAuthGuard implements CanActivate {
constructor(
private router: Router,
private store: Store) {
public readonly router: Router,
protected keycloakAngular: KeycloakService,
private readonly store: Store) {
super(router, keycloakAngular);
}
canActivate(routeSnapshot: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
isAccessAllowed(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise<boolean> {
return new Promise((resolve, reject) => {
if (!this.authenticated) {
this.keycloakAngular.login()
.catch(e => console.error(e));
return reject(false);
}
const requiredRoles: string[] = route.data.roles;
if (!requiredRoles || requiredRoles.length === 0) {
this.store.dispatch(new UpdateIsAuthenticated(true));
this.store.dispatch(new UpdateUser(route.data.user, true));
return resolve(true);
} else {
if (!this.roles || this.roles.length === 0) {
this.store.dispatch(new UpdateIsAuthenticated(false));
this.store.dispatch(new UpdateUser(null, true));
resolve(false);
}
resolve(requiredRoles.every(role => this.roles.indexOf(role) > -1));
}
});
}
/* canActivate(routeSnapshot: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
return this.isAuthenticated().pipe(
tap((canAccess: boolean) => {
if (canAccess) {
@ -27,9 +54,9 @@ export class LoginGuardService implements CanActivate {
);
}
/**
/!**
* @return state of authentication
*/
*!/
private isAuthenticated(): Observable<boolean> {
// ToDo: Should check from Authentication Provider
return of(this.store.selectSnapshot(SessionState.isAuthenticated))
@ -41,6 +68,6 @@ export class LoginGuardService implements CanActivate {
return of(false);
})
);
}
}*/
}

View File

@ -0,0 +1,6 @@
import {HTTP_INTERCEPTORS} from '@angular/common/http';
import {TokenInterceptor} from './token.interceptor';
export const httpInterceptorProviders = [
{provide: HTTP_INTERCEPTORS, useClass: TokenInterceptor, multi: true},
];

View File

@ -0,0 +1,72 @@
import {Injectable} from '@angular/core';
import {HttpEvent, HttpHandler, HttpInterceptor, HttpRequest} from '@angular/common/http';
import {KeycloakService} from 'keycloak-angular';
import {environment} from '../../environments/environment';
import {Observable, Subscriber} from 'rxjs';
import {mergeMap} from 'rxjs/operators';
@Injectable()
export class TokenInterceptor implements HttpInterceptor {
constructor(private keycloakService: KeycloakService) {
}
/**
* TODO: has to be edit every time a service is added and requires the keycloak token on HEADER
*/
private static listOfKeycloakRelevantHosts(): { origin: string }[] {
const relevantList = new Array<{ origin: string }>();
relevantList.push({origin: getOriginByUrl(environment.apiEndpoint)});
relevantList.push({origin: getOriginByUrl(environment.keycloakURL)});
return relevantList;
function getOriginByUrl(inputUrl: string): string {
return (new URL(inputUrl)).origin;
}
}
private static requestTargetIsWhitelisted(requestTargetOrigin: string): boolean {
if (requestTargetOrigin) {
try {
const targetUrl: URL = new URL(requestTargetOrigin);
if (targetUrl && targetUrl.origin) {
const matchList = TokenInterceptor.listOfKeycloakRelevantHosts()
.map(value => value.origin)
.filter(value => (value === targetUrl.origin));
return !!matchList.length;
}
} catch (e) {
// ignore e.g. local calls
}
}
return false;
}
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const requestTargetHost = request.url || '';
if (TokenInterceptor.requestTargetIsWhitelisted(requestTargetHost)) {
const tokenObserver: Observable<string> = new Observable((observer: Subscriber<any>): void => {
this.keycloakService.getToken().then(token => {
observer.next(token);
observer.complete();
}).catch(error => {
observer.error(error);
observer.complete();
});
});
return tokenObserver.pipe(
mergeMap((authToken: string) => {
request = request.clone({
headers: request.headers.append('Authorization', `Bearer ${authToken}`)
});
return next.handle(request);
}
));
} else {
// Do nothing
return next.handle(request);
}
}
}

View File

@ -1,12 +1,12 @@
import { v4 as UUID } from 'uuid';
export class User {
id: string;
username: string;
firstName: string;
lastName: string;
mailAddress: string;
interfaceLang: string;
id?: string;
username?: string;
firstName?: string;
lastName?: string;
mailAddress?: string;
interfaceLang?: string;
constructor(username?: string,
firstName?: string,
@ -17,7 +17,15 @@ export class User {
this.username = username;
this.firstName = firstName;
this.lastName = lastName;
this.mailAddress = email;
this.interfaceLang = interfaceLang;
if (email) {
this.mailAddress = email;
} else {
this.mailAddress = null;
}
if (interfaceLang) {
this.interfaceLang = interfaceLang;
} else {
this.interfaceLang = 'en-US';
}
}
}

View File

@ -10,6 +10,7 @@ import {HttpLoaderFactory} from '../../app/common-app.module';
import {HttpClient} from '@angular/common/http';
import {NgxsModule} from '@ngxs/store';
import {SessionState} from '../stores/session-state/session-state';
import {KeycloakService} from 'keycloak-angular';
describe('NotificationService', () => {
let toastrServiceStub: Partial<NbToastrService>;
@ -45,6 +46,7 @@ describe('NotificationService', () => {
],
providers: [
NotificationService,
KeycloakService,
{provide: NbToastrService, useValue: toastrServiceStub},
{provide: TranslateService, useValue: translateServiceStub}]
});

View File

@ -3,6 +3,7 @@ import { TestBed } from '@angular/core/testing';
import { ProjectService } from './project.service';
import {HttpClientTestingModule} from '@angular/common/http/testing';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {KeycloakService} from 'keycloak-angular';
describe('ProjectService', () => {
let service: ProjectService;
@ -13,7 +14,9 @@ describe('ProjectService', () => {
HttpClientTestingModule,
BrowserAnimationsModule,
],
providers: []
providers: [
KeycloakService
]
});
service = TestBed.inject(ProjectService);
});

View File

@ -9,7 +9,7 @@ import {Observable} from 'rxjs';
})
export class ProjectService {
private apiBaseURL = `${environment.apiEndpoint}/v1/projects`;
private apiBaseURL = `${environment.apiEndpoint}/projects`;
constructor(private http: HttpClient) {
}

View File

@ -8,6 +8,7 @@ import {HttpLoaderFactory} from '../../app/common-app.module';
import {HttpClient} from '@angular/common/http';
import {NgxsModule} from '@ngxs/store';
import {SessionState} from '../stores/session-state/session-state';
import {KeycloakService} from 'keycloak-angular';
describe('UserService', () => {
let service: UserService;
@ -26,7 +27,9 @@ describe('UserService', () => {
}
}),
],
providers: []
providers: [
KeycloakService
]
});
service = TestBed.inject(UserService);
});

View File

@ -1,9 +1,10 @@
import {Injectable} from '@angular/core';
import {HttpClient, HttpHeaders} from '@angular/common/http';
import {User} from '../models/user.model';
import {Observable} from 'rxjs';
import {from, Observable, Subscriber} from 'rxjs';
import {Store} from '@ngxs/store';
import {SessionState} from '../stores/session-state/session-state';
import {KeycloakService} from 'keycloak-angular';
import {map} from 'rxjs/operators';
@Injectable({
providedIn: 'root'
@ -11,6 +12,7 @@ import {SessionState} from '../stores/session-state/session-state';
export class UserService {
constructor(private http: HttpClient,
private keycloakService: KeycloakService,
private store: Store) {
}
@ -20,7 +22,33 @@ export class UserService {
});
}
getCurrentAuthenticatedUser(): Observable<User> {
return this.store.select(SessionState.userAccount);
private createHttpOptions(): Observable<any> {
return this.getToken().pipe(
// create HttpHeaders
map((token: string): HttpHeaders => {
return UserService.createHttpHeadersWithContentType(token);
}),
// createHttpOptions
map((httpHeaders: HttpHeaders): { headers } => {
return {headers: httpHeaders};
})
);
}
public loadUserProfile(): Observable<User> {
return from(this.keycloakService.loadUserProfile()) as Observable<User>;
}
private getToken(): Observable<string> {
return new Observable((observer: Subscriber<any>): void => {
this.keycloakService.getToken().then(token => {
console.warn(token);
observer.next(token);
observer.complete();
}).catch(error => {
observer.error(error);
observer.complete();
});
});
}
}

View File

@ -9,6 +9,10 @@ export class ResetSession {
static readonly type = '[Session] ResetSession';
}
export class FetchUser {
static readonly type = '[Session] FetchUser';
}
export class UpdateUser {
static readonly type = '[Session] UpdateUser';

View File

@ -7,6 +7,7 @@ import {HttpClient} from '@angular/common/http';
import {SESSION_STATE_NAME, SessionState, SessionStateModel} from './session-state';
import {User} from '../../models/user.model';
import {InitSession, UpdateUser} from './session-state.actions';
import {KeycloakService} from 'keycloak-angular';
const INITIAL_STORE_STATE_SESSION: SessionStateModel = {
userAccount: {
@ -40,7 +41,9 @@ describe('SessionState', () => {
}),
NgxsModule.forRoot([SessionState]),
],
providers: []
providers: [
KeycloakService
]
});
store = TestBed.inject(Store);
store.reset({

View File

@ -2,9 +2,10 @@ import {User} from '../../models/user.model';
import {Inject, Injectable, LOCALE_ID} from '@angular/core';
import {Action, Selector, State, StateContext} from '@ngxs/store';
import {TranslateService} from '@ngx-translate/core';
import {InitSession, ResetSession, UpdateIsAuthenticated, UpdateUser, UpdateUserSettings} from './session-state.actions';
import {FetchUser, InitSession, ResetSession, UpdateIsAuthenticated, UpdateUser, UpdateUserSettings} from './session-state.actions';
import deepEqual from 'deep-equal';
import moment from 'moment';
import {UserService} from '../../services/user.service';
export interface SessionStateModel {
userAccount: User;
@ -24,6 +25,7 @@ export const SESSION_STORAGE_KEY_USER = 'user';
@Injectable()
export class SessionState {
constructor(@Inject(LOCALE_ID) private readonly localeId: string,
private readonly userService: UserService,
private readonly translateService: TranslateService) {
}
@ -51,6 +53,17 @@ export class SessionState {
ctx.dispatch(new InitSession());
}
@Action(FetchUser)
fetchUser(ctx: StateContext<SessionStateModel>): void {
this.userService.loadUserProfile().subscribe({
next: (user: User): void => {
ctx.dispatch(new UpdateUser(user, true));
},
// TODO: add better error handling
error: (err) => console.error('Failed to load UserProfile', err)
});
}
@Action(UpdateUser)
updateUser(ctx: StateContext<SessionStateModel>, {user, force}: UpdateUser): void {
const state = ctx.getState();

View File

@ -61,6 +61,7 @@ dependencies {
implementation("com.fasterxml.jackson.datatype:jackson-datatype-joda:2.11.3")
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions:1.1.1")
implementation("javax.websocket:javax.websocket-api:1.1")
implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
@ -72,7 +73,7 @@ dependencies {
implementation("org.modelmapper:modelmapper:2.3.2")
api("org.springframework.boot:spring-boot-starter-test")
/*api("org.springframework.security:spring-security-jwt:1.0.10.RELEASE")*/
api("org.springframework.security:spring-security-jwt:1.1.1.RELEASE")
testImplementation("com.nhaarman.mockitokotlin2:mockito-kotlin:2.1.0")
testImplementation("io.projectreactor:reactor-test")

View File

@ -11,17 +11,41 @@
{
"name": "getProjects",
"request": {
"auth": {
"type": "oauth2",
"oauth2": [
{
"key": "tokenType",
"value": "",
"type": "string"
},
{
"key": "accessToken",
"value": "",
"type": "string"
},
{
"key": "grant_type",
"value": "authorization_code_with_pkce",
"type": "string"
},
{
"key": "addTokenTo",
"value": "header",
"type": "string"
}
]
},
"method": "GET",
"header": [],
"url": {
"raw": "http://localhost:8443/v1/projects",
"raw": "http://localhost:8443/projects",
"protocol": "http",
"host": [
"localhost"
],
"port": "8443",
"path": [
"v1",
"projects"
]
}
@ -49,6 +73,87 @@
}
},
"response": []
},
{
"name": "postKeycloakToken",
"request": {
"method": "POST",
"header": [],
"body": {
"mode": "urlencoded",
"urlencoded": [
{
"key": "client_id",
"value": "c4po_local",
"type": "text"
},
{
"key": "username",
"value": "ttt",
"type": "text"
},
{
"key": "password",
"value": "Test1234!",
"type": "text"
},
{
"key": "grant_type",
"value": "password",
"type": "text"
},
{
"key": "token",
"value": "",
"type": "text",
"disabled": true
},
{
"key": "client_secret",
"value": "secret",
"type": "text",
"disabled": true
}
]
},
"url": {
"raw": "http://localhost:8888/auth/realms/c4po_realm_local/protocol/openid-connect/token",
"protocol": "http",
"host": [
"localhost"
],
"port": "8888",
"path": [
"auth",
"realms",
"c4po_realm_local",
"protocol",
"openid-connect",
"token"
]
}
},
"response": []
},
{
"name": "getASCIIDocumentation",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "http://localhost:8443/docs/SecurityC4PO.html",
"protocol": "http",
"host": [
"localhost"
],
"port": "8443",
"path": [
"docs",
"SecurityC4PO.html"
]
}
},
"response": []
}
]
}

View File

@ -25,15 +25,15 @@ When creating requests you must not follow the examples exactly, e.g. instead of
=== Get projects
To get projects, call the GET request /v1/projects
To get projects, call the GET request /projects
==== Request example
#include::{snippets}/getProjects/http-request.adoc[]
#include::{snippets}/getProjects/com.securityc4po.api.http-request.adoc[]
==== Response example
#include::{snippets}/getProjects/http-response.adoc[]
#include::{snippets}/getProjects/com.securityc4po.api.http-response.adoc[]
==== Response structure

View File

@ -1,6 +1,6 @@
package com.securityc4po.api.v1
package com.securityc4po.api
abstract class BaseEntity<T>(
var data: T
) {
}
}

View File

@ -1,3 +1,3 @@
package com.securityc4po.api.v1
package com.securityc4po.api
typealias ResponseBody = Map<String, Any?>

View File

@ -1,4 +1,4 @@
package com.securityc4po.api.v1
package com.securityc4po.api
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication

View File

@ -1,4 +1,4 @@
package com.securityc4po.api.v1.configuration
package com.securityc4po.api.configuration
// Constants for SpotBugs warning suppressions
const val NP_NONNULL_FIELD_NOT_INITIALIZED_IN_CONSTRUCTOR = "NP_NONNULL_FIELD_NOT_INITIALIZED_IN_CONSTRUCTOR"

View File

@ -0,0 +1,47 @@
package com.securityc4po.api.configuration.security
import java.util.stream.Collectors
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.userdetails.UserDetails
class Appuser : UserDetails {
override fun getAuthorities(): Collection<GrantedAuthority> {
return listOf("user").stream().map {
it.toUpperCase()
}.map {
ROLE_PREFIX + it
}.map {
SimpleGrantedAuthority(it)
}.collect(Collectors.toList())
}
override fun getPassword(): String {
return "n/a"
}
override fun getUsername(): String {
return "n/a"
}
override fun isAccountNonExpired(): Boolean {
return true
}
override fun isAccountNonLocked(): Boolean {
return true
}
override fun isCredentialsNonExpired(): Boolean {
return true
}
override fun isEnabled(): Boolean {
return true
}
companion object {
private val ROLE_PREFIX = "ROLE_"
}
}

View File

@ -0,0 +1,53 @@
package com.securityc4po.api.configuration.security
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import org.springframework.core.convert.converter.Converter
import org.springframework.security.authentication.AbstractAuthenticationToken
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.oauth2.jwt.Jwt
import reactor.core.publisher.Mono
import java.util.stream.Collectors
/** JWT converter that takes the roles from 'groups' claim of JWT token. */
class AppuserJwtAuthConverter(
private val appuserDetailsService: UserAccountDetailsService) : Converter<Jwt, Mono<AbstractAuthenticationToken>> {
override fun convert(jwt: Jwt): Mono<AbstractAuthenticationToken> {
val authorities = extractAuthorities(jwt)
return appuserDetailsService
.findByUsername(jwt.getClaimAsString("sub"))
.map { u ->
UsernamePasswordAuthenticationToken(u, "n/a", authorities);
}
}
private fun extractAuthorities(jwt: Jwt): Collection<GrantedAuthority> {
return this.getScopes(jwt).stream().map { authority ->
ROLE_PREFIX + authority.toUpperCase()
}.map {
SimpleGrantedAuthority(it)
}.collect(Collectors.toList())
}
private fun getScopes(jwt: Jwt): Collection<String> {
val mapper = ObjectMapper()
val scopes = jwt.getClaims().get(GROUPS_CLAIM).toString()
if (scopes != null) {
val roleStringValue = mapper.readTree(scopes).get("roles").toString()
val roles = mapper.readValue<Collection<String>>(roleStringValue)
if (!roles.isEmpty()){
return roles
}
}
return emptyList()
}
companion object {
private val GROUPS_CLAIM = "realm_access"
private val ROLE_PREFIX = "ROLE_"
}
}

View File

@ -0,0 +1,17 @@
package com.securityc4po.api.configuration.security
import org.modelmapper.ModelMapper
import org.modelmapper.convention.MatchingStrategies
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class ModelmapperCfg {
@Bean
fun modelMapper(): ModelMapper {
val modelMapper = ModelMapper()
modelMapper.getConfiguration().setMatchingStrategy(MatchingStrategies.STRICT)
return modelMapper
}
}

View File

@ -0,0 +1,15 @@
package com.securityc4po.api.configuration.security
import org.springframework.security.core.userdetails.ReactiveUserDetailsService
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.stereotype.Service
import reactor.core.publisher.Mono
import reactor.kotlin.core.publisher.toMono
@Service
class UserAccountDetailsService : ReactiveUserDetailsService {
override fun findByUsername(username: String): Mono<UserDetails> {
return Appuser().toMono()
}
}

View File

@ -0,0 +1,48 @@
package com.securityc4po.api.configuration.security
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpMethod
import org.springframework.web.cors.CorsConfiguration
import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity
import org.springframework.security.config.web.server.ServerHttpSecurity
import org.springframework.security.web.server.SecurityWebFilterChain
@Configuration
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
class WebSecurityConfiguration(private val userAccountDetailsService: UserAccountDetailsService) {
@Bean
fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
http.cors().configurationSource {
CorsConfiguration().apply {
this.applyPermitDefaultValues()
this.addAllowedMethod(HttpMethod.DELETE)
this.addAllowedMethod(HttpMethod.PATCH)
this.addAllowedMethod(HttpMethod.POST)
this.addAllowedMethod(HttpMethod.GET)
this.addAllowedMethod(HttpMethod.PUT)
}
}
.and()
.csrf()
.disable()
.authorizeExchange()
.pathMatchers(HttpMethod.GET, "/v1/projects/**").authenticated()
.pathMatchers("/actuator/**").permitAll()
.pathMatchers("/docs/SecurityC4PO.html").permitAll()
.anyExchange().authenticated()
.and()
.oauth2ResourceServer()
.jwt()
.jwtAuthenticationConverter(appuserJwtAuthenticationConverter())
return http.build()
}
@Bean
fun appuserJwtAuthenticationConverter(): AppuserJwtAuthConverter {
return AppuserJwtAuthConverter(userAccountDetailsService)
}
}

View File

@ -1,4 +1,4 @@
package com.securityc4po.api.v1.extensions
package com.securityc4po.api.extensions
import org.slf4j.LoggerFactory

View File

@ -0,0 +1,23 @@
package com.securityc4po.api.http
import org.springframework.stereotype.Component
import org.springframework.web.server.ServerWebExchange
import org.springframework.web.server.WebFilter
import org.springframework.web.server.WebFilterChain
import reactor.core.publisher.Mono
@Component
class AddResponseHeaderFilter: WebFilter {
override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
val httpHeaders: HashMap<String, String> = HashMap()
httpHeaders.put("Application-Name", ApplicationHeaders.APPLICATION_NAME)
httpHeaders.put("X-Version", ApplicationHeaders.XVERSION.toString())
return chain.filter(
exchange.apply {
response.headers.setAll(httpHeaders)
}
)
}
}

View File

@ -0,0 +1,9 @@
package com.securityc4po.api.http
import org.springframework.http.HttpHeaders
object ApplicationHeaders {
const val AUTHORIZATION = HttpHeaders.AUTHORIZATION
const val APPLICATION_NAME = "SecurityC4PO"
const val XVERSION = 1
}

View File

@ -0,0 +1,24 @@
package com.securityc4po.api.http
import com.securityc4po.api.extensions.getLoggerFor
import org.springframework.context.annotation.Bean
import org.springframework.stereotype.Component
import org.springframework.web.server.WebFilter
@Component
class RequestLogIntercepter {
private val logger = getLoggerFor<RequestLogIntercepter>()
@Bean
fun loggingFilter(): WebFilter =
WebFilter { exchange, chain ->
val request = exchange.request
if (request.headers.getFirst(ApplicationHeaders.AUTHORIZATION) == null) {
logger.warn("No Authorization header present for request: ${request.id}")
}
logger.info("Request recognized: [id: ${request.id}, method=${request.method}, path=${request.path.pathWithinApplication()}, params=[${request.queryParams}] }")
val result = chain.filter(exchange)
return@WebFilter result
}
}

View File

@ -1,7 +1,7 @@
package com.securityc4po.api.v1.project
package com.securityc4po.api.project
import com.fasterxml.jackson.annotation.JsonFormat
import com.securityc4po.api.v1.ResponseBody
import com.securityc4po.api.ResponseBody
import java.time.Instant
import java.util.UUID

View File

@ -1,14 +1,15 @@
package com.securityc4po.api.v1.project
package com.securityc4po.api.project
import com.securityc4po.api.v1.ResponseBody
import com.securityc4po.api.v1.configuration.BC_BAD_CAST_TO_ABSTRACT_COLLECTION
import com.securityc4po.api.v1.extensions.getLoggerFor
import com.securityc4po.api.configuration.BC_BAD_CAST_TO_ABSTRACT_COLLECTION
import com.securityc4po.api.extensions.getLoggerFor
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings
import com.securityc4po.api.ResponseBody
import org.springframework.http.HttpHeaders
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/v1/projects")
@RequestMapping("/projects")
@CrossOrigin(
origins = [],
allowCredentials = "false",
@ -25,4 +26,4 @@ class ProjectController(private val projectService: ProjectService) {
return projectService.getProjects()
}
}
}

View File

@ -1,6 +1,6 @@
package com.securityc4po.api.v1.project
package com.securityc4po.api.project
import com.securityc4po.api.v1.BaseEntity
import com.securityc4po.api.BaseEntity
/*
* @Document(collection = "project")
@ -8,4 +8,4 @@ import com.securityc4po.api.v1.BaseEntity
*/
open class ProjectEntity(
data: Project
) : BaseEntity<Project>(data)
) : BaseEntity<Project>(data)

View File

@ -1,6 +1,6 @@
package com.securityc4po.api.v1.project
package com.securityc4po.api.project
import com.securityc4po.api.v1.extensions.getLoggerFor
import com.securityc4po.api.extensions.getLoggerFor
import org.junit.BeforeClass
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
@ -25,7 +25,6 @@ class ProjectService() {
mapper.registerModule(JavaTimeModule())
}
/**
* Get all [Project]s
*
@ -37,4 +36,4 @@ class ProjectService() {
/* After database integration the return should be Flux of ProjectEntity */
return jsonProjectList;
}
}
}

View File

@ -1,4 +1,4 @@
package com.securityc4po.api.v1.user
package com.securityc4po.api.user
data class User(
val id: String,
@ -12,4 +12,4 @@ data class User(
val email: String? = null,
val interfaceLang: String? = null
)
)

View File

@ -1,6 +1,6 @@
package com.securityc4po.api.v1.user
package com.securityc4po.api.user
import com.securityc4po.api.v1.BaseEntity
import com.securityc4po.api.BaseEntity
/*
* @Document(collection = "user")
@ -8,4 +8,4 @@ import com.securityc4po.api.v1.BaseEntity
*/
open class UserEntity(
data: User
) : BaseEntity<User>(data)
) : BaseEntity<User>(data)

View File

@ -15,4 +15,9 @@ management.endpoints.web.exposure.include=info, health, metrics
# spring.data.mongodb.host=localhost
# spring.data.mongodb.port=27017
# spring.main.allow-bean-definition-overriding=true
# spring.data.mongodb.auto-index-creation=true
# spring.data.mongodb.auto-index-creation=true
## IdentityProvider (Keycloak for tests) ##
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:8888/auth/realms/c4po_realm_local
keycloakhost=localhost
keycloak.client.url=http://localhost:8888/

View File

@ -1,13 +0,0 @@
package com.securityc4po.api.v1
import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest
@SpringBootTest
class SecurityC4POApplicationTests {
@Test
fun contextLoads() {
}
}

View File

@ -1,4 +1,4 @@
package com.securityc4po.api.v1
package com.securityc4po.api
import org.junit.jupiter.api.TestInstance
import org.springframework.test.context.TestPropertySource

View File

@ -1,8 +1,8 @@
package com.securityc4po.api.v1
package com.securityc4po.api
import com.securityc4po.api.v1.configuration.MESSAGE_NOT_INITIALIZED_REDUNDANT_NULLCHECK
import com.securityc4po.api.v1.configuration.NP_NONNULL_FIELD_NOT_INITIALIZED_IN_CONSTRUCTOR
import com.securityc4po.api.v1.configuration.RCN_REDUNDANT_NULLCHECK_OF_NONNULL_VALUE
import com.securityc4po.api.configuration.MESSAGE_NOT_INITIALIZED_REDUNDANT_NULLCHECK
import com.securityc4po.api.configuration.NP_NONNULL_FIELD_NOT_INITIALIZED_IN_CONSTRUCTOR
import com.securityc4po.api.configuration.RCN_REDUNDANT_NULLCHECK_OF_NONNULL_VALUE
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.extension.ExtendWith
@ -28,7 +28,7 @@ abstract class BaseDocumentationIntTest : BaseContainerizedTest() {
@BeforeEach
fun setupDocs(restDocumentation: RestDocumentationContextProvider) {
webTestClient = WebTestClient.bindToServer()
.baseUrl("http://localhost:$port")
.baseUrl("com.securityc4po.api.http://localhost:$port")
.filter(documentationConfiguration(restDocumentation))
.responseTimeout(Duration.ofMillis(10000))
.build()

View File

@ -1,4 +1,4 @@
package com.securityc4po.api.v1
package com.securityc4po.api
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.boot.test.context.SpringBootTest

View File

@ -1,8 +1,8 @@
package com.securityc4po.api.v1.project
package com.securityc4po.api.project
import com.github.tomakehurst.wiremock.common.Json
import com.securityc4po.api.v1.BaseDocumentationIntTest
import com.securityc4po.api.v1.configuration.SIC_INNER_SHOULD_BE_STATIC
import com.securityc4po.api.BaseDocumentationIntTest
import com.securityc4po.api.configuration.SIC_INNER_SHOULD_BE_STATIC
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested

View File

@ -1,9 +1,9 @@
package com.securityc4po.api.v1.project
package com.securityc4po.api.project
import com.github.tomakehurst.wiremock.common.Json
import com.securityc4po.api.v1.BaseIntTest
import com.securityc4po.api.v1.configuration.SIC_INNER_SHOULD_BE_STATIC
import com.securityc4po.api.v1.configuration.URF_UNREAD_FIELD
import com.securityc4po.api.BaseIntTest
import com.securityc4po.api.configuration.SIC_INNER_SHOULD_BE_STATIC
import com.securityc4po.api.configuration.URF_UNREAD_FIELD
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested

View File

@ -1,7 +1,7 @@
package com.securityc4po.api.v1.project
package com.securityc4po.api.project
import com.nhaarman.mockitokotlin2.mock
import com.securityc4po.api.v1.configuration.SIC_INNER_SHOULD_BE_STATIC
import com.securityc4po.api.configuration.SIC_INNER_SHOULD_BE_STATIC
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test

View File

@ -0,0 +1,16 @@
version: '3.1'
services:
c4po-api:
build: '../../security-c4po-api'
image: security-c4po-api:latest
container_name: security-c4po-api
deploy:
resources:
limits:
memory: "1G"
ports:
- '8443:8443'
networks:
c4po:

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,9 @@
# cfg for local keycloak
DB_VENDOR=postgres
DB_ADDR=keycloak-postgres
DB_PORT=5432
DB_USER=c4po_kc_local
DB_PASSWORD=Test1234!
KEYCLOAK_USER=admin
KEYCLOAK_PASSWORD=admin
KEYCLOAK_IMPORT=/tmp/c4po_realm_export.json

View File

@ -0,0 +1,4 @@
# database.env
POSTGRES_USER=c4po_kc_local
POSTGRES_PASSWORD=Test1234!
POSTGRES_DB=keycloak

View File

@ -0,0 +1,16 @@
version: '3.1'
services:
c4po-angular:
build: '../../security-c4po-angular'
image: security-c4po-angular:latest
container_name: security-c4po-angular
deploy:
resources:
limits:
memory: "1G"
ports:
- '4200:4200'
networks:
c4po:

View File

@ -0,0 +1,24 @@
version: '3.1'
services:
c4po-keycloak:
container_name: security-c4po-keycloak
depends_on:
- keycloak-postgres
image: jboss/keycloak:11.0.3
volumes:
- ../cfg/c4po_realm_export.json:/tmp/c4po_realm_export.json
ports:
- 8888:8080
- 9990:9990
env_file:
- ../cfg/keycloak.env
keycloak-postgres:
container_name: security-c4po-postgres-keycloak
image: postgres:latest
env_file:
- ../cfg/keycloakdb.env
ports:
- 5433:5432
volumes:
- ../volumes/keycloak/data:/var/lib/postgresql/data