Merge pull request #7169 from jumpserver/dev

v2.16.0 rc1
pull/7230/head
Jiangjie.Bai 2021-11-11 14:00:33 +08:00 committed by GitHub
commit 3051996e35
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
67 changed files with 1780 additions and 1135 deletions

View File

@ -2,6 +2,7 @@ from django.utils.translation import ugettext as _
from rest_framework import serializers
from common.drf.serializers import BulkModelSerializer
from common.drf.serializers import MethodSerializer
from jumpserver.utils import has_valid_xpack_license
from ..models import LoginACL
from .rules import RuleSerializer
@ -40,12 +41,11 @@ class LoginACLSerializer(BulkModelSerializer):
self.set_action_choices()
def set_action_choices(self):
from xpack.plugins.license.models import License
action = self.fields.get('action')
if not action:
return
choices = action._choices
if not License.has_valid_license():
if not has_valid_xpack_license():
choices.pop(LoginACL.ActionChoices.confirm, None)
action._choices = choices

View File

@ -8,6 +8,7 @@ from orgs.mixins.api import OrgBulkModelViewSet
from orgs.mixins import generics
from common.mixins.api import SuggestionMixin
from orgs.utils import tmp_to_root_org
from rest_framework.decorators import action
from ..models import SystemUser, Asset
from .. import serializers
from ..serializers import SystemUserWithAuthInfoSerializer, SystemUserTempAuthSerializer
@ -45,6 +46,32 @@ class SystemUserViewSet(SuggestionMixin, OrgBulkModelViewSet):
ordering = ('name', )
permission_classes = (IsOrgAdminOrAppUser,)
@action(methods=['get'], detail=False, url_path='su-from')
def su_from(self, request, *args, **kwargs):
""" API 获取可选的 su_from 系统用户"""
queryset = self.filter_queryset(self.get_queryset())
queryset = queryset.filter(
protocol=SystemUser.Protocol.ssh, login_mode=SystemUser.LOGIN_AUTO
)
return self.get_paginate_response_if_need(queryset)
@action(methods=['get'], detail=True, url_path='su-to')
def su_to(self, request, *args, **kwargs):
""" 获取系统用户的所有 su_to 系统用户 """
pk = kwargs.get('pk')
system_user = get_object_or_404(SystemUser, pk=pk)
queryset = system_user.su_to.all()
queryset = self.filter_queryset(queryset)
return self.get_paginate_response_if_need(queryset)
def get_paginate_response_if_need(self, queryset):
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
class SystemUserAuthInfoApi(generics.RetrieveUpdateDestroyAPIView):
"""

View File

@ -0,0 +1,28 @@
# Generated by Django 3.1.12 on 2021-11-02 11:22
from django.db import migrations
def create_internal_platform(apps, schema_editor):
model = apps.get_model("assets", "Platform")
db_alias = schema_editor.connection.alias
type_platforms = (
('Windows-RDP', 'Windows', {'security': 'rdp'}),
('Windows-TLS', 'Windows', {'security': 'tls'}),
)
for name, base, meta in type_platforms:
defaults = {'name': name, 'base': base, 'meta': meta, 'internal': True}
model.objects.using(db_alias).update_or_create(
name=name, defaults=defaults
)
class Migration(migrations.Migration):
dependencies = [
('assets', '0078_auto_20211014_2209'),
]
operations = [
migrations.RunPython(create_internal_platform)
]

View File

@ -0,0 +1,24 @@
# Generated by Django 3.1.13 on 2021-11-04 05:47
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('assets', '0079_auto_20211102_1922'),
]
operations = [
migrations.AddField(
model_name='systemuser',
name='su_enabled',
field=models.BooleanField(default=False, verbose_name='User switch'),
),
migrations.AddField(
model_name='systemuser',
name='su_from',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='su_to', to='assets.systemuser', verbose_name='Switch from'),
),
]

View File

@ -164,38 +164,7 @@ class Platform(models.Model):
# ordering = ('name',)
class Asset(AbsConnectivity, ProtocolsMixin, NodesRelationMixin, OrgModelMixin):
# Important
PLATFORM_CHOICES = (
('Linux', 'Linux'),
('Unix', 'Unix'),
('MacOS', 'MacOS'),
('BSD', 'BSD'),
('Windows', 'Windows'),
('Windows2016', 'Windows(2016)'),
('Other', 'Other'),
)
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
ip = models.CharField(max_length=128, verbose_name=_('IP'), db_index=True)
hostname = models.CharField(max_length=128, verbose_name=_('Hostname'))
protocol = models.CharField(max_length=128, default=ProtocolsMixin.Protocol.ssh,
choices=ProtocolsMixin.Protocol.choices,
verbose_name=_('Protocol'))
port = models.IntegerField(default=22, verbose_name=_('Port'))
protocols = models.CharField(max_length=128, default='ssh/22', blank=True, verbose_name=_("Protocols"))
platform = models.ForeignKey(Platform, default=Platform.default, on_delete=models.PROTECT, verbose_name=_("Platform"), related_name='assets')
domain = models.ForeignKey("assets.Domain", null=True, blank=True, related_name='assets', verbose_name=_("Domain"), on_delete=models.SET_NULL)
nodes = models.ManyToManyField('assets.Node', default=default_node, related_name='assets', verbose_name=_("Nodes"))
is_active = models.BooleanField(default=True, verbose_name=_('Is active'))
# Auth
admin_user = models.ForeignKey('assets.SystemUser', on_delete=models.SET_NULL, null=True, verbose_name=_("Admin user"), related_name='admin_assets')
# Some information
public_ip = models.CharField(max_length=128, blank=True, null=True, verbose_name=_('Public IP'))
number = models.CharField(max_length=32, null=True, blank=True, verbose_name=_('Asset number'))
class AbsHardwareInfo(models.Model):
# Collect
vendor = models.CharField(max_length=64, null=True, blank=True, verbose_name=_('Vendor'))
model = models.CharField(max_length=54, null=True, blank=True, verbose_name=_('Model'))
@ -214,6 +183,49 @@ class Asset(AbsConnectivity, ProtocolsMixin, NodesRelationMixin, OrgModelMixin):
os_arch = models.CharField(max_length=16, blank=True, null=True, verbose_name=_('OS arch'))
hostname_raw = models.CharField(max_length=128, blank=True, null=True, verbose_name=_('Hostname raw'))
class Meta:
abstract = True
@property
def cpu_info(self):
info = ""
if self.cpu_model:
info += self.cpu_model
if self.cpu_count and self.cpu_cores:
info += "{}*{}".format(self.cpu_count, self.cpu_cores)
return info
@property
def hardware_info(self):
if self.cpu_count:
return '{} Core {} {}'.format(
self.cpu_vcpus or self.cpu_count * self.cpu_cores,
self.memory, self.disk_total
)
else:
return ''
class Asset(AbsConnectivity, AbsHardwareInfo, ProtocolsMixin, NodesRelationMixin, OrgModelMixin):
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
ip = models.CharField(max_length=128, verbose_name=_('IP'), db_index=True)
hostname = models.CharField(max_length=128, verbose_name=_('Hostname'))
protocol = models.CharField(max_length=128, default=ProtocolsMixin.Protocol.ssh,
choices=ProtocolsMixin.Protocol.choices, verbose_name=_('Protocol'))
port = models.IntegerField(default=22, verbose_name=_('Port'))
protocols = models.CharField(max_length=128, default='ssh/22', blank=True, verbose_name=_("Protocols"))
platform = models.ForeignKey(Platform, default=Platform.default, on_delete=models.PROTECT, verbose_name=_("Platform"), related_name='assets')
domain = models.ForeignKey("assets.Domain", null=True, blank=True, related_name='assets', verbose_name=_("Domain"), on_delete=models.SET_NULL)
nodes = models.ManyToManyField('assets.Node', default=default_node, related_name='assets', verbose_name=_("Nodes"))
is_active = models.BooleanField(default=True, verbose_name=_('Is active'))
# Auth
admin_user = models.ForeignKey('assets.SystemUser', on_delete=models.SET_NULL, null=True, verbose_name=_("Admin user"), related_name='admin_assets')
# Some information
public_ip = models.CharField(max_length=128, blank=True, null=True, verbose_name=_('Public IP'))
number = models.CharField(max_length=32, null=True, blank=True, verbose_name=_('Asset number'))
labels = models.ManyToManyField('assets.Label', blank=True, related_name='assets', verbose_name=_("Labels"))
created_by = models.CharField(max_length=128, null=True, blank=True, verbose_name=_('Created by'))
date_created = models.DateTimeField(auto_now_add=True, null=True, blank=True, verbose_name=_('Date created'))
@ -269,25 +281,6 @@ class Asset(AbsConnectivity, ProtocolsMixin, NodesRelationMixin, OrgModelMixin):
def is_support_ansible(self):
return self.has_protocol('ssh') and self.platform_base not in ("Other",)
@property
def cpu_info(self):
info = ""
if self.cpu_model:
info += self.cpu_model
if self.cpu_count and self.cpu_cores:
info += "{}*{}".format(self.cpu_count, self.cpu_cores)
return info
@property
def hardware_info(self):
if self.cpu_count:
return '{} Core {} {}'.format(
self.cpu_vcpus or self.cpu_count * self.cpu_cores,
self.memory, self.disk_total
)
else:
return ''
def get_auth_info(self):
if not self.admin_user:
return {}

View File

@ -208,6 +208,9 @@ class SystemUser(ProtocolMixin, AuthMixin, BaseUser):
home = models.CharField(max_length=4096, default='', verbose_name=_('Home'), blank=True)
system_groups = models.CharField(default='', max_length=4096, verbose_name=_('System groups'), blank=True)
ad_domain = models.CharField(default='', max_length=256)
# linux su 命令 (switch user)
su_enabled = models.BooleanField(default=False, verbose_name=_('User switch'))
su_from = models.ForeignKey('self', on_delete=models.SET_NULL, related_name='su_to', null=True, verbose_name=_("Switch from"))
def __str__(self):
username = self.username
@ -267,6 +270,21 @@ class SystemUser(ProtocolMixin, AuthMixin, BaseUser):
assets = Asset.objects.filter(id__in=asset_ids)
return assets
def add_related_assets(self, assets_or_ids):
self.assets.add(*tuple(assets_or_ids))
self.add_related_assets_to_su_from_if_need(assets_or_ids)
def add_related_assets_to_su_from_if_need(self, assets_or_ids):
if self.protocol not in [self.Protocol.ssh.value]:
return
if not self.su_enabled:
return
if not self.su_from:
return
if self.su_from.protocol != self.protocol:
return
self.su_from.assets.add(*tuple(assets_or_ids))
class Meta:
ordering = ['name']
unique_together = [('name', 'org_id')]

View File

@ -66,7 +66,9 @@ class AssetSerializer(BulkOrgResourceModelSerializer):
)
protocols = ProtocolsField(label=_('Protocols'), required=False, default=['ssh/22'])
domain_display = serializers.ReadOnlyField(source='domain.name', label=_('Domain name'))
nodes_display = serializers.ListField(child=serializers.CharField(), label=_('Nodes name'), required=False)
nodes_display = serializers.ListField(
child=serializers.CharField(), label=_('Nodes name'), required=False
)
"""
资产的数据结构
@ -79,11 +81,11 @@ class AssetSerializer(BulkOrgResourceModelSerializer):
'protocol', 'port', 'protocols', 'is_active',
'public_ip', 'number', 'comment',
]
hardware_fields = [
fields_hardware = [
'vendor', 'model', 'sn', 'cpu_model', 'cpu_count',
'cpu_cores', 'cpu_vcpus', 'memory', 'disk_total', 'disk_info',
'os', 'os_version', 'os_arch', 'hostname_raw', 'hardware_info',
'connectivity', 'date_verified'
'os', 'os_version', 'os_arch', 'hostname_raw',
'cpu_info', 'hardware_info',
]
fields_fk = [
'domain', 'domain_display', 'platform', 'admin_user', 'admin_user_display'
@ -92,18 +94,16 @@ class AssetSerializer(BulkOrgResourceModelSerializer):
'nodes', 'nodes_display', 'labels',
]
read_only_fields = [
'connectivity', 'date_verified', 'cpu_info', 'hardware_info',
'created_by', 'date_created',
]
fields = fields_small + hardware_fields + fields_fk + fields_m2m + read_only_fields
extra_kwargs = {k: {'read_only': True} for k in hardware_fields}
extra_kwargs.update({
fields = fields_small + fields_hardware + fields_fk + fields_m2m + read_only_fields
extra_kwargs = {
'protocol': {'write_only': True},
'port': {'write_only': True},
'hardware_info': {'label': _('Hardware info'), 'read_only': True},
'org_name': {'label': _('Org name'), 'read_only': True},
'admin_user_display': {'label': _('Admin user display'), 'read_only': True},
})
}
def get_fields(self):
fields = super().get_fields()

View File

@ -40,6 +40,7 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
'login_mode', 'login_mode_display', 'priority',
'sudo', 'shell', 'sftp_root', 'home', 'system_groups', 'ad_domain',
'username_same_with_user', 'auto_push', 'auto_generate_key',
'su_enabled', 'su_from',
'date_created', 'date_updated', 'comment', 'created_by',
]
fields_m2m = ['cmd_filters', 'assets_amount', 'applications_amount', 'nodes']
@ -57,7 +58,8 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
'login_mode_display': {'label': _('Login mode display')},
'created_by': {'read_only': True},
'ad_domain': {'required': False, 'allow_blank': True, 'label': _('Ad domain')},
'is_asset_protocol': {'label': _('Is asset protocol')}
'is_asset_protocol': {'label': _('Is asset protocol')},
'su_from': {'help_text': _('Only ssh and automatic login system users are supported')}
}
def validate_auto_push(self, value):
@ -146,6 +148,29 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
raise serializers.ValidationError(_("Password or private key required"))
return password
def validate_su_from(self, su_from: SystemUser):
# self: su enabled
su_enabled = self.get_initial_value('su_enabled', default=False)
if not su_enabled:
return
if not su_from:
error = _('This field is required.')
raise serializers.ValidationError(error)
# self: protocol ssh
protocol = self.get_initial_value('protocol', default=SystemUser.Protocol.ssh.value)
if protocol not in [SystemUser.Protocol.ssh.value]:
error = _('Only ssh protocol system users are allowed')
raise serializers.ValidationError(error)
# su_from: protocol same
if su_from.protocol != protocol:
error = _('The protocol must be consistent with the current user: {}').format(protocol)
raise serializers.ValidationError(error)
# su_from: login model auto
if su_from.login_mode != su_from.LOGIN_AUTO:
error = _('Only system users with automatic login are allowed')
raise serializers.ValidationError(error)
return su_from
def _validate_admin_user(self, attrs):
if self.instance:
tp = self.instance.type

