diff --git a/packages/ui/certd-client/src/views/certd/mine/user-profile.vue b/packages/ui/certd-client/src/views/certd/mine/user-profile.vue index a126a98d6..62d428581 100644 --- a/packages/ui/certd-client/src/views/certd/mine/user-profile.vue +++ b/packages/ui/certd-client/src/views/certd/mine/user-profile.vue @@ -107,6 +107,10 @@
{{ passkey.deviceName }}
+ + + {{ passkey.rpId || "-" }} + {{ 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; diff --git a/packages/ui/certd-server/db/migration-mysql/v10050__passkey_rp.sql b/packages/ui/certd-server/db/migration-mysql/v10050__passkey_rp.sql new file mode 100644 index 000000000..3bdf2f76a --- /dev/null +++ b/packages/ui/certd-server/db/migration-mysql/v10050__passkey_rp.sql @@ -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`); diff --git a/packages/ui/certd-server/db/migration-pg/v10050__passkey_rp.sql b/packages/ui/certd-server/db/migration-pg/v10050__passkey_rp.sql new file mode 100644 index 000000000..cacdd267e --- /dev/null +++ b/packages/ui/certd-server/db/migration-pg/v10050__passkey_rp.sql @@ -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"); diff --git a/packages/ui/certd-server/db/migration/v10050__passkey_rp.sql b/packages/ui/certd-server/db/migration/v10050__passkey_rp.sql new file mode 100644 index 000000000..cacdd267e --- /dev/null +++ b/packages/ui/certd-server/db/migration/v10050__passkey_rp.sql @@ -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"); diff --git a/packages/ui/certd-server/src/controller/user/mine/passkey-controller.ts b/packages/ui/certd-server/src/controller/user/mine/passkey-controller.ts index 854636413..daed1c482 100644 --- a/packages/ui/certd-server/src/controller/user/mine/passkey-controller.ts +++ b/packages/ui/certd-server/src/controller/user/mine/passkey-controller.ts @@ -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" }, }); diff --git a/packages/ui/certd-server/src/modules/login/entity/passkey.ts b/packages/ui/certd-server/src/modules/login/entity/passkey.ts index cafdca240..c3c9ac262 100644 --- a/packages/ui/certd-server/src/modules/login/entity/passkey.ts +++ b/packages/ui/certd-server/src/modules/login/entity/passkey.ts @@ -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; diff --git a/packages/ui/certd-server/src/modules/login/service/passkey-service.ts b/packages/ui/certd-server/src/modules/login/service/passkey-service.ts index 4fc9bdd14..740e2b43a 100644 --- a/packages/ui/certd-server/src/modules/login/service/passkey-service.ts +++ b/packages/ui/certd-server/src/modules/login/service/passkey-service.ts @@ -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 { return this.repository; } - async getRpInfo() { + async getRpInfo(ctx: any) { let rpName = "Certd"; if (isComm()) { const siteInfo = await this.sysSettingsService.getSetting(SysSiteInfo); rpName = siteInfo.title || rpName; } - const installInfo = await this.sysSettingsService.getSetting(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 { 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 { 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 { } 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 { 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 { 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 { 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 { 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'; - // } } diff --git a/packages/ui/certd-server/src/plugins/plugin-aliyun/plugin/deploy-to-esa/index.test.ts b/packages/ui/certd-server/src/plugins/plugin-aliyun/plugin/deploy-to-esa/index.test.ts index 5f15a623e..9660e0916 100644 --- a/packages/ui/certd-server/src/plugins/plugin-aliyun/plugin/deploy-to-esa/index.test.ts +++ b/packages/ui/certd-server/src/plugins/plugin-aliyun/plugin/deploy-to-esa/index.test.ts @@ -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"]); }); -}); \ No newline at end of file +});