feat(auth): record user login ip history

master
moonrailgun 4 weeks ago
parent 5a21d630e5
commit c1366f0026

@ -78,7 +78,7 @@
"source-ref-runtime": "^1.0.7", "source-ref-runtime": "^1.0.7",
"styled-components": "^5.3.6", "styled-components": "^5.3.6",
"tailchat-design": "workspace:^1.0.0", "tailchat-design": "workspace:^1.0.0",
"tailchat-shared": "^1.0.0", "tailchat-shared": "workspace:^1.0.0",
"tailwindcss": "^2.2.19", "tailwindcss": "^2.2.19",
"url": "^0.11.0", "url": "^0.11.0",
"web-vitals": "^3.1.1", "web-vitals": "^3.1.1",

@ -774,7 +774,7 @@ importers:
specifier: workspace:^1.0.0 specifier: workspace:^1.0.0
version: link:../packages/design version: link:../packages/design
tailchat-shared: tailchat-shared:
specifier: ^1.0.0 specifier: workspace:^1.0.0
version: link:../shared version: link:../shared
tailwindcss: tailwindcss:
specifier: ^2.2.19 specifier: ^2.2.19

@ -23,7 +23,12 @@ import {
} from 'tushan/icon'; } from 'tushan/icon';
import { authHTTPClient, authProvider } from './auth'; import { authHTTPClient, authProvider } from './auth';
import { Dashboard } from './components/Dashboard'; import { Dashboard } from './components/Dashboard';
import { discoverFields, mailFields, messageFields } from './fields'; import {
discoverFields,
mailFields,
messageFields,
userLoginLogFields,
} from './fields';
import { i18n } from './i18n'; import { i18n } from './i18n';
import { GroupList } from './resources/group'; import { GroupList } from './resources/group';
import { UserList } from './resources/user'; import { UserList } from './resources/user';
@ -54,6 +59,27 @@ function App() {
<Resource name="users" icon={<IconUser />} list={<UserList />} /> <Resource name="users" icon={<IconUser />} list={<UserList />} />
<Resource
name="user_login_logs"
icon={<IconStorage />}
list={
<ListTable
filter={[
createTextField('q', {
label: 'Search',
}),
]}
fields={userLoginLogFields}
action={{
detail: true,
export: true,
refresh: true,
}}
showSizeChanger={true}
/>
}
/>
<Resource <Resource
name="messages" name="messages"
icon={<IconMessage />} icon={<IconMessage />}

@ -63,6 +63,26 @@ export const userFields = [
hidden: true, 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', { createJSONField('settings', {
list: { list: {
width: 200, 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 = [ export const messageFields = [
createTextField('id', { createTextField('id', {
list: { list: {

@ -21,9 +21,22 @@ export const zhTranslation = {
emailVerified: '邮箱校验', emailVerified: '邮箱校验',
settings: '用户设置', settings: '用户设置',
banned: '是否被封禁', banned: '是否被封禁',
lastLoginIp: '最近登录IP',
lastLoginAt: '最近登录时间',
lastLoginUserAgent: '最近登录UA',
createdAt: '创建时间', createdAt: '创建时间',
}, },
}, },
user_login_logs: {
name: '登录日志',
fields: {
id: '日志ID',
userId: '用户',
ip: '登录IP',
userAgent: 'User-Agent',
createdAt: '登录时间',
},
},
messages: { messages: {
name: '消息管理', name: '消息管理',
fields: { fields: {

@ -7,6 +7,7 @@ import { networkRouter } from './network';
import { fileRouter } from './file'; import { fileRouter } from './file';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import userModel from '../../../../models/user/user'; import userModel from '../../../../models/user/user';
import userLoginLogModel from '../../../../models/user/userLoginLog';
import messageModel from '../../../../models/chat/message'; import messageModel from '../../../../models/chat/message';
import fileModel from '../../../../models/file'; import fileModel from '../../../../models/file';
import groupModel from '../../../../models/group/group'; import groupModel from '../../../../models/group/group';
@ -179,6 +180,19 @@ router.use(
allowedRegexFields: ['nickname'], 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) => { router.delete('/messages/:id', auth(), async (req, res) => {
try { try {
const messageId = req.params.id; const messageId = req.params.id;

@ -116,6 +116,26 @@ export class User extends TimeStamps implements Base {
}) })
banned: boolean; banned: boolean;
/**
* IP
*/
@prop({
index: true,
})
lastLoginIp?: string;
/**
*
*/
@prop()
lastLoginAt?: Date;
/**
* User-Agent
*/
@prop()
lastLoginUserAgent?: string;
/** /**
* *
*/ */

@ -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<User>;
/**
* IP
*/
@prop({
required: true,
})
ip: string;
/**
* User-Agent
*/
@prop()
userAgent?: string;
}
export type UserLoginLogDocument = DocumentType<UserLoginLog>;
const model = getModelForClass(UserLoginLog);
export type UserLoginLogModel = typeof model;
export default model;

@ -21,6 +21,21 @@ import send from 'send';
import path from 'path'; import path from 'path';
import mime from 'mime'; 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 { export default class ApiService extends TcService {
authWhitelist = []; authWhitelist = [];
@ -142,13 +157,17 @@ export default class ApiService extends TcService {
* @param {ServerResponse} res * @param {ServerResponse} res
* @param {Object} data*/ * @param {Object} data*/
onBeforeCall( onBeforeCall(
ctx: PureContext<any, { userAgent: string; language: string }>, ctx: PureContext<
any,
{ userAgent?: string; language: string; ip?: string }
>,
route: object, route: object,
req: IncomingMessage, req: IncomingMessage,
res: ServerResponse res: ServerResponse
) { ) {
// Set request headers to context meta // Set request headers to context meta
ctx.meta.userAgent = req.headers['user-agent']; ctx.meta.userAgent = req.headers['user-agent'];
ctx.meta.ip = getRequestIp(req);
ctx.meta.language = parseLanguageFromHead( ctx.meta.language = parseLanguageFromHead(
req.headers['accept-language'] req.headers['accept-language']
); );

@ -31,6 +31,7 @@ import {
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import _ from 'lodash'; import _ from 'lodash';
import type { UserStruct } from 'tailchat-server-sdk'; import type { UserStruct } from 'tailchat-server-sdk';
import userLoginLogModel from '../../../models/user/userLoginLog';
const { isValidObjectId, Types } = db; const { isValidObjectId, Types } = db;
@ -284,6 +285,36 @@ class UserService extends TcService {
comparePassword = async (password: string, hash: string): Promise<boolean> => comparePassword = async (password: string, hash: string): Promise<boolean> =>
bcrypt.compare(password, hash); bcrypt.compare(password, hash);
private async recordUserLoginMeta(userId: string, meta: any) {
if (typeof meta.ip !== 'string' || meta.ip.length === 0) {
return;
}
const loginRecord: Partial<User> = {
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); throw new BannedError(t('用户被封禁'), 403);
} }
await this.recordUserLoginMeta(String(user._id), ctx.meta);
// Transform user entity (remove password and all protected fields) // Transform user entity (remove password and all protected fields)
const doc = await this.transformDocuments(ctx, {}, user); const doc = await this.transformDocuments(ctx, {}, user);
return await this.transformEntity(doc, true, ctx.meta.token); return await this.transformEntity(doc, true, ctx.meta.token);
@ -467,6 +500,7 @@ class UserService extends TcService {
emailVerified, emailVerified,
createdAt: new Date(), createdAt: new Date(),
}); });
await this.recordUserLoginMeta(String(doc._id), ctx.meta);
const user = await this.transformDocuments(ctx, {}, doc); const user = await this.transformDocuments(ctx, {}, doc);
const json = await this.transformEntity(user, true, ctx.meta.token); const json = await this.transformEntity(user, true, ctx.meta.token);
await this.entityChanged('created', json, ctx); await this.entityChanged('created', json, ctx);
@ -491,6 +525,7 @@ class UserService extends TcService {
email: userInfo.email, email: userInfo.email,
avatar: userInfo.avatar, avatar: userInfo.avatar,
}); });
await this.recordUserLoginMeta(userInfo._id, ctx.meta);
return token; return token;
} }
@ -551,6 +586,7 @@ class UserService extends TcService {
avatar: null, avatar: null,
createdAt: new Date(), createdAt: new Date(),
}); });
await this.recordUserLoginMeta(String(doc._id), ctx.meta);
const user = await this.transformDocuments(ctx, {}, doc); const user = await this.transformDocuments(ctx, {}, doc);
const json = await this.transformEntity(user, true); const json = await this.transformEntity(user, true);
await this.entityChanged('created', json, ctx); await this.entityChanged('created', json, ctx);
@ -605,6 +641,7 @@ class UserService extends TcService {
user.password = password; user.password = password;
user.temporary = false; user.temporary = false;
await user.save(); await user.save();
await this.recordUserLoginMeta(String(user._id), ctx.meta);
const json = await this.transformEntity(user, true); const json = await this.transformEntity(user, true);
await this.entityChanged('updated', json, ctx); await this.entityChanged('updated', json, ctx);

@ -3,6 +3,7 @@ import UserService from '../../../services/core/user/user.service';
import { createTestServiceBroker } from '../../utils'; import { createTestServiceBroker } from '../../utils';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import type { UserDocument } from '../../../models/user/user'; 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'); 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 () => { test('Test "user.updateUserExtra"', async () => {
const testUser = await insertTestData(createTestUser()); const testUser = await insertTestData(createTestUser());

Loading…
Cancel
Save