diff --git a/AdminUi/src/AdminUi/AdminUi.csproj b/AdminUi/src/AdminUi/AdminUi.csproj index b891988275..fd53e292f4 100644 --- a/AdminUi/src/AdminUi/AdminUi.csproj +++ b/AdminUi/src/AdminUi/AdminUi.csproj @@ -15,7 +15,7 @@ - + diff --git a/AdminUi/src/AdminUi/Authentication/ApiKeyAuthenticationSchemeHandler.cs b/AdminUi/src/AdminUi/Authentication/ApiKeyAuthenticationSchemeHandler.cs new file mode 100644 index 0000000000..71842dac42 --- /dev/null +++ b/AdminUi/src/AdminUi/Authentication/ApiKeyAuthenticationSchemeHandler.cs @@ -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 +{ + private readonly ApiKeyValidator _apiKeyValidator; + private const string API_KEY_HEADER_NAME = "X-API-KEY"; + + public ApiKeyAuthenticationSchemeHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock, ApiKeyValidator apiKeyValidator) : base(options, logger, encoder, clock) + { + _apiKeyValidator = apiKeyValidator; + } + + protected override Task 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)); + } +} \ No newline at end of file diff --git a/AdminUi/src/AdminUi/Authentication/ApiKeyValidator.cs b/AdminUi/src/AdminUi/Authentication/ApiKeyValidator.cs new file mode 100644 index 0000000000..1d45a748fe --- /dev/null +++ b/AdminUi/src/AdminUi/Authentication/ApiKeyValidator.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.Options; + +namespace AdminUi.AspNet; + +public class ApiKeyValidator +{ + private readonly ApiKeyAuthenticationSchemeOptions _options; + + public ApiKeyValidator(IOptionsMonitor options) + { + _options = options.Get("ApiKey"); + } + + public bool IsApiKeyValid(string? apiKey) + { + var apiKeyIsConfigured = !string.IsNullOrEmpty(_options.ApiKey); + + if (!apiKeyIsConfigured) return true; + + return apiKey == _options.ApiKey; + } +} diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/app-routing.module.ts b/AdminUi/src/AdminUi/ClientApp/src/app/app-routing.module.ts index d287bf6584..61437f1f95 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/app-routing.module.ts +++ b/AdminUi/src/AdminUi/ClientApp/src/app/app-routing.module.ts @@ -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 }, ]; diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/app.component.css b/AdminUi/src/AdminUi/ClientApp/src/app/app.component.css index 9a13eab844..c0a413c941 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/app.component.css +++ b/AdminUi/src/AdminUi/ClientApp/src/app/app.component.css @@ -9,3 +9,9 @@ .layout-content { padding: 1rem; } + +.login-layout { + display: flex; + justify-content: center; + background: #673ab7; +} \ No newline at end of file diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/app.component.html b/AdminUi/src/AdminUi/ClientApp/src/app/app.component.html index 0ef7d81f72..9323162ad1 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/app.component.html +++ b/AdminUi/src/AdminUi/ClientApp/src/app/app.component.html @@ -1,17 +1,16 @@ -
- - - +
+ + +
- +
-
+
+ +
+
\ No newline at end of file diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/app.component.ts b/AdminUi/src/AdminUi/ClientApp/src/app/app.component.ts index a4e107dc58..6c779b8302 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/app.component.ts +++ b/AdminUi/src/AdminUi/ClientApp/src/app/app.component.ts @@ -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; - constructor(private sidebarService: SidebarService) {} + constructor(private sidebarService: SidebarService, + private authService: AuthService, + private snackBar: MatSnackBar) { } + + ngOnInit() { + this.isLoggedIn$ = this.authService.isLoggedIn; + } closeSidebar() { this.sidebarService.close(); @@ -22,4 +32,8 @@ export class AppComponent { isMobile(): boolean { return this.sidebarService.isMobile(); } + + changeOfRoute(): void { + this.snackBar.dismiss(); + } } diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/app.module.ts b/AdminUi/src/AdminUi/ClientApp/src/app/app.module.ts index 2c22306646..d0f30098b3 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/app.module.ts +++ b/AdminUi/src/AdminUi/ClientApp/src/app/app.module.ts @@ -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'; @@ -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'; @@ -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: [ @@ -56,6 +59,7 @@ import { ConfirmationDialogComponent } from './components/shared/confirmation-di ClientEditComponent, AssignQuotasDialogComponent, ConfirmationDialogComponent, + LoginComponent, ], imports: [ FormsModule, @@ -63,6 +67,7 @@ import { ConfirmationDialogComponent } from './components/shared/confirmation-di BrowserModule, AppRoutingModule, BrowserAnimationsModule, + ClipboardModule, HttpClientModule, MatCardModule, MatToolbarModule, @@ -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 { } diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/components/client/client-edit/client-edit.component.html b/AdminUi/src/AdminUi/ClientApp/src/app/components/client/client-edit/client-edit.component.html index a34552e44e..e014f2b12a 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/components/client/client-edit/client-edit.component.html +++ b/AdminUi/src/AdminUi/ClientApp/src/app/components/client/client-edit/client-edit.component.html @@ -17,15 +17,20 @@

{{ headerCreate }}

Display Name - - You must enter a value + + Client ID will be used as a Display Name if no value is provided. Client Secret - visibility + + A Client Secret will be generated if this field is left blank.
@@ -36,8 +41,7 @@

{{ headerCreate }}

- +
\ No newline at end of file diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/components/client/client-edit/client-edit.component.ts b/AdminUi/src/AdminUi/ClientApp/src/app/components/client/client-edit/client-edit.component.ts index c281741775..901a8b3d0c 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/components/client/client-edit/client-edit.component.ts +++ b/AdminUi/src/AdminUi/ClientApp/src/app/components/client/client-edit/client-edit.component.ts @@ -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; } diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/components/client/client-list/client-list.component.html b/AdminUi/src/AdminUi/ClientApp/src/app/components/client/client-list/client-list.component.html index 0a460dab68..e881adc1fe 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/components/client/client-list/client-list.component.html +++ b/AdminUi/src/AdminUi/ClientApp/src/app/components/client/client-list/client-list.component.html @@ -9,8 +9,9 @@

{{ header }}

- +
\ No newline at end of file diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/components/error/page-not-found/page-not-found.component.html b/AdminUi/src/AdminUi/ClientApp/src/app/components/error/page-not-found/page-not-found.component.html index f3443b1815..93c4f4569a 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/components/error/page-not-found/page-not-found.component.html +++ b/AdminUi/src/AdminUi/ClientApp/src/app/components/error/page-not-found/page-not-found.component.html @@ -1,4 +1,3 @@ -

{{ header }}

{{ error.code }} @@ -14,4 +13,4 @@

{{ header }}

Go to Dashboard
-
+ \ No newline at end of file diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/components/error/page-not-found/page-not-found.component.ts b/AdminUi/src/AdminUi/ClientApp/src/app/components/error/page-not-found/page-not-found.component.ts index a539393146..487da594d7 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/components/error/page-not-found/page-not-found.component.ts +++ b/AdminUi/src/AdminUi/ClientApp/src/app/components/error/page-not-found/page-not-found.component.ts @@ -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: '', @@ -19,7 +17,6 @@ export class PageNotFoundComponent { } ngOnInit() { - this.header = 'Oops!'; this.error = { code: 404, title: 'Page not found', diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/assign-quotas-dialog/assign-quotas-dialog.component.html b/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/assign-quotas-dialog/assign-quotas-dialog.component.html index 5dc9f2859d..b34483657c 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/assign-quotas-dialog/assign-quotas-dialog.component.html +++ b/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/assign-quotas-dialog/assign-quotas-dialog.component.html @@ -34,12 +34,7 @@

{{ header }}

- - + \ No newline at end of file diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/tier/tier-edit/tier-edit.component.html b/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/tier/tier-edit/tier-edit.component.html index ec894890ae..7d337c6909 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/tier/tier-edit/tier-edit.component.html +++ b/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/tier/tier-edit/tier-edit.component.html @@ -21,11 +21,8 @@

{{ editMode ? headerEdit : headerCreate }}

- @@ -34,13 +31,13 @@

{{ editMode ? headerEdit : headerCreate }}

-
+ \ No newline at end of file diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/tier/tier-list/tier-list.component.html b/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/tier/tier-list/tier-list.component.html index 94a2aab380..b2bf1acb1e 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/tier/tier-list/tier-list.component.html +++ b/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/tier/tier-list/tier-list.component.html @@ -16,28 +16,16 @@

{{ header }}

- - - - diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/components/shared/login/login.component.css b/AdminUi/src/AdminUi/ClientApp/src/app/components/shared/login/login.component.css new file mode 100644 index 0000000000..ed83ccb34e --- /dev/null +++ b/AdminUi/src/AdminUi/ClientApp/src/app/components/shared/login/login.component.css @@ -0,0 +1,75 @@ +.form-details { + display: flex; + flex-direction: column; + align-items: flex-start; + min-width: 150px; + max-width: 500px; + width: 100%; +} + +.form-field { + width: 100%; + margin-bottom: 20px; + display: flex; + align-items: center; + justify-content: center; +} + +.card-layout { + display: flex; + justify-content: center; + align-items: center; + padding: 30px !important; + min-width: 340px; +} + +.logo-container { + width: 100%; + display: flex; + justify-content: center; + margin-bottom: -20px; + z-index: 1; + position: relative; + margin-top: 50px; +} + +.logo-layout { + display: flex; + justify-content: center; + align-items: center; + padding: 20px !important; + background-color: #fff; + border-radius: 50%; + width: 40px; + height: 40px; + box-shadow: 0px 2px 1px -1px rgba(0, 0, 0, 0.2), 0px 1px 1px 0px rgba(0, 0, 0, 0.14), 0px 1px 3px 0px rgba(0, 0, 0, 0.12); +} + +.description { + font-size: 1.5em; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; +} + +.login-button { + width: 50%; +} + +.login-button-disabled { + opacity: 0.4; +} + +.loading { + display: flex; + justify-content: center; + align-items: center; + z-index: 999; + position: absolute; + margin-top: -60px; + width: 100%; + height: 100%; +} + +.disabled-container { + pointer-events: none; + opacity: 0.7; +} \ No newline at end of file diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/components/shared/login/login.component.html b/AdminUi/src/AdminUi/ClientApp/src/app/components/shared/login/login.component.html new file mode 100644 index 0000000000..a44795c099 --- /dev/null +++ b/AdminUi/src/AdminUi/ClientApp/src/app/components/shared/login/login.component.html @@ -0,0 +1,26 @@ +
+
+ +
+
+ + +
+ + +
+
+
+

Sign In to Enmeshed

+
+ + Api Key + + +
+ +
+
+
+
\ No newline at end of file diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/components/shared/login/login.component.spec.ts b/AdminUi/src/AdminUi/ClientApp/src/app/components/shared/login/login.component.spec.ts new file mode 100644 index 0000000000..10eca249d5 --- /dev/null +++ b/AdminUi/src/AdminUi/ClientApp/src/app/components/shared/login/login.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LoginComponent } from './login.component'; + +describe('LoginComponent', () => { + let component: LoginComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ LoginComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(LoginComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/components/shared/login/login.component.ts b/AdminUi/src/AdminUi/ClientApp/src/app/components/shared/login/login.component.ts new file mode 100644 index 0000000000..20879abf1b --- /dev/null +++ b/AdminUi/src/AdminUi/ClientApp/src/app/components/shared/login/login.component.ts @@ -0,0 +1,55 @@ +import { Component, OnInit } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; +import { AuthService, ValidateApiKeyRequest, ValidateApiKeyResponse } from 'src/app/services/auth-service/auth.service'; +import { HttpResponseEnvelope } from 'src/app/utils/http-response-envelope'; + +@Component({ + selector: 'app-login', + templateUrl: './login.component.html', + styleUrls: ['./login.component.css'] +}) +export class LoginComponent implements OnInit { + apiKey: string; + loading: boolean; + + constructor(private router: Router, + private snackBar: MatSnackBar, + private authService: AuthService) { + this.apiKey = ''; + this.loading = false; + } + + ngOnInit(): void { + if (this.authService.isCurrentlyLoggedIn()) { + this.router.navigate(['/']); + } + } + + login(): void { + this.loading = true; + let apiKeyRequest: ValidateApiKeyRequest = { + apiKey: this.apiKey + }; + this.authService.validateApiKey(apiKeyRequest).subscribe({ + next: (response: ValidateApiKeyResponse) => { + if (response.isValid) { + this.authService.login(this.apiKey); + } else { + this.snackBar.open('Invalid API Key.', 'Dismiss', { + verticalPosition: 'top', + horizontalPosition: 'center' + }); + } + }, + complete: () => (this.loading = false), + error: (err: any) => { + this.loading = false; + this.snackBar.open(err, 'Dismiss', { + verticalPosition: 'top', + horizontalPosition: 'center' + }); + }, + }); + } +} \ No newline at end of file diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/components/topbar/topbar.component.css b/AdminUi/src/AdminUi/ClientApp/src/app/components/topbar/topbar.component.css index 69e1a8eb9b..312a7623ff 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/components/topbar/topbar.component.css +++ b/AdminUi/src/AdminUi/ClientApp/src/app/components/topbar/topbar.component.css @@ -7,4 +7,8 @@ .logo { width: 20px; margin-right: 10px; +} + +.spacer { + flex: 1 1 auto; } \ No newline at end of file diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/components/topbar/topbar.component.html b/AdminUi/src/AdminUi/ClientApp/src/app/components/topbar/topbar.component.html index 3143287bdd..695347f7f6 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/components/topbar/topbar.component.html +++ b/AdminUi/src/AdminUi/ClientApp/src/app/components/topbar/topbar.component.html @@ -5,5 +5,9 @@ Enmeshed + + \ No newline at end of file diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/components/topbar/topbar.component.ts b/AdminUi/src/AdminUi/ClientApp/src/app/components/topbar/topbar.component.ts index 496a060b75..1053809230 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/components/topbar/topbar.component.ts +++ b/AdminUi/src/AdminUi/ClientApp/src/app/components/topbar/topbar.component.ts @@ -1,4 +1,5 @@ import { Component } from '@angular/core'; +import { AuthService } from 'src/app/services/auth-service/auth.service'; import { SidebarService } from 'src/app/services/sidebar-service/sidebar.service'; @Component({ @@ -7,9 +8,14 @@ import { SidebarService } from 'src/app/services/sidebar-service/sidebar.service styleUrls: ['./topbar.component.css'], }) export class TopbarComponent { - constructor(private sidebarService: SidebarService) {} + constructor(private sidebarService: SidebarService, + private authService: AuthService) { } - toggleSidebar() { + toggleSidebar(): void { this.sidebarService.toggle(); } + + logout(): void { + this.authService.logout(); + } } diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/services/auth-service/auth.service.spec.ts b/AdminUi/src/AdminUi/ClientApp/src/app/services/auth-service/auth.service.spec.ts new file mode 100644 index 0000000000..f1251cacf9 --- /dev/null +++ b/AdminUi/src/AdminUi/ClientApp/src/app/services/auth-service/auth.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { AuthService } from './auth.service'; + +describe('AuthService', () => { + let service: AuthService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(AuthService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/services/auth-service/auth.service.ts b/AdminUi/src/AdminUi/ClientApp/src/app/services/auth-service/auth.service.ts new file mode 100644 index 0000000000..098064a4a0 --- /dev/null +++ b/AdminUi/src/AdminUi/ClientApp/src/app/services/auth-service/auth.service.ts @@ -0,0 +1,59 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { HttpResponseEnvelope } from 'src/app/utils/http-response-envelope'; +import { environment } from 'src/environments/environment'; + +@Injectable({ + providedIn: 'root' +}) +export class AuthService { + private loggedIn: BehaviorSubject = new BehaviorSubject(this.hasApiKey()); + apiUrl: string; + + get isLoggedIn() { + return this.loggedIn.asObservable(); + } + + constructor(private router: Router, + private http: HttpClient) { + this.apiUrl = environment.apiUrl; + } + + isCurrentlyLoggedIn(): boolean { + return this.loggedIn.value; + } + + hasApiKey(): boolean { + return !!localStorage.getItem('api-key'); + } + + getApiKey(): string | null { + return localStorage.getItem('api-key'); + } + + validateApiKey(apiKeyRequest: ValidateApiKeyRequest): Observable { + return this.http.post(`${this.apiUrl}/ValidateApiKey`, apiKeyRequest, { headers: { skip: "true" } }); + } + + login(apiKey: string): void { + localStorage.setItem('api-key', apiKey); + this.loggedIn.next(true); + this.router.navigate(['/']); + } + + logout(): void { + localStorage.removeItem('api-key'); + this.loggedIn.next(false); + this.router.navigate(['/login']); + } +} + +export interface ValidateApiKeyResponse { + isValid: boolean; +} + +export interface ValidateApiKeyRequest { + apiKey: string; +} \ No newline at end of file diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/shared/auth-guard/auth-guard.guard.spec.ts b/AdminUi/src/AdminUi/ClientApp/src/app/shared/auth-guard/auth-guard.guard.spec.ts new file mode 100644 index 0000000000..92d81ec3c4 --- /dev/null +++ b/AdminUi/src/AdminUi/ClientApp/src/app/shared/auth-guard/auth-guard.guard.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { AuthGuardGuard } from './auth-guard.guard'; + +describe('AuthGuardGuard', () => { + let guard: AuthGuardGuard; + + beforeEach(() => { + TestBed.configureTestingModule({}); + guard = TestBed.inject(AuthGuardGuard); + }); + + it('should be created', () => { + expect(guard).toBeTruthy(); + }); +}); diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/shared/auth-guard/auth-guard.guard.ts b/AdminUi/src/AdminUi/ClientApp/src/app/shared/auth-guard/auth-guard.guard.ts new file mode 100644 index 0000000000..ed37ded7c9 --- /dev/null +++ b/AdminUi/src/AdminUi/ClientApp/src/app/shared/auth-guard/auth-guard.guard.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; +import { map, Observable, take } from 'rxjs'; +import { AuthService } from 'src/app/services/auth-service/auth.service'; + +@Injectable({ + providedIn: 'root', +}) +export class AuthGuard { + + constructor(private authService: AuthService, private router: Router) { } + + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return this.authService.isLoggedIn.pipe( + take(1), + map((isLoggedIn: boolean) => { + if (!isLoggedIn) { + this.router.navigate(['/login']); + return false; + } + return true; + }) + ); + } +} diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/shared/interceptors/api-key.interceptor.spec.ts b/AdminUi/src/AdminUi/ClientApp/src/app/shared/interceptors/api-key.interceptor.spec.ts new file mode 100644 index 0000000000..3cbf053e81 --- /dev/null +++ b/AdminUi/src/AdminUi/ClientApp/src/app/shared/interceptors/api-key.interceptor.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { ApiKeyInterceptor } from './api-key.interceptor'; + +describe('ApiKeyInterceptor', () => { + beforeEach(() => TestBed.configureTestingModule({ + providers: [ + ApiKeyInterceptor + ] + })); + + it('should be created', () => { + const interceptor: ApiKeyInterceptor = TestBed.inject(ApiKeyInterceptor); + expect(interceptor).toBeTruthy(); + }); +}); diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/shared/interceptors/api-key.interceptor.ts b/AdminUi/src/AdminUi/ClientApp/src/app/shared/interceptors/api-key.interceptor.ts new file mode 100644 index 0000000000..0404522002 --- /dev/null +++ b/AdminUi/src/AdminUi/ClientApp/src/app/shared/interceptors/api-key.interceptor.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@angular/core'; +import { + HttpRequest, + HttpHandler, + HttpEvent, + HttpInterceptor, +} from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { AuthService } from 'src/app/services/auth-service/auth.service'; + +@Injectable() +export class ApiKeyInterceptor implements HttpInterceptor { + + isLoggedIn$: Observable | undefined; + + constructor(private authService: AuthService) { } + + intercept( + request: HttpRequest, + next: HttpHandler + ): Observable> { + this.isLoggedIn$ = this.authService.isLoggedIn; + const skipIntercept = request.headers.has('skip'); + if (skipIntercept) { + request = request.clone({ + headers: request.headers.delete('skip') + }); + } else if (this.isLoggedIn$) { + request = request.clone({ + setHeaders: { + 'X-API-KEY': this.authService.getApiKey()!, + }, + }); + } + return next.handle(request); + } +} diff --git a/AdminUi/src/AdminUi/Configuration/AdminConfiguration.cs b/AdminUi/src/AdminUi/Configuration/AdminConfiguration.cs index 4c93145c8f..786c6ce5dd 100644 --- a/AdminUi/src/AdminUi/Configuration/AdminConfiguration.cs +++ b/AdminUi/src/AdminUi/Configuration/AdminConfiguration.cs @@ -5,6 +5,8 @@ namespace AdminUi.Configuration; public class AdminConfiguration { + public AuthenticationConfiguration Authentication { get; set; } + public CorsConfiguration Cors { get; set; } = new(); public SwaggerUiConfiguration SwaggerUi { get; set; } = new(); @@ -15,6 +17,11 @@ public class AdminConfiguration [Required] public ModulesConfiguration Modules { get; set; } = new(); + public class AuthenticationConfiguration + { + public string ApiKey{ get; set; } = ""; + } + public class CorsConfiguration { public string AllowedOrigins { get; set; } = ""; diff --git a/AdminUi/src/AdminUi/Controllers/ApiKeyValidationController.cs b/AdminUi/src/AdminUi/Controllers/ApiKeyValidationController.cs new file mode 100644 index 0000000000..26e80b3e64 --- /dev/null +++ b/AdminUi/src/AdminUi/Controllers/ApiKeyValidationController.cs @@ -0,0 +1,22 @@ +using AdminUi.AspNet; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace AdminUi.Controllers; + +[Route("api/v1/ValidateApiKey")] +public class ApiKeyValidationController : ControllerBase +{ + [HttpPost] + [AllowAnonymous] + public IActionResult ValidateApiKey([FromBody] ValidateApiKeyRequest? request, [FromServices] ApiKeyValidator apiKeyValidator) + { + var apiKeyIsValid = apiKeyValidator.IsApiKeyValid(request?.ApiKey); + return Ok(new { isValid = apiKeyIsValid }); + } + + public class ValidateApiKeyRequest + { + public string? ApiKey { get; set; } + } +} diff --git a/AdminUi/src/AdminUi/Controllers/ClientsController.cs b/AdminUi/src/AdminUi/Controllers/ClientsController.cs index f48e8c6f1d..2d78b3522e 100644 --- a/AdminUi/src/AdminUi/Controllers/ClientsController.cs +++ b/AdminUi/src/AdminUi/Controllers/ClientsController.cs @@ -4,11 +4,13 @@ using Enmeshed.BuildingBlocks.API; using Enmeshed.BuildingBlocks.API.Mvc; using MediatR; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace AdminUi.Controllers; [Route("api/v1/[controller]")] +[Authorize("ApiKey")] public class ClientsController : ApiControllerBase { public ClientsController(IMediator mediator) : base(mediator) { } diff --git a/AdminUi/src/AdminUi/Controllers/HomeController.cs b/AdminUi/src/AdminUi/Controllers/HomeController.cs index ec856bd7c7..b4d1f95c14 100644 --- a/AdminUi/src/AdminUi/Controllers/HomeController.cs +++ b/AdminUi/src/AdminUi/Controllers/HomeController.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; namespace AdminUi.Controllers; diff --git a/AdminUi/src/AdminUi/Controllers/IdentitiesController.cs b/AdminUi/src/AdminUi/Controllers/IdentitiesController.cs index 31daa83af6..910556e764 100644 --- a/AdminUi/src/AdminUi/Controllers/IdentitiesController.cs +++ b/AdminUi/src/AdminUi/Controllers/IdentitiesController.cs @@ -5,6 +5,7 @@ using Enmeshed.BuildingBlocks.Application.Abstractions.Exceptions; using Enmeshed.BuildingBlocks.Application.Pagination; using MediatR; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using ApplicationException = Enmeshed.BuildingBlocks.Application.Abstractions.Exceptions.ApplicationException; @@ -12,6 +13,7 @@ namespace AdminUi.Controllers; [Route("api/v1/[controller]")] +[Authorize("ApiKey")] public class IdentitiesController : ApiControllerBase { private readonly ApplicationOptions _options; diff --git a/AdminUi/src/AdminUi/Controllers/MetricsController.cs b/AdminUi/src/AdminUi/Controllers/MetricsController.cs index 71d5b47c6b..3de46ff2aa 100644 --- a/AdminUi/src/AdminUi/Controllers/MetricsController.cs +++ b/AdminUi/src/AdminUi/Controllers/MetricsController.cs @@ -2,12 +2,13 @@ using Enmeshed.BuildingBlocks.API; using Enmeshed.BuildingBlocks.API.Mvc; using MediatR; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace AdminUi.Controllers; [Route("api/v1/[controller]")] - +[Authorize("ApiKey")] public class MetricsController : ApiControllerBase { public MetricsController(IMediator mediator) : base(mediator) { } diff --git a/AdminUi/src/AdminUi/Controllers/QuotasController.cs b/AdminUi/src/AdminUi/Controllers/QuotasController.cs index 0ff8c14831..912f253894 100644 --- a/AdminUi/src/AdminUi/Controllers/QuotasController.cs +++ b/AdminUi/src/AdminUi/Controllers/QuotasController.cs @@ -7,7 +7,7 @@ namespace AdminUi.Controllers; [Route("api/v1/[controller]")] -[Authorize(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)] +[Authorize("ApiKey")] public class QuotasController : ApiControllerBase { public QuotasController(IMediator mediator) : base(mediator) diff --git a/AdminUi/src/AdminUi/Controllers/TiersController.cs b/AdminUi/src/AdminUi/Controllers/TiersController.cs index 9cbcbec6e6..a80f19530d 100644 --- a/AdminUi/src/AdminUi/Controllers/TiersController.cs +++ b/AdminUi/src/AdminUi/Controllers/TiersController.cs @@ -9,6 +9,7 @@ using Enmeshed.BuildingBlocks.Application.Abstractions.Exceptions; using Enmeshed.BuildingBlocks.Application.Pagination; using MediatR; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using ApplicationException = Enmeshed.BuildingBlocks.Application.Abstractions.Exceptions.ApplicationException; @@ -16,6 +17,7 @@ namespace AdminUi.Controllers; [Route("api/v1/[controller]")] +[Authorize("ApiKey")] public class TiersController : ApiControllerBase { private readonly ApplicationOptions _options; diff --git a/AdminUi/src/AdminUi/Extensions/IServiceCollectionExtensions.cs b/AdminUi/src/AdminUi/Extensions/IServiceCollectionExtensions.cs index 5f2a5b975b..8758317e31 100644 --- a/AdminUi/src/AdminUi/Extensions/IServiceCollectionExtensions.cs +++ b/AdminUi/src/AdminUi/Extensions/IServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; +using AdminUi.AspNet; using AdminUi.Configuration; using Backbone.Modules.Devices.Application.Devices.Commands.RegisterDevice; using Backbone.Modules.Devices.Application.Devices.DTOs; @@ -89,6 +90,21 @@ public static IServiceCollection AddCustomAspNetCore(this IServiceCollection ser }); }); + services.AddAuthentication("ApiKey") + .AddScheme( + "ApiKey", + opts => opts.ApiKey = configuration.Authentication.ApiKey + ); + + services.AddAuthorization(options => + { + options.AddPolicy("ApiKey", policy => + { + policy.AddAuthenticationSchemes("ApiKey"); + policy.RequireAuthenticatedUser(); + }); + }); + var modules = configuration.Modules.GetType().GetProperties(); foreach (var moduleProperty in modules) { @@ -138,4 +154,4 @@ public static IServiceCollection AddCustomSwaggerWithUi(this IServiceCollection return services; } -} \ No newline at end of file +} diff --git a/AdminUi/src/AdminUi/Program.cs b/AdminUi/src/AdminUi/Program.cs index 8450965012..510c060d1e 100644 --- a/AdminUi/src/AdminUi/Program.cs +++ b/AdminUi/src/AdminUi/Program.cs @@ -1,4 +1,5 @@ using System.Reflection; +using AdminUi.AspNet; using AdminUi.Configuration; using AdminUi.Extensions; using AdminUi.OpenIddict; @@ -6,10 +7,8 @@ using Backbone.Infrastructure.EventBus; using Backbone.Modules.Devices.Application; using Backbone.Modules.Devices.Infrastructure.Persistence.Database; -using Backbone.Modules.Quotas.Application.QuotaCheck; using Enmeshed.BuildingBlocks.API.Extensions; using Enmeshed.BuildingBlocks.Application.QuotaCheck; -using Enmeshed.Common.Infrastructure; using Enmeshed.Tooling.Extensions; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.Extensions.Options; @@ -43,6 +42,8 @@ static void ConfigureServices(IServiceCollection services, IConfiguration configuration, IHostEnvironment environment) { + services.AddSingleton(); + services .ConfigureAndValidate(configuration.Bind) .ConfigureAndValidate(options => configuration.GetSection("Modules:Devices:Application").Bind(options)); @@ -117,6 +118,9 @@ static void Configure(WebApplication app) app.UseStaticFiles(); app.UseRouting(); + app.UseAuthentication(); + app.UseAuthorization(); + app.MapControllers(); app.MapFallbackToFile("{*path:regex(^(?!api/).*$)}", "index.html"); // don't match paths beginning with "api/" @@ -124,4 +128,4 @@ static void Configure(WebApplication app) { ResponseWriter = HealthCheckWriter.WriteResponse }); -} \ No newline at end of file +} diff --git a/AdminUi/src/AdminUi/appsettings.override.json b/AdminUi/src/AdminUi/appsettings.override.json index e62f545759..365538071a 100644 --- a/AdminUi/src/AdminUi/appsettings.override.json +++ b/AdminUi/src/AdminUi/appsettings.override.json @@ -1,4 +1,7 @@ { + "Authentication": { + "ApiKey": "test" + }, "Cors": { "AllowedOrigins": "http://localhost:44480;https://localhost:44480", "ExposedHeaders": "" diff --git a/BuildingBlocks/src/BuildingBlocks.API/Extensions/ApplicationBuilderExtensions.cs b/BuildingBlocks/src/BuildingBlocks.API/Extensions/ApplicationBuilderExtensions.cs deleted file mode 100644 index 46c189add0..0000000000 --- a/BuildingBlocks/src/BuildingBlocks.API/Extensions/ApplicationBuilderExtensions.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Enmeshed.BuildingBlocks.API.Mvc.Middleware; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Hosting; -using Microsoft.IdentityModel.Logging; - -namespace Enmeshed.BuildingBlocks.API.Extensions; - -public static class ApplicationBuilderExtensions -{ - public static void ConfigureMiddleware(this IApplicationBuilder app, IWebHostEnvironment env) - { - app.UseMiddleware(); - app.UseMiddleware(); - app.UseMiddleware(); - - app.UseSecurityHeaders(policies => - policies - .AddDefaultSecurityHeaders() - .AddCustomHeader("Strict-Transport-Security", "max-age=5184000; includeSubDomains") - .AddCustomHeader("X-Frame-Options", "Deny") - ); - - app.UseRouting(); - - app.UseCors(); - - if (env.IsDevelopment()) IdentityModelEventSource.ShowPII = true; - - app.UseAuthentication(); - app.UseAuthorization(); - - app.UseEndpoints(endpoints => - { - endpoints.MapControllers(); - endpoints.MapHealthChecks("/health"); - }); - } -} \ No newline at end of file diff --git a/ConsumerApi/Extensions/ApplicationBuilderExtensions.cs b/ConsumerApi/Extensions/ApplicationBuilderExtensions.cs deleted file mode 100644 index fad763ed8d..0000000000 --- a/ConsumerApi/Extensions/ApplicationBuilderExtensions.cs +++ /dev/null @@ -1,36 +0,0 @@ -using ConsumerApi.Mvc.Middleware; -using Microsoft.IdentityModel.Logging; - -namespace ConsumerApi.Extensions; - -public static class ApplicationBuilderExtensions -{ - public static void ConfigureMiddleware(this IApplicationBuilder app, IWebHostEnvironment env) - { - app.UseMiddleware(); - app.UseMiddleware(); - app.UseMiddleware(); - - app.UseSecurityHeaders(policies => - policies - .AddDefaultSecurityHeaders() - .AddCustomHeader("Strict-Transport-Security", "max-age=5184000; includeSubDomains") - .AddCustomHeader("X-Frame-Options", "Deny") - ); - - app.UseRouting(); - - app.UseCors(); - - if (env.IsDevelopment()) IdentityModelEventSource.ShowPII = true; - - app.UseAuthentication(); - app.UseAuthorization(); - - app.UseEndpoints(endpoints => - { - endpoints.MapControllers(); - endpoints.MapHealthChecks("/health"); - }); - } -} \ No newline at end of file diff --git a/Modules/Devices/src/Devices.Application/Clients/Commands/CreateClients/Handler.cs b/Modules/Devices/src/Devices.Application/Clients/Commands/CreateClients/Handler.cs index 3da2b08ef3..6a997940b4 100644 --- a/Modules/Devices/src/Devices.Application/Clients/Commands/CreateClients/Handler.cs +++ b/Modules/Devices/src/Devices.Application/Clients/Commands/CreateClients/Handler.cs @@ -24,7 +24,7 @@ public async Task Handle(CreateClientCommand request, Canc var clientSecret = string.IsNullOrEmpty(request.ClientSecret) ? PasswordGenerator.Generate(30) : request.ClientSecret; var clientId = string.IsNullOrEmpty(request.ClientId) ? ClientIdGenerator.Generate() : request.ClientId; - var displayName = string.IsNullOrEmpty(request.DisplayName) ? request.ClientId : request.DisplayName; + var displayName = string.IsNullOrEmpty(request.DisplayName) ? clientId : request.DisplayName; await _oAuthClientsRepository.Add(clientId, displayName, clientSecret, cancellationToken); diff --git a/Modules/Devices/src/Devices.Application/Devices.Application.csproj b/Modules/Devices/src/Devices.Application/Devices.Application.csproj index da9eedc5a2..77992a563b 100644 --- a/Modules/Devices/src/Devices.Application/Devices.Application.csproj +++ b/Modules/Devices/src/Devices.Application/Devices.Application.csproj @@ -11,9 +11,9 @@ - - - + + + diff --git a/Modules/Devices/src/Devices.Infrastructure/Devices.Infrastructure.csproj b/Modules/Devices/src/Devices.Infrastructure/Devices.Infrastructure.csproj index d1a3006517..4f7e142c1d 100644 --- a/Modules/Devices/src/Devices.Infrastructure/Devices.Infrastructure.csproj +++ b/Modules/Devices/src/Devices.Infrastructure/Devices.Infrastructure.csproj @@ -3,8 +3,8 @@ net7.0 enable - Backbone.Modules.$(MSBuildProjectName) - $(AssemblyName.Replace(" ", "_")) + Backbone.Modules.$(MSBuildProjectName) + $(AssemblyName.Replace(" ", "_")) @@ -22,8 +22,8 @@ - - + +
Id + {{ tier.id }} Name + {{ tier.name }}