diff --git a/.gitignore b/.gitignore index 658ea27ac..959682445 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .DS_Store *.pyc *.pyo +*.swp env env* dist diff --git a/apps/jumpserver/settings.py b/apps/jumpserver/settings.py index 0e8487b2a..409cc2d03 100644 --- a/apps/jumpserver/settings.py +++ b/apps/jumpserver/settings.py @@ -108,6 +108,7 @@ TEMPLATES = [ # WSGI_APPLICATION = 'jumpserver.wsgi.application' LOGIN_REDIRECT_URL = reverse_lazy('index') +LOGIN_URL = reverse_lazy('users:login') # Database # https://docs.djangoproject.com/en/1.10/ref/settings/#databases @@ -227,7 +228,7 @@ USE_L10N = True USE_TZ = True # I18N translation -LOCALE_PATHS = [os.path.join(BASE_DIR, 'locale'),] +LOCALE_PATHS = [os.path.join(BASE_DIR, 'locale'), ] # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.10/howto/static-files/ diff --git a/apps/users/forms.py b/apps/users/forms.py index 8eb8ebcf8..a24565f89 100644 --- a/apps/users/forms.py +++ b/apps/users/forms.py @@ -18,6 +18,7 @@ class UserLoginForm(AuthenticationForm): class UserCreateForm(forms.ModelForm): + class Meta: model = User fields = [ @@ -67,3 +68,23 @@ class UserGroupForm(forms.ModelForm): help_texts = { 'name': '* required' } + + +class UserInfoForm(forms.Form): + name = forms.CharField(max_length=20, label=_('name')) + avatar = forms.ImageField(label=_('avatar'), required=False) + wechat = forms.CharField(max_length=30, label=_('wechat'), required=False) + phone = forms.CharField(max_length=20, label=_('phone'), required=False) + enable_otp = forms.BooleanField(required=False, label=_('enable otp')) + + +class UserKeyForm(forms.Form): + private_key = forms.CharField(max_length=5000, widget=forms.Textarea, label=_('private key')) + + def clean_private_key(self): + from users.utils import validate_ssh_pk + ssh_pk = self.cleaned_data['private_key'] + checked, reason = validate_ssh_pk(ssh_pk) + if not checked: + raise forms.ValidationError(_('Not a valid ssh private key.')) + return ssh_pk diff --git a/apps/users/models.py b/apps/users/models.py index f78c6082d..fba66a8d8 100644 --- a/apps/users/models.py +++ b/apps/users/models.py @@ -2,19 +2,17 @@ from __future__ import unicode_literals -import datetime - from django.conf import settings from django.contrib.auth.hashers import make_password -from django.utils import timezone -from django.db import models -from django.contrib.auth.models import AbstractUser, Permission +from django.contrib.auth.models import AbstractUser +from django.core import signing +from django.db import models, IntegrityError from django.db.models.signals import post_save from django.dispatch import receiver -from django.db import IntegrityError +from django.utils import timezone from django.utils.translation import ugettext_lazy as _ + from rest_framework.authtoken.models import Token -from django.core import signing from common.utils import encrypt, decrypt @@ -44,16 +42,15 @@ class UserGroup(models.Model): @classmethod def generate_fake(cls, count=100): - from random import seed, randint, choice + from random import seed, choice import forgery_py - from django.db import IntegrityError seed() for i in range(count): group = cls(name=forgery_py.name.full_name(), comment=forgery_py.lorem_ipsum.sentence(), created_by=choice(User.objects.all()).username - ) + ) try: group.save() except IntegrityError: @@ -84,7 +81,7 @@ class User(AbstractUser): _private_key = models.CharField(max_length=5000, blank=True, verbose_name=_('ssh private key')) _public_key = models.CharField(max_length=1000, blank=True, verbose_name=_('ssh public key')) comment = models.TextField(max_length=200, blank=True, verbose_name=_('Comment')) - is_first_login = models.BooleanField(default=False) + is_first_login = models.BooleanField(default=True) date_expired = models.DateTimeField(default=date_expired_default, blank=True, null=True, verbose_name=_('Date expired')) created_by = models.CharField(max_length=30, default='', verbose_name=_('Created by')) @@ -235,7 +232,7 @@ class User(AbstractUser): wechat=forgery_py.internet.user_name(True), comment=forgery_py.lorem_ipsum.sentence(), created_by=choice(cls.objects.all()).username, - ) + ) try: user.save() except IntegrityError: @@ -264,4 +261,3 @@ def create_auth_token(sender, instance=None, created=False, **kwargs): Token.objects.create(user=instance) except IntegrityError: pass - diff --git a/apps/users/templates/users/first_login.html b/apps/users/templates/users/first_login.html new file mode 100644 index 000000000..915a1f425 --- /dev/null +++ b/apps/users/templates/users/first_login.html @@ -0,0 +1,80 @@ +{% extends 'base.html' %} +{% load static %} +{% load i18n %} +{% load bootstrap %} + +{% block custom_head_css_js %} +{{ wizard.form.media }} + +{% endblock %} +{% block content %} +
+
+
+
+
+
{% trans 'First Login' %}
+ +
+
+
+
+ +
+
+
+ {% csrf_token %} + {{ wizard.management_form }} + {% if wizard.form.forms %} + {{ wizard.form.management_form }} + {% for form in wizard.form.forms %} + {{ form|bootstrap }} + {% endfor %} + {% else %} + {{ wizard.form|bootstrap }} + {% endif %} +
+
+
+ +
+
+
+
+
+
+
+{% endblock %} +{% block custom_foot_js %} + +{% endblock %} diff --git a/apps/users/urls.py b/apps/users/urls.py index e31071c11..179827216 100644 --- a/apps/users/urls.py +++ b/apps/users/urls.py @@ -16,6 +16,8 @@ urlpatterns = [ name='reset-password-success'), url(r'^user$', views.UserListView.as_view(), name='user-list'), url(r'^user/(?P[0-9]+)$', views.UserDetailView.as_view(), name='user-detail'), + url(r'^first-login/$', views.UserFirstLoginView.as_view(), name='user-first-login'), + url(r'^user/(?P[0-9]+)/assets-perm$', views.UserDetailView.as_view(), name='user-detail'), url(r'^user/create$', views.UserCreateView.as_view(), name='user-create'), url(r'^user/(?P[0-9]+)/update$', views.UserUpdateView.as_view(), name='user-update'), url(r'^user/(?P[0-9]+)/delete$', views.UserDeleteView.as_view(), name='user-delete'), diff --git a/apps/users/utils.py b/apps/users/utils.py index 7b3222c29..9e9a34321 100644 --- a/apps/users/utils.py +++ b/apps/users/utils.py @@ -1,18 +1,18 @@ # ~*~ coding: utf-8 ~*~ # - from __future__ import unicode_literals -import os import logging +import os +import re -from paramiko.rsakey import RSAKey from django.contrib.auth.mixins import UserPassesTestMixin from django.urls import reverse_lazy from django.utils.translation import ugettext as _ +from paramiko.rsakey import RSAKey + from common.tasks import send_mail_async from common.utils import reverse -from users.models import User try: @@ -125,5 +125,56 @@ def send_reset_password_mail(user): send_mail_async.delay(subject, message, recipient_list, html_message=message) +def validate_ssh_pk(text): + """ + Expects a SSH private key as string. + Returns a boolean and a error message. + If the text is parsed as private key successfully, + (True,'') is returned. Otherwise, + (False, ) is returned. + from https://github.com/githubnemo/SSH-private-key-validator/blob/master/validate.py + """ + + if not text: + return False, 'No text given' + + startPattern = re.compile("^-----BEGIN [A-Z]+ PRIVATE KEY-----") + optionPattern = re.compile("^.+: .+") + contentPattern = re.compile("^([a-zA-Z0-9+/]{64}|[a-zA-Z0-9+/]{1,64}[=]{0,2})$") + endPattern = re.compile("^-----END [A-Z]+ PRIVATE KEY-----") + + def contentState(text): + for i in range(0, len(text)): + line = text[i] + + if endPattern.match(line): + if i == len(text) - 1 or len(text[i + 1]) == 0: + return True, '' + else: + return False, 'At end but content coming' + + elif not contentPattern.match(line): + return False, 'Wrong string in content section' + + return False, 'No content or missing end line' + + def optionState(text): + for i in range(0, len(text)): + line = text[i] + + if line[-1:] == '\\': + return optionState(text[i + 2:]) + + if not optionPattern.match(line): + return contentState(text[i + 1:]) + + return False, 'Expected option, found nothing' + + def startState(text): + if len(text) == 0 or not startPattern.match(text[0]): + return False, 'Header is wrong' + return optionState(text[1:]) + + return startState([n.strip() for n in text.splitlines()]) diff --git a/apps/users/views.py b/apps/users/views.py index a7ec40c53..cfa3dd6fc 100644 --- a/apps/users/views.py +++ b/apps/users/views.py @@ -6,7 +6,9 @@ import logging from django.conf import settings from django.contrib.auth import login as auth_login, logout as auth_logout +from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.messages.views import SuccessMessageMixin +from django.core.files.storage import default_storage from django.db.models import Q from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, reverse, redirect @@ -21,10 +23,12 @@ from django.views.generic.list import ListView from django.views.generic.edit import CreateView, DeleteView, UpdateView, FormView from django.views.generic.detail import DetailView +from formtools.wizard.views import SessionWizardView + from common.utils import get_object_or_none from .models import User, UserGroup -from .forms import UserCreateForm, UserUpdateForm, UserGroupForm, UserLoginForm +from .forms import (UserCreateForm, UserUpdateForm, UserGroupForm, UserLoginForm, UserInfoForm, UserKeyForm) from .utils import AdminUserRequiredMixin, user_add_success_next, send_reset_password_mail @@ -49,6 +53,9 @@ class UserLoginView(FormView): return redirect(self.get_success_url()) def get_success_url(self): + if self.request.user.is_first_login: + return reverse('users:user-first-login') + return self.request.POST.get( self.redirect_field_name, self.request.GET.get(self.redirect_field_name, reverse('index'))) @@ -292,3 +299,42 @@ class UserResetPasswordView(TemplateView): user.reset_password(password) return HttpResponseRedirect(reverse('users:reset-password-success')) + + +class UserFirstLoginView(LoginRequiredMixin, SessionWizardView): + template_name = 'users/first_login.html' + form_list = [UserInfoForm, UserKeyForm] + file_storage = default_storage + + def dispatch(self, request, *args, **kwargs): + if request.user.is_authenticated() and not request.user.is_first_login: + return redirect(reverse('index')) + return super(UserFirstLoginView, self).dispatch(request, *args, **kwargs) + + def done(self, form_list, form_dict, **kwargs): + user = self.request.user + for form in form_list: + for field in form: + if field.value(): + setattr(user, field.name, field.value()) + if field.name == 'enable_otp': + user.enable_otp = field.value() + user.is_first_login = False + user.save() + return redirect(reverse('index')) + + def get_context_data(self, **kwargs): + context = super(UserFirstLoginView, self).get_context_data(**kwargs) + context.update({'app': _('Users'), 'action': _('First Login')}) + return context + + def get_form_initial(self, step): + user = self.request.user + if step == '0': + return { + 'name': user.name or user.username, + 'enable_otp': user.enable_otp or True, + 'wechat': user.wechat or '', + 'phone': user.phone or '' + } + return super(UserFirstLoginView, self).get_form_initial(step) diff --git a/requirements.txt b/requirements.txt index 1b3476a53..ffc458d20 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,3 +17,4 @@ paramiko==2.0.2 celery==3.1.23 ansible==2.1.1.0 django-simple-captcha==0.5.2 +django-formtools==1.0