1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-09-19 18:29:23 +00:00

Client sso via local browser (#5627)

* client sso via local browser

* improve code

* optimize code

* fix code

* optimize code

* update server-info api

* fix test

* fix sso.py

* replace email with username
This commit is contained in:
WJH
2023-12-19 15:50:02 +08:00
committed by GitHub
parent 048d0aac2c
commit 843482bd07
13 changed files with 339 additions and 5 deletions

View File

View File

@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
import logging
from rest_framework import status
from rest_framework.views import APIView
from rest_framework.reverse import reverse
from rest_framework.response import Response
from django.db import transaction
from django.shortcuts import get_object_or_404
from django.utils import timezone
from seahub.api2.throttling import AnonRateThrottle
from seahub.base.models import ClientSSOToken, STATUS_ERROR
from seahub.utils import get_service_url, gen_token
from seahub.api2.utils import api_error
from seahub.settings import CLIENT_SSO_TOKEN_EXPIRATION
logger = logging.getLogger(__name__)
class ClientSSOLink(APIView):
throttle_classes = (AnonRateThrottle, )
def get(self, request, token):
# query SSO status
t = get_object_or_404(ClientSSOToken, token=token)
if not t.is_success():
logger.error('{} client sso login status: not success status.'.format(token))
return Response({'status': t.status})
if not t.accessed_at:
logger.error('{} client sso login error: no accessed_at info.'.format(token))
return Response({'status': STATUS_ERROR})
interval = (timezone.now() - t.accessed_at).total_seconds()
if int(interval) >= CLIENT_SSO_TOKEN_EXPIRATION:
logger.error('{} client sso login error: login timeout.'.format(token))
return Response({'status': STATUS_ERROR})
return Response({
'status': t.status,
'username': t.username,
'apiToken': t.api_key
})
def post(self, request):
# create SSO link
token = gen_token(30) + gen_token(30)
transaction.set_autocommit(False)
try:
t = ClientSSOToken(token=token)
t.save()
transaction.commit()
except Exception as e:
logger.error(e)
transaction.rollback()
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error')
finally:
transaction.set_autocommit(True)
return Response({
'link': get_service_url().rstrip('/') + reverse(
'client_sso', args=[t.token])
})

View File

@@ -15,6 +15,7 @@ from .endpoints.file_comments_counts import FileCommentsCounts
from .endpoints.search_user import SearchUser from .endpoints.search_user import SearchUser
from .endpoints.send_share_link_email import SendShareLinkView from .endpoints.send_share_link_email import SendShareLinkView
from .endpoints.send_upload_link_email import SendUploadLinkView from .endpoints.send_upload_link_email import SendUploadLinkView
from .endpoints.sso.client_sso_link import ClientSSOLink
urlpatterns = [ urlpatterns = [
path('ping/', Ping.as_view()), path('ping/', Ping.as_view()),
@@ -115,3 +116,10 @@ if HAS_OFFICE_CONVERTER:
urlpatterns += [ urlpatterns += [
re_path(r'^office-convert/generate/repos/(?P<repo_id>[-0-9-a-f]{36})/$', OfficeGenerateView.as_view()), re_path(r'^office-convert/generate/repos/(?P<repo_id>[-0-9-a-f]{36})/$', OfficeGenerateView.as_view()),
] ]
from seahub.settings import CLIENT_SSO_VIA_LOCAL_BROWSER
if CLIENT_SSO_VIA_LOCAL_BROWSER:
urlpatterns += [
path('client-sso-link/', ClientSSOLink.as_view()),
re_path(r'^client-sso-link/(?P<token>[^/]+)/$', ClientSSOLink.as_view()),
]

View File

@@ -31,6 +31,9 @@ class ServerInfoView(APIView):
if config.DISABLE_SYNC_WITH_ANY_FOLDER: if config.DISABLE_SYNC_WITH_ANY_FOLDER:
features.append('disable-sync-with-any-folder') features.append('disable-sync-with-any-folder')
if config.CLIENT_SSO_VIA_LOCAL_BROWSER:
features.append('client-sso-via-local-browser')
if hasattr(settings, 'DESKTOP_CUSTOM_LOGO'): if hasattr(settings, 'DESKTOP_CUSTOM_LOGO'):
info['desktop-custom-logo'] = settings.MEDIA_URL + getattr(settings, 'DESKTOP_CUSTOM_LOGO') info['desktop-custom-logo'] = settings.MEDIA_URL + getattr(settings, 'DESKTOP_CUSTOM_LOGO')

View File

@@ -9,7 +9,7 @@ from pysearpc import SearpcError
from seaserv import seafile_api from seaserv import seafile_api
from seahub.auth.signals import user_logged_in from seahub.auth.signals import user_logged_in
from seahub.utils import within_time_range, \ from seahub.utils import within_time_range, gen_token, \
normalize_file_path, normalize_dir_path normalize_file_path, normalize_dir_path
from seahub.utils.timeutils import datetime_to_isoformat_timestr from seahub.utils.timeutils import datetime_to_isoformat_timestr
from seahub.tags.models import FileUUIDMap from seahub.tags.models import FileUUIDMap
@@ -395,3 +395,59 @@ class UserMonitoredRepos(models.Model):
class Meta: class Meta:
unique_together = [["email", "repo_id"]] unique_together = [["email", "repo_id"]]
STATUS_WAITING = 'waiting'
STATUS_SUCCESS = 'success'
STATUS_ERROR = 'error'
class ClientSSOTokenManager(models.Manager):
def new(self):
o = self.model()
o.save(using=self._db)
return o
class ClientSSOToken(models.Model):
STATUS_CHOICES = (
(STATUS_WAITING, 'waiting'),
(STATUS_SUCCESS, 'success'),
(STATUS_ERROR, 'error'),
)
token = models.CharField(max_length=100, unique=True)
username = LowerCaseCharField(max_length=255, db_index=True, blank=True, null=True)
status = models.CharField(max_length=10, default=STATUS_WAITING, choices=STATUS_CHOICES)
api_key = models.CharField(max_length=40, blank=True, null=True)
created_at = models.DateTimeField(db_index=True, auto_now_add=True)
updated_at = models.DateTimeField(db_index=True, blank=True, null=True)
accessed_at = models.DateTimeField(db_index=True, blank=True, null=True)
objects = ClientSSOTokenManager()
def gen_token(self):
return gen_token(30) + gen_token(30)
def is_waiting(self):
return self.status == STATUS_WAITING
def is_success(self):
return self.status == STATUS_SUCCESS
def completed(self, username, api_key):
assert self.is_waiting() is True
self.username = username
self.api_key = api_key
self.status = STATUS_SUCCESS
self.updated_at = timezone.now()
self.save()
def accessed(self):
self.accessed_at = timezone.now()
self.save()
def save(self, *args, **kwargs):
if not self.token:
self.token = self.gen_token()
return super(ClientSSOToken, self).save(*args, **kwargs)

View File

@@ -762,6 +762,10 @@ ENABLE_SSO_TO_THIRDPART_WEBSITE = False
THIRDPART_WEBSITE_SECRET_KEY = '' THIRDPART_WEBSITE_SECRET_KEY = ''
THIRDPART_WEBSITE_URL = '' THIRDPART_WEBSITE_URL = ''
# client sso
CLIENT_SSO_VIA_LOCAL_BROWSER = False
CLIENT_SSO_TOKEN_EXPIRATION = 5 * 60
##################### #####################
# Global AddressBook # # Global AddressBook #
##################### #####################
@@ -989,6 +993,9 @@ CONSTANCE_CONFIG = {
'ENABLE_TERMS_AND_CONDITIONS': (ENABLE_TERMS_AND_CONDITIONS, ''), 'ENABLE_TERMS_AND_CONDITIONS': (ENABLE_TERMS_AND_CONDITIONS, ''),
'ENABLE_USER_CLEAN_TRASH': (ENABLE_USER_CLEAN_TRASH, ''), 'ENABLE_USER_CLEAN_TRASH': (ENABLE_USER_CLEAN_TRASH, ''),
'CLIENT_SSO_VIA_LOCAL_BROWSER': (CLIENT_SSO_VIA_LOCAL_BROWSER, ''),
'CLIENT_SSO_TOKEN_EXPIRATION': (CLIENT_SSO_TOKEN_EXPIRATION, ''),
} }
# if Seafile admin enable remote user authentication in conf/seahub_settings.py # if Seafile admin enable remote user authentication in conf/seahub_settings.py

View File

@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% load i18n %}
{% block extra_style %}
<link rel="stylesheet" type="text/css" href="{{ MEDIA_URL }}css/bootstrap.popover.min.css" />
{% endblock %}
{% block main_content %}
<div class="new-narrow-panel">
<h2 class="hd">{% trans "Client Login Confirm" %}</h2>
<form action="" method="post" class="con">{% csrf_token %}
<label>{% trans "Do you want to login to your client?" %}</label><br />
<input type="submit" value="{% trans "Yes" %}" class="submit" />
</form>
</div>
{% endblock %}
{% block extra_script %}
<script type="text/javascript" src="{{MEDIA_URL}}js/bootstrap.min.js"></script>
{% endblock %}

View File

@@ -972,3 +972,9 @@ if settings.ENABLE_SEAFILE_AI:
re_path(r'^api/v2.1/ai/library-index-state/$', LibraryIndexState.as_view(), name='api-v2.1-ai-library-index-state'), re_path(r'^api/v2.1/ai/library-index-state/$', LibraryIndexState.as_view(), name='api-v2.1-ai-library-index-state'),
re_path(r'^api/v2.1/ai/repo/file-download-token/$', FileDownloadToken.as_view(), name='api-v2.1-ai-repo-file-download-token'), re_path(r'^api/v2.1/ai/repo/file-download-token/$', FileDownloadToken.as_view(), name='api-v2.1-ai-repo-file-download-token'),
] ]
if getattr(settings, 'CLIENT_SSO_VIA_LOCAL_BROWSER', False):
urlpatterns += [
re_path(r'^client-sso/(?P<token>[^/]+)/$', client_sso, name="client_sso"),
re_path(r'^client-sso/(?P<token>[^/]+)/complete/$', client_sso_complete, name="client_sso_complete"),
]