View File

@ -140,3 +140,5 @@ def on_system_user_update(instance: SystemUser, created, **kwargs):
logger.info("System user update signal recv: {}".format(instance))
assets = instance.assets.all().valid()
push_system_user_to_assets.delay(instance.id, [_asset.id for _asset in assets])
# add assets to su_from
instance.add_related_assets_to_su_from_if_need(assets)

View File

@ -15,7 +15,7 @@ from rest_framework.request import Request
from assets.models import Asset, SystemUser
from authentication.signals import post_auth_failed, post_auth_success
from authentication.utils import check_different_city_login
from authentication.utils import check_different_city_login_if_need
from jumpserver.utils import current_request
from users.models import User
from users.signals import post_user_change_password
@ -304,7 +304,7 @@ def generate_data(username, request, login_type=None):
@receiver(post_auth_success)
def on_user_auth_success(sender, user, request, login_type=None, **kwargs):
logger.debug('User login success: {}'.format(user.username))
check_different_city_login(user, request)
check_different_city_login_if_need(user, request)
data = generate_data(user.username, request, login_type=login_type)
data.update({'mfa': int(user.mfa_enabled), 'status': True})
write_login_log(**data)

View File

@ -4,6 +4,7 @@ import urllib.parse
import json
import base64
from typing import Callable
import os
from django.conf import settings
from django.core.cache import cache
@ -50,6 +51,10 @@ class ClientProtocolMixin:
user = self.request.user
return asset, application, system_user, user
@staticmethod
def parse_env_bool(env_key, env_default, true_value, false_value):
return true_value if is_true(os.getenv(env_key, env_default)) else false_value
def get_rdp_file_content(self, serializer):
options = {
'full address:s': '',
@ -112,6 +117,10 @@ class ClientProtocolMixin:
options['desktopheight:i'] = height
else:
options['smart sizing:i'] = '1'
options['session bpp:i'] = os.getenv('JUMPSERVER_COLOR_DEPTH', '32')
options['audiomode:i'] = self.parse_env_bool('JUMPSERVER_DISABLE_AUDIO', 'false', '2', '0')
content = ''
for k, v in options.items():
content += f'{k}:{v}\n'

View File

@ -1,6 +1,5 @@
# -*- coding: utf-8 -*-
#
import builtins
import time
from django.utils.translation import ugettext as _
@ -12,55 +11,76 @@ from rest_framework.serializers import ValidationError
from rest_framework.response import Response
from common.permissions import IsValidUser, NeedMFAVerify
from users.models.user import MFAType, User
from common.utils import get_logger
from users.models.user import User
from ..serializers import OtpVerifySerializer
from .. import serializers
from .. import errors
from ..mfa.otp import MFAOtp
from ..mixins import AuthMixin
__all__ = ['MFAChallengeApi', 'UserOtpVerifyApi', 'SendSMSVerifyCodeApi', 'MFASelectTypeApi']
logger = get_logger(__name__)
__all__ = [
'MFAChallengeVerifyApi', 'UserOtpVerifyApi',
'MFASendCodeApi'
]
class MFASelectTypeApi(AuthMixin, CreateAPIView):
# MFASelectAPi 原来的名字
class MFASendCodeApi(AuthMixin, CreateAPIView):
"""
选择 MFA 后对应操作 apikoko 目前在用
"""
permission_classes = (AllowAny,)
serializer_class = serializers.MFASelectTypeSerializer
def perform_create(self, serializer):
username = serializer.validated_data.get('username', '')
mfa_type = serializer.validated_data['type']
if mfa_type == MFAType.SMS_CODE:
if not username:
user = self.get_user_from_session()
user.send_sms_code()
else:
user = get_object_or_404(User, username=username)
mfa_backend = user.get_mfa_backend_by_type(mfa_type)
if not mfa_backend or not mfa_backend.challenge_required:
raise ValidationError('MFA type not support: {} {}'.format(mfa_type, mfa_backend))
mfa_backend.send_challenge()
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
self.perform_create(serializer)
return Response(serializer.data, status=201)
except Exception as e:
logger.exception(e)
return Response({'error': str(e)}, status=400)
class MFAChallengeApi(AuthMixin, CreateAPIView):
class MFAChallengeVerifyApi(AuthMixin, CreateAPIView):
permission_classes = (AllowAny,)
serializer_class = serializers.MFAChallengeSerializer
def perform_create(self, serializer):
try:
user = self.get_user_from_session()
code = serializer.validated_data.get('code')
mfa_type = serializer.validated_data.get('type', MFAType.OTP)
user = self.get_user_from_session()
code = serializer.validated_data.get('code')
mfa_type = serializer.validated_data.get('type', '')
self._do_check_user_mfa(code, mfa_type, user)
valid = user.check_mfa(code, mfa_type=mfa_type)
if not valid:
self.request.session['auth_mfa'] = ''
raise errors.MFAFailedError(
username=user.username, request=self.request, ip=self.get_request_ip()
)
else:
self.request.session['auth_mfa'] = '1'
def create(self, request, *args, **kwargs):
try:
super().create(request, *args, **kwargs)
return Response({'msg': 'ok'})
except errors.AuthFailedError as e:
data = {"error": e.error, "msg": e.msg}
raise ValidationError(data)
except errors.NeedMoreInfoError as e:
return Response(e.as_data(), status=200)
def create(self, request, *args, **kwargs):
super().create(request, *args, **kwargs)
return Response({'msg': 'ok'})
class UserOtpVerifyApi(CreateAPIView):
permission_classes = (IsValidUser,)
@ -73,30 +93,17 @@ class UserOtpVerifyApi(CreateAPIView):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
code = serializer.validated_data["code"]
otp = MFAOtp(request.user)
if request.user.check_mfa(code):
ok, error = otp.check_code(code)
if ok:
request.session["MFA_VERIFY_TIME"] = int(time.time())
return Response({"ok": "1"})
else:
return Response({"error": _("Code is invalid")}, status=400)
return Response({"error": _("Code is invalid") + ", " + error}, status=400)
def get_permissions(self):
if self.request.method.lower() == 'get' and settings.SECURITY_VIEW_AUTH_NEED_MFA:
if self.request.method.lower() == 'get' \
and settings.SECURITY_VIEW_AUTH_NEED_MFA:
self.permission_classes = [NeedMFAVerify]
return super().get_permissions()
class SendSMSVerifyCodeApi(AuthMixin, CreateAPIView):
permission_classes = (AllowAny,)
def create(self, request, *args, **kwargs):
username = request.data.get('username', '')
username = username.strip()
if username:
user = get_object_or_404(User, username=username)
else:
user = self.get_user_from_session()
if not user.mfa_enabled:
raise errors.NotEnableMFAError
timeout = user.send_sms_code()
return Response({'code': 'ok', 'timeout': timeout})

View File

@ -4,7 +4,7 @@ from rest_framework.response import Response
from authentication.serializers import PasswordVerifySerializer
from common.permissions import IsValidUser
from authentication.mixins import authenticate
from authentication.errors import PasswdInvalid
from authentication.errors import PasswordInvalid
from authentication.mixins import AuthMixin
@ -20,7 +20,7 @@ class UserPasswordVerifyApi(AuthMixin, CreateAPIView):
user = authenticate(request=request, username=user.username, password=password)
if not user:
raise PasswdInvalid
raise PasswordInvalid
self.set_passwd_verify_on_session(user)
self.mark_password_ok(user)
return Response()

View File

@ -40,5 +40,5 @@ class TokenCreateApi(AuthMixin, CreateAPIView):
return Response(e.as_data(), status=400)
except errors.NeedMoreInfoError as e:
return Response(e.as_data(), status=200)
except errors.PasswdTooSimple as e:
except errors.PasswordTooSimple as e:
return redirect(e.url)

View File

@ -8,7 +8,6 @@ from rest_framework import status
from common.exceptions import JMSException
from .signals import post_auth_failed
from users.utils import LoginBlockUtil, MFABlockUtils
from users.models import MFAType
reason_password_failed = 'password_failed'
reason_password_decrypt_failed = 'password_decrypt_failed'
@ -60,22 +59,11 @@ block_mfa_msg = _(
"The account has been locked "
"(please contact admin to unlock it or try again after {} minutes)"
)
otp_failed_msg = _(
"One-time password invalid, or ntp sync server time, "
mfa_error_msg = _(
"{error},"
"You can also try {times_try} times "
"(The account will be temporarily locked for {block_time} minutes)"
)
sms_failed_msg = _(
"SMS verify code invalid,"
"You can also try {times_try} times "
"(The account will be temporarily locked for {block_time} minutes)"
)
mfa_type_failed_msg = _(
"The MFA type({mfa_type}) is not supported, "
"You can also try {times_try} times "
"(The account will be temporarily locked for {block_time} minutes)"
)
mfa_required_msg = _("MFA required")
mfa_unset_msg = _("MFA not set, please set it first")
otp_unset_msg = _("OTP not set, please set it first")
@ -151,29 +139,19 @@ class MFAFailedError(AuthFailedNeedLogMixin, AuthFailedError):
error = reason_mfa_failed
msg: str
def __init__(self, username, request, ip, mfa_type=MFAType.OTP):
util = MFABlockUtils(username, ip)
util.incr_failed_count()
def __init__(self, username, request, ip, mfa_type, error):
super().__init__(username=username, request=request)
times_remainder = util.get_remainder_times()
util = MFABlockUtils(username, ip)
times_remainder = util.incr_failed_count()
block_time = settings.SECURITY_LOGIN_LIMIT_TIME
if times_remainder:
if mfa_type == MFAType.OTP:
self.msg = otp_failed_msg.format(
times_try=times_remainder, block_time=block_time
)
elif mfa_type == MFAType.SMS_CODE:
self.msg = sms_failed_msg.format(
times_try=times_remainder, block_time=block_time
)
else:
self.msg = mfa_type_failed_msg.format(
mfa_type=mfa_type, times_try=times_remainder, block_time=block_time
)
self.msg = mfa_error_msg.format(
error=error, times_try=times_remainder, block_time=block_time
)
else:
self.msg = block_mfa_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
super().__init__(username=username, request=request)
class BlockMFAError(AuthFailedNeedLogMixin, AuthFailedError):
@ -228,7 +206,7 @@ class MFARequiredError(NeedMoreInfoError):
msg = mfa_required_msg
error = 'mfa_required'
def __init__(self, error='', msg='', mfa_types=tuple(MFAType)):
def __init__(self, error='', msg='', mfa_types=()):
super().__init__(error=error, msg=msg)
self.choices = mfa_types
@ -305,7 +283,7 @@ class SSOAuthClosed(JMSException):
default_detail = _('SSO auth closed')
class PasswdTooSimple(JMSException):
class PasswordTooSimple(JMSException):
default_code = 'passwd_too_simple'
default_detail = _('Your password is too simple, please change it for security')
@ -314,7 +292,7 @@ class PasswdTooSimple(JMSException):
self.url = url
class PasswdNeedUpdate(JMSException):
class PasswordNeedUpdate(JMSException):
default_code = 'passwd_need_update'
default_detail = _('You should to change your password before login')
@ -357,7 +335,7 @@ class FeiShuNotBound(JMSException):
default_detail = 'FeiShu is not bound'
class PasswdInvalid(JMSException):
class PasswordInvalid(JMSException):
default_code = 'passwd_invalid'
default_detail = _('Your password is invalid')
@ -368,10 +346,6 @@ class NotHaveUpDownLoadPerm(JMSException):
default_detail = _('No upload or download permission')
class NotEnableMFAError(JMSException):
default_detail = mfa_unset_msg
class OTPBindRequiredError(JMSException):
default_detail = otp_unset_msg
@ -380,11 +354,13 @@ class OTPBindRequiredError(JMSException):
self.url = url
class OTPCodeRequiredError(AuthFailedError):
class MFACodeRequiredError(AuthFailedError):
msg = _("Please enter MFA code")
class SMSCodeRequiredError(AuthFailedError):
msg = _("Please enter SMS code")
class UserPhoneNotSet(AuthFailedError):
msg = _('Phone not set')

View File

@ -0,0 +1,5 @@
from .otp import MFAOtp, otp_failed_msg
from .sms import MFASms
from .radius import MFARadius
MFA_BACKENDS = [MFAOtp, MFASms, MFARadius]

View File

@ -0,0 +1,72 @@
import abc
from django.utils.translation import ugettext_lazy as _
class BaseMFA(abc.ABC):
placeholder = _('Please input security code')
def __init__(self, user):
"""
:param user: Authenticated user, Anonymous or None
因为首页登录时可能没法获取到一些状态
"""
self.user = user
def is_authenticated(self):
return self.user and self.user.is_authenticated
@property
@abc.abstractmethod
def name(self):
return ''
@property
@abc.abstractmethod
def display_name(self):
return ''
@staticmethod
def challenge_required():
return False
def send_challenge(self):
pass
@abc.abstractmethod
def check_code(self, code) -> tuple:
return False, 'Error msg'
@abc.abstractmethod
def is_active(self):
return False
@staticmethod
@abc.abstractmethod
def global_enabled():
return False
@abc.abstractmethod
def get_enable_url(self) -> str:
return ''
@abc.abstractmethod
def get_disable_url(self) -> str:
return ''
@abc.abstractmethod
def disable(self):
pass
@abc.abstractmethod
def can_disable(self) -> bool:
return True
@staticmethod
def help_text_of_enable():
return ''
@staticmethod
def help_text_of_disable():
return ''

View File

@ -0,0 +1,51 @@
from django.utils.translation import gettext_lazy as _
from django.shortcuts import reverse
from .base import BaseMFA
otp_failed_msg = _("OTP code invalid, or server time error")
class MFAOtp(BaseMFA):
name = 'otp'
display_name = _('OTP')
def check_code(self, code):
from users.utils import check_otp_code
assert self.is_authenticated()
ok = check_otp_code(self.user.otp_secret_key, code)
msg = '' if ok else otp_failed_msg
return ok, msg
def is_active(self):
if not self.is_authenticated():
return True
return self.user.otp_secret_key
@staticmethod
def global_enabled():
return True
def get_enable_url(self) -> str:
return reverse('authentication:user-otp-enable-start')
def disable(self):
assert self.is_authenticated()
self.user.otp_secret_key = ''
self.user.save(update_fields=['otp_secret_key'])
def can_disable(self) -> bool:
return True
def get_disable_url(self):
return reverse('authentication:user-otp-disable')
@staticmethod
def help_text_of_enable():
return _("Virtual OTP based MFA")
def help_text_of_disable(self):
return ''

View File

@ -0,0 +1,46 @@
from django.utils.translation import ugettext_lazy as _
from django.conf import settings
from .base import BaseMFA
from ..backends.radius import RadiusBackend
mfa_failed_msg = _("Radius verify code invalid")
class MFARadius(BaseMFA):
name = 'otp_radius'
display_name = _('Radius MFA')
def check_code(self, code):
assert self.is_authenticated()
backend = RadiusBackend()
username = self.user.username
user = backend.authenticate(
None, username=username, password=code
)
ok = user is not None
msg = '' if ok else mfa_failed_msg
return ok, msg
def is_active(self):
return True
@staticmethod
def global_enabled():
return settings.OTP_IN_RADIUS
def get_enable_url(self) -> str:
return ''
def can_disable(self):
return False
def disable(self):
return ''
@staticmethod
def help_text_of_disable():
return _("Radius global enabled, cannot disable")
def get_disable_url(self) -> str:
return ''

View File

@ -0,0 +1,60 @@
from django.utils.translation import ugettext_lazy as _
from django.conf import settings
from .base import BaseMFA
from common.sdk.sms import SendAndVerifySMSUtil
sms_failed_msg = _("SMS verify code invalid")
class MFASms(BaseMFA):
name = 'sms'
display_name = _("SMS")
placeholder = _("SMS verification code")
def __init__(self, user):
super().__init__(user)
phone = user.phone if self.is_authenticated() else ''
self.sms = SendAndVerifySMSUtil(phone)
def check_code(self, code):
assert self.is_authenticated()
ok = self.sms.verify(code)
msg = '' if ok else sms_failed_msg
return ok, msg
def is_active(self):
if not self.is_authenticated():
return True
return self.user.phone
@staticmethod
def challenge_required():
return True
def send_challenge(self):
self.sms.gen_and_send()
@staticmethod
def global_enabled():
return settings.SMS_ENABLED
def get_enable_url(self) -> str:
return '/ui/#/users/profile/?activeTab=ProfileUpdate'
def can_disable(self) -> bool:
return True
def disable(self):
return '/ui/#/users/profile/?activeTab=ProfileUpdate'
@staticmethod
def help_text_of_enable():
return _("Set phone number to enable")
@staticmethod
def help_text_of_disable():
return _("Clear phone number to disable")
def get_disable_url(self) -> str:
return '/ui/#/users/profile/?activeTab=ProfileUpdate'

View File

@ -10,5 +10,5 @@ class MFAMiddleware:
if request.path.find('/auth/login/otp/') > -1:
return response
if request.session.get('auth_mfa_required'):
return redirect('authentication:login-otp')
return redirect('authentication:login-mfa')
return response

View File

@ -1,24 +1,26 @@
# -*- coding: utf-8 -*-
#
import inspect
from django.utils.http import urlencode
from functools import partial
import time
from typing import Callable
from django.utils.http import urlencode
from django.core.cache import cache
from django.conf import settings
from django.urls import reverse_lazy
from django.contrib import auth
from django.utils.translation import ugettext as _
from rest_framework.request import Request
from django.contrib.auth import (
BACKEND_SESSION_KEY, _get_backends,
PermissionDenied, user_login_failed, _clean_credentials
)
from django.shortcuts import reverse, redirect
from django.shortcuts import reverse, redirect, get_object_or_404
from common.utils import get_object_or_none, get_request_ip, get_logger, bulk_get, FlashMessageUtil
from acls.models import LoginACL
from users.models import User, MFAType
from users.models import User
from users.utils import LoginBlockUtil, MFABlockUtils
from . import errors
from .utils import rsa_decrypt, gen_key_pair
@ -32,8 +34,7 @@ def check_backend_can_auth(username, backend_path, allowed_auth_backends):
if allowed_auth_backends is not None and backend_path not in allowed_auth_backends:
logger.debug('Skip user auth backend: {}, {} not in'.format(
username, backend_path, ','.join(allowed_auth_backends)
)
)
))
return False
return True
@ -109,17 +110,18 @@ class PasswordEncryptionViewMixin:
def decrypt_passwd(self, raw_passwd):
# 获取解密密钥,对密码进行解密
rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY)
if rsa_private_key is not None:
try:
return rsa_decrypt(raw_passwd, rsa_private_key)
except Exception as e:
logger.error(e, exc_info=True)
logger.error(
f'Decrypt password failed: password[{raw_passwd}] '
f'rsa_private_key[{rsa_private_key}]'
)
return None
return raw_passwd
if rsa_private_key is None:
return raw_passwd
try:
return rsa_decrypt(raw_passwd, rsa_private_key)
except Exception as e:
logger.error(e, exc_info=True)
logger.error(
f'Decrypt password failed: password[{raw_passwd}] '
f'rsa_private_key[{rsa_private_key}]'
)
return None
def get_request_ip(self):
ip = ''
@ -132,7 +134,7 @@ class PasswordEncryptionViewMixin:
# 生成加解密密钥对public_key传递给前端private_key存入session中供解密使用
rsa_public_key = self.request.session.get(RSA_PUBLIC_KEY)
rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY)
if not all((rsa_private_key, rsa_public_key)):
if not all([rsa_private_key, rsa_public_key]):
rsa_private_key, rsa_public_key = gen_key_pair()
rsa_public_key = rsa_public_key.replace('\n', '\\n')
self.request.session[RSA_PRIVATE_KEY] = rsa_private_key
@ -144,49 +146,9 @@ class PasswordEncryptionViewMixin:
return super().get_context_data(**kwargs)
class AuthMixin(PasswordEncryptionViewMixin):
request = None
partial_credential_error = None
key_prefix_captcha = "_LOGIN_INVALID_{}"
def get_user_from_session(self):
if self.request.session.is_empty():
raise errors.SessionEmptyError()
if all((self.request.user,
not self.request.user.is_anonymous,
BACKEND_SESSION_KEY in self.request.session)):
user = self.request.user
user.backend = self.request.session[BACKEND_SESSION_KEY]
return user
user_id = self.request.session.get('user_id')
if not user_id:
user = None
else:
user = get_object_or_none(User, pk=user_id)
if not user:
raise errors.SessionEmptyError()
user.backend = self.request.session.get("auth_backend")
return user
def _check_is_block(self, username, raise_exception=True):
ip = self.get_request_ip()
if LoginBlockUtil(username, ip).is_block():
logger.warn('Ip was blocked' + ': ' + username + ':' + ip)
exception = errors.BlockLoginError(username=username, ip=ip)
if raise_exception:
raise errors.BlockLoginError(username=username, ip=ip)
else:
return exception
def check_is_block(self, raise_exception=True):
if hasattr(self.request, 'data'):
username = self.request.data.get("username")
else:
username = self.request.POST.get("username")
self._check_is_block(username, raise_exception)
class CommonMixin(PasswordEncryptionViewMixin):
request: Request
get_request_ip: Callable
def raise_credential_error(self, error):
raise self.partial_credential_error(error=error)
@ -197,6 +159,31 @@ class AuthMixin(PasswordEncryptionViewMixin):
ip=ip, request=request
)
def get_user_from_session(self):
if self.request.session.is_empty():
raise errors.SessionEmptyError()
if all([
self.request.user,
not self.request.user.is_anonymous,
BACKEND_SESSION_KEY in self.request.session
]):
user = self.request.user
user.backend = self.request.session[BACKEND_SESSION_KEY]
return user
user_id = self.request.session.get('user_id')
auth_password = self.request.session.get('auth_password')
auth_expired_at = self.request.session.get('auth_password_expired_at')
auth_expired = auth_expired_at < time.time() if auth_expired_at else False
if not user_id or not auth_password or auth_expired:
raise errors.SessionEmptyError()
user = get_object_or_404(User, pk=user_id)
user.backend = self.request.session.get("auth_backend")
return user
def get_auth_data(self, decrypt_passwd=False):
request = self.request
if hasattr(request, 'data'):
@ -214,6 +201,31 @@ class AuthMixin(PasswordEncryptionViewMixin):
password = password + challenge.strip()
return username, password, public_key, ip, auto_login
class AuthPreCheckMixin:
request: Request
get_request_ip: Callable
raise_credential_error: Callable
def _check_is_block(self, username, raise_exception=True):
ip = self.get_request_ip()
is_block = LoginBlockUtil(username, ip).is_block()
if not is_block:
return
logger.warn('Ip was blocked' + ': ' + username + ':' + ip)
exception = errors.BlockLoginError(username=username, ip=ip)
if raise_exception:
raise errors.BlockLoginError(username=username, ip=ip)
else:
return exception
def check_is_block(self, raise_exception=True):
if hasattr(self.request, 'data'):
username = self.request.data.get("username")
else:
username = self.request.POST.get("username")
self._check_is_block(username, raise_exception)
def _check_only_allow_exists_user_auth(self, username):
# 仅允许预先存在的用户认证
if not settings.ONLY_ALLOW_EXIST_USER_AUTH:
@ -224,105 +236,92 @@ class AuthMixin(PasswordEncryptionViewMixin):
logger.error(f"Only allow exist user auth, login failed: {username}")
self.raise_credential_error(errors.reason_user_not_exist)
def _check_auth_user_is_valid(self, username, password, public_key):
user = authenticate(self.request, username=username, password=password, public_key=public_key)
if not user:
self.raise_credential_error(errors.reason_password_failed)
elif user.is_expired:
self.raise_credential_error(errors.reason_user_expired)
elif not user.is_active:
self.raise_credential_error(errors.reason_user_inactive)
return user
def _check_login_mfa_login_if_need(self, user):
class MFAMixin:
request: Request
get_user_from_session: Callable
get_request_ip: Callable
def _check_login_page_mfa_if_need(self, user):
if not settings.SECURITY_MFA_IN_LOGIN_PAGE:
return
request = self.request
if hasattr(request, 'data'):
data = request.data
else:
data = request.POST
data = request.data if hasattr(request, 'data') else request.POST
code = data.get('code')
mfa_type = data.get('mfa_type')
if settings.SECURITY_MFA_IN_LOGIN_PAGE and mfa_type:
if not code:
if mfa_type == MFAType.OTP and bool(user.otp_secret_key):
raise errors.OTPCodeRequiredError
elif mfa_type == MFAType.SMS_CODE:
raise errors.SMSCodeRequiredError
self.check_user_mfa(code, mfa_type, user=user)
mfa_type = data.get('mfa_type', 'otp')
if not code:
raise errors.MFACodeRequiredError
self._do_check_user_mfa(code, mfa_type, user=user)
def _check_login_acl(self, user, ip):
# ACL 限制用户登录
is_allowed, limit_type = LoginACL.allow_user_to_login(user, ip)
if not is_allowed:
if limit_type == 'ip':
raise errors.LoginIPNotAllowed(username=user.username, request=self.request)
elif limit_type == 'time':
raise errors.TimePeriodNotAllowed(username=user.username, request=self.request)
def check_user_mfa_if_need(self, user):
if self.request.session.get('auth_mfa'):
return
if not user.mfa_enabled:
return
def set_login_failed_mark(self):
active_mfa_mapper = user.active_mfa_backends_mapper
if not active_mfa_mapper:
url = reverse('authentication:user-otp-enable-start')
raise errors.MFAUnsetError(user, self.request, url)
raise errors.MFARequiredError(mfa_types=tuple(active_mfa_mapper.keys()))
def mark_mfa_ok(self, mfa_type):
self.request.session['auth_mfa'] = 1
self.request.session['auth_mfa_time'] = time.time()
self.request.session['auth_mfa_required'] = 0
self.request.session['auth_mfa_type'] = mfa_type
def clean_mfa_mark(self):
keys = ['auth_mfa', 'auth_mfa_time', 'auth_mfa_required', 'auth_mfa_type']
for k in keys:
self.request.session.pop(k, '')
def check_mfa_is_block(self, username, ip, raise_exception=True):
blocked = MFABlockUtils(username, ip).is_block()
if not blocked:
return
logger.warn('Ip was blocked' + ': ' + username + ':' + ip)
exception = errors.BlockMFAError(username=username, request=self.request, ip=ip)
if raise_exception:
raise exception
else:
return exception
def _do_check_user_mfa(self, code, mfa_type, user=None):
user = user if user else self.get_user_from_session()
if not user.mfa_enabled:
return
# 监测 MFA 是不是屏蔽了
ip = self.get_request_ip()
cache.set(self.key_prefix_captcha.format(ip), 1, 3600)
self.check_mfa_is_block(user.username, ip)
def set_passwd_verify_on_session(self, user: User):
self.request.session['user_id'] = str(user.id)
self.request.session['auth_password'] = 1
self.request.session['auth_password_expired_at'] = time.time() + settings.AUTH_EXPIRED_SECONDS
ok = False
mfa_backend = user.get_mfa_backend_by_type(mfa_type)
if mfa_backend:
ok, msg = mfa_backend.check_code(code)
else:
msg = _('The MFA type({}) is not supported'.format(mfa_type))
def check_is_need_captcha(self):
# 最近有登录失败时需要填写验证码
ip = get_request_ip(self.request)
need = cache.get(self.key_prefix_captcha.format(ip))
return need
if ok:
self.mark_mfa_ok(mfa_type)
return
def check_user_auth(self, decrypt_passwd=False):
self.check_is_block()
username, password, public_key, ip, auto_login = self.get_auth_data(decrypt_passwd)
raise errors.MFAFailedError(
username=user.username,
request=self.request,
ip=ip, mfa_type=mfa_type,
error=msg
)
self._check_only_allow_exists_user_auth(username)
user = self._check_auth_user_is_valid(username, password, public_key)
# 校验login-acl规则
self._check_login_acl(user, ip)
self._check_password_require_reset_or_not(user)
self._check_passwd_is_too_simple(user, password)
self._check_passwd_need_update(user)
@staticmethod
def get_user_mfa_context(user=None):
mfa_backends = User.get_user_mfa_backends(user)
return {'mfa_backends': mfa_backends}
# 校验login-mfa, 如果登录页面上显示 mfa 的话
self._check_login_mfa_login_if_need(user)
LoginBlockUtil(username, ip).clean_failed_count()
request = self.request
request.session['auth_password'] = 1
request.session['user_id'] = str(user.id)
request.session['auto_login'] = auto_login
request.session['auth_backend'] = getattr(user, 'backend', settings.AUTH_BACKEND_MODEL)
return user
def _check_is_local_user(self, user: User):
if user.source != User.Source.local:
raise self.raise_credential_error(error=errors.only_local_users_are_allowed)
def check_oauth2_auth(self, user: User, auth_backend):
ip = self.get_request_ip()
request = self.request
self._set_partial_credential_error(user.username, ip, request)
if user.is_expired:
self.raise_credential_error(errors.reason_user_expired)
elif not user.is_active:
self.raise_credential_error(errors.reason_user_inactive)
self._check_is_block(user.username)
self._check_login_acl(user, ip)
LoginBlockUtil(user.username, ip).clean_failed_count()
MFABlockUtils(user.username, ip).clean_failed_count()
request.session['auth_password'] = 1
request.session['user_id'] = str(user.id)
request.session['auth_backend'] = auth_backend
return user
class AuthPostCheckMixin:
@classmethod
def generate_reset_password_url_with_flash_msg(cls, user, message):
reset_passwd_url = reverse('authentication:reset-password')
@ -344,14 +343,14 @@ class AuthMixin(PasswordEncryptionViewMixin):
if user.is_superuser and password == 'admin':
message = _('Your password is too simple, please change it for security')
url = cls.generate_reset_password_url_with_flash_msg(user, message=message)
raise errors.PasswdTooSimple(url)
raise errors.PasswordTooSimple(url)
@classmethod
def _check_passwd_need_update(cls, user: User):
if user.need_update_password:
message = _('You should to change your password before login')
url = cls.generate_reset_password_url_with_flash_msg(user, message)
raise errors.PasswdNeedUpdate(url)
raise errors.PasswordNeedUpdate(url)
@classmethod
def _check_password_require_reset_or_not(cls, user: User):
@ -360,76 +359,20 @@ class AuthMixin(PasswordEncryptionViewMixin):
url = cls.generate_reset_password_url_with_flash_msg(user, message)
raise errors.PasswordRequireResetError(url)
def check_user_auth_if_need(self, decrypt_passwd=False):
request = self.request
if request.session.get('auth_password') and \
request.session.get('user_id'):
user = self.get_user_from_session()
if user:
return user
return self.check_user_auth(decrypt_passwd=decrypt_passwd)
def check_user_mfa_if_need(self, user):
if self.request.session.get('auth_mfa'):
class AuthACLMixin:
request: Request
get_request_ip: Callable
def _check_login_acl(self, user, ip):
# ACL 限制用户登录
is_allowed, limit_type = LoginACL.allow_user_to_login(user, ip)
if is_allowed:
return
if settings.OTP_IN_RADIUS:
return
if not user.mfa_enabled:
return
unset, url = user.mfa_enabled_but_not_set()
if unset:
raise errors.MFAUnsetError(user, self.request, url)
raise errors.MFARequiredError(mfa_types=user.get_supported_mfa_types())
def mark_mfa_ok(self, mfa_type=MFAType.OTP):
self.request.session['auth_mfa'] = 1
self.request.session['auth_mfa_time'] = time.time()
self.request.session['auth_mfa_required'] = ''
self.request.session['auth_mfa_type'] = mfa_type
def clean_mfa_mark(self):
self.request.session['auth_mfa'] = ''
self.request.session['auth_mfa_time'] = ''
self.request.session['auth_mfa_required'] = ''
self.request.session['auth_mfa_type'] = ''
def check_mfa_is_block(self, username, ip, raise_exception=True):
blocked = MFABlockUtils(username, ip).is_block()
if not blocked:
return
logger.warn('Ip was blocked' + ': ' + username + ':' + ip)
exception = errors.BlockMFAError(username=username, request=self.request, ip=ip)
if raise_exception:
raise exception
else:
return exception
def check_user_mfa(self, code, mfa_type=MFAType.OTP, user=None):
user = user if user else self.get_user_from_session()
if not user.mfa_enabled:
return
if not bool(user.phone) and mfa_type == MFAType.SMS_CODE:
raise errors.UserPhoneNotSet
if not bool(user.otp_secret_key) and mfa_type == MFAType.OTP:
self.set_passwd_verify_on_session(user)
raise errors.OTPBindRequiredError(reverse_lazy('authentication:user-otp-enable-bind'))
ip = self.get_request_ip()
self.check_mfa_is_block(user.username, ip)
ok = user.check_mfa(code, mfa_type=mfa_type)
if ok:
self.mark_mfa_ok()
return
raise errors.MFAFailedError(
username=user.username,
request=self.request,
ip=ip, mfa_type=mfa_type,
)
if limit_type == 'ip':
raise errors.LoginIPNotAllowed(username=user.username, request=self.request)
elif limit_type == 'time':
raise errors.TimePeriodNotAllowed(username=user.username, request=self.request)
def get_ticket(self):
from tickets.models import Ticket
@ -480,11 +423,99 @@ class AuthMixin(PasswordEncryptionViewMixin):
self.get_ticket_or_create(confirm_setting)
self.check_user_login_confirm()
class AuthMixin(CommonMixin, AuthPreCheckMixin, AuthACLMixin, MFAMixin, AuthPostCheckMixin):
request = None
partial_credential_error = None
key_prefix_captcha = "_LOGIN_INVALID_{}"
def _check_auth_user_is_valid(self, username, password, public_key):
user = authenticate(
self.request, username=username,
password=password, public_key=public_key
)
if not user:
self.raise_credential_error(errors.reason_password_failed)
elif user.is_expired:
self.raise_credential_error(errors.reason_user_expired)
elif not user.is_active:
self.raise_credential_error(errors.reason_user_inactive)
return user
def set_login_failed_mark(self):
ip = self.get_request_ip()
cache.set(self.key_prefix_captcha.format(ip), 1, 3600)
def check_is_need_captcha(self):
# 最近有登录失败时需要填写验证码
ip = get_request_ip(self.request)
need = cache.get(self.key_prefix_captcha.format(ip))
return need
def check_user_auth(self, decrypt_passwd=False):
# pre check
self.check_is_block()
username, password, public_key, ip, auto_login = self.get_auth_data(decrypt_passwd)
self._check_only_allow_exists_user_auth(username)
# check auth
user = self._check_auth_user_is_valid(username, password, public_key)
# 校验login-acl规则
self._check_login_acl(user, ip)
# post check
self._check_password_require_reset_or_not(user)
self._check_passwd_is_too_simple(user, password)
self._check_passwd_need_update(user)
# 校验login-mfa, 如果登录页面上显示 mfa 的话
self._check_login_page_mfa_if_need(user)
# 标记密码验证成功
self.mark_password_ok(user=user, auto_login=auto_login)
LoginBlockUtil(user.username, ip).clean_failed_count()
return user
def mark_password_ok(self, user, auto_login=False):
request = self.request
request.session['auth_password'] = 1
request.session['auth_password_expired_at'] = time.time() + settings.AUTH_EXPIRED_SECONDS
request.session['user_id'] = str(user.id)
request.session['auto_login'] = auto_login
request.session['auth_backend'] = getattr(user, 'backend', settings.AUTH_BACKEND_MODEL)
def check_oauth2_auth(self, user: User, auth_backend):
ip = self.get_request_ip()
request = self.request
self._set_partial_credential_error(user.username, ip, request)
if user.is_expired:
self.raise_credential_error(errors.reason_user_expired)
elif not user.is_active:
self.raise_credential_error(errors.reason_user_inactive)
self._check_is_block(user.username)
self._check_login_acl(user, ip)
LoginBlockUtil(user.username, ip).clean_failed_count()
MFABlockUtils(user.username, ip).clean_failed_count()
self.mark_password_ok(user, False)
return user
def check_user_auth_if_need(self, decrypt_passwd=False):
request = self.request
if not request.session.get('auth_password'):
return self.check_user_auth(decrypt_passwd=decrypt_passwd)
return self.get_user_from_session()
def clear_auth_mark(self):
self.request.session['auth_password'] = ''
self.request.session['auth_user_id'] = ''
self.request.session['auth_confirm'] = ''
self.request.session['auth_ticket_id'] = ''
keys = ['auth_password', 'user_id', 'auth_confirm', 'auth_ticket_id']
for k in keys:
self.request.session.pop(k, '')
def send_auth_signal(self, success=True, user=None, username='', reason=''):
if success:
@ -503,31 +534,3 @@ class AuthMixin(PasswordEncryptionViewMixin):
if args:
guard_url = "%s?%s" % (guard_url, args)
return redirect(guard_url)
@staticmethod
def get_user_mfa_methods(user=None):
otp_enabled = user.otp_secret_key if user else True
# 没有用户时,或者有用户并且有电话配置
sms_enabled = any([user and user.phone, not user]) \
and settings.SMS_ENABLED and settings.XPACK_ENABLED
methods = [
{
'name': 'otp',
'label': 'MFA',
'enable': otp_enabled,
'selected': False,
},
{
'name': 'sms',
'label': _('SMS'),
'enable': sms_enabled,
'selected': False,
},
]
for item in methods:
if item['enable']:
item['selected'] = True
break
return methods

