mirror of
https://github.com/haiwen/seahub.git
synced 2025-09-26 07:19:58 +00:00
[2fa] Remember device for certain days
This commit is contained in:
@@ -8,6 +8,7 @@ from seahub.auth import authenticate
|
|||||||
from seahub.api2.models import DESKTOP_PLATFORMS
|
from seahub.api2.models import DESKTOP_PLATFORMS
|
||||||
from seahub.api2.utils import get_token_v1, get_token_v2
|
from seahub.api2.utils import get_token_v1, get_token_v2
|
||||||
from seahub.profile.models import Profile
|
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, \
|
from seahub.utils.two_factor_auth import has_two_factor_auth, \
|
||||||
two_factor_auth_enabled, verify_two_factor_token
|
two_factor_auth_enabled, verify_two_factor_token
|
||||||
|
|
||||||
@@ -108,6 +109,11 @@ class AuthTokenSerializer(serializers.Serializer):
|
|||||||
def _two_factor_auth(self, request, user):
|
def _two_factor_auth(self, request, user):
|
||||||
if not has_two_factor_auth() or not two_factor_auth_enabled(user):
|
if not has_two_factor_auth() or not two_factor_auth_enabled(user):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if is_device_remembered(request.META.get('HTTP_X_SEAFILE_S2FA', ''),
|
||||||
|
user):
|
||||||
|
return
|
||||||
|
|
||||||
token = request.META.get('HTTP_X_SEAFILE_OTP', '')
|
token = request.META.get('HTTP_X_SEAFILE_OTP', '')
|
||||||
if not token:
|
if not token:
|
||||||
self.two_factor_auth_failed = True
|
self.two_factor_auth_failed = True
|
||||||
@@ -118,6 +124,7 @@ class AuthTokenSerializer(serializers.Serializer):
|
|||||||
msg = 'Two factor auth token is invalid.'
|
msg = 'Two factor auth token is invalid.'
|
||||||
raise serializers.ValidationError(msg)
|
raise serializers.ValidationError(msg)
|
||||||
|
|
||||||
|
|
||||||
class AccountSerializer(serializers.Serializer):
|
class AccountSerializer(serializers.Serializer):
|
||||||
email = serializers.EmailField()
|
email = serializers.EmailField()
|
||||||
password = serializers.CharField()
|
password = serializers.CharField()
|
||||||
|
@@ -3,6 +3,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import stat
|
import stat
|
||||||
|
from importlib import import_module
|
||||||
import json
|
import json
|
||||||
import datetime
|
import datetime
|
||||||
import posixpath
|
import posixpath
|
||||||
@@ -18,6 +19,7 @@ from rest_framework.permissions import IsAuthenticated, IsAdminUser
|
|||||||
from rest_framework.reverse import reverse
|
from rest_framework.reverse import reverse
|
||||||
from rest_framework.response import Response
|
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.auth.hashers import check_password
|
||||||
from django.contrib.sites.models import RequestSite
|
from django.contrib.sites.models import RequestSite
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
@@ -180,12 +182,45 @@ class ObtainAuthToken(APIView):
|
|||||||
renderer_classes = (renderers.JSONRenderer,)
|
renderer_classes = (renderers.JSONRenderer,)
|
||||||
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
|
headers = {}
|
||||||
context = { 'request': request }
|
context = { 'request': request }
|
||||||
serializer = AuthTokenSerializer(data=request.data, context=context)
|
serializer = AuthTokenSerializer(data=request.data, context=context)
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
key = serializer.validated_data
|
key = serializer.validated_data
|
||||||
|
|
||||||
|
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})
|
return Response({'token': key})
|
||||||
headers = {}
|
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:
|
if serializer.two_factor_auth_failed:
|
||||||
# Add a special response header so the client knows to ask the user
|
# Add a special response header so the client knows to ask the user
|
||||||
# for the 2fa token.
|
# for the 2fa token.
|
||||||
|
@@ -29,6 +29,7 @@ from seahub.auth.utils import (
|
|||||||
from seahub.base.accounts import User
|
from seahub.base.accounts import User
|
||||||
from seahub.options.models import UserOptions
|
from seahub.options.models import UserOptions
|
||||||
from seahub.profile.models import Profile
|
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 import is_ldap_user
|
||||||
from seahub.utils.ip import get_remote_ip
|
from seahub.utils.ip import get_remote_ip
|
||||||
from seahub.utils.file_size import get_quota_from_string
|
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)
|
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)
|
return handle_two_factor_auth(request, user, redirect_to)
|
||||||
|
|
||||||
# Okay, security checks complete. Log the user in.
|
# Okay, security checks complete. Log the user in.
|
||||||
|
@@ -634,6 +634,7 @@ CLOUD_DEMO_USER = 'demo@seafile.com'
|
|||||||
|
|
||||||
ENABLE_TWO_FACTOR_AUTH = False
|
ENABLE_TWO_FACTOR_AUTH = False
|
||||||
OTP_LOGIN_URL = '/profile/two_factor_authentication/setup/'
|
OTP_LOGIN_URL = '/profile/two_factor_authentication/setup/'
|
||||||
|
TWO_FACTOR_DEVICE_REMEMBER_DAYS = 90
|
||||||
|
|
||||||
# Enable personal wiki, group wiki
|
# Enable personal wiki, group wiki
|
||||||
ENABLE_WIKI = False
|
ENABLE_WIKI = False
|
||||||
|
@@ -295,6 +295,7 @@ class OTPAuthenticationFormMixin(object):
|
|||||||
class AuthenticationTokenForm(OTPAuthenticationFormMixin, Form):
|
class AuthenticationTokenForm(OTPAuthenticationFormMixin, Form):
|
||||||
otp_token = forms.IntegerField(label=_("Token"), min_value=1,
|
otp_token = forms.IntegerField(label=_("Token"), min_value=1,
|
||||||
max_value=int('9' * totp_digits()))
|
max_value=int('9' * totp_digits()))
|
||||||
|
remember_me = forms.BooleanField(required=False)
|
||||||
|
|
||||||
def __init__(self, user, request=None, *args, **kwargs):
|
def __init__(self, user, request=None, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
@@ -22,6 +22,11 @@
|
|||||||
<label for="token">{% trans "Authentication token" %}</label>
|
<label for="token">{% trans "Authentication token" %}</label>
|
||||||
<input id="token" type="text" name="{{form_prefix}}otp_token" value="" class="input two-factor-auth-login-token-input" autocomplete="off" />
|
<input id="token" type="text" name="{{form_prefix}}otp_token" value="" class="input two-factor-auth-login-token-input" autocomplete="off" />
|
||||||
|
|
||||||
|
<label class="checkbox-label remember">
|
||||||
|
<input type="checkbox" name="{{form_prefix}}remember_me" class="vam remember-input" />
|
||||||
|
<span class="vam">{% blocktrans %}Remember this computer for {{remember_days}} days{% endblocktrans %}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
{% if form.errors %}
|
{% if form.errors %}
|
||||||
<p class="error">{% trans "Incorrect code" %}</p>
|
<p class="error">{% trans "Incorrect code" %}</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
@@ -3,6 +3,7 @@ import hashlib
|
|||||||
import re
|
import re
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from importlib import import_module
|
||||||
|
|
||||||
from constance import config
|
from constance import config
|
||||||
|
|
||||||
@@ -108,7 +109,20 @@ class TwoFactorVerifyView(SessionWizardView):
|
|||||||
|
|
||||||
if not is_safe_url(url=redirect_to, host=self.request.get_host()):
|
if not is_safe_url(url=redirect_to, host=self.request.get_host()):
|
||||||
redirect_to = str(settings.LOGIN_REDIRECT_URL)
|
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):
|
def get_form_kwargs(self, step=None):
|
||||||
if step in ('token', 'backup'):
|
if step in ('token', 'backup'):
|
||||||
@@ -168,9 +182,9 @@ class TwoFactorVerifyView(SessionWizardView):
|
|||||||
|
|
||||||
context['cancel_url'] = settings.LOGOUT_URL
|
context['cancel_url'] = settings.LOGOUT_URL
|
||||||
context['form_prefix'] = '%s-' % self.steps.current
|
context['form_prefix'] = '%s-' % self.steps.current
|
||||||
|
|
||||||
login_bg_image_path = get_login_bg_image_path()
|
login_bg_image_path = get_login_bg_image_path()
|
||||||
context['login_bg_image_path'] = login_bg_image_path
|
context['login_bg_image_path'] = login_bg_image_path
|
||||||
|
context['remember_days'] = settings.TWO_FACTOR_DEVICE_REMEMBER_DAYS
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
@@ -212,3 +226,26 @@ def verify_two_factor_token(username, token):
|
|||||||
device = TOTPDevice.objects.device_for_user(username)
|
device = TOTPDevice.objects.device_for_user(username)
|
||||||
if device:
|
if device:
|
||||||
return device.verify_token(token)
|
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
|
||||||
|
@@ -58,4 +58,7 @@ class DisableView(CheckTwoFactorEnabledMixin, FormView):
|
|||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
for device in devices_for_user(self.request.user):
|
for device in devices_for_user(self.request.user):
|
||||||
device.delete()
|
device.delete()
|
||||||
return HttpResponseRedirect(reverse('edit_profile'))
|
|
||||||
|
resp = HttpResponseRedirect(reverse('edit_profile'))
|
||||||
|
resp.delete_cookie('S2FA', domain=settings.SESSION_COOKIE_DOMAIN)
|
||||||
|
return resp
|
||||||
|
Reference in New Issue
Block a user