Browse Source

feat: 实现 passkey 登录注册功能前端支持

- 添加 passkey 组件和相关路由配置
- 修复 build.gradle 添加 -parameters 编译参数
- 更新前端依赖和国际化配置
- 优化登录界面支持 passkey 认证
Spock12138 1 month ago
parent
commit
33734f1387

+ 1 - 0
build.gradle

@@ -54,6 +54,7 @@ allprojects {
     targetCompatibility = 17
     compileJava.options.encoding = 'UTF-8'
     
+    compileJava.options.compilerArgs += ['-parameters']
     eclipse {
         /*设置工程字符集*/
         jdt  {

+ 1 - 1
maxkey-web-frontend/maxkey-web-app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "maxkey",
-  "version": "4.1.x",
+  "version": "4.1.0",
   "description": "Leading-Edge IAM Identity and Access Management",
   "author": "MaxKey <support@maxsso.net>",
   "repository": {

+ 13 - 2
maxkey-web-frontend/maxkey-web-app/src/app/routes/config/config.module.ts

@@ -27,8 +27,14 @@ import { NzPageHeaderModule } from 'ng-zorro-antd/page-header';
 import { NzPaginationModule } from 'ng-zorro-antd/pagination';
 import { NzStepsModule } from 'ng-zorro-antd/steps';
 
+import { NzEmptyModule } from 'ng-zorro-antd/empty';
+import { NzListModule } from 'ng-zorro-antd/list';
+import { NzPopconfirmModule } from 'ng-zorro-antd/popconfirm';
+
 import { AccoutsComponent } from './accouts/accouts.component';
 import { MfaComponent } from './mfa/mfa.component';
+
+import { PasskeyComponent } from './passkey/passkey.component';
 import { PasswordComponent } from './password/password.component';
 import { ProfileComponent } from './profile/profile.component';
 import { SocialsAssociateComponent } from './socials-associate/socials-associate.component';
@@ -45,6 +51,10 @@ const routes: Routes = [
     component: PasswordComponent
   },
   {
+    path: 'passkey',
+    component: PasskeyComponent
+  },
+  {
     path: 'socialsassociate',
     component: SocialsAssociateComponent
   },
@@ -69,9 +79,10 @@ const COMPONENTS = [ProfileComponent];
     PasswordComponent,
     ProfileComponent,
     AccoutsComponent,
-    MfaComponent
+    MfaComponent,
+    PasskeyComponent
   ],
-  imports: [SharedModule, CommonModule, RouterModule.forChild(routes)],
+  imports: [SharedModule, CommonModule, RouterModule.forChild(routes), NzEmptyModule, NzListModule, NzPopconfirmModule],
   exports: [RouterModule]
 })
 export class ConfigModule {}

+ 58 - 0
maxkey-web-frontend/maxkey-web-app/src/app/routes/config/passkey/passkey.component.html

@@ -0,0 +1,58 @@
+<nz-card nzTitle="Passkey 管理">
+  
+  <div nz-row [nzGutter]="24">
+    <div nz-col [nzSpan]="24">
+      <div class="mb-md">
+        <p class="text-grey">Passkey 是一种更安全、更便捷的登录方式,使用您的设备生物识别或 PIN 码进行身份验证。</p>
+      </div>
+      
+      <div class="mb-lg">
+        <button 
+          nz-button 
+          nzType="primary" 
+          nzSize="large" 
+          [nzLoading]="loading" 
+          (click)="registerPasskey()">
+          <i nz-icon nzType="plus-circle" nzTheme="outline"></i>
+          注册新的 Passkey
+        </button>
+      </div>
+
+      <nz-divider nzText="已注册的 Passkey"></nz-divider>
+
+
+
+      <nz-table #basicTable [nzData]="passkeyList" [nzShowPagination]="false">
+        <thead>
+          <tr>
+            <th>凭证信息</th>
+            <th>签名统计</th>
+            <th>创建时间</th>
+            <th>最近访问时间</th>
+            <th>操作</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr *ngFor="let item of passkeyList; let i = index">
+            <td>
+              <div class="credential-info">
+                <div class="credential-id">{{ item.credentialId || item.id }}</div>
+                <div class="device-type">{{ item.deviceType === 'platform' ? '平台认证器' : '跨平台认证器' }}</div>
+              </div>
+            </td>
+            <td>{{ item.signatureCount || 0 }}</td>
+            <td>{{ item.createdDate | date:'yyyy-MM-dd HH:mm:ss' }}</td>
+            <td>{{ item.lastUsedDate | date:'yyyy-MM-dd HH:mm:ss' }}</td>
+            <td>
+              <button nz-button nzType="link" nzDanger nzSize="small" (click)="confirmDeletePasskey(item.credentialId || item.id)">
+                删除 passkey
+              </button>
+            </td>
+          </tr>
+        </tbody>
+      </nz-table>
+
+
+    </div>
+  </div>
+</nz-card>

+ 112 - 0
maxkey-web-frontend/maxkey-web-app/src/app/routes/config/passkey/passkey.component.less

@@ -0,0 +1,112 @@
+.text-grey {
+  color: #666;
+  line-height: 1.6;
+}
+
+.mb-md {
+  margin-bottom: 16px;
+}
+
+.mb-lg {
+  margin-bottom: 24px;
+}
+
+.mt-lg {
+  margin-top: 24px;
+}
+
+.py-lg {
+  padding: 24px 0;
+}
+
+.text-center {
+  text-align: center;
+}
+
+nz-list-item {
+  padding: 16px 0;
+  border-bottom: 1px solid #f0f0f0;
+  
+  &:last-child {
+    border-bottom: none;
+  }
+}
+
+nz-list-item-meta-title h4 {
+  margin: 0;
+  font-size: 16px;
+  font-weight: 500;
+}
+
+nz-list-item-meta-description p {
+  margin: 4px 0;
+  color: #666;
+  font-size: 12px;
+}
+
+.passkey-info {
+  p {
+    margin: 6px 0;
+    line-height: 1.4;
+    
+    strong {
+      color: #333;
+      font-weight: 500;
+    }
+    
+    code {
+      background: #f5f5f5;
+      padding: 2px 6px;
+      border-radius: 3px;
+      font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+      font-size: 11px;
+      color: #d63384;
+      word-break: break-all;
+    }
+  }
+}
+
+nz-list-item-meta-title {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  
+  h4 {
+    margin: 0;
+    flex: 1;
+  }
+  
+  nz-tag {
+    font-size: 11px;
+    padding: 2px 6px;
+    border-radius: 10px;
+  }
+}
+
+// 表格样式
+.credential-info {
+  .credential-id {
+    font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+    font-size: 12px;
+    color: #333;
+    margin-bottom: 4px;
+    word-break: break-all;
+  }
+  
+  .device-type {
+    font-size: 12px;
+    color: #666;
+  }
+}
+
+nz-table {
+  th {
+    background-color: #fafafa;
+    font-weight: 500;
+  }
+  
+  td {
+    vertical-align: top;
+    padding: 12px 16px;
+  }
+}

+ 468 - 0
maxkey-web-frontend/maxkey-web-app/src/app/routes/config/passkey/passkey.component.ts

@@ -0,0 +1,468 @@
+/*
+ * Copyright [2022] [MaxKey of copyright http://www.maxkey.top]
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Component, OnInit, ChangeDetectorRef, OnDestroy } from '@angular/core';
+import { NzMessageService } from 'ng-zorro-antd/message';
+import { NzModalService } from 'ng-zorro-antd/modal';
+import { HttpClient, HttpErrorResponse } from '@angular/common/http';
+import { SettingsService } from '@delon/theme';
+import { Subject, takeUntil, finalize } from 'rxjs';
+
+// 定义接口类型
+interface PasskeyInfo {
+  id: string;
+  credentialId: string;
+  displayName: string;
+  deviceType: string;
+  signatureCount: number;
+  createdDate: string;
+  lastUsedDate?: string;
+  status: number; // 修复:状态应为数字类型,1表示活跃,0表示禁用
+}
+
+interface ApiResponse<T = any> {
+  code: number;
+  message?: string;
+  data?: T;
+}
+
+interface UserInfo {
+  userId?: string;
+  id?: string;
+  username?: string;
+  displayName?: string;
+}
+
+@Component({
+  selector: 'app-passkey',
+  templateUrl: './passkey.component.html',
+  styleUrls: ['./passkey.component.less']
+})
+export class PasskeyComponent implements OnInit, OnDestroy {
+  loading = false;
+  passkeyList: PasskeyInfo[] = [];
+  private destroy$ = new Subject<void>();
+
+  constructor(
+    private msg: NzMessageService,
+    private modal: NzModalService,
+    private cdr: ChangeDetectorRef,
+    private http: HttpClient,
+    private settingsService: SettingsService
+  ) {}
+
+  ngOnInit(): void {
+    this.loadPasskeys();
+  }
+
+  ngOnDestroy(): void {
+    this.destroy$.next();
+    this.destroy$.complete();
+  }
+
+  loadPasskeys(): void {
+    const userId = this.getCurrentUserId();
+    if (!userId) {
+      this.msg.error('无法获取当前用户ID,请重新登录');
+      return;
+    }
+
+    this.loading = true;
+    this.http.get<ApiResponse<PasskeyInfo[]>>(`/passkey/registration/list/${userId}`)
+      .pipe(
+        takeUntil(this.destroy$),
+        finalize(() => {
+          this.loading = false;
+          this.cdr.detectChanges();
+        })
+      )
+      .subscribe({
+        next: (response) => {
+          if (response.code === 0) {
+            this.passkeyList = response.data || [];
+          } else {
+            this.passkeyList = [];
+            this.msg.warning(response.message || '获取Passkey列表失败');
+          }
+        },
+        error: (error: HttpErrorResponse) => {
+          console.error('加载Passkey列表失败:', error);
+          this.passkeyList = [];
+          this.handleHttpError(error, '加载Passkey列表失败');
+        }
+      });
+  }
+
+  async registerPasskey(): Promise<void> {
+    console.log('=== PASSKEY REGISTRATION DEBUG START ===');
+    console.log('Passkey registration clicked at:', new Date().toISOString());
+    
+    const userId = this.getCurrentUserId();
+    console.log('Current user ID:', userId);
+    
+    if (!userId) {
+      console.error('No user ID available');
+      this.msg.error('无法获取当前用户ID,请重新登录');
+      return;
+    }
+
+    // 检查浏览器是否支持 WebAuthn
+    if (!this.isWebAuthnSupported()) {
+      console.error('WebAuthn not supported');
+      this.msg.error('您的浏览器不支持 WebAuthn/Passkey 功能');
+      return;
+    }
+    console.log('WebAuthn support confirmed');
+
+    if (this.loading) {
+      console.log('Registration already in progress, ignoring click');
+      return; // 防止重复点击
+    }
+
+    try {
+      this.loading = true;
+      this.cdr.detectChanges();
+
+      const currentUser = this.settingsService.user as UserInfo;
+      console.log('Current user info:', {
+        userId: currentUser?.userId,
+        username: currentUser?.username,
+        displayName: currentUser?.displayName
+      });
+      
+      // 调用后端 API 获取注册选项
+      console.log('Step 1: Requesting registration options from backend...');
+      const registrationRequest = {
+        userId: userId,
+        username: currentUser?.username || 'unknown_user',
+        displayName: currentUser?.displayName || '未知用户'
+      };
+      console.log('Registration request payload:', registrationRequest);
+      
+      const beginResponse = await this.http.post<ApiResponse>('/passkey/registration/begin', registrationRequest).toPromise();
+      console.log('Backend registration options response:', beginResponse);
+      
+      if (!beginResponse || beginResponse.code !== 0) {
+        console.error('Failed to get registration options:', beginResponse);
+        throw new Error(beginResponse?.message || '获取注册选项失败');
+      }
+      
+      const regOptions = beginResponse.data;
+      console.log('Registration options received:', regOptions);
+      
+      if (!regOptions) {
+        console.error('Empty registration options');
+        throw new Error('注册选项为空');
+      }
+
+      // 转换Base64字符串为ArrayBuffer
+      console.log('Step 2: Converting registration options...');
+      console.log('Original registration options:', {
+        challengeLength: regOptions.challenge?.length,
+        userIdLength: regOptions.user?.id?.length,
+        excludeCredentialsCount: regOptions.excludeCredentials?.length || 0,
+        timeout: regOptions.timeout,
+        rpId: regOptions.rp?.id,
+        rpName: regOptions.rp?.name
+      });
+      
+      const convertedOptions = this.convertRegistrationOptions(regOptions);
+      console.log('Converted registration options:', {
+        challengeLength: convertedOptions.challenge.byteLength,
+        userIdLength: convertedOptions.user.id.byteLength,
+        userName: convertedOptions.user.name,
+        userDisplayName: convertedOptions.user.displayName,
+        excludeCredentialsCount: convertedOptions.excludeCredentials?.length || 0,
+        timeout: convertedOptions.timeout,
+        rpId: convertedOptions.rp.id,
+        rpName: convertedOptions.rp.name,
+        pubKeyCredParamsCount: convertedOptions.pubKeyCredParams?.length || 0
+      });
+
+      // 调用 WebAuthn API 进行注册
+      console.log('Step 3: Calling WebAuthn API navigator.credentials.create()...');
+      const credential = await navigator.credentials.create({
+        publicKey: convertedOptions
+      }) as PublicKeyCredential;
+
+      if (!credential) {
+        console.error('No credential returned from WebAuthn API');
+        throw new Error('凭证创建失败');
+      }
+      
+      console.log('=== REGISTRATION CREDENTIAL DEBUG INFO ===');
+      console.log('Credential ID:', credential.id);
+      console.log('Credential ID length:', credential.id.length);
+      console.log('Credential type:', credential.type);
+      console.log('Credential rawId length:', credential.rawId.byteLength);
+      console.log('Credential rawId as base64:', this.arrayBufferToBase64(credential.rawId));
+      
+      // 验证 credential.id 和 rawId 的一致性
+      const rawIdBase64 = this.arrayBufferToBase64(credential.rawId);
+      console.log('ID consistency check:');
+      console.log('  credential.id:', credential.id);
+      console.log('  rawId as base64:', rawIdBase64);
+      console.log('  IDs match:', credential.id === rawIdBase64);
+      
+      const credentialResponse = credential.response as AuthenticatorAttestationResponse;
+      console.log('Authenticator response type:', credentialResponse.constructor.name);
+      console.log('Attestation object length:', credentialResponse.attestationObject.byteLength);
+      console.log('Client data JSON length:', credentialResponse.clientDataJSON.byteLength);
+      console.log('=== END REGISTRATION CREDENTIAL DEBUG INFO ===');
+
+      // 将注册结果发送到后端保存
+      console.log('Step 4: Sending registration result to backend...');
+      const finishRequest = {
+        userId: userId,
+        challengeId: regOptions.challengeId,
+        credentialId: credential.id,
+        attestationObject: this.arrayBufferToBase64(credentialResponse.attestationObject),
+        clientDataJSON: this.arrayBufferToBase64(credentialResponse.clientDataJSON)
+      };
+      console.log('Registration finish request payload:', {
+        userId: finishRequest.userId,
+        challengeId: finishRequest.challengeId,
+        credentialId: finishRequest.credentialId,
+        credentialIdLength: finishRequest.credentialId.length,
+        attestationObjectLength: finishRequest.attestationObject.length,
+        clientDataJSONLength: finishRequest.clientDataJSON.length
+      });
+      
+      const finishResponse = await this.http.post<ApiResponse<PasskeyInfo>>('/passkey/registration/finish', finishRequest).toPromise();
+      console.log('Backend registration finish response:', finishResponse);
+      
+      if (!finishResponse || finishResponse.code !== 0) {
+        console.error('Backend registration verification failed:', finishResponse);
+        throw new Error(finishResponse?.message || 'Passkey注册失败');
+      }
+
+      const passkeyInfo = finishResponse.data;
+      console.log('Registration successful, passkey info:', passkeyInfo);
+      
+      if (passkeyInfo) {
+        this.msg.success(`Passkey 注册成功!`);
+        
+        // 添加新注册的Passkey到列表中
+        console.log('Adding new passkey to list, current list length:', this.passkeyList.length);
+        this.passkeyList.unshift(passkeyInfo);
+        console.log('New list length:', this.passkeyList.length);
+        this.cdr.detectChanges();
+        console.log('=== PASSKEY REGISTRATION SUCCESS ===');
+      }
+    } catch (error: any) {
+      console.error('=== PASSKEY REGISTRATION ERROR ===');
+      console.error('Error type:', error.constructor.name);
+      console.error('Error message:', error.message);
+      console.error('Error stack:', error.stack);
+      console.error('Full error object:', error);
+      
+      // 如果是 WebAuthn 相关错误,提供更详细的信息
+      if (error.name) {
+        console.error('WebAuthn error name:', error.name);
+        switch (error.name) {
+          case 'NotAllowedError':
+            console.error('User cancelled the operation or timeout occurred');
+            break;
+          case 'SecurityError':
+            console.error('Security error - invalid domain or HTTPS required');
+            break;
+          case 'NotSupportedError':
+            console.error('Operation not supported by authenticator');
+            break;
+          case 'InvalidStateError':
+            console.error('Authenticator is in invalid state');
+            break;
+          case 'ConstraintError':
+            console.error('Constraint error in authenticator');
+            break;
+          case 'NotReadableError':
+            console.error('Authenticator data not readable');
+            break;
+          default:
+            console.error('Unknown WebAuthn error');
+        }
+      }
+      
+      console.error('Passkey 注册失败:', error);
+      this.handlePasskeyError(error);
+      console.log('=== PASSKEY REGISTRATION DEBUG END ===');
+    } finally {
+      this.loading = false;
+      this.cdr.detectChanges();
+      console.log('Registration loading state reset');
+    }
+  }
+
+  deletePasskey(credentialId: string): void {
+    if (!credentialId) {
+      this.msg.error('凭证ID无效');
+      return;
+    }
+
+    const userId = this.getCurrentUserId();
+    if (!userId) {
+      this.msg.error('无法获取当前用户ID,请重新登录');
+      return;
+    }
+
+    this.http.delete<ApiResponse>(`/passkey/registration/delete/${userId}/${credentialId}`)
+      .pipe(takeUntil(this.destroy$))
+      .subscribe({
+        next: (response) => {
+          if (response.code === 0) {
+            this.msg.success('Passkey 删除成功');
+            // 从本地列表中移除,避免重新加载
+            this.passkeyList = this.passkeyList.filter(item => item.credentialId !== credentialId);
+            this.cdr.detectChanges();
+          } else {
+            this.msg.error(response.message || 'Passkey 删除失败');
+          }
+        },
+        error: (error: HttpErrorResponse) => {
+          console.error('删除Passkey失败:', error);
+          this.handleHttpError(error, 'Passkey 删除失败');
+        }
+      });
+  }
+
+  confirmDeletePasskey(credentialId: string): void {
+    this.modal.confirm({
+      nzTitle: '确认删除',
+      nzContent: '确定要删除这个 Passkey 吗?此操作不可撤销。',
+      nzOkText: '删除',
+      nzOkType: 'primary',
+      nzOkDanger: true,
+      nzCancelText: '取消',
+      nzOnOk: () => {
+        this.deletePasskey(credentialId);
+      }
+    });
+  }
+
+  /**
+   * 获取当前用户ID
+   */
+  private getCurrentUserId(): string | null {
+    const currentUser = this.settingsService.user as UserInfo;
+    return currentUser?.userId || currentUser?.id || null;
+  }
+
+  /**
+   * 检查浏览器是否支持WebAuthn
+   */
+  private isWebAuthnSupported(): boolean {
+    return !!(window.PublicKeyCredential && navigator.credentials && navigator.credentials.create);
+  }
+
+  /**
+   * 转换注册选项中的Base64字符串为ArrayBuffer
+   */
+  private convertRegistrationOptions(regOptions: any): PublicKeyCredentialCreationOptions {
+    return {
+      ...regOptions,
+      challenge: this.base64ToArrayBuffer(regOptions.challenge),
+      user: {
+        ...regOptions.user,
+        id: this.base64ToArrayBuffer(regOptions.user.id)
+      },
+      excludeCredentials: regOptions.excludeCredentials?.map((cred: any) => ({
+        ...cred,
+        id: this.base64ToArrayBuffer(cred.id)
+      })) || []
+    };
+  }
+
+  /**
+   * 处理Passkey相关错误
+   */
+  private handlePasskeyError(error: any): void {
+    if (error.name === 'NotAllowedError') {
+      this.msg.error('Passkey 注册被取消或失败');
+    } else if (error.name === 'NotSupportedError') {
+      this.msg.error('您的设备不支持 Passkey 功能');
+    } else if (error.name === 'SecurityError') {
+      this.msg.error('安全错误,请检查HTTPS连接');
+    } else if (error.name === 'InvalidStateError') {
+      this.msg.error('设备状态无效,请重试');
+    } else {
+      this.msg.error(error.message || 'Passkey 注册失败,请重试');
+    }
+  }
+
+  /**
+   * 处理HTTP错误
+   */
+  private handleHttpError(error: HttpErrorResponse, defaultMessage: string): void {
+    if (error.status === 401) {
+      this.msg.error('认证失败,请重新登录');
+    } else if (error.status === 403) {
+      this.msg.error('权限不足');
+    } else if (error.status === 404) {
+      this.msg.error('接口不存在');
+    } else if (error.status >= 500) {
+      this.msg.error('服务器错误,请稍后重试');
+    } else {
+      this.msg.error(defaultMessage);
+    }
+  }
+
+
+
+  /**
+   * 将Base64URL字符串转换为ArrayBuffer
+   */
+  private base64ToArrayBuffer(base64: string): ArrayBuffer {
+    try {
+      // 将Base64URL转换为标准Base64
+      let normalizedBase64 = base64
+        .replace(/-/g, '+')  // 替换 - 为 +
+        .replace(/_/g, '/'); // 替换 _ 为 /
+      
+      // 添加必要的填充
+      const padded = normalizedBase64 + '='.repeat((4 - normalizedBase64.length % 4) % 4);
+      const binaryString = atob(padded);
+      const bytes = new Uint8Array(binaryString.length);
+      for (let i = 0; i < binaryString.length; i++) {
+        bytes[i] = binaryString.charCodeAt(i);
+      }
+      return bytes.buffer;
+    } catch (error) {
+      console.error('Base64解码失败:', error);
+      throw new Error('Base64解码失败');
+    }
+  }
+
+  /**
+   * 将ArrayBuffer转换为Base64URL字符串(WebAuthn标准格式)
+   */
+  private arrayBufferToBase64(buffer: ArrayBuffer): string {
+    try {
+      const bytes = new Uint8Array(buffer);
+      let binary = '';
+      for (let i = 0; i < bytes.byteLength; i++) {
+        binary += String.fromCharCode(bytes[i]);
+      }
+      // 转换为标准Base64,然后转换为Base64URL格式
+      return btoa(binary)
+        .replace(/\+/g, '-')  // 替换 + 为 -
+        .replace(/\//g, '_')  // 替换 / 为 _
+        .replace(/=/g, '');   // 移除填充字符 =
+    } catch (error) {
+      console.error('ArrayBuffer编码失败:', error);
+      throw new Error('ArrayBuffer编码失败');
+    }
+  }
+}

+ 6 - 0
maxkey-web-frontend/maxkey-web-app/src/app/routes/passport/login/login.component.html

@@ -101,6 +101,12 @@
       {{ 'app.login.login' | i18n }}
     </button>
   </nz-form-item>
+  <nz-form-item *ngIf="loginType == 'normal'">
+      <button nz-button type="button" nzType="default" nzSize="large" (click)="passkeyLogin()" nzBlock>
+        <i nz-icon nzType="safety-certificate" nzTheme="outline"></i>
+        {{ 'mxk.login.passkey-login' | i18n }}
+      </button>
+    </nz-form-item>
 </form>
 <div class="other" *ngIf="loginType == 'normal'">
   {{ 'app.login.sign-in-with' | i18n }}

+ 279 - 1
maxkey-web-frontend/maxkey-web-app/src/app/routes/passport/login/login.component.ts

@@ -77,7 +77,8 @@ export class UserLoginComponent implements OnInit, OnDestroy {
     private reuseTabService: ReuseTabService,
     private route: ActivatedRoute,
     private msg: NzMessageService,
-    private cdr: ChangeDetectorRef
+    private cdr: ChangeDetectorRef,
+    private http: _HttpClient
   ) {
     this.form = fb.group({
       userName: [null, [Validators.required]],
@@ -500,4 +501,281 @@ export class UserLoginComponent implements OnInit, OnDestroy {
       this.cdr.detectChanges();
     }, 2000);
   }
+
+  /**
+   * Passkey 无用户名登录
+   */
+  async passkeyLogin(): Promise<void> {
+    console.log('=== PASSKEY LOGIN DEBUG START ===');
+    console.log('Passkey usernameless login clicked at:', new Date().toISOString());
+    
+    try {
+      // 检查浏览器是否支持 WebAuthn
+      if (!window.PublicKeyCredential) {
+        console.error('WebAuthn not supported');
+        this.msg.error('您的浏览器不支持 WebAuthn/Passkey 功能');
+        return;
+      }
+      console.log('WebAuthn support confirmed');
+
+      this.loading = true;
+      this.cdr.detectChanges();
+      
+      // 1. 调用后端 API 获取认证选项(不传递任何用户信息)
+      console.log('Step 1: Requesting authentication options from backend...');
+      let authOptionsResponse;
+      try {
+        authOptionsResponse = await this.http.post<any>('/passkey/authentication/begin?_allow_anonymous=true', {}).toPromise();
+      } catch (httpError: any) {
+        console.error('HTTP error occurred:', httpError);
+        // 处理HTTP错误,提取错误信息
+        let errorMessage = '获取认证选项失败';
+        if (httpError.error && httpError.error.message) {
+          errorMessage = httpError.error.message;
+        } else if (httpError.message) {
+          errorMessage = httpError.message;
+        }
+        
+        // 检查是否是没有注册 Passkey 的错误
+        if (errorMessage.includes('没有注册任何 Passkey') || 
+            errorMessage.includes('No Passkeys registered') ||
+            errorMessage.includes('还没有注册任何 Passkey') ||
+            errorMessage.includes('系统中还没有注册任何 Passkey')) {
+          // 直接显示友好提示并返回,不抛出错误避免被全局拦截器捕获
+          this.msg.warning('还未注册 Passkey,请注册 Passkey');
+          console.log('=== PASSKEY LOGIN DEBUG END ===');
+          return;
+        }
+        throw new Error(errorMessage);
+      }
+      
+      console.log('Backend auth options response:', authOptionsResponse);
+      
+      if (!authOptionsResponse || authOptionsResponse.code !== 0) {
+        console.error('Failed to get auth options:', authOptionsResponse);
+        // 检查是否是没有注册 Passkey 的错误
+        const errorMessage = authOptionsResponse?.message || '获取认证选项失败';
+        if (errorMessage.includes('没有注册任何 Passkey') || 
+            errorMessage.includes('No Passkeys registered') ||
+            errorMessage.includes('还没有注册任何 Passkey') ||
+            errorMessage.includes('系统中还没有注册任何 Passkey')) {
+          // 直接显示友好提示并返回,不抛出错误避免被全局拦截器捕获
+          this.msg.warning('还未注册 Passkey,请注册 Passkey');
+          console.log('=== PASSKEY LOGIN DEBUG END ===');
+          return;
+        }
+        throw new Error(errorMessage);
+      }
+      
+      const authOptions = authOptionsResponse.data;
+      console.log('Auth options received:', authOptions);
+      
+      // 检查返回的数据是否有效
+      if (!authOptions || !authOptions.challenge) {
+        console.error('Invalid auth options:', authOptions);
+        throw new Error('服务器返回的认证选项无效');
+      }
+      
+      // 2. 转换认证选项格式
+      console.log('Step 2: Converting authentication options...');
+      const convertedOptions: PublicKeyCredentialRequestOptions = {
+        challenge: this.base64ToArrayBuffer(authOptions.challenge),
+        timeout: authOptions.timeout || 60000,
+        rpId: authOptions.rpId,
+        userVerification: authOptions.userVerification || 'preferred'
+        // 注意:不设置 allowCredentials,让认证器自动选择可用的凭据
+      };
+      console.log('Converted options:', {
+        challengeLength: convertedOptions.challenge.byteLength,
+        timeout: convertedOptions.timeout,
+        rpId: convertedOptions.rpId,
+        userVerification: convertedOptions.userVerification,
+        allowCredentials: convertedOptions.allowCredentials || 'undefined (auto-select)'
+      });
+
+      // 3. 调用 WebAuthn API 进行认证
+      console.log('Step 3: Calling WebAuthn API navigator.credentials.get()...');
+      console.log('Available authenticators will be queried automatically');
+      
+      const credential = await navigator.credentials.get({
+        publicKey: convertedOptions
+      }) as PublicKeyCredential;
+
+      if (!credential) {
+        console.error('No credential returned from WebAuthn API');
+        throw new Error('认证失败');
+      }
+      
+      console.log('=== CREDENTIAL DEBUG INFO ===');
+      console.log('Credential ID:', credential.id);
+      console.log('Credential ID length:', credential.id.length);
+      console.log('Credential type:', credential.type);
+      console.log('Credential rawId length:', credential.rawId.byteLength);
+      console.log('Credential rawId as base64:', this.arrayBufferToBase64(credential.rawId));
+      
+      // 验证 credential.id 和 rawId 的一致性
+      const rawIdBase64 = this.arrayBufferToBase64(credential.rawId);
+      console.log('ID consistency check:');
+      console.log('  credential.id:', credential.id);
+      console.log('  rawId as base64:', rawIdBase64);
+      console.log('  IDs match:', credential.id === rawIdBase64);
+      
+      const credentialResponse = credential.response as AuthenticatorAssertionResponse;
+      console.log('Authenticator response type:', credentialResponse.constructor.name);
+      console.log('User handle:', credentialResponse.userHandle ? this.arrayBufferToBase64(credentialResponse.userHandle) : 'null');
+      console.log('=== END CREDENTIAL DEBUG INFO ===');
+
+      // 4. 将认证结果发送到后端验证
+      console.log('Step 4: Sending credential to backend for verification...');
+      const requestPayload = {
+        challengeId: authOptions.challengeId,
+        credentialId: credential.id,
+        authenticatorData: this.arrayBufferToBase64(credentialResponse.authenticatorData),
+        clientDataJSON: this.arrayBufferToBase64(credentialResponse.clientDataJSON),
+        signature: this.arrayBufferToBase64(credentialResponse.signature),
+        userHandle: credentialResponse.userHandle ? this.arrayBufferToBase64(credentialResponse.userHandle) : null
+      };
+      console.log('Request payload to backend:', {
+        challengeId: requestPayload.challengeId,
+        credentialId: requestPayload.credentialId,
+        credentialIdLength: requestPayload.credentialId.length,
+        authenticatorDataLength: requestPayload.authenticatorData.length,
+        clientDataJSONLength: requestPayload.clientDataJSON.length,
+        signatureLength: requestPayload.signature.length,
+        userHandle: requestPayload.userHandle
+      });
+      
+      const finishResponse = await this.http.post<any>('/passkey/authentication/finish?_allow_anonymous=true', requestPayload).toPromise();
+      console.log('Backend finish response:', finishResponse);
+      
+      if (!finishResponse || finishResponse.code !== 0) {
+        console.error('Backend verification failed:', finishResponse);
+        throw new Error(finishResponse?.message || 'Passkey认证失败');
+      }
+
+      // 5. 认证成功,设置用户信息并跳转
+      console.log('Step 5: Authentication successful, setting user info...');
+      const authResult = finishResponse.data;
+      console.log('Auth result received:', authResult);
+      
+      this.msg.success(`Passkey 登录成功!欢迎 ${authResult.username || '用户'}`);
+      
+      // 清空路由复用信息
+      console.log('Clearing reuse tab service...');
+      this.reuseTabService.clear();
+      
+      // 设置用户Token信息
+      if (authResult && authResult.userId) {
+        console.log('Valid auth result with userId:', authResult.userId);
+        // 构建完整的认证信息对象,包含 SimpleGuard 所需的 token 和 ticket
+        const userInfo = {
+          id: authResult.userId,
+          userId: authResult.userId,
+          username: authResult.username,
+          displayName: authResult.displayName || authResult.username,
+          email: authResult.email || '',
+          authTime: authResult.authTime,
+          authType: 'passkey',
+          // 关键:包含认证所需的 token 和 ticket
+          token: authResult.token || authResult.congress || '',
+          ticket: authResult.ticket || authResult.onlineTicket || '',
+          // 其他可能需要的字段
+          remeberMe: false,
+          passwordSetType: authResult.passwordSetType || 'normal',
+          authorities: authResult.authorities || []
+        };
+        
+        console.log('Setting auth info:', userInfo);
+        
+        // 设置认证信息
+        this.authnService.auth(userInfo);
+        
+        // 使用 navigate 方法进行跳转,它会处理 StartupService 的重新加载
+        console.log('Navigating with auth result...');
+        this.authnService.navigate(authResult);
+        console.log('=== PASSKEY LOGIN SUCCESS ===');
+      } else {
+        console.error('Invalid auth result - missing userId:', authResult);
+        throw new Error('认证成功但用户数据无效');
+      }
+      
+    } catch (error: any) {
+      console.error('=== PASSKEY LOGIN ERROR ===');
+      console.error('Error type:', error.constructor.name);
+      console.error('Error message:', error.message);
+      console.error('Error stack:', error.stack);
+      console.error('Full error object:', error);
+      
+      // 检查是否是没有注册 Passkey 的错误
+      if (error.message && (error.message.includes('PASSKEY_NOT_REGISTERED') ||
+                           error.message.includes('没有找到可用的凭据') || 
+                           error.message.includes('No credentials available') ||
+                           error.message.includes('用户未注册') ||
+                           error.message.includes('credential not found') ||
+                           error.message.includes('没有注册任何 Passkey') ||
+                           error.message.includes('No Passkeys registered') ||
+                           error.message.includes('还没有注册任何 Passkey'))) {
+        this.msg.warning('还未注册 Passkey,请注册 Passkey');
+        console.log('=== PASSKEY LOGIN DEBUG END ===');
+        return;
+      }
+      
+      // 如果是 WebAuthn 相关错误,提供更详细的信息
+      if (error.name) {
+        console.error('WebAuthn error name:', error.name);
+        switch (error.name) {
+          case 'NotAllowedError':
+            console.error('User cancelled the operation or timeout occurred');
+            // 检查是否是因为没有可用凭据导致的取消
+            this.msg.warning('Passkey 登录已取消。如果您还没有注册 Passkey,请先注册后再使用');
+            break;
+          case 'SecurityError':
+            console.error('Security error - invalid domain or HTTPS required');
+            this.msg.error('安全错误:请确保在 HTTPS 环境下使用 Passkey 功能');
+            break;
+          case 'NotSupportedError':
+            console.error('Operation not supported by authenticator');
+            this.msg.error('您的设备不支持 Passkey 功能');
+            break;
+          case 'InvalidStateError':
+            console.error('Authenticator is in invalid state');
+            this.msg.error('认证器状态异常,请重试');
+            break;
+          case 'ConstraintError':
+            console.error('Constraint error in authenticator');
+            this.msg.error('认证器约束错误,请重试');
+            break;
+          default:
+            console.error('Unknown WebAuthn error');
+            this.msg.error('Passkey 登录失败:' + (error.message || '请重试或使用其他登录方式'));
+        }
+      } else {
+        this.msg.error('Passkey 登录失败:' + (error.message || '请重试或使用其他登录方式'));
+      }
+      console.log('=== PASSKEY LOGIN DEBUG END ===');
+    } finally {
+      this.loading = false;
+      this.cdr.detectChanges();
+      console.log('Login loading state reset');
+    }
+  }
+
+  // 添加辅助方法
+  private base64ToArrayBuffer(base64: string): ArrayBuffer {
+    const binaryString = atob(base64.replace(/-/g, '+').replace(/_/g, '/'));
+    const bytes = new Uint8Array(binaryString.length);
+    for (let i = 0; i < binaryString.length; i++) {
+      bytes[i] = binaryString.charCodeAt(i);
+    }
+    return bytes.buffer;
+  }
+
+  private arrayBufferToBase64(buffer: ArrayBuffer): string {
+    const bytes = new Uint8Array(buffer);
+    let binary = '';
+    for (let i = 0; i < bytes.byteLength; i++) {
+      binary += String.fromCharCode(bytes[i]);
+    }
+    return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
+  }
 }

+ 8 - 0
maxkey-web-frontend/maxkey-web-app/src/assets/app-data.json

@@ -44,6 +44,14 @@
               "acl": "ROLE_USER",
               "children": []
             },