View File

@@ -1,17 +1,29 @@
# Copyright (c) 2012-2016 Seafile Ltd. # Copyright (c) 2012-2016 Seafile Ltd.
# -*- coding: utf-8 -*-
import jwt import jwt
import time import time
import logging
from django.conf import settings from django.conf import settings
from django.urls import reverse from django.urls import reverse
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from urllib.parse import quote from urllib.parse import quote
from django.utils.http import url_has_allowed_host_and_scheme from django.utils.http import url_has_allowed_host_and_scheme
from django.utils import timezone
from django.utils.translation import gettext as _
from django.shortcuts import get_object_or_404, render
from django.views.decorators.csrf import csrf_protect
from seahub.base.templatetags.seahub_tags import email2nickname from seahub.base.templatetags.seahub_tags import email2nickname
from seahub.auth import REDIRECT_FIELD_NAME from seahub.auth import REDIRECT_FIELD_NAME
from seahub.utils import render_error from seahub.auth.decorators import login_required
from seahub.utils import render_permission_error, render_error
from seahub.api2.utils import get_token_v1, get_token_v2
from seahub.settings import CLIENT_SSO_VIA_LOCAL_BROWSER, CLIENT_SSO_TOKEN_EXPIRATION, LOGIN_URL
from seahub.base.models import ClientSSOToken
# Get an instance of a logger
logger = logging.getLogger(__name__)
def sso(request): def sso(request):
@@ -106,3 +118,84 @@ def shib_login(request):
return HttpResponseRedirect(reverse('multi_adfs_sso') + params) return HttpResponseRedirect(reverse('multi_adfs_sso') + params)
return HttpResponseRedirect(reverse('sso') + params) return HttpResponseRedirect(reverse('sso') + params)
def client_sso(request, token):
if not CLIENT_SSO_VIA_LOCAL_BROWSER:
return render_error(request, 'Feature is not enabled.')
t = get_object_or_404(ClientSSOToken, token=token)
if not t.accessed_at:
t.accessed()
else:
error_msg = _('This link has already been visited, please click the login button on the client again')
return render_error(request, error_msg)
next_page = reverse('client_sso_complete', args=[token, ])
# client platform args used to create api v2 token
req_qs = request.META['QUERY_STRING']
if req_qs:
next_page = next_page + '?' + req_qs
# light security check
if not url_has_allowed_host_and_scheme(url=next_page, allowed_hosts=request.get_host()):
logger.error('%s is not safe url.' % next_page)
next_page = reverse('client_sso_complete', args=[token, ])
redirect_url = LOGIN_URL + '?next=' + quote(next_page)
return HttpResponseRedirect(redirect_url)
@csrf_protect
@login_required
def client_sso_complete(request, token):
t = get_object_or_404(ClientSSOToken, token=token)
if not t.accessed_at:
error_msg = _('Invalid link, please click the login button on the client again')
return render_error(request, error_msg)
interval = (timezone.now() - t.accessed_at).total_seconds()
if int(interval) >= CLIENT_SSO_TOKEN_EXPIRATION:
error_msg = _('Login timeout, please click the login button on the client again')
return render_error(request, error_msg)
if request.method == "GET":
template_name = 'client_login_confirm.html'
return render(request, template_name, {})
elif request.method == "POST":
username = request.user.username
if t.is_waiting():
# generate tokenv2 using information in request params
keys = (
'platform',
'device_id',
'device_name',
'client_version',
'platform_version',
)
if all(['shib_' + key in request.GET for key in keys]):
platform = request.GET['shib_platform']
device_id = request.GET['shib_device_id']
device_name = request.GET['shib_device_name']
client_version = request.GET['shib_client_version']
platform_version = request.GET['shib_platform_version']
api_token = get_token_v2(
request, username, platform, device_id,
device_name, client_version, platform_version)
elif all(['shib_' + key not in request.GET for key in keys]):
api_token = get_token_v1(username)
t.completed(username=username, api_key=api_token.key)
logger.info('Client SSO success, token: %s, user: %s' % (token, username))
else:
logger.warning('Client SSO token is not waiting, skip.')
return HttpResponseRedirect(reverse('libraries'))
else:
return render_permission_error(request, _('Permission denied.'))

