Ajout type contrat

This commit is contained in:
2026-04-29 11:52:03 +02:00
parent 375549cb30
commit 1c0e4c3048
10530 changed files with 1842149 additions and 158 deletions

View File

@@ -0,0 +1 @@
__version__ = '1.3.0'

View File

@@ -0,0 +1,6 @@
class WebserviceError(Exception):
pass
class BadRequest(WebserviceError):
pass

View File

@@ -0,0 +1,3 @@
"""
This is only here so I can run tests
"""

View File

@@ -0,0 +1,129 @@
from copy import copy
from urllib.parse import urlparse, urlunparse, urljoin, urlencode
from django.urls import re_path
from django.contrib.auth import login
from django.contrib.auth.backends import ModelBackend
from django.contrib.auth.models import User
from django.http import HttpResponseRedirect
from django.urls import NoReverseMatch, reverse
from django.views.generic import View
from itsdangerous import URLSafeTimedSerializer
from simple_sso.utils import SyncConsumer
class LoginView(View):
client = None
def get(self, request):
next_ = self.get_next()
scheme = 'https' if request.is_secure() else 'http'
query = urlencode([('next', next_)])
netloc = request.get_host()
path = reverse('simple-sso-authenticate')
redirect_to = urlunparse((scheme, netloc, path, '', query, ''))
request_token = self.client.get_request_token(redirect_to)
host = urljoin(self.client.server_url, 'authorize/')
url = '%s?%s' % (host, urlencode([('token', request_token)]))
return HttpResponseRedirect(url)
def get_next(self):
"""
Given a request, returns the URL where a user should be redirected to
after login. Defaults to '/'
"""
next_ = self.request.GET.get('next', None)
if not next_:
return '/'
netloc = urlparse(next_)[1]
# Heavier security check -- don't allow redirection to a different
# host.
# Taken from django.contrib.auth.views.login
if netloc and netloc != self.request.get_host():
return '/'
return next_
class AuthenticateView(LoginView):
client = None
def get(self, request):
raw_access_token = request.GET['access_token']
access_token = URLSafeTimedSerializer(self.client.private_key).loads(raw_access_token)
user = self.client.get_user(access_token)
user.backend = self.client.backend
login(request, user)
next_ = self.get_next()
return HttpResponseRedirect(next_)
class Client:
login_view = LoginView
authenticate_view = AuthenticateView
backend = "%s.%s" % (ModelBackend.__module__, ModelBackend.__name__)
user_extra_data = None
def __init__(self, server_url, public_key, private_key,
user_extra_data=None):
self.server_url = server_url
self.public_key = public_key
self.private_key = private_key
self.consumer = SyncConsumer(self.server_url, self.public_key, self.private_key)
if user_extra_data:
self.user_extra_data = user_extra_data
@classmethod
def from_dsn(cls, dsn):
parse_result = urlparse(dsn)
public_key = parse_result.username
private_key = parse_result.password
netloc = parse_result.hostname
if parse_result.port:
netloc += ':%s' % parse_result.port
server_url = urlunparse((parse_result.scheme, netloc, parse_result.path,
parse_result.params, parse_result.query, parse_result.fragment))
return cls(server_url, public_key, private_key)
def get_request_token(self, redirect_to):
try:
url = reverse('simple-sso-request-token')
except NoReverseMatch:
# thisisfine
url = '/request-token/'
return self.consumer.consume(url, {'redirect_to': redirect_to})['request_token']
def get_user(self, access_token):
data = {'access_token': access_token}
if self.user_extra_data:
data['extra_data'] = self.user_extra_data
try:
url = reverse('simple-sso-verify')
except NoReverseMatch:
# thisisfine
url = '/verify/'
user_data = self.consumer.consume(url, data)
user = self.build_user(user_data)
return user
def build_user(self, user_data):
try:
user = User.objects.get(username=user_data['username'])
# Update user data, excluding username changes
# Work on copied _tmp dict to keep an untouched user_data
user_data_tmp = copy(user_data)
del user_data_tmp['username']
for _attr, _val in user_data_tmp.items():
setattr(user, _attr, _val)
except User.DoesNotExist:
user = User(**user_data)
user.set_unusable_password()
user.save()
return user
def get_urls(self):
return [
re_path(r'^$', self.login_view.as_view(client=self), name='simple-sso-login'),
re_path(r'^authenticate/$', self.authenticate_view.as_view(client=self), name='simple-sso-authenticate'),
]

View File

@@ -0,0 +1 @@
default_app_config = 'simple_sso.sso_server.apps.SimpleSSOServer'

View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class SimpleSSOServer(AppConfig):
name = 'simple_sso.sso_server'

View File

@@ -0,0 +1,35 @@
from django.db import migrations, models
from django.utils import timezone
from django.conf import settings
import simple_sso.sso_server.models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Consumer',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('name', models.CharField(unique=True, max_length=100)),
('private_key', models.CharField(default=simple_sso.sso_server.models.ConsumerSecretKeyGenerator('private_key'), unique=True, max_length=64)),
('public_key', models.CharField(default=simple_sso.sso_server.models.ConsumerSecretKeyGenerator('public_key'), unique=True, max_length=64)),
],
),
migrations.CreateModel(
name='Token',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('request_token', models.CharField(default=simple_sso.sso_server.models.TokenSecretKeyGenerator('request_token'), unique=True, max_length=64)),
('access_token', models.CharField(default=simple_sso.sso_server.models.TokenSecretKeyGenerator('access_token'), unique=True, max_length=64)),
('timestamp', models.DateTimeField(default=timezone.now)),
('redirect_to', models.CharField(max_length=255)),
('consumer', models.ForeignKey(related_name='tokens', to='sso_server.Consumer', on_delete=models.CASCADE)),
('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)),
],
),
]

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sso_server', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='consumer',
name='name',
field=models.CharField(unique=True, max_length=255),
),
]

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sso_server', '0002_consumer_name_max_length'),
]
operations = [
migrations.AlterField(
model_name='token',
name='redirect_to',
field=models.CharField(max_length=1023),
),
]

View File

@@ -0,0 +1,79 @@
from django.conf import settings
from django.db import models
from django.utils import timezone
from django.utils.deconstruct import deconstructible
from ..utils import gen_secret_key
@deconstructible
class SecretKeyGenerator:
"""
Helper to give default values to Client.secret and Client.key
"""
def __init__(self, field):
self.field = field
def __call__(self):
key = gen_secret_key(64)
while self.get_model().objects.filter(**{self.field: key}).exists():
key = gen_secret_key(64)
return key
class ConsumerSecretKeyGenerator(SecretKeyGenerator):
def get_model(self):
return Consumer
class TokenSecretKeyGenerator(SecretKeyGenerator):
def get_model(self):
return Token
class Consumer(models.Model):
name = models.CharField(max_length=255, unique=True)
private_key = models.CharField(
max_length=64, unique=True,
default=ConsumerSecretKeyGenerator('private_key')
)
public_key = models.CharField(
max_length=64, unique=True,
default=ConsumerSecretKeyGenerator('public_key')
)
def __unicode__(self):
return self.name
def rotate_keys(self):
self.secret = ConsumerSecretKeyGenerator('private_key')()
self.key = ConsumerSecretKeyGenerator('public_key')()
self.save()
class Token(models.Model):
consumer = models.ForeignKey(
Consumer,
related_name='tokens',
on_delete=models.CASCADE,
)
request_token = models.CharField(
unique=True, max_length=64,
default=TokenSecretKeyGenerator('request_token')
)
access_token = models.CharField(
unique=True, max_length=64,
default=TokenSecretKeyGenerator('access_token')
)
timestamp = models.DateTimeField(default=timezone.now)
redirect_to = models.CharField(max_length=1023)
user = models.ForeignKey(
getattr(settings, 'AUTH_USER_MODEL', 'auth.User'),
null=True,
on_delete=models.CASCADE,
)
def refresh(self):
self.timestamp = timezone.now()
self.save()

View File

@@ -0,0 +1,175 @@
import datetime
from urllib.parse import urlparse, urlencode, urlunparse
from django.contrib import admin
from django.contrib.admin.options import ModelAdmin
from django.http import (HttpResponseForbidden, HttpResponseBadRequest, HttpResponseRedirect, QueryDict)
from django.urls import re_path
from django.urls import reverse
from django.utils import timezone
from django.views.generic.base import View
from itsdangerous import URLSafeTimedSerializer
from simple_sso.sso_server.models import Token, Consumer
from simple_sso.utils import BaseProvider, provider_wrapper
class Provider(BaseProvider):
max_age = 5
def __init__(self, server):
self.server = server
def get_private_key(self, public_key):
try:
self.consumer = Consumer.objects.get(public_key=public_key)
except Consumer.DoesNotExist:
return None
return self.consumer.private_key
class RequestTokenProvider(Provider):
def provide(self, data):
redirect_to = data['redirect_to']
token = Token.objects.create(consumer=self.consumer, redirect_to=redirect_to)
return {'request_token': token.request_token}
class AuthorizeView(View):
"""
The client get's redirected to this view with the `request_token` obtained
by the Request Token Request by the client application beforehand.
This view checks if the user is logged in on the server application and if
that user has the necessary rights.
If the user is not logged in, the user is prompted to log in.
"""
server = None
def get(self, request):
request_token = request.GET.get('token', None)
if not request_token:
return self.missing_token_argument()
try:
self.token = Token.objects.select_related('consumer').get(request_token=request_token)
except Token.DoesNotExist:
return self.token_not_found()
if not self.check_token_timeout():
return self.token_timeout()
self.token.refresh()
if request.user.is_authenticated:
return self.handle_authenticated_user()
else:
return self.handle_unauthenticated_user()
def missing_token_argument(self):
return HttpResponseBadRequest('Token missing')
def token_not_found(self):
return HttpResponseForbidden('Token not found')
def token_timeout(self):
return HttpResponseForbidden('Token timed out')
def check_token_timeout(self):
delta = timezone.now() - self.token.timestamp
if delta > self.server.token_timeout:
self.token.delete()
return False
else:
return True
def handle_authenticated_user(self):
if self.server.has_access(self.request.user, self.token.consumer):
return self.success()
else:
return self.access_denied()
def handle_unauthenticated_user(self):
next_ = '%s?%s' % (self.request.path, urlencode([('token', self.token.request_token)]))
url = '%s?%s' % (reverse(self.server.auth_view_name), urlencode([('next', next_)]))
return HttpResponseRedirect(url)
def access_denied(self):
return HttpResponseForbidden("Access denied")
def success(self):
self.token.user = self.request.user
self.token.save()
serializer = URLSafeTimedSerializer(self.token.consumer.private_key)
parse_result = urlparse(self.token.redirect_to)
query_dict = QueryDict(parse_result.query, mutable=True)
query_dict['access_token'] = serializer.dumps(self.token.access_token)
url = urlunparse((parse_result.scheme, parse_result.netloc, parse_result.path, '', query_dict.urlencode(), ''))
return HttpResponseRedirect(url)
class VerificationProvider(Provider, AuthorizeView):
def provide(self, data):
token = data['access_token']
try:
self.token = Token.objects.select_related('user').get(access_token=token, consumer=self.consumer)
except Token.DoesNotExist:
return self.token_not_found()
if not self.check_token_timeout():
return self.token_timeout()
if not self.token.user:
return self.token_not_bound()
extra_data = data.get('extra_data', None)
return self.server.get_user_data(
self.token.user, self.consumer, extra_data=extra_data)
def token_not_bound(self):
return HttpResponseForbidden("Invalid token")
class ConsumerAdmin(ModelAdmin):
readonly_fields = ['public_key', 'private_key']
class Server:
request_token_provider = RequestTokenProvider
authorize_view = AuthorizeView
verification_provider = VerificationProvider
token_timeout = datetime.timedelta(minutes=5)
client_admin = ConsumerAdmin
auth_view_name = 'login'
def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key, value)
self.register_admin()
def register_admin(self):
admin.site.register(Consumer, self.client_admin)
def has_access(self, user, consumer):
return True
def get_user_extra_data(self, user, consumer, extra_data):
raise NotImplementedError()
def get_user_data(self, user, consumer, extra_data=None):
user_data = {
'username': user.username,
'email': user.email,
'first_name': user.first_name,
'last_name': user.last_name,
'is_staff': False,
'is_superuser': False,
'is_active': user.is_active,
}
if extra_data:
user_data['extra_data'] = self.get_user_extra_data(
user, consumer, extra_data)
return user_data
def get_urls(self):
return [
re_path(r'^request-token/$', provider_wrapper(self.request_token_provider(server=self)),
name='simple-sso-request-token'),
re_path(r'^authorize/$', self.authorize_view.as_view(server=self), name='simple-sso-authorize'),
re_path(r'^verify/$', provider_wrapper(
self.verification_provider(server=self)), name='simple-sso-verify'),
]

View File

@@ -0,0 +1,158 @@
import string
from random import SystemRandom
from urllib.parse import urlparse, urlunparse, urljoin
import requests
from django.conf import settings
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from itsdangerous import TimedSerializer, SignatureExpired, BadSignature
from simple_sso.exceptions import BadRequest, WebserviceError
random = SystemRandom()
KEY_CHARACTERS = string.ascii_letters + string.digits
PUBLIC_KEY_HEADER = 'x-services-public-key'
def default_gen_secret_key(length=40):
return ''.join([random.choice(KEY_CHARACTERS) for _ in range(length)])
def gen_secret_key(length=40):
generator = getattr(settings, 'SIMPLE_SSO_KEYGENERATOR', default_gen_secret_key)
return generator(length)
def _split_dsn(dsn):
parse_result = urlparse(dsn)
host = parse_result.hostname
if parse_result.port:
host += ':%s' % parse_result.port
base_url = urlunparse((
parse_result.scheme,
host,
parse_result.path,
parse_result.params,
parse_result.query,
parse_result.fragment,
))
return base_url, parse_result.username, parse_result.password
class BaseConsumer(object):
def __init__(self, base_url, public_key, private_key):
self.base_url = base_url
self.public_key = public_key
self.signer = TimedSerializer(private_key)
@classmethod
def from_dsn(cls, dsn):
base_url, public_key, private_key = _split_dsn(dsn)
return cls(base_url, public_key, private_key)
def consume(self, path, data, max_age=None):
if not path.startswith('/'):
raise ValueError("Paths must start with a slash")
signed_data = self.signer.dumps(data)
headers = {
PUBLIC_KEY_HEADER: self.public_key,
'Content-Type': 'application/json',
}
url = self.build_url(path)
body = self.send_request(url, data=signed_data, headers=headers)
return self.handle_response(body, max_age)
def handle_response(self, body, max_age):
return self.signer.loads(body, max_age=max_age)
def send_request(self, url, data, headers):
raise NotImplementedError(
'Implement send_request on BaseConsumer subclasses')
@staticmethod
def raise_for_status(status_code, message):
if status_code == 400:
raise BadRequest(message)
elif status_code >= 300:
raise WebserviceError(message)
def build_url(self, path):
path = path.lstrip('/')
return urljoin(self.base_url, path)
class SyncConsumer(BaseConsumer):
def __init__(self, base_url, public_key, private_key):
super(SyncConsumer, self).__init__(base_url, public_key, private_key)
self.session = requests.session()
def send_request(self, url, data, headers): # pragma: no cover
response = self.session.post(url, data=data, headers=headers)
self.raise_for_status(response.status_code, response.content)
return response.content
class BaseProvider(object):
max_age = None
def provide(self, data):
raise NotImplementedError(
'Subclasses of services.models.Provider must implement '
'the provide method'
)
def get_private_key(self, public_key):
raise NotImplementedError(
'Subclasses of services.models.Provider must implement '
'the get_private_key method'
)
def report_exception(self):
pass
def get_response(self, method, signed_data, get_header):
if method != 'POST':
return 405, ['POST']
public_key = get_header(PUBLIC_KEY_HEADER, None)
if not public_key:
return 400, "No public key"
private_key = self.get_private_key(public_key)
if not private_key:
return 400, "Invalid public key"
signer = TimedSerializer(private_key)
try:
data = signer.loads(signed_data, max_age=self.max_age)
except SignatureExpired:
return 400, "Signature expired"
except BadSignature:
return 400, "Bad Signature"
try:
raw_response_data = self.provide(data)
except:
self.report_exception()
return 400, "Failed to process the request"
response_data = signer.dumps(raw_response_data)
return 200, response_data
def provider_wrapper(provider):
def provider_view(request):
def get_header(key, default):
django_key = 'HTTP_%s' % key.upper().replace('-', '_')
return request.META.get(django_key, default)
method = request.method
if getattr(request, 'body', None):
signed_data = request.body
else:
signed_data = request.raw_post_data
status_code, data = provider.get_response(
method,
signed_data,
get_header,
)
return HttpResponse(data, status=status_code)
return csrf_exempt(provider_view)