Skip to content

Commit

Permalink
Admin UI apikey authentication (#194)
Browse files Browse the repository at this point in the history
* feat: add api key authentication to API

* chore: remove unused classes

* refactor: move ApiKeyAuthenticationSchemeHandler and extract constant for api key header name

* feat: add ApiKeyValidator

* refactor: move ApiKeyAuthenticationSchemeHandler to "Authentication" folder

* feat: add ValidateApiKey endpoint

* feat: added login component

* feat: added auth service, guards and interceptor to manage authentication

* fix: generated client id should be assigned instead of empty one

* feat: add logout behavior and manage navigation based on authentication status

* chore: pin OpenIddict to 4.3.0

* feat: validate api key on login attempt

* feat: dismiss message on route change

---------

Co-authored-by: Timo Notheisen <[email protected]>
  • Loading branch information
daniel-almeida-konkconsulting and tnotheis authored Jul 7, 2023
1 parent 44bd5ad commit 3d1f663
Show file tree
Hide file tree
Showing 46 changed files with 580 additions and 176 deletions.
2 changes: 1 addition & 1 deletion AdminUi/src/AdminUi/AdminUi.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SpaProxy" Version="7.0.8" />
<PackageReference Include="Autofac.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="4.3.0" />
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="[4.3.0]" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" Version="2.21.0" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;

namespace AdminUi.AspNet;

public class ApiKeyAuthenticationSchemeOptions : AuthenticationSchemeOptions
{
public string ApiKey { get; set; } = string.Empty;
}

public class ApiKeyAuthenticationSchemeHandler : AuthenticationHandler<ApiKeyAuthenticationSchemeOptions>
{
private readonly ApiKeyValidator _apiKeyValidator;
private const string API_KEY_HEADER_NAME = "X-API-KEY";

public ApiKeyAuthenticationSchemeHandler(IOptionsMonitor<ApiKeyAuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock, ApiKeyValidator apiKeyValidator) : base(options, logger, encoder, clock)
{
_apiKeyValidator = apiKeyValidator;
}

protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var apiKey = Context.Request.Headers[API_KEY_HEADER_NAME];

if (!_apiKeyValidator.IsApiKeyValid(apiKey))
{
return Task.FromResult(AuthenticateResult.Fail($"Invalid {API_KEY_HEADER_NAME}"));
}
var claims = new[] { new Claim(ClaimTypes.Name, "VALID USER") };
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
22 changes: 22 additions & 0 deletions AdminUi/src/AdminUi/Authentication/ApiKeyValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using Microsoft.Extensions.Options;

namespace AdminUi.AspNet;

public class ApiKeyValidator
{
private readonly ApiKeyAuthenticationSchemeOptions _options;

public ApiKeyValidator(IOptionsMonitor<ApiKeyAuthenticationSchemeOptions> options)
{
_options = options.Get("ApiKey");
}

public bool IsApiKeyValid(string? apiKey)
{
var apiKeyIsConfigured = !string.IsNullOrEmpty(_options.ApiKey);

if (!apiKeyIsConfigured) return true;

return apiKey == _options.ApiKey;
}
}
17 changes: 10 additions & 7 deletions AdminUi/src/AdminUi/ClientApp/src/app/app-routing.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,19 @@ import { TierListComponent } from './components/quotas/tier/tier-list/tier-list.
import { TierEditComponent } from './components/quotas/tier/tier-edit/tier-edit.component';
import { ClientListComponent } from './components/client/client-list/client-list.component';
import { ClientEditComponent } from './components/client/client-edit/client-edit.component';
import { AuthGuard } from './shared/auth-guard/auth-guard.guard';
import { LoginComponent } from './components/shared/login/login.component';

const routes: Routes = [
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
{ path: 'dashboard', component: DashboardComponent },
{ path: 'identities', component: IdentityListComponent },
{ path: 'tiers', component: TierListComponent },
{ path: 'tiers/create', component: TierEditComponent },
{ path: 'tiers/:id', component: TierEditComponent },
{ path: 'clients', component: ClientListComponent },
{ path: 'clients/create', component: ClientEditComponent },
{ path: 'login', component: LoginComponent },
{ path: 'dashboard', component: DashboardComponent, canActivate: [AuthGuard] },
{ path: 'identities', component: IdentityListComponent, canActivate: [AuthGuard] },
{ path: 'tiers', component: TierListComponent, canActivate: [AuthGuard] },
{ path: 'tiers/create', component: TierEditComponent, canActivate: [AuthGuard] },
{ path: 'tiers/:id', component: TierEditComponent, canActivate: [AuthGuard] },
{ path: 'clients', component: ClientListComponent, canActivate: [AuthGuard] },
{ path: 'clients/create', component: ClientEditComponent, canActivate: [AuthGuard] },
{ path: '**', component: PageNotFoundComponent },
];

Expand Down
6 changes: 6 additions & 0 deletions AdminUi/src/AdminUi/ClientApp/src/app/app.component.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,9 @@
.layout-content {
padding: 1rem;
}

.login-layout {
display: flex;
justify-content: center;
background: #673ab7;
}
19 changes: 9 additions & 10 deletions AdminUi/src/AdminUi/ClientApp/src/app/app.component.html
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
<div class="layout-wrapper">
<app-topbar></app-topbar>
<mat-sidenav-container (backdropClick)="closeSidebar()" autosize>
<mat-sidenav
#sidebar
[opened]="isSidebarOpen()"
[mode]="isMobile() ? 'over' : 'side'"
>
<div class="layout-wrapper" [ngClass]="{'login-layout': (isLoggedIn$ | async) === false}">
<app-topbar *ngIf="isLoggedIn$ | async"></app-topbar>
<mat-sidenav-container *ngIf="isLoggedIn$ | async" (backdropClick)="closeSidebar()" autosize>
<mat-sidenav #sidebar [opened]="isSidebarOpen()" [mode]="isMobile() ? 'over' : 'side'">
<app-sidebar></app-sidebar>
</mat-sidenav>
<mat-sidenav-content class="layout-main-container">
<div class="layout-content">
<router-outlet></router-outlet>
<router-outlet (activate)="changeOfRoute()"></router-outlet>
</div>
</mat-sidenav-content>
</mat-sidenav-container>
</div>
<div *ngIf="(isLoggedIn$ | async) === false">
<router-outlet></router-outlet>
</div>
</div>
20 changes: 17 additions & 3 deletions AdminUi/src/AdminUi/ClientApp/src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
import { Component } from '@angular/core';
import { Component, OnInit } from '@angular/core';
import { SidebarService } from './services/sidebar-service/sidebar.service';
import { Observable } from 'rxjs';
import { AuthService } from './services/auth-service/auth.service';
import { MatSnackBar } from '@angular/material/snack-bar';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent {
export class AppComponent implements OnInit {
title = 'AdminUI';
isLoggedIn$?: Observable<boolean>;

constructor(private sidebarService: SidebarService) {}
constructor(private sidebarService: SidebarService,
private authService: AuthService,
private snackBar: MatSnackBar) { }

ngOnInit() {
this.isLoggedIn$ = this.authService.isLoggedIn;
}

closeSidebar() {
this.sidebarService.close();
Expand All @@ -22,4 +32,8 @@ export class AppComponent {
isMobile(): boolean {
return this.sidebarService.isMobile();
}

changeOfRoute(): void {
this.snackBar.dismiss();
}
}
14 changes: 11 additions & 3 deletions AdminUi/src/AdminUi/ClientApp/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { HttpClientModule } from '@angular/common/http';
import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http';

import { MatCardModule } from '@angular/material/card';
import { MatToolbarModule } from '@angular/material/toolbar';
Expand All @@ -20,6 +20,7 @@ import { MatInputModule } from '@angular/material/input';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatTooltipModule } from '@angular/material/tooltip';
import { LayoutModule } from '@angular/cdk/layout';
import { ClipboardModule } from '@angular/cdk/clipboard';
import { MatDialogModule } from '@angular/material/dialog';
import { MatSelectModule } from '@angular/material/select';
import { MatDatepickerModule } from '@angular/material/datepicker';
Expand All @@ -41,6 +42,8 @@ import { ClientListComponent } from './components/client/client-list/client-list
import { ClientEditComponent } from './components/client/client-edit/client-edit.component';
import { AssignQuotasDialogComponent } from './components/quotas/assign-quotas-dialog/assign-quotas-dialog.component';
import { ConfirmationDialogComponent } from './components/shared/confirmation-dialog/confirmation-dialog.component';
import { LoginComponent } from './components/shared/login/login.component';
import { ApiKeyInterceptor } from './shared/interceptors/api-key.interceptor';

@NgModule({
declarations: [
Expand All @@ -56,13 +59,15 @@ import { ConfirmationDialogComponent } from './components/shared/confirmation-di
ClientEditComponent,
AssignQuotasDialogComponent,
ConfirmationDialogComponent,
LoginComponent,
],
imports: [
FormsModule,
ReactiveFormsModule,
BrowserModule,
AppRoutingModule,
BrowserAnimationsModule,
ClipboardModule,
HttpClientModule,
MatCardModule,
MatToolbarModule,
Expand All @@ -87,7 +92,10 @@ import { ConfirmationDialogComponent } from './components/shared/confirmation-di
MatNativeDateModule,
MatChipsModule,
],
providers: [SidebarService],
providers: [
SidebarService,
{ provide: HTTP_INTERCEPTORS, useClass: ApiKeyInterceptor, multi: true }
],
bootstrap: [AppComponent],
})
export class AppModule {}
export class AppModule { }
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,20 @@ <h2 class="header-title ">{{ headerCreate }}</h2>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>Display Name</mat-label>
<input matInput required [(ngModel)]="client.displayName" [disabled]="disabled" />
<mat-error>You must enter a value</mat-error>
<input matInput [(ngModel)]="client.displayName" [disabled]="disabled" />
<mat-hint>Client ID will be used as a Display Name if no value is provided.</mat-hint>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>Client Secret</mat-label>
<input [type]="showPassword ? 'text' : 'password'" matInput [(ngModel)]="client.clientSecret"
[disabled]="disabled" />
<mat-icon matSuffix (click)="togglePasswordVisibility()"
style="cursor: pointer;">visibility</mat-icon>
<button matSuffix mat-icon-button (click)="togglePasswordVisibility()" style="cursor: pointer;">
<mat-icon>visibility</mat-icon>
</button>
<button matSuffix mat-icon-button [disabled]="client.clientSecret! === ''"
[cdkCopyToClipboard]="client.clientSecret!" style="cursor: pointer;">
<mat-icon>file_copy</mat-icon>
</button>
<mat-hint>A Client Secret will be generated if this field is left blank.</mat-hint>
</mat-form-field>
<div *ngIf="displayClientSecretWarning" class="client-secret-warning-container">
Expand All @@ -36,8 +41,7 @@ <h2 class="header-title ">{{ headerCreate }}</h2>
</mat-card-content>
</mat-card>
<div class="action-buttons">
<button mat-raised-button color="primary" (click)="createClient()"
[disabled]="disabled || !validateClient()">Save</button>
<button mat-raised-button color="primary" (click)="createClient()" [disabled]="disabled">Save</button>
<button mat-raised-button routerLink="/clients">Cancel</button>
</div>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,6 @@ export class ClientEditComponent {
});
}

validateClient(): boolean {
if (this.client && this.client.displayName && this.client.displayName.length > 0) {
return true;
}
return false;
}

togglePasswordVisibility(): void {
this.showPassword = !this.showPassword;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ <h2 class="header-title ">{{ header }}</h2>
</mat-progress-spinner>
</div>
<div class="action-buttons">
<button mat-mini-fab color="warn" (click)="openConfirmationDialog()" [disabled]="selection.selected.length === 0" style="margin-right: 10px;">
<mat-icon >delete</mat-icon>
<button mat-mini-fab color="warn" (click)="openConfirmationDialog()"
[disabled]="selection.selected.length === 0" style="margin-right: 10px;">
<mat-icon>delete</mat-icon>
</button>
<button mat-mini-fab color="primary" (click)="addClient()">
<mat-icon>add</mat-icon>
Expand All @@ -20,18 +21,13 @@ <h2 class="header-title ">{{ header }}</h2>
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef>
<mat-checkbox (change)="$event ? toggleAllRows() : null"
[checked]="selection.hasValue() && isAllSelected()"
color="primary"
[indeterminate]="selection.hasValue() && !isAllSelected()"
[aria-label]="checkboxLabel()">
[checked]="selection.hasValue() && isAllSelected()" color="primary"
[indeterminate]="selection.hasValue() && !isAllSelected()" [aria-label]="checkboxLabel()">
</mat-checkbox>
</th>
<td mat-cell *matCellDef="let row; let i = index;">
<mat-checkbox (click)="$event.stopPropagation()"
(change)="$event ? selection.toggle(row) : null"
[checked]="selection.isSelected(row)"
color="primary"
[aria-label]="checkboxLabel(i, row)">
<mat-checkbox (click)="$event.stopPropagation()" (change)="$event ? selection.toggle(row) : null"
[checked]="selection.isSelected(row)" color="primary" [aria-label]="checkboxLabel(i, row)">
</mat-checkbox>
</td>
</ng-container>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@
<h4 class="panel-title">{{ dashboardPanel.header }}</h4>
<p class="panel-description">{{ dashboardPanel.description }}
</p>
<button mat-raised-button routerLink="/clients" color="primary"
class="more-button">More</button>
<button mat-raised-button routerLink="/clients" color="primary" class="more-button">More</button>
</div>
</mat-grid-tile>
</mat-grid-list>
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
<h2>{{ header }}</h2>
<div class="error-container">
<div class="error-code">
{{ error.code }}
Expand All @@ -14,4 +13,4 @@ <h2>{{ header }}</h2>
Go to Dashboard
</button>
</div>
</div>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,9 @@ import { Component } from '@angular/core';
styleUrls: ['./page-not-found.component.css'],
})
export class PageNotFoundComponent {
header: string;
error: ErrorInfo;

constructor() {
this.header = '';
this.error = {
code: 0,
title: '',
Expand All @@ -19,7 +17,6 @@ export class PageNotFoundComponent {
}

ngOnInit() {
this.header = 'Oops!';
this.error = {
code: 404,
title: 'Page not found',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,7 @@ <h2 mat-dialog-title>{{ header }}</h2>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-flat-button mat-dialog-close>Cancel</button>
<button
mat-flat-button
color="primary"
(click)="assignQuota()"
[disabled]="!isValid()"
>
<button mat-flat-button color="primary" (click)="assignQuota()" [disabled]="!isValid()">
Confirm
</button>
</mat-dialog-actions>
</mat-dialog-actions>
Loading

0 comments on commit 3d1f663

Please sign in to comment.