View File

@ -78,6 +78,7 @@ class BearerTokenSerializer(serializers.Serializer):
class MFASelectTypeSerializer(serializers.Serializer):
type = serializers.CharField()
username = serializers.CharField(required=False, allow_blank=True, allow_null=True)
class MFAChallengeSerializer(serializers.Serializer):

View File

@ -13,11 +13,11 @@ from .signals import post_auth_success, post_auth_failed
@receiver(user_logged_in)
def on_user_auth_login_success(sender, user, request, **kwargs):
# 开启了 MFA且没有校验过
if user.mfa_enabled and not settings.OTP_IN_RADIUS and not request.session.get('auth_mfa'):
# 开启了 MFA且没有校验过, 可以全局校验, middleware 中可以全局管理 oidc 等第三方认证的 MFA
if user.mfa_enabled and not request.session.get('auth_mfa'):
request.session['auth_mfa_required'] = 1
# 单点登录,超过了自动退出
if settings.USER_LOGIN_SINGLE_MACHINE_ENABLED:
user_id = 'single_machine_login_' + str(user.id)
session_key = cache.get(user_id)

View File

@ -160,7 +160,7 @@
{% bootstrap_field form.challenge show_label=False %}
{% elif form.mfa_type %}
<div class="form-group" style="display: flex">
{% include '_mfa_otp_login.html' %}
{% include '_mfa_login_field.html' %}
</div>
{% elif form.captcha %}
<div class="captch-field">
@ -208,6 +208,7 @@
</div>
</div>
</body>
{% include '_foot_js.html' %}
<script type="text/javascript" src="/static/js/plugins/jsencrypt/jsencrypt.min.js"></script>
<script>
function encryptLoginPassword(password, rsaPublicKey) {

View File

@ -13,19 +13,18 @@
<p class="red-fonts">{{ form.code.errors.as_text }}</p>
{% endif %}
<div class="form-group">
{% include '_mfa_otp_login.html' %}
{% include '_mfa_login_field.html' %}
</div>
<button id='submit_button' type="submit"
class="btn btn-primary block full-width m-b">{% trans 'Next' %}</button>
<button id='submit_button' type="submit" class="btn btn-primary block full-width m-b">
{% trans 'Next' %}
</button>
<div>
<small>{% trans "Can't provide security? Please contact the administrator!" %}</small>
</div>
</form>
<style type="text/css">
<style>
.mfa-div {
margin-top: 15px;
}
</style>
<script>
</script>
{% endblock %}

View File

@ -25,10 +25,11 @@ urlpatterns = [
path('auth/', api.TokenCreateApi.as_view(), name='user-auth'),
path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'),
path('mfa/challenge/', api.MFAChallengeApi.as_view(), name='mfa-challenge'),
path('mfa/select/', api.MFASelectTypeApi.as_view(), name='mfa-select'),
path('mfa/verify/', api.MFAChallengeVerifyApi.as_view(), name='mfa-verify'),
path('mfa/challenge/', api.MFAChallengeVerifyApi.as_view(), name='mfa-challenge'),
path('mfa/select/', api.MFASendCodeApi.as_view(), name='mfa-select'),
path('mfa/send-code/', api.MFASendCodeApi.as_view(), name='mfa-send-codej'),
path('otp/verify/', api.UserOtpVerifyApi.as_view(), name='user-otp-verify'),
path('sms/verify-code/send/', api.SendSMSVerifyCodeApi.as_view(), name='sms-verify-code-send'),
path('password/verify/', api.UserPasswordVerifyApi.as_view(), name='user-password-verify'),
path('login-confirm-ticket/status/', api.TicketStatusApi.as_view(), name='login-confirm-ticket-status'),
]

View File

@ -12,7 +12,7 @@ app_name = 'authentication'
urlpatterns = [
# login
path('login/', non_atomic_requests(views.UserLoginView.as_view()), name='login'),
path('login/otp/', views.UserLoginOtpView.as_view(), name='login-otp'),
path('login/mfa/', views.UserLoginMFAView.as_view(), name='login-mfa'),
path('login/wait-confirm/', views.UserLoginWaitConfirmView.as_view(), name='login-wait-confirm'),
path('login/guard/', views.UserLoginGuardView.as_view(), name='login-guard'),
path('logout/', views.UserLogoutView.as_view(), name='logout'),
@ -42,14 +42,15 @@ urlpatterns = [
# Profile
path('profile/pubkey/generate/', users_view.UserPublicKeyGenerateView.as_view(), name='user-pubkey-generate'),
path('profile/mfa/', users_view.MFASettingView.as_view(), name='user-mfa-setting'),
# OTP Setting
path('profile/otp/enable/start/', users_view.UserOtpEnableStartView.as_view(), name='user-otp-enable-start'),
path('profile/otp/enable/install-app/', users_view.UserOtpEnableInstallAppView.as_view(),
name='user-otp-enable-install-app'),
path('profile/otp/enable/bind/', users_view.UserOtpEnableBindView.as_view(), name='user-otp-enable-bind'),
path('profile/otp/disable/authentication/', users_view.UserDisableMFAView.as_view(),
name='user-otp-disable-authentication'),
path('profile/otp/update/', users_view.UserOtpUpdateView.as_view(), name='user-otp-update'),
path('profile/otp/settings-success/', users_view.UserOtpSettingsSuccessView.as_view(), name='user-otp-settings-success'),
path('profile/otp/disable/', users_view.UserOtpDisableView.as_view(),
name='user-otp-disable'),
path('first-login/', users_view.UserFirstLoginView.as_view(), name='user-first-login'),
# openid

View File

@ -5,6 +5,7 @@ from Cryptodome.PublicKey import RSA
from Cryptodome.Cipher import PKCS1_v1_5
from Cryptodome import Random
from django.conf import settings
from .notifications import DifferentCityLoginMessage
from audits.models import UserLoginLog
from audits.const import DEFAULT_CITY
@ -51,7 +52,10 @@ def rsa_decrypt(cipher_text, rsa_private_key=None):
return message
def check_different_city_login(user, request):
def check_different_city_login_if_need(user, request):
if not settings.SECURITY_CHECK_DIFFERENT_CITY_LOGIN:
return
ip = get_request_ip(request) or '0.0.0.0'
if not (ip and validate_ip(ip)):
@ -59,6 +63,10 @@ def check_different_city_login(user, request):
else:
city = get_ip_city(ip) or DEFAULT_CITY
last_user_login = UserLoginLog.objects.filter(username=user.username, status=True).first()
if last_user_login and last_user_login.city != city:
DifferentCityLoginMessage(user, ip, city).publish_async()
city_white = ['LAN', ]
if city not in city_white:
last_user_login = UserLoginLog.objects.exclude(city__in=city_white) \
.filter(username=user.username, status=True).first()
if last_user_login and last_user_login.city != city:
DifferentCityLoginMessage(user, ip, city).publish_async()

View File

@ -122,16 +122,16 @@ class UserLoginView(mixins.AuthMixin, FormView):
self.request.session.set_test_cookie()
return self.render_to_response(context)
except (
errors.PasswdTooSimple,
errors.PasswordTooSimple,
errors.PasswordRequireResetError,
errors.PasswdNeedUpdate,
errors.PasswordNeedUpdate,
errors.OTPBindRequiredError
) as e:
return redirect(e.url)
except (
errors.MFAFailedError,
errors.BlockMFAError,
errors.OTPCodeRequiredError,
errors.MFACodeRequiredError,
errors.SMSCodeRequiredError,
errors.UserPhoneNotSet
) as e:
@ -199,7 +199,7 @@ class UserLoginView(mixins.AuthMixin, FormView):
'demo_mode': os.environ.get("DEMO_MODE"),
'auth_methods': self.get_support_auth_methods(),
'forgot_password_url': self.get_forgot_password_url(),
'methods': self.get_user_mfa_methods(),
**self.get_user_mfa_context(self.request.user)
}
kwargs.update(context)
return super().get_context_data(**kwargs)
@ -208,7 +208,7 @@ class UserLoginView(mixins.AuthMixin, FormView):
class UserLoginGuardView(mixins.AuthMixin, RedirectView):
redirect_field_name = 'next'
login_url = reverse_lazy('authentication:login')
login_otp_url = reverse_lazy('authentication:login-otp')
login_mfa_url = reverse_lazy('authentication:login-mfa')
login_confirm_url = reverse_lazy('authentication:login-wait-confirm')
def format_redirect_url(self, url):
@ -229,15 +229,16 @@ class UserLoginGuardView(mixins.AuthMixin, RedirectView):
user = self.check_user_auth_if_need()
self.check_user_mfa_if_need(user)
self.check_user_login_confirm_if_need(user)
except (errors.CredentialError, errors.SessionEmptyError):
except (errors.CredentialError, errors.SessionEmptyError) as e:
print("Error: ", e)
return self.format_redirect_url(self.login_url)
except errors.MFARequiredError:
return self.format_redirect_url(self.login_otp_url)
return self.format_redirect_url(self.login_mfa_url)
except errors.LoginConfirmBaseError:
return self.format_redirect_url(self.login_confirm_url)
except errors.MFAUnsetError as e:
return e.url
except errors.PasswdTooSimple as e:
except errors.PasswordTooSimple as e:
return e.url
else:
self.login_it(user)

View File

@ -3,32 +3,39 @@
from __future__ import unicode_literals
from django.views.generic.edit import FormView
from django.utils.translation import gettext_lazy as _
from django.conf import settings
from common.utils import get_logger
from .. import forms, errors, mixins
from .utils import redirect_to_guard_view
from common.utils import get_logger
logger = get_logger(__name__)
__all__ = ['UserLoginOtpView']
__all__ = ['UserLoginMFAView']
class UserLoginOtpView(mixins.AuthMixin, FormView):
template_name = 'authentication/login_otp.html'
class UserLoginMFAView(mixins.AuthMixin, FormView):
template_name = 'authentication/login_mfa.html'
form_class = forms.UserCheckOtpCodeForm
redirect_field_name = 'next'
def get(self, *args, **kwargs):
try:
self.get_user_from_session()
except errors.SessionEmptyError:
return redirect_to_guard_view()
return super().get(*args, **kwargs)
def form_valid(self, form):
code = form.cleaned_data.get('code')
mfa_type = form.cleaned_data.get('mfa_type')
try:
self.check_user_mfa(code, mfa_type)
self._do_check_user_mfa(code, mfa_type)
return redirect_to_guard_view()
except (errors.MFAFailedError, errors.BlockMFAError) as e:
form.add_error('code', e.msg)
return super().form_invalid(form)
except errors.SessionEmptyError:
return redirect_to_guard_view()
except Exception as e:
logger.error(e)
import traceback
@ -37,6 +44,7 @@ class UserLoginOtpView(mixins.AuthMixin, FormView):
def get_context_data(self, **kwargs):
user = self.get_user_from_session()
methods = self.get_user_mfa_methods(user)
kwargs.update({'methods': methods})
mfa_context = self.get_user_mfa_context(user)
kwargs.update(mfa_context)
return kwargs

View File

@ -298,9 +298,12 @@ class CommonSerializerMixin(DynamicFieldsMixin, DefaultValueFieldsMixin):
def get_initial_value(self, attr, default=None):
value = self.initial_data.get(attr)
if not value and self.instance:
if value is not None:
return value
if self.instance:
value = getattr(self.instance, attr, default)
return value
return value
return default
class CommonBulkSerializerMixin(BulkSerializerMixin, CommonSerializerMixin):

View File

@ -1,65 +1,2 @@
from collections import OrderedDict
import importlib
from django.utils.translation import gettext_lazy as _
from django.db.models import TextChoices
from django.conf import settings
from common.utils import get_logger
from common.exceptions import JMSException
logger = get_logger(__file__)
class BACKENDS(TextChoices):
ALIBABA = 'alibaba', _('Alibaba cloud')
TENCENT = 'tencent', _('Tencent cloud')
class BaseSMSClient:
"""
短信终端的基类
"""
SIGN_AND_TMPL_SETTING_FIELD_PREFIX: str
@classmethod
def new_from_settings(cls):
raise NotImplementedError
def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: dict, **kwargs):
raise NotImplementedError
class SMS:
client: BaseSMSClient
def __init__(self, backend=None):
backend = backend or settings.SMS_BACKEND
if backend not in BACKENDS:
raise JMSException(
code='sms_provider_not_support',
detail=_('SMS provider not support: {}').format(backend)
)
m = importlib.import_module(f'.{backend or settings.SMS_BACKEND}', __package__)
self.client = m.client.new_from_settings()
def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: dict, **kwargs):
return self.client.send_sms(
phone_numbers=phone_numbers,
sign_name=sign_name,
template_code=template_code,
template_param=template_param,
**kwargs
)
def send_verify_code(self, phone_number, code):
sign_name = getattr(settings, f'{self.client.SIGN_AND_TMPL_SETTING_FIELD_PREFIX}_VERIFY_SIGN_NAME')
template_code = getattr(settings, f'{self.client.SIGN_AND_TMPL_SETTING_FIELD_PREFIX}_VERIFY_TEMPLATE_CODE')
if not (sign_name and template_code):
raise JMSException(
code='verify_code_sign_tmpl_invalid',
detail=_('SMS verification code signature or template invalid')
)
return self.send_sms([phone_number], sign_name, template_code, OrderedDict(code=code))
from .endpoint import SMS, BACKENDS
from .utils import SendAndVerifySMSUtil

