diff --git a/freenit/__init__.py b/freenit/__init__.py index 260c070..f9aa3e1 100644 --- a/freenit/__init__.py +++ b/freenit/__init__.py @@ -1 +1 @@ -__version__ = "0.3.1" +__version__ = "0.3.2" diff --git a/freenit/api/auth.py b/freenit/api/auth.py index 1ba8593..c2433a6 100644 --- a/freenit/api/auth.py +++ b/freenit/api/auth.py @@ -58,10 +58,8 @@ async def login(credentials: LoginInput, response: Response): } -@api.post("/auth/register", tags=["auth"]) -async def register(credentials: LoginInput, host=Header(default="")): +async def register_ormar(credentials: LoginInput) -> User: import ormar.exceptions - try: user = await User.objects.get(email=credentials.email) raise HTTPException(status_code=409, detail="User already registered") @@ -73,6 +71,21 @@ async def register(credentials: LoginInput, host=Header(default="")): active=False, ) await user.save() + return user + + +async def register_bonsai(credentials: LoginInput) -> User: + user = await User.register(credentials) + await user.save() + return user + + +@api.post("/auth/register", tags=["auth"]) +async def register(credentials: LoginInput, host=Header(default="")): + if User.dbtype() == "ormar": + user = await register_ormar(credentials) + else: + user = await register_bonsai(credentials) token = encode(user) print(token) mail = config.mail diff --git a/freenit/api/role.py b/freenit/api/role.py index 7a2b937..ff3b650 100644 --- a/freenit/api/role.py +++ b/freenit/api/role.py @@ -1,5 +1,3 @@ -from typing import List - import ormar import ormar.exceptions from fastapi import Depends, Header, HTTPException @@ -23,45 +21,113 @@ async def get( perpage: int = Header(default=10), _: User = Depends(role_perms), ) -> Page[RoleSafe]: - roles = Role.objects - return await paginate(roles, page, perpage) + if Role.dbtype() == "ormar": + return await paginate(Role.objects, page, perpage) + elif Role.dbtype() == "bonsai": + import bonsai + + from freenit.models.ldap.base import get_client + + client = get_client() + try: + async with client.connect(is_async=True) as conn: + res = await conn.search( + f"dc=group,dc=ldap", + bonsai.LDAPSearchScope.SUB, + "objectClass=groupOfUniqueNames", + ) + except bonsai.errors.AuthenticationError: + raise HTTPException(status_code=403, detail="Failed to login") + data = [] + for gdata in res: + role = Role( + cn=gdata["cn"][0], + dn=str(gdata["dn"]), + uniqueMembers=gdata["uniqueMember"], + ) + data.append(role) + + total = len(res) + page = Page(total=total, page=1, pages=1, perpage=total, data=data) + return page + raise HTTPException(status_code=409, detail="Unknown group type") @staticmethod - async def post(role: Role, _: User = Depends(role_perms)) -> RoleSafe: - await role.save() + async def post(role: Role, user: User = Depends(role_perms)) -> RoleSafe: + if Role.dbtype() == "ormar": + await role.save() + elif Role.dbtype() == "bonsai": + import bonsai + try: + await role.create(user) + except bonsai.errors.AlreadyExists: + raise HTTPException(status_code=409, detail="Role already exists") return role @route("/roles/{id}", tags=tags) class RoleDetailAPI: @staticmethod - async def get(id: int, _: User = Depends(role_perms)) -> RoleSafe: - try: - role = await Role.objects.get(pk=id) - except ormar.exceptions.NoMatch: - raise HTTPException(status_code=404, detail="No such role") - await role.load_all(follow=True) - return role + async def get(id, _: User = Depends(role_perms)) -> RoleSafe: + if Role.dbtype() == "ormar": + try: + role = await Role.objects.get(pk=id) + except ormar.exceptions.NoMatch: + raise HTTPException(status_code=404, detail="No such role") + await role.load_all(follow=True) + return role + elif Role.dbtype() == "bonsai": + role = Role.get(id) + return role + raise HTTPException(status_code=409, detail="Unknown role type") - @staticmethod async def patch( - id: int, role_data: RoleOptional, _: User = Depends(role_perms) + id, role_data: RoleOptional, _: User = Depends(role_perms) ) -> RoleSafe: - try: - role = await Role.objects.get(pk=id) - except ormar.exceptions.NoMatch: - raise HTTPException(status_code=404, detail="No such role") - await role.patch(role_data) - return role + if Role.dbtype() == "ormar": + try: + role = await Role.objects.get(pk=id) + except ormar.exceptions.NoMatch: + raise HTTPException(status_code=404, detail="No such role") + await role.patch(role_data) + return role + raise HTTPException(status_code=409, detail=f"Role type {Role.dbtype()} doesn't support PATCH method") @staticmethod - async def delete(id: int, _: User = Depends(role_perms)) -> RoleSafe: - try: - role = await Role.objects.get(pk=id) - except ormar.exceptions.NoMatch: - raise HTTPException(status_code=404, detail="No such role") - await role.delete() - return role + async def delete(id, _: User = Depends(role_perms)) -> RoleSafe: + if Role.dbtype() == "ormar": + try: + role = await Role.objects.get(pk=id) + except ormar.exceptions.NoMatch: + raise HTTPException(status_code=404, detail="No such role") + await role.delete() + return role + elif Role.dbtype() == "bonsai": + import bonsai + + from freenit.models.ldap.base import get_client + + client = get_client() + try: + async with client.connect(is_async=True) as conn: + res = await conn.search( + id, bonsai.LDAPSearchScope.SUB, "objectClass=groupOfUniqueNames" + ) + if len(res) < 1: + raise HTTPException(status_code=404, detail="No such role") + if len(res) > 1: + raise HTTPException(status_code=409, detail="Multiple role found") + existing = res[0] + role = Role( + cn=existing["cn"][0], + dn=str(existing["dn"]), + uniqueMembers=existing["uniqueMember"], + ) + await existing.delete() + return role + except bonsai.errors.AuthenticationError: + raise HTTPException(status_code=403, detail="Failed to login") + raise HTTPException(status_code=409, detail="Unknown role type") @route("/roles/{role_id}/{user_id}", tags=tags) @@ -69,39 +135,53 @@ class RoleUserAPI: @staticmethod @description("Assign user to role") async def post( - role_id: int, user_id: int, _: User = Depends(role_perms) + role_id, user_id, _: User = Depends(role_perms) ) -> UserSafe: - try: - user = await User.objects.get(pk=user_id) - except ormar.exceptions.NoMatch: - raise HTTPException(status_code=404, detail="No such user") - await user.load_all() - for role in user.roles: - if role.id == role_id: - raise HTTPException(status_code=409, detail="User already assigned") - try: - role = await Role.objects.get(pk=role_id) - except ormar.exceptions.NoMatch: - raise HTTPException(status_code=404, detail="No such role") - await user.roles.add(role) - return user + if Role.dbtype() == "ormar": + try: + user = await User.objects.get(pk=user_id) + except ormar.exceptions.NoMatch: + raise HTTPException(status_code=404, detail="No such user") + await user.load_all() + for role in user.roles: + if role.id == role_id: + raise HTTPException(status_code=409, detail="User already assigned") + try: + role = await Role.objects.get(pk=role_id) + except ormar.exceptions.NoMatch: + raise HTTPException(status_code=404, detail="No such role") + await user.roles.add(role) + return user + elif Role.dbtype() == "bonsai": + user = await User.get(user_id) + role = await Role.get(role_id) + await role.add(user) + return user + raise HTTPException(status_code=409, detail="Unknown role type") @staticmethod @description("Deassign user to role") async def delete( - role_id: int, user_id: int, _: User = Depends(role_perms) + role_id, user_id, _: User = Depends(role_perms) ) -> UserSafe: - try: - user = await User.objects.get(pk=user_id) - except ormar.exceptions.NoMatch: - raise HTTPException(status_code=404, detail="No such user") - try: - role = await Role.objects.get(pk=role_id) - except ormar.exceptions.NoMatch: - raise HTTPException(status_code=404, detail="No such role") - await user.load_all() - try: - await user.roles.remove(role) - except ormar.exceptions.NoMatch: - raise HTTPException(status_code=404, detail="User is not part of role") - return user + if Role.dbtype() == "ormar": + try: + user = await User.objects.get(pk=user_id) + except ormar.exceptions.NoMatch: + raise HTTPException(status_code=404, detail="No such user") + try: + role = await Role.objects.get(pk=role_id) + except ormar.exceptions.NoMatch: + raise HTTPException(status_code=404, detail="No such role") + await user.load_all() + try: + await user.roles.remove(role) + except ormar.exceptions.NoMatch: + raise HTTPException(status_code=404, detail="User is not part of role") + return user + elif Role.dbtype() == "bonsai": + user = await User.get(user_id) + role = await Role.get(role_id) + await role.remove(user) + return user + raise HTTPException(status_code=409, detail="Unknown role type") diff --git a/freenit/api/user.py b/freenit/api/user.py index bb62d93..b2e1503 100644 --- a/freenit/api/user.py +++ b/freenit/api/user.py @@ -30,7 +30,9 @@ async def get( elif User.dbtype() == "bonsai": import bonsai - client = bonsai.LDAPClient(f"ldap://{config.ldap.host}", config.ldap.tls) + from freenit.models.ldap.base import get_client + + client = get_client() try: async with client.connect(is_async=True) as conn: res = await conn.search( @@ -64,35 +66,79 @@ async def get( @route("/users/{id}", tags=tags) class UserDetailAPI: @staticmethod - async def get(id: int, _: User = Depends(user_perms)) -> UserSafe: - try: - user = await User.objects.get(pk=id) - except ormar.exceptions.NoMatch: - raise HTTPException(status_code=404, detail="No such user") - await user.load_all(follow=True) - return user + async def get(id, _: User = Depends(user_perms)) -> UserSafe: + if User.dbtype() == "ormar": + try: + user = await User.objects.get(pk=id) + except ormar.exceptions.NoMatch: + raise HTTPException(status_code=404, detail="No such user") + await user.load_all(follow=True) + return user + elif User.dbtype() == "bonsai": + user = await User.get(id) + return user + raise HTTPException(status_code=409, detail="Unknown user type") @staticmethod async def patch( - id: int, data: UserOptional, _: User = Depends(user_perms) + id, data: UserOptional, _: User = Depends(user_perms) ) -> UserSafe: - if data.password: - data.password = encrypt(data.password) - try: - user = await User.objects.get(pk=id) - except ormar.exceptions.NoMatch: - raise HTTPException(status_code=404, detail="No such user") - await user.patch(data) - return user + if User.dbtype() == "ormar": + if data.password: + data.password = encrypt(data.password) + try: + user = await User.objects.get(pk=id) + except ormar.exceptions.NoMatch: + raise HTTPException(status_code=404, detail="No such user") + await user.patch(data) + return user + elif User.dbtype() == "bonsai": + user = await User.get(id) + update = { + field: getattr(data, field) for field in data.__fields__ if getattr(data, field) != '' + } + await user.update(active=user.userClass, **update) + return user + raise HTTPException(status_code=409, detail="Unknown user type") @staticmethod - async def delete(id: int, _: User = Depends(user_perms)) -> UserSafe: - try: - user = await User.objects.get(pk=id) - except ormar.exceptions.NoMatch: - raise HTTPException(status_code=404, detail="No such user") - await user.delete() - return user + async def delete(id, _: User = Depends(user_perms)) -> UserSafe: + if User.dbtype() == "ormar": + try: + user = await User.objects.get(pk=id) + except ormar.exceptions.NoMatch: + raise HTTPException(status_code=404, detail="No such user") + await user.delete() + return user + elif User.dbtype() == "bonsai": + import bonsai + + from freenit.models.ldap.base import get_client + + client = get_client() + try: + async with client.connect(is_async=True) as conn: + res = await conn.search( + id, bonsai.LDAPSearchScope.SUB, "objectClass=person" + ) + if len(res) < 1: + raise HTTPException(status_code=404, detail="No such user") + if len(res) > 1: + raise HTTPException(status_code=409, detail="Multiple users found") + existing = res[0] + user = User( + email=existing["mail"][0], + sn=existing["sn"][0], + cn=existing["cn"][0], + dn=str(existing["dn"]), + uid=existing["uid"][0], + userClass=existing["userClass"][0], + ) + await existing.delete() + return user + except bonsai.errors.AuthenticationError: + raise HTTPException(status_code=403, detail="Failed to login") + raise HTTPException(status_code=409, detail="Unknown user type") @route("/profile", tags=["profile"]) diff --git a/freenit/auth.py b/freenit/auth.py index c68b029..c3e7a75 100644 --- a/freenit/auth.py +++ b/freenit/auth.py @@ -25,23 +25,7 @@ async def decode(token): except ormar.exceptions.NoMatch: raise HTTPException(status_code=403, detail="Unauthorized") elif User.dbtype() == "bonsai": - import bonsai - - client = bonsai.LDAPClient(f"ldap://{config.ldap.host}", config.ldap.tls) - async with client.connect(is_async=True) as conn: - res = await conn.search( - pk, - bonsai.LDAPSearchScope.BASE, - "objectClass=person", - ) - data = res[0] - user = User( - email=data["mail"][0], - sn=data["sn"][0], - cn=data["cn"][0], - dn=str(data["dn"]), - uid=data["uid"][0], - ) + user = User.get(pk) return user raise HTTPException(status_code=409, detail="Unknown user type") diff --git a/freenit/base_config.py b/freenit/base_config.py index d38aa43..8d4808d 100644 --- a/freenit/base_config.py +++ b/freenit/base_config.py @@ -32,7 +32,7 @@ def __init__( self, server="mail.example.com", user="user@example.com", - password="Secrit", #nosec + password="Sekrit", # nosec port=587, tls=True, from_addr="no-reply@mail.com", @@ -51,11 +51,22 @@ def __init__( class LDAP: def __init__( - self, host="ldap.example.com", tls=True, base="uid={},ou={},dc=account,dc=ldap" + self, + host="ldap.example.com", + tls=True, + base="uid={},ou={},dc=account,dc=ldap", + service_dn="cn=freenit,dc=service,dc=ldap", + service_pw="", + userClasses=["pilotPerson", "posixAccount"], + groupClasses=["groupOfUniqueNames"], ): self.host = host self.tls = tls self.base = base + self.service_dn = service_dn + self.service_pw = service_pw + self.userClasses = userClasses + self.groupClasses = groupClasses class BaseConfig: @@ -69,7 +80,7 @@ class BaseConfig: dburl = "sqlite:///db.sqlite" database = None engine = None - secret = "SECRET" #nosec + secret = "SECRET" # nosec user = "freenit.models.ormar.user" role = "freenit.models.ormar.role" theme = "freenit.models.ormar.theme" @@ -115,4 +126,4 @@ class TestConfig(BaseConfig): class ProdConfig(BaseConfig): - secret = "MORESECURESECRET" #nosec + secret = "MORESECURESECRET" # nosec diff --git a/freenit/models/ldap/base.py b/freenit/models/ldap/base.py index 544687b..c736644 100644 --- a/freenit/models/ldap/base.py +++ b/freenit/models/ldap/base.py @@ -1,19 +1,88 @@ from typing import Generic, TypeVar -from pydantic import EmailStr, Field, generics +from bonsai import LDAPClient, LDAPSearchScope, errors +from bonsai.errors import AuthenticationError, InsufficientAccess, UnwillingToPerform +from fastapi import HTTPException +from pydantic import BaseModel, Field + +from freenit.config import getConfig T = TypeVar("T") +config = getConfig() + + +def get_client(credentials=None): + client = LDAPClient(f"ldap://{config.ldap.host}", config.ldap.tls) + if credentials is not None: + username, domain = credentials.email.split("@") + dn = config.ldap.base.format(username, domain) + client.set_credentials("SIMPLE", user=dn, password=credentials.password) + else: + dn = config.ldap.service_dn + client.set_credentials("SIMPLE", user=dn, password=config.ldap.service_pw) + return client + +async def save_data(data): + client = get_client() + async with client.connect(is_async=True) as conn: + try: + await conn.add(data) + except InsufficientAccess: + raise HTTPException( + status_code=403, detail="No permission to create user" + ) -class LDAPBaseModel(generics.GenericModel, Generic[T]): - class Meta: - type = "bonsai" + +class LDAPBaseModel(BaseModel, Generic[T]): + @classmethod + def dbtype(cls): + return "bonsai" dn: str = Field("", description=("Distinguished name")) class LDAPUserMixin: - uid: str = Field("", description=("User ID")) - email: EmailStr = Field("", description=("Email")) - cn: str = Field("", description=("Common name")) - sn: str = Field("", description=("Surname")) + @classmethod + async def _login(cls, credentials) -> dict: + client = get_client(credentials) + try: + async with client.connect(is_async=True) as conn: + username, domain = credentials.email.split("@") + dn = config.ldap.base.format(username, domain) + res = await conn.search(dn, LDAPSearchScope.BASE, "objectClass=person") + except errors.AuthenticationError: + raise HTTPException(status_code=403, detail="Failed to login") + data = res[0] + return data + + @classmethod + async def login(cls, credentials): + data = await cls._login(credentials) + user = cls( + dn=str(data["dn"]), + email=credentials.email, + sn=data["sn"][0], + cn=data["cn"][0], + uid=data["uid"][0], + ) + return user + + @classmethod + async def register(cls, credentials): + client = get_client() + username, domain = credentials.email.split("@") + dn = config.ldap.base.format(username, domain) + try: + async with client.connect(is_async=True) as conn: + res = await conn.search(dn, LDAPSearchScope.BASE, "objectClass=person") + if len(res) > 0: + raise HTTPException(status_code=409, detail="User already exists") + except UnwillingToPerform: + raise HTTPException(status_code=409, detail="Can not bind to LDAP") + except AuthenticationError: + raise HTTPException(status_code=409, detail="Can not bind to LDAP") + user = cls( + dn=dn, uid=username, email=credentials.email, password=credentials.password + ) + return user diff --git a/freenit/models/ldap/role.py b/freenit/models/ldap/role.py new file mode 100644 index 0000000..fbd4e32 --- /dev/null +++ b/freenit/models/ldap/role.py @@ -0,0 +1,91 @@ +from bonsai import LDAPEntry, LDAPSearchScope, errors +from fastapi import HTTPException +from pydantic import Field + +from freenit.config import getConfig +from freenit.models.ldap.base import LDAPBaseModel, get_client, save_data + +config = getConfig() + + +class Role(LDAPBaseModel): + cn: str = Field("", description=("Common name")) + uniqueMembers: list = Field([], description=("Group members")) + + @classmethod + async def get(cls, dn): + client = get_client() + try: + async with client.connect(is_async=True) as conn: + res = await conn.search( + dn, + LDAPSearchScope.SUB, + "objectClass=groupOfUniqueNames", + ) + except errors.AuthenticationError: + raise HTTPException(status_code=403, detail="Failed to login") + if len(res) < 1: + raise HTTPException(status_code=404, detail="No such role") + if len(res) > 1: + raise HTTPException(status_code=409, detail="Multiple roles found") + data = res[0] + role = cls( + cn=data["cn"][0], + dn=str(data["dn"]), + uniqueMembers=data["uniqueMember"], + ) + return role + + + async def create(self, user): + data = LDAPEntry(self.dn) + data["objectClass"] = config.ldap.groupClasses + data["cn"] = self.cn + data["uniqueMember"] = user.dn + await save_data(data) + self.uniqueMembers = data["uniqueMember"] + + async def add(self, user): + client = get_client() + try: + async with client.connect(is_async=True) as conn: + res = await conn.search( + self.dn, LDAPSearchScope.BASE, "objectClass=groupOfUniqueNames" + ) + if len(res) < 1: + raise HTTPException(status_code=404, detail="No such role") + if len(res) > 1: + raise HTTPException(status_code=409, detail="Multiple roles found") + data = res[0] + try: + data["uniqueMember"].append(user.dn) + except ValueError: + raise HTTPException(status_code=409, detail="User is already member of the role") + await data.modify() + except errors.AuthenticationError: + raise HTTPException(status_code=403, detail="Failed to login") + self.uniqueMembers.append(user) + + async def remove(self, user): + client = get_client() + try: + async with client.connect(is_async=True) as conn: + res = await conn.search( + self.dn, LDAPSearchScope.BASE, "objectClass=groupOfUniqueNames" + ) + if len(res) < 1: + raise HTTPException(status_code=404, detail="No such role") + if len(res) > 1: + raise HTTPException(status_code=409, detail="Multiple roles found") + data = res[0] + try: + data["uniqueMember"].remove(user.dn) + except ValueError: + raise HTTPException(status_code=409, detail="User is not member of the role") + await data.modify() + except errors.AuthenticationError: + raise HTTPException(status_code=403, detail="Failed to login") + self.uniqueMembers.append(user) + + +RoleOptional = Role diff --git a/freenit/models/ldap/user.py b/freenit/models/ldap/user.py index 5dae20f..cd709b9 100644 --- a/freenit/models/ldap/user.py +++ b/freenit/models/ldap/user.py @@ -1,46 +1,77 @@ from __future__ import annotations -from bonsai import LDAPClient, LDAPSearchScope, errors -from fastapi import HTTPException -from pydantic import Field +from bonsai import LDAPEntry, LDAPModOp, LDAPSearchScope, errors +from pydantic import EmailStr, Field from freenit.config import getConfig -from freenit.models.ldap.base import LDAPBaseModel, LDAPUserMixin +from freenit.models.ldap.base import LDAPBaseModel, LDAPUserMixin, get_client, save_data config = getConfig() class UserSafe(LDAPBaseModel, LDAPUserMixin): + uid: str = Field("", description=("User ID")) + email: EmailStr = Field("", description=("Email")) + cn: str = Field("", description=("Common name")) + sn: str = Field("", description=("Surname")) + userClass: str = Field("", description=("User class")) + + +class User(UserSafe): + password: str = Field("", description=("Password")) + @classmethod - async def _login(cls, credentials) -> dict: - username, domain = credentials.email.split("@") - client = LDAPClient(f"ldap://{config.ldap.host}", config.ldap.tls) - dn = config.ldap.base.format(username, domain) - client.set_credentials("SIMPLE", user=dn, password=credentials.password) + async def get(cls, dn): + client = get_client() try: async with client.connect(is_async=True) as conn: res = await conn.search(dn, LDAPSearchScope.BASE, "objectClass=person") except errors.AuthenticationError: raise HTTPException(status_code=403, detail="Failed to login") - + if len(res) < 1: + raise HTTPException(status_code=404, detail="No such user") + if len(res) > 1: + raise HTTPException(status_code=409, detail="Multiple users found") data = res[0] - return data - - @classmethod - async def login(cls, credentials) -> UserSafe: - data = await cls._login(credentials) user = cls( - dn=str(data["dn"]), - email=credentials.email, + email=data["mail"][0], sn=data["sn"][0], cn=data["cn"][0], + dn=str(data["dn"]), uid=data["uid"][0], + userClass=data["userClass"][0], ) return user + async def save(self): + _, domain = self.email.split("@") + data = LDAPEntry(self.dn) + data["objectClass"] = config.ldap.userClasses + data["uid"] = self.uid + data["cn"] = self.uid + data["sn"] = self.uid + data["uidNumber"] = 65535 + data["gidNumber"] = 65535 + data["homeDirectory"] = f"/var/mail/domains/{domain}/{self.uid}" + data.change_attribute("userPassword", LDAPModOp.REPLACE, self.password) + data["mail"] = self.email + await save_data(data) -class User(UserSafe): - password: str = Field("", description=("Password")) + async def update(self, active=False, **kwargs): + client = get_client() + userclass = "disabled" + if active: + userclass = "enabled" + async with client.connect(is_async=True) as conn: + res = await conn.search(self.dn, LDAPSearchScope.BASE) + data = res[0] + data["userClass"] = userclass + self.userClass = userclass + for field in kwargs: + data[field] = kwargs[field] + await data.modify() + for field in kwargs: + setattr(self, field, kwargs[field]) class UserOptional(User): diff --git a/pyproject.toml b/pyproject.toml index 1648741..da22a45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,24 @@ classifiers = [ "pytest-factoryboy", "requests", ] + all = [ + "beanie", + "aiosqlite", + "black", + "isort", + "uvicorn", + "bonsai", + "alembic", + "ormar", + "aiosqlite", + "bandit", + "black", + "httpx", + "isort", + "pytest-asyncio", + "pytest-factoryboy", + "requests", + ] [project.urls] Homepage = "https://freenit.org"