diff --git a/client/web/package.json b/client/web/package.json
index e840b9f3..bb265da6 100644
--- a/client/web/package.json
+++ b/client/web/package.json
@@ -78,7 +78,7 @@
"source-ref-runtime": "^1.0.7",
"styled-components": "^5.3.6",
"tailchat-design": "workspace:^1.0.0",
- "tailchat-shared": "^1.0.0",
+ "tailchat-shared": "workspace:^1.0.0",
"tailwindcss": "^2.2.19",
"url": "^0.11.0",
"web-vitals": "^3.1.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 07d651d4..5e366ae0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -774,7 +774,7 @@ importers:
specifier: workspace:^1.0.0
version: link:../packages/design
tailchat-shared:
- specifier: ^1.0.0
+ specifier: workspace:^1.0.0
version: link:../shared
tailwindcss:
specifier: ^2.2.19
diff --git a/server/admin/src/client/App.tsx b/server/admin/src/client/App.tsx
index 9f8be26c..62c61538 100644
--- a/server/admin/src/client/App.tsx
+++ b/server/admin/src/client/App.tsx
@@ -23,7 +23,12 @@ import {
} from 'tushan/icon';
import { authHTTPClient, authProvider } from './auth';
import { Dashboard } from './components/Dashboard';
-import { discoverFields, mailFields, messageFields } from './fields';
+import {
+ discoverFields,
+ mailFields,
+ messageFields,
+ userLoginLogFields,
+} from './fields';
import { i18n } from './i18n';
import { GroupList } from './resources/group';
import { UserList } from './resources/user';
@@ -54,6 +59,27 @@ function App() {
} list={} />
+ }
+ list={
+
+ }
+ />
+
}
diff --git a/server/admin/src/client/fields.ts b/server/admin/src/client/fields.ts
index 100c77c7..a2908bc7 100644
--- a/server/admin/src/client/fields.ts
+++ b/server/admin/src/client/fields.ts
@@ -63,6 +63,26 @@ export const userFields = [
hidden: true,
},
}),
+ createTextField('lastLoginIp', {
+ edit: {
+ hidden: true,
+ },
+ }),
+ createDateTimeField('lastLoginAt', {
+ format: 'iso',
+ edit: {
+ hidden: true,
+ },
+ }),
+ createTextField('lastLoginUserAgent', {
+ list: {
+ width: 240,
+ ellipsis: true,
+ },
+ edit: {
+ hidden: true,
+ },
+ }),
createJSONField('settings', {
list: {
width: 200,
@@ -76,6 +96,39 @@ export const userFields = [
}),
];
+export const userLoginLogFields = [
+ createTextField('id', {
+ list: {
+ sort: true,
+ },
+ }),
+ createUserField('userId', {
+ reference: 'users',
+ displayField: (record) => `${record.nickname}#${record.discriminator}`,
+ list: {
+ width: 160,
+ },
+ }),
+ createTextField('ip', {
+ list: {
+ sort: true,
+ width: 140,
+ },
+ }),
+ createTextField('userAgent', {
+ list: {
+ width: 360,
+ ellipsis: true,
+ },
+ }),
+ createDateTimeField('createdAt', {
+ format: 'iso',
+ list: {
+ sort: true,
+ },
+ }),
+];
+
export const messageFields = [
createTextField('id', {
list: {
diff --git a/server/admin/src/client/i18n/zh.ts b/server/admin/src/client/i18n/zh.ts
index a950a9ab..16f4aa16 100644
--- a/server/admin/src/client/i18n/zh.ts
+++ b/server/admin/src/client/i18n/zh.ts
@@ -21,9 +21,22 @@ export const zhTranslation = {
emailVerified: '邮箱校验',
settings: '用户设置',
banned: '是否被封禁',
+ lastLoginIp: '最近登录IP',
+ lastLoginAt: '最近登录时间',
+ lastLoginUserAgent: '最近登录UA',
createdAt: '创建时间',
},
},
+ user_login_logs: {
+ name: '登录日志',
+ fields: {
+ id: '日志ID',
+ userId: '用户',
+ ip: '登录IP',
+ userAgent: 'User-Agent',
+ createdAt: '登录时间',
+ },
+ },
messages: {
name: '消息管理',
fields: {
diff --git a/server/admin/src/server/router/api.ts b/server/admin/src/server/router/api.ts
index 5fa8330e..2a38e2e3 100644
--- a/server/admin/src/server/router/api.ts
+++ b/server/admin/src/server/router/api.ts
@@ -7,6 +7,7 @@ import { networkRouter } from './network';
import { fileRouter } from './file';
import dayjs from 'dayjs';
import userModel from '../../../../models/user/user';
+import userLoginLogModel from '../../../../models/user/userLoginLog';
import messageModel from '../../../../models/chat/message';
import fileModel from '../../../../models/file';
import groupModel from '../../../../models/group/group';
@@ -179,6 +180,19 @@ router.use(
allowedRegexFields: ['nickname'],
})
);
+router.use(
+ '/user_login_logs',
+ auth(),
+ raExpressMongoose(userLoginLogModel, {
+ q: ['ip', 'userAgent'],
+ allowedRegexFields: ['ip', 'userAgent'],
+ capabilities: {
+ create: false,
+ update: false,
+ delete: false,
+ },
+ })
+);
router.delete('/messages/:id', auth(), async (req, res) => {
try {
const messageId = req.params.id;
diff --git a/server/models/user/user.ts b/server/models/user/user.ts
index 09e27ab5..e5d61603 100644
--- a/server/models/user/user.ts
+++ b/server/models/user/user.ts
@@ -116,6 +116,26 @@ export class User extends TimeStamps implements Base {
})
banned: boolean;
+ /**
+ * 最近一次登录IP
+ */
+ @prop({
+ index: true,
+ })
+ lastLoginIp?: string;
+
+ /**
+ * 最近一次登录时间
+ */
+ @prop()
+ lastLoginAt?: Date;
+
+ /**
+ * 最近一次登录 User-Agent
+ */
+ @prop()
+ lastLoginUserAgent?: string;
+
/**
* 用户的额外信息
*/
diff --git a/server/models/user/userLoginLog.ts b/server/models/user/userLoginLog.ts
new file mode 100644
index 00000000..bb618474
--- /dev/null
+++ b/server/models/user/userLoginLog.ts
@@ -0,0 +1,55 @@
+import {
+ getModelForClass,
+ prop,
+ DocumentType,
+ Ref,
+ modelOptions,
+ Severity,
+ index,
+} from '@typegoose/typegoose';
+import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses';
+import type { Types } from 'mongoose';
+import { User } from './user';
+
+@modelOptions({
+ options: {
+ allowMixed: Severity.ALLOW,
+ },
+ schemaOptions: {
+ collection: 'user_login_logs',
+ },
+})
+@index({ userId: 1, createdAt: -1 })
+@index({ ip: 1, createdAt: -1 })
+export class UserLoginLog extends TimeStamps implements Base {
+ _id: Types.ObjectId;
+ id: string;
+
+ /**
+ * 登录用户
+ */
+ @prop({ ref: () => User, required: true })
+ userId: Ref;
+
+ /**
+ * 登录IP
+ */
+ @prop({
+ required: true,
+ })
+ ip: string;
+
+ /**
+ * 登录 User-Agent
+ */
+ @prop()
+ userAgent?: string;
+}
+
+export type UserLoginLogDocument = DocumentType;
+
+const model = getModelForClass(UserLoginLog);
+
+export type UserLoginLogModel = typeof model;
+
+export default model;
diff --git a/server/services/core/gateway.service.ts b/server/services/core/gateway.service.ts
index 8f329fa7..539579c5 100644
--- a/server/services/core/gateway.service.ts
+++ b/server/services/core/gateway.service.ts
@@ -21,6 +21,21 @@ import send from 'send';
import path from 'path';
import mime from 'mime';
+function getHeaderValue(
+ header: string | string[] | undefined
+): string | undefined {
+ return Array.isArray(header) ? header[0] : header;
+}
+
+function getRequestIp(req: IncomingMessage): string | undefined {
+ const forwardedFor = getHeaderValue(req.headers['x-forwarded-for'])
+ ?.split(',')[0]
+ ?.trim();
+ const realIp = getHeaderValue(req.headers['x-real-ip'])?.trim();
+
+ return forwardedFor || realIp || req.socket.remoteAddress;
+}
+
export default class ApiService extends TcService {
authWhitelist = [];
@@ -142,13 +157,17 @@ export default class ApiService extends TcService {
* @param {ServerResponse} res
* @param {Object} data*/
onBeforeCall(
- ctx: PureContext,
+ ctx: PureContext<
+ any,
+ { userAgent?: string; language: string; ip?: string }
+ >,
route: object,
req: IncomingMessage,
res: ServerResponse
) {
// Set request headers to context meta
ctx.meta.userAgent = req.headers['user-agent'];
+ ctx.meta.ip = getRequestIp(req);
ctx.meta.language = parseLanguageFromHead(
req.headers['accept-language']
);
diff --git a/server/services/core/user/user.service.ts b/server/services/core/user/user.service.ts
index 42a6d2c6..84d1c319 100644
--- a/server/services/core/user/user.service.ts
+++ b/server/services/core/user/user.service.ts
@@ -31,6 +31,7 @@ import {
import type { TFunction } from 'i18next';
import _ from 'lodash';
import type { UserStruct } from 'tailchat-server-sdk';
+import userLoginLogModel from '../../../models/user/userLoginLog';
const { isValidObjectId, Types } = db;
@@ -284,6 +285,36 @@ class UserService extends TcService {
comparePassword = async (password: string, hash: string): Promise =>
bcrypt.compare(password, hash);
+ private async recordUserLoginMeta(userId: string, meta: any) {
+ if (typeof meta.ip !== 'string' || meta.ip.length === 0) {
+ return;
+ }
+
+ const loginRecord: Partial = {
+ lastLoginIp: meta.ip,
+ lastLoginAt: new Date(),
+ };
+
+ if (typeof meta.userAgent === 'string' && meta.userAgent.length > 0) {
+ loginRecord.lastLoginUserAgent = meta.userAgent;
+ }
+
+ await this.adapter.model.updateOne(
+ {
+ _id: userId,
+ },
+ {
+ $set: loginRecord,
+ }
+ );
+
+ await userLoginLogModel.create({
+ userId,
+ ip: meta.ip,
+ userAgent: loginRecord.lastLoginUserAgent,
+ });
+ }
+
/**
* 用户登录
* 登录可以使用用户名登录或者邮箱登录
@@ -329,6 +360,8 @@ class UserService extends TcService {
throw new BannedError(t('用户被封禁'), 403);
}
+ await this.recordUserLoginMeta(String(user._id), ctx.meta);
+
// Transform user entity (remove password and all protected fields)
const doc = await this.transformDocuments(ctx, {}, user);
return await this.transformEntity(doc, true, ctx.meta.token);
@@ -467,6 +500,7 @@ class UserService extends TcService {
emailVerified,
createdAt: new Date(),
});
+ await this.recordUserLoginMeta(String(doc._id), ctx.meta);
const user = await this.transformDocuments(ctx, {}, doc);
const json = await this.transformEntity(user, true, ctx.meta.token);
await this.entityChanged('created', json, ctx);
@@ -491,6 +525,7 @@ class UserService extends TcService {
email: userInfo.email,
avatar: userInfo.avatar,
});
+ await this.recordUserLoginMeta(userInfo._id, ctx.meta);
return token;
}
@@ -551,6 +586,7 @@ class UserService extends TcService {
avatar: null,
createdAt: new Date(),
});
+ await this.recordUserLoginMeta(String(doc._id), ctx.meta);
const user = await this.transformDocuments(ctx, {}, doc);
const json = await this.transformEntity(user, true);
await this.entityChanged('created', json, ctx);
@@ -605,6 +641,7 @@ class UserService extends TcService {
user.password = password;
user.temporary = false;
await user.save();
+ await this.recordUserLoginMeta(String(user._id), ctx.meta);
const json = await this.transformEntity(user, true);
await this.entityChanged('updated', json, ctx);
diff --git a/server/test/integration/user/user.spec.ts b/server/test/integration/user/user.spec.ts
index f328803a..fddbecec 100644
--- a/server/test/integration/user/user.spec.ts
+++ b/server/test/integration/user/user.spec.ts
@@ -3,6 +3,7 @@ import UserService from '../../../services/core/user/user.service';
import { createTestServiceBroker } from '../../utils';
import bcrypt from 'bcryptjs';
import type { UserDocument } from '../../../models/user/user';
+import userLoginLogModel from '../../../models/user/userLoginLog';
/**
* 创建测试用户
@@ -103,6 +104,44 @@ describe('Test "user" service', () => {
expect(res).not.toHaveProperty('password');
});
+ test('Test "user.login" records login ip', async () => {
+ const testDoc = await insertTestData(createTestUser());
+ const ip = '203.0.113.10';
+ const userAgent = 'tailchat-test-agent';
+
+ try {
+ await broker.call(
+ 'user.login',
+ {
+ email: testDoc.email,
+ password: '123456',
+ },
+ {
+ meta: {
+ ip,
+ userAgent,
+ },
+ }
+ );
+
+ const updatedUser = await service.adapter.model.findById(testDoc._id);
+
+ expect(updatedUser?.lastLoginIp).toBe(ip);
+ expect(updatedUser?.lastLoginUserAgent).toBe(userAgent);
+ expect(updatedUser?.lastLoginAt).toBeInstanceOf(Date);
+
+ const loginLog = await userLoginLogModel.findOne({
+ userId: testDoc._id,
+ ip,
+ });
+
+ expect(loginLog?.userAgent).toBe(userAgent);
+ expect(loginLog?.createdAt).toBeInstanceOf(Date);
+ } finally {
+ await userLoginLogModel.deleteMany({ userId: testDoc._id });
+ }
+ });
+
test('Test "user.updateUserExtra"', async () => {
const testUser = await insertTestData(createTestUser());