feat: add CNB login functionality and user management

- Introduced `cnb-login` route to handle user login via CNB token.
- Created `CnbServices` class for managing CNB user interactions.
- Added `findByCnbId` method in the User model to retrieve users by CNB ID.
- Updated error handling to provide more structured error messages.
- Enhanced user creation logic to handle CNB users.
- Added tests for the new CNB login functionality.
This commit is contained in:
2026-02-20 23:30:53 +08:00
parent 1782a9ef19
commit 366a21d621
16 changed files with 392 additions and 40 deletions

View File

@@ -7,7 +7,7 @@ import { cryptPwd } from '../oauth/salt.ts';
import { OauthUser } from '../oauth/oauth.ts';
import { db } from '../../modules/db.ts';
import { Org } from './org.ts';
import { UserSecret } from './user-secret.ts';
import { cfUser, cfOrgs, cfUserSecrets } from '../../db/drizzle/schema.ts';
import { eq, sql, InferSelectModel, InferInsertModel } from 'drizzle-orm';
@@ -17,6 +17,7 @@ export type UserData = {
wxUnionId?: string;
phone?: string;
canChangeUsername?: boolean;
cnbId?: string;
};
export enum UserTypes {
@@ -95,7 +96,6 @@ export class User {
* @returns
*/
static async verifyToken(token: string) {
const { UserSecret } = await import('./user-secret.ts');
return await UserSecret.verifyToken(token);
}
/**
@@ -108,7 +108,6 @@ export class User {
return { accessToken: token.accessToken, refreshToken: token.refreshToken, token: token.accessToken };
}
static async getOauthUser(token: string) {
const { UserSecret } = await import('./user-secret.ts');
return await UserSecret.verifyToken(token);
}
/**
@@ -126,7 +125,6 @@ export class User {
* @returns
*/
static async getUserByToken(token: string) {
const { UserSecret } = await import('./user-secret.ts');
const oauthUser = await UserSecret.verifyToken(token);
if (!oauthUser) {
throw new CustomError('Token is invalid. get UserByToken');
@@ -176,6 +174,20 @@ export class User {
return users.length > 0 ? new User(users[0]) : null;
}
/**
* 根据 CNB ID 查找用户
* @param cnbId
* @returns
*/
static async findByCnbId(cnbId: string): Promise<User | null> {
const users = await db
.select()
.from(usersTable)
.where(sql`${usersTable.data}->>'cnbId' = ${cnbId}`)
.limit(1);
return users.length > 0 ? new User(users[0]) : null;
}
/**
* 根据条件查找一个用户
*/
@@ -193,7 +205,7 @@ export class User {
const users = await query.limit(1);
return users.length > 0 ? new User(users[0]) : null;
}
static findByunionid(){
static findByunionid() {
}
@@ -345,7 +357,7 @@ export class User {
if (this.tokenUser && this.tokenUser.uid) {
id = this.tokenUser.uid;
} else {
throw new CustomError(400, 'Permission denied');
throw new CustomError('Permission denied', { code: 400 });
}
}
const cache = await redis.get(`user:${id}:orgs`);

View File

@@ -147,6 +147,7 @@ export class RedisTokenStore implements Store<OauthUser> {
// 计算过期时间根据opts.expire 和 opts.loginType
// 如果expire存在则使用expire否则使用opts.loginType 进行计算;
let expire = opts?.expire;
const day = 24 * 60 * 60; // 一天的秒数
if (!expire) {
switch (opts.loginType) {
case 'day':
@@ -170,7 +171,8 @@ export class RedisTokenStore implements Store<OauthUser> {
await this.set(accessToken, JSON.stringify(value), expire);
await this.set(userPrefix + ':token:' + accessToken, accessToken, expire);
let refreshTokenExpiresIn = Math.min(expire * 7, 60 * 60 * 24 * 30, 60 * 60 * 24 * 365); // 最大为一年
// refreshToken的过期时间比accessToken多2天确保在accessToken过期后refreshToken仍然有效
let refreshTokenExpiresIn = expire + 2 * day;
if (refreshToken) {
// 小于7天, 则设置为7天
if (refreshTokenExpiresIn < 60 * 60 * 24 * 7) {

View File

@@ -18,14 +18,14 @@ export class ShareConfigService {
shareCacheConfig = JSON.parse(shareCacheConfigString);
} catch (e) {
await redis.set(`config:share:${username}:${key}`, '', 'EX', 0); // 删除缓存
throw new CustomError(400, 'config parse error');
throw new CustomError(400, { message: 'config parse error' });
}
const owner = username;
if (shareCacheConfig) {
const permission = new UserPermission({ permission: (shareCacheConfig?.data as any)?.permission, owner });
const result = permission.checkPermissionSuccess(options);
if (!result.success) {
throw new CustomError(403, 'no permission');
throw new CustomError(403, { message: 'no permission' });
}
return shareCacheConfig;
}
@@ -35,7 +35,7 @@ export class ShareConfigService {
.limit(1);
const user = users[0];
if (!user) {
throw new CustomError(404, 'user not found');
throw new CustomError(404, { message: 'user not found' });
}
const configs = await db.select()
.from(schema.kvConfig)
@@ -43,12 +43,12 @@ export class ShareConfigService {
.limit(1);
const config = configs[0];
if (!config) {
throw new CustomError(404, 'config not found');
throw new CustomError(404, { message: 'config not found' });
}
const permission = new UserPermission({ permission: (config?.data as any)?.permission, owner });
const result = permission.checkPermissionSuccess(options);
if (!result.success) {
throw new CustomError(403, 'no permission');
throw new CustomError(403, { message: 'no permission' });
}
await redis.set(`config:share:${username}:${key}`, JSON.stringify(config), 'EX', 60 * 60 * 24 * 7); // 7天
return config;

View File

@@ -9,18 +9,18 @@ import { eq } from 'drizzle-orm';
export const checkUsername = (username: string) => {
if (username.length > 30) {
throw new CustomError(400, '用户名不能过长');
throw new CustomError(400, { message: '用户名不能过长' });
}
if (!/^[a-zA-Z0-9_@]+$/.test(username)) {
throw new CustomError(400, '用户名包含非法字符');
throw new CustomError(400, { message: '用户名包含非法字符' });
}
if (username.includes(' ')) {
throw new CustomError(400, '用户名不能包含空格');
throw new CustomError(400, { message: '用户名不能包含空格' });
}
};
export const checkUsernameShort = (username: string) => {
if (username.length <= 3) {
throw new CustomError(400, '用户名不能过短');
throw new CustomError(400, { message: '用户名不能过短' });
}
};
@@ -31,13 +31,13 @@ export const toChangeName = async (opts: { id: string; newName: string; admin?:
}
const user = await User.findByPk(id);
if (!user) {
ctx.throw(404, 'User not found');
ctx.throw(404, { message: 'User not found' });
}
const oldName = user.username;
checkUsername(newName);
const findUserByUsername = await User.findOne({ username: newName });
if (findUserByUsername) {
ctx.throw(400, 'Username already exists');
ctx.throw(400, { message: 'Username already exists' });
}
user.username = newName;
const data = user.data || {};
@@ -65,7 +65,7 @@ export const toChangeName = async (opts: { id: string; newName: string; admin?:
}
} catch (error) {
console.error('迁移文件数据失败', error);
ctx.throw(500, 'Failed to change username');
ctx.throw(500, { message: 'Failed to change username' });
}
return user;
}
@@ -79,13 +79,13 @@ app
const { id, newName } = ctx.query.data || {};
try {
if (!id || !newName) {
ctx.throw(400, '参数错误');
ctx.throw(400, { message: '参数错误' });
}
const user = await toChangeName({ id, newName, admin: true, ctx });
ctx.body = await user?.getInfo?.();
} catch (error) {
console.error('changeName error', error);
ctx.throw(500, 'Failed to change username');
ctx.throw(500, { message: 'Failed to change username' });
}
})
.addTo(app);
@@ -99,7 +99,7 @@ app
.define(async (ctx) => {
const { username } = ctx.query.data || {};
if (!username) {
ctx.throw(400, 'Username is required');
ctx.throw(400, { message: 'Username is required' });
}
checkUsername(username);
const user = await User.findOne({ username });
@@ -121,7 +121,7 @@ app
const { id, password } = ctx.query.data || {};
const user = await User.findByPk(id);
if (!user) {
ctx.throw(404, 'User not found');
ctx.throw(404, { message: 'User not found' });
}
let pwd = password || nanoid(6);
user.createPassword(pwd);
@@ -149,7 +149,7 @@ app
checkUsername(username);
const findUserByUsername = await User.findOne({ username });
if (findUserByUsername) {
ctx.throw(400, 'Username already exists');
ctx.throw(400, { message: 'Username already exists' });
}
let pwd = password || nanoid(6);
const user = await User.createUser(username, pwd, description);
@@ -172,7 +172,7 @@ app
const { id } = ctx.query.data || {};
const user = await User.findByPk(id);
if (!user) {
ctx.throw(404, 'User not found');
ctx.throw(404, { message: 'User not found' });
}
await db.delete(schema.cfUser).where(eq(schema.cfUser.id, user.id));
backupUserA(user.username, user.id);

View File

@@ -0,0 +1,30 @@
import { app, redis } from "@/app.ts";
import z from "zod";
import { CnbServices } from "./modules/cnb-services.ts";
import { createCookie } from "./me.ts";
app
.route({
path: 'user',
key: 'cnb-login',
description: 'cnb登陆, 根据 CNB_TOKEN 获取用户信息',
metadata: {
args: {
data: z.object({
cnbToken: z.string().describe('cnb token'),
}),
}
}
})
.define(async (ctx) => {
const { cnbToken } = ctx.query.data || {};
if (!cnbToken) {
ctx.throw(400, 'CNB Token is required');
}
const cnb = new CnbServices(cnbToken);
const token = await cnb.login();
if (!token) {
ctx.throw(500, '登陆失败');
}
createCookie(token, ctx);
ctx.body = token;
}).addTo(app);

View File

@@ -16,3 +16,5 @@ import './admin/user.ts';
import './secret-key/list.ts';
import './wx-login.ts'
import './cnb-login.ts';

View File

@@ -35,15 +35,15 @@ app
const tokenUser = ctx.state.tokenUser;
const { id, username, password, description } = ctx.query.data || {};
if (!id) {
throw new CustomError(400, 'id is required');
throw new CustomError(400, { message: 'id is required' });
}
const user = await User.findByPk(id);
if (user.id !== tokenUser.id) {
throw new CustomError(403, 'Permission denied');
throw new CustomError(403, { message: 'Permission denied' });
}
if (!user) {
throw new CustomError(500, 'user not found');
throw new CustomError(500, { message: 'user not found' });
}
if (username) {
user.username = username;
@@ -73,12 +73,12 @@ app
.define(async (ctx) => {
const { username, password, description } = ctx.query.data || {};
if (!username) {
throw new CustomError(400, 'username is required');
throw new CustomError(400, { message: 'username is required' });
}
checkUsername(username);
const findUserByUsername = await User.findOne({ username });
if (findUserByUsername) {
throw new CustomError(400, 'username already exists');
throw new CustomError(400, { message: 'username already exists' });
}
const pwd = password || nanoid(6);
const user = await User.createUser(username, pwd, description);

View File

@@ -1,19 +1,24 @@
import { app } from '@/app.ts';
import { Org } from '@/models/org.ts';
import { User } from '@/models/user.ts';
import { proxyDomain as domain } from '@/modules/domain.ts';
import { logger } from '@/modules/logger.ts';
import z from 'zod';
/**
* 当配置了domain后创建cookie当get请求地址的时候会自动带上cookie
* @param token
* @param ctx
* @returns
*/
export const createCookie = (token: any, ctx: any) => {
export const createCookie = (token: { accessToken?: string; token?: string }, ctx: any) => {
if (!domain) {
return;
}
if (!ctx?.req) {
logger.debug('登陆用户没有请求对象不需要创建cookie');
return
}
//TODO, 获取访问的 hostname 如果访问的和 domain 的不一致也创建cookie
const browser = ctx.req.headers['user-agent'];
const browser = ctx?.req?.headers['user-agent'];
const isBrowser = browser.includes('Mozilla'); // 浏览器
if (isBrowser && ctx.res.cookie) {
ctx.res.cookie('token', token.accessToken || token?.token, {
@@ -351,6 +356,14 @@ app
.route({
path: 'user',
key: 'refreshToken',
description: '根据refreshToken刷新token',
metadata: {
args: {
data: z.object({
refreshToken: z.string().describe('刷新token'),
}),
}
}
})
.define(async (ctx) => {
const { refreshToken } = ctx.query.data || {};

View File

@@ -0,0 +1,37 @@
import { CNB } from '@kevisual/cnb'
import { UserModel } from '../../../auth/index.ts';
import { CustomError } from '@kevisual/router';
export class CnbServices {
cnb: CNB;
constructor(token?: string) {
this.cnb = new CNB({
token,
});
}
async login(): Promise<ReturnType<typeof UserModel.prototype.createToken>> {
const cnbUserRes = await this.cnb.user.getUser();
if (cnbUserRes.code !== 200) {
throw new CustomError('CNB Token is invalid');
}
const cnbUser = cnbUserRes?.data;
const cnbUserId = cnbUser?.id
if (!cnbUserId) {
throw new CustomError('CNB User ID is missing');
}
let user = await UserModel.findByCnbId(cnbUserId);
if (!user) {
const username = '@cnb-' + cnbUser.username;
// 如果用户不存在,创建一个新用户
user = await UserModel.createUser(username, cnbUserId);
user.data = {
...user.data,
cnbId: cnbUserId,
}
await user.save();
}
const token = await user.createToken();
return token;
}
}

View File

@@ -54,7 +54,7 @@ export class WxServices {
const token = await fetchToken(code, type);
console.log('login token', token);
if (!token.unionid) {
throw new CustomError(400, 'code is invalid, wxdata can not be found');
throw new CustomError(400, { message: 'code is invalid, wxdata can not be found' });
}
this.wxToken = token;
const unionid = token.unionid;
@@ -180,7 +180,7 @@ export class WxServices {
async getUserInfo() {
try {
if (!this.wxToken) {
throw new CustomError(400, 'wxToken is not set');
throw new CustomError(400, { message: 'wxToken is not set' });
}
const openid = this.wxToken.openid;
const access_token = this.wxToken.access_token;

View File

@@ -45,7 +45,7 @@ export const fetchToken = async (code: string, type: 'open' | 'mp' = 'open'): Pr
appSecret = wx.appSecret;
}
if (!appId || !appSecret) {
throw new CustomError(500, 'appId or appSecret is not set');
throw new CustomError(500, { message: 'appId or appSecret is not set' });
}
console.log('fetchToken===', appId, appSecret, code);
const wxUrl = `https://api.weixin.qq.com/sns/oauth2/access_token?appid=${appId}&secret=${appSecret}&code=${code}&grant_type=authorization_code`;

View File

@@ -6,12 +6,21 @@ import jsonwebtoken from 'jsonwebtoken';
import { redis } from '@/app.ts';
import { createCookie, clearCookie } from './me.ts';
import z from 'zod';
app
.route({
path: 'user',
key: 'webLogin',
description: 'web登录接口配合插件使用',
middleware: [authCan],
metadata: {
args: {
loginToken: z.string().describe('web登录令牌服务端生成客户端保持一致'),
sign: z.string().describe('签名,服务端生成,客户端保持一致'),
randomId: z.string().describe('随机字符串,服务端和客户端保持一致'),
}
}
})
.define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
@@ -97,6 +106,7 @@ app
.route({
path: 'user',
key: 'checkLoginStatus',
description: '循环检查登陆状态',
})
.define(async (ctx) => {
const { loginToken } = ctx.query;

22
src/test/cnb-login.ts Normal file
View File

@@ -0,0 +1,22 @@
import { app, showMore, cnbToken } from './common.ts';
const res = await app.run({
path: 'user',
key: 'cnb-login',
payload: {
data: {
cnbToken
}
}
})
console.log(showMore(res));
const token = res.data.token;
const me = await app.run({
path: 'user',
key: 'me',
payload: {
token
}
})
console.log(showMore(me));

View File

@@ -13,6 +13,7 @@ export {
export const config = useConfig();
export const token = config.KEVISUAL_TOKEN || '';
export const cnbToken = config.CNB_TOKEN || '';
export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export const showRes = (res, ...args) => {