2020-05-09 18:00:07 +00:00
|
|
|
from flask import current_app
|
|
|
|
from ldap3_orm import AttrDef, EntryBase as _EntryBase, ObjectDef, EntryType
|
|
|
|
from ldap3_orm import Reader
|
2022-02-06 22:57:01 +00:00
|
|
|
from ldap3 import Connection, Entry, HASHED_SALTED_SHA256
|
2020-05-09 18:00:07 +00:00
|
|
|
from ldap3.utils.conv import escape_filter_chars
|
2020-05-27 15:56:10 +00:00
|
|
|
from ldap3.utils.hashed import hashed
|
2020-05-09 18:00:07 +00:00
|
|
|
from flask_login import UserMixin
|
|
|
|
from ldap3.core.exceptions import LDAPSessionTerminatedByServerError
|
|
|
|
from cryptography.hazmat.primitives import hashes
|
|
|
|
from cryptography.hazmat.primitives import serialization
|
2020-05-10 12:34:28 +00:00
|
|
|
from collections.abc import MutableSequence
|
|
|
|
from datetime import datetime
|
2020-05-25 18:23:27 +00:00
|
|
|
from dateutil import tz
|
2020-05-10 12:34:28 +00:00
|
|
|
import pyotp
|
|
|
|
import json
|
2020-05-27 15:56:10 +00:00
|
|
|
import logging
|
|
|
|
import crypt
|
|
|
|
from flask_sqlalchemy import SQLAlchemy, orm
|
2022-02-20 15:54:35 +00:00
|
|
|
from flask_migrate import Migrate
|
2020-05-27 15:56:10 +00:00
|
|
|
from datetime import datetime
|
|
|
|
import uuid
|
|
|
|
import pyotp
|
2022-02-11 15:09:40 +00:00
|
|
|
from typing import Optional, Callable
|
2020-05-27 15:56:10 +00:00
|
|
|
|
2020-05-09 18:00:07 +00:00
|
|
|
|
2020-05-27 15:56:10 +00:00
|
|
|
logger = logging.getLogger(__name__)
|
2020-05-09 18:00:07 +00:00
|
|
|
ldap_conn = None # type: Connection
|
|
|
|
base_dn = ''
|
|
|
|
|
2020-05-27 15:56:10 +00:00
|
|
|
db = SQLAlchemy() # type: SQLAlchemy
|
2022-02-20 15:54:35 +00:00
|
|
|
migrate = Migrate()
|
2020-05-27 15:56:10 +00:00
|
|
|
|
2020-06-01 21:43:10 +00:00
|
|
|
class UserSignUp(db.Model):
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
|
|
username = db.Column(db.String, nullable=False)
|
|
|
|
password = db.Column(db.String, nullable=False)
|
|
|
|
alternative_email = db.Column(db.String)
|
|
|
|
created_at = db.Column(db.DateTime, nullable=False,
|
|
|
|
default=datetime.now)
|
|
|
|
|
2020-05-09 18:00:07 +00:00
|
|
|
|
|
|
|
class SecurityUser(UserMixin):
|
|
|
|
|
|
|
|
def __init__(self, username):
|
|
|
|
self._username = username
|
|
|
|
|
|
|
|
def get_id(self):
|
|
|
|
return self._username
|
|
|
|
|
|
|
|
|
|
|
|
class LambdaStr:
|
|
|
|
|
2022-02-11 15:09:40 +00:00
|
|
|
def __init__(self, lam: Callable[[],str]):
|
2020-05-09 18:00:07 +00:00
|
|
|
self.lam = lam
|
|
|
|
|
2022-02-11 15:09:40 +00:00
|
|
|
def __str__(self) -> str:
|
2020-05-09 18:00:07 +00:00
|
|
|
return self.lam()
|
|
|
|
|
|
|
|
|
2020-05-27 15:56:10 +00:00
|
|
|
class EntryBase(db.Model):
|
|
|
|
__abstract__ = True # for sqlalchemy
|
|
|
|
|
2020-05-09 18:00:07 +00:00
|
|
|
_type = None # will get replaced by the local type
|
2020-06-01 21:43:10 +00:00
|
|
|
_ldap_query_object = None # will get replaced by the local type
|
2020-05-09 18:00:07 +00:00
|
|
|
_base_dn = LambdaStr(lambda: base_dn)
|
|
|
|
|
2020-05-27 15:56:10 +00:00
|
|
|
# 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
|
2022-02-11 15:09:40 +00:00
|
|
|
dn = ''
|
|
|
|
base_dn = ''
|
2020-05-09 18:00:07 +00:00
|
|
|
|
2020-06-01 21:43:10 +00:00
|
|
|
def __str__(self) -> str:
|
2020-05-09 18:00:07 +00:00
|
|
|
return str(self._ldap_object)
|
|
|
|
|
|
|
|
@classmethod
|
2020-06-01 21:43:10 +00:00
|
|
|
def get_object_def(cls) -> ObjectDef:
|
2020-05-09 18:00:07 +00:00
|
|
|
return ObjectDef(cls.object_classes, ldap_conn)
|
|
|
|
|
|
|
|
@classmethod
|
2020-06-01 21:43:10 +00:00
|
|
|
def get_entry_type(cls) -> EntryType:
|
|
|
|
return EntryType(cls.get_dn(), cls.object_classes, ldap_conn)
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def get_base(cls) -> str:
|
2020-05-09 18:00:07 +00:00
|
|
|
return cls.base_dn.format(_base_dn=base_dn)
|
|
|
|
|
2020-06-01 21:43:10 +00:00
|
|
|
@classmethod
|
|
|
|
def get_dn(cls) -> str:
|
|
|
|
return cls.dn.replace('{base_dn}', cls.get_base())
|
|
|
|
|
2020-05-09 18:00:07 +00:00
|
|
|
@classmethod
|
|
|
|
def get_type(cls):
|
|
|
|
if cls._type is None:
|
2020-06-01 21:43:10 +00:00
|
|
|
cls._type = EntryType(cls.get_dn(), cls.object_classes, ldap_conn)
|
2020-05-09 18:00:07 +00:00
|
|
|
return cls._type
|
|
|
|
|
2020-06-01 21:43:10 +00:00
|
|
|
def ldap_commit(self):
|
2020-05-27 15:56:10 +00:00
|
|
|
self._ldap_object.entry_commit_changes()
|
|
|
|
|
2020-06-01 21:43:10 +00:00
|
|
|
def ldap_add(self):
|
2020-05-09 18:00:07 +00:00
|
|
|
ret = ldap_conn.add(
|
2020-06-01 21:43:10 +00:00
|
|
|
self.entry_dn, self.object_classes, self._ldap_object.entry_attributes_as_dict)
|
|
|
|
if not ret:
|
|
|
|
raise Exception('ldap error')
|
2020-05-09 18:00:07 +00:00
|
|
|
|
|
|
|
@classmethod
|
2020-05-27 15:56:10 +00:00
|
|
|
def query_(cls):
|
2020-06-01 21:43:10 +00:00
|
|
|
if cls._ldap_query_object is None:
|
|
|
|
cls._ldap_query_object = cls._query(cls)
|
|
|
|
return cls._ldap_query_object
|
2020-05-09 18:00:07 +00:00
|
|
|
|
|
|
|
class _query(object):
|
|
|
|
def __init__(self, clazz):
|
|
|
|
self._class = clazz
|
|
|
|
|
2020-05-21 11:20:27 +00:00
|
|
|
def _mapping(self, ldap_object):
|
|
|
|
return ldap_object
|
|
|
|
|
2020-05-09 18:00:07 +00:00
|
|
|
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()
|
2020-05-21 11:20:27 +00:00
|
|
|
return [self._mapping(entry) for entry in reader]
|
2020-05-09 18:00:07 +00:00
|
|
|
|
|
|
|
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:
|
2020-05-10 17:37:57 +00:00
|
|
|
service._pki_config.update(config['pki_config'])
|
2020-05-09 18:00:07 +00:00
|
|
|
|
|
|
|
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):
|
|
|
|
|
2020-05-25 18:23:27 +00:00
|
|
|
def __init__(self, cn, ca_name: str, cert_data, revoked=False):
|
2020-05-09 18:00:07 +00:00
|
|
|
self._cn = cn
|
|
|
|
self._ca_name = ca_name
|
|
|
|
self._cert_data = cert_data
|
2020-05-25 18:23:27 +00:00
|
|
|
self._revoked = revoked
|
|
|
|
self._cert_data.not_valid_after.replace(tzinfo=tz.tzutc())
|
|
|
|
self._cert_data.not_valid_before.replace(tzinfo=tz.tzutc())
|
2020-05-09 18:00:07 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def cn(self):
|
|
|
|
return self._cn
|
|
|
|
|
|
|
|
@property
|
|
|
|
def ca_name(self):
|
|
|
|
return self._ca_name
|
|
|
|
|
|
|
|
@property
|
2020-05-25 18:23:27 +00:00
|
|
|
def not_valid_before(self) -> datetime:
|
|
|
|
return self._cert_data.not_valid_before.replace(tzinfo=tz.tzutc()).astimezone(tz.tzlocal()).replace(tzinfo=None)
|
2020-05-09 18:00:07 +00:00
|
|
|
|
|
|
|
@property
|
2020-05-25 18:23:27 +00:00
|
|
|
def not_valid_after(self) -> datetime:
|
|
|
|
return self._cert_data.not_valid_after.replace(tzinfo=tz.tzutc()).astimezone(tz.tzlocal()).replace(tzinfo=None)
|
2020-05-09 18:00:07 +00:00
|
|
|
|
2020-05-25 18:23:27 +00:00
|
|
|
@property
|
|
|
|
def serial_number(self) -> int:
|
|
|
|
return self._cert_data.serial_number
|
|
|
|
|
|
|
|
@property
|
|
|
|
def serial_number_hex(self) -> str:
|
|
|
|
return f'{self._cert_data.serial_number:X}'
|
|
|
|
|
|
|
|
def fingerprint(self, algorithm=hashes.SHA256()) -> bytes:
|
2020-05-09 18:00:07 +00:00
|
|
|
return self._cert_data.fingerprint(algorithm)
|
|
|
|
|
2020-05-25 18:23:27 +00:00
|
|
|
@property
|
|
|
|
def is_valid(self) -> bool:
|
|
|
|
return self.not_valid_after > datetime.now() and not self._revoked
|
|
|
|
|
|
|
|
def pem(self) -> str:
|
2020-05-09 18:00:07 +00:00
|
|
|
return self._cert_data.public_bytes(encoding=serialization.Encoding.PEM).decode()
|
|
|
|
|
2020-05-25 18:23:27 +00:00
|
|
|
@property
|
|
|
|
def raw(self):
|
|
|
|
return self._cert_data
|
|
|
|
|
2020-05-09 18:00:07 +00:00
|
|
|
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})'
|
|
|
|
|
2020-05-10 12:34:28 +00:00
|
|
|
|
2020-05-27 15:56:10 +00:00
|
|
|
def generate_uuid():
|
|
|
|
return str(uuid.uuid4())
|
2020-05-10 12:34:28 +00:00
|
|
|
|
|
|
|
|
2020-05-27 15:56:10 +00:00
|
|
|
class User(EntryBase):
|
|
|
|
id = db.Column(
|
|
|
|
db.String(length=36), primary_key=True, default=generate_uuid)
|
|
|
|
username = db.Column(
|
2020-06-01 21:43:10 +00:00
|
|
|
db.String, unique=True, nullable=False)
|
2022-02-06 22:57:01 +00:00
|
|
|
alternative_email = db.Column(
|
|
|
|
db.String, nullable=True)
|
2020-06-01 21:43:10 +00:00
|
|
|
created_at = db.Column(db.DateTime, nullable=False,
|
|
|
|
default=datetime.now)
|
|
|
|
modified_at = db.Column(db.DateTime, nullable=False,
|
|
|
|
default=datetime.now, onupdate=datetime.now)
|
|
|
|
last_login = db.Column(db.DateTime, nullable=True)
|
2020-05-10 12:34:28 +00:00
|
|
|
|
2020-05-27 15:56:10 +00:00
|
|
|
totps = db.relationship('Totp', back_populates='user')
|
2022-04-08 19:28:22 +00:00
|
|
|
webauthn_credentials = db.relationship('WebauthnCredential', back_populates='user', cascade='delete,delete-orphan', passive_deletes=True)
|
2020-05-10 12:34:28 +00:00
|
|
|
|
2020-05-09 18:00:07 +00:00
|
|
|
dn = "uid={uid},{base_dn}"
|
|
|
|
base_dn = "ou=users,{_base_dn}"
|
2022-02-06 22:57:01 +00:00
|
|
|
object_classes = ["inetOrgPerson"] #, "LenticularUser"]
|
2020-05-09 18:00:07 +00:00
|
|
|
|
2020-06-01 21:43:10 +00:00
|
|
|
def __init__(self, **kwargs):
|
2020-05-27 15:56:10 +00:00
|
|
|
self._ldap_object = None
|
|
|
|
super(db.Model).__init__(**kwargs)
|
2020-05-10 12:34:28 +00:00
|
|
|
|
2020-05-09 18:00:07 +00:00
|
|
|
@property
|
|
|
|
def is_authenticated(self):
|
|
|
|
return True # TODO
|
|
|
|
|
|
|
|
def get(self, key):
|
2022-02-06 22:57:01 +00:00
|
|
|
print(f'getitem: {key}') # TODO
|
2020-05-09 18:00:07 +00:00
|
|
|
|
2020-05-10 12:34:28 +00:00
|
|
|
def make_writeable(self):
|
|
|
|
self._ldap_object = self._ldap_object.entry_writable()
|
|
|
|
|
2020-06-01 21:43:10 +00:00
|
|
|
@property
|
2022-02-06 22:57:01 +00:00
|
|
|
def groups(self) -> list[str]:
|
2020-06-01 21:43:10 +00:00
|
|
|
if self.username == 'tuxcoder':
|
|
|
|
return [Group(name='admin')]
|
|
|
|
else:
|
|
|
|
return []
|
|
|
|
|
2020-05-09 18:00:07 +00:00
|
|
|
@property
|
2022-02-06 22:57:01 +00:00
|
|
|
def entry_dn(self) -> str:
|
2020-05-09 18:00:07 +00:00
|
|
|
return self._ldap_object.entry_dn
|
|
|
|
|
|
|
|
@property
|
2022-02-06 22:57:01 +00:00
|
|
|
def email(self) -> str:
|
2020-05-30 21:43:55 +00:00
|
|
|
domain = current_app.config['DOMAIN']
|
|
|
|
return f'{self.username}@{domain}'
|
2020-05-09 18:00:07 +00:00
|
|
|
return self._ldap_object.mail
|
|
|
|
|
2022-02-06 22:57:01 +00:00
|
|
|
def change_password(self, password_new: str) -> bool:
|
2020-05-27 15:56:10 +00:00
|
|
|
self.make_writeable()
|
|
|
|
password_hashed = crypt.crypt(password_new)
|
|
|
|
self._ldap_object.userPassword = ('{CRYPT}' + password_hashed).encode()
|
2020-06-01 21:43:10 +00:00
|
|
|
self.ldap_commit()
|
2022-02-06 22:57:01 +00:00
|
|
|
return True
|
2020-05-09 18:00:07 +00:00
|
|
|
|
|
|
|
class _query(EntryBase._query):
|
2020-05-21 11:20:27 +00:00
|
|
|
|
|
|
|
def _mapping(self, ldap_object):
|
2020-05-27 15:56:10 +00:00
|
|
|
user = User.query.filter(User.username == str(ldap_object.uid)).first()
|
|
|
|
if user is None:
|
|
|
|
# migration time
|
|
|
|
user = User()
|
|
|
|
user.username = str(ldap_object.uid)
|
|
|
|
db.session.add(user)
|
|
|
|
db.session.commit()
|
|
|
|
user._ldap_object = ldap_object
|
|
|
|
return user
|
2020-05-21 11:20:27 +00:00
|
|
|
|
2022-02-06 22:57:01 +00:00
|
|
|
def by_username(self, username) -> Optional['User']:
|
2020-05-09 18:00:07 +00:00
|
|
|
result = self._query('(uid={username:s})'.format(username=escape_filter_chars(username)))
|
2022-04-08 19:28:22 +00:00
|
|
|
if len(result) > 0 and isinstance(result[0], User):
|
2020-05-21 11:20:27 +00:00
|
|
|
return result[0]
|
2020-05-09 18:00:07 +00:00
|
|
|
else:
|
|
|
|
return None
|
|
|
|
|
2020-06-01 21:43:10 +00:00
|
|
|
@staticmethod
|
|
|
|
def new(user_data: UserSignUp):
|
|
|
|
user = User()
|
|
|
|
user.username = user_data.username.lower()
|
|
|
|
domain = current_app.config['DOMAIN']
|
|
|
|
ldap_object = User.get_entry_type()(
|
|
|
|
uid=user_data.username.lower(),
|
|
|
|
sn=user_data.username,
|
|
|
|
cn=user_data.username,
|
|
|
|
userPassword='{CRYPT}' + user_data.password,
|
|
|
|
mail=f'{user_data.username}@{domain}')
|
|
|
|
user._ldap_object = ldap_object
|
|
|
|
user.ldap_add()
|
|
|
|
return user
|
|
|
|
|
2020-05-09 18:00:07 +00:00
|
|
|
|
|
|
|
|
2020-05-27 15:56:10 +00:00
|
|
|
class Totp(db.Model):
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
|
|
secret = db.Column(db.String, nullable=False)
|
|
|
|
name = db.Column(db.String, nullable=False)
|
|
|
|
created_at = db.Column(db.DateTime, default=datetime.now, nullable=False)
|
|
|
|
|
|
|
|
user_id = db.Column(
|
|
|
|
db.String(length=36),
|
|
|
|
db.ForeignKey(User.id), nullable=False)
|
|
|
|
user = db.relationship(User)
|
|
|
|
|
|
|
|
def verify(self, token: str):
|
|
|
|
totp = pyotp.TOTP(self.secret)
|
|
|
|
return totp.verify(token)
|
|
|
|
|
2022-04-08 19:28:22 +00:00
|
|
|
|
|
|
|
class WebauthnCredential(db.Model): # pylint: disable=too-few-public-methods
|
|
|
|
"""Webauthn credential model"""
|
|
|
|
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
|
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False)
|
|
|
|
user_handle = db.Column(db.String(64), nullable=False)
|
|
|
|
credential_data = db.Column(db.LargeBinary, nullable=False)
|
|
|
|
name = db.Column(db.String(250))
|
|
|
|
registered = db.Column(db.DateTime, default=datetime.utcnow)
|
|
|
|
|
|
|
|
user = db.relationship('User', back_populates='webauthn_credentials')
|
|
|
|
|
|
|
|
|
2020-05-09 18:00:07 +00:00
|
|
|
class Group(EntryBase):
|
2020-05-27 15:56:10 +00:00
|
|
|
__abstract__ = True # for sqlalchemy, disable for now
|
2020-05-09 18:00:07 +00:00
|
|
|
dn = "cn={cn},{base_dn}"
|
|
|
|
base_dn = "ou=Users,{_base_dn}"
|
|
|
|
object_classes = ["top"]
|
|
|
|
|
|
|
|
fullname = AttrDef("cn")
|
2020-05-27 19:16:14 +00:00
|
|
|
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
2020-06-01 21:43:10 +00:00
|
|
|
name = db.Column(db.String(), nullable=False, unique=True)
|
|
|
|
|