perf: 支持passkey登录

This commit is contained in:
xiaojunnuo
2026-03-12 18:11:02 +08:00
parent 2c399a078e
commit 10b7644bb7
22 changed files with 1222 additions and 246 deletions
@@ -81,6 +81,23 @@ export class LoginController extends BaseController {
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({
credential,
challenge,
}, this.ctx);
// this.writeTokenCookie(token);
return this.ok(token);
}
@Post('/logout', { summary: Constants.per.authOnly })
public logout() {
this.ctx.cookies.set("certd_token", "", {
@@ -0,0 +1,99 @@
import { ALL, Body, Controller, Inject, Post, Provide, RequestIP } from "@midwayjs/core";
import { PasskeyService } from "../../../modules/login/service/passkey-service.js";
import { BaseController, Constants } from "@certd/lib-server";
import { UserService } from "../../../modules/sys/authority/service/user-service.js";
@Provide()
@Controller('/api/passkey')
export class PasskeyController extends BaseController {
@Inject()
passkeyService: PasskeyService;
@Inject()
userService: UserService;
@Post('/generateRegistration', { summary: Constants.per.authOnly })
public async generateRegistration(
@Body(ALL)
body: any,
@RequestIP()
remoteIp: string
) {
const userId = this.getUserId()
const user = await this.userService.info(userId);
if (!user) {
throw new Error('用户不存在');
}
const options = await this.passkeyService.generateRegistrationOptions(
userId,
user.username,
remoteIp,
this.ctx
);
return this.ok({
...options,
userId
});
}
@Post('/verifyRegistration', { summary: Constants.per.guest })
public async verifyRegistration(
@Body(ALL)
body: any
) {
const userId = body.userId;
const response = body.response;
const challenge = body.challenge;
const deviceName = body.deviceName;
const result = await this.passkeyService.registerPasskey(
userId,
response,
challenge,
deviceName,
this.ctx
);
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({
...options,
});
}
@Post('/register', { summary: Constants.per.guest })
public async registerPasskey(
@Body(ALL)
body: any
) {
const userId = body.userId;
const response = body.response;
const deviceName = body.deviceName;
const challenge = body.challenge;
const result = await this.passkeyService.registerPasskey(
userId,
response,
challenge,
deviceName,
this.ctx
);
return this.ok(result);
}
}
@@ -4,6 +4,7 @@ import { In } from 'typeorm';
import { AuthService } from '../../../modules/sys/authority/service/auth-service.js';
import { UserService } from '../../../modules/sys/authority/service/user-service.js';
import { BasicController } from '../../basic/code-controller.js';
import { RoleService } from '../../../modules/sys/authority/service/role-service.js';
/**
* 通知
@@ -15,6 +16,8 @@ export class BasicUserController extends BasicController {
service: UserService;
@Inject()
authService: AuthService;
@Inject()
roleService: RoleService;
getService(): UserService {
return this.service;
@@ -57,4 +60,15 @@ export class BasicUserController extends BasicController {
return this.ok(users);
}
@Post('/getSimpleRoles', {summary: Constants.per.authOnly})
async getSimpleRoles() {
const roles = await this.roleService.find({
select: {
id: true,
name: true,
},
});
return this.ok(roles);
}
}
@@ -1,7 +1,8 @@
import { ALL, Body, Controller, Inject, Post, Provide } from '@midwayjs/core';
import { BaseController, Constants } from '@certd/lib-server';
import { UserService } from '../../../modules/sys/authority/service/user-service.js';
import { ALL, Body, Controller, Inject, Post, Provide } from '@midwayjs/core';
import { PasskeyService } from '../../../modules/login/service/passkey-service.js';
import { RoleService } from '../../../modules/sys/authority/service/role-service.js';
import { UserService } from '../../../modules/sys/authority/service/user-service.js';
/**
*/
@@ -10,8 +11,14 @@ import { RoleService } from '../../../modules/sys/authority/service/role-service
export class MineController extends BaseController {
@Inject()
userService: UserService;
@Inject()
roleService: RoleService;
@Inject()
passkeyService: PasskeyService;
@Post('/info', { summary: Constants.per.authOnly })
public async info() {
const userId = this.getUserId();
@@ -43,4 +50,30 @@ export class MineController extends BaseController {
});
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({});
}
}
@@ -0,0 +1,35 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('sys_passkey')
export class PasskeyEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'user_id', comment: '用户id' })
userId: number;
@Column({ name: 'device_name', comment: '设备名称' })
deviceName: string;
@Column({ name: 'passkey_id', comment: 'passkey_id' })
passkeyId: string;
@Column({ name: 'public_key', comment: '公钥', type: 'text' })
publicKey: string;
@Column({ name: 'counter', comment: '计数器' })
counter: number;
@Column({ name: 'transports', comment: '传输方式', type: 'text', nullable: true })
transports: string;
@Column({ name: 'registered_at', comment: '注册时间' })
registeredAt: number;
@Column({ name: 'create_time', comment: '创建时间', default: () => 'CURRENT_TIMESTAMP' })
createTime: Date;
@Column({ name: 'update_time', comment: '修改时间', default: () => 'CURRENT_TIMESTAMP' })
updateTime: Date;
}
@@ -18,6 +18,7 @@ import { UserSettingsService } from "../../mine/service/user-settings-service.js
import { isPlus } from "@certd/plus-core";
import { AddonService } from "@certd/lib-server";
import { OauthBoundService } from "./oauth-bound-service.js";
import { PasskeyService } from "./passkey-service.js";
/**
*/
@@ -45,6 +46,9 @@ export class LoginService {
@Inject()
oauthBoundService: OauthBoundService;
@Inject()
passkeyService: PasskeyService;
checkIsBlocked(username: string) {
const blockDurationKey = `login_block_duration:${username}`;
const value = cache.get(blockDurationKey);
@@ -254,4 +258,10 @@ export class LoginService {
}
return this.generateToken(info);
}
}
async loginByPasskey(req: { credential: any; challenge: string }, ctx: any) {
const {credential, challenge} = req;
const user = await this.passkeyService.loginByPasskey(credential, challenge, ctx);
return this.generateToken(user);
}}
@@ -0,0 +1,208 @@
import { cache } from "@certd/basic";
import { AuthException, BaseService } from "@certd/lib-server";
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
import { UserService } from "../../sys/authority/service/user-service.js";
import { PasskeyEntity } from "../entity/passkey.js";
import { Repository } from "typeorm";
import { InjectEntityModel } from "@midwayjs/typeorm";
@Provide()
@Scope(ScopeEnum.Request, { allowDowngrade: true })
export class PasskeyService extends BaseService<PasskeyEntity> {
@Inject()
userService: UserService;
@InjectEntityModel(PasskeyEntity)
repository: Repository<PasskeyEntity>;
getRepository(): Repository<PasskeyEntity> {
return this.repository;
}
async generateRegistrationOptions(userId: number, username: string, remoteIp: string, ctx: any) {
const { generateRegistrationOptions } = await import("@simplewebauthn/server");
const user = await this.userService.info(userId);
const options = await generateRegistrationOptions({
rpName: "Certd",
rpID: this.getRpId(ctx),
userID: new Uint8Array([userId]),
userName: username,
userDisplayName: user.nickName || username,
timeout: 60000,
attestationType: "none",
excludeCredentials: [],
});
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");
const storedUserId = cache.get(`passkey:registration:${challenge}`);
if (!storedUserId || storedUserId !== userId) {
throw new AuthException("注册验证失败");
}
const verification = await verifyRegistrationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: this.getOrigin(ctx),
expectedRPID: this.getRpId(ctx),
});
if (!verification.verified) {
throw new AuthException("注册验证失败");
}
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) {
const { generateAuthenticationOptions } = await import("@simplewebauthn/server");
const options = await generateAuthenticationOptions({
rpID: this.getRpId(ctx),
timeout: 60000,
allowCredentials: [],
});
// 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");
const passkey = await this.repository.findOne({
where: {
passkeyId: credential.id,
},
});
if (!passkey) {
throw new AuthException("Passkey不存在");
}
const verification = await verifyAuthenticationResponse({
response:credential,
expectedChallenge: challenge,
expectedOrigin: this.getOrigin(ctx),
expectedRPID: this.getRpId(ctx),
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 };
}
async loginByPasskey( credential: any, challenge: string, ctx: any) {
const verification = await this.verifyAuthenticationResponse(
credential,
challenge,
ctx
);
const passkey = await this.repository.findOne({
where: {
passkeyId: verification.credentialId,
},
});
if (!passkey) {
throw new AuthException("Passkey不存在");
}
if (verification.counter <= passkey.counter) {
throw new AuthException("认证失败:计数器异常");
}
passkey.counter = verification.counter;
await this.repository.save(passkey);
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';
}
}
@@ -25,6 +25,7 @@ export const AdminRoleId = 1
@Provide()
@Scope(ScopeEnum.Request, { allowDowngrade: true })
export class UserService extends BaseService<UserEntity> {
@InjectEntityModel(UserEntity)
repository: Repository<UserEntity>;
@@ -278,6 +279,10 @@ export class UserService extends BaseService<UserEntity> {
return user.username;
}
async getByUsername(username: any) {
return await this.findOne({ username });
}
async changePassword(userId: any, form: any) {
const user = await this.info(userId);
const passwordChecked = await this.checkPassword(form.password, user.password, user.passwordVersion);