mirror of https://github.com/jumpserver/jumpserver
commit
3051996e35
|
@ -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
|
||||
|
||||
|
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
@ -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)
|
||||
]
|
|
@ -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'),
|
||||
),
|
||||
]
|
|
@ -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 {}
|
||||
|
|
|
@ -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')]
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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 后对应操作 api,koko 目前在用
|
||||
"""
|
||||
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})
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
from .otp import MFAOtp, otp_failed_msg
|
||||
from .sms import MFASms
|
||||
from .radius import MFARadius
|
||||
|
||||
MFA_BACKENDS = [MFAOtp, MFASms, MFARadius]
|
|
@ -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 ''
|
||||
|
|
@ -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 ''
|
||||
|
|
@ -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 ''
|
|
@ -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'
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 %}
|
|
@ -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'),
|
||||
]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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__)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
@ -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))
|
|
@ -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__)
|
||||
|
||||
|
|
|
@ -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)
|
|
@ -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')
|
||||
|
|
|
@ -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': '',
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
@ -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()
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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'))
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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/')
|
||||
)
|
||||
|
||||
# 准备废弃
|
||||
|
|
|
@ -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')
|
||||
)
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -11,8 +11,6 @@
|
|||
</h2>
|
||||
</div>
|
||||
<div>
|
||||
<div class="verify">{% trans 'Please enter the password of' %} {% trans 'account' %} <span>{{ user.username }}</span> {% trans 'to complete the binding operation' %}</div>
|
||||
<hr style="width: 500px; margin: auto; margin-top: 10px;">
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
|
|
@ -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 %}
|
|
@ -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' %} {% trans 'account' %} <span>{{ user.username }}</span> {% 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">
|
||||
|
|
|
@ -3,7 +3,11 @@
|
|||
{% load i18n %}
|
||||
|
||||
{% block small_title %}
|
||||
{% trans 'Authenticate' %}
|
||||
{% if title %}
|
||||
{{ title }}
|
||||
{% else %}
|
||||
{% trans 'Authenticate' %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
Loading…
Reference in New Issue