View File

@ -9,7 +9,7 @@ from Tea.exceptions import TeaException
from common.utils import get_logger
from common.exceptions import JMSException
from . import BaseSMSClient
from .base import BaseSMSClient
logger = get_logger(__file__)

View File

@ -0,0 +1,20 @@
from common.utils import get_logger
logger = get_logger(__file__)
class BaseSMSClient:
"""
短信终端的基类
"""
SIGN_AND_TMPL_SETTING_FIELD_PREFIX: str
@classmethod
def new_from_settings(cls):
raise NotImplementedError
def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: dict, **kwargs):
raise NotImplementedError

View File

@ -0,0 +1,51 @@
from collections import OrderedDict
import importlib
from django.utils.translation import gettext_lazy as _
from django.db.models import TextChoices
from django.conf import settings
from common.utils import get_logger
from common.exceptions import JMSException
from .base import BaseSMSClient
logger = get_logger(__name__)
class BACKENDS(TextChoices):
ALIBABA = 'alibaba', _('Alibaba cloud')
TENCENT = 'tencent', _('Tencent cloud')
class SMS:
client: BaseSMSClient
def __init__(self, backend=None):
backend = backend or settings.SMS_BACKEND
if backend not in BACKENDS:
raise JMSException(
code='sms_provider_not_support',
detail=_('SMS provider not support: {}').format(backend)
)
m = importlib.import_module(f'.{backend or settings.SMS_BACKEND}', __package__)
self.client = m.client.new_from_settings()
def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: dict, **kwargs):
return self.client.send_sms(
phone_numbers=phone_numbers,
sign_name=sign_name,
template_code=template_code,
template_param=template_param,
**kwargs
)
def send_verify_code(self, phone_number, code):
sign_name = getattr(settings, f'{self.client.SIGN_AND_TMPL_SETTING_FIELD_PREFIX}_VERIFY_SIGN_NAME')
template_code = getattr(settings, f'{self.client.SIGN_AND_TMPL_SETTING_FIELD_PREFIX}_VERIFY_TEMPLATE_CODE')
if not (sign_name and template_code):
raise JMSException(
code='verify_code_sign_tmpl_invalid',
detail=_('SMS verification code signature or template invalid')
)
return self.send_sms([phone_number], sign_name, template_code, OrderedDict(code=code))