+            {
+              "text": "Passkey 注册",
+              "i18n": "mxk.menu.config.passkey",
+              "link": "/config/passkey",
+              "icon": "anticon-safety-certificate",
+              "acl": "ROLE_USER",
+              "children": []
+            },
              {
               "text": "二次认证",
               "i18n": "mxk.menu.config.mfa",

+ 3 - 0
maxkey-web-frontend/maxkey-web-app/src/assets/i18n/en-US.json

@@ -16,6 +16,8 @@
 			"signup": "Sign up",
 			"login": "Login",
       "twoFactor": "2-Factor Authentication",
+			"passkey-login": "Passkey Login",
+			"passkey-register": "Register Passkey",
 			"text.username": "Username",
 			"text.mobile": "Mobile Number",
 			"text.password": "Password",
@@ -45,6 +47,7 @@
 				"": "Settings",
 				"setting": "Setting",
 				"profile": "Profile",
+				"passkey": "Passkey Registration",
 				"mfa": "MFA",
 				"password": "Password",
 				"socialsassociate": "Socials",

+ 3 - 0
maxkey-web-frontend/maxkey-web-app/src/assets/i18n/zh-CN.json

@@ -16,6 +16,8 @@
 			"signup": "用户注册",
 			"login": "登录",
       "twoFactor": "二次身份认证",
+			"passkey-login": "Passkey登录",
+			"passkey-register": "注册Passkey",
 			"text.username": "用户名",
 			"text.mobile": "手机号码",
 			"text.password": "密码",
@@ -45,6 +47,7 @@
 				"": "配置",
 				"setting": "基本设置",
 				"profile": "我的资料",
+				"passkey": "Passkey 注册",
 				"mfa": "二次认证",
 				"socialsassociate": "社交关联",
 				"password": "密码修改",

+ 20 - 0
maxkey-web-frontend/maxkey-web-mgt-app/src/app/routes/dashboard/home/home.component.html

@@ -33,6 +33,26 @@
   <div nz-col nzXs="24" nzSm="12" nzMd="6" class="mb-md">
     <div nz-row nzType="flex" nzAlign="middle" class="bg-success rounded-md">
       <div nz-col nzSpan="12" class="p-md text-white">
+        <div class="h2 mt0">{{ dayCount }}</div>
+        <p class="text-nowrap mb0">{{ 'mxk.home.dayCount' | i18n }}</p>
+      </div>
+      <div nz-col nzSpan="12">
+        <g2-mini-bar
+          *ngIf="simulateData"
+          height="35"
+          color="#fff"
+          borderWidth="3"
+          [padding]="[5, 30]"
+          [data]="simulateData"
+          tooltipType="mini"
+          (ready)="fixDark($event)"
+        ></g2-mini-bar>
+      </div>
+    </div>
+  </div>
+  <div nz-col nzXs="24" nzSm="12" nzMd="6" class="mb-md">
+    <div nz-row nzType="flex" nzAlign="middle" class="bg-orange rounded-md">
+      <div nz-col nzSpan="12" class="p-md text-white">
         <div class="h2 mt0">{{ newUsers }}</div>
         <p class="text-nowrap mb0">{{ 'mxk.home.newUsers' | i18n }}</p>
       </div>

+ 8 - 0
maxkey-webs/maxkey-web-maxkey/build.gradle

@@ -48,6 +48,7 @@ dependencies {
    	implementation project(":maxkey-starter:maxkey-starter-captcha")
    	implementation project(":maxkey-starter:maxkey-starter-ip2location")
 	implementation project(":maxkey-starter:maxkey-starter-otp")
+	implementation project(":maxkey-starter:maxkey-starter-passkey")
 	implementation project(":maxkey-starter:maxkey-starter-sms")
 	implementation project(":maxkey-starter:maxkey-starter-social")
 	implementation project(":maxkey-starter:maxkey-starter-web")
@@ -64,4 +65,11 @@ dependencies {
    	implementation project(":maxkey-protocols:maxkey-protocol-oauth-2.0")
    	implementation project(":maxkey-protocols:maxkey-protocol-saml-2.0")
 	implementation project(":maxkey-protocols:maxkey-protocol-jwt")
+
+	// WebAuthn4J 完整依赖
+	implementation "com.webauthn4j:webauthn4j-core:${webauthn4jVersion}"
+	implementation "com.webauthn4j:webauthn4j-util:${webauthn4jVersion}"
+	implementation 'com.fasterxml.jackson.core:jackson-databind'
+	implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-cbor'
+	implementation 'commons-codec:commons-codec'
 }

+ 7 - 0
maxkey-webs/maxkey-web-maxkey/src/main/resources/application.properties

@@ -29,3 +29,10 @@ spring.main.banner-mode                     =log
 ############################################################################
 spring.profiles.active                      =${SERVER_PROFILES:maxkey}
 
+############################################################################
+# Passkey Configuration                                                     #
+############################################################################
+maxkey.passkey.enabled=true
+maxkey.passkey.relying-party.name=MaxKey
+maxkey.passkey.relying-party.id=localhost
+maxkey.passkey.relying-party.allowed-origins=http://localhost:8527,http://localhost:8080