perf(passkey): passkey支持多域名rpid

This commit is contained in:
xiaojunnuo
2026-07-04 21:56:35 +08:00
parent 56e5524a0f
commit 79f65868ca
8 changed files with 74 additions and 44 deletions
@@ -107,6 +107,10 @@
<div class="passkey-info">
<div class="passkey-name">{{ passkey.deviceName }}</div>
<div class="passkey-meta flex items-center">
<span class="meta-item flex items-center">
<fs-icon icon="ion:globe-outline" class="meta-icon" />
{{ passkey.rpId || "-" }}
</span>
<span class="meta-item flex items-center">
<fs-icon icon="ion:calendar-outline" class="meta-icon" />
{{ formatDate(passkey.registeredAt) }}
@@ -454,6 +458,8 @@ onMounted(async () => {
}
.card-header {
background: linear-gradient(145deg, #1e1e1e, #252525);
.header-bg-gradient {
background: rgba(255, 255, 255, 0.04);
opacity: 1;
@@ -472,6 +478,7 @@ onMounted(async () => {
.detail-tag {
background: #3b3b3b;
border-color: rgba(255, 255, 255, 0.12);
color: #e5e5e5;
.tag-icon {
@@ -480,6 +487,23 @@ onMounted(async () => {
}
}
.card-title {
border-bottom-color: rgba(255, 255, 255, 0.1);
}
.binding-icon {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.22) 0%, rgba(160, 120, 234, 0.22) 100%);
}
.passkey-icon {
background: linear-gradient(135deg, rgba(17, 153, 142, 0.22) 0%, rgba(56, 239, 125, 0.22) 100%);
}
.binding-icon .icon,
.passkey-icon .icon {
color: rgba(255, 255, 255, 0.7);
}
.bindings-list {
.binding-item {
background: #2d2d2d;
@@ -0,0 +1,6 @@
ALTER TABLE `sys_passkey` ADD COLUMN `rp_id` varchar(256) NULL;
DROP INDEX `index_passkey_passkey_id` ON `sys_passkey`;
ALTER TABLE `sys_passkey` ADD UNIQUE INDEX `index_passkey_passkey_id` (`passkey_id`);
@@ -0,0 +1,6 @@
ALTER TABLE "sys_passkey" ADD COLUMN "rp_id" varchar(256) NULL;
DROP INDEX "index_passkey_passkey_id";
CREATE UNIQUE INDEX "index_passkey_passkey_id" ON "sys_passkey" ("passkey_id");
@@ -0,0 +1,6 @@
ALTER TABLE "sys_passkey" ADD COLUMN "rp_id" varchar(256) NULL;
DROP INDEX "index_passkey_passkey_id";
CREATE UNIQUE INDEX "index_passkey_passkey_id" ON "sys_passkey" ("passkey_id");
@@ -69,7 +69,7 @@ export class MinePasskeyController extends BaseController {
public async getPasskeys() {
const userId = this.getUserId();
const passkeys = await this.passkeyService.find({
select: ["id", "deviceName", "registeredAt", "transports", "passkeyId", "updateTime"],
select: ["id", "deviceName", "registeredAt", "transports", "passkeyId", "rpId", "updateTime"],
where: { userId },
order: { registeredAt: "DESC" },
});
@@ -11,7 +11,7 @@ export class PasskeyEntity {
@Column({ name: "device_name", comment: "设备名称" })
deviceName: string;
@Column({ name: "passkey_id", comment: "passkey_id" })
@Column({ name: "passkey_id", comment: "passkey_id", unique: true })
passkeyId: string;
@Column({ name: "public_key", comment: "公钥", type: "text" })
@@ -23,6 +23,9 @@ export class PasskeyEntity {
@Column({ name: "transports", comment: "传输方式", type: "text", nullable: true })
transports: string;
@Column({ name: "rp_id", comment: "注册时的rpId,域名可能会变", nullable: true })
rpId: string;
@Column({ name: "registered_at", comment: "注册时间" })
registeredAt: number;
@@ -1,5 +1,5 @@
import { cache, logger } from "@certd/basic";
import { AuthException, BaseService, SysInstallInfo, SysSettingsService, SysSiteInfo } 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 { InjectEntityModel } from "@midwayjs/typeorm";
@@ -23,19 +23,15 @@ export class PasskeyService extends BaseService<PasskeyEntity> {
return this.repository;
}
async getRpInfo() {
async getRpInfo(ctx: any) {
let rpName = "Certd";
if (isComm()) {
const siteInfo = await this.sysSettingsService.getSetting<SysSiteInfo>(SysSiteInfo);
rpName = siteInfo.title || rpName;
}
const installInfo = await this.sysSettingsService.getSetting<SysInstallInfo>(SysInstallInfo);
const url = installInfo.bindUrl || "http://localhost:7001";
const uri = new URL(url);
const rpId = uri.hostname;
const origin = uri.origin;
const rpId = ctx.hostname;
const origin = ctx.origin;
return {
rpName,
@@ -47,7 +43,7 @@ export class PasskeyService extends BaseService<PasskeyEntity> {
const { generateRegistrationOptions } = await import("@simplewebauthn/server");
const user = await this.userService.info(userId);
const { rpName, rpId } = await this.getRpInfo();
const { rpName, rpId } = await this.getRpInfo(ctx);
const options = await generateRegistrationOptions({
rpName: rpName,
@@ -84,7 +80,7 @@ export class PasskeyService extends BaseService<PasskeyEntity> {
throw new AuthException("注册验证失败");
}
const { rpId, origin } = await this.getRpInfo();
const { rpId, origin } = await this.getRpInfo(ctx);
let verification: any = null;
const verifyReq = {
@@ -115,7 +111,7 @@ export class PasskeyService extends BaseService<PasskeyEntity> {
}
async generateAuthenticationOptions(ctx: any) {
const { rpId } = await this.getRpInfo();
const { rpId } = await this.getRpInfo(ctx);
const { generateAuthenticationOptions } = await import("@simplewebauthn/server");
const options = await generateAuthenticationOptions({
rpID: rpId,
@@ -146,13 +142,19 @@ export class PasskeyService extends BaseService<PasskeyEntity> {
throw new AuthException("Passkey不存在");
}
const { rpId, origin } = await this.getRpInfo();
const { rpId, origin } = await this.getRpInfo(ctx);
if (passkey.rpId && passkey.rpId !== rpId) {
throw new AuthException(`当前站点域名(${rpId})与Passkey注册域名(${passkey.rpId})不一致,请在${passkey.rpId}域名下使用该Passkey登录`);
}
const expectedRPID = passkey.rpId || rpId;
const verification = await verifyAuthenticationResponse({
response: credential,
expectedChallenge: challenge,
expectedOrigin: origin,
expectedRPID: rpId,
expectedRPID,
requireUserVerification: false,
credential: {
id: passkey.passkeyId,
@@ -166,6 +168,11 @@ export class PasskeyService extends BaseService<PasskeyEntity> {
throw new AuthException("认证验证失败");
}
if (!passkey.rpId) {
passkey.rpId = rpId;
await this.repository.save(passkey);
}
cache.delete(`passkey:authentication:${challenge}`);
return {
@@ -178,12 +185,15 @@ export class PasskeyService extends BaseService<PasskeyEntity> {
async registerPasskey(userId: number, response: any, challenge: string, deviceName: string, ctx: any) {
const verification = await this.verifyRegistrationResponse(userId, response, challenge, ctx);
const rpInfo = await this.getRpInfo(ctx);
await this.add({
userId,
passkeyId: verification.credentialId,
publicKey: Buffer.from(verification.credentialPublicKey).toString("base64"),
counter: verification.counter,
deviceName,
rpId: rpInfo.rpId,
registeredAt: Date.now(),
});
@@ -215,20 +225,4 @@ export class PasskeyService extends BaseService<PasskeyEntity> {
const user = await this.userService.info(passkey.userId);
return user;
}
// private getRpId(ctx: any): string {
// if (ctx && ctx.request && ctx.request.host) {
// return ctx.request.host.split(':')[0];
// }
// return 'localhost';
// }
// private getOrigin(ctx: any): string {
// if (ctx && ctx.request) {
// const protocol = ctx.request.protocol;
// const host = ctx.request.host;
// return `${protocol}://${host}`;
// }
// return 'https://localhost';
// }
}
@@ -28,10 +28,7 @@ describe("AliyunDeployCertToESA", () => {
plugin.deployMode = "saas";
plugin.siteIds = [];
await assert.rejects(
() => (plugin as any).executeSaaS(null, null, 1, "test"),
/SaaS证书模式下请先选择站点/
);
await assert.rejects(() => (plugin as any).executeSaaS(null, null, 1, "test"), /SaaS证书模式下请先选择站点/);
});
it("executeSaaS throws error when multiple sites are selected", async () => {
@@ -40,10 +37,7 @@ describe("AliyunDeployCertToESA", () => {
plugin.deployMode = "saas";
plugin.siteIds = ["site1", "site2"];
await assert.rejects(
() => (plugin as any).executeSaaS(null, null, 1, "test"),
/SaaS证书模式下站点只能单选/
);
await assert.rejects(() => (plugin as any).executeSaaS(null, null, 1, "test"), /SaaS证书模式下站点只能单选/);
});
it("executeSaaS throws error when no SaaS domains selected", async () => {
@@ -53,10 +47,7 @@ describe("AliyunDeployCertToESA", () => {
plugin.siteIds = ["site1"];
plugin.saasDomainIds = [];
await assert.rejects(
() => (plugin as any).executeSaaS(null, null, 1, "test"),
/SaaS证书模式下请选择要部署的SaaS域名/
);
await assert.rejects(() => (plugin as any).executeSaaS(null, null, 1, "test"), /SaaS证书模式下请选择要部署的SaaS域名/);
});
it("executeSaaS calls UpdateCustomHostname for each selected SaaS domain", async () => {
@@ -120,4 +111,4 @@ describe("AliyunDeployCertToESA", () => {
assert.deepEqual(calledSites, ["site1", "site2"]);
});
});
});