View File

@ -10,7 +10,8 @@ from tencentcloud.sms.v20210111 import sms_client, models
# 导入可选配置类
from tencentcloud.common.profile.client_profile import ClientProfile
from tencentcloud.common.profile.http_profile import HttpProfile
from . import BaseSMSClient
from .base import BaseSMSClient
logger = get_logger(__file__)

View File

@ -3,7 +3,7 @@ import random
from django.core.cache import cache
from django.utils.translation import gettext_lazy as _
from common.sdk.sms import SMS
from .endpoint import SMS
from common.utils import get_logger
from common.exceptions import JMSException
@ -28,32 +28,24 @@ class CodeSendTooFrequently(JMSException):
super().__init__(detail=self.default_detail.format(ttl))
class VerifyCodeUtil:
KEY_TMPL = 'auth-verify_code-{}'
class SendAndVerifySMSUtil:
KEY_TMPL = 'auth-verify-code-{}'
TIMEOUT = 60
def __init__(self, account, key_suffix=None, timeout=None):
self.account = account
self.key_suffix = key_suffix
def __init__(self, phone, key_suffix=None, timeout=None):
self.phone = phone
self.code = ''
self.timeout = timeout or self.TIMEOUT
self.key_suffix = key_suffix or str(phone)
self.key = self.KEY_TMPL.format(key_suffix)
if key_suffix is not None:
self.key = self.KEY_TMPL.format(key_suffix)
else:
self.key = self.KEY_TMPL.format(account)
self.timeout = self.TIMEOUT if timeout is None else timeout
def touch(self):
def gen_and_send(self):
"""
生成保存发送
"""
ttl = self.ttl()
if ttl > 0:
raise CodeSendTooFrequently(ttl)
try:
self.generate()
self.save()
self.send()
code = self.generate()
self.send(code)
except JMSException:
self.clear()
raise
@ -66,19 +58,18 @@ class VerifyCodeUtil:
def clear(self):
cache.delete(self.key)
def save(self):
cache.set(self.key, self.code, self.timeout)
def send(self):
def send(self, code):
"""
发送信息的方法如果有错误直接抛出 api 异常
"""
account = self.account
code = self.code
ttl = self.ttl()
if ttl > 0:
logger.error('Send sms too frequently, delay {}'.format(ttl))
raise CodeSendTooFrequently(ttl)
sms = SMS()
sms.send_verify_code(account, code)
logger.info(f'Send sms verify code: account={account} code={code}')
sms.send_verify_code(self.phone, code)
cache.set(self.key, self.code, self.timeout)
logger.info(f'Send sms verify code to {self.phone}: {code}')
def verify(self, code):
right = cache.get(self.key)

