Przeglądaj źródła

webauthn implementation

Dr-Swopt 4 miesięcy temu
rodzic
commit
ad4f051637

Plik diff jest za duży
+ 390 - 347
package-lock.json


+ 23 - 22
package.json

@@ -5,44 +5,45 @@
     "ng": "ng",
     "start": "ng serve",
     "build": "ng build",
+    "nest": "ng build --configuration production --base-href /",
     "watch": "ng build --watch --configuration development",
     "test": "ng test"
   },
   "private": true,
   "dependencies": {
-    "@angular/cdk": "^19.2.18",
-    "@angular/common": "^19.2.0",
-    "@angular/compiler": "^19.2.0",
-    "@angular/core": "^19.2.0",
-    "@angular/forms": "^19.2.0",
-    "@angular/material": "^19.2.18",
-    "@angular/platform-browser": "^19.2.0",
-    "@angular/platform-browser-dynamic": "^19.2.0",
-    "@angular/router": "^19.2.0",
+    "@angular/cdk": "^20.0.4",
+    "@angular/common": "^20.0.5",
+    "@angular/compiler": "^20.0.5",
+    "@angular/core": "^20.0.5",
+    "@angular/forms": "^20.0.5",
+    "@angular/material": "^20.0.4",
+    "@angular/platform-browser": "^20.0.5",
+    "@angular/platform-browser-dynamic": "^20.0.5",
+    "@angular/router": "^20.0.5",
     "@capacitor/android": "^7.4.0",
     "@capacitor/barcode-scanner": "^2.0.3",
     "@capacitor/core": "^7.4.0",
     "@capgo/capacitor-native-biometric": "^7.1.7",
     "@simplewebauthn/browser": "^13.1.0",
-    "angularx-qrcode": "^19.0.0",
-    "ngx-socket-io": "^4.8.5",
-    "rxjs": "~7.8.0",
+    "angularx-qrcode": "^20.0.0",
+    "ngx-socket-io": "^4.9.1",
+    "rxjs": "~7.8.2",
     "socket.io-client": "^4.8.1",
-    "tslib": "^2.3.0",
-    "zone.js": "~0.15.0"
+    "tslib": "^2.8.1",
+    "zone.js": "~0.15.1"
   },
   "devDependencies": {
-    "@angular-devkit/build-angular": "^19.2.7",
-    "@angular/cli": "^19.2.7",
-    "@angular/compiler-cli": "^19.2.0",
+    "@angular-devkit/build-angular": "^20.0.4",
+    "@angular/cli": "^20.0.4",
+    "@angular/compiler-cli": "^20.0.5",
     "@capacitor/cli": "^7.4.0",
-    "@types/jasmine": "~5.1.0",
-    "jasmine-core": "~5.6.0",
-    "karma": "~6.4.0",
+    "@types/jasmine": "~5.1.8",
+    "jasmine-core": "~5.8.0",
+    "karma": "~6.4.4",
     "karma-chrome-launcher": "~3.2.0",
-    "karma-coverage": "~2.2.0",
+    "karma-coverage": "~2.2.1",
     "karma-jasmine": "~5.1.0",
     "karma-jasmine-html-reporter": "~2.1.0",
-    "typescript": "~5.7.2"
+    "typescript": "~5.8.3"
   }
 }

+ 4 - 0
src/app/app.routes.ts

@@ -4,10 +4,14 @@ import { LoginComponent } from './login/login.component';
 import { DashboardComponent } from './dashboard/dashboard.component';
 import { authGuard } from './services/auth.guard';
 import { RegisterComponent } from './register/register.component';
+import { WebauthnRegisterComponent } from './webauthn-register/webauthn-register.component';
+import { WebauthnLoginComponent } from './webauthn-login/webauthn-login.component';
 
 export const routes: Routes = [
   { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
   { path: 'login', component: LoginComponent },
   { path: 'register', component: RegisterComponent },
+  { path: 'webauthn-register', component: WebauthnRegisterComponent },
+  { path: 'webauthn-login', component: WebauthnLoginComponent },
   { path: 'dashboard', component: DashboardComponent, canActivate: [authGuard] },
 ];

+ 3 - 1
src/app/dashboard/dashboard.home.component.ts

@@ -13,13 +13,15 @@ import { AuthService } from '../services/auth.service';
 })
 export class DashboardHomeComponent {
     username: string = `Guest`
+    token: string = ``
 
     constructor(private auth: AuthService) {
 
     }
 
     ngOnInit(): void {
-        this.username = this.auth.getUsername()
+        this.username = this.auth.getUsername() || `No name`
+        this.token = this.auth.getToken() || `Null`
     }
 
 }

+ 19 - 4
src/app/login/login.component.html

