592 lines
16 KiB
TypeScript
592 lines
16 KiB
TypeScript
import { nanoid, customAlphabet } from 'nanoid';
|
||
import { CustomError } from '@kevisual/router';
|
||
import { useContextKey } from '@kevisual/context';
|
||
import { Redis } from 'ioredis';
|
||
import { oauth, jwksManager } from '../oauth/auth.ts';
|
||
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';
|
||
|
||
// 类型定义
|
||
export type UserData = {
|
||
orgs?: string[];
|
||
wxUnionId?: string;
|
||
phone?: string;
|
||
canChangeUsername?: boolean;
|
||
cnbId?: string;
|
||
};
|
||
|
||
export enum UserTypes {
|
||
user = 'user',
|
||
org = 'org',
|
||
visitor = 'visitor',
|
||
}
|
||
|
||
export type UserSelect = InferSelectModel<typeof cfUser>;
|
||
export type UserInsert = InferInsertModel<typeof cfUser>;
|
||
export type OrgSelect = InferSelectModel<typeof cfOrgs>;
|
||
|
||
const usersTable = cfUser;
|
||
const orgsTable = cfOrgs;
|
||
const userSecretsTable = cfUserSecrets;
|
||
|
||
// 常量定义
|
||
const JWKS_TOKEN_EXPIRY = 2 * 3600; // 2 hours in seconds
|
||
|
||
export const redis = useContextKey<Redis>('redis');
|
||
|
||
type TokenOptions = {
|
||
expire?: number; // 过期时间,单位秒
|
||
ip?: string; // 用户ID,默认为当前用户ID
|
||
browser?: string; // 浏览器信息
|
||
host?: string; // 主机信息
|
||
wx?: any;
|
||
loginWith?: string; // 登录方式,如 'cli', 'web', 'plugin' 等
|
||
hasRefreshToken?: boolean; // 是否需要 refresh token,默认为 false
|
||
}
|
||
/**
|
||
* 用户模型,使用 Drizzle ORM
|
||
*/
|
||
export class User {
|
||
static oauth = oauth;
|
||
id: string;
|
||
username: string;
|
||
nickname: string;
|
||
password: string;
|
||
salt: string;
|
||
needChangePassword: boolean;
|
||
description: string;
|
||
data: UserData;
|
||
type: string;
|
||
owner: string;
|
||
orgId: string;
|
||
email: string;
|
||
avatar: string;
|
||
tokenUser: any;
|
||
|
||
constructor(data?: UserSelect) {
|
||
if (data) {
|
||
Object.assign(this, data);
|
||
}
|
||
}
|
||
|
||
setTokenUser(tokenUser: any) {
|
||
this.tokenUser = tokenUser;
|
||
}
|
||
|
||
/**
|
||
* uid 是用于 orgId 的用户id, 如果uid存在,则表示是用户是组织,其中uid为真实用户
|
||
* @param uid
|
||
* @returns
|
||
*/
|
||
/**
|
||
* 创建JWKS token的通用方法
|
||
*/
|
||
static async createJwksTokenResponse(user: { id: string; username: string }, opts: { expire?: number, hasRefreshToken?: boolean } = {}) {
|
||
const expiresIn = opts?.expire ?? JWKS_TOKEN_EXPIRY;
|
||
const hasRefreshToken = opts?.hasRefreshToken ?? true;
|
||
const accessToken = await jwksManager.sign({
|
||
sub: 'user:' + user.id,
|
||
name: user.username,
|
||
exp: Math.floor(Date.now() / 1000) + expiresIn,
|
||
});
|
||
if (hasRefreshToken) {
|
||
await oauth.setJwksToken(accessToken, { id: user.id, expire: expiresIn });
|
||
}
|
||
|
||
const token = {
|
||
accessToken,
|
||
refreshToken: accessToken,
|
||
token: accessToken,
|
||
refreshTokenExpiresIn: expiresIn,
|
||
accessTokenExpiresIn: expiresIn,
|
||
};
|
||
|
||
return {
|
||
type: 'jwks',
|
||
...token,
|
||
};
|
||
}
|
||
|
||
async createToken(uid?: string, loginType?: 'default' | 'plugin' | 'month' | 'season' | 'year' | 'week' | 'jwks', opts: TokenOptions = {}) {
|
||
const { id, username, type } = this;
|
||
const hasRefreshToken = opts.hasRefreshToken ?? true;
|
||
const oauthUser: OauthUser = {
|
||
id,
|
||
username,
|
||
uid,
|
||
userId: uid || id, // 必存在,真实用户id
|
||
type: type as 'user' | 'org',
|
||
};
|
||
if (uid) {
|
||
oauthUser.orgId = id;
|
||
}
|
||
if (loginType === 'jwks') {
|
||
return await User.createJwksTokenResponse(this, opts);
|
||
}
|
||
const token = await oauth.generateToken(oauthUser, { type: loginType, hasRefreshToken, ...opts });
|
||
return {
|
||
type: 'default',
|
||
...token,
|
||
};
|
||
}
|
||
/**
|
||
* 验证token
|
||
* @param token
|
||
* @returns
|
||
*/
|
||
static async verifyToken(token: string) {
|
||
return await UserSecret.verifyToken(token);
|
||
}
|
||
static async checkJwksValid(token: string) {
|
||
const verified = await User.verifyToken(token);
|
||
let isValid = false;
|
||
if (verified) {
|
||
isValid = true;
|
||
}
|
||
const jwksToken = await oauth.getJwksToken(token);
|
||
if (!isValid && !jwksToken) {
|
||
throw new CustomError('Invalid refresh token');
|
||
}
|
||
}
|
||
/**
|
||
* 刷新token
|
||
* @param refreshToken
|
||
* @returns
|
||
*/
|
||
static async refreshToken(opts: { refreshToken?: string, accessToken?: string }) {
|
||
const { refreshToken, accessToken } = opts;
|
||
let jwsRefreshToken = accessToken || refreshToken;
|
||
if (oauth.getTokenType(jwsRefreshToken) === 'jwks') {
|
||
// 可能是 jwks token
|
||
await User.checkJwksValid(jwsRefreshToken);
|
||
const decoded = await jwksManager.decode(jwsRefreshToken);
|
||
return await User.createJwksTokenResponse({
|
||
id: decoded.sub.replace('user:', ''),
|
||
username: decoded.name
|
||
});
|
||
}
|
||
if (!refreshToken && !accessToken) {
|
||
throw new CustomError('Refresh Token or Access Token 必须提供一个');
|
||
}
|
||
if (accessToken) {
|
||
try {
|
||
const token = await User.refreshTokenByAccessToken(accessToken);
|
||
return token;
|
||
} catch (e) {
|
||
// access token 无效,继续使用 refresh token 刷新
|
||
}
|
||
}
|
||
const token = await User.refreshTokenByRefreshToken(refreshToken);
|
||
return {
|
||
type: 'default',
|
||
...token,
|
||
};
|
||
}
|
||
static async refreshTokenByAccessToken(accessToken: string) {
|
||
const accessUser = await User.verifyToken(accessToken);
|
||
if (!accessUser) {
|
||
throw new CustomError('Invalid access token');
|
||
}
|
||
const refreshToken = accessUser.oauthExpand?.refreshToken;
|
||
if (refreshToken) {
|
||
return await User.refreshTokenByRefreshToken(refreshToken);
|
||
} else {
|
||
await User.oauth.delToken(accessToken);
|
||
const token = await User.oauth.generateToken(accessUser, {
|
||
...accessUser.oauthExpand,
|
||
hasRefreshToken: true,
|
||
});
|
||
return {
|
||
type: 'default',
|
||
...token,
|
||
};
|
||
}
|
||
}
|
||
static async refreshTokenByRefreshToken(refreshToken: string) {
|
||
const token = await oauth.refreshToken(refreshToken);
|
||
return {
|
||
type: 'default',
|
||
...token
|
||
};
|
||
}
|
||
/**
|
||
* 重置token,立即过期token
|
||
* @param token
|
||
* @returns
|
||
*/
|
||
static async resetToken(refreshToken: string, expand?: Record<string, any>) {
|
||
if (oauth.getTokenType(refreshToken) === 'jwks') {
|
||
// 可能是 jwks token
|
||
await User.checkJwksValid(refreshToken);
|
||
const decoded = await jwksManager.decode(refreshToken);
|
||
return await User.createJwksTokenResponse({
|
||
id: decoded.sub.replace('user:', ''),
|
||
username: decoded.name
|
||
});
|
||
}
|
||
return await oauth.resetToken(refreshToken, expand);
|
||
}
|
||
static async getOauthUser(token: string) {
|
||
return await UserSecret.verifyToken(token);
|
||
}
|
||
/**
|
||
* 清理用户的token,需要重新登陆
|
||
* @param userid
|
||
* @param orgid
|
||
* @returns
|
||
*/
|
||
static async clearUserToken(userid: string, type: 'org' | 'user' = 'user') {
|
||
return await oauth.expireUserTokens(userid, type);
|
||
}
|
||
/**
|
||
* 获取用户信息, 并设置tokenUser
|
||
* @param token
|
||
* @returns
|
||
*/
|
||
static async getUserByToken(token: string) {
|
||
const oauthUser = await UserSecret.verifyToken(token);
|
||
if (!oauthUser) {
|
||
throw new CustomError('Token is invalid. get UserByToken');
|
||
}
|
||
const userId = oauthUser?.uid || oauthUser.id;
|
||
const user = await User.findByPk(userId);
|
||
if (!user) {
|
||
throw new CustomError('User not found');
|
||
}
|
||
user.setTokenUser(oauthUser);
|
||
return user;
|
||
}
|
||
/**
|
||
* 判断是否在用户列表中, 需要预先设置 tokenUser
|
||
* orgs has set curentUser
|
||
* @param username
|
||
* @param includeMe
|
||
* @returns
|
||
*/
|
||
async hasUser(username: string, includeMe = true) {
|
||
const orgs = await this.getOrgs();
|
||
const me = this.username;
|
||
const allUsers = [...orgs];
|
||
if (includeMe) {
|
||
allUsers.push(me);
|
||
}
|
||
return allUsers.includes(username);
|
||
}
|
||
|
||
/**
|
||
* 根据主键查找用户
|
||
*/
|
||
static async findByPk(id: string): Promise<User | null> {
|
||
const users = await db.select().from(usersTable).where(eq(usersTable.id, id)).limit(1);
|
||
return users.length > 0 ? new User(users[0]) : null;
|
||
}
|
||
|
||
/**
|
||
* 根据微信 UnionId 查找用户
|
||
*/
|
||
static async findByUnionId(unionId: string): Promise<User | null> {
|
||
const users = await db
|
||
.select()
|
||
.from(usersTable)
|
||
.where(sql`${usersTable.data}->>'wxUnionId' = ${unionId}`)
|
||
.limit(1);
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 根据条件查找一个用户
|
||
*/
|
||
static async findOne(where: { username?: string; id?: string; email?: string }): Promise<User | null> {
|
||
let query = db.select().from(usersTable);
|
||
|
||
if (where.username) {
|
||
query = query.where(eq(usersTable.username, where.username)) as any;
|
||
} else if (where.id) {
|
||
query = query.where(eq(usersTable.id, where.id)) as any;
|
||
} else if (where.email) {
|
||
query = query.where(eq(usersTable.email, where.email)) as any;
|
||
}
|
||
|
||
const users = await query.limit(1);
|
||
return users.length > 0 ? new User(users[0]) : null;
|
||
}
|
||
/**
|
||
* 创建新用户
|
||
*/
|
||
static async createUser(username: string, password?: string, description?: string) {
|
||
const user = await User.findOne({ username });
|
||
if (user) {
|
||
throw new CustomError('User already exists');
|
||
}
|
||
const salt = nanoid(6);
|
||
let needChangePassword = !password;
|
||
password = password || '123456';
|
||
const cPassword = cryptPwd(password, salt);
|
||
|
||
const insertData: any = {
|
||
username,
|
||
password: cPassword,
|
||
salt,
|
||
};
|
||
|
||
// 只在需要时才设置非默认值
|
||
if (needChangePassword) {
|
||
insertData.needChangePassword = true;
|
||
}
|
||
|
||
if (description !== undefined && description !== null) {
|
||
insertData.description = description;
|
||
}
|
||
try {
|
||
const inserted = await db.insert(usersTable).values(insertData).returning();
|
||
return new User(inserted[0]);
|
||
} catch (e) {
|
||
console.log(e)
|
||
throw e
|
||
}
|
||
|
||
}
|
||
|
||
static async createOrg(username: string, owner: string, description?: string) {
|
||
const user = await User.findOne({ username });
|
||
if (user) {
|
||
throw new CustomError('User already exists');
|
||
}
|
||
const me = await User.findByPk(owner);
|
||
if (!me) {
|
||
throw new CustomError('Owner not found');
|
||
}
|
||
if (me.type !== 'user') {
|
||
throw new CustomError('Owner type is not user');
|
||
}
|
||
|
||
const org = await Org.create({ username, description, users: [{ uid: owner, role: 'owner' }] });
|
||
|
||
const insertData: any = {
|
||
username,
|
||
password: '',
|
||
type: 'org',
|
||
owner,
|
||
orgId: org.id,
|
||
};
|
||
|
||
if (description !== undefined && description !== null) {
|
||
insertData.description = description;
|
||
}
|
||
|
||
const inserted = await db.insert(usersTable).values(insertData).returning();
|
||
|
||
// owner add
|
||
await redis.del(`user:${me.id}:orgs`);
|
||
return new User(inserted[0]);
|
||
}
|
||
async createPassword(password: string) {
|
||
const salt = this.salt;
|
||
const cPassword = cryptPwd(password, salt);
|
||
this.password = cPassword;
|
||
await db.update(usersTable).set({ password: cPassword }).where(eq(usersTable.id, this.id));
|
||
return cPassword;
|
||
}
|
||
|
||
checkPassword(password: string) {
|
||
const salt = this.salt;
|
||
const cPassword = cryptPwd(password, salt);
|
||
return this.password === cPassword;
|
||
}
|
||
|
||
/**
|
||
* 更新用户
|
||
*/
|
||
async update(data: Partial<UserInsert>) {
|
||
await db.update(usersTable).set(data).where(eq(usersTable.id, this.id));
|
||
Object.assign(this, data);
|
||
}
|
||
|
||
/**
|
||
* 保存用户
|
||
*/
|
||
async save() {
|
||
await db.update(usersTable).set({
|
||
username: this.username,
|
||
nickname: this.nickname,
|
||
password: this.password,
|
||
email: this.email,
|
||
avatar: this.avatar,
|
||
salt: this.salt,
|
||
description: this.description,
|
||
type: this.type,
|
||
owner: this.owner,
|
||
orgId: this.orgId,
|
||
needChangePassword: this.needChangePassword,
|
||
data: this.data,
|
||
updatedAt: new Date().toISOString(),
|
||
}).where(eq(usersTable.id, this.id));
|
||
}
|
||
/**
|
||
* 获取用户信息, 需要先设置 tokenUser 或者设置 uid
|
||
* @param uid 如果存在,则表示是组织,其中uid为真实用户
|
||
* @returns
|
||
*/
|
||
async getInfo(uid?: string) {
|
||
const orgs = await this.getOrgs();
|
||
|
||
const info: Record<string, any> = {
|
||
id: this.id,
|
||
username: this.username,
|
||
nickname: this.nickname,
|
||
description: this.description,
|
||
needChangePassword: this.needChangePassword,
|
||
type: this.type,
|
||
avatar: this.avatar,
|
||
orgs,
|
||
};
|
||
if (this.data?.canChangeUsername) {
|
||
info.canChangeUsername = this.data.canChangeUsername;
|
||
}
|
||
const tokenUser = this.tokenUser;
|
||
if (uid) {
|
||
info.uid = uid;
|
||
} else if (tokenUser.uid) {
|
||
info.uid = tokenUser.uid;
|
||
}
|
||
return info;
|
||
}
|
||
/**
|
||
* 获取用户组织
|
||
* @returns
|
||
*/
|
||
async getOrgs() {
|
||
let id = this.id;
|
||
if (this.type === 'org') {
|
||
if (this.tokenUser && this.tokenUser.uid) {
|
||
id = this.tokenUser.uid;
|
||
} else {
|
||
throw new CustomError('Permission denied', { code: 400 });
|
||
}
|
||
}
|
||
const cache = await redis.get(`user:${id}:orgs`);
|
||
if (cache) {
|
||
return JSON.parse(cache) as string[];
|
||
}
|
||
|
||
// 使用 Drizzle 的 SQL 查询来检查 JSONB 数组
|
||
const orgs = await db
|
||
.select()
|
||
.from(orgsTable)
|
||
.where(sql`${orgsTable.users} @> ${JSON.stringify([{ uid: id }])}::jsonb`)
|
||
.orderBy(sql`${orgsTable.updatedAt} DESC`);
|
||
|
||
const orgNames = orgs.map((org) => org.username);
|
||
if (orgNames.length > 0) {
|
||
await redis.set(`user:${id}:orgs`, JSON.stringify(orgNames), 'EX', 60 * 60); // 1 hour
|
||
}
|
||
return orgNames;
|
||
}
|
||
async expireOrgs() {
|
||
await redis.del(`user:${this.id}:orgs`);
|
||
}
|
||
static async getUserNameById(id: string) {
|
||
const redisName = await redis.get(`user:id:${id}:name`);
|
||
if (redisName) {
|
||
return redisName;
|
||
}
|
||
const user = await User.findByPk(id);
|
||
if (user?.username) {
|
||
await redis.set(`user:id:${id}:name`, user.username, 'EX', 60 * 60); // 1 hour
|
||
}
|
||
return user?.username;
|
||
}
|
||
|
||
/**
|
||
* 查找所有符合条件的用户
|
||
*/
|
||
static async findAll(options: { where?: any; attributes?: string[] }) {
|
||
let query = db.select().from(usersTable);
|
||
|
||
if (options.where?.id?.in) {
|
||
query = query.where(sql`${usersTable.id} = ANY(${options.where.id.in})`) as any;
|
||
}
|
||
|
||
const users = await query;
|
||
return users.map(u => new User(u));
|
||
}
|
||
}
|
||
|
||
const letter = 'abcdefghijklmnopqrstuvwxyz';
|
||
const custom = customAlphabet(letter, 6);
|
||
|
||
export const initializeUser = async (pwd = custom()) => {
|
||
const w = await User.findOne({ username: 'root' });
|
||
if (!w) {
|
||
const root = await User.createUser('root', pwd, '系统管理员');
|
||
const org = await User.createOrg('admin', root.id, '管理员');
|
||
console.info(' new Users name', root.username, org.username);
|
||
console.info('new Users root password', pwd);
|
||
console.info('new Users id', root.id, org.id);
|
||
const demo = await createDemoUser();
|
||
return {
|
||
code: 200,
|
||
data: { root, org, pwd: pwd, demo },
|
||
};
|
||
} else {
|
||
return {
|
||
code: 500,
|
||
message: 'Users has been created',
|
||
};
|
||
}
|
||
};
|
||
|
||
export const createDemoUser = async (username = 'demo', pwd = custom()) => {
|
||
const u = await User.findOne({ username });
|
||
if (!u) {
|
||
const user = await User.createUser(username, pwd, 'demo');
|
||
console.info('new Users name', user.username, pwd);
|
||
return {
|
||
code: 200,
|
||
data: { user, pwd: pwd },
|
||
};
|
||
} else {
|
||
console.info('Users has been created', u.username);
|
||
return {
|
||
code: 500,
|
||
message: 'Users has been created',
|
||
};
|
||
}
|
||
};
|
||
|
||
export class UserServices extends User {
|
||
static async loginByPhone(phone: string) {
|
||
let user = await User.findOne({ username: phone });
|
||
let isNew = false;
|
||
if (!user) {
|
||
user = await User.createUser(phone, phone.slice(-6));
|
||
isNew = true;
|
||
}
|
||
const token = await user.createToken(null, 'season');
|
||
return { ...token, isNew };
|
||
}
|
||
static initializeUser = initializeUser;
|
||
static createDemoUser = createDemoUser;
|
||
}
|
||
|
||
export const UserModel = useContextKey('UserModel', () => UserServices);
|