|
|
@@ -0,0 +1,468 @@
|
|
|
+/*
|
|
|
+ * Copyright [2025] [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编码失败');
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|