chore: passkey登录优化

This commit is contained in:
xiaojunnuo
2026-03-13 15:31:03 +08:00
parent 12fed34e10
commit eae4f721e8
11 changed files with 136 additions and 109 deletions
@@ -101,4 +101,5 @@ export default {
registerPasskey: "Register Passkey", registerPasskey: "Register Passkey",
deviceName: "Device Name", deviceName: "Device Name",
deviceNameHelper: "Please enter the device name used to identify the device", deviceNameHelper: "Please enter the device name used to identify the device",
passkeyRegisterHelper: "Site domain change will invalidate passkey",
}; };
@@ -103,4 +103,5 @@ export default {
registerPasskey: "注册Passkey", registerPasskey: "注册Passkey",
deviceName: "设备名称", deviceName: "设备名称",
deviceNameHelper: "请输入当前设备名称,绑定多个时好做区分", deviceNameHelper: "请输入当前设备名称,绑定多个时好做区分",
passkeyRegisterHelper: "1、站点域名变更会导致passkey失效;\n2、同一设备同一个用户绑定多次只有最后一次的有效,之前绑定的会失效,需要手动删除",
}; };
@@ -115,23 +115,22 @@ export async function generatePasskeyRegistrationOptions() {
}); });
} }
export async function verifyPasskeyRegistration(userId: number, response: any, challenge: string) { export async function verifyPasskeyRegistration(response: any, challenge: string) {
return await request({ return await request({
url: "/passkey/verifyRegistration", url: "/passkey/verifyRegistration",
method: "post", method: "post",
data: { userId, response, challenge }, data: { response, challenge },
}); });
} }
export async function generatePasskeyAuthenticationOptions(userId: number) { export async function generatePasskeyAuthenticationOptions() {
return await request({ return await request({
url: "/passkey/generateAuthentication", url: "/passkey/generateAuthentication",
method: "post", method: "post",
data: { userId },
}); });
} }
export async function loginByPasskey(form: { userId: number; credential: any; challenge: string }) { export async function loginByPasskey(form: { credential: any; challenge: string }) {
return await request({ return await request({
url: "/loginByPasskey", url: "/loginByPasskey",
method: "post", method: "post",
@@ -139,7 +138,7 @@ export async function loginByPasskey(form: { userId: number; credential: any; ch
}); });
} }
export async function registerPasskey(form: { userId: number; response: any; challenge: string }) { export async function registerPasskey(form: { response: any; challenge: string }) {
return await request({ return await request({
url: "/passkey/register", url: "/passkey/register",
method: "post", method: "post",
@@ -295,7 +295,7 @@ h6 {
} }
.helper { .helper {
color: #aeaeae; color: #8f8f8f;
font-size: 12px; font-size: 12px;
margin-top: 3px; margin-top: 3px;
margin-bottom: 3px; margin-bottom: 3px;
@@ -58,14 +58,14 @@ export async function OauthBoundUrl(type: string) {
export async function GetPasskeys() { export async function GetPasskeys() {
return await request({ return await request({
url: "/mine/passkeys", url: "/mine/passkey/list",
method: "POST", method: "POST",
}); });
} }
export async function UnbindPasskey(id: number) { export async function UnbindPasskey(id: number) {
return await request({ return await request({
url: "/mine/unbindPasskey", url: "/mine/passkey/unbind",
method: "POST", method: "POST",
data: { id }, data: { id },
}); });
@@ -110,39 +110,23 @@ export interface PasskeyCredential {
export async function generatePasskeyRegistrationOptions() { export async function generatePasskeyRegistrationOptions() {
return await request({ return await request({
url: "/passkey/generateRegistration", url: "/mine/passkey/generateRegistration",
method: "post", method: "post",
}); });
} }
export async function verifyPasskeyRegistration(userId: number, response: any, challenge: string, deviceName: string) { export async function verifyPasskeyRegistration(response: any, challenge: string, deviceName: string) {
return await request({ return await request({
url: "/passkey/verifyRegistration", url: "/mine/passkey/verifyRegistration",
method: "post", method: "post",
data: { userId, response, challenge, deviceName }, data: { response, challenge, deviceName },
}); });
} }
export async function generatePasskeyAuthenticationOptions(userId: number) { export async function registerPasskey(response: any, challenge: string, deviceName: string) {
return await request({ return await request({
url: "/passkey/generateAuthentication", url: "/mine/passkey/register",
method: "post", method: "post",
data: { userId }, data: { response, challenge, deviceName },
});
}
export async function loginByPasskey(userId: number, credential: any, challenge: string) {
return await request({
url: "/passkey/login",
method: "post",
data: { userId, credential, challenge },
});
}
export async function registerPasskey(userId: number, response: any, challenge: string) {
return await request({
url: "/passkey/register",
method: "post",
data: { userId, response, challenge },
}); });
} }
@@ -33,7 +33,11 @@
<div v-for="passkey in passkeys" :key="passkey.id" class="flex items-center gap-4 p-2 border-b"> <div v-for="passkey in passkeys" :key="passkey.id" class="flex items-center gap-4 p-2 border-b">
<fs-icon icon="ion:finger-print" class="text-blue-500 fs-24" /> <fs-icon icon="ion:finger-print" class="text-blue-500 fs-24" />
<span class="w-40 truncate" :title="passkey.passkeyId">{{ passkey.deviceName }}</span> <span class="w-40 truncate" :title="passkey.passkeyId">{{ passkey.deviceName }}</span>
<span class="text-sm text-gray-500">{{ formatDate(passkey.registeredAt) }}</span> <span>
<div class="text-sm text-gray-500">注册时间{{ formatDate(passkey.registeredAt) }}</div>
<div class="text-sm text-gray-500">最后使用{{ formatDate(passkey.updateTime) }}</div>
</span>
<a-button type="primary" danger @click="unbindPasskey(passkey.id)">解绑</a-button> <a-button type="primary" danger @click="unbindPasskey(passkey.id)">解绑</a-button>
</div> </div>
</div> </div>
@@ -42,6 +46,9 @@
<div v-if="!passkeySupported" class="text-red-500 text-sm mt-2"> <div v-if="!passkeySupported" class="text-red-500 text-sm mt-2">
{{ t("authentication.passkeyNotSupported") }} {{ t("authentication.passkeyNotSupported") }}
</div> </div>
<pre class="helper"
>{{ t("authentication.passkeyRegisterHelper") }}
</pre>
</a-descriptions-item> </a-descriptions-item>
<a-descriptions-item :label="t('common.handle')"> <a-descriptions-item :label="t('common.handle')">
<a-button type="primary" @click="doUpdate">{{ t("authentication.updateProfile") }}</a-button> <a-button type="primary" @click="doUpdate">{{ t("authentication.updateProfile") }}</a-button>
@@ -59,10 +66,11 @@ import ChangePasswordButton from "/@/views/certd/mine/change-password-button.vue
import { useI18n } from "/src/locales"; import { useI18n } from "/src/locales";
import { useUserProfile } from "./use"; import { useUserProfile } from "./use";
import { usePasskeyRegister } from "./use"; import { usePasskeyRegister } from "./use";
import { message, Modal } from "ant-design-vue"; import { message, Modal, notification } from "ant-design-vue";
import { useSettingStore } from "/@/store/settings"; import { useSettingStore } from "/@/store/settings";
import { isEmpty } from "lodash-es"; import { isEmpty } from "lodash-es";
import { dict } from "@fast-crud/fast-crud"; import { dict } from "@fast-crud/fast-crud";
import dayjs from "dayjs";
const { t } = useI18n(); const { t } = useI18n();
@@ -190,9 +198,15 @@ async function doRegisterPasskey(deviceName: string) {
const res: any = await api.generatePasskeyRegistrationOptions(); const res: any = await api.generatePasskeyRegistrationOptions();
const options = res; const options = res;
navigator.credentials.query({ // navigator.credentials.query({
publicKey: options, // publicKey: options,
}); // });
// const excludeCredentials = passkeys.value.map(item => ({
// id: new TextEncoder().encode(item.passkeyId),
// type: "public-key",
// }));
const credential = await (navigator.credentials as any).create({ const credential = await (navigator.credentials as any).create({
publicKey: { publicKey: {
challenge: Uint8Array.from(atob(options.challenge.replace(/-/g, "+").replace(/_/g, "/")), c => c.charCodeAt(0)), challenge: Uint8Array.from(atob(options.challenge.replace(/-/g, "+").replace(/_/g, "/")), c => c.charCodeAt(0)),
@@ -200,9 +214,9 @@ async function doRegisterPasskey(deviceName: string) {
pubKeyCredParams: options.pubKeyCredParams, pubKeyCredParams: options.pubKeyCredParams,
timeout: options.timeout || 60000, timeout: options.timeout || 60000,
attestation: options.attestation, attestation: options.attestation,
excludeCredentials: options.excludeCredentials || [], // excludeCredentials: excludeCredentials,
user: { user: {
id: Uint8Array.from([res.userId]), id: new TextEncoder().encode(options.userId + ""),
name: userInfo.value.username, name: userInfo.value.username,
displayName: deviceName, displayName: deviceName,
}, },
@@ -222,18 +236,20 @@ async function doRegisterPasskey(deviceName: string) {
clientDataJSON: toBase64Url(credential.response.clientDataJSON), clientDataJSON: toBase64Url(credential.response.clientDataJSON),
}, },
}; };
console.log("credential", credential, response);
debugger;
const verifyRes: any = await api.verifyPasskeyRegistration(userInfo.value.id, response, options.challenge, deviceName); const verifyRes: any = await api.verifyPasskeyRegistration(response, options.challenge, deviceName);
await loadPasskeys(); await loadPasskeys();
} catch (e: any) { } catch (e: any) {
console.error("Passkey注册失败:", e); console.error("Passkey注册失败:", e);
Modal.error({ title: "错误", content: e.message || "Passkey注册失败" }); notification.error({ message: "错误", description: e.message || "Passkey注册失败" });
} }
} }
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
if (!dateString) return ""; if (!dateString) return "";
return new Date(dateString).toLocaleString("zh-CN"); return dayjs(dateString).format("YYYY-MM-DD HH:mm:ss");
}; };
const checkPasskeySupport = () => { const checkPasskeySupport = () => {
@@ -233,7 +233,6 @@ const handlePasskeyLogin = async () => {
} }
const loginRes: any = await UserApi.loginByPasskey({ const loginRes: any = await UserApi.loginByPasskey({
userId: optionsResponse.userId,
credential, credential,
challenge: options.challenge, challenge: options.challenge,
}); });
@@ -4,6 +4,7 @@ import { AddonService, BaseController, Constants, SysPublicSettings, SysSettings
import { CodeService } from "../../../modules/basic/service/code-service.js"; import { CodeService } from "../../../modules/basic/service/code-service.js";
import { checkComm } from "@certd/plus-core"; import { checkComm } from "@certd/plus-core";
import { CaptchaService } from "../../../modules/basic/service/captcha-service.js"; import { CaptchaService } from "../../../modules/basic/service/captcha-service.js";
import { PasskeyService } from "../../../modules/login/service/passkey-service.js";
/** /**
*/ */
@@ -23,6 +24,10 @@ export class LoginController extends BaseController {
@Inject() @Inject()
captchaService: CaptchaService; captchaService: CaptchaService;
@Inject()
passkeyService: PasskeyService;
@Post('/login', { summary: Constants.per.guest }) @Post('/login', { summary: Constants.per.guest })
public async login( public async login(
@Body(ALL) @Body(ALL)
@@ -81,22 +86,36 @@ export class LoginController extends BaseController {
return this.ok(token); return this.ok(token);
} }
@Post('/loginByPasskey', { summary: Constants.per.guest })
public async loginByPasskey(
@Body(ALL)
body: any
) {
const credential = body.credential;
const challenge = body.challenge;
const token = await this.loginService.loginByPasskey({ @Post('/passkey/generateAuthentication', { summary: Constants.per.guest })
credential, public async generateAuthentication() {
challenge, const options = await this.passkeyService.generateAuthenticationOptions(
}, this.ctx); this.ctx
);
// this.writeTokenCookie(token);
return this.ok(token); return this.ok({
} ...options,
});
}
@Post('/loginByPasskey', { summary: Constants.per.guest })
public async loginByPasskey(
@Body(ALL)
body: any
) {
const credential = body.credential;
const challenge = body.challenge;
const token = await this.loginService.loginByPasskey({
credential,
challenge,
}, this.ctx);
// this.writeTokenCookie(token);
return this.ok(token);
}
@Post('/logout', { summary: Constants.per.authOnly }) @Post('/logout', { summary: Constants.per.authOnly })
public logout() { public logout() {
@@ -50,30 +50,4 @@ export class MineController extends BaseController {
}); });
return this.ok({}); return this.ok({});
} }
@Post('/passkeys', { summary: Constants.per.authOnly })
public async getPasskeys() {
const userId = this.getUserId();
const passkeys = await this.passkeyService.find({
select: ['id', 'deviceName', 'registeredAt'],
where: { userId }});
return this.ok(passkeys);
}
@Post('/unbindPasskey', { summary: Constants.per.authOnly })
public async unbindPasskey(@Body(ALL) body: any) {
const userId = this.getUserId();
const passkeyId = body.id;
const passkey = await this.passkeyService.findOne({
where: { id: passkeyId, userId },
});
if (!passkey) {
throw new Error('Passkey不存在');
}
await this.passkeyService.delete([passkey.id]);
return this.ok({});
}
} }
@@ -4,8 +4,8 @@ import { BaseController, Constants } from "@certd/lib-server";
import { UserService } from "../../../modules/sys/authority/service/user-service.js"; import { UserService } from "../../../modules/sys/authority/service/user-service.js";
@Provide() @Provide()
@Controller('/api/passkey') @Controller('/api/mine/passkey')
export class PasskeyController extends BaseController { export class MinePasskeyController extends BaseController {
@Inject() @Inject()
passkeyService: PasskeyService; passkeyService: PasskeyService;
@@ -39,12 +39,12 @@ export class PasskeyController extends BaseController {
}); });
} }
@Post('/verifyRegistration', { summary: Constants.per.guest }) @Post('/verifyRegistration', { summary: Constants.per.authOnly })
public async verifyRegistration( public async verifyRegistration(
@Body(ALL) @Body(ALL)
body: any body: any
) { ) {
const userId = body.userId; const userId = this.getUserId()
const response = body.response; const response = body.response;
const challenge = body.challenge; const challenge = body.challenge;
const deviceName = body.deviceName; const deviceName = body.deviceName;
@@ -60,28 +60,14 @@ export class PasskeyController extends BaseController {
return this.ok(result); return this.ok(result);
} }
@Post('/generateAuthentication', { summary: Constants.per.guest })
public async generateAuthentication(
@Body(ALL)
body: any
) {
const options = await this.passkeyService.generateAuthenticationOptions(
this.ctx
);
return this.ok({ @Post('/register', { summary: Constants.per.authOnly })
...options,
});
}
@Post('/register', { summary: Constants.per.guest })
public async registerPasskey( public async registerPasskey(
@Body(ALL) @Body(ALL)
body: any body: any
) { ) {
const userId = body.userId; const userId = this.getUserId();
const response = body.response; const response = body.response;
const deviceName = body.deviceName; const deviceName = body.deviceName;
const challenge = body.challenge; const challenge = body.challenge;
@@ -96,4 +82,34 @@ export class PasskeyController extends BaseController {
return this.ok(result); return this.ok(result);
} }
@Post('/list', { summary: Constants.per.authOnly })
public async getPasskeys() {
const userId = this.getUserId();
const passkeys = await this.passkeyService.find({
select: ['id', 'deviceName', 'registeredAt', 'transports', 'passkeyId' ,'updateTime'],
where: { userId },
order: { registeredAt: 'DESC' },
});
return this.ok(passkeys);
}
@Post('/unbind', { summary: Constants.per.authOnly })
public async unbindPasskey(@Body(ALL) body: any) {
const userId = this.getUserId();
const passkeyId = body.id;
const passkey = await this.passkeyService.findOne({
where: { id: passkeyId, userId },
});
if (!passkey) {
throw new Error('Passkey不存在');
}
await this.passkeyService.delete([passkey.id]);
return this.ok({});
}
} }
@@ -1,10 +1,11 @@
import { cache } from "@certd/basic"; import { cache } from "@certd/basic";
import { AuthException, BaseService } from "@certd/lib-server"; import { AuthException, BaseService, SysSettingsService, SysSiteInfo } from "@certd/lib-server";
import { isComm } from "@certd/plus-core";
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core"; import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
import { InjectEntityModel } from "@midwayjs/typeorm";
import { Repository } from "typeorm";
import { UserService } from "../../sys/authority/service/user-service.js"; import { UserService } from "../../sys/authority/service/user-service.js";
import { PasskeyEntity } from "../entity/passkey.js"; import { PasskeyEntity } from "../entity/passkey.js";
import { Repository } from "typeorm";
import { InjectEntityModel } from "@midwayjs/typeorm";
@Provide() @Provide()
@Scope(ScopeEnum.Request, { allowDowngrade: true }) @Scope(ScopeEnum.Request, { allowDowngrade: true })
@@ -16,15 +17,31 @@ export class PasskeyService extends BaseService<PasskeyEntity> {
@InjectEntityModel(PasskeyEntity) @InjectEntityModel(PasskeyEntity)
repository: Repository<PasskeyEntity>; repository: Repository<PasskeyEntity>;
@Inject()
sysSettingsService: SysSettingsService;
getRepository(): Repository<PasskeyEntity> { getRepository(): Repository<PasskeyEntity> {
return this.repository; return this.repository;
} }
async getRpInfo(){
let rpName = "Certd"
if(isComm()){
const siteInfo = await this.sysSettingsService.getSetting<SysSiteInfo>(SysSiteInfo);
rpName = siteInfo.title || rpName;
}
return {
rpName,
}
}
async generateRegistrationOptions(userId: number, username: string, remoteIp: string, ctx: any) { async generateRegistrationOptions(userId: number, username: string, remoteIp: string, ctx: any) {
const { generateRegistrationOptions } = await import("@simplewebauthn/server"); const { generateRegistrationOptions } = await import("@simplewebauthn/server");
const user = await this.userService.info(userId); const user = await this.userService.info(userId);
const {rpName} = await this.getRpInfo();
const options = await generateRegistrationOptions({ const options = await generateRegistrationOptions({
rpName: "Certd", rpName: rpName,
rpID: this.getRpId(ctx), rpID: this.getRpId(ctx),
userID: new Uint8Array([userId]), userID: new Uint8Array([userId]),
userName: username, userName: username,
@@ -184,6 +201,7 @@ export class PasskeyService extends BaseService<PasskeyEntity> {
} }
passkey.counter = verification.counter; passkey.counter = verification.counter;
passkey.updateTime = new Date();
await this.repository.save(passkey); await this.repository.save(passkey);
const user = await this.userService.info(passkey.userId); const user = await this.userService.info(passkey.userId);