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.send_share_link_email import SendShareLinkView
from .endpoints.send_upload_link_email import SendUploadLinkView
from .endpoints.sso.client_sso_link import ClientSSOLink
urlpatterns = [
path('ping/', Ping.as_view()),
@@ -115,3 +116,10 @@ if HAS_OFFICE_CONVERTER:
urlpatterns += [
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:
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'):
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 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
from seahub.utils.timeutils import datetime_to_isoformat_timestr
from seahub.tags.models import FileUUIDMap
@@ -395,3 +395,59 @@ class UserMonitoredRepos(models.Model):
class Meta:
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_URL = ''
# client sso
CLIENT_SSO_VIA_LOCAL_BROWSER = False
CLIENT_SSO_TOKEN_EXPIRATION = 5 * 60
#####################
# Global AddressBook #
#####################
@@ -989,6 +993,9 @@ CONSTANCE_CONFIG = {
'ENABLE_TERMS_AND_CONDITIONS': (ENABLE_TERMS_AND_CONDITIONS, ''),
'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

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/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.
# -*- coding: utf-8 -*-
import jwt
import time
import logging
from django.conf import settings
from django.urls import reverse
from django.http import HttpResponseRedirect
from urllib.parse import quote
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.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):
@@ -106,3 +118,84 @@ def shib_login(request):
return HttpResponseRedirect(reverse('multi_adfs_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
from seahub.base.models import FileComment
from seahub.base.models import FileComment, ClientSSOToken
from seahub.test_utils import BaseTestCase
from seahub.tags.models import FileUUIDMap
@@ -89,3 +89,11 @@ class FileCommentTest(BaseTestCase):
comment='test comment').save()
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.urls import reverse
from django.test import override_settings
from django.urls import re_path
from urllib.parse import quote
from seahub.base.models import ClientSSOToken
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):
@@ -22,3 +29,18 @@ class SSOTest(BaseTestCase):
resp = self.client.get(self.url + '?next=' + quote('http://testserver\@example.com'))
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