mirror of
https://github.com/certd/certd.git
synced 2026-04-24 04:17:25 +08:00
perf: 支持passkey登录
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
|
||||
CREATE TABLE "sys_passkey"
|
||||
(
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"device_name" varchar(512) NOT NULL,
|
||||
"passkey_id" varchar(512) NOT NULL,
|
||||
"public_key" varchar(1024) NOT NULL,
|
||||
"counter" integer NOT NULL,
|
||||
"transports" varchar(512) NULL,
|
||||
"registered_at" integer NOT NULL,
|
||||
"create_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
"update_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP)
|
||||
);
|
||||
|
||||
|
||||
CREATE INDEX "index_passkey_user_id" ON "sys_passkey" ("user_id");
|
||||
CREATE INDEX "index_passkey_passkey_id" ON "sys_passkey" ("passkey_id");
|
||||
@@ -82,6 +82,8 @@
|
||||
"@midwayjs/upload": "3.20.13",
|
||||
"@midwayjs/validate": "3.20.13",
|
||||
"@peculiar/x509": "^1.11.0",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@simplewebauthn/server": "^13.2.3",
|
||||
"@ucloud-sdks/ucloud-sdk-js": "^0.2.4",
|
||||
"@volcengine/openapi": "^1.28.1",
|
||||
"ali-oss": "^6.21.0",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user