View File

@ -3,3 +3,7 @@ import re
def no_special_chars(s):
return bool(re.match(r'\w+$', s))
def safe_str(s):
return s.encode('utf-8', errors='ignore').decode('utf-8')

View File

@ -311,6 +311,7 @@ class Config(dict):
'SECURITY_WATERMARK_ENABLED': True,
'SECURITY_MFA_VERIFY_TTL': 3600,
'SECURITY_SESSION_SHARE': True,
'SECURITY_CHECK_DIFFERENT_CITY_LOGIN': True,
'OLD_PASSWORD_HISTORY_LIMIT_COUNT': 5,
'CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED': True,
'USER_LOGIN_SINGLE_MACHINE_ENABLED': False,
@ -354,6 +355,10 @@ class Config(dict):
'WINDOWS_SSH_DEFAULT_SHELL': 'cmd',
'PERIOD_TASK_ENABLED': True,
# 导航栏 帮助
'HELP_DOCUMENT_URL': 'http://docs.jumpserver.org',
'HELP_SUPPORT_URL': 'http://www.jumpserver.org/support/',
'TICKETS_ENABLED': True,
'FORGOT_PASSWORD_URL': '',
'HEALTH_CHECK_TOKEN': '',

View File

@ -61,6 +61,7 @@ SECURITY_DATA_CRYPTO_ALGO = CONFIG.SECURITY_DATA_CRYPTO_ALGO
SECURITY_INSECURE_COMMAND = CONFIG.SECURITY_INSECURE_COMMAND
SECURITY_INSECURE_COMMAND_LEVEL = CONFIG.SECURITY_INSECURE_COMMAND_LEVEL
SECURITY_INSECURE_COMMAND_EMAIL_RECEIVER = CONFIG.SECURITY_INSECURE_COMMAND_EMAIL_RECEIVER
SECURITY_CHECK_DIFFERENT_CITY_LOGIN = CONFIG.SECURITY_CHECK_DIFFERENT_CITY_LOGIN
# Terminal other setting
TERMINAL_PASSWORD_AUTH = CONFIG.TERMINAL_PASSWORD_AUTH
@ -105,7 +106,7 @@ TASK_LOG_KEEP_DAYS = CONFIG.TASK_LOG_KEEP_DAYS
ORG_CHANGE_TO_URL = CONFIG.ORG_CHANGE_TO_URL
WINDOWS_SKIP_ALL_MANUAL_PASSWORD = CONFIG.WINDOWS_SKIP_ALL_MANUAL_PASSWORD
AUTH_EXPIRED_SECONDS = 60 * 5
AUTH_EXPIRED_SECONDS = 60 * 10
CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED = CONFIG.CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bdad14124356449843ef2e77801fd1add5147862488baa2f24f5f14f1ad8f125
size 90869
oid sha256:925c5a219a4ee6835ad59e3b8e9f7ea5074ee3df6527c0f73ef1a50eaedaf59c
size 91777

File diff suppressed because it is too large Load Diff

View File

@ -68,5 +68,8 @@ class SiteMsgWebsocket(JsonWebsocketConsumer):
def disconnect(self, close_code):
if self.chan is not None:
self.chan.close()
try:
self.chan.close()
except:
pass
self.close()

View File

