mirror of https://github.com/openspug/spug
add MFA
parent
78b5f42afd
commit
466d036c71
|
@ -19,6 +19,7 @@ class User(models.Model, ModelMixin):
|
||||||
last_login = models.CharField(max_length=20)
|
last_login = models.CharField(max_length=20)
|
||||||
last_ip = models.CharField(max_length=50)
|
last_ip = models.CharField(max_length=50)
|
||||||
role = models.ForeignKey('Role', on_delete=models.PROTECT, null=True)
|
role = models.ForeignKey('Role', on_delete=models.PROTECT, null=True)
|
||||||
|
wx_token = models.CharField(max_length=50, null=True)
|
||||||
|
|
||||||
created_at = models.CharField(max_length=20, default=human_datetime)
|
created_at = models.CharField(max_length=20, default=human_datetime)
|
||||||
created_by = models.ForeignKey('User', models.PROTECT, related_name='+', null=True)
|
created_by = models.ForeignKey('User', models.PROTECT, related_name='+', null=True)
|
||||||
|
|
|
@ -5,7 +5,8 @@ from django.core.cache import cache
|
||||||
from django.views.generic import View
|
from django.views.generic import View
|
||||||
from django.db.models import F
|
from django.db.models import F
|
||||||
from libs import JsonParser, Argument, human_datetime, json_response
|
from libs import JsonParser, Argument, human_datetime, json_response
|
||||||
from libs.utils import get_request_real_ip
|
from libs.utils import get_request_real_ip, generate_random_str
|
||||||
|
from libs.spug import send_login_wx_code
|
||||||
from apps.account.models import User, Role, History
|
from apps.account.models import User, Role, History
|
||||||
from apps.setting.utils import AppSetting
|
from apps.setting.utils import AppSetting
|
||||||
from libs.ldap import LDAP
|
from libs.ldap import LDAP
|
||||||
|
@ -18,7 +19,7 @@ import json
|
||||||
class UserView(View):
|
class UserView(View):
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
users = []
|
users = []
|
||||||
for u in User.objects.filter(is_supper=False, deleted_by_id__isnull=True).annotate(role_name=F('role__name')):
|
for u in User.objects.filter(deleted_by_id__isnull=True).annotate(role_name=F('role__name')):
|
||||||
tmp = u.to_dict(excludes=('access_token', 'password_hash'))
|
tmp = u.to_dict(excludes=('access_token', 'password_hash'))
|
||||||
tmp['role_name'] = u.role_name
|
tmp['role_name'] = u.role_name
|
||||||
users.append(tmp)
|
users.append(tmp)
|
||||||
|
@ -30,6 +31,7 @@ class UserView(View):
|
||||||
Argument('password', help='请输入密码'),
|
Argument('password', help='请输入密码'),
|
||||||
Argument('nickname', help='请输入姓名'),
|
Argument('nickname', help='请输入姓名'),
|
||||||
Argument('role_id', type=int, help='请选择角色'),
|
Argument('role_id', type=int, help='请选择角色'),
|
||||||
|
Argument('wx_token', required=False),
|
||||||
).parse(request.body)
|
).parse(request.body)
|
||||||
if error is None:
|
if error is None:
|
||||||
if User.objects.filter(username=form.username, deleted_by_id__isnull=True).exists():
|
if User.objects.filter(username=form.username, deleted_by_id__isnull=True).exists():
|
||||||
|
@ -46,6 +48,7 @@ class UserView(View):
|
||||||
Argument('password', required=False),
|
Argument('password', required=False),
|
||||||
Argument('nickname', required=False),
|
Argument('nickname', required=False),
|
||||||
Argument('role_id', required=False),
|
Argument('role_id', required=False),
|
||||||
|
Argument('wx_token', required=False),
|
||||||
Argument('is_active', type=bool, required=False),
|
Argument('is_active', type=bool, required=False),
|
||||||
).parse(request.body, True)
|
).parse(request.body, True)
|
||||||
if error is None:
|
if error is None:
|
||||||
|
@ -156,10 +159,10 @@ def login(request):
|
||||||
form, error = JsonParser(
|
form, error = JsonParser(
|
||||||
Argument('username', help='请输入用户名'),
|
Argument('username', help='请输入用户名'),
|
||||||
Argument('password', help='请输入密码'),
|
Argument('password', help='请输入密码'),
|
||||||
|
Argument('captcha', required=False),
|
||||||
Argument('type', required=False)
|
Argument('type', required=False)
|
||||||
).parse(request.body)
|
).parse(request.body)
|
||||||
if error is None:
|
if error is None:
|
||||||
x_real_ip = get_request_real_ip(request.headers)
|
|
||||||
user = User.objects.filter(username=form.username, type=form.type).first()
|
user = User.objects.filter(username=form.username, type=form.type).first()
|
||||||
if user and not user.is_active:
|
if user and not user.is_active:
|
||||||
return json_response(error="账户已被系统禁用")
|
return json_response(error="账户已被系统禁用")
|
||||||
|
@ -171,13 +174,13 @@ def login(request):
|
||||||
if is_success:
|
if is_success:
|
||||||
if not user:
|
if not user:
|
||||||
user = User.objects.create(username=form.username, nickname=form.username, type=form.type)
|
user = User.objects.create(username=form.username, nickname=form.username, type=form.type)
|
||||||
return handle_user_info(user, x_real_ip)
|
return handle_user_info(request, user, form.captcha)
|
||||||
elif message:
|
elif message:
|
||||||
return json_response(error=message)
|
return json_response(error=message)
|
||||||
else:
|
else:
|
||||||
if user and user.deleted_by is None:
|
if user and user.deleted_by is None:
|
||||||
if user.verify_password(form.password):
|
if user.verify_password(form.password):
|
||||||
return handle_user_info(user, x_real_ip)
|
return handle_user_info(request, user, form.captcha)
|
||||||
|
|
||||||
value = cache.get_or_set(form.username, 0, 86400)
|
value = cache.get_or_set(form.username, 0, 86400)
|
||||||
if value >= 3:
|
if value >= 3:
|
||||||
|
@ -190,8 +193,29 @@ def login(request):
|
||||||
return json_response(error=error)
|
return json_response(error=error)
|
||||||
|
|
||||||
|
|
||||||
def handle_user_info(user, x_real_ip):
|
def handle_user_info(request, user, captcha):
|
||||||
cache.delete(user.username)
|
cache.delete(user.username)
|
||||||
|
key = f'{user.username}:code'
|
||||||
|
if captcha:
|
||||||
|
code = cache.get(key)
|
||||||
|
if not code:
|
||||||
|
return json_response(error='验证码已失效,请重新获取')
|
||||||
|
if code != captcha:
|
||||||
|
ttl = cache.ttl(key)
|
||||||
|
cache.expire(key, ttl - 100)
|
||||||
|
return json_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,请联系管理员')
|
||||||
|
code = generate_random_str(6)
|
||||||
|
send_login_wx_code(user.wx_token, code)
|
||||||
|
cache.set(key, code, 300)
|
||||||
|
return json_response({'required_mfa': True})
|
||||||
|
|
||||||
|
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()
|
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
|
user.access_token = user.access_token if token_isvalid else uuid.uuid4().hex
|
||||||
user.token_expired = time.time() + 8 * 60 * 60
|
user.token_expired = time.time() + 8 * 60 * 60
|
||||||
|
|
|
@ -13,6 +13,16 @@ spug_server = 'https://api.spug.cc'
|
||||||
notify_source = 'monitor'
|
notify_source = 'monitor'
|
||||||
|
|
||||||
|
|
||||||
|
def send_login_wx_code(wx_token, code):
|
||||||
|
url = f'{spug_server}/apis/login/wx/'
|
||||||
|
res = requests.post(url, json={'token': wx_token, 'code': code}, timeout=30)
|
||||||
|
if res.status_code != 200:
|
||||||
|
raise Exception(f'status code: {res.status_code}')
|
||||||
|
res = res.json()
|
||||||
|
if res.get('error'):
|
||||||
|
raise Exception(res['error'])
|
||||||
|
|
||||||
|
|
||||||
class Notification:
|
class Notification:
|
||||||
def __init__(self, grp, event, target, title, message, duration):
|
def __init__(self, grp, event, target, title, message, duration):
|
||||||
self.event = event
|
self.event = event
|
||||||
|
|
|
@ -21,6 +21,8 @@ export default function () {
|
||||||
const [counter, setCounter] = useState(0);
|
const [counter, setCounter] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [loginType, setLoginType] = useState('default');
|
const [loginType, setLoginType] = useState('default');
|
||||||
|
const [codeVisible, setCodeVisible] = useState(false);
|
||||||
|
const [codeLoading, setCodeLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
envStore.records = [];
|
envStore.records = [];
|
||||||
|
@ -45,7 +47,11 @@ export default function () {
|
||||||
formData['type'] = loginType;
|
formData['type'] = loginType;
|
||||||
http.post('/api/account/login/', formData)
|
http.post('/api/account/login/', formData)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (!data['has_real_ip']) {
|
if (data['required_mfa']) {
|
||||||
|
setCodeVisible(true);
|
||||||
|
setCounter(30);
|
||||||
|
setLoading(false)
|
||||||
|
} else if (!data['has_real_ip']) {
|
||||||
Modal.warning({
|
Modal.warning({
|
||||||
title: '安全警告',
|
title: '安全警告',
|
||||||
className: styles.tips,
|
className: styles.tips,
|
||||||
|
@ -78,7 +84,12 @@ export default function () {
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCaptcha() {
|
function handleCaptcha() {
|
||||||
setCounter(60);
|
setCodeLoading(true);
|
||||||
|
const formData = form.getFieldsValue(['username', 'password']);
|
||||||
|
formData['type'] = loginType;
|
||||||
|
http.post('/api/account/login/', formData)
|
||||||
|
.then(() => setCounter(30))
|
||||||
|
.finally(() => setCodeLoading(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -109,7 +120,7 @@ export default function () {
|
||||||
onPressEnter={handleSubmit}
|
onPressEnter={handleSubmit}
|
||||||
prefix={<LockOutlined className={styles.icon}/>}/>
|
prefix={<LockOutlined className={styles.icon}/>}/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="captcha" className={styles.formItem}>
|
<Form.Item hidden={!codeVisible} name="captcha" className={styles.formItem}>
|
||||||
<div style={{display: 'flex'}}>
|
<div style={{display: 'flex'}}>
|
||||||
<Form.Item noStyle name="captcha">
|
<Form.Item noStyle name="captcha">
|
||||||
<Input
|
<Input
|
||||||
|
@ -121,7 +132,8 @@ export default function () {
|
||||||
{counter > 0 ? (
|
{counter > 0 ? (
|
||||||
<Button disabled size="large" style={{marginLeft: 8}}>{counter} 秒后重新获取</Button>
|
<Button disabled size="large" style={{marginLeft: 8}}>{counter} 秒后重新获取</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button size="large" style={{marginLeft: 8}} onClick={handleCaptcha}>获取验证码</Button>
|
<Button size="large" loading={codeLoading} style={{marginLeft: 8}}
|
||||||
|
onClick={handleCaptcha}>获取验证码</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
|
@ -71,6 +71,9 @@ export default observer(function () {
|
||||||
<Link to="/system/role">新建角色</Link>
|
<Link to="/system/role">新建角色</Link>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="wx_token" label="微信Token">
|
||||||
|
<Input placeholder="请输入微信Token"/>
|
||||||
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|
Loading…
Reference in New Issue