init commit
This commit is contained in:
commit
dfd166bd3b
55 changed files with 18538 additions and 0 deletions
0
lenticular_cloud/__init__.py
Normal file
0
lenticular_cloud/__init__.py
Normal file
173
lenticular_cloud/app.py
Normal file
173
lenticular_cloud/app.py
Normal file
|
@ -0,0 +1,173 @@
|
|||
from flask.app import Flask
|
||||
from flask import g
|
||||
from flask.helpers import url_for
|
||||
from jwkest.jwk import RSAKey, rsa_load
|
||||
from flask_babel import Babel
|
||||
from flask_login import LoginManager
|
||||
import time
|
||||
|
||||
from pyop.authz_state import AuthorizationState
|
||||
from pyop.provider import Provider
|
||||
from pyop.subject_identifier import HashBasedSubjectIdentifierFactory
|
||||
from pyop.userinfo import Userinfo as _Userinfo
|
||||
from ldap3 import Connection, Server, ALL
|
||||
|
||||
from . import model
|
||||
from .pki import Pki
|
||||
|
||||
|
||||
def init_oidc_provider(app):
|
||||
with app.app_context():
|
||||
issuer = url_for('frontend.index')[:-1]
|
||||
authentication_endpoint = url_for('oidc_provider.authentication_endpoint')
|
||||
jwks_uri = url_for('oidc_provider.jwks_uri')
|
||||
token_endpoint = url_for('oidc_provider.token_endpoint')
|
||||
userinfo_endpoint = url_for('oidc_provider.userinfo_endpoint')
|
||||
registration_endpoint = url_for('oidc_provider.registration_endpoint')
|
||||
end_session_endpoint = url_for('auth.logout')
|
||||
|
||||
configuration_information = {
|
||||
'issuer': issuer,
|
||||
'authorization_endpoint': authentication_endpoint,
|
||||
'jwks_uri': jwks_uri,
|
||||
'token_endpoint': token_endpoint,
|
||||
'userinfo_endpoint': userinfo_endpoint,
|
||||
'registration_endpoint': registration_endpoint,
|
||||
'end_session_endpoint': end_session_endpoint,
|
||||
'scopes_supported': ['openid', 'profile'],
|
||||
'response_types_supported': ['code', 'code id_token', 'code token', 'code id_token token'], # code and hybrid
|
||||
'response_modes_supported': ['query', 'fragment'],
|
||||
'grant_types_supported': ['authorization_code', 'implicit'],
|
||||
'subject_types_supported': ['pairwise'],
|
||||
'token_endpoint_auth_methods_supported': ['client_secret_basic', 'client_secret_post'],
|
||||
'claims_parameter_supported': True
|
||||
}
|
||||
|
||||
from .model_db import db, Client, AuthzCode, AccessToken, RefreshToken, SubjectIdentifier
|
||||
from .model import User
|
||||
import json
|
||||
db.init_app(app)
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
class SqlAlchemyWrapper(object):
|
||||
def __init__(self, cls):
|
||||
self._cls = cls
|
||||
pass
|
||||
|
||||
def __getitem__(self, item):
|
||||
o = self._cls.query.get(item)
|
||||
if o is not None:
|
||||
return json.loads(o.value)
|
||||
else:
|
||||
raise KeyError()
|
||||
|
||||
def __setitem__(self, item, value):
|
||||
o = self._cls.query.get(item)
|
||||
if o is None:
|
||||
o = self._cls(key=item)
|
||||
db.session.add(o)
|
||||
o.value = json.dumps(value)
|
||||
db.session.commit()
|
||||
|
||||
def items(self):
|
||||
aa = self._cls.query.all()
|
||||
return [(a.key, json.loads(a.value)) for a in aa]
|
||||
|
||||
def __contains__(self, item):
|
||||
return self._cls.query.get(item) is not None
|
||||
|
||||
class Userinfo(_Userinfo):
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def __getitem__(self, item):
|
||||
return User.query().by_username(item)
|
||||
|
||||
def __contains__(self, item):
|
||||
return User.query().by_username(item) is not None
|
||||
|
||||
def get_claims_for(self, user_id, requested_claims):
|
||||
user = self[user_id]
|
||||
print(f'user {user.username}')
|
||||
claims = {}
|
||||
for claim in requested_claims:
|
||||
if claim == 'name':
|
||||
claims[claim] = str(user.username)
|
||||
elif claim == 'email':
|
||||
claims[claim] = str(user.mail)
|
||||
elif claim == 'email_verified':
|
||||
claims[claim] = True
|
||||
else:
|
||||
print(f'claim not found {claim}')
|
||||
return claims
|
||||
|
||||
client_db = SqlAlchemyWrapper(Client)
|
||||
|
||||
userinfo_db = Userinfo()
|
||||
signing_key = RSAKey(key=rsa_load('signing_key.pem'), alg='RS256')
|
||||
provider = Provider(
|
||||
signing_key,
|
||||
configuration_information,
|
||||
AuthorizationState(
|
||||
HashBasedSubjectIdentifierFactory(app.config['SUBJECT_ID_HASH_SALT']),
|
||||
SqlAlchemyWrapper(AuthzCode),
|
||||
SqlAlchemyWrapper(AccessToken),
|
||||
SqlAlchemyWrapper(RefreshToken),
|
||||
SqlAlchemyWrapper(SubjectIdentifier)
|
||||
),
|
||||
client_db,
|
||||
userinfo_db)
|
||||
|
||||
return provider
|
||||
|
||||
def oidc_provider_init_app(name=None):
|
||||
name = name or __name__
|
||||
app = Flask(name)
|
||||
app.config.from_pyfile('application.cfg')
|
||||
app.config.from_pyfile('production.cfg')
|
||||
|
||||
#app.ldap_orm = Connection(app.config['LDAP_URL'], app.config['LDAP_BIND_DN'], app.config['LDAP_BIND_PW'], auto_bind=True)
|
||||
server = Server(app.config['LDAP_URL'], get_info=ALL)
|
||||
app.ldap_conn = Connection(server, app.config['LDAP_BIND_DN'], app.config['LDAP_BIND_PW'], auto_bind=True)
|
||||
model.ldap_conn = app.ldap_conn
|
||||
model.base_dn = app.config['LDAP_BASE_DN']
|
||||
|
||||
app.babel = Babel(app)
|
||||
app.login_manager = LoginManager(app)
|
||||
init_login_manager(app)
|
||||
|
||||
from .views import oidc_provider_views, auth_views, frontend_views
|
||||
app.register_blueprint(oidc_provider_views)
|
||||
app.register_blueprint(auth_views)
|
||||
app.register_blueprint(frontend_views)
|
||||
|
||||
@app.before_request
|
||||
def befor_request():
|
||||
request_start_time = time.time()
|
||||
g.request_time = lambda: "%.5fs" % (time.time() - request_start_time)
|
||||
|
||||
# Initialize the oidc_provider after views to be able to set correct urls
|
||||
app.provider = init_oidc_provider(app)
|
||||
|
||||
from .translations import init_babel
|
||||
|
||||
init_babel(app)
|
||||
|
||||
app.lenticular_services = {}
|
||||
for service_name, service_config in app.config['LENTICULAR_CLOUD_SERVICES'].items():
|
||||
app.lenticular_services[service_name] = model.Service.from_config(service_name, service_config)
|
||||
|
||||
app.pki = Pki(app.config['PKI_PATH'], app.config['DOMAIN'])
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def init_login_manager(app):
|
||||
@app.login_manager.user_loader
|
||||
def user_loader(username):
|
||||
return model.User.query().by_username(username)
|
||||
|
||||
@app.login_manager.request_loader
|
||||
def request_loader(request):
|
||||
pass
|
76
lenticular_cloud/auth_providers.py
Normal file
76
lenticular_cloud/auth_providers.py
Normal file
|
@ -0,0 +1,76 @@
|
|||
from flask import current_app
|
||||
from .form.login import PasswordForm, TotpForm, Fido2Form
|
||||
from ldap3 import Server, Connection
|
||||
from ldap3.core.exceptions import LDAPException
|
||||
|
||||
import pyotp
|
||||
|
||||
class AuthProvider:
|
||||
|
||||
@classmethod
|
||||
def get_name(csl):
|
||||
return csl.__name__
|
||||
|
||||
@staticmethod
|
||||
def get_form():
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def check_auth(user, form) -> bool:
|
||||
'''
|
||||
checks the submited form is valid
|
||||
return true if user is allowed to auth
|
||||
'''
|
||||
return False
|
||||
|
||||
|
||||
class LdapAuthProvider(AuthProvider):
|
||||
|
||||
@staticmethod
|
||||
def get_form():
|
||||
return PasswordForm(prefix='password')
|
||||
|
||||
@staticmethod
|
||||
def check_auth(user, form):
|
||||
server = Server(current_app.config['LDAP_URL'])
|
||||
ldap_conn = Connection(server, user.entry_dn, form.data['password'])
|
||||
try:
|
||||
return ldap_conn.bind()
|
||||
except LDAPException:
|
||||
return False
|
||||
|
||||
|
||||
class U2FAuthProvider(AuthProvider):
|
||||
@staticmethod
|
||||
def get_from():
|
||||
return Fido2Form(prefix='fido2')
|
||||
|
||||
|
||||
class WebAuthProvider(AuthProvider):
|
||||
pass
|
||||
|
||||
|
||||
class TotpAuthProvider(AuthProvider):
|
||||
|
||||
@staticmethod
|
||||
def get_form():
|
||||
return TotpForm(prefix='totp')
|
||||
|
||||
@staticmethod
|
||||
def check_auth(user, form):
|
||||
data = form.data['totp']
|
||||
if data is not None:
|
||||
print(f'data totp: {data}')
|
||||
for totp in user.totps:
|
||||
if pyotp.TOTP(totp).verify(data):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
AUTH_PROVIDER_LIST = [
|
||||
LdapAuthProvider,
|
||||
TotpAuthProvider
|
||||
]
|
||||
|
||||
print(LdapAuthProvider.get_name())
|
||||
|
0
lenticular_cloud/form/__init__.py
Normal file
0
lenticular_cloud/form/__init__.py
Normal file
24
lenticular_cloud/form/frontend.py
Normal file
24
lenticular_cloud/form/frontend.py
Normal file
|
@ -0,0 +1,24 @@
|
|||
from flask_babel import gettext
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, SubmitField, TextField, \
|
||||
TextAreaField, PasswordField, IntegerField, FloatField, \
|
||||
DateTimeField, DateField, FormField, BooleanField, \
|
||||
SelectField, Form as NoCsrfForm
|
||||
from wtforms.widgets.html5 import NumberInput, DateInput
|
||||
from wtforms.validators import DataRequired, NumberRange, Optional, NoneOf, Length
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
|
||||
class ClientCertForm(FlaskForm):
|
||||
publickey = TextAreaField(gettext('Public Key'), validators=[
|
||||
DataRequired()
|
||||
])
|
||||
valid_time = IntegerField(
|
||||
gettext('valid time in days'),
|
||||
default=365,
|
||||
validators=[
|
||||
DataRequired(),
|
||||
NumberRange(min=1, max=365*2)
|
||||
])
|
||||
submit = SubmitField(gettext('Submit'))
|
29
lenticular_cloud/form/login.py
Normal file
29
lenticular_cloud/form/login.py
Normal file
|
@ -0,0 +1,29 @@
|
|||
from flask_babel import gettext
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, SubmitField, TextField, \
|
||||
TextAreaField, PasswordField, IntegerField, FloatField, \
|
||||
DateTimeField, DateField, FormField, BooleanField, \
|
||||
SelectField, Form as NoCsrfForm
|
||||
from wtforms.widgets.html5 import NumberInput, DateInput
|
||||
from wtforms.validators import DataRequired, NumberRange, Optional, NoneOf, Length
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
name = StringField(gettext('User Name'), validators=[DataRequired()])
|
||||
submit = SubmitField(gettext('Login'))
|
||||
|
||||
|
||||
class PasswordForm(FlaskForm):
|
||||
password = PasswordField(gettext('Password'))
|
||||
submit = SubmitField(gettext('Authorize'))
|
||||
|
||||
|
||||
class TotpForm(FlaskForm):
|
||||
totp = TextField(gettext('2FA Token'))
|
||||
submit = SubmitField(gettext('Authorize'))
|
||||
|
||||
|
||||
class Fido2Form(FlaskForm):
|
||||
fido2 = TextField(gettext('Fido2'), default="Javascript Required")
|
||||
submit = SubmitField(gettext('Authorize'))
|
239
lenticular_cloud/model.py
Normal file
239
lenticular_cloud/model.py
Normal file
|
@ -0,0 +1,239 @@
|
|||
from flask import current_app
|
||||
from ldap3_orm import AttrDef, EntryBase as _EntryBase, ObjectDef, EntryType
|
||||
from ldap3_orm import Reader
|
||||
from ldap3 import Entry
|
||||
from ldap3.utils.conv import escape_filter_chars
|
||||
from flask_login import UserMixin
|
||||
from ldap3.core.exceptions import LDAPSessionTerminatedByServerError
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
ldap_conn = None # type: Connection
|
||||
base_dn = ''
|
||||
|
||||
|
||||
class SecurityUser(UserMixin):
|
||||
|
||||
def __init__(self, username):
|
||||
self._username = username
|
||||
|
||||
def get_id(self):
|
||||
return self._username
|
||||
|
||||
|
||||
class LambdaStr:
|
||||
|
||||
def __init__(self, lam):
|
||||
self.lam = lam
|
||||
|
||||
def __str__(self):
|
||||
return self.lam()
|
||||
|
||||
|
||||
class EntryBase(object):
|
||||
_type = None # will get replaced by the local type
|
||||
_query_object = None # will get replaced by the local type
|
||||
_base_dn = LambdaStr(lambda: base_dn)
|
||||
|
||||
def __init__(self, ldap_object=None, **kwargs):
|
||||
if ldap_object is None:
|
||||
self._ldap_object = self.get_type()(**kwargs)
|
||||
else:
|
||||
self._ldap_object = ldap_object
|
||||
|
||||
def __str__(self):
|
||||
return str(self._ldap_object)
|
||||
|
||||
@classmethod
|
||||
def get_object_def(cls):
|
||||
return ObjectDef(cls.object_classes, ldap_conn)
|
||||
|
||||
@classmethod
|
||||
def get_base(cls):
|
||||
return cls.base_dn.format(_base_dn=base_dn)
|
||||
|
||||
@classmethod
|
||||
def get_type(cls):
|
||||
if cls._type is None:
|
||||
cls._type = EntryType(cls.dn.replace('{base_dn}',cls.get_base()), cls.object_classes, ldap_conn)
|
||||
return cls._type
|
||||
|
||||
def commit(self):
|
||||
print(self._ldap_object.entry_attributes_as_dict)
|
||||
ret = ldap_conn.add(
|
||||
self.dn, self.object_classes, self._ldap_object.entry_attributes_as_dict)
|
||||
print(ret)
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def query(cls):
|
||||
if cls._query_object is None:
|
||||
cls._query_object = cls._query(cls)
|
||||
return cls._query_object
|
||||
|
||||
|
||||
class _query(object):
|
||||
def __init__(self, clazz):
|
||||
self._class = clazz
|
||||
|
||||
def _query(self, ldap_filter: str):
|
||||
reader = Reader(ldap_conn, self._class.get_object_def(), self._class.get_base(), ldap_filter)
|
||||
try:
|
||||
reader.search()
|
||||
except LDAPSessionTerminatedByServerError:
|
||||
ldap_conn.bind()
|
||||
reader.search()
|
||||
return list(reader)
|
||||
|
||||
def all(self):
|
||||
return self._query(None)
|
||||
|
||||
|
||||
|
||||
|
||||
class Service(object):
|
||||
|
||||
def __init__(self, name):
|
||||
self._name = name
|
||||
self._client_cert = False
|
||||
self._pki_config = {
|
||||
'cn': '{username}',
|
||||
'email': '{username}@{domain}'
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_config(name, config):
|
||||
"""
|
||||
"""
|
||||
service = Service(name)
|
||||
if 'client_cert' in config:
|
||||
service._client_cert = bool(config['client_cert'])
|
||||
if 'pki_config' in config:
|
||||
service._pki_config = config['pki_config']
|
||||
|
||||
return service
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def client_cert(self):
|
||||
return self._client_cert
|
||||
|
||||
@property
|
||||
def pki_config(self):
|
||||
if not self._client_cert:
|
||||
raise Exception('invalid call')
|
||||
return self._pki_config
|
||||
|
||||
|
||||
class Certificate(object):
|
||||
|
||||
def __init__(self, cn, ca_name, cert_data):
|
||||
self._cn = cn
|
||||
self._ca_name = ca_name
|
||||
self._cert_data = cert_data
|
||||
|
||||
@property
|
||||
def cn(self):
|
||||
return self._cn
|
||||
|
||||
@property
|
||||
def ca_name(self):
|
||||
return self._ca_name
|
||||
|
||||
@property
|
||||
def not_valid_before(self):
|
||||
return self._cert_data.not_valid_before
|
||||
|
||||
@property
|
||||
def not_valid_after(self):
|
||||
return self._cert_data.not_valid_after
|
||||
|
||||
def fingerprint(self, algorithm=hashes.SHA256()):
|
||||
return self._cert_data.fingerprint(algorithm)
|
||||
|
||||
def pem(self):
|
||||
return self._cert_data.public_bytes(encoding=serialization.Encoding.PEM).decode()
|
||||
|
||||
def __str__(self):
|
||||
return f'Certificate(cn={self._cn}, ca_name={self._ca_name}, not_valid_before={self.not_valid_before}, not_valid_after={self.not_valid_after})'
|
||||
|
||||
class User(EntryBase):
|
||||
|
||||
dn = "uid={uid},{base_dn}"
|
||||
base_dn = "ou=users,{_base_dn}"
|
||||
object_classes = ["top", "inetOrgPerson", "LenticularUser"]
|
||||
|
||||
@property
|
||||
def is_authenticated(self):
|
||||
return True # TODO
|
||||
|
||||
def get(self, key):
|
||||
print(f'getitem: {key}')
|
||||
|
||||
@property
|
||||
def entry_dn(self):
|
||||
return self._ldap_object.entry_dn
|
||||
|
||||
@property
|
||||
def username(self):
|
||||
return self._ldap_object.uid
|
||||
|
||||
@username.setter
|
||||
def username(self, value):
|
||||
self._ldap_object.uid = value
|
||||
|
||||
@property
|
||||
def userPassword(self):
|
||||
return self._ldap_object.userPassword
|
||||
|
||||
@property
|
||||
def fullname(self):
|
||||
return self._ldap_object.fullname
|
||||
|
||||
@property
|
||||
def givenname(self):
|
||||
return self._ldap_object.givenname
|
||||
|
||||
@property
|
||||
def surname(self):
|
||||
return self._ldap_object.surname
|
||||
|
||||
@property
|
||||
def mail(self):
|
||||
return self._ldap_object.mail
|
||||
|
||||
@property
|
||||
def alternative_email(self):
|
||||
return self._ldap_object.altMail
|
||||
|
||||
@property
|
||||
def auth_role(self):
|
||||
return self._ldap_object.authRole
|
||||
|
||||
@property
|
||||
def gpg_public_key(self):
|
||||
return self._ldap_object.gpgPublicKey
|
||||
|
||||
@property
|
||||
def totps(self):
|
||||
return ['JBSWY3DPEHPK3PXP']
|
||||
|
||||
class _query(EntryBase._query):
|
||||
def by_username(self, username) -> 'User':
|
||||
result = self._query('(uid={username:s})'.format(username=escape_filter_chars(username)))
|
||||
if len(result) > 0:
|
||||
return User(result[0])
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
|
||||
class Group(EntryBase):
|
||||
dn = "cn={cn},{base_dn}"
|
||||
base_dn = "ou=Users,{_base_dn}"
|
||||
object_classes = ["top"]
|
||||
|
||||
fullname = AttrDef("cn")
|
30
lenticular_cloud/model_db.py
Normal file
30
lenticular_cloud/model_db.py
Normal file
|
@ -0,0 +1,30 @@
|
|||
from flask_sqlalchemy import SQLAlchemy, orm
|
||||
|
||||
db = SQLAlchemy() # type: SQLAlchemy
|
||||
|
||||
|
||||
class Client(db.Model):
|
||||
key = db.Column(db.Text, primary_key=True)
|
||||
value = db.Column(db.Text)
|
||||
|
||||
|
||||
class AuthzCode(db.Model):
|
||||
key = db.Column(db.Text, primary_key=True)
|
||||
value = db.Column(db.Text)
|
||||
|
||||
|
||||
class AccessToken(db.Model):
|
||||
key = db.Column(db.Text, primary_key=True)
|
||||
value = db.Column(db.Text)
|
||||
|
||||
|
||||
class RefreshToken(db.Model):
|
||||
key = db.Column(db.Text, primary_key=True)
|
||||
value = db.Column(db.Text)
|
||||
|
||||
|
||||
class SubjectIdentifier(db.Model):
|
||||
key = db.Column(db.Text, primary_key=True)
|
||||
value = db.Column(db.Text)
|
||||
|
||||
|
215
lenticular_cloud/pki.py
Normal file
215
lenticular_cloud/pki.py
Normal file
|
@ -0,0 +1,215 @@
|
|||
from flask import current_app
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID
|
||||
from cryptography.x509 import ObjectIdentifier
|
||||
from pathlib import Path
|
||||
import os
|
||||
import string
|
||||
import re
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from .model import Service, User, Certificate
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# partial source from https://github.com/Snawoot/quickcerts
|
||||
# MIT (C) Snawoot
|
||||
|
||||
DAY = datetime.timedelta(1, 0, 0)
|
||||
CA_FILENAME = 'ca'
|
||||
KEY_EXT = 'key'
|
||||
CERT_EXT = 'pem'
|
||||
E = 65537
|
||||
|
||||
|
||||
safe_symbols = set(string.ascii_letters + string.digits + '-.')
|
||||
|
||||
|
||||
def safe_filename(name):
|
||||
return "".join(c if c in safe_symbols else '_' for c in name)
|
||||
|
||||
|
||||
class Pki(object):
|
||||
|
||||
def __init__(self, pki_path: str, domain: str):
|
||||
'''
|
||||
pki_path: str base path from the pkis
|
||||
'''
|
||||
self._pki_path = Path(pki_path)
|
||||
self._domain = domain
|
||||
|
||||
|
||||
def _init_ca(self, service: Service):
|
||||
'''
|
||||
'''
|
||||
ca_name = service.name
|
||||
|
||||
ca_private_key = self._ensure_private_key(ca_name)
|
||||
ca_cert = self._ensure_ca_cert(ca_name, ca_private_key)
|
||||
|
||||
pki_path = self._pki_path / ca_name
|
||||
if not pki_path.exists():
|
||||
pki_path.mkdir()
|
||||
|
||||
return (ca_private_key, ca_cert)
|
||||
|
||||
def get_client_certs(self, user: User, service: Service):
|
||||
pki_path = self._pki_path / service.name
|
||||
certs = []
|
||||
for cert_path in pki_path.glob(f'{user.username}*.crt.pem'):
|
||||
print(cert_path)
|
||||
with cert_path.open('rb') as cert_fd:
|
||||
cert_data = x509.load_pem_x509_certificate(
|
||||
cert_fd.read(),
|
||||
backend=default_backend())
|
||||
cert = Certificate(user.username, service.name, cert_data)
|
||||
certs.append(cert)
|
||||
return certs
|
||||
|
||||
def signing_publickey(self, user: User, service: Service, publickey: str, valid_time=DAY*365):
|
||||
_public_key = serialization.load_pem_public_key(
|
||||
publickey.encode(), backend=default_backend())
|
||||
|
||||
ca_private_key, ca_cert = self._init_ca(service)
|
||||
ca_name = service.name
|
||||
username = str(user.username)
|
||||
config = service.pki_config #TODO use this config
|
||||
domain = self._domain
|
||||
not_valid_before = datetime.datetime.now()
|
||||
|
||||
ca_public_key = ca_private_key.public_key()
|
||||
end_entity_cert_builder = x509.CertificateBuilder().\
|
||||
subject_name(x509.Name([
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, username),
|
||||
x509.NameAttribute(NameOID.EMAIL_ADDRESS, f'{username}@jabber.{domain}'),
|
||||
])).\
|
||||
issuer_name(ca_cert.subject).\
|
||||
not_valid_before(not_valid_before).\
|
||||
not_valid_after(not_valid_before + valid_time).\
|
||||
serial_number(x509.random_serial_number()).\
|
||||
public_key(_public_key).\
|
||||
add_extension(
|
||||
x509.SubjectAlternativeName([
|
||||
x509.DNSName(f'{username}'),
|
||||
]),
|
||||
critical=False).\
|
||||
add_extension(
|
||||
x509.BasicConstraints(ca=False, path_length=None),
|
||||
critical=True).\
|
||||
add_extension(
|
||||
x509.KeyUsage(digital_signature=True,
|
||||
content_commitment=True, # False
|
||||
key_encipherment=True,
|
||||
data_encipherment=False,
|
||||
key_agreement=False,
|
||||
key_cert_sign=False,
|
||||
crl_sign=False,
|
||||
encipher_only=False,
|
||||
decipher_only=False),
|
||||
critical=True).\
|
||||
add_extension(
|
||||
x509.ExtendedKeyUsage([
|
||||
ExtendedKeyUsageOID.CLIENT_AUTH,
|
||||
ExtendedKeyUsageOID.SERVER_AUTH,
|
||||
]), critical=False).\
|
||||
add_extension(
|
||||
x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_public_key),
|
||||
critical=False).\
|
||||
add_extension(
|
||||
x509.SubjectKeyIdentifier.from_public_key(_public_key),
|
||||
critical=False)
|
||||
end_entity_cert = end_entity_cert_builder.\
|
||||
sign(
|
||||
private_key=ca_private_key,
|
||||
algorithm=hashes.SHA256(),
|
||||
backend=default_backend()
|
||||
)
|
||||
|
||||
fingerprint =end_entity_cert.fingerprint(hashes.SHA256()).hex()
|
||||
end_entity_cert_filename = self._pki_path / ca_name / \
|
||||
f'{safe_filename(username)}-{fingerprint}.crt.pem'
|
||||
# save cert
|
||||
with end_entity_cert_filename.open("wb") as end_entity_cert_file:
|
||||
end_entity_cert_file.write(
|
||||
end_entity_cert.public_bytes(encoding=serialization.Encoding.PEM))
|
||||
|
||||
return Certificate(user.username, service.name, end_entity_cert)
|
||||
|
||||
def get_ca_cert_pem(self, service: Service):
|
||||
ca_private_key, ca_cert = self._init_ca(service)
|
||||
return ca_cert.public_bytes(encoding=serialization.Encoding.PEM).decode()
|
||||
|
||||
def _ensure_private_key(self, name, key_size=4096):
|
||||
key_filename = self._pki_path / f'{safe_filename(name)}.key.pem'
|
||||
if key_filename.exists():
|
||||
with open(key_filename, "rb") as key_file:
|
||||
private_key = serialization.load_pem_private_key(key_file.read(),
|
||||
password=None, backend=default_backend())
|
||||
else:
|
||||
logger.info(f'Generate new Private key for {name}')
|
||||
private_key = rsa.generate_private_key(public_exponent=E,
|
||||
key_size=key_size, backend=default_backend())
|
||||
with key_filename.open('wb') as key_file:
|
||||
key_file.write(private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption()))
|
||||
return private_key
|
||||
|
||||
def _ensure_ca_cert(self, ca_name, ca_private_key):
|
||||
ca_cert_filename =self._pki_path / f'{ca_name}.crt.pem'
|
||||
ca_public_key = ca_private_key.public_key()
|
||||
if ca_cert_filename.exists():
|
||||
with ca_cert_filename.open("rb") as ca_cert_file:
|
||||
ca_cert = x509.load_pem_x509_certificate(
|
||||
ca_cert_file.read(),
|
||||
backend=default_backend())
|
||||
else:
|
||||
logger.info(f'Generate new Certificate key for {ca_name}')
|
||||
iname = x509.Name([
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, f'Lenticular Cloud CA - {ca_name}'),
|
||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME,
|
||||
'Lenticluar Cloud'),
|
||||
x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME,
|
||||
ca_name),
|
||||
])
|
||||
ca_cert = x509.CertificateBuilder().\
|
||||
subject_name(iname).\
|
||||
issuer_name(iname).\
|
||||
not_valid_before(datetime.datetime.today() - DAY).\
|
||||
not_valid_after(datetime.datetime.today() + 3650 * DAY).\
|
||||
serial_number(x509.random_serial_number()).\
|
||||
public_key(ca_public_key).\
|
||||
add_extension(
|
||||
x509.BasicConstraints(ca=True, path_length=None),
|
||||
critical=True).\
|
||||
add_extension(
|
||||
x509.KeyUsage(digital_signature=False,
|
||||
content_commitment=False,
|
||||
key_encipherment=False,
|
||||
data_encipherment=False,
|
||||
key_agreement=False,
|
||||
key_cert_sign=True,
|
||||
crl_sign=True,
|
||||
encipher_only=False,
|
||||
decipher_only=False),
|
||||
critical=True).\
|
||||
add_extension(
|
||||
x509.SubjectKeyIdentifier.from_public_key(ca_public_key),
|
||||
critical=False).\
|
||||
sign(
|
||||
private_key=ca_private_key,
|
||||
algorithm=hashes.SHA256(),
|
||||
backend=default_backend()
|
||||
)
|
||||
with open(ca_cert_filename, "wb") as ca_cert_file:
|
||||
ca_cert_file.write(
|
||||
ca_cert.public_bytes(encoding=serialization.Encoding.PEM))
|
||||
assert isinstance(ca_cert, x509.Certificate)
|
||||
return ca_cert
|
48
lenticular_cloud/translations/__init__.py
Normal file
48
lenticular_cloud/translations/__init__.py
Normal file
|
@ -0,0 +1,48 @@
|
|||
from flask import g, request, Flask
|
||||
from flask_login import current_user
|
||||
from typing import Optional
|
||||
|
||||
LANGUAGES = {
|
||||
'en': 'English',
|
||||
'de': 'Deutsch'
|
||||
}
|
||||
|
||||
|
||||
def init_babel(app: Flask) -> None:
|
||||
babel = app.babel
|
||||
|
||||
@babel.localeselector
|
||||
def get_locale() -> str:
|
||||
# if a user is logged in, use the locale from the user settings
|
||||
user = current_user # type: Optional[User]
|
||||
return 'de'
|
||||
|
||||
# prefer lang argument
|
||||
if 'lang' in request.args:
|
||||
lang = request.args['lang'] # type: str
|
||||
if lang in LANGUAGES:
|
||||
if not isinstance(user, User):
|
||||
return lang
|
||||
user.locale = lang
|
||||
db.session.commit()
|
||||
|
||||
if isinstance(user, User):
|
||||
return user.locale
|
||||
# otherwise try to guess the language from the user accept
|
||||
# header the browser transmits. We support de/fr/en in this
|
||||
# example. The best match wins.
|
||||
return request.accept_languages.best_match(['de'])
|
||||
|
||||
@babel.timezoneselector
|
||||
def get_timezone() -> Optional[str]:
|
||||
# user = getattr(g, 'user', None)
|
||||
# if user is not None:
|
||||
# return user.timezone
|
||||
return None
|
||||
|
||||
@app.context_processor
|
||||
def get_locale_jinja() -> dict:
|
||||
def get_locale_() -> str:
|
||||
return get_locale()
|
||||
|
||||
return dict(get_locale=get_locale_)
|
0
lenticular_cloud/user_provider.py
Normal file
0
lenticular_cloud/user_provider.py
Normal file
5
lenticular_cloud/views/__init__.py
Normal file
5
lenticular_cloud/views/__init__.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
# pylint: disable=unused-import
|
||||
|
||||
from .oidc import oidc_provider_views
|
||||
from .auth import auth_views
|
||||
from .frontend import frontend_views
|
73
lenticular_cloud/views/auth.py
Normal file
73
lenticular_cloud/views/auth.py
Normal file
|
@ -0,0 +1,73 @@
|
|||
|
||||
from urllib.parse import urlencode, parse_qs
|
||||
|
||||
import flask
|
||||
from flask import Blueprint, redirect
|
||||
from flask import current_app, session
|
||||
from flask import jsonify
|
||||
from flask.helpers import make_response
|
||||
from flask.templating import render_template
|
||||
from oic.oic.message import TokenErrorResponse, UserInfoErrorResponse, EndSessionRequest
|
||||
|
||||
from pyop.access_token import AccessToken, BearerTokenError
|
||||
from pyop.exceptions import InvalidAuthenticationRequest, InvalidAccessToken, InvalidClientAuthentication, OAuthError, \
|
||||
InvalidSubjectIdentifier, InvalidClientRegistrationRequest
|
||||
from pyop.util import should_fragment_encode
|
||||
|
||||
from flask import Blueprint, render_template, request, url_for
|
||||
from flask_login import login_required, login_user, logout_user
|
||||
from werkzeug.utils import redirect
|
||||
import logging
|
||||
|
||||
|
||||
from ..model import User, SecurityUser
|
||||
from ..form.login import LoginForm
|
||||
from ..auth_providers import AUTH_PROVIDER_LIST
|
||||
from .oidc import do_logout
|
||||
|
||||
|
||||
auth_views = Blueprint('auth', __name__, url_prefix='')
|
||||
|
||||
|
||||
@auth_views.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
form = LoginForm()
|
||||
if form.validate_on_submit():
|
||||
user = User.query().by_username(form.data['name'])
|
||||
session['username'] = str(user.username)
|
||||
session['auth_providers'] = []
|
||||
return redirect(url_for('auth.login_auth'))
|
||||
return render_template('frontend/login.html.j2', form=form)
|
||||
|
||||
|
||||
@auth_views.route('/login/auth', methods=['GET', 'POST'])
|
||||
def login_auth():
|
||||
if 'username' not in session:
|
||||
return redirect(url_for('auth.login'))
|
||||
auth_forms = []
|
||||
user = User.query().by_username(session['username'])
|
||||
for auth_provider in AUTH_PROVIDER_LIST:
|
||||
form = auth_provider.get_form()
|
||||
if auth_provider.get_name() not in session['auth_providers'] and\
|
||||
auth_provider.check_auth(user, form):
|
||||
session['auth_providers'].append(auth_provider.get_name())
|
||||
|
||||
if auth_provider.get_name() not in session['auth_providers']:
|
||||
auth_forms.append(form)
|
||||
|
||||
if len(session['auth_providers']) >= 2:
|
||||
login_user(SecurityUser(session['username']))
|
||||
# TODO use this var
|
||||
_next = request.args.get('next')
|
||||
return redirect(url_for('frontend.index'))
|
||||
print(auth_forms)
|
||||
return render_template('frontend/login_auth.html.j2', forms=auth_forms)
|
||||
|
||||
|
||||
@auth_views.route("/logout")
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
do_logout()
|
||||
return redirect(url_for('.login'))
|
||||
|
93
lenticular_cloud/views/frontend.py
Normal file
93
lenticular_cloud/views/frontend.py
Normal file
|
@ -0,0 +1,93 @@
|
|||
|
||||
from urllib.parse import urlencode, parse_qs
|
||||
|
||||
import flask
|
||||
from flask import Blueprint, redirect
|
||||
from flask import current_app, session
|
||||
from flask import jsonify, send_file
|
||||
from flask.helpers import make_response
|
||||
from flask.templating import render_template
|
||||
from oic.oic.message import TokenErrorResponse, UserInfoErrorResponse, EndSessionRequest
|
||||
|
||||
from pyop.access_token import AccessToken, BearerTokenError
|
||||
from pyop.exceptions import InvalidAuthenticationRequest, InvalidAccessToken, InvalidClientAuthentication, OAuthError, \
|
||||
InvalidSubjectIdentifier, InvalidClientRegistrationRequest
|
||||
from pyop.util import should_fragment_encode
|
||||
|
||||
from flask import Blueprint, render_template, request, url_for
|
||||
from flask_login import login_required, login_user, logout_user, current_user
|
||||
from werkzeug.utils import redirect
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
from ..model import User, SecurityUser
|
||||
from ..form.login import LoginForm
|
||||
from ..form.frontend import ClientCertForm
|
||||
from ..auth_providers import AUTH_PROVIDER_LIST
|
||||
|
||||
|
||||
frontend_views = Blueprint('frontend', __name__, url_prefix='')
|
||||
|
||||
|
||||
@frontend_views.route('/', methods=['GET'])
|
||||
@login_required
|
||||
def index():
|
||||
return render_template('frontend/index.html.j2')
|
||||
|
||||
|
||||
@frontend_views.route('/client_cert')
|
||||
@login_required
|
||||
def client_cert():
|
||||
client_certs = {}
|
||||
for service in current_app.lenticular_services.values():
|
||||
client_certs[str(service.name)] = current_app.pki.get_client_certs(current_user, service)
|
||||
|
||||
return render_template('frontend/client_cert.html.j2', services=current_app.lenticular_services, client_certs=client_certs)
|
||||
|
||||
|
||||
@frontend_views.route('/client_cert/<service_name>/<fingerprint>')
|
||||
@login_required
|
||||
def get_client_cert(service_name, fingerprint):
|
||||
service = current_app.lenticular_services[service_name]
|
||||
current_app.pki.get_client_cert(current_user, service, fingerprint)
|
||||
pass
|
||||
|
||||
|
||||
@frontend_views.route(
|
||||
'/client_cert/<service_name>/new',
|
||||
methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def client_cert_new(service_name):
|
||||
service = current_app.lenticular_services[service_name]
|
||||
form = ClientCertForm()
|
||||
if form.validate_on_submit():
|
||||
valid_time = int(form.data['valid_time']) * timedelta(1, 0, 0)
|
||||
cert = current_app.pki.signing_publickey(
|
||||
current_user,
|
||||
service,
|
||||
form.data['publickey'],
|
||||
valid_time=valid_time)
|
||||
return jsonify( {
|
||||
'status': 'ok',
|
||||
'data': {
|
||||
'cert': cert.pem(),
|
||||
'ca_cert': current_app.pki.get_ca_cert_pem(service)
|
||||
}})
|
||||
elif form.is_submitted():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'errors': form.errors
|
||||
})
|
||||
|
||||
return render_template('frontend/client_cert_new.html.j2',
|
||||
service=service,
|
||||
form=form)
|
||||
|
||||
|
||||
@frontend_views.route('/totp')
|
||||
@login_required
|
||||
def totp():
|
||||
return render_template('frontend/totp.html.j2')
|
||||
|
||||
|
113
lenticular_cloud/views/oidc.py
Normal file
113
lenticular_cloud/views/oidc.py
Normal file
|
@ -0,0 +1,113 @@
|
|||
from urllib.parse import urlencode, parse_qs
|
||||
|
||||
import flask
|
||||
from flask import Blueprint, redirect
|
||||
from flask import current_app, session
|
||||
from flask import jsonify
|
||||
from flask.helpers import make_response
|
||||
from flask.templating import render_template
|
||||
from oic.oic.message import TokenErrorResponse, UserInfoErrorResponse, EndSessionRequest
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from pyop.access_token import AccessToken, BearerTokenError
|
||||
from pyop.exceptions import InvalidAuthenticationRequest, InvalidAccessToken, InvalidClientAuthentication, OAuthError, \
|
||||
InvalidSubjectIdentifier, InvalidClientRegistrationRequest
|
||||
from pyop.util import should_fragment_encode
|
||||
|
||||
oidc_provider_views = Blueprint('oidc_provider', __name__, url_prefix='')
|
||||
|
||||
|
||||
@oidc_provider_views.route('/registration', methods=['POST'])
|
||||
def registration_endpoint():
|
||||
try:
|
||||
response = current_app.provider.handle_client_registration_request(flask.request.get_data().decode('utf-8'))
|
||||
return make_response(jsonify(response.to_dict()), 201)
|
||||
except InvalidClientRegistrationRequest as e:
|
||||
print(e)
|
||||
return make_response(jsonify(str(e)), 400)
|
||||
|
||||
|
||||
@oidc_provider_views.route('/authentication', methods=['GET'])
|
||||
@login_required
|
||||
def authentication_endpoint():
|
||||
# parse authentication request
|
||||
print(flask.request)
|
||||
print(flask.request.headers)
|
||||
try:
|
||||
auth_req = current_app.provider.parse_authentication_request(urlencode(flask.request.args),
|
||||
flask.request.args)
|
||||
except InvalidAuthenticationRequest as e:
|
||||
current_app.logger.debug('received invalid authn request', exc_info=True)
|
||||
error_url = e.to_error_url()
|
||||
if error_url:
|
||||
return redirect(error_url, 303)
|
||||
else:
|
||||
# show error to user
|
||||
return make_response('Something went wrong: {}'.format(str(e)), 400)
|
||||
|
||||
# automagic authentication
|
||||
authn_response = current_app.provider.authorize(auth_req, str(current_user.username))
|
||||
response_url = authn_response.request(auth_req['redirect_uri'], should_fragment_encode(auth_req))
|
||||
return redirect(response_url, 303)
|
||||
|
||||
|
||||
@oidc_provider_views.route('/.well-known/openid-configuration')
|
||||
def provider_configuration():
|
||||
return jsonify(current_app.provider.provider_configuration.to_dict())
|
||||
|
||||
|
||||
@oidc_provider_views.route('/jwks')
|
||||
def jwks_uri():
|
||||
return jsonify(current_app.provider.jwks)
|
||||
|
||||
|
||||
@oidc_provider_views.route('/token', methods=['POST'])
|
||||
def token_endpoint():
|
||||
try:
|
||||
token_response = current_app.provider.handle_token_request(flask.request.get_data().decode('utf-8'),
|
||||
flask.request.headers)
|
||||
return jsonify(token_response.to_dict())
|
||||
except InvalidClientAuthentication as e:
|
||||
current_app.logger.debug('invalid client authentication at token endpoint', exc_info=True)
|
||||
error_resp = TokenErrorResponse(error='invalid_client', error_description=str(e))
|
||||
response = make_response(error_resp.to_json(), 401)
|
||||
response.headers['Content-Type'] = 'application/json'
|
||||
response.headers['WWW-Authenticate'] = 'Basic'
|
||||
return response
|
||||
except OAuthError as e:
|
||||
current_app.logger.debug('invalid request: %s', str(e), exc_info=True)
|
||||
error_resp = TokenErrorResponse(error=e.oauth_error, error_description=str(e))
|
||||
response = make_response(error_resp.to_json(), 400)
|
||||
response.headers['Content-Type'] = 'application/json'
|
||||
return response
|
||||
|
||||
|
||||
@oidc_provider_views.route('/userinfo', methods=['GET', 'POST'])
|
||||
def userinfo_endpoint():
|
||||
try:
|
||||
response = current_app.provider.handle_userinfo_request(flask.request.get_data().decode('utf-8'),
|
||||
flask.request.headers)
|
||||
return jsonify(response.to_dict())
|
||||
except (BearerTokenError, InvalidAccessToken) as e:
|
||||
error_resp = UserInfoErrorResponse(error='invalid_token', error_description=str(e))
|
||||
response = make_response(error_resp.to_json(), 401)
|
||||
response.headers['WWW-Authenticate'] = AccessToken.BEARER_TOKEN_TYPE
|
||||
response.headers['Content-Type'] = 'application/json'
|
||||
return response
|
||||
|
||||
|
||||
def do_logout():
|
||||
|
||||
end_session_request = EndSessionRequest().deserialize(urlencode(flask.request.args)).to_dict()
|
||||
try:
|
||||
current_app.provider.logout_user(end_session_request=end_session_request)
|
||||
except InvalidSubjectIdentifier as e:
|
||||
return make_response('Logout unsuccessful!', 400)
|
||||
|
||||
redirect_url = current_app.provider.do_post_logout_redirect(end_session_request)
|
||||
if redirect_url:
|
||||
return redirect(redirect_url, 303)
|
||||
|
||||
return make_response('Logout successful!')
|
||||
|
||||
|
Loading…
Add table
Add a link
Reference in a new issue