2026-03-17 19:16:11 +08:00
|
|
|
import { cache, logger } from "@certd/basic";
|
2026-03-15 02:20:39 +08:00
|
|
|
import { AuthException, BaseService, SysInstallInfo, SysSettingsService, SysSiteInfo } from "@certd/lib-server";
|
2026-03-13 15:31:03 +08:00
|
|
|
import { isComm } from "@certd/plus-core";
|
2026-03-12 18:11:02 +08:00
|
|
|
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
|
2026-03-13 15:31:03 +08:00
|
|
|
import { InjectEntityModel } from "@midwayjs/typeorm";
|
|
|
|
|
import { Repository } from "typeorm";
|
2026-03-12 18:11:02 +08:00
|
|
|
import { UserService } from "../../sys/authority/service/user-service.js";
|
|
|
|
|
import { PasskeyEntity } from "../entity/passkey.js";
|
|
|
|
|
|
|
|
|
|
@Provide()
|
|
|
|
|
@Scope(ScopeEnum.Request, { allowDowngrade: true })
|
|
|
|
|
export class PasskeyService extends BaseService<PasskeyEntity> {
|
2026-03-15 02:20:39 +08:00
|
|
|
|
2026-03-12 18:11:02 +08:00
|
|
|
@Inject()
|
|
|
|
|
userService: UserService;
|
|
|
|
|
|
|
|
|
|
@InjectEntityModel(PasskeyEntity)
|
|
|
|
|
repository: Repository<PasskeyEntity>;
|
|
|
|
|
|
2026-03-13 15:31:03 +08:00
|
|
|
@Inject()
|
|
|
|
|
sysSettingsService: SysSettingsService;
|
|
|
|
|
|
2026-03-12 18:11:02 +08:00
|
|
|
getRepository(): Repository<PasskeyEntity> {
|
|
|
|
|
return this.repository;
|
|
|
|
|
}
|
2026-03-13 15:31:03 +08:00
|
|
|
|
2026-03-15 02:20:39 +08:00
|
|
|
async getRpInfo() {
|
2026-03-13 15:31:03 +08:00
|
|
|
let rpName = "Certd"
|
2026-03-15 02:20:39 +08:00
|
|
|
if (isComm()) {
|
2026-03-13 15:31:03 +08:00
|
|
|
const siteInfo = await this.sysSettingsService.getSetting<SysSiteInfo>(SysSiteInfo);
|
|
|
|
|
rpName = siteInfo.title || rpName;
|
|
|
|
|
}
|
2026-03-15 02:20:39 +08:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
2026-03-13 15:31:03 +08:00
|
|
|
return {
|
|
|
|
|
rpName,
|
2026-03-15 02:20:39 +08:00
|
|
|
rpId,
|
|
|
|
|
origin,
|
2026-03-13 15:31:03 +08:00
|
|
|
}
|
|
|
|
|
}
|
2026-03-12 18:11:02 +08:00
|
|
|
async generateRegistrationOptions(userId: number, username: string, remoteIp: string, ctx: any) {
|
|
|
|
|
const { generateRegistrationOptions } = await import("@simplewebauthn/server");
|
|
|
|
|
const user = await this.userService.info(userId);
|
2026-03-15 02:20:39 +08:00
|
|
|
|
|
|
|
|
const { rpName, rpId } = await this.getRpInfo();
|
|
|
|
|
|
2026-03-13 15:31:03 +08:00
|
|
|
|
2026-03-12 18:11:02 +08:00
|
|
|
const options = await generateRegistrationOptions({
|
2026-03-13 15:31:03 +08:00
|
|
|
rpName: rpName,
|
2026-03-15 02:20:39 +08:00
|
|
|
rpID: rpId,
|
2026-03-17 19:16:11 +08:00
|
|
|
userID: new TextEncoder().encode(userId + ""),
|
2026-03-12 18:11:02 +08:00
|
|
|
userName: username,
|
|
|
|
|
userDisplayName: user.nickName || username,
|
|
|
|
|
timeout: 60000,
|
|
|
|
|
attestationType: "none",
|
|
|
|
|
excludeCredentials: [],
|
2026-03-18 00:43:01 +08:00
|
|
|
preferredAuthenticatorType: 'localDevice',
|
|
|
|
|
authenticatorSelection: {
|
|
|
|
|
authenticatorAttachment: "cross-platform",
|
|
|
|
|
userVerification: "preferred",
|
|
|
|
|
residentKey: "preferred",
|
|
|
|
|
requireResidentKey: false
|
|
|
|
|
},
|
2026-03-12 18:11:02 +08:00
|
|
|
});
|
2026-03-17 19:16:11 +08:00
|
|
|
logger.info('[passkey] 注册选项:', JSON.stringify(options));
|
2026-03-12 18:11:02 +08:00
|
|
|
cache.set(`passkey:registration:${options.challenge}`, userId, {
|
|
|
|
|
ttl: 5 * 60 * 1000,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...options,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async verifyRegistrationResponse(
|
|
|
|
|
userId: number,
|
|
|
|
|
response: any,
|
|
|
|
|
challenge: string,
|
|
|
|
|
ctx: any
|
|
|
|
|
) {
|
|
|
|
|
const { verifyRegistrationResponse } = await import("@simplewebauthn/server");
|
2026-03-15 02:20:39 +08:00
|
|
|
|
2026-03-12 18:11:02 +08:00
|
|
|
const storedUserId = cache.get(`passkey:registration:${challenge}`);
|
|
|
|
|
if (!storedUserId || storedUserId !== userId) {
|
|
|
|
|
throw new AuthException("注册验证失败");
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 02:20:39 +08:00
|
|
|
const { rpId, origin } = await this.getRpInfo();
|
|
|
|
|
|
2026-03-17 19:16:11 +08:00
|
|
|
let verification: any = null;
|
|
|
|
|
const verifyReq = {
|
2026-03-12 18:11:02 +08:00
|
|
|
response,
|
|
|
|
|
expectedChallenge: challenge,
|
2026-03-15 02:20:39 +08:00
|
|
|
expectedOrigin: origin,
|
|
|
|
|
expectedRPID: rpId,
|
2026-03-18 01:04:43 +08:00
|
|
|
requireUserVerification: false,
|
2026-03-17 19:16:11 +08:00
|
|
|
};
|
|
|
|
|
try {
|
|
|
|
|
verification = await verifyRegistrationResponse(verifyReq);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
// 后端验证时
|
|
|
|
|
logger.error('[passkey] 注册验证失败:', JSON.stringify(verifyReq));
|
|
|
|
|
throw new AuthException(`注册验证失败:${error.message || error}`);
|
|
|
|
|
}
|
2026-03-12 18:11:02 +08:00
|
|
|
if (!verification.verified) {
|
|
|
|
|
throw new AuthException("注册验证失败");
|
|
|
|
|
}
|
2026-03-17 19:16:11 +08:00
|
|
|
|
2026-03-12 18:11:02 +08:00
|
|
|
|
|
|
|
|
cache.delete(`passkey:registration:${challenge}`);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
credentialId: verification.registrationInfo.credential.id,
|
|
|
|
|
credentialPublicKey: verification.registrationInfo.credential.publicKey,
|
|
|
|
|
counter: verification.registrationInfo.credential.counter,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async generateAuthenticationOptions(ctx: any) {
|
2026-03-15 02:20:39 +08:00
|
|
|
const { rpId } = await this.getRpInfo();
|
2026-03-12 18:11:02 +08:00
|
|
|
const { generateAuthenticationOptions } = await import("@simplewebauthn/server");
|
|
|
|
|
const options = await generateAuthenticationOptions({
|
2026-03-15 02:20:39 +08:00
|
|
|
rpID: rpId,
|
2026-03-12 18:11:02 +08:00
|
|
|
timeout: 60000,
|
|
|
|
|
allowCredentials: [],
|
2026-03-18 00:43:01 +08:00
|
|
|
userVerification: 'preferred' //'required' | 'preferred' | 'discouraged';
|
2026-03-12 18:11:02 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// cache.set(`passkey:authentication:${options.challenge}`, userId, {
|
|
|
|
|
// ttl: 5 * 60 * 1000,
|
|
|
|
|
// });
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...options,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async verifyAuthenticationResponse(
|
|
|
|
|
credential: any,
|
|
|
|
|
challenge: string,
|
|
|
|
|
ctx: any
|
|
|
|
|
) {
|
|
|
|
|
const { verifyAuthenticationResponse } = await import("@simplewebauthn/server");
|
2026-03-15 02:20:39 +08:00
|
|
|
|
2026-03-12 18:11:02 +08:00
|
|
|
const passkey = await this.repository.findOne({
|
|
|
|
|
where: {
|
|
|
|
|
passkeyId: credential.id,
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-03-15 02:20:39 +08:00
|
|
|
|
2026-03-12 18:11:02 +08:00
|
|
|
if (!passkey) {
|
|
|
|
|
throw new AuthException("Passkey不存在");
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 02:20:39 +08:00
|
|
|
const { rpId, origin } = await this.getRpInfo();
|
|
|
|
|
|
2026-03-12 18:11:02 +08:00
|
|
|
const verification = await verifyAuthenticationResponse({
|
2026-03-15 02:20:39 +08:00
|
|
|
response: credential,
|
2026-03-12 18:11:02 +08:00
|
|
|
expectedChallenge: challenge,
|
2026-03-15 02:20:39 +08:00
|
|
|
expectedOrigin: origin,
|
|
|
|
|
expectedRPID: rpId,
|
2026-03-18 01:04:43 +08:00
|
|
|
requireUserVerification: false,
|
2026-03-12 18:11:02 +08:00
|
|
|
credential: {
|
|
|
|
|
id: passkey.passkeyId,
|
|
|
|
|
publicKey: new Uint8Array(Buffer.from(passkey.publicKey, 'base64')),
|
|
|
|
|
counter: passkey.counter,
|
|
|
|
|
transports: passkey.transports as any,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!verification.verified) {
|
|
|
|
|
throw new AuthException("认证验证失败");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cache.delete(`passkey:authentication:${challenge}`);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
credentialId: verification.authenticationInfo.credentialID,
|
|
|
|
|
counter: verification.authenticationInfo.newCounter,
|
|
|
|
|
userId: passkey.userId,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async registerPasskey(
|
|
|
|
|
userId: number,
|
|
|
|
|
response: any,
|
|
|
|
|
challenge: string,
|
|
|
|
|
deviceName: string,
|
|
|
|
|
ctx: any
|
|
|
|
|
) {
|
|
|
|
|
const verification = await this.verifyRegistrationResponse(
|
|
|
|
|
userId,
|
|
|
|
|
response,
|
|
|
|
|
challenge,
|
|
|
|
|
ctx
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await this.add({
|
|
|
|
|
userId,
|
|
|
|
|
passkeyId: verification.credentialId,
|
|
|
|
|
publicKey: Buffer.from(verification.credentialPublicKey).toString('base64'),
|
|
|
|
|
counter: verification.counter,
|
|
|
|
|
deviceName,
|
|
|
|
|
registeredAt: Date.now(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return { success: true };
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 02:20:39 +08:00
|
|
|
async loginByPasskey(credential: any, challenge: string, ctx: any) {
|
2026-03-12 18:11:02 +08:00
|
|
|
const verification = await this.verifyAuthenticationResponse(
|
|
|
|
|
credential,
|
|
|
|
|
challenge,
|
|
|
|
|
ctx
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const passkey = await this.repository.findOne({
|
|
|
|
|
where: {
|
|
|
|
|
passkeyId: verification.credentialId,
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-03-15 02:20:39 +08:00
|
|
|
|
2026-03-12 18:11:02 +08:00
|
|
|
if (!passkey) {
|
|
|
|
|
throw new AuthException("Passkey不存在");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (verification.counter <= passkey.counter) {
|
|
|
|
|
throw new AuthException("认证失败:计数器异常");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
passkey.counter = verification.counter;
|
2026-03-13 15:31:03 +08:00
|
|
|
passkey.updateTime = new Date();
|
2026-03-12 18:11:02 +08:00
|
|
|
await this.repository.save(passkey);
|
|
|
|
|
|
|
|
|
|
const user = await this.userService.info(passkey.userId);
|
|
|
|
|
return user;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 02:20:39 +08:00
|
|
|
// 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';
|
|
|
|
|
// }
|
2026-03-12 18:11:02 +08:00
|
|
|
}
|