diff --git a/seahub/api2/serializers.py b/seahub/api2/serializers.py index a36b69ac11..a1c84312b0 100644 --- a/seahub/api2/serializers.py +++ b/seahub/api2/serializers.py @@ -8,6 +8,7 @@ from seahub.auth import authenticate from seahub.api2.models import DESKTOP_PLATFORMS from seahub.api2.utils import get_token_v1, get_token_v2 from seahub.profile.models import Profile +from seahub.two_factor.views.login import is_device_remembered from seahub.utils.two_factor_auth import has_two_factor_auth, \ two_factor_auth_enabled, verify_two_factor_token @@ -108,6 +109,11 @@ class AuthTokenSerializer(serializers.Serializer): def _two_factor_auth(self, request, user): if not has_two_factor_auth() or not two_factor_auth_enabled(user): return + + if is_device_remembered(request.META.get('HTTP_X_SEAFILE_S2FA', ''), + user): + return + token = request.META.get('HTTP_X_SEAFILE_OTP', '') if not token: self.two_factor_auth_failed = True @@ -118,6 +124,7 @@ class AuthTokenSerializer(serializers.Serializer): msg = 'Two factor auth token is invalid.' raise serializers.ValidationError(msg) + class AccountSerializer(serializers.Serializer): email = serializers.EmailField() password = serializers.CharField() diff --git a/seahub/api2/views.py b/seahub/api2/views.py index 1283aa7d9a..cca5ce6075 100644 --- a/seahub/api2/views.py +++ b/seahub/api2/views.py @@ -3,6 +3,7 @@ import logging import os import stat +from importlib import import_module import json import datetime import posixpath @@ -18,6 +19,7 @@ from rest_framework.permissions import IsAuthenticated, IsAdminUser from rest_framework.reverse import reverse from rest_framework.response import Response +from django.conf import settings as dj_settings from django.contrib.auth.hashers import check_password from django.contrib.sites.models import RequestSite from django.db import IntegrityError @@ -180,12 +182,45 @@ class ObtainAuthToken(APIView): renderer_classes = (renderers.JSONRenderer,) def post(self, request): + headers = {} context = { 'request': request } serializer = AuthTokenSerializer(data=request.data, context=context) if serializer.is_valid(): key = serializer.validated_data - return Response({'token': key}) - headers = {} + + trust_dev = False + try: + trust_dev_header = int(request.META.get('HTTP_X_SEAFILE_2FA_TRUST_DEVICE', '')) + trust_dev = True if trust_dev_header == 1 else False + except ValueError: + trust_dev = False + + skip_2fa_header = request.META.get('HTTP_X_SEAFILE_S2FA', None) + if skip_2fa_header is None: + if trust_dev: + # 2fa login with trust device, + # create new session, and return session id. + pass + else: + # No 2fa login or 2fa login without trust device, + # return token only. + return Response({'token': key}) + else: + # 2fa login without OTP token, + # get or create session, and return session id + pass + + SessionStore = import_module(dj_settings.SESSION_ENGINE).SessionStore + s = SessionStore(skip_2fa_header) + if not s.exists(skip_2fa_header) or s.is_empty(): + from seahub.two_factor.views.login import remember_device + s = remember_device(request.data['username']) + + headers = { + 'X-SEAFILE-S2FA': s.session_key + } + return Response({'token': key}, headers=headers) + if serializer.two_factor_auth_failed: # Add a special response header so the client knows to ask the user # for the 2fa token. diff --git a/seahub/auth/views.py b/seahub/auth/views.py index 5c8959a3bd..51b245efa4 100644 --- a/seahub/auth/views.py +++ b/seahub/auth/views.py @@ -29,6 +29,7 @@ from seahub.auth.utils import ( from seahub.base.accounts import User from seahub.options.models import UserOptions from seahub.profile.models import Profile +from seahub.two_factor.views.login import is_device_remembered from seahub.utils import is_ldap_user from seahub.utils.ip import get_remote_ip from seahub.utils.file_size import get_quota_from_string @@ -54,7 +55,8 @@ def log_user_in(request, user, redirect_to): clear_login_failed_attempts(request, user.username) - if two_factor_auth_enabled(user): + if two_factor_auth_enabled(user) and \ + not is_device_remembered(request.COOKIES.get('S2FA', ''), user): return handle_two_factor_auth(request, user, redirect_to) # Okay, security checks complete. Log the user in. diff --git a/seahub/settings.py b/seahub/settings.py index 24207f4ce4..6185ba2249 100644 --- a/seahub/settings.py +++ b/seahub/settings.py @@ -634,6 +634,7 @@ CLOUD_DEMO_USER = 'demo@seafile.com' ENABLE_TWO_FACTOR_AUTH = False OTP_LOGIN_URL = '/profile/two_factor_authentication/setup/' +TWO_FACTOR_DEVICE_REMEMBER_DAYS = 90 # Enable personal wiki, group wiki ENABLE_WIKI = False diff --git a/seahub/two_factor/forms.py b/seahub/two_factor/forms.py index 31e271eaec..922e008f4c 100644 --- a/seahub/two_factor/forms.py +++ b/seahub/two_factor/forms.py @@ -295,6 +295,7 @@ class OTPAuthenticationFormMixin(object): class AuthenticationTokenForm(OTPAuthenticationFormMixin, Form): otp_token = forms.IntegerField(label=_("Token"), min_value=1, max_value=int('9' * totp_digits())) + remember_me = forms.BooleanField(required=False) def __init__(self, user, request=None, *args, **kwargs): """ diff --git a/seahub/two_factor/templates/two_factor/core/login.html b/seahub/two_factor/templates/two_factor/core/login.html index 758a2ad5ff..86357e361c 100644 --- a/seahub/two_factor/templates/two_factor/core/login.html +++ b/seahub/two_factor/templates/two_factor/core/login.html @@ -22,6 +22,11 @@ + + {% if form.errors %}
{% trans "Incorrect code" %}
{% else %} diff --git a/seahub/two_factor/views/login.py b/seahub/two_factor/views/login.py index f55bbe0301..9b379d134c 100644 --- a/seahub/two_factor/views/login.py +++ b/seahub/two_factor/views/login.py @@ -3,6 +3,7 @@ import hashlib import re import logging from datetime import datetime +from importlib import import_module from constance import config @@ -108,7 +109,20 @@ class TwoFactorVerifyView(SessionWizardView): if not is_safe_url(url=redirect_to, host=self.request.get_host()): redirect_to = str(settings.LOGIN_REDIRECT_URL) - return redirect(redirect_to) + + res = HttpResponseRedirect(redirect_to) + if form_list[0].is_valid(): + remember_me = form_list[0].cleaned_data['remember_me'] + if remember_me: + s = remember_device(self.user.username) + res.set_cookie( + 'S2FA', s.session_key, + max_age=settings.TWO_FACTOR_DEVICE_REMEMBER_DAYS * 24 * 60 * 60, + domain=settings.SESSION_COOKIE_DOMAIN, + path=settings.SESSION_COOKIE_PATH, + secure=settings.SESSION_COOKIE_SECURE or None, + httponly=settings.SESSION_COOKIE_HTTPONLY or None) + return res def get_form_kwargs(self, step=None): if step in ('token', 'backup'): @@ -168,9 +182,9 @@ class TwoFactorVerifyView(SessionWizardView): context['cancel_url'] = settings.LOGOUT_URL context['form_prefix'] = '%s-' % self.steps.current - login_bg_image_path = get_login_bg_image_path() context['login_bg_image_path'] = login_bg_image_path + context['remember_days'] = settings.TWO_FACTOR_DEVICE_REMEMBER_DAYS return context @@ -212,3 +226,26 @@ def verify_two_factor_token(username, token): device = TOTPDevice.objects.device_for_user(username) if device: return device.verify_token(token) + +def remember_device(s_data): + SessionStore = import_module(settings.SESSION_ENGINE).SessionStore + s = SessionStore() + s.set_expiry(settings.TWO_FACTOR_DEVICE_REMEMBER_DAYS * 24 * 60 * 60) + s['2fa-skip'] = s_data + s.create() + return s + +def is_device_remembered(request_header, user): + if not request_header: + return False + + # User must be authenticated, otherwise this function is wrong used. + assert user.is_authenticated() + + SessionStore = import_module(settings.SESSION_ENGINE).SessionStore + s = SessionStore(request_header) + try: + username = s['2fa-skip'] + return username == user.username + except KeyError: + return False diff --git a/seahub/two_factor/views/profile.py b/seahub/two_factor/views/profile.py index 31dd0ff725..de05bc4dfe 100644 --- a/seahub/two_factor/views/profile.py +++ b/seahub/two_factor/views/profile.py @@ -58,4 +58,7 @@ class DisableView(CheckTwoFactorEnabledMixin, FormView): def form_valid(self, form): for device in devices_for_user(self.request.user): device.delete() - return HttpResponseRedirect(reverse('edit_profile')) + + resp = HttpResponseRedirect(reverse('edit_profile')) + resp.delete_cookie('S2FA', domain=settings.SESSION_COOKIE_DOMAIN) + return resp