@ -10,6 +10,8 @@ from ansible.plugins.callback import CallbackBase
from ansible.plugins.callback.default import CallbackModule
from ansible.plugins.callback.minimal import CallbackModule as CMDCallBackModule
from common.utils.strings import safe_str
class CallbackMixin:
def __init__(self, display=None):
@ -84,7 +86,7 @@ class AdHocResultCallback(CallbackMixin, CallbackModule, CMDCallBackModule):
detail = {
'cmd': cmd,
'stderr': task_result.get('stderr'),
'stdout': task_result.get('stdout'),
'stdout': safe_str(str(task_result.get('stdout', ''))),
'rc': task_result.get('rc'),
'delta': task_result.get('delta'),
'msg': task_result.get('msg', '')
@ -216,7 +218,7 @@ class CommandResultCallback(AdHocResultCallback):
if t == "ok":
cmd['cmd'] = res._result.get('cmd')
cmd['stderr'] = res._result.get('stderr')
cmd['stdout'] = res._result.get('stdout')
cmd['stdout'] = safe_str(str(res._result.get('stdout', '')))
cmd['rc'] = res._result.get('rc')
cmd['delta'] = res._result.get('delta')
else:

View File

@ -53,7 +53,7 @@ def set_remote_app_asset_system_users_if_need(instance: ApplicationPermission, s
system_users = system_users or instance.system_users.all()
for system_user in system_users:
system_user.assets.add(*asset_ids)
system_user.add_related_assets(asset_ids)
if system_user.username_same_with_user:
users = users or instance.users.all()

View File

@ -70,7 +70,8 @@ def on_permission_assets_changed(instance, action, reverse, pk_set, model, **kwa
# TODO 待优化
system_users = instance.system_users.all()
for system_user in system_users:
system_user.assets.add(*tuple(assets))
system_user: SystemUser
system_user.add_related_assets(assets)
@receiver(m2m_changed, sender=AssetPermission.system_users.through)
@ -88,7 +89,7 @@ def on_asset_permission_system_users_changed(instance, action, reverse, **kwargs
for system_user in system_users:
system_user.nodes.add(*tuple(nodes))
system_user.assets.add(*tuple(assets))
system_user.add_related_assets(assets)
# 动态系统用户,需要关联用户和用户组了
if system_user.username_same_with_user:

View File

@ -10,10 +10,10 @@ __all__ = [
class RadiusSettingSerializer(serializers.Serializer):
AUTH_RADIUS = serializers.BooleanField(required=False, label=_('Enable RADIUS Auth'))
RADIUS_SERVER = serializers.CharField(required=False, max_length=1024, label=_('Host'))
AUTH_RADIUS = serializers.BooleanField(required=False, label=_('Enable Radius Auth'))
RADIUS_SERVER = serializers.CharField(required=False, allow_blank=True, max_length=1024, label=_('Host'))
RADIUS_PORT = serializers.IntegerField(required=False, label=_('Port'))
RADIUS_SECRET = serializers.CharField(
required=False, max_length=1024, allow_null=True, label=_('Secret'), write_only=True
)
OTP_IN_RADIUS = serializers.BooleanField(required=False, label=_('OTP in radius'))
OTP_IN_RADIUS = serializers.BooleanField(required=False, label=_('OTP in Radius'))

View File

@ -57,7 +57,7 @@ class EmailContentSettingSerializer(serializers.Serializer):
EMAIL_CUSTOM_USER_CREATED_BODY = serializers.CharField(
max_length=4096, allow_blank=True, required=False,
label=_('Create user email content'),
help_text=_('Tips:When creating a user, send the content of the email')
help_text=_('Tips: When creating a user, send the content of the email, support {username} {name} {email} label')
)
EMAIL_CUSTOM_USER_CREATED_SIGNATURE = serializers.CharField(
max_length=512, allow_blank=True, required=False, label=_('Signature'),

View File

@ -26,7 +26,18 @@ class OtherSettingSerializer(serializers.Serializer):
)
PERM_SINGLE_ASSET_TO_UNGROUP_NODE = serializers.BooleanField(
required=False, label=_("Perm single to ungroup node")
required=False, label=_("Perm ungroup node"),
help_text=_("Perm single to ungroup node")
)
HELP_DOCUMENT_URL = serializers.URLField(
required=False, allow_blank=True, allow_null=True, label=_("Help Docs URL"),
help_text=_('default: http://docs.jumpserver.org')
)
HELP_SUPPORT_URL = serializers.URLField(
required=False, allow_blank=True, allow_null=True, label=_("Help Support URL"),
help_text=_('default: http://www.jumpserver.org/support/')
)
# 准备废弃

View File

@ -140,3 +140,8 @@ class SecuritySettingSerializer(SecurityPasswordRuleSerializer, SecurityAuthSeri
required=True, label=_('Session share'),
help_text=_("Enabled, Allows user active session to be shared with other users")
)
SECURITY_CHECK_DIFFERENT_CITY_LOGIN = serializers.BooleanField(
required=False, label=_('Remote Login Protection'),
help_text=_('The system determines whether the login IP address belongs to a common login city. '
'If the account is logged in from a common login city, the system sends a remote login reminder')
)

View File

@ -1174,6 +1174,14 @@ button.dim:active:before {
.onoffswitch-checkbox:checked + .onoffswitch-label .onoffswitch-switch {
right: 0;
}
.onoffswitch-checkbox:disabled + .onoffswitch-label .onoffswitch-inner:before {
background-color: #919191;
}
.onoffswitch-checkbox:disabled + .onoffswitch-label, .onoffswitch-checkbox:disabled + .onoffswitch-label .onoffswitch-switch {
border-color: #919191;
}
/* CHOSEN PLUGIN */
.chosen-container-single .chosen-single {
background: #ffffff;

View File

@ -11,7 +11,6 @@
{% include '_head_css_js.html' %}
<link href="{% static "css/jumpserver.css" %}" rel="stylesheet">
<script src="{% static "js/jumpserver.js" %}"></script>
<style>
.passwordBox {
@ -43,5 +42,6 @@
</div>
</div>
</body>
{% include '_foot_js.html' %}
{% block custom_foot_js %} {% endblock %}
</html>

View File

@ -0,0 +1,117 @@
{% load i18n %}
{% load static %}
<select id="mfa-select" name="mfa_type" class="form-control select-con"
onchange="selectChange(this.value)"
>
{% for backend in mfa_backends %}
<option value="{{ backend.name }}"
{% if not backend.is_active %} disabled {% endif %}
>
{{ backend.display_name }}
</option>
{% endfor %}
</select>
<div class="mfa-div">
{% for backend in mfa_backends %}
<div id="mfa-{{ backend.name }}" class="mfa-field
{% if backend.challenge_required %}challenge-required{% endif %}"
style="display: none"
>
<input type="text" class="form-control input-style"
placeholder="{{ backend.placeholder }}"
>
{% if backend.challenge_required %}
<button class="btn btn-primary full-width btn-challenge"
type='button' onclick="sendChallengeCode(this)"
>
{% trans 'Send verification code' %}
</button>
{% endif %}
</div>
{% endfor %}
</div>
<style>
.input-style {
width: 100%;
display: inline-block;
}
.challenge-required .input-style {
width: calc(100% - 114px);
display: inline-block;
}
.btn-challenge {
width: 110px !important;
height: 100%;
vertical-align: top;
}
</style>
<script>
const preferMFAKey = 'mfaPrefer'
$(document).ready(function () {
const mfaSelectRef = document.getElementById('mfa-select');
const preferMFA = localStorage.getItem(preferMFAKey);
if (preferMFA) {
mfaSelectRef.value = preferMFA;
}
const mfaSelect = mfaSelectRef.value;
if (mfaSelect !== null) {
selectChange(mfaSelect, true);
}
})
function selectChange(name, onLoad) {
$('.mfa-field').hide()
$('#mfa-' + name).show()
if (!onLoad) {
localStorage.setItem(preferMFAKey, name)
}
$('.input-style').each(function (i, ele){
$(ele).attr('name', '').attr('required', false)
})
$('#mfa-' + name + ' .input-style').attr('name', 'code').attr('required', true)
}
function sendChallengeCode(currentBtn) {
let time = 60;
const url = "{% url 'api-auth:mfa-select' %}";
const data = {
type: $("#mfa-select").val(),
username: $('input[name="username"]').val()
};
function onSuccess() {
const originBtnText = currentBtn.innerHTML;
currentBtn.disabled = true
const interval = setInterval(function () {
currentBtn.innerHTML = `{% trans 'Wait: ' %} ${time}`;
time -= 1
if (time === 0) {
currentBtn.innerHTML = originBtnText
currentBtn.disabled = false
clearInterval(interval)
}
}, 1000)
setTimeout(function (){
toastr.success("{% trans 'The verification code has been sent' %}");
})
}
requestApi({
url: url,
method: "POST",
body: JSON.stringify(data),
success: onSuccess,
error: function (text, data) {
toastr.error(data.error)
},
flash_message: false
})
}
</script>

View File

@ -1,81 +0,0 @@
{% load i18n %}
<select id="verify-method-select" name="mfa_type" class="form-control select-con" onchange="selectChange(this.value)">
{% for method in methods %}
<option value="{{ method.name }}"
{% if method.selected %} selected {% endif %}
{% if not method.enable %} disabled {% endif %}
>
{{ method.label }}
</option>
{% endfor %}
</select>
<div class="mfa-div">
<input id="mfa-code" type="text" class="form-control input-style" required name="code"
placeholder="{% trans 'Please enter verification code' %}">
<button id='send-sms-verify-code' type="button" class="btn btn-primary full-width" onclick="sendSMSVerifyCode()"
style="margin-left: 10px!important;height: 100%">{% trans 'Send verification code' %}</button>
</div>
<style type="text/css">
.input-style {
width: calc(100% - 114px);
display: inline-block;
}
#send-sms-verify-code {
width: 110px !important;
height: 100%;
vertical-align: top;
}
</style>
<script>
var methodSelect = document.getElementById('verify-method-select');
if (methodSelect.value !== null) {
selectChange(methodSelect.value);
}
function selectChange(type) {
var otpPlaceholder = '{% trans 'Please enter MFA code' %}';
var smsPlaceholder = '{% trans 'Please enter SMS code' %}';
if (type === "sms") {
$("#mfa-code").css("cssText", "width: calc(100% - 114px)").attr('placeholder', smsPlaceholder);
$("#send-sms-verify-code").css("cssText", "display: inline-block !important");
} else {
$("#mfa-code").css("cssText", "width: 100% !important").attr('placeholder', otpPlaceholder);
$("#send-sms-verify-code").css("cssText", "display: none !important");
}
}
function sendSMSVerifyCode() {
var currentBtn = document.getElementById('send-sms-verify-code');
var time = 60
var url = "{% url 'api-auth:sms-verify-code-send' %}";
var data = {
username: $("#id_username").val()
};
requestApi({
url: url,
method: "POST",
body: JSON.stringify(data),
success: function (data) {
currentBtn.innerHTML = `{% trans 'Wait: ' %} ${time}`;
currentBtn.disabled = true
currentBtn.classList.add("disabledBtn")
var TimeInterval = setInterval(() => {
--time
currentBtn.innerHTML = `{% trans 'Wait: ' %} ${time}`;
if (time === 0) {
currentBtn.innerHTML = "{% trans 'Send verification code' %}"
currentBtn.disabled = false
currentBtn.classList.remove("disabledBtn")
clearInterval(TimeInterval)
}
}, 1000)
alert("{% trans 'The verification code has been sent' %}");
},
error: function (text, data) {
alert(data.detail)
},
flash_message: false
})
}
</script>

View File

@ -7,26 +7,23 @@
<meta charset="UTF-8">
<title> {{ JMS_TITLE }} </title>
<link rel="shortcut icon" href="{{ FAVICON_URL }}" type="image/x-icon">
{# <link rel="stylesheet" href="{% static 'fonts/font_otp/iconfont.css' %}" />#}
{% include '_head_css_js.html' %}
<link href="{% static 'css/jumpserver.css' %}" rel="stylesheet">
<link href="{% static 'css/style.css' %}" rel="stylesheet">
<link rel="stylesheet" href="{% static 'css/otp.css' %}" />
<script src="{% static 'js/jquery-3.1.1.min.js' %}"></script>
<script src="{% static "js/plugins/qrcode/qrcode.min.js" %}"></script>
</head>
<body>
<body style="background-color: #f3f3f4">
<header>
<div class="logo">
<a href="{% url 'index' %}">
<img src="{{ LOGO_URL }}" alt="" width="50px" height="50px"/>
</a>
<a href="{% url 'index' %}">{{ JMS_TITLE }}</a>
<span style="font-size: 18px; line-height: 50px">{{ JMS_TITLE }}</span>
</div>
<div>
<a href="{% url 'index' %}">{% trans 'Home page' %}</a>
<b></b>
<a href="http://docs.jumpserver.org/zh/docs/">{% trans 'Docs' %}</a>
<b></b>
<a href="https://www.github.com/jumpserver/">GitHub</a>
</div>
</header>
<body>
@ -34,10 +31,11 @@
{% endblock %}
</body>
<footer>
<div class="" style="margin-top: 100px;">
<div style="margin-top: 100px;">
{% include '_copyright.html' %}
</div>
</footer>
{% include '_foot_js.html' %}
</body>
</html>

View File

View File

@ -12,33 +12,28 @@ from django.contrib.auth.models import AbstractUser
from django.contrib.auth.hashers import check_password
from django.core.cache import cache
from django.db import models
from django.utils.translation import ugettext_lazy as _
from django.utils import timezone
from django.shortcuts import reverse
from orgs.utils import current_org
from orgs.models import OrganizationMember, Organization
from common.exceptions import JMSException
from common.utils import date_expired_default, get_logger, lazyproperty, random_string
from common import fields
from common.const import choices
from common.db.models import TextChoices
from users.exceptions import MFANotEnabled, PhoneNotSet
from ..signals import post_user_change_password
__all__ = ['User', 'UserPasswordHistory', 'MFAType']
__all__ = ['User', 'UserPasswordHistory']
logger = get_logger(__file__)
class MFAType(TextChoices):
OTP = 'otp', _('One-time password')
SMS_CODE = 'sms', _('SMS verify code')
class AuthMixin:
date_password_last_updated: datetime.datetime
history_passwords: models.Manager
need_update_password: bool
public_key: str
is_local: bool
@property
@ -77,7 +72,8 @@ class AuthMixin:
def is_history_password(self, password):
allow_history_password_count = settings.OLD_PASSWORD_HISTORY_LIMIT_COUNT
history_passwords = self.history_passwords.all().order_by('-date_created')[:int(allow_history_password_count)]
history_passwords = self.history_passwords.all() \
.order_by('-date_created')[:int(allow_history_password_count)]
for history_password in history_passwords:
if check_password(password, history_password.password):
@ -474,9 +470,11 @@ class MFAMixin:
@property
def mfa_force_enabled(self):
if settings.SECURITY_MFA_AUTH in [True, 1]:
force_level = settings.SECURITY_MFA_AUTH
if force_level in [True, 1]:
return True
if settings.SECURITY_MFA_AUTH == 2 and self.is_org_admin:
# 2 管理员强制开启
if force_level == 2 and self.is_org_admin:
return True
return self.mfa_level == 2
@ -489,86 +487,32 @@ class MFAMixin:
def disable_mfa(self):
self.mfa_level = 0
self.otp_secret_key = None
def reset_mfa(self):
if self.mfa_is_otp():
self.otp_secret_key = ''
def no_active_mfa(self):
return len(self.active_mfa_backends) == 0
@lazyproperty
def active_mfa_backends(self):
backends = self.get_user_mfa_backends(self)
active_backends = [b for b in backends if b.is_active()]
return active_backends
@property
def active_mfa_backends_mapper(self):
return {b.name: b for b in self.active_mfa_backends}
@staticmethod
def mfa_is_otp():
if settings.OTP_IN_RADIUS:
return False
return True
def get_user_mfa_backends(user):
from authentication.mfa import MFA_BACKENDS
backends = [cls(user) for cls in MFA_BACKENDS if cls.global_enabled()]
return backends
def check_radius(self, code):
from authentication.backends.radius import RadiusBackend
backend = RadiusBackend()
user = backend.authenticate(None, username=self.username, password=code)
if user:
return True
return False
def check_otp(self, code):
from ..utils import check_otp_code
return check_otp_code(self.otp_secret_key, code)
def check_mfa(self, code, mfa_type=MFAType.OTP):
if not self.mfa_enabled:
raise MFANotEnabled
if mfa_type == MFAType.OTP:
if settings.OTP_IN_RADIUS:
return self.check_radius(code)
else:
return self.check_otp(code)
elif mfa_type == MFAType.SMS_CODE:
return self.check_sms_code(code)
def get_supported_mfa_types(self):
methods = []
if self.otp_secret_key:
methods.append(MFAType.OTP)
if settings.XPACK_ENABLED and settings.SMS_ENABLED and self.phone:
methods.append(MFAType.SMS_CODE)
return methods
def check_sms_code(self, code):
from authentication.sms_verify_code import VerifyCodeUtil
if not self.phone:
raise PhoneNotSet
try:
util = VerifyCodeUtil(self.phone)
return util.verify(code)
except JMSException:
return False
def send_sms_code(self):
from authentication.sms_verify_code import VerifyCodeUtil
if not self.phone:
raise PhoneNotSet
util = VerifyCodeUtil(self.phone)
util.touch()
return util.timeout
def mfa_enabled_but_not_set(self):
if not self.mfa_enabled:
return False, None
if not self.mfa_is_otp():
return False, None
if self.mfa_is_otp() and self.otp_secret_key:
return False, None
if self.phone and settings.SMS_ENABLED and settings.XPACK_ENABLED:
return False, None
return True, reverse('authentication:user-otp-enable-start')
def get_mfa_backend_by_type(self, mfa_type):
mfa_mapper = self.active_mfa_backends_mapper
backend = mfa_mapper.get(mfa_type)
if not backend:
return None
return backend
class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser):

View File

@ -1,4 +1,5 @@
from urllib.parse import urljoin
from collections import defaultdict
from django.utils import timezone
from django.utils.translation import ugettext as _
@ -13,13 +14,19 @@ class UserCreatedMsg(UserMessage):
def get_html_msg(self) -> dict:
user = self.user
subject = str(settings.EMAIL_CUSTOM_USER_CREATED_SUBJECT)
honorific = str(settings.EMAIL_CUSTOM_USER_CREATED_HONORIFIC)
content = str(settings.EMAIL_CUSTOM_USER_CREATED_BODY)
mail_context = {
'subject': str(settings.EMAIL_CUSTOM_USER_CREATED_SUBJECT),
'honorific': str(settings.EMAIL_CUSTOM_USER_CREATED_HONORIFIC),
'content': str(settings.EMAIL_CUSTOM_USER_CREATED_BODY)
}
user_info = {'username': user.username, 'name': user.name, 'email': user.email}
# 转换成 defaultdict否则 format 时会报 KeyError
user_info = defaultdict(str, **user_info)
mail_context = {k: v.format_map(user_info) for k, v in mail_context.items()}
context = {
'honorific': honorific,
'content': content,
**mail_context,
'user': user,
'rest_password_url': reverse('authentication:reset-password', external=True),
'rest_password_token': user.generate_reset_token(),
@ -28,7 +35,7 @@ class UserCreatedMsg(UserMessage):
}
message = render_to_string('users/_msg_user_created.html', context)
return {
'subject': subject,
'subject': mail_context['subject'],
'message': message
}

View File

@ -11,8 +11,6 @@
</h2>
</div>
<div>
<div class="verify">{% trans 'Please enter the password of' %}&nbsp;{% trans 'account' %}&nbsp;<span>{{ user.username }}</span>&nbsp;{% trans 'to complete the binding operation' %}</div>
<hr style="width: 500px; margin: auto; margin-top: 10px;">
{% block content %}
{% endblock %}
</div>

View File

@ -0,0 +1,116 @@
{% extends '_without_nav_base.html' %}
{% load static %}
{% load i18n %}
{% block body %}
<style>
.help-inline {
color: #7d8293;
font-size: 12px;
padding-right: 10px;
}
.btn-xs {
width: 54px;
}
.onoffswitch-switch {
height: 20px;
}
</style>
<article>
<div>
{# // Todoi:#}
<h3>{% trans 'Enable MFA' %}</h3>
<div class="row" style="padding-top: 10px">
<li class="col-sm-6" style="font-size: 14px">{% trans 'Enable' %} MFA</li>
<div class="switch col-sm-6">
<span class="help-inline">
{% if user.mfa_force_enabled %}
{% trans 'MFA force enable, cannot disable' %}
{% endif %}
</span>
<div class="onoffswitch" style="float: right">
<input type="checkbox" class="onoffswitch-checkbox"
id="mfa-switch" onchange="switchMFA()"
{% if user.mfa_force_enabled %} disabled {% endif %}
{% if user.mfa_enabled %} checked {% endif %}
>
<label class="onoffswitch-label" for="mfa-switch">
<span class="onoffswitch-inner"></span>
<span class="onoffswitch-switch"></span>
</label>
</div>
</div>
</div>
</div>
<div id="mfa-setting" style="display: none; padding-top: 30px">
<h3>{% trans 'MFA setting' %}</h3>
<div style="height: 100%; width: 100%;">
{% for b in mfa_backends %}
<div class="row" style="padding-top: 10px">
<li class="col-sm-6" style="font-size: 14px">{{ b.display_name }}
{{ b.enable }}</li>
<span class="col-sm-6">
{% if b.is_active %}
<button class="btn btn-warning btn-xs" style="float: right"
{% if not b.can_disable %} disabled {% endif %}
onclick="goTo('{{ b.get_disable_url }}')"
>
{% trans 'Disable' %}
</button>
<span class="help-inline">{{ b.help_text_of_disable }}</span>
{% else %}
<button class="btn btn-primary btn-xs" style="float: right"
onclick="goTo('{{ b.get_enable_url }}')"
>
{% trans 'Enable' %}
</button>
<span class="help-inline">{{ b.help_text_of_enable }}</span>
{% endif %}
</span>
</div>
{% endfor %}
</div>
</div>
</article>
<script src="{% static 'js/jumpserver.js' %}"></script>
<script>
function goTo(url) {
window.open(url, '_self')
}
function switchMFA() {
const switchRef = $('#mfa-switch')
const enabled = switchRef.is(":checked")
requestApi({
url: '/api/v1/users/profile/',
data: {
mfa_level: enabled ? 1 : 0
},
method: 'PATCH',
success() {
showSettingOrNot()
},
error() {
switchRef.prop('checked', !enabled)
}
})
showSettingOrNot()
}
function showSettingOrNot() {
const enabled = $('#mfa-switch').is(":checked")
const settingRef = $('#mfa-setting')
if (enabled) {
settingRef.show()
} else {
settingRef.hide()
}
}
window.onload = function () {
showSettingOrNot()
}
</script>
{% endblock %}

View File

@ -3,10 +3,12 @@
{% load i18n %}
{% block small_title %}
{% trans 'Authenticate' %}
{% trans 'Enable OTP' %}
{% endblock %}
{% block content %}
<div class="verify">{% trans 'Please enter the password of' %}&nbsp;{% trans 'account' %}&nbsp;<span>{{ user.username }}</span>&nbsp;{% trans 'to complete the binding operation' %}</div>
<hr style="width: 500px; margin: auto; margin-top: 10px;">
<form id="verify-form" class="" role="form" method="post" action="">
{% csrf_token %}
<div class="form-input">

View File

@ -3,7 +3,11 @@
{% load i18n %}
{% block small_title %}
{% trans 'Authenticate' %}
{% if title %}
{{ title }}
{% else %}
{% trans 'Authenticate' %}
{% endif %}
{% endblock %}
{% block content %}

View File

@ -137,7 +137,7 @@ class BlockUtilBase:
times_remainder = int(times_up) - int(times_failed)
return times_remainder
def incr_failed_count(self):
def incr_failed_count(self) -> int:
limit_key = self.limit_key
count = cache.get(limit_key, 0)
count += 1
@ -146,6 +146,7 @@ class BlockUtilBase:
limit_count = settings.SECURITY_LOGIN_LIMIT_COUNT
if count >= limit_count:
cache.set(self.block_key, True, self.key_ttl)
return limit_count - count
def get_failed_count(self):
count = cache.get(self.limit_key, 0)
@ -205,4 +206,4 @@ def is_auth_password_time_valid(session):
def is_auth_otp_time_valid(session):
return is_auth_time_valid(session, 'auth_opt_expired_at')
return is_auth_time_valid(session, 'auth_otp_expired_at')

View File

@ -1,2 +1,24 @@
# -*- coding: utf-8 -*-
#
from __future__ import unicode_literals
from django.views.generic.base import TemplateView
from common.permissions import IsValidUser
from common.mixins.views import PermissionsMixin
from users.models import User
__all__ = ['MFASettingView']
class MFASettingView(PermissionsMixin, TemplateView):
template_name = 'users/mfa_setting.html'
permission_classes = [IsValidUser]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
mfa_backends = User.get_user_mfa_backends(self.request.user)
context.update({
'mfa_backends': mfa_backends,
})
return context

View File

@ -1,31 +1,30 @@
# ~*~ coding: utf-8 ~*~
import time
from django.urls import reverse_lazy, reverse
from django.shortcuts import get_object_or_404
from django.urls import reverse
from django.utils.translation import ugettext as _
from django.views.generic.base import TemplateView
from django.views.generic.edit import FormView
from django.contrib.auth import logout as auth_logout
from django.conf import settings
from django.http.response import HttpResponseForbidden
from django.http.response import HttpResponseRedirect
from authentication.mixins import AuthMixin
from users.models import User
from common.utils import get_logger, get_object_or_none
from authentication.mfa import MFAOtp, otp_failed_msg
from common.utils import get_logger, FlashMessageUtil
from common.mixins.views import PermissionsMixin
from common.permissions import IsValidUser
from ... import forms
from .password import UserVerifyPasswordView
from ... import forms
from ...utils import (
generate_otp_uri, check_otp_code, get_user_or_pre_auth_user,
is_auth_password_time_valid, is_auth_otp_time_valid
generate_otp_uri, check_otp_code,
get_user_or_pre_auth_user,
)
__all__ = [
'UserOtpEnableStartView',
'UserOtpEnableInstallAppView',
'UserOtpEnableBindView', 'UserOtpSettingsSuccessView',
'UserDisableMFAView', 'UserOtpUpdateView',
'UserOtpEnableBindView',
'UserOtpDisableView',
]
logger = get_logger(__name__)
@ -34,22 +33,8 @@ logger = get_logger(__name__)
class UserOtpEnableStartView(UserVerifyPasswordView):
template_name = 'users/user_otp_check_password.html'
def form_valid(self, form):
# 开启了 OTP IN RADIUS 就不用绑定了
resp = super().form_valid(form)
if settings.OTP_IN_RADIUS:
user_id = self.request.session.get('user_id')
user = get_object_or_404(User, id=user_id)
user.enable_mfa()
user.save()
return resp
def get_success_url(self):
if settings.OTP_IN_RADIUS:
success_url = reverse_lazy('authentication:user-otp-settings-success')
else:
success_url = reverse('authentication:user-otp-enable-install-app')
return success_url
return reverse('authentication:user-otp-enable-install-app')
class UserOtpEnableInstallAppView(TemplateView):
@ -65,69 +50,68 @@ class UserOtpEnableInstallAppView(TemplateView):
class UserOtpEnableBindView(AuthMixin, TemplateView, FormView):
template_name = 'users/user_otp_enable_bind.html'
form_class = forms.UserCheckOtpCodeForm
success_url = reverse_lazy('authentication:user-otp-settings-success')
def get(self, request, *args, **kwargs):
if self._check_can_bind():
return super().get(request, *args, **kwargs)
return HttpResponseForbidden()
pre_response = self._pre_check_can_bind()
if pre_response:
return pre_response
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
if self._check_can_bind():
return super().post(request, *args, **kwargs)
return HttpResponseForbidden()
pre_response = self._pre_check_can_bind()
if pre_response:
return pre_response
return super().post(request, *args, **kwargs)
def _check_authenticated_user_can_bind(self):
user = self.request.user
session = self.request.session
def _pre_check_can_bind(self):
try:
user = self.get_user_from_session()
except:
verify_url = reverse('authentication:user-otp-enable-start')
return HttpResponseRedirect(verify_url)
if not user.mfa_enabled:
return is_auth_password_time_valid(session)
if user.otp_secret_key:
return self.has_already_bound_message()
return None
if not user.otp_secret_key:
return is_auth_password_time_valid(session)
return is_auth_otp_time_valid(session)
def _check_unauthenticated_user_can_bind(self):
session_user = None
if not self.request.session.is_empty():
user_id = self.request.session.get('user_id')
session_user = get_object_or_none(User, pk=user_id)
if session_user:
if all((
is_auth_password_time_valid(self.request.session),
session_user.mfa_enabled,
not session_user.otp_secret_key
)):
return True
return False
def _check_can_bind(self):
if self.request.user.is_authenticated:
return self._check_authenticated_user_can_bind()
else:
return self._check_unauthenticated_user_can_bind()
@staticmethod
def has_already_bound_message():
message_data = {
'title': _('Already bound'),
'error': _('MFA already bound, disable first, then bound'),
'interval': 10,
'redirect_url': reverse('authentication:user-otp-disable'),
}
response = FlashMessageUtil.gen_and_redirect_to(message_data)
return response
def form_valid(self, form):
otp_code = form.cleaned_data.get('otp_code')
otp_secret_key = self.request.session.get('otp_secret_key', '')
valid = check_otp_code(otp_secret_key, otp_code)
if valid:
self.save_otp(otp_secret_key)
return super().form_valid(form)
else:
error = _("MFA code invalid, or ntp sync server time")
form.add_error("otp_code", error)
if not valid:
form.add_error("otp_code", otp_failed_msg)
return self.form_invalid(form)
self.save_otp(otp_secret_key)
auth_logout(self.request)
return super().form_valid(form)
def save_otp(self, otp_secret_key):
user = get_user_or_pre_auth_user(self.request)
user.enable_mfa()
user.otp_secret_key = otp_secret_key
user.save()
user.save(update_fields=['otp_secret_key'])
def get_success_url(self):
message_data = {
'title': _('OTP enable success'),
'message': _('OTP enable success, return login page'),
'interval': 5,
'redirect_url': reverse('authentication:login'),
}
url = FlashMessageUtil.gen_message_url(message_data)
return url
def get_context_data(self, **kwargs):
user = get_user_or_pre_auth_user(self.request)
@ -142,70 +126,40 @@ class UserOtpEnableBindView(AuthMixin, TemplateView, FormView):
return super().get_context_data(**kwargs)
class UserDisableMFAView(FormView):
class UserOtpDisableView(PermissionsMixin, FormView):
template_name = 'users/user_verify_mfa.html'
form_class = forms.UserCheckOtpCodeForm
success_url = reverse_lazy('authentication:user-otp-settings-success')
permission_classes = [IsValidUser]
def form_valid(self, form):
user = self.request.user
otp_code = form.cleaned_data.get('otp_code')
otp = MFAOtp(user)
valid = user.check_mfa(otp_code)
if valid:
user.disable_mfa()
user.save()
return super().form_valid(form)
else:
error = _('MFA code invalid, or ntp sync server time')
ok, error = otp.check_code(otp_code)
if not ok:
form.add_error('otp_code', error)
return super().form_invalid(form)
class UserOtpUpdateView(FormView):
template_name = 'users/user_verify_mfa.html'
form_class = forms.UserCheckOtpCodeForm
success_url = reverse_lazy('authentication:user-otp-enable-bind')
permission_classes = [IsValidUser]
def form_valid(self, form):
user = self.request.user
otp_code = form.cleaned_data.get('otp_code')
valid = user.check_mfa(otp_code)
if valid:
self.request.session['auth_opt_expired_at'] = time.time() + settings.AUTH_EXPIRED_SECONDS
return super().form_valid(form)
else:
error = _('MFA code invalid, or ntp sync server time')
form.add_error('otp_code', error)
return super().form_invalid(form)
class UserOtpSettingsSuccessView(TemplateView):
template_name = 'flash_message_standalone.html'
otp.disable()
auth_logout(self.request)
return super().form_valid(form)
def get_context_data(self, **kwargs):
title, describe = self.get_title_describe()
context = {
'title': title,
'message': describe,
'interval': 1,
context = super().get_context_data(**kwargs)
context.update({
'title': _("Disable OTP")
})
return context
def get_success_url(self):
message_data = {
'title': _('OTP disable success'),
'message': _('OTP disable success, return login page'),
'interval': 5,
'redirect_url': reverse('authentication:login'),
'auto_redirect': True,
}
kwargs.update(context)
return super().get_context_data(**kwargs)
url = FlashMessageUtil.gen_message_url(message_data)
return url
def get_title_describe(self):
user = get_user_or_pre_auth_user(self.request)
if self.request.user.is_authenticated:
auth_logout(self.request)
title = _('MFA enable success')
describe = _('MFA enable success, return login page')
if not user.mfa_enabled:
title = _('MFA disable success')
describe = _('MFA disable success, return login page')
return title, describe

View File

@ -6,7 +6,8 @@ from django.contrib.auth import authenticate
from django.shortcuts import redirect
from django.utils.translation import ugettext as _
from django.views.generic.edit import FormView
from authentication.mixins import PasswordEncryptionViewMixin
from authentication.mixins import PasswordEncryptionViewMixin, AuthMixin
from authentication import errors
from common.utils import get_logger
@ -20,24 +21,27 @@ __all__ = ['UserVerifyPasswordView']
logger = get_logger(__name__)
class UserVerifyPasswordView(PasswordEncryptionViewMixin, FormView):
class UserVerifyPasswordView(AuthMixin, FormView):
template_name = 'users/user_password_verify.html'
form_class = forms.UserCheckPasswordForm
def form_valid(self, form):
user = get_user_or_pre_auth_user(self.request)
if user is None:
return redirect('authentication:login')
try:
password = self.get_decrypted_password(username=user.username)
except errors.AuthFailedError as e:
form.add_error("password", _(f"Password invalid") + f'({e.msg})')
return self.form_invalid(form)
user = authenticate(request=self.request, username=user.username, password=password)
if not user:
form.add_error("password", _("Password invalid"))
return self.form_invalid(form)
self.request.session['user_id'] = str(user.id)
self.request.session['auth_password'] = 1
self.request.session['auth_password_expired_at'] = time.time() + settings.AUTH_EXPIRED_SECONDS
self.mark_password_ok(user)
return redirect(self.get_success_url())
def get_success_url(self):