View File

View File

@@ -0,0 +1,48 @@
import json
from django.test import TransactionTestCase
from django.urls import path, re_path
from seahub.base.models import ClientSSOToken
from seahub.test_utils import Fixtures
from seahub.api2.urls import urlpatterns as api2_urls
from seahub.api2.endpoints.sso.client_sso_link import ClientSSOLink
from seahub.urls import urlpatterns
from seahub.views.sso import client_sso
urlpatterns += [
re_path(r'^client-sso/(?P<token>[^/]+)/$', client_sso, name="client_sso"),
]
api2_urls += [
path('client-sso-link/', ClientSSOLink.as_view()),
re_path(r'^client-sso-link/(?P<token>[^/]+)/$', ClientSSOLink.as_view()),
]
class ClientSSOLinkTest(TransactionTestCase, Fixtures):
def test_create(self):
resp = self.client.post('/api2/client-sso-link/')
self.assertEqual(resp.status_code, 200)
json_resp = json.loads(resp.content)
assert json_resp['link'] is not None
def test_query_status(self):
t = ClientSSOToken.objects.new()
url = '/api2/client-sso-link/%s/' % t.token
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
json_resp = json.loads(resp.content)
assert json_resp['status'] == 'waiting'
t.accessed()
t.completed(username=self.user.username, api_key='xxx')
resp = self.client.get(url)
json_resp = json.loads(resp.content)
assert json_resp['status'] == 'success'
assert json_resp['username'] == self.user.username
assert json_resp['apiToken'] == 'xxx'