@@ -4,15 +4,30 @@
   <form [formGroup]="form" (ngSubmit)="onSubmit()">
     <mat-form-field appearance="outline" class="full-width">
       <mat-label>Email</mat-label>
-      <input matInput formControlName="email" type="email" />
+      <input matInput formControlName="email" type="email" required />
     </mat-form-field>
 
     <mat-form-field appearance="outline" class="full-width">
       <mat-label>Password</mat-label>
-      <input matInput formControlName="password" type="password" />
+      <input matInput formControlName="password" type="password" required />
     </mat-form-field>
 
-    <button mat-raised-button color="primary" type="submit" class="full-width">Login</button>
-    <button mat-button type="button" class="full-width" (click)="goToRegister()">Register</button>
+    <button mat-raised-button color="primary" type="submit" class="full-width">
+      Login
+    </button>
+
+    <button mat-button type="button" class="full-width" (click)="goToRegister()">
+      Register
+    </button>
+
+    <button mat-stroked-button color="accent" type="button" class="full-width" (click)="goToWebauthnRegister()">
+      Register with Passkey
+    </button>
+
+    <mat-divider class="my-3"></mat-divider>
+
+    <button mat-flat-button color="warn" type="button" class="full-width" (click)="loginWithWebauthn()">
+      Login with Passkey
+    </button>
   </form>
 </mat-card>

+ 13 - 2
src/app/login/login.component.ts

@@ -8,6 +8,8 @@ import { MatFormFieldModule } from '@angular/material/form-field';
 import { MatInputModule } from '@angular/material/input';
 import { MatButtonModule } from '@angular/material/button';
 import { MatCardModule } from '@angular/material/card';
+import { MatDividerModule } from '@angular/material/divider';
+
 @Component({
   selector: 'app-login',
   standalone: true,
@@ -18,8 +20,9 @@ import { MatCardModule } from '@angular/material/card';
     MatFormFieldModule,
     MatInputModule,
     MatButtonModule,
-    MatCardModule
-  ], 
+    MatCardModule,
+    MatDividerModule
+  ],
   templateUrl: './login.component.html',
   styleUrls: ['./login.component.css']
 })
@@ -43,6 +46,14 @@ export class LoginComponent implements OnInit {
     this.router.navigate(['/register']);
   }
 
