diff --git a/maxkey-starter/maxkey-starter-passkey/src/main/java/org/dromara/maxkey/passkey/config/PasskeyProperties.java b/maxkey-starter/maxkey-starter-passkey/src/main/java/org/dromara/maxkey/passkey/config/PasskeyProperties.java index 451f2c7f6..2964403db 100644 --- a/maxkey-starter/maxkey-starter-passkey/src/main/java/org/dromara/maxkey/passkey/config/PasskeyProperties.java +++ b/maxkey-starter/maxkey-starter-passkey/src/main/java/org/dromara/maxkey/passkey/config/PasskeyProperties.java @@ -21,7 +21,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties; /** * Passkey配置属性 */ -@ConfigurationProperties(prefix = "maxkey.passkey") +@ConfigurationProperties(prefix = "maxkey.login.passkey") public class PasskeyProperties { /** 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 a9ea05b56..7f51002de 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,12 +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 90bd7a7eb..805250b17 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 @@ -56,6 +56,8 @@ export class UserLoginComponent implements OnInit, OnDestroy { loading = false; passwordVisible = false; qrexpire = false; + passkeyEnabled = false; + passkeyAllowedOrigins = []; imageCaptcha = ''; captchaType = ''; state = ''; @@ -136,6 +138,25 @@ export class UserLoginComponent implements OnInit, OnDestroy { this.socials = res.data.socials; this.state = res.data.state; this.captchaType = res.data.captcha; + this.passkeyEnabled = res.data.passkeyEnabled; + this.passkeyAllowedOrigins = res.data.passkeyAllowedOrigins; + let passkeyAllowedOriginsMatch = false; + for (let allowedOrigin of this.passkeyAllowedOrigins) { + console.log(`passkey allowedOrigin ${allowedOrigin}`); + console.log(`location ${window.location.href}`); + if ( + window.location.href.startsWith('http://localhost') || + (window.location.href.startsWith('https') && window.location.href.indexOf(allowedOrigin) > -1) + ) { + console.log(window.location.href.indexOf(allowedOrigin) > -1); + passkeyAllowedOriginsMatch = true; + } + } + if (window.PublicKeyCredential && this.passkeyEnabled && passkeyAllowedOriginsMatch) { + this.passkeyEnabled = true; + } else { + this.passkeyEnabled = false; + } if (this.captchaType === 'NONE') { //清除校验规则 this.form.get('captcha')?.clearValidators(); @@ -508,7 +529,7 @@ export class UserLoginComponent implements OnInit, OnDestroy { async passkeyLogin(): Promise { console.log('=== PASSKEY LOGIN DEBUG START ==='); console.log('Passkey usernameless login clicked at:', new Date().toISOString()); - + try { // 检查浏览器是否支持 WebAuthn if (!window.PublicKeyCredential) { @@ -520,7 +541,7 @@ export class UserLoginComponent implements OnInit, OnDestroy { this.loading = true; this.cdr.detectChanges(); - + // 1. 调用后端 API 获取认证选项(不传递任何用户信息) console.log('Step 1: Requesting authentication options from backend...'); let authOptionsResponse; @@ -535,12 +556,14 @@ export class UserLoginComponent implements OnInit, OnDestroy { } else if (httpError.message) { errorMessage = httpError.message; } - + // 检查是否是没有注册 Passkey 的错误 - if (errorMessage.includes('没有注册任何 Passkey') || - errorMessage.includes('No Passkeys registered') || - errorMessage.includes('还没有注册任何 Passkey') || - errorMessage.includes('系统中还没有注册任何 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 ==='); @@ -548,17 +571,19 @@ export class UserLoginComponent implements OnInit, OnDestroy { } 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')) { + 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 ==='); @@ -566,16 +591,16 @@ export class UserLoginComponent implements OnInit, OnDestroy { } 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 = { @@ -596,30 +621,30 @@ export class UserLoginComponent implements OnInit, OnDestroy { // 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({ + + const credential = (await navigator.credentials.get({ publicKey: convertedOptions - }) as PublicKeyCredential; + })) 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'); @@ -644,10 +669,10 @@ export class UserLoginComponent implements OnInit, OnDestroy { 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认证失败'); @@ -657,13 +682,13 @@ export class UserLoginComponent implements OnInit, OnDestroy { 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); @@ -684,12 +709,12 @@ export class UserLoginComponent implements OnInit, OnDestroy { 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); @@ -698,28 +723,30 @@ export class UserLoginComponent implements OnInit, OnDestroy { 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'))) { + 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); @@ -747,10 +774,10 @@ export class UserLoginComponent implements OnInit, OnDestroy { break; default: console.error('Unknown WebAuthn error'); - this.msg.error('Passkey 登录失败:' + (error.message || '请重试或使用其他登录方式')); + this.msg.error(`Passkey 登录失败:${error.message || '请重试或使用其他登录方式'}`); } } else { - this.msg.error('Passkey 登录失败:' + (error.message || '请重试或使用其他登录方式')); + this.msg.error(`Passkey 登录失败:${error.message || '请重试或使用其他登录方式'}`); } console.log('=== PASSKEY LOGIN DEBUG END ==='); } finally { diff --git a/maxkey-web-frontend/maxkey-web-app/src/style-icons-auto.ts b/maxkey-web-frontend/maxkey-web-app/src/style-icons-auto.ts index 06f54743a..9255d6d4d 100644 --- a/maxkey-web-frontend/maxkey-web-app/src/style-icons-auto.ts +++ b/maxkey-web-frontend/maxkey-web-app/src/style-icons-auto.ts @@ -107,6 +107,8 @@ import { FileProtectOutline, HistoryOutline, UserAddOutline, + SafetyCertificateOutline, + PlusCircleOutline, AuditOutline } from '@ant-design/icons-angular/icons'; import { QR_DEFULAT_CONFIG } from '@delon/abc/qr'; @@ -199,5 +201,7 @@ export const ICONS_AUTO = [ FileProtectOutline, HistoryOutline, UserAddOutline, + SafetyCertificateOutline, + PlusCircleOutline, AuditOutline ]; diff --git a/maxkey-webs/maxkey-web-maxkey/src/main/java/org/dromara/maxkey/web/contorller/LoginEntryPoint.java b/maxkey-webs/maxkey-web-maxkey/src/main/java/org/dromara/maxkey/web/contorller/LoginEntryPoint.java index a80d0ebcc..50ef20ce8 100644 --- a/maxkey-webs/maxkey-web-maxkey/src/main/java/org/dromara/maxkey/web/contorller/LoginEntryPoint.java +++ b/maxkey-webs/maxkey-web-maxkey/src/main/java/org/dromara/maxkey/web/contorller/LoginEntryPoint.java @@ -33,6 +33,7 @@ import org.dromara.maxkey.configuration.ApplicationConfig; import org.dromara.maxkey.constants.ConstsLoginType; import org.dromara.maxkey.entity.*; import org.dromara.maxkey.entity.idm.UserInfo; +import org.dromara.maxkey.passkey.config.PasskeyProperties; import org.dromara.maxkey.password.onetimepwd.AbstractOtpAuthn; import org.dromara.maxkey.password.sms.SmsOtpAuthnService; import org.dromara.maxkey.persistence.service.SocialsAssociatesService; @@ -72,6 +73,9 @@ public class LoginEntryPoint { @Autowired ApplicationConfig applicationConfig; + + @Autowired + PasskeyProperties passkeyProperties; @Autowired AbstractAuthenticationProvider authenticationProvider ; @@ -134,6 +138,8 @@ public class LoginEntryPoint { model.put("otpType", tfaOtpAuthn.getOtpType()); model.put("otpInterval", tfaOtpAuthn.getInterval()); } + model.put("passkeyEnabled", passkeyProperties.isEnabled()); + model.put("passkeyAllowedOrigins", passkeyProperties.getRelyingParty().getAllowedOrigins()); if( applicationConfig.getLoginConfig().isKerberos()){ model.put("userDomainUrlJson", kerberosService.buildKerberosProxys()); diff --git a/maxkey-webs/maxkey-web-maxkey/src/main/resources/application-maxkey.properties b/maxkey-webs/maxkey-web-maxkey/src/main/resources/application-maxkey.properties index b82dee6e0..cfeb3c0af 100644 --- a/maxkey-webs/maxkey-web-maxkey/src/main/resources/application-maxkey.properties +++ b/maxkey-webs/maxkey-web-maxkey/src/main/resources/application-maxkey.properties @@ -90,10 +90,10 @@ maxkey.notices.visible =false ############################################################################ # 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,http://localhost +maxkey.login.passkey.enabled=true +maxkey.login.passkey.relying-party.name=MaxKey +maxkey.login.passkey.relying-party.id=localhost +maxkey.login.passkey.relying-party.allowed-origins=http://localhost ############################################################################ #ssl configuration # ############################################################################