diff --git a/spug_api/apps/account/history.py b/spug_api/apps/account/history.py index 8af9545..8d6a983 100644 --- a/spug_api/apps/account/history.py +++ b/spug_api/apps/account/history.py @@ -1,20 +1,12 @@ # Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug # Copyright: (c) # Released under the AGPL-3.0 License. -from django.views.generic import View -from django.db.models import F -from libs import json_response, auth +from libs.mixins import AdminView +from libs import json_response from apps.account.models import History -class HistoryView(View): - @auth('dashboard.dashboard.view') +class HistoryView(AdminView): def get(self, request): - histories = [] - for item in History.objects.annotate(nickname=F('user__nickname')): - histories.append({ - 'nickname': item.nickname, - 'ip': item.ip, - 'created_at': item.created_at.split('-', 1)[1], - }) + histories = History.objects.all() return json_response(histories) diff --git a/spug_api/apps/account/models.py b/spug_api/apps/account/models.py index db7070d..6031104 100644 --- a/spug_api/apps/account/models.py +++ b/spug_api/apps/account/models.py @@ -123,8 +123,12 @@ class Role(models.Model, ModelMixin): class History(models.Model, ModelMixin): - user = models.ForeignKey(User, on_delete=models.CASCADE) + username = models.CharField(max_length=100, null=True) + type = models.CharField(max_length=20, default='default') ip = models.CharField(max_length=50) + agent = models.CharField(max_length=255, null=True) + message = models.CharField(max_length=255, null=True) + is_success = models.BooleanField(default=True) created_at = models.CharField(max_length=20, default=human_datetime) class Meta: diff --git a/spug_api/apps/account/views.py b/spug_api/apps/account/views.py index 8f60643..182b9bd 100644 --- a/spug_api/apps/account/views.py +++ b/spug_api/apps/account/views.py @@ -11,6 +11,8 @@ from apps.account.models import User, Role, History from apps.setting.utils import AppSetting from apps.account.utils import verify_password from libs.ldap import LDAP +from functools import partial +import user_agents import ipaddress import time import uuid @@ -190,59 +192,76 @@ def login(request): Argument('type', required=False) ).parse(request.body) if error is None: + handle_response = partial(handle_login_record, request, form.username, form.type) user = User.objects.filter(username=form.username, type=form.type).first() if user and not user.is_active: - return json_response(error="账户已被系统禁用") + return handle_response(error="账户已被系统禁用") if form.type == 'ldap': config = AppSetting.get_default('ldap_service') if not config: - return json_response(error='请在系统设置中配置LDAP后再尝试通过该方式登录') + return handle_response(error='请在系统设置中配置LDAP后再尝试通过该方式登录') ldap = LDAP(**config) is_success, message = ldap.valid_user(form.username, form.password) if is_success: if not user: user = User.objects.create(username=form.username, nickname=form.username, type=form.type) - return handle_user_info(request, user, form.captcha) + return handle_user_info(handle_response, request, user, form.captcha) elif message: - return json_response(error=message) + return handle_response(error=message) else: if user and user.deleted_by is None: if user.verify_password(form.password): - return handle_user_info(request, user, form.captcha) + return handle_user_info(handle_response, request, user, form.captcha) value = cache.get_or_set(form.username, 0, 86400) if value >= 3: if user and user.is_active: user.is_active = False user.save() - return json_response(error='账户已被系统禁用') + return handle_response(error='账户已被系统禁用') cache.set(form.username, value + 1, 86400) - return json_response(error="用户名或密码错误,连续多次错误账户将会被禁用") + return handle_response(error="用户名或密码错误,连续多次错误账户将会被禁用") return json_response(error=error) -def handle_user_info(request, user, captcha): +def handle_login_record(request, username, login_type, error=None): + x_real_ip = get_request_real_ip(request.headers) + user_agent = user_agents.parse(request.headers.get('User-Agent')) + History.objects.create( + username=username, + type=login_type, + ip=x_real_ip, + agent=user_agent, + is_success=False if error else True, + message=error + ) + if error: + return json_response(error=error) + + +def handle_user_info(handle_response, request, user, captcha): cache.delete(user.username) key = f'{user.username}:code' if captcha: code = cache.get(key) if not code: - return json_response(error='验证码已失效,请重新获取') + return handle_response(error='验证码已失效,请重新获取') if code != captcha: ttl = cache.ttl(key) cache.expire(key, ttl - 100) - return json_response(error='验证码错误') + return handle_response(error='验证码错误') cache.delete(key) else: mfa = AppSetting.get_default('MFA', {'enable': False}) if mfa['enable']: if not user.wx_token: - return json_response(error='已启用登录双重认证,但您的账户未配置微信Token,请联系管理员') + return handle_response(error='已启用登录双重认证,但您的账户未配置微信Token,请联系管理员') code = generate_random_str(6) send_login_wx_code(user.wx_token, code) cache.set(key, code, 300) return json_response({'required_mfa': True}) + handle_response() x_real_ip = get_request_real_ip(request.headers) token_isvalid = user.access_token and len(user.access_token) == 32 and user.token_expired >= time.time() user.access_token = user.access_token if token_isvalid else uuid.uuid4().hex @@ -250,7 +269,6 @@ def handle_user_info(request, user, captcha): user.last_login = human_datetime() user.last_ip = x_real_ip user.save() - History.objects.create(user=user, ip=x_real_ip) verify_ip = AppSetting.get_default('verify_ip', True) return json_response({ 'id': user.id, diff --git a/spug_api/requirements.txt b/spug_api/requirements.txt index 2c2ca76..36d1780 100644 --- a/spug_api/requirements.txt +++ b/spug_api/requirements.txt @@ -8,4 +8,5 @@ django-redis==4.10.0 requests==2.22.0 GitPython==3.0.8 python-ldap==3.4.0 -openpyxl==3.0.3 \ No newline at end of file +openpyxl==3.0.3 +user_agents==2.2.0 \ No newline at end of file diff --git a/spug_web/src/pages/system/login/Table.js b/spug_web/src/pages/system/login/Table.js new file mode 100644 index 0000000..7b1e47f --- /dev/null +++ b/spug_web/src/pages/system/login/Table.js @@ -0,0 +1,84 @@ +/** + * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug + * Copyright (c) + * Released under the AGPL-3.0 License. + */ +import React from 'react'; +import { observer } from 'mobx-react'; +import { Radio, Tag } from 'antd'; +import { TableCard } from 'components'; +import store from './store'; + +@observer +class ComTable extends React.Component { + constructor(props) { + super(props); + this.state = { + password: '' + } + } + + componentDidMount() { + store.fetchRecords() + } + + columns = [{ + title: '时间', + width: 200, + dataIndex: 'created_at' + }, { + title: '账户名', + width: 120, + dataIndex: 'username', + }, { + title: '登录方式', + width: 100, + hide: true, + dataIndex: 'type', + render: text => text === 'ldap' ? 'LDAP' : '普通登录' + }, { + title: '状态', + width: 90, + render: text => text['is_success'] ? 成功 : 失败 + }, { + title: '登录IP', + width: 160, + dataIndex: 'ip', + }, { + title: 'User Agent', + ellipsis: true, + dataIndex: 'agent' + }, { + title: '提示信息', + ellipsis: true, + dataIndex: 'message' + }]; + + render() { + return ( + store.f_status = e.target.value}> + 全部 + 成功 + 失败 + + ]} + pagination={{ + showSizeChanger: true, + showLessItems: true, + showTotal: total => `共 ${total} 条`, + pageSizeOptions: ['10', '20', '50', '100'] + }} + columns={this.columns}/> + ) + } +} + +export default ComTable diff --git a/spug_web/src/pages/system/login/index.js b/spug_web/src/pages/system/login/index.js new file mode 100644 index 0000000..5f57366 --- /dev/null +++ b/spug_web/src/pages/system/login/index.js @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug + * Copyright (c) + * Released under the AGPL-3.0 License. + */ +import React from 'react'; +import { observer } from 'mobx-react'; +import { Input } from 'antd'; +import { SearchForm, AuthDiv, Breadcrumb } from 'components'; +import ComTable from './Table'; +import store from './store'; + +export default observer(function () { + return ( + + + 首页 + 系统管理 + 账户管理 + + + + store.f_name = e.target.value} placeholder="请输入"/> + + + + + ) +}) diff --git a/spug_web/src/pages/system/login/store.js b/spug_web/src/pages/system/login/store.js new file mode 100644 index 0000000..1ccf1d1 --- /dev/null +++ b/spug_web/src/pages/system/login/store.js @@ -0,0 +1,31 @@ +/** + * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug + * Copyright (c) + * Released under the AGPL-3.0 License. + */ +import { observable, computed } from 'mobx'; +import { http, includes } from 'libs'; + +class Store { + @observable records = []; + @observable isFetching = false; + + @observable f_name; + @observable f_status = ''; + + @computed get dataSource() { + let records = this.records; + if (this.f_name) records = records.filter(x => includes(x.username, this.f_name)); + if (this.f_status) records = records.filter(x => String(x.is_success) === this.f_status); + return records + } + + fetchRecords = () => { + this.isFetching = true; + http.get('/api/account/login/history/') + .then(res => this.records = res) + .finally(() => this.isFetching = false) + }; +} + +export default new Store() diff --git a/spug_web/src/routes.js b/spug_web/src/routes.js index cb9d6ce..f00975a 100644 --- a/spug_web/src/routes.js +++ b/spug_web/src/routes.js @@ -38,6 +38,7 @@ import AlarmContact from './pages/alarm/contact'; import SystemAccount from './pages/system/account'; import SystemRole from './pages/system/role'; import SystemSetting from './pages/system/setting'; +import SystemLogin from './pages/system/login'; import WelcomeIndex from './pages/welcome/index'; import WelcomeInfo from './pages/welcome/info'; @@ -90,6 +91,7 @@ export default [ }, { icon: , title: '系统管理', auth: "system.account.view|system.role.view|system.setting.view", child: [ + {title: '登录日志', auth: 'system.login.view', path: '/system/login', component: SystemLogin}, {title: '账户管理', auth: 'system.account.view', path: '/system/account', component: SystemAccount}, {title: '角色管理', auth: 'system.role.view', path: '/system/role', component: SystemRole}, {title: '系统设置', auth: 'system.setting.view', path: '/system/setting', component: SystemSetting},