+  goToWebauthnRegister(): void {
+    this.router.navigate(['/webauthn-register']);
+  }
+
+  loginWithWebauthn(): void {
+    this.router.navigate(['/webauthn-login'])
+  }
+
   onSubmit(): void {
     if (this.form.invalid) {
       this.form.markAllAsTouched();

+ 88 - 4
src/app/services/auth.service.ts

@@ -3,10 +3,12 @@ import { HttpClient } from '@angular/common/http';
 import { Router } from '@angular/router';
 import { Observable } from 'rxjs';
 import { AuthResponse, LoginPayload, RegisterPayload } from '../interfaces/interface';
+import { AuthenticationResponseJSON, RegistrationResponseJSON, startAuthentication, startRegistration } from '@simplewebauthn/browser';
+import { A11yModule } from '@angular/cdk/a11y';
 
 @Injectable({ providedIn: 'root' })
 export class AuthService {
-  private readonly baseUrl = 'https://a654-124-13-232-72.ngrok-free.app';
+  private readonly baseUrl = 'https://b8c8-115-132-229-66.ngrok-free.app/api';
   private readonly tokenKey = 'auth_token';
   private userName!: string
 
@@ -22,6 +24,90 @@ export class AuthService {
     return this.http.post<AuthResponse>(`${this.baseUrl}/auth/login`, payload);
   }
 
+  async webauthnRegister(username: string, email: string): Promise<AuthResponse> {
+    return new Promise(async (resolve, reject) => {
+
+      const options = await this.http
+        .post<any>(`${this.baseUrl}/auth/webauthn-register-options`, { username })
+        .toPromise();
+
+      if (!options) {
+        throw new Error('Registration options not received from server');
+      }
+
+      const attestationResponse: RegistrationResponseJSON = await startRegistration({
+        optionsJSON: options,
+      });
+
+      const credentialJSON = {
+        id: attestationResponse.id,
+        rawId: attestationResponse.rawId,
+        type: attestationResponse.type,
+        response: {
+          attestationObject: attestationResponse.response.attestationObject,
+          clientDataJSON: attestationResponse.response.clientDataJSON,
+        },
+        clientExtensionResults: attestationResponse.clientExtensionResults,
+        authenticatorAttachment: attestationResponse.authenticatorAttachment,
+        email: email,
+        name: username
+      };
+
+      // POST to /webauthn/register
+      this.http.post<AuthResponse>(`${this.baseUrl}/auth/webauthn-register`, credentialJSON).toPromise().then(res => {
+        if (!res) {
+          reject(res)
+        } else {
+          // Optionally store token and username
+          this.storeToken(res.access_token);
+          this.setUserName(res.name);
+          resolve(res)
+        }
+      }).catch((err) => reject(err))
+    })
+  }
+
+  async webauthnLogin(email: string): Promise<AuthResponse> {
+    return new Promise(async (resolve, reject) => {
+      try {
+        // 1. Get options from backend
+        const options = await this.http
+          .post<any>(`${this.baseUrl}/auth/webauthn-login-options`, { email })
+          .toPromise();
+
+        if (!options) {
+          throw new Error('Login options not received from server');
+        }
+
+        // 2. Start authentication with browser/device
+        const assertionResponse: AuthenticationResponseJSON = await startAuthentication({
+          optionsJSON: options,
+        });
+
+        // 3. Attach email so backend knows which user to validate against
+        const loginPayload = {
+          ...assertionResponse,
+          email,
+        };
+
+        // 4. POST to backend and verify
+        const res = await this.http
+          .post<AuthResponse>(`${this.baseUrl}/auth/webauthn-login`, loginPayload)
+          .toPromise();
+
+        if (!res || !res.access_token) {
+          reject(new Error('Invalid login response'));
+        } else {
+          this.storeToken(res.access_token);
+          this.setUserName(res.name);
+          resolve(res);
+        }
+      } catch (err) {
+        reject(err);
+      }
+    });
+  }
+
   reportAttendance(url: string): Observable<AuthResponse> {
     let payload = {
       name: this.userName,
@@ -69,8 +155,6 @@ export class AuthService {
   }
 
   getServerUrl(): Observable<string> {
-    return this.http.get(`${this.baseUrl}/server`, {
-      responseType: 'text'
-    });
+    return this.http.get(`${this.baseUrl}/auth/server`, { responseType: 'text' });
   }
 }

+ 12 - 0
src/app/webauthn-login/webauthn-login.component.css

@@ -0,0 +1,12 @@
+.login-card {
+  max-width: 400px;
+  margin: 5rem auto;
+  padding: 2rem;
+  display: flex;
+  flex-direction: column;
+  gap: 1rem;
+}
+
+.full-width {
+  width: 100%;
+}

+ 21 - 0
src/app/webauthn-login/webauthn-login.component.html

@@ -0,0 +1,21 @@
+<mat-card class="login-card">
+  <h2>Login with Passkey</h2>
+
+  <form (ngSubmit)="loginWithPasskey()">
+    <mat-form-field appearance="outline" class="full-width">
+      <mat-label>Email</mat-label>
+      <input matInput [(ngModel)]="email" name="email" required />
+    </mat-form-field>
+
+    <button mat-raised-button color="primary" type="submit" class="full-width" [disabled]="loading || !email">
+      Login with Passkey
+    </button>
+
+    <button mat-button type="button" class="full-width" (click)="goBack()">
+      Back to Login
+    </button>
+
+    <mat-hint *ngIf="message">{{ message }}</mat-hint>
+    <mat-error *ngIf="errorMessage">{{ errorMessage }}</mat-error>
+  </form>
+</mat-card>

+ 23 - 0
src/app/webauthn-login/webauthn-login.component.spec.ts

@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { WebauthnLoginComponent } from './webauthn-login.component';
+
+describe('WebauthnLoginComponent', () => {
+  let component: WebauthnLoginComponent;
+  let fixture: ComponentFixture<WebauthnLoginComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      imports: [WebauthnLoginComponent]
+    })
+    .compileComponents();
+
+    fixture = TestBed.createComponent(WebauthnLoginComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});

+ 57 - 0
src/app/webauthn-login/webauthn-login.component.ts

@@ -0,0 +1,57 @@
+import { Component } from '@angular/core';
+import { startAuthentication } from '@simplewebauthn/browser';
+import { AuthService } from '../services/auth.service';
+import { Router, RouterModule } from '@angular/router';
+import { CommonModule } from '@angular/common';
+import { FormsModule, ReactiveFormsModule } from '@angular/forms';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatInputModule } from '@angular/material/input';
+import { MatButtonModule } from '@angular/material/button';
+import { MatCardModule } from '@angular/material/card';
+import { MatDividerModule } from '@angular/material/divider';
+
+@Component({
+  selector: 'app-webauthn-login',
+  standalone: true,
+  imports: [
+    CommonModule,
+    FormsModule,
+    ReactiveFormsModule,
+    RouterModule,
+    MatFormFieldModule,
+    MatInputModule,
+    MatButtonModule,
+    MatCardModule,
+    MatDividerModule
+  ],
+  templateUrl: './webauthn-login.component.html',
+  styleUrl: './webauthn-login.component.css'
+})
+export class WebauthnLoginComponent {
+  email = '';
+  message = '';
+  errorMessage = '';
+  loading = false;
+
+  constructor(private authService: AuthService, private router: Router) { }
+
+  async loginWithPasskey() {
+    this.message = '';
+    this.errorMessage = '';
+    this.loading = true;
+
+    try {
+      const res = await this.authService.webauthnLogin(this.email);
+      this.message = 'Login successful!';
+      this.router.navigate(['/dashboard']);
+    } catch (err: any) {
+      this.errorMessage = err?.message || 'Login failed';
+    } finally {
+      this.loading = false;
+    }
+  }
+
+  goBack() {
+    this.router.navigate(['/login']);
+  }
+}

+ 12 - 0
src/app/webauthn-register/webauthn-register.component.css

@@ -0,0 +1,12 @@
+.login-card {
+  max-width: 400px;
+  margin: 5rem auto;
+  padding: 2rem;
+  display: flex;
+  flex-direction: column;
+  gap: 1rem;
+}
+
+.full-width {
+  width: 100%;
+}

+ 51 - 0
src/app/webauthn-register/webauthn-register.component.html

@@ -0,0 +1,51 @@
+<mat-card class="login-card">
+  <h2>Register with Passkey</h2>
+
+  <form (ngSubmit)="register()" #registerForm="ngForm">
+    <mat-form-field appearance="outline" class="full-width">
+      <mat-label>Username</mat-label>
+      <input
+        matInput
+        [(ngModel)]="username"
+        name="username"
+        required
+        #usernameInput="ngModel"
+      />
+      <mat-error *ngIf="usernameInput.invalid && usernameInput.touched">
+        Username is required
+      </mat-error>
+    </mat-form-field>
+
+    <mat-form-field appearance="outline" class="full-width">
+      <mat-label>Email</mat-label>
+      <input
+        matInput
+        type="email"
+        [(ngModel)]="email"
+        name="email"
+        required
+        email
+        #emailInput="ngModel"
+      />
+      <mat-error *ngIf="emailInput.invalid && emailInput.touched">
+        Valid email is required
+      </mat-error>
+    </mat-form-field>
+
+    <button
+      mat-raised-button
+      color="primary"
+      type="submit"
+      class="full-width"
+      [disabled]="loading || registerForm.invalid"
+    >
+      Register with Passkey
+    </button>
+
+    <button mat-button type="button" class="full-width" (click)="goBack()">
+      Back to Login
+    </button>
+
+    <mat-hint *ngIf="message">{{ message }}</mat-hint>
+  </form>
+</mat-card>

+ 23 - 0
src/app/webauthn-register/webauthn-register.component.spec.ts

@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { WebauthnRegisterComponent } from './webauthn-register.component';
+
+describe('WebauthnRegister', () => {
+  let component: WebauthnRegisterComponent;
+  let fixture: ComponentFixture<WebauthnRegisterComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      imports: [WebauthnRegisterComponent]
+    })
+    .compileComponents();
+
+    fixture = TestBed.createComponent(WebauthnRegisterComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});

+ 57 - 0
src/app/webauthn-register/webauthn-register.component.ts

@@ -0,0 +1,57 @@
+import { Component, inject } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { CommonModule } from '@angular/common';
+import { Router, RouterModule } from '@angular/router';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatInputModule } from '@angular/material/input';
+import { MatButtonModule } from '@angular/material/button';
+import { MatCardModule } from '@angular/material/card';
+import { AuthService } from '../services/auth.service';
+import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
+
+@Component({
+  selector: 'app-webauthn-register',
+  standalone: true,
+  imports: [
+    CommonModule,
+    FormsModule,
+    RouterModule,
+    MatFormFieldModule,
+    MatInputModule,
+    MatButtonModule,
+    MatCardModule,
+    MatSnackBarModule
+  ],
+  templateUrl: './webauthn-register.component.html',
+  styleUrls: ['./webauthn-register.component.css']
+})
+export class WebauthnRegisterComponent {
+  private snackbar = inject(MatSnackBar);
+  username = '';
+  email = '';
+  message = ''
+  loading = false;
+
+  constructor(private router: Router, private authService: AuthService) { }
+
+  async register() {
+    this.loading = true;
+    // ✅ Show snackbar before sending
+    try {
+      await this.authService.webauthnRegister(this.username, this.email);
+      // ✅ Navigate to dashboard
+      this.router.navigate(['/dashboard']);
+    } catch (err) {
+      this.snackbar.open('Registration Failed', 'Dismiss', {
+        duration: undefined, // stays until explicitly closed
+      });
+      this.message = 'Registration failed: ' + (err as any).message;
+    } finally {
+      this.loading = false;
+    }
+  }
+
+  goBack() {
+    this.router.navigate(['/login']);
+  }
+}

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików