This commit is contained in:
Éloi Rivard 2020-08-14 13:18:08 +02:00
parent 202ed48ab4
commit e3ff713693
16 changed files with 1354 additions and 0 deletions

9
app.py Normal file
View file

@ -0,0 +1,9 @@
from website.app import create_app
app = create_app({
'SECRET_KEY': 'secret',
'OAUTH2_REFRESH_TOKEN_GENERATOR': True,
'SQLALCHEMY_TRACK_MODIFICATIONS': False,
'SQLALCHEMY_DATABASE_URI': 'sqlite:///db.sqlite',
})

21
docker-compose.yml Normal file
View file

@ -0,0 +1,21 @@
---
version: "3"
services:
ldap:
image: osixia/openldap
environment:
- LDAP_DOMAIN=mydomain.tld
volumes:
- ./docker/bootstrap.ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom/50-boostrap.ldif:ro
- ./oauth2-openldap.ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom/40-oauth2.ldif:ro
command: --copy-service
oauth:
build:
context: .
dockerfile: docker/Dockerfile
ports:
- 5000:5000
volumes:
- .:/app

17
docker/Dockerfile Normal file
View file

@ -0,0 +1,17 @@
FROM python:3-alpine
COPY requirements.txt /app/
RUN apk add curl libldap libffi su-exec
RUN apk add --virtual .dev-dependencies gcc musl-dev openldap-dev libffi-dev
RUN adduser -D -h /app oauthserver
RUN pip install --requirement /app/requirements.txt
WORKDIR /app
USER oauthserver
ENV FLASK_ENV=development
ENV AUTHLIB_INSECURE_TRANSPORT=1
COPY --chown=oauthserver:oauthserver . /app/
ENTRYPOINT [ "flask", "run", "--host", "0.0.0.0" ]

27
docker/bootstrap.ldif Normal file
View file

@ -0,0 +1,27 @@
dn: ou=users,dc=mydomain,dc=tld
objectclass: organizationalUnit
ou: users
dn: ou=clients,dc=mydomain,dc=tld
objectclass: organizationalUnit
ou: clients
dn: ou=tokens,dc=mydomain,dc=tld
objectclass: organizationalUnit
ou: tokens
dn: ou=authorizations,dc=mydomain,dc=tld
objectclass: organizationalUnit
ou: authorizations
dn: cn=Jane Doe,ou=users,dc=mydomain,dc=tld
objectclass: person
cn: Jane Doe
sn: Doe
userpassword: {SSHA}7zQVLckaEc6cJEsS0ylVipvb2PAR/4tS
dn: cn=John Doe,ou=users,dc=mydomain,dc=tld
objectclass: person
cn: John Doe
sn: Doe
userpassword: {SSHA}Yr1ZxSljRsKyaTB30suY2iZ1KRTStF1X

294
oauth2-openldap.ldif Normal file
View file