View File

@@ -1,6 +1,6 @@
import hashlib import hashlib
from seahub.base.models import FileComment from seahub.base.models import FileComment, ClientSSOToken
from seahub.test_utils import BaseTestCase from seahub.test_utils import BaseTestCase
from seahub.tags.models import FileUUIDMap from seahub.tags.models import FileUUIDMap
@@ -89,3 +89,11 @@ class FileCommentTest(BaseTestCase):
comment='test comment').save() comment='test comment').save()
assert len(FileComment.objects.all()) == 1 assert len(FileComment.objects.all()) == 1
class ClientSSOTokenManagerTest(BaseTestCase):
def test_new(self):
t = ClientSSOToken.objects.new()
assert len(t.token) == 60
assert t.created_at is not None
assert t.api_key is None

View File

@@ -1,10 +1,17 @@
from django.conf import settings from django.conf import settings
from django.urls import reverse from django.urls import reverse
from django.test import override_settings from django.test import override_settings
from django.urls import re_path
from urllib.parse import quote from urllib.parse import quote
from seahub.base.models import ClientSSOToken
from seahub.test_utils import BaseTestCase from seahub.test_utils import BaseTestCase
from seahub.views.sso import sso from seahub.views.sso import sso, client_sso_complete
from seahub.urls import urlpatterns
urlpatterns += [
re_path(r'^client-sso/(?P<token>[^/]+)/complete/$', client_sso_complete, name="client_sso_complete"),
]
class SSOTest(BaseTestCase): class SSOTest(BaseTestCase):
@@ -22,3 +29,18 @@ class SSOTest(BaseTestCase):
resp = self.client.get(self.url + '?next=' + quote('http://testserver\@example.com')) resp = self.client.get(self.url + '?next=' + quote('http://testserver\@example.com'))
self.assertRegex(resp['Location'], settings.LOGIN_REDIRECT_URL) self.assertRegex(resp['Location'], settings.LOGIN_REDIRECT_URL)
def test_client_sso_complete(self):
self.login_as(self.user)
t = ClientSSOToken.objects.new()
assert t.api_key is None
assert t.username is None
t.accessed()
resp = self.client.post('/client-sso/%s/complete/' % t.token)
self.assertEqual(resp.status_code, 302)
t2 = ClientSSOToken.objects.get(token=t.token)
assert t2.api_key is not None
assert t2.username == self.user.username