From c1366f0026e0751676e735de4261498c48ba1935 Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Thu, 11 Jun 2026 23:02:33 +0800 Subject: [PATCH] feat(auth): record user login ip history --- client/web/package.json | 2 +- pnpm-lock.yaml | 2 +- server/admin/src/client/App.tsx | 28 +++++++++++- server/admin/src/client/fields.ts | 53 ++++++++++++++++++++++ server/admin/src/client/i18n/zh.ts | 13 ++++++ server/admin/src/server/router/api.ts | 14 ++++++ server/models/user/user.ts | 20 +++++++++ server/models/user/userLoginLog.ts | 55 +++++++++++++++++++++++ server/services/core/gateway.service.ts | 21 ++++++++- server/services/core/user/user.service.ts | 37 +++++++++++++++ server/test/integration/user/user.spec.ts | 39 ++++++++++++++++ 11 files changed, 280 insertions(+), 4 deletions(-) create mode 100644 server/models/user/userLoginLog.ts 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());