diff --git a/build.gradle b/build.gradle index e916407ee..42343b078 100644 --- a/build.gradle +++ b/build.gradle @@ -54,6 +54,7 @@ allprojects { targetCompatibility = 17 compileJava.options.encoding = 'UTF-8' + compileJava.options.compilerArgs += ['-parameters'] eclipse { /*设置工程字符集*/ jdt { diff --git a/maxkey-web-frontend/maxkey-web-app/package.json b/maxkey-web-frontend/maxkey-web-app/package.json index 5704425f8..ea8755d1c 100644 --- a/maxkey-web-frontend/maxkey-web-app/package.json +++ b/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 ", "repository": { diff --git a/maxkey-web-frontend/maxkey-web-app/src/app/routes/config/config.module.ts b/maxkey-web-frontend/maxkey-web-app/src/app/routes/config/config.module.ts index 57181d794..24794de8b 100644 --- a/maxkey-web-frontend/maxkey-web-app/src/app/routes/config/config.module.ts +++ b/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'; @@ -44,6 +50,10 @@ const routes: Routes = [ path: 'password', 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 {} diff --git a/maxkey-web-frontend/maxkey-web-app/src/app/routes/config/passkey/passkey.component.html b/maxkey-web-frontend/maxkey-web-app/src/app/routes/config/passkey/passkey.component.html new file mode 100644 index 000000000..127f5608b --- /dev/null +++ b/maxkey-web-frontend/maxkey-web-app/src/app/routes/config/passkey/passkey.component.html @@ -0,0 +1,58 @@ + + +
+
+
+

Passkey 是一种更安全、更便捷的登录方式,使用您的设备生物识别或 PIN 码进行身份验证。

+
+ +
+ +
+ + + + + + + + + 凭证信息 + 签名统计 + 创建时间 + 最近访问时间 + 操作 + + + + + +
+
{{ item.credentialId || item.id }}
+
{{ item.deviceType === 'platform' ? '平台认证器' : '跨平台认证器' }}
+
+ + {{ item.signatureCount || 0 }} + {{ item.createdDate | date:'yyyy-MM-dd HH:mm:ss' }} + {{ item.lastUsedDate | date:'yyyy-MM-dd HH:mm:ss' }} + + + + + +
+ + +
+
+
\ No newline at end of file diff --git a/maxkey-web-frontend/maxkey-web-app/src/app/routes/config/passkey/passkey.component.less b/maxkey-web-frontend/maxkey-web-app/src/app/routes/config/passkey/passkey.component.less new file mode 100644 index 000000000..9d942dd04 --- /dev/null +++ b/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; + } +} \ No newline at end of file diff --git a/maxkey-web-frontend/maxkey-web-app/src/app/routes/config/passkey/passkey.component.ts b/maxkey-web-frontend/maxkey-web-app/src/app/routes/config/passkey/passkey.component.ts new file mode 100644 index 000000000..05c7e209a --- /dev/null +++ b/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 { + 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(); + + 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>(`/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 { + 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('/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>('/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(`/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编码失败'); + } + } +} \ No newline at end of file diff --git a/maxkey-web-frontend/maxkey-web-app/src/app/routes/passport/login/login.component.html b/maxkey-web-frontend/maxkey-web-app/src/app/routes/passport/login/login.component.html index dad2175d9..a9ea05b56 100644 --- a/maxkey-web-frontend/maxkey-web-app/src/app/routes/passport/login/login.component.html +++ b/maxkey-web-frontend/maxkey-web-app/src/app/routes/passport/login/login.component.html @@ -101,6 +101,12 @@ {{ 'app.login.login' | i18n }} + + +
{{ 'app.login.sign-in-with' | i18n }} diff --git a/maxkey-web-frontend/maxkey-web-app/src/app/routes/passport/login/login.component.ts b/maxkey-web-frontend/maxkey-web-app/src/app/routes/passport/login/login.component.ts index 67f2c8c81..90bd7a7eb 100644 --- a/maxkey-web-frontend/maxkey-web-app/src/app/routes/passport/login/login.component.ts +++ b/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 { + 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('/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('/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, ''); + } } diff --git a/maxkey-web-frontend/maxkey-web-app/src/assets/app-data.json b/maxkey-web-frontend/maxkey-web-app/src/assets/app-data.json index b3f9e908d..4d9114c1b 100644 --- a/maxkey-web-frontend/maxkey-web-app/src/assets/app-data.json +++ b/maxkey-web-frontend/maxkey-web-app/src/assets/app-data.json @@ -43,6 +43,14 @@ "icon": "anticon-appstore", "acl": "ROLE_USER", "children": [] + }, + { + "text": "Passkey 注册", + "i18n": "mxk.menu.config.passkey", + "link": "/config/passkey", + "icon": "anticon-safety-certificate", + "acl": "ROLE_USER", + "children": [] }, { "text": "二次认证", diff --git a/maxkey-web-frontend/maxkey-web-app/src/assets/i18n/en-US.json b/maxkey-web-frontend/maxkey-web-app/src/assets/i18n/en-US.json index 894eb9f1b..ceca34a9f 100644 --- a/maxkey-web-frontend/maxkey-web-app/src/assets/i18n/en-US.json +++ b/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", diff --git a/maxkey-web-frontend/maxkey-web-app/src/assets/i18n/zh-CN.json b/maxkey-web-frontend/maxkey-web-app/src/assets/i18n/zh-CN.json index 02d7e16fd..29329154a 100644 --- a/maxkey-web-frontend/maxkey-web-app/src/assets/i18n/zh-CN.json +++ b/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": "密码修改", diff --git a/maxkey-web-frontend/maxkey-web-mgt-app/src/app/routes/dashboard/home/home.component.html b/maxkey-web-frontend/maxkey-web-mgt-app/src/app/routes/dashboard/home/home.component.html index 21989646f..32637b478 100644 --- a/maxkey-web-frontend/maxkey-web-mgt-app/src/app/routes/dashboard/home/home.component.html +++ b/maxkey-web-frontend/maxkey-web-mgt-app/src/app/routes/dashboard/home/home.component.html @@ -32,6 +32,26 @@
+
+
{{ dayCount }}
+

{{ 'mxk.home.dayCount' | i18n }}

+
+
+ +
+
+
+
+
{{ newUsers }}

{{ 'mxk.home.newUsers' | i18n }}

diff --git a/maxkey-webs/maxkey-web-maxkey/build.gradle b/maxkey-webs/maxkey-web-maxkey/build.gradle index 5bc874cc2..af59281fd 100644 --- a/maxkey-webs/maxkey-web-maxkey/build.gradle +++ b/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' } \ No newline at end of file diff --git a/maxkey-webs/maxkey-web-maxkey/src/main/resources/application.properties b/maxkey-webs/maxkey-web-maxkey/src/main/resources/application.properties index 1894d76ef..753f4028c 100644 --- a/maxkey-webs/maxkey-web-maxkey/src/main/resources/application.properties +++ b/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 \ No newline at end of file