@ -0,0 +1,294 @@
dn: cn=oauth,cn=schema,cn=config
objectClass: olcSchemaConfig
cn: oauth
olcAttributeTypes: ( 1.3.6.1.4.1.40805.1.1.1 NAME 'oauthCode'
DESC 'OAuth 2.0 Authorization Code'
EQUALITY caseExactMatch
ORDERING caseExactOrderingMatch
SUBSTR caseExactSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
SINGLE-VALUE
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcAttributeTypes: ( 1.3.6.1.4.1.40805.1.1.2 NAME 'oauthClientID'
DESC 'Authorized client'
EQUALITY caseExactMatch
ORDERING caseExactOrderingMatch
SUBSTR caseExactSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
SINGLE-VALUE
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcAttributeTypes: ( 1.3.6.1.4.1.40805.1.1.3 NAME 'oauthRedirectURI'
DESC 'Authorization Code Redirection URI'
EQUALITY caseIgnoreMatch
ORDERING caseIgnoreOrderingMatch
SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
SINGLE-VALUE
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcAttributeTypes: ( 1.3.6.1.4.1.40805.1.1.4 NAME 'oauthResponseType'
DESC 'OAuth 2.0 response type'
EQUALITY caseExactMatch
ORDERING caseExactOrderingMatch
SUBSTR caseExactSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcAttributeTypes: ( 1.3.6.1.4.1.40805.1.1.5 NAME 'oauthScope'
DESC 'OAuth 2.0 scope value'
EQUALITY caseExactMatch
ORDERING caseExactOrderingMatch
SUBSTR caseExactSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcAttributeTypes: ( 1.3.6.1.4.1.40805.1.1.6 NAME 'oauthNonce'
DESC 'OAuth 2.0 nonce'
EQUALITY caseExactMatch
ORDERING caseExactOrderingMatch
SUBSTR caseExactSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcAttributeTypes: ( 1.3.6.1.4.1.40805.1.1.7 NAME 'oauthAuthorizationDate'
DESC 'Access token issue date'
EQUALITY generalizedTimeMatch
ORDERING generalizedTimeOrderingMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.24
SINGLE-VALUE
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcAttributeTypes: ( 1.3.6.1.4.1.40805.1.1.8 NAME 'oauthCodeChallenge'
DESC 'OAuth 2.0 nonce'
EQUALITY caseExactMatch
ORDERING caseExactOrderingMatch
SUBSTR caseExactSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcAttributeTypes: ( 1.3.6.1.4.1.40805.1.1.9 NAME 'oauthCodeChallengeMethod'
DESC 'OAuth 2.0 nonce'
EQUALITY caseExactMatch
ORDERING caseExactOrderingMatch
SUBSTR caseExactSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcAttributeTypes: ( 1.3.6.1.4.1.40805.1.1.10 NAME 'oauthClientSecret'
DESC 'Client secret'
EQUALITY caseExactMatch
ORDERING caseExactOrderingMatch
SUBSTR caseExactSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
SINGLE-VALUE
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcAttributeTypes: ( 1.3.6.1.4.1.40805.1.1.11 NAME 'oauthClientSecretExpDate'
DESC 'Client secret expiration date/time'
EQUALITY generalizedTimeMatch
ORDERING generalizedTimeOrderingMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.24
SINGLE-VALUE
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcAttributeTypes: ( 1.3.6.1.4.1.40805.1.1.12 NAME 'oauthIssueDate'
DESC 'Client identifier issue date/time'
EQUALITY generalizedTimeMatch
ORDERING generalizedTimeOrderingMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.24
SINGLE-VALUE
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcAttributeTypes: ( 1.3.6.1.4.1.40805.1.1.13 NAME 'oauthGrantType'
DESC 'OAuth 2.0 grant type'
EQUALITY caseExactMatch
ORDERING caseExactOrderingMatch
SUBSTR caseExactSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcAttributeTypes: ( 1.3.6.1.4.1.40805.1.1.14 NAME 'oauthTokenLifetime'
DESC 'OAuth 2.0 refresh token lifetime, in seconds'
EQUALITY integerMatch
ORDERING integerOrderingMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
SINGLE-VALUE
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcAttributeTypes: ( 1.3.6.1.4.1.40805.1.1.15 NAME 'oauthClientName'
DESC 'Client name'
EQUALITY caseIgnoreMatch
ORDERING caseIgnoreOrderingMatch
SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcAttributeTypes: ( 1.3.6.1.4.1.40805.1.1.16 NAME 'oauthClientContact'
DESC 'Client name'
EQUALITY caseIgnoreMatch
ORDERING caseIgnoreOrderingMatch
SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcAttributeTypes: ( 1.3.6.1.4.1.40805.1.1.17 NAME 'oauthClientURI'
DESC 'Client URI'
EQUALITY caseIgnoreMatch
ORDERING caseIgnoreOrderingMatch
SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcAttributeTypes: ( 1.3.6.1.4.1.40805.1.1.18 NAME 'oauthLogoURI'
DESC 'Logo URI'
EQUALITY caseIgnoreMatch
ORDERING caseIgnoreOrderingMatch
SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
SINGLE-VALUE
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcAttributeTypes: ( 1.3.6.1.4.1.40805.1.1.19 NAME 'oauthTermsOfServiceURI'
DESC 'Terms of service URI'
EQUALITY caseIgnoreMatch
ORDERING caseIgnoreOrderingMatch
SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcAttributeTypes: ( 1.3.6.1.4.1.40805.1.1.20 NAME 'oauthPolicyURI'
DESC 'Policy URI'
EQUALITY caseIgnoreMatch
ORDERING caseIgnoreOrderingMatch
SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcAttributeTypes: ( 1.3.6.1.4.1.40805.1.1.21 NAME 'oauthJWKURI'
DESC 'JWK set URI'
EQUALITY caseIgnoreMatch
ORDERING caseIgnoreOrderingMatch
SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
SINGLE-VALUE
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcAttributeTypes: ( 1.3.6.1.4.1.40805.1.1.22 NAME 'oauthJWK'
DESC 'JWK set JSON'
EQUALITY caseExactMatch
ORDERING caseExactOrderingMatch
SUBSTR caseExactSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
SINGLE-VALUE
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcAttributeTypes: ( 1.3.6.1.4.1.40805.1.1.23 NAME 'oauthSoftwareID'
DESC 'Software identifier'
EQUALITY caseExactMatch
ORDERING caseExactOrderingMatch
SUBSTR caseExactSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
SINGLE-VALUE
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcAttributeTypes: ( 1.3.6.1.4.1.40805.1.1.24 NAME 'oauthSoftwareVersion'
DESC 'Software version'
EQUALITY caseExactMatch
ORDERING caseExactOrderingMatch
SUBSTR caseExactSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
SINGLE-VALUE
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcAttributeTypes: ( 1.3.6.1.4.1.40805.1.1.25 NAME 'oauthToken'
DESC 'OAuth 2.0 Token'
EQUALITY caseExactMatch
ORDERING caseExactOrderingMatch
SUBSTR caseExactSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
SINGLE-VALUE
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcAttributeTypes: ( 1.3.6.1.4.1.40805.1.1.26 NAME 'oauthTokenType'
DESC 'OAuth 2.0 Token'
EQUALITY caseExactMatch
ORDERING caseExactOrderingMatch
SUBSTR caseExactSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
SINGLE-VALUE
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcAttributeTypes: ( 1.3.6.1.4.1.40805.1.1.27 NAME 'oauthAccessToken'
DESC 'OAuth 2.0 access token'
EQUALITY caseExactMatch
ORDERING caseExactOrderingMatch
SUBSTR caseExactSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
SINGLE-VALUE
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcAttributeTypes: ( 1.3.6.1.4.1.40805.1.1.28 NAME 'oauthRefreshToken'
DESC 'OAuth 2.0 refresh token'
EQUALITY caseExactMatch
ORDERING caseExactOrderingMatch
SUBSTR caseExactSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
SINGLE-VALUE
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcObjectClasses: ( 1.3.6.1.4.1.40805.1.2.1 NAME 'oauthClient'
DESC 'OAuth 2.0 Authorization Code'
SUP top
STRUCTURAL
MUST oauthClientID
MAY ( description $
oauthClientName $
oauthClientContact $
oauthClientURI $
oauthLogoURI $
oauthIssueDate $
oauthClientSecret $
oauthClientSecretExpDate $
oauthGrantType $
oauthResponseType $
oauthScope $
oauthTermsOfServiceURI $
oauthPolicyURI $
oauthJWKURI $
oauthJWK $
oauthSoftwareID $
oauthSoftwareVersion )
)
X-ORIGIN 'OAuth 2.0' )
olcObjectClasses: ( 1.3.6.1.4.1.40805.1.2.2 NAME 'oauthAuthorizationCode'
DESC 'OAuth 2.0 Authorization Code'
SUP top
STRUCTURAL
MUST oauthCode
MAY ( description $
oauthCode $
oauthClientID $
oauthRedirectURI $
oauthResponseType $
oauthScope $
oauthNonce $
oauthAuthorizationDate $
oauthCodeChallenge $
oauthCodeChallengeMethod )
X-ORIGIN 'OAuth 2.0' )
olcObjectClasses: ( 1.3.6.1.4.1.40805.1.2.3 NAME 'oauthToken'
DESC 'OAuth 2.0 Token'
SUP top
STRUCTURAL
MUST oauthToken
MAY ( description $
oauthClientID $
oauthTokenType $
oauthAccessToken $
oauthRefreshToken $
oauthScope $
oauthIssueDate $
oauthTokenLifetime )
X-ORIGIN 'OAuth 2.0' )

4
requirements.txt Normal file
View file

@ -0,0 +1,4 @@
Flask
Flask-SQLAlchemy
Authlib==0.14.3
python-ldap

179
test.py Normal file
View file

@ -0,0 +1,179 @@
import ldap
from pprint import pprint
l = ldap.initialize("ldap://ldap")
l.simple_bind_s("cn=admin,dc=mydomain,dc=tld", "admin")
class LDAPObjectHelper:
_object_class_by_name = None
may = None
must = None
def __init__(self, dn=None, **kwargs):
self.dn = dn
self.attrs = {}
for k, v in kwargs.items():
self.attrs[k] = [v] if not isinstance(v, list) else v
self.attrs.setdefault("objectClass", self.objectClass)
by_name = self.ocs_by_name()
ocs = [by_name[name] for name in self.objectClass]
self.may = []
self.must = []
for oc in ocs:
self.may.extend(oc.may)
self.must.extend(oc.must)
@classmethod
def ocs_by_name(cls):
if cls._object_class_by_name:
return cls._object_class_by_name
res = l.search_s("cn=subschema", ldap.SCOPE_BASE, "(objectclass=*)", ["*", "+"])
subschema_entry = res[0]
subschema_subentry = ldap.cidict.cidict(subschema_entry[1])
subschema = ldap.schema.SubSchema(subschema_subentry)
object_class_oids = subschema.listall(ldap.schema.models.ObjectClass)
cls._object_class_by_name = {}
for oid in object_class_oids:
oc = subschema.get_obj(ldap.schema.models.ObjectClass, oid)
for name in oc.names:
cls._object_class_by_name[name] = oc
return cls._object_class_by_name
def save(self):
try:
match = bool(l.search_s(self.dn, ldap.SCOPE_SUBTREE))
except ldap.NO_SUCH_OBJECT:
match = False
if match:
attributes = [
(ldap.MOD_REPLACE, k, [elt.encode("utf-8") for elt in v])
for k, v in self.attrs.items()
]
l.modify_s(self.dn, attributes)
else:
attributes = [
(k, [elt.encode("utf-8") for elt in v]) for k, v in self.attrs.items()
]
l.add_s(self.dn, attributes)
@classmethod
def get(cls, dn):
result = l.search_s(dn, ldap.SCOPE_SUBTREE)
if not result:
return None
o = cls(
dn=dn,
**{k: [elt.decode("utf-8") for elt in v] for k, v in result[0][1].items()},
)
return o
@classmethod
def filter(cls, base=None, **kwargs):
class_filter = "".join([f"(objectClass={oc})" for oc in cls.objectClass])
arg_filter = "".join(f"({k}={v})" for k, v in kwargs.items())
filter = f"(&{class_filter}{arg_filter})"
result = l.search_s(base or cls.base, ldap.SCOPE_SUBTREE, filter)
return [
cls(
dn=dn,
**{k: [elt.decode("utf-8") for elt in v] for k, v in args.items()},
)
for dn, args in result
]
def __getattr__(self, name):
if (self.may and name in self.may) or (self.must and name in self.must):
return self.attrs.get(name, [])
return super().__getattr__(name)
def __setattr__(self, name, value):
super().__setattr__(name, value)
if not isinstance(value, list):
value = [value]
if (self.may and name in self.may) or (self.must and name in self.must):
self.attrs[name] = value
class Client(LDAPObjectHelper):
objectClass = ["oauthClientIdentity", "oauthClientMetadataAux"]
base = "ou=clients,dc=mydomain,dc=tld"
def get_client_id(self):
return self.oauthClientID[0]
def get_default_redirect_uri(self):
return self.oauthRedirectURI[0]
def get_allowed_scope(self, scope):
return self.oauthScopeValue[0]
def check_redirect_uri(self, redirect_uri):
return redirect_uri in self.oauthRedirectURI
def has_client_secret(self):
return self.oauthClientSecret and self.oauthClientSecret[0]
def check_client_secret(self, client_secret):
return client_secret == self.oauthClientSecret[0]
def check_token_endpoint_auth_method(self, method):
return method == self.oauthTokenEndpointAuthMethod[0]
def check_response_type(self, response_type):
return response_type in self.oauthResponseType
def check_grant_type(self, grant_type):
return grant_type in self.oauthGrantType
class User(LDAPObjectHelper):
objectClass = ["person"]
base = "ou=users,dc=mydomain,dc=tld"
class Authorization(LDAPObjectHelper):
objectClass = ["oauth2Authz"]
base = "ou=authorizations,dc=mydomain,dc=tld"
def get_redirect_uri(self):
return Client.get(self.authzClientID[0]).oauthRedirectURI[0]
def get_scope(self):
return self.oauth2ScopeValue[0]
class Token(LDAPObjectHelper):
objectClass = ["oauth2IdAccessToken"]
base = "ou=tokens,dc=mydomain,dc=tld"
def get_client_id(self):
return self.authzClientID[0]
def get_scope(self):
return self.authzScopeValue[0]
def get_expires_in(self):
return self.authzAccessTokenLifetime[0]
def get_expires_at(self):
return self.authzAccessTokenIssueDate[0] + self.authzAccessTokenLifetime[0]
# u = User.get("cn=John Doe,ou=users,dc=mydomain,dc=tld")
# print(u.attrs)
# u = User.get("cn=Jane Doe,ou=users,dc=mydomain,dc=tld")
# print(u.attrs)
users = Client.filter()
pprint([u.attrs for u in users])

0
website/__init__.py Normal file
View file

49
website/app.py Normal file
View file

@ -0,0 +1,49 @@
import ldap
import os
from flask import Flask, g
#from .models import db
from .oauth2 import config_oauth
from .routes import bp
def create_app(config=None):
app = Flask(__name__)
# load default configuration
app.config.from_object("website.settings")
# load environment configuration
if "WEBSITE_CONF" in os.environ:
app.config.from_envvar("WEBSITE_CONF")
# load app specified configuration
if config is not None:
if isinstance(config, dict):
app.config.update(config)
elif config.endswith(".py"):
app.config.from_pyfile(config)
setup_app(app)
return app
def setup_app(app):
@app.before_request
def before_request():
g.ldap = ldap.initialize("ldap://ldap")
g.ldap.simple_bind_s("cn=admin,dc=mydomain,dc=tld", "admin")
@app.after_request
def after_request(response):
if "ldap" in g:
g.ldap.unbind_s()
return response
# # Create tables if they do not exist already
# @app.before_first_request
# def create_tables():
# db.create_all()
#
# db.init_app(app)
config_oauth(app)
app.register_blueprint(bp, url_prefix="")

403
website/models.py Normal file
View file

@ -0,0 +1,403 @@
import ldap
import datetime
from flask import g
from authlib.common.encoding import json_loads, json_dumps
from authlib.oauth2.rfc6749.util import scope_to_list, list_to_scope
from authlib.oauth2.rfc6749 import (
ClientMixin,
TokenMixin,
AuthorizationCodeMixin,
)
#class OAuth2Client(db.Model, ClientMixin):
# __tablename__ = 'oauth2_client'
#
# id = db.Column(db.Integer, primary_key=True)
# user_id = db.Column(
# db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'))
# user = db.relationship('User')
#
# client_id = db.Column(db.String(48), index=True)
# client_secret = db.Column(db.String(120))
# client_id_issued_at = db.Column(db.Integer, nullable=False, default=0)
# client_secret_expires_at = db.Column(db.Integer, nullable=False, default=0)
# _client_metadata = db.Column('client_metadata', db.Text)
#
# @property
# def client_info(self):
# """Implementation for Client Info in OAuth 2.0 Dynamic Client
# Registration Protocol via `Section 3.2.1`_.
# .. _`Section 3.2.1`: https://tools.ietf.org/html/rfc7591#section-3.2.1
# """
# return dict(
# client_id=self.client_id,
# client_secret=self.client_secret,
# client_id_issued_at=self.client_id_issued_at,
# client_secret_expires_at=self.client_secret_expires_at,
# )
#
# @property
# def client_metadata(self):
# if 'client_metadata' in self.__dict__:
# return self.__dict__['client_metadata']
# if self._client_metadata:
# data = json_loads(self._client_metadata)
# self.__dict__['client_metadata'] = data
# return data
# return {}
#
# def set_client_metadata(self, value):
# self._client_metadata = json_dumps(value)
#
# @property
# def redirect_uris(self):
# return self.client_metadata.get('redirect_uris', [])
#
# @property
# def token_endpoint_auth_method(self):
# return self.client_metadata.get(
# 'token_endpoint_auth_method',
# 'client_secret_basic'
# )
#
# @property
# def grant_types(self):
# return self.client_metadata.get('grant_types', [])
#
# @property
# def response_types(self):
# return self.client_metadata.get('response_types', [])
#
# @property
# def client_name(self):
# return self.client_metadata.get('client_name')
#
# @property
# def client_uri(self):
# return self.client_metadata.get('client_uri')
#
# @property
# def logo_uri(self):
# return self.client_metadata.get('logo_uri')
#
# @property
# def scope(self):
# return self.client_metadata.get('scope', '')
#
# @property
# def contacts(self):
# return self.client_metadata.get('contacts', [])
#
# @property
# def tos_uri(self):
# return self.client_metadata.get('tos_uri')
#
# @property
# def policy_uri(self):
# return self.client_metadata.get('policy_uri')
#
# @property
# def jwks_uri(self):
# return self.client_metadata.get('jwks_uri')
#
# @property
# def jwks(self):
# return self.client_metadata.get('jwks', [])
#
# @property
# def software_id(self):
# return self.client_metadata.get('software_id')
#
# @property
# def software_version(self):
# return self.client_metadata.get('software_version')
#
# def get_client_id(self):
# return self.client_id
#
# def get_default_redirect_uri(self):
# if self.redirect_uris:
# return self.redirect_uris[0]
#
# def get_allowed_scope(self, scope):
# if not scope:
# return ''
# allowed = set(self.scope.split())
# scopes = scope_to_list(scope)
# return list_to_scope([s for s in scopes if s in allowed])
#
# def check_redirect_uri(self, redirect_uri):
# return redirect_uri in self.redirect_uris
#
# def has_client_secret(self):
# return bool(self.client_secret)
#
# def check_client_secret(self, client_secret):
# return self.client_secret == client_secret
#
# def check_token_endpoint_auth_method(self, method):
# return self.token_endpoint_auth_method == method
#
# def check_response_type(self, response_type):
# return response_type in self.response_types
#
# def check_grant_type(self, grant_type):
# return grant_type in self.grant_types
#
#
#class OAuth2AuthorizationCode(db.Model, AuthorizationCodeMixin):
# __tablename__ = 'oauth2_code'
#
# id = db.Column(db.Integer, primary_key=True)
# user_id = db.Column(
# db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'))
# user = db.relationship('User')
#
# code = db.Column(db.String(120), unique=True, nullable=False)
# client_id = db.Column(db.String(48))
# redirect_uri = db.Column(db.Text, default='')
# response_type = db.Column(db.Text, default='')
# scope = db.Column(db.Text, default='')
# nonce = db.Column(db.Text)
# auth_time = db.Column(
# db.Integer, nullable=False,
# default=lambda: int(time.time())
# )
#
# code_challenge = db.Column(db.Text)
# code_challenge_method = db.Column(db.String(48))
#
# def is_expired(self):
# return self.auth_time + 300 < time.time()
#
# def get_redirect_uri(self):
# return self.redirect_uri
#
# def get_scope(self):
# return self.scope
#
# def get_auth_time(self):
# return self.auth_time
#
# def get_nonce(self):
# return self.nonce
#
#
#class OAuth2Token(db.Model, TokenMixin):
# __tablename__ = 'oauth2_token'
#
# id = db.Column(db.Integer, primary_key=True)
# user_id = db.Column(
# db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'))
# user = db.relationship('User')
#
# client_id = db.Column(db.String(48))
# token_type = db.Column(db.String(40))
# access_token = db.Column(db.String(255), unique=True, nullable=False)
# refresh_token = db.Column(db.String(255), index=True)
# scope = db.Column(db.Text, default='')
# revoked = db.Column(db.Boolean, default=False)
# issued_at = db.Column(
# db.Integer, nullable=False, default=lambda: int(time.time())
# )
# expires_in = db.Column(db.Integer, nullable=False, default=0)
#
# def is_refresh_token_active(self):
# if self.revoked:
# return False
# expires_at = self.issued_at + self.expires_in * 2
# return expires_at >= time.time()
#
# def get_client_id(self):
# return self.client_id
#
# def get_scope(self):
# return self.scope
#
# def get_expires_in(self):
# return self.expires_in
#
# def get_expires_at(self):
# return self.issued_at + self.expires_in
class LDAPObjectHelper:
_object_class_by_name = None
may = None
must = None
base = None
id = None
#TODO If ldap attribute is SINGLE-VALUE, do not bother with lists
def __init__(self, dn=None, **kwargs):
self.attrs = {}
for k, v in kwargs.items():
self.attrs[k] = [v] if not isinstance(v, list) else v
self.attrs.setdefault("objectClass", self.objectClass)
by_name = self.ocs_by_name()
ocs = [by_name[name] for name in self.objectClass]
self.may = []
self.must = []
for oc in ocs:
self.may.extend(oc.may)
self.must.extend(oc.must)
@property
def dn(self):
if not self.id in self.attrs:
return None
return f"{self.id}={self.attrs[self.id][0]},{self.base}"
@classmethod
def ocs_by_name(cls):
if cls._object_class_by_name:
return cls._object_class_by_name
res = g.ldap.search_s("cn=subschema", ldap.SCOPE_BASE, "(objectclass=*)", ["*", "+"])
subschema_entry = res[0]
subschema_subentry = ldap.cidict.cidict(subschema_entry[1])
subschema = ldap.schema.SubSchema(subschema_subentry)
object_class_oids = subschema.listall(ldap.schema.models.ObjectClass)
cls._object_class_by_name = {}
for oid in object_class_oids:
oc = subschema.get_obj(ldap.schema.models.ObjectClass, oid)
for name in oc.names:
cls._object_class_by_name[name] = oc
return cls._object_class_by_name
def save(self):
try:
match = bool(g.ldap.search_s(self.dn, ldap.SCOPE_SUBTREE))
except ldap.NO_SUCH_OBJECT:
match = False
if match:
attributes = [
(ldap.MOD_REPLACE, k, [elt.encode("utf-8") for elt in v])
for k, v in self.attrs.items()
]
g.ldap.modify_s(self.dn, attributes)
else:
attributes = [
(k, [elt.encode("utf-8") for elt in v]) for k, v in self.attrs.items()
]
g.ldap.add_s(self.dn, attributes)
@classmethod
def get(cls, dn):
if "=" not in dn:
dn = f"{cls.id}={dn},{cls.base}"
result = g.ldap.search_s(dn, ldap.SCOPE_SUBTREE)
if not result:
return None
o = cls(
**{k: [elt.decode("utf-8") for elt in v] for k, v in result[0][1].items()}
)
return o
@classmethod
def filter(cls, base=None, **kwargs):
class_filter = "".join([f"(objectClass={oc})" for oc in cls.objectClass])
arg_filter = "".join(f"({k}={v})" for k, v in kwargs.items())
ldapfilter = f"(&{class_filter}{arg_filter})"
result = g.ldap.search_s(base or cls.base, ldap.SCOPE_SUBTREE, ldapfilter)
return [
cls(
**{k: [elt.decode("utf-8") for elt in v] for k, v in args.items()},
)
for _, args in result
]
def __getattr__(self, name):
if (self.may and name in self.may) or (self.must and name in self.must):
return self.attrs.get(name, [])
return super().__getattribute__(name)
def __setattr__(self, name, value):
super().__setattr__(name, value)
if not isinstance(value, list):
value = [value]
if (self.may and name in self.may) or (self.must and name in self.must):
self.attrs[name] = value
class User(LDAPObjectHelper):
objectClass = ["person"]
base = "ou=users,dc=mydomain,dc=tld"
id = "cn"
def __repr__(self):
return self.cn[0]
def check_password(self, password):
return password == "valid"
class Client(LDAPObjectHelper, ClientMixin):
objectClass = ["oauthClientIdentity"]
base = "ou=clients,dc=mydomain,dc=tld"
id = "oauthClientID"
def get_client_id(self):
return self.oauthClientID[0]
def get_default_redirect_uri(self):
return self.oauthRedirectURI[0]
def get_allowed_scope(self, scope):
return self.oauthScopeValue[0]
def check_redirect_uri(self, redirect_uri):
return redirect_uri in self.oauthRedirectURI
def has_client_secret(self):
return self.oauthClientSecret and self.oauthClientSecret[0]
def check_client_secret(self, client_secret):
return client_secret == self.oauthClientSecret[0]
def check_token_endpoint_auth_method(self, method):
return method == self.oauthTokenEndpointAuthMethod[0]
def check_response_type(self, response_type):
return response_type in self.oauthResponseType
def check_grant_type(self, grant_type):
return grant_type in self.oauthGrantType
class AuthorizationCode(LDAPObjectHelper, AuthorizationCodeMixin):
objectClass = ["oauth2Authz"]
base = "ou=authorizations,dc=mydomain,dc=tld"
def get_redirect_uri(self):
return Client.get(self.authzClientID[0]).oauthRedirectURI[0]
def get_scope(self):
return self.oauth2ScopeValue[0]
class Token(LDAPObjectHelper, TokenMixin):
objectClass = ["oauth2IdAccessToken", "oauth2AuthzAux"]
base = "ou=tokens,dc=mydomain,dc=tld"
id = "authzAccessToken"
def get_client_id(self):
return self.authzClientID[0]
def get_scope(self):
return self.authzScopeValue[0]
def get_expires_in(self):
return int(self.authzAccessTokenLifetime[0])
def get_expires_at(self):
issue_date = datetime.datetime.strptime(self.authzAccessTokenIssueDate[0], "%Y%m%d%H%M%SZ")
issue_timestamp = (issue_date - datetime.datetime(1970, 1, 1)).total_seconds()
return issue_timestamp + int(self.authzAccessTokenLifetime[0])

133
website/oauth2.py Normal file
View file

@ -0,0 +1,133 @@
import datetime
from authlib.integrations.flask_oauth2 import (
AuthorizationServer,
ResourceProtector,
)
from authlib.oauth2.rfc6749 import grants, util
from authlib.oauth2.rfc6750 import BearerTokenValidator
from authlib.oauth2.rfc7009 import RevocationEndpoint
from authlib.oauth2.rfc7636 import CodeChallenge
from .models import User, Client, Authorization, Token
class AuthorizationCodeGrant(grants.AuthorizationCodeGrant):
TOKEN_ENDPOINT_AUTH_METHODS = [
"client_secret_basic",
"client_secret_post",
"none",
]
def save_authorization_code(self, code, request):
raise NotImplementedError()
code_challenge = request.data.get("code_challenge")
code_challenge_method = request.data.get("code_challenge_method")
auth_code = Authorization(
code=code,
client_id=request.client.client_id,
redirect_uri=request.redirect_uri,
scope=request.scope,
user_id=request.user.id,
code_challenge=code_challenge,
code_challenge_method=code_challenge_method,
)
#db.session.add(auth_code)
#db.session.commit()
return auth_code
def query_authorization_code(self, code, client):
raise NotImplementedError()
auth_code = Authorization.query.filter_by(
code=code, client_id=client.client_id
).first()
if auth_code and not auth_code.is_expired():
return auth_code
def delete_authorization_code(self, authorization_code):
raise NotImplementedError()
pass
#db.session.delete(authorization_code)
#db.session.commit()
def authenticate_user(self, authorization_code):
raise NotImplementedError()
return User.query.get(authorization_code.user_id)
class PasswordGrant(grants.ResourceOwnerPasswordCredentialsGrant):
def authenticate_user(self, username, password):
user = User.get(username)
if user is not None and user.check_password(password):
return user
class RefreshTokenGrant(grants.RefreshTokenGrant):
def authenticate_refresh_token(self, refresh_token):
raise NotImplementedError()
token = Token.query.filter_by(refresh_token=refresh_token).first()
if token and token.is_refresh_token_active():
return token
def authenticate_user(self, credential):
raise NotImplementedError()
return User.query.get(credential.user_id)
def revoke_old_credential(self, credential):
raise NotImplementedError()
credential.revoked = True
#db.session.add(credential)
#db.session.commit()
def query_client(client_id):
return Client.get(client_id)
def save_token(token, request):
client_id, client_secret = util.extract_basic_authorization(request.headers)
t = Token(
authzAccessToken=token['access_token'],
authzScopeValue=token['scope'],
authzAccessTokenIssueDate=datetime.datetime.now().strftime("%Y%m%d%H%M%SZ"),
authzSubject=request.user.dn,
authzClientID=client_id,
authzRefreshTokenSecret=token['refresh_token'],
authzAccessTokenLifetime=str(token['expires_in']),
# ??? = token['type']
)
t.save()
return t
class RevocationEndpoint(RevocationEndpoint):
def query_token(self, token, token_type_hint, client):
raise NotImplementedError()
def revoke_token(self, token):
raise NotImplementedError()
class BearerTokenValidator(BearerTokenValidator):
def authenticate_token(self, token_string):
return Token.get(token_string)
def request_invalid(self, request):
return False
def token_revoked(self, token):
return False
authorization = AuthorizationServer(query_client=query_client, save_token=save_token)
require_oauth = ResourceProtector()
def config_oauth(app):
authorization.init_app(app)
# support all grants
authorization.register_grant(grants.ImplicitGrant)
authorization.register_grant(grants.ClientCredentialsGrant)
authorization.register_grant(AuthorizationCodeGrant, [CodeChallenge(required=True)])
authorization.register_grant(PasswordGrant)
authorization.register_grant(RefreshTokenGrant)
authorization.register_endpoint(RevocationEndpoint)
require_oauth.register_token_validator(BearerTokenValidator())

120
website/routes.py Normal file
View file

@ -0,0 +1,120 @@
import datetime
from flask import Blueprint, request, session, url_for
from flask import render_template, redirect, jsonify
from werkzeug.security import gen_salt
from authlib.integrations.flask_oauth2 import current_token
from authlib.oauth2 import OAuth2Error
from .models import User, Client
from .oauth2 import authorization, require_oauth
bp = Blueprint(__name__, 'home')
def current_user():
if 'user_dn' in session:
return User.get(session['user_dn'])
return None
def split_by_crlf(s):
return [v for v in s.splitlines() if v]
@bp.route('/', methods=('GET', 'POST'))
def home():
if request.method == 'POST':
username = request.form.get('username')
user = User.filter(cn=username)
if not user:
user = User(cn=username, sn=username)
user.save()
else:
user = user[0]
session["user_dn"] = user.dn
return redirect('/')
user = current_user()
clients = Client.filter()
return render_template('home.html', user=user, clients=clients)
@bp.route('/logout')
def logout():
del session['id']
return redirect('/')
@bp.route('/create_client', methods=('GET', 'POST'))
def create_client():
user = current_user()
if not user:
return redirect('/')
if request.method == 'GET':
return render_template('create_client.html')
form = request.form
client_id = gen_salt(24)
client_id_issued_at = datetime.datetime.now().strftime("%Y%m%d%H%M%SZ")
client = Client(
oauthClientID=client_id,
oauthClientIDIssueTime=client_id_issued_at,
oauthClientName=form["client_name"],
oauthClientURI=form["client_uri"],
oauthGrantType=split_by_crlf(form["grant_type"]),
oauthRedirectURI=split_by_crlf(form["redirect_uri"]),
oauthResponseType=split_by_crlf(form["response_type"]),
oauthScopeValue=form["scope"],
oauthTokenEndpointAuthMethod=form["token_endpoint_auth_method"]
)
if form['token_endpoint_auth_method'] == 'none':
client.oauthClientSecret = ''
else:
client.oauthClientSecret = gen_salt(48)
client.save()
#db.session.add(client)
#db.session.commit()
return redirect('/')
@bp.route('/oauth/authorize', methods=['GET', 'POST'])
def authorize():
user = current_user()
# if user log status is not true (Auth server), then to log it in
if not user:
return redirect(url_for('website.routes.home', next=request.url))
if request.method == 'GET':
try:
grant = authorization.validate_consent_request(end_user=user)
except OAuth2Error as error:
return error.error
return render_template('authorize.html', user=user, grant=grant)
if not user and 'username' in request.form:
username = request.form.get('username')
user = User.query.filter_by(username=username).first()
if request.form['confirm']:
grant_user = user
else:
grant_user = None
return authorization.create_authorization_response(grant_user=grant_user)
@bp.route('/oauth/token', methods=['POST'])
def issue_token():
return authorization.create_token_response()
@bp.route('/oauth/revoke', methods=['POST'])
def revoke_token():
return authorization.create_endpoint_response('revocation')
@bp.route('/api/me')
@require_oauth('profile')
def api_me():
user_dn = current_token.authzSubject[0]
user = User.get(user_dn)
return jsonify(id=user.cn, name=user.sn)

0
website/settings.py Normal file
View file

View file

@ -0,0 +1,22 @@
<p>The application <strong>{{grant.client.client_name}}</strong> is requesting:
<strong>{{ grant.request.scope }}</strong>
</p>
<p>
from You - a.k.a. <strong>{{ user.username }}</strong>
</p>
<form action="" method="post">
<label>
<input type="checkbox" name="confirm">
<span>Consent?</span>
</label>
{% if not user %}
<p>You haven't logged in. Log in with:</p>
<div>
<input type="text" name="username">
</div>
{% endif %}
<br>
<button>Submit</button>
</form>

View file

@ -0,0 +1,42 @@
<style>
label, label > span { display: block; }
label { margin: 15px 0; }
</style>
<a href="/">Home</a>
<form action="" method="post">
<label>
<span>Client Name</span>
<input type="text" name="client_name">
</label>
<label>
<span>Client URI</span>
<input type="url" name="client_uri">
</label>
<label>
<span>Allowed Scope</span>
<input type="text" name="scope">
</label>
<label>
<span>Redirect URIs</span>
<textarea name="redirect_uri" cols="30" rows="10"></textarea>
</label>
<label>
<span>Allowed Grant Types</span>
<textarea name="grant_type" cols="30" rows="10"></textarea>
</label>
<label>
<span>Allowed Response Types</span>
<textarea name="response_type" cols="30" rows="10"></textarea>
</label>
<label>
<span>Token Endpoint Auth Method</span>
<select name="token_endpoint_auth_method">
<option value="client_secret_basic">client_secret_basic</option>
<option value="client_secret_post">client_secret_post</option>
<option value="none">none</option>
</select>
</label>
<button>Submit</button>
</form>

View file

@ -0,0 +1,34 @@
{% if user %}
<style>pre{white-space:wrap}</style>
<div>Logged in as <strong>{{user}}</strong> (<a href="{{ url_for('.logout') }}">Log Out</a>)</div>
{% for client in clients %}
<strong>Client</strong>
<ul>
{%- for key in client.must -%}
{%- if key in client.attrs and client.attrs[key] -%}
{%- for value in client.attrs[key] -%}
<li><strong>{{ key }}: </strong>{{ value }}</li>
{%- endfor -%}
{%- endif -%}
{%- endfor -%}
{%- for key in client.may -%}
{%- if key in client.attrs and client.attrs[key] -%}
{%- for value in client.attrs[key] -%}
<li><strong>{{ key }}: </strong>{{ value }}</li>
{%- endfor -%}
{%- endif -%}
{%- endfor -%}
</ul>
<hr>
{% endfor %}
<br><a href="{{ url_for('.create_client') }}">Create Client</a>
{% else %}
<form action="" method="post">
<input type="text" name="username" placeholder="username">
<button type="submit">Login / Signup</button>
</form>
{% endif %}