feat: add oauth

This commit is contained in:
xion 2025-03-21 13:41:34 +08:00
parent ceab01aefa
commit 4f12ed332c
10 changed files with 431 additions and 95 deletions

View File

@ -83,6 +83,10 @@
"./models": {
"import": "./dist/models.mjs",
"types": "./dist/models.d.ts"
},
"./oauth": {
"import": "./dist/oauth.mjs",
"types": "./dist/oauth.d.ts"
}
}
}

View File

@ -13,20 +13,16 @@ const version = pkgs.version|| '1.0.0';
const external = [
/@kevisual\/router(\/.*)?/, //, // 路由
/@kevisual\/use-config(\/.*)?/, //
/@kevisual\/auth(\/.*)?/, //
'sequelize', // 数据库 orm
'ioredis', // redis
'socket.io', // socket.io
'minio', // minio
'pm2',
'pg', // pg
'pino', // pino
'pino-pretty', // pino-pretty
'@msgpack/msgpack', // msgpack
]
const replaceConfig = {
preventAssignment: true, // 防止意外赋值
DEV_SERVER: JSON.stringify(isDev), // 替换 process.env.NODE_ENV
VERSION: JSON.stringify(version), // 替换版本号
}
/**
* @type {import('rollup').RollupOptions}
*/
@ -35,50 +31,11 @@ const config = {
output: {
dir: './dist',
entryFileNames: 'lib.mjs',
chunkFileNames: '[name]-[hash].mjs',
format: 'esm',
},
plugins: [
replace({
preventAssignment: true, // 防止意外赋值
DEV_SERVER: JSON.stringify(isDev), // 替换 process.env.NODE_ENV
VERSION: JSON.stringify(version), // 替换版本号
}),
replace(replaceConfig),
alias({
// only esbuild needs to be configured
entries: [
{ find: '@', replacement: path.resolve('src') }, // 配置 @ 为 src 目录
],
}),
resolve({
preferBuiltins: true, // 强制优先使用内置模块
}),
commonjs(),
esbuild({
target: 'node22', // 目标为 Node.js 14
minify: false, // 启用代码压缩
tsconfig: 'tsconfig.json',
}),
json(),
],
external: external,
};
const configCjs = {
input: './src/lib.ts',
output: {
dir: './dist',
entryFileNames: 'lib.cjs',
chunkFileNames: '[name]-[hash].cjs',
format: 'cjs',
},
plugins: [
replace({
preventAssignment: true, // 防止意外赋值
DEV_SERVER: JSON.stringify(isDev), // 替换 process.env.NODE_ENV
VERSION: JSON.stringify(version), // 替换版本号
}),
alias({
// only esbuild needs to be configured
entries: [
{ find: '@', replacement: path.resolve('src') }, // 配置 @ 为 src 目录
],
@ -96,6 +53,7 @@ const configCjs = {
],
external: external,
};
const dtsConfig = {
input: './src/lib.ts',
output: {
@ -113,15 +71,10 @@ const systemConfig = [
output: {
dir: './dist',
entryFileNames: 'system.mjs',
chunkFileNames: '[name]-[hash].mjs',
format: 'esm',
},
plugins: [
replace({
preventAssignment: true, // 防止意外赋值
DEV_SERVER: JSON.stringify(isDev), // 替换 process.env.NODE_ENV
VERSION: JSON.stringify(version), // 替换版本号
}),
replace(replaceConfig),
alias({
entries: [
{ find: '@', replacement: path.resolve('src') }, // 配置 @ 为 src 目录
@ -158,15 +111,10 @@ export const modelConfig = [
output: {
dir: './dist',
entryFileNames: 'models.mjs',
chunkFileNames: '[name]-[hash].mjs',
format: 'esm',
},
plugins: [
replace({
preventAssignment: true, // 防止意外赋值
DEV_SERVER: JSON.stringify(isDev), // 替换 process.env.NODE_ENV
VERSION: JSON.stringify(version), // 替换版本号
}),
replace(replaceConfig),
alias({
entries: [
{ find: '@', replacement: path.resolve('src') }, // 配置 @ 为 src 目录
@ -199,4 +147,44 @@ export const modelConfig = [
],
},
]
export default [config, dtsConfig, ...systemConfig, ...modelConfig];
const oauthConfig = [
{
input: './src/oauth/index.ts',
output: {
dir: './dist',
entryFileNames: 'oauth.mjs',
format: 'esm',
},
plugins: [
replace(replaceConfig),
alias({
entries: [
{ find: '@', replacement: path.resolve('src') }, // 配置 @ 为 src 目录
],
}),
resolve({
preferBuiltins: true, // 强制优先使用内置模块
}),
commonjs(),
esbuild({
target: 'node22', // 目标为 Node.js 14
minify: false, // 启用代码压缩
tsconfig: 'tsconfig.json',
}),
json(),
],
},
{
input: './src/oauth/index.ts',
output: {
dir: './dist',
entryFileNames: 'oauth.d.ts',
format: 'esm',
},
plugins: [
dts(),
],
},
]
export default [config, dtsConfig, ...systemConfig, ...modelConfig, ...oauthConfig];

View File

@ -1,4 +1,8 @@
/**
* Sequlize也不要了sequelize自己默认已经有了
*/
import { UserServices, User, UserInit, UserModel } from './models/user.ts';
import { Org, OrgInit, OrgModel } from './models/org.ts';
import { addAuth } from './middleware/auth.ts';
export { User, Org, UserServices, UserInit, OrgInit, UserModel, OrgModel };
export { addAuth };

View File

@ -1,5 +1,5 @@
/**
* redis和sequelizeuseContextKey当中
* @description redis和sequelizeuseContextKey当中
*/
import { app } from './app.ts';
import { UserServices, UserInit, UserModel, User } from './models/user.ts';

53
src/middleware/auth.ts Normal file
View File

@ -0,0 +1,53 @@
import { User } from '../models/user.ts';
import type { App } from '@kevisual/router';
/**
* auth中间件, token
* @param app
*/
export const addAuth = (app: App) => {
app
.route({
path: 'auth',
id: 'auth',
})
.define(async (ctx) => {
const token = ctx.query.token;
if (!token) {
app.throw(401, 'Token is required');
}
const user = await User.getOauthUser(token);
if (!user) {
app.throw(401, 'Token is invalid');
}
if (ctx.state) {
ctx.state.tokenUser = user;
} else {
ctx.state = {
tokenUser: user,
};
}
})
.addTo(app);
app
.route({
path: 'auth',
key: 'can',
id: 'auth-can',
})
.define(async (ctx) => {
if (ctx.query?.token) {
const token = ctx.query.token;
const user = await User.getOauthUser(token);
if (ctx.state) {
ctx.state.tokenUser = user;
} else {
ctx.state = {
tokenUser: user,
};
}
}
})
.addTo(app);
};

View File

@ -1,13 +1,14 @@
import { useConfig } from '@kevisual/use-config';
import { DataTypes, Model, Op, Sequelize } from 'sequelize';
import { createToken, checkToken } from '@kevisual/auth';
import { cryptPwd } from '@kevisual/auth';
import { customRandom, nanoid, customAlphabet } from 'nanoid';
import { nanoid, customAlphabet } from 'nanoid';
import { CustomError } from '@kevisual/router';
import { Org } from './org.ts';
import { useContextKey } from '@kevisual/use-config/context';
import { Redis } from 'ioredis';
import { oauth } from '../oauth/auth.ts';
import { cryptPwd } from '../oauth/salt.ts';
import { OauthUser } from '../oauth/oauth.ts';
export const redis = useContextKey<Redis>('redis');
const config = useConfig<{ tokenSecret: string }>();
@ -25,6 +26,7 @@ export enum UserTypes {
* ,sequelize和Org之后初始化
*/
export class User extends Model {
static oauth = oauth;
declare id: string;
declare username: string;
declare nickname: string; // 昵称
@ -43,44 +45,51 @@ export class User extends Model {
this.tokenUser = tokenUser;
}
/**
* uid orgId id id
* uid orgId id, uid存在uid为真实用户
* @param uid
* @returns
*/
async createToken(uid?: string, loginType?: 'default' | 'plugin' | 'month' | 'season' | 'year') {
async createToken(uid?: string, loginType?: 'default' | 'plugin' | 'month' | 'season' | 'year', expand: any = {}) {
const { id, username, type } = this;
let expireTime = 60 * 60 * 24 * 7; // 7 days
switch (loginType) {
case 'plugin':
expireTime = 60 * 60 * 24 * 30 * 12; // 365 days
break;
case 'month':
expireTime = 60 * 60 * 24 * 30; // 30 days
break;
case 'season':
expireTime = 60 * 60 * 24 * 30 * 3; // 90 days
break;
case 'year':
expireTime = 60 * 60 * 24 * 30 * 12; // 365 days
break;
const oauthUser: OauthUser = {
id,
username,
uid,
userId: uid || id, // 必存在真实用户id
type: type as 'user' | 'org',
};
if (uid) {
oauthUser.orgId = id;
}
const now = new Date().getTime();
const token = await createToken({ id, username, uid, type }, config.tokenSecret);
return { token, expireTime: now + expireTime };
const token = await oauth.generateToken(oauthUser, { type: loginType, hasRefreshToken: true, ...expand });
return { accessToken: token.accessToken, refreshToken: token.refreshToken, token: token.accessToken };
}
/**
* token
* @param token
* @returns
*/
static async verifyToken(token: string) {
const ct = await checkToken(token, config.tokenSecret);
const tokenUser = ct.payload;
return tokenUser;
return await oauth.verifyToken(token);
}
/**
* token
* @param refreshToken
* @returns
*/
static async refreshToken(refreshToken: string) {
const token = await oauth.refreshToken(refreshToken);
return { accessToken: token.accessToken, refreshToken: token.refreshToken, token: token.accessToken };
}
static async getOauthUser(token: string) {
return await oauth.verifyToken(token);
}
static async getUserByToken(token: string) {
const ct = await checkToken(token, config.tokenSecret);
const tokenUser = ct.payload;
let userId = tokenUser.id;
if (tokenUser.uid) {
// 如果tokenUser.uid 存在则表示是token是o用户的user需要获取o的真实用户
userId = tokenUser.uid;
const oauthUser = await oauth.verifyToken(token);
if (!oauthUser) {
throw new CustomError('Token is invalid');
}
const userId = oauthUser?.uid || oauthUser.id;
const user = await User.findByPk(userId);
return user;
}

6
src/oauth/auth.ts Normal file
View File

@ -0,0 +1,6 @@
import { OAuth, RedisTokenStore } from './oauth.ts';
import { useContextKey } from '@kevisual/use-config/context';
import { Redis } from 'ioredis';
export const redis = useContextKey<Redis>('redis');
export const oauth = useContextKey('oauth', () => new OAuth(new RedisTokenStore(redis)));

2
src/oauth/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from './oauth.ts';
export * from './salt.ts';

238
src/oauth/oauth.ts Normal file
View File

@ -0,0 +1,238 @@
/**
* token的模块使jwt使redis缓存
* token access_tokenrefresh_token
*
* access_token 1
* refresh_token access_token7
*
* token时access_token和refresh_tokenredis中
* token时token从redis中获取用户信息
* token时refresh_token生成一个新的access_token和refresh_tokenredis中
*
* access_token和refresh_token
*
* token的方法使nanoid生成一个随机字符串
* token的方法使redis的get方法验证token是否存在
*
* token的方法使redis的set方法刷新token
*
* 使redisstore.get和store.set去实现
*/
import { Redis } from 'ioredis';
import { customAlphabet } from 'nanoid';
export const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
export const randomId16 = customAlphabet(alphabet, 16);
export const randomId24 = customAlphabet(alphabet, 24);
export const randomId32 = customAlphabet(alphabet, 32);
export const randomId64 = customAlphabet(alphabet, 64);
export type OauthUser = {
/**
* org
*/
id: string;
/**
* id
*/
orgId?: string;
/**
* id
*/
userId: string;
/**
* idorguid为org的id
*/
uid?: string;
username: string;
type?: 'user' | 'org'; // 用户类型默认是usertoken类型是用于token的扩展
oauthType?: 'user' | 'token'; // 用户类型默认是usertoken类型是用于token的扩展
oauthExpand?: UserExpand;
};
export type UserExpand = {
createTime?: number;
refreshToken?: string;
[key: string]: any;
} & StoreSetOpts;
type StoreSetOpts = {
loginType?: 'default' | 'plugin' | 'month' | 'season' | 'year'; // 登陆类型 'default' | 'plugin' | 'month' | 'season' | 'year'
expire?: number; // 过期时间,单位为秒
hasRefreshToken?: boolean;
[key: string]: any;
};
interface Store<T> {
getObject: (key: string) => Promise<T>;
setObject: (key: string, value: T, opts?: StoreSetOpts) => Promise<void>;
expire: (key: string, ttl?: number) => Promise<void>;
delObject: (key: string, value?: T) => Promise<void>;
setToken: (value: { accessToken: string; refreshToken: string; value?: T }, opts?: StoreSetOpts) => Promise<void>;
}
export class RedisTokenStore implements Store<OauthUser> {
private redis: Redis;
private prefix: string = 'oauth:';
constructor(redis: Redis, prefix?: string) {
this.redis = redis;
this.prefix = prefix || this.prefix;
}
async set(key: string, value: string, ttl?: number) {
await this.redis.set(this.prefix + key, value, 'EX', ttl);
}
async get(key: string) {
return await this.redis.get(this.prefix + key);
}
async getObject(key: string) {
try {
const value = await this.get(key);
if (!value) {
return null;
}
return JSON.parse(value);
} catch (error) {
return null;
}
}
async setObject(key: string, value: OauthUser, opts?: StoreSetOpts) {
await this.set(key, JSON.stringify(value), opts?.expire);
}
async expire(key: string, ttl?: number) {
await this.redis.expire(this.prefix + key, ttl);
}
async delObject(key: string, value?: OauthUser) {
await this.redis.del(this.prefix + key);
if (value) {
// await this.redis.del(this.prefix + value.refreshToken);
}
}
async setToken(data: { accessToken: string; refreshToken: string; value?: OauthUser }, opts?: StoreSetOpts) {
const { accessToken, refreshToken, value } = data;
let userPrefix = 'user:' + value?.id;
if (value?.orgId) {
userPrefix = 'org:' + value?.orgId + ':user:' + value?.id;
}
// 计算过期时间根据opts.expire 和 opts.loginType
// 如果expire存在则使用expire否则使用opts.loginType 进行计算;
let expire = opts?.expire;
if (!expire) {
switch (opts.loginType) {
case 'month':
expire = 30 * 24 * 60 * 60;
break;
case 'season':
expire = 90 * 24 * 60 * 60;
break;
default:
expire = 25 * 60 * 60; // 默认过期时间为25小时
}
} else {
expire = Math.min(expire, 60 * 60 * 24 * 30, 60 * 60 * 24 * 90); // 默认的过期时间最大为90天
}
await this.set(accessToken, JSON.stringify(value), expire);
await this.set(userPrefix + ':token:' + accessToken, accessToken, expire);
if (refreshToken) {
let refreshTokenExpire = Math.min(expire * 7, 60 * 60 * 24 * 30, 60 * 60 * 24 * 365); // 最大为一年
// 小于7天, 则设置为7天
if (refreshTokenExpire < 60 * 60 * 24 * 7) {
refreshTokenExpire = 60 * 60 * 24 * 7;
}
await this.set(refreshToken, JSON.stringify(value), refreshTokenExpire);
await this.set(userPrefix + ':refreshToken:' + refreshToken, refreshToken, refreshTokenExpire);
}
}
}
export class OAuth<T extends OauthUser> {
private store: Store<T>;
constructor(store: Store<T>) {
this.store = store;
}
/**
* token
* @param user
* @returns
*/
async generateToken(
user: T,
expandOpts?: StoreSetOpts,
): Promise<{
accessToken: string;
refreshToken?: string;
}> {
// 拥有refreshToken 为 true 时accessToken 为 st_ 开头refreshToken 为 rk_开头
// 意思是secretToken 和 secretKey的缩写
const accessToken = expandOpts?.hasRefreshToken ? 'st_' + randomId32() : 'sk_' + randomId64();
const refreshToken = expandOpts?.hasRefreshToken ? 'rk_' + randomId64() : null;
// 初始化 appExpand
user.oauthExpand = user.oauthExpand || {};
if (expandOpts) {
user.oauthExpand = {
...user.oauthExpand,
...expandOpts,
createTime: new Date().getTime(), //
};
if (expandOpts?.hasRefreshToken) {
user.oauthExpand.refreshToken = refreshToken;
}
}
await this.store.setToken({ accessToken, refreshToken, value: user }, expandOpts);
return { accessToken, refreshToken };
}
/**
* tokentoken不存在null
* @param token
* @returns
*/
async verifyToken(token: string) {
return await this.store.getObject(token);
}
/**
* token
* @param refreshToken
* @returns
*/
async refreshToken(refreshToken: string) {
const user = await this.store.getObject(refreshToken);
if (!user) {
// 过期
throw new Error('Refresh token not found');
}
const token = await this.generateToken(user, {
...user.oauthExpand,
hasRefreshToken: true,
});
// 删除旧的token
await this.store.delObject(refreshToken, user);
return token;
}
/**
* token的过期时间
* expand user.oauthExpand中
* @param token
* @returns
*/
async resetToken(accessToken: string, expand?: Record<string, any>) {
const user = await this.store.getObject(accessToken);
if (!user) {
// 过期
throw new Error('token not found');
}
user.oauthExpand = user.oauthExpand || {};
const refreshToken = user.oauthExpand.refreshToken;
if (refreshToken) {
await this.store.delObject(refreshToken, user);
}
user.oauthExpand = {
...user.oauthExpand,
...expand,
};
const token = await this.generateToken(user, {
...user.oauthExpand,
hasRefreshToken: true,
});
return token;
}
}

32
src/oauth/salt.ts Normal file
View File

@ -0,0 +1,32 @@
import MD5 from 'crypto-js/md5.js';
/**
*
* @returns
*/
export const getRandomSalt = () => {
return Math.random().toString().slice(2, 7);
};
/**
*
* @param password
* @param salt
* @returns
*/
export const cryptPwd = (password: string, salt = '') => {
const saltPassword = password + ':' + salt;
const md5 = MD5(saltPassword);
return md5.toString();
};
/**
* Check password
* @param password
* @param salt
* @param md5
* @returns
*/
export const checkPwd = (password: string, salt: string, md5: string) => {
return cryptPwd(password, salt) === md5;
};