diff --git a/freenit/api/auth/__init__.py b/freenit/api/auth/__init__.py index 7ae3555..c56cfe4 100644 --- a/freenit/api/auth/__init__.py +++ b/freenit/api/auth/__init__.py @@ -102,7 +102,7 @@ async def register(credentials: LoginInput, host=Header(default="")): msg["From"] = mail.from_addr msg["Subject"] = mail.register_subject sendmail(user.email, msg) - return {"status": True} + return user @api.post("/auth/verify", response_model=UserSafe, tags=["auth"]) diff --git a/freenit/api/role/__init__.py b/freenit/api/role/__init__.py index 3b7b46e..190e1d6 100644 --- a/freenit/api/role/__init__.py +++ b/freenit/api/role/__init__.py @@ -4,3 +4,4 @@ from .sql import RoleListAPI, RoleDetailAPI, RoleUserAPI elif Role.dbtype() == "ldap": from .ldap import RoleListAPI, RoleDetailAPI, RoleUserAPI + from .ldap_group import GroupListAPI, GroupDetailAPI, GroupUserAPI diff --git a/freenit/api/role/ldap.py b/freenit/api/role/ldap.py index 4465ac3..21c4db9 100644 --- a/freenit/api/role/ldap.py +++ b/freenit/api/role/ldap.py @@ -2,15 +2,17 @@ from fastapi import Depends, Header, HTTPException from freenit.api.router import route +from freenit.config import getConfig from freenit.decorators import description -from freenit.models.ldap.base import get_client +from freenit.models.ldap.role import RoleCreate from freenit.models.pagination import Page from freenit.models.role import Role -from freenit.models.safe import RoleSafe, UserSafe +from freenit.models.safe import RoleSafe from freenit.models.user import User from freenit.permissions import role_perms tags = ["role"] +config = getConfig() @route("/roles", tags=tags) @@ -19,68 +21,60 @@ class RoleListAPI: @description("Get roles") async def get( page: int = Header(default=1), - _: int = Header(default=10), - user: User = Depends(role_perms), + perpage: int = Header(default=10), + _: User = Depends(role_perms), ) -> Page[RoleSafe]: data = await Role.get_all() - total = len(data) - page = Page(total=total, page=1, pages=1, perpage=total, data=data) - return page + perpage = len(data) + data = Page(total=perpage, page=page, pages=1, perpage=perpage, data=data) + return data @staticmethod - async def post(role: Role, user: User = Depends(role_perms)) -> RoleSafe: + async def post(data: RoleCreate, user: User = Depends(role_perms)) -> RoleSafe: + if data.name == "": + raise HTTPException(status_code=409, detail="Name is mandatory") + role = Role.create(data.name) try: - await role.create(user) + await role.save(user) except bonsai.errors.AlreadyExists: raise HTTPException(status_code=409, detail="Role already exists") return role -@route("/roles/{id}", tags=tags) +@route("/roles/{name}", tags=tags) class RoleDetailAPI: @staticmethod - async def get(id, _: User = Depends(role_perms)) -> RoleSafe: - role = await Role.get(id) + async def get(name, _: User = Depends(role_perms)) -> RoleSafe: + role = await Role.get(name) return role @staticmethod - async def delete(id, _: User = Depends(role_perms)) -> RoleSafe: - client = get_client() + async def delete(name, _: User = Depends(role_perms)) -> RoleSafe: 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"]), - users=existing["uniqueMember"], - ) - await existing.delete() - return role + role = await Role.get(name) + await role.destroy() + return role except bonsai.errors.AuthenticationError: raise HTTPException(status_code=403, detail="Failed to login") -@route("/roles/{role_id}/{user_id}", tags=tags) +@route("/roles/{role_name}/{id}", tags=tags) class RoleUserAPI: @staticmethod @description("Assign user to role") - async def post(role_id, user_id, _: User = Depends(role_perms)) -> UserSafe: - user = await User.get(user_id) - role = await Role.get(role_id) + async def post(role_name, id, _: User = Depends(role_perms)) -> RoleSafe: + user = await User.get_by_uid(id) + role = await Role.get(role_name) await role.add(user) - return user + return role @staticmethod - @description("Deassign user to role") - async def delete(role_id, user_id, _: User = Depends(role_perms)) -> UserSafe: - user = await User.get(user_id) - role = await Role.get(role_id) + @description("Remove user from role") + async def delete(role_name, id, _: User = Depends(role_perms)) -> RoleSafe: + user = await User.get_by_uid(id) + role = await Role.get(role_name) + if len(role.users) == 1: + if role.users[0] == user.dn: + raise HTTPException(status_code=409, detail="Can not remove last member") await role.remove(user) - return user + return role diff --git a/freenit/api/role/ldap_group.py b/freenit/api/role/ldap_group.py new file mode 100644 index 0000000..4304517 --- /dev/null +++ b/freenit/api/role/ldap_group.py @@ -0,0 +1,77 @@ +import bonsai +from fastapi import Depends, Header, HTTPException + +from freenit.api.router import route +from freenit.config import getConfig +from freenit.decorators import description +from freenit.models.ldap.group import Group, GroupCreate +from freenit.models.pagination import Page +from freenit.models.user import User +from freenit.permissions import group_perms + +tags = ["group"] +config = getConfig() + + +@route("/groups/{domain}", tags=tags) +class GroupListAPI: + @staticmethod + @description("Get groups") + async def get( + domain: str, + page: int = Header(default=1), + perpage: int = Header(default=10), + ) -> Page[Group]: + data = await Group.get_all(domain) + total = len(data) + data = Page(total=total, page=1, pages=1, perpage=total, data=data) + return data + + @staticmethod + async def post( + domain: str, data: GroupCreate, _: User = Depends(group_perms) + ) -> Group: + if data.name == "": + raise HTTPException(status_code=409, detail="Name is mandatory") + group = Group.create(data.name, domain) + try: + await group.save() + except bonsai.errors.AlreadyExists: + raise HTTPException(status_code=409, detail="Group already exists") + return group + + +@route("/groups/{domain}/{name}", tags=tags) +class GroupDetailAPI: + @staticmethod + async def get(domain, name) -> Group: + group = await Group.get(name, domain) + return group + + @staticmethod + async def delete(domain, name) -> Group: + try: + group = await Group.get(name, domain) + await group.destroy() + return group + except bonsai.errors.AuthenticationError: + raise HTTPException(status_code=403, detail="Failed to login") + + +@route("/groups/{domain}/{name}/{uid}", tags=tags) +class GroupUserAPI: + @staticmethod + @description("Assign user to group") + async def post(domain, name, id, _: User = Depends(group_perms)) -> Group: + user = await User.get_by_uid(id) + group = await Group.get(name, domain) + await group.add(user) + return group + + @staticmethod + @description("Remove user from group") + async def delete(domain, name, id, _: User = Depends(group_perms)) -> Group: + user = await User.get_by_uid(id) + group = await Group.get(name, domain) + await group.remove(user) + return group diff --git a/freenit/api/user/ldap.py b/freenit/api/user/ldap.py index ac81766..c86136b 100644 --- a/freenit/api/user/ldap.py +++ b/freenit/api/user/ldap.py @@ -1,10 +1,9 @@ -import bonsai +from bonsai import errors from fastapi import Depends, Header, HTTPException from freenit.api.router import route from freenit.config import getConfig from freenit.decorators import description -from freenit.models.ldap.base import get_client from freenit.models.pagination import Page from freenit.models.safe import UserSafe from freenit.models.user import User, UserOptional @@ -26,15 +25,15 @@ async def get( ) -> Page[UserSafe]: users = await User.get_all() total = len(users) - page = Page(total=total, page=1, pages=1, perpage=total, data=users) - return page + data = Page(total=total, page=1, pages=1, perpage=total, data=users) + return data @route("/users/{id}", tags=tags) class UserDetailAPI: @staticmethod async def get(id, _: User = Depends(user_perms)) -> UserSafe: - user = await User.get(id) + user = await User.get_by_uid(id) return user @staticmethod @@ -50,29 +49,12 @@ async def patch(id, data: UserOptional, _: User = Depends(user_perms)) -> UserSa @staticmethod async def delete(id, _: User = Depends(user_perms)) -> UserSafe: - 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: + user = await User.get_by_uid(id) + await user.destroy() + except errors.AuthenticationError: raise HTTPException(status_code=403, detail="Failed to login") + return user @route("/profile", tags=["profile"]) diff --git a/freenit/auth.py b/freenit/auth.py index 392f17c..c35e5c3 100644 --- a/freenit/auth.py +++ b/freenit/auth.py @@ -34,9 +34,9 @@ def encode(user): config = getConfig() payload = {} if user.dbtype() == "sql": - payload = {"pk": user.pk, "type": "ormar"} + payload = {"pk": user.pk, "type": "sql"} elif user.dbtype() == "ldap": - payload = {"pk": user.dn, "type": "bonsai"} + payload = {"pk": user.dn, "type": "ldap"} return jwt.encode(payload, config.secret, algorithm="HS256") diff --git a/freenit/base_config.py b/freenit/base_config.py index 3a549f7..5b4b615 100644 --- a/freenit/base_config.py +++ b/freenit/base_config.py @@ -58,19 +58,46 @@ def __init__( self, host="ldap.example.com", tls=True, - base="uid={},ou={},dc=account,dc=ldap", service_dn="cn=freenit,dc=service,dc=ldap", service_pw="", + roleDN="cn={}", + roleBase="dc=group,dc=ldap", + roleClasses=["groupOfUniqueNames"], + roleMemberAttr="uniqueMember", + groupBase="ou={},dc=group,dc=ldap", + groupDN="cn={}", + groupClasses=["posixGroup"], + userBase="dc=account,dc=ldap", + userDN="uid={},ou={}", userClasses=["pilotPerson", "posixAccount"], - groupClasses=["groupOfUniqueNames"], + userMemberAttr="memberOf", + uidNextClass="uidNext", + uidNextDN="cn=uidnext,dc=ldap", + uidNextField="uidNumber", + gidNextClass="gidNext", + gidNextDN="cn=gidnext,dc=ldap", + gidNextField="gidNumber", ): self.host = host self.tls = tls - self.base = base self.service_dn = service_dn self.service_pw = service_pw - self.userClasses = userClasses + self.roleBase = roleBase + self.roleClasses = roleClasses + self.roleDN = f"{roleDN},{roleBase}" + self.roleMemberAttr = roleMemberAttr self.groupClasses = groupClasses + self.groupDN = f"{groupDN},{groupBase}" + self.userBase = userBase + self.userDN = f"{userDN},{userBase}" + self.userClasses = userClasses + self.userMemberAttr = userMemberAttr + self.uidNextClass = uidNextClass + self.uidNextDN = uidNextDN + self.uidNextField = uidNextField + self.gidNextClass = gidNextClass + self.gidNextDN = gidNextDN + self.gidNextField = gidNextField class BaseConfig: diff --git a/freenit/models/ldap/base.py b/freenit/models/ldap/base.py index 7a1039a..d464f5d 100644 --- a/freenit/models/ldap/base.py +++ b/freenit/models/ldap/base.py @@ -1,7 +1,6 @@ from typing import Generic, TypeVar from bonsai import LDAPClient, LDAPSearchScope, errors -from bonsai.errors import AuthenticationError, InsufficientAccess, UnwillingToPerform from fastapi import HTTPException from pydantic import BaseModel, Field @@ -15,7 +14,7 @@ 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) + dn = config.ldap.userDN.format(username, domain) client.set_credentials("SIMPLE", user=dn, password=credentials.password) else: dn = config.ldap.service_dn @@ -28,61 +27,57 @@ async def save_data(data): 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" + except errors.InsufficientAccess: + raise HTTPException(status_code=403, detail="No permission to create group") + + +async def next_uid(increment=True) -> int: + client = get_client() + try: + async with client.connect(is_async=True) as conn: + res = await conn.search( + config.ldap.uidNextDN, + LDAPSearchScope.BASE, + f"objectClass={config.ldap.uidNextClass}", ) + if len(res) < 1: + raise HTTPException(status_code=404, detail="Can not find next UID") + uidNext = int(res[0][config.ldap.uidNextField][0]) + if increment: + res[0][config.ldap.uidNextField] = uidNext + 1 + await res[0].modify() + return uidNext + except errors.AuthenticationError: + raise HTTPException(status_code=403, detail="Failed to login") -class LDAPBaseModel(BaseModel, Generic[T]): - @classmethod - def dbtype(cls): - return "ldap" +async def next_gid(increment=True): + client = get_client() + try: + async with client.connect(is_async=True) as conn: + res = await conn.search( + config.ldap.gidNextDN, + LDAPSearchScope.BASE, + f"objectClass={config.ldap.gidNextClass}", + ) + if len(res) < 1: + raise HTTPException(status_code=404, detail="Can not find next GID") + gidNext = int(res[0][config.ldap.gidNextField][0]) + if increment: + res[0][config.ldap.gidNextField] = gidNext + 1 + await res[0].modify() + return gidNext + except errors.AuthenticationError: + raise HTTPException(status_code=403, detail="Failed to login") - dn: str = Field("", description=("Distinguished name")) +def class2filter(classes): + return "".join([f"(objectClass={group})" for group in classes]) -class LDAPUserMixin: - @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 +class LDAPBaseModel(BaseModel, Generic[T]): + dn: str = Field("", description=("Distinguished name")) @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 + def dbtype(cls): + return "ldap" diff --git a/freenit/models/ldap/group.py b/freenit/models/ldap/group.py new file mode 100644 index 0000000..3826be6 --- /dev/null +++ b/freenit/models/ldap/group.py @@ -0,0 +1,122 @@ +from bonsai import LDAPEntry, LDAPSearchScope, errors +from fastapi import HTTPException +from pydantic import BaseModel, Field + +from freenit.config import getConfig +from freenit.models.ldap.base import LDAPBaseModel, get_client, save_data, class2filter + +config = getConfig() + + +class Group(LDAPBaseModel): + cn: str = Field("", description=("Common name")) + users: list = Field([], description=("Group members")) + + @classmethod + def from_entry(cls, entry): + group = cls( + cn=entry["cn"][0], + dn=str(entry["dn"]), + users=entry["memberUid"], + ) + return group + + @classmethod + def create(cls, name, domain): + group = Group(dn=config.ldap.groupDn.format(name, domain), cn=name, users=[]) + return group + + @classmethod + async def get(cls, name, domain): + classes = class2filter(config.ldap.groupClasses) + client = get_client() + try: + async with client.connect(is_async=True) as conn: + dn = config.ldap.groupDN.format(name, domain) + res = await conn.search(dn, LDAPSearchScope.SUB, f"(|{classes})") + except errors.AuthenticationError: + raise HTTPException(status_code=403, detail="Failed to login") + if len(res) < 1: + raise HTTPException(status_code=404, detail="No such group") + if len(res) > 1: + raise HTTPException(status_code=409, detail="Multiple groups found") + data = res[0] + group = cls.from_entry(data) + return group + + @classmethod + async def get_all(cls, domain): + classes = class2filter(config.ldap.groupClasses) + client = get_client() + try: + async with client.connect(is_async=True) as conn: + dn = config.ldap.groupBase.format(domain) + res = await conn.search(dn, LDAPSearchScope.SUB, f"(|{classes})") + data = [] + for gdata in res: + data.append(cls.from_entry(gdata)) + except errors.AuthenticationError: + raise HTTPException(status_code=403, detail="Failed to login") + return data + + async def save(self): + data = LDAPEntry(self.dn) + data["objectClass"] = config.ldap.groupClasses + data["gidNumber"] = 0 + await save_data(data) + + async def destroy(self): + client = get_client() + try: + async with client.connect(is_async=True) as conn: + await conn.delete(self.dn) + except errors.AuthenticationError: + raise HTTPException(status_code=403, detail="Failed to login") + + async def add(self, user): + classes = class2filter(config.ldap.groupClasses) + client = get_client() + try: + async with client.connect(is_async=True) as conn: + res = await conn.search(self.dn, LDAPSearchScope.BASE, f"(|{classes})") + if len(res) < 1: + raise HTTPException(status_code=404, detail="No such group") + if len(res) > 1: + raise HTTPException(status_code=409, detail="Multiple groups found") + data = res[0] + try: + data["memberUid"].append(user.uidNumber) + except ValueError: + raise HTTPException( + status_code=409, detail="User is already member of the group" + ) + await data.modify() + except errors.AuthenticationError: + raise HTTPException(status_code=403, detail="Failed to login") + self.users.append(user.uidNumber) + + async def remove(self, user): + classes = class2filter(config.ldap.groupClasses) + client = get_client() + try: + async with client.connect(is_async=True) as conn: + res = await conn.search(self.dn, LDAPSearchScope.BASE, f"(|{classes})") + if len(res) < 1: + raise HTTPException(status_code=404, detail="No such group") + if len(res) > 1: + raise HTTPException(status_code=409, detail="Multiple groups found") + data = res[0] + try: + data["memberUid"].remove(user.uidNumber) + except ValueError: + raise HTTPException( + status_code=409, detail="User is not member of the group" + ) + await data.modify() + except errors.AuthenticationError: + raise HTTPException(status_code=403, detail="Failed to login") + self.users.remove(user.uidNumber) + + +class GroupCreate(BaseModel): + name: str = Field(description=("Common name")) diff --git a/freenit/models/ldap/role.py b/freenit/models/ldap/role.py index a165707..ab6c17b 100644 --- a/freenit/models/ldap/role.py +++ b/freenit/models/ldap/role.py @@ -1,9 +1,9 @@ from bonsai import LDAPEntry, LDAPSearchScope, errors from fastapi import HTTPException -from pydantic import Field +from pydantic import BaseModel, Field from freenit.config import getConfig -from freenit.models.ldap.base import LDAPBaseModel, get_client, save_data +from freenit.models.ldap.base import LDAPBaseModel, get_client, save_data, class2filter config = getConfig() @@ -13,101 +13,108 @@ class Role(LDAPBaseModel): users: list = Field([], description=("Role members")) @classmethod - async def get(cls, dn): + def from_entry(cls, entry): + return cls( + cn=entry["cn"][0], + dn=str(entry["dn"]), + users=entry[config.ldap.roleMemberAttr], + ) + + + @classmethod + def create(cls, name): + dn=config.ldap.roleDN.format(name) + return Role(dn=dn, cn=name, users=[]) + + @classmethod + async def get(cls, name): + classes = class2filter(config.ldap.roleClasses) client = get_client() + dn=config.ldap.roleDN.format(name) try: async with client.connect(is_async=True) as conn: - res = await conn.search( - dn, - LDAPSearchScope.SUB, - "objectClass=groupOfUniqueNames", - ) + res = await conn.search(dn, LDAPSearchScope.SUB, f"(|{classes})") 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"]), - users=data["uniqueMember"], - ) - return role + return cls.from_entry(res[0]) @classmethod async def get_all(cls): + classes = class2filter(config.ldap.roleClasses) client = get_client() try: async with client.connect(is_async=True) as conn: res = await conn.search( - f"dc=group,dc=ldap", + config.ldap.roleBase, LDAPSearchScope.SUB, - "objectClass=groupOfUniqueNames", + f"(|{classes})", ) data = [] for gdata in res: - role = Role( - cn=gdata["cn"][0], - dn=str(gdata["dn"]), - users=gdata["uniqueMember"], - ) + role = cls.from_entry(gdata) data.append(role) except errors.AuthenticationError: raise HTTPException(status_code=403, detail="Failed to login") return data - - async def create(self, user): + async def save(self, user): data = LDAPEntry(self.dn) - data["objectClass"] = config.ldap.groupClasses - data["cn"] = self.cn - data["uniqueMember"] = user.dn + data["objectClass"] = config.ldap.roleClasses + data[config.ldap.roleMemberAttr] = user.dn await save_data(data) - self.users = data["uniqueMember"] + self.users = [user.dn] async def add(self, user): + classes = class2filter(config.ldap.roleClasses) client = get_client() try: async with client.connect(is_async=True) as conn: - res = await conn.search( - self.dn, LDAPSearchScope.BASE, "objectClass=groupOfUniqueNames" - ) + res = await conn.search(self.dn, LDAPSearchScope.BASE, f"(|{classes})") 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) + data[config.ldap.roleMemberAttr].append(user.dn) except ValueError: - raise HTTPException(status_code=409, detail="User is already member of the role") + 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.users.append(user) + self.users.append(user.dn) async def remove(self, user): + classes = class2filter(config.ldap.roleClasses) client = get_client() try: async with client.connect(is_async=True) as conn: - res = await conn.search( - self.dn, LDAPSearchScope.BASE, "objectClass=groupOfUniqueNames" - ) + res = await conn.search(self.dn, LDAPSearchScope.BASE, f"(|{classes})") 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) + data[config.ldap.roleMemberAttr].remove(user.dn) except ValueError: - raise HTTPException(status_code=409, detail="User is not member of the role") + 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.users.remove(user) + self.users.remove(user.dn) + + +class RoleCreate(BaseModel): + name: str = Field(description=("Common name")) RoleOptional = Role diff --git a/freenit/models/ldap/user.py b/freenit/models/ldap/user.py index 1162f82..17c03f7 100644 --- a/freenit/models/ldap/user.py +++ b/freenit/models/ldap/user.py @@ -5,111 +5,254 @@ from pydantic import EmailStr, Field from freenit.config import getConfig -from freenit.models.ldap.base import LDAPBaseModel, LDAPUserMixin, get_client, save_data +from freenit.models.ldap.base import ( + LDAPBaseModel, + class2filter, + get_client, + save_data, + next_uid, + next_gid, +) config = getConfig() -class UserSafe(LDAPBaseModel, LDAPUserMixin): +class UserSafe(LDAPBaseModel): 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")) roles: list = Field([], description=("Roles the user is a member of")) + uidNumber: int = Field(0, description=("UID")) + gidNumber: int = Field(0, description=("GID")) + @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.userDN.format(username, domain) + res = await conn.search(dn, LDAPSearchScope.BASE, "objectClass=person") + except errors.ConnectionError: + raise HTTPException(status_code=409, detail="Can not connect to LDAP server") + except errors.AuthenticationError: + raise HTTPException(status_code=403, detail="Failed to login") + data = res[0] + return data -class User(UserSafe): - password: str = Field("", description=("Password")) + @classmethod + async def login(cls, credentials): + data = await cls._login(credentials) + user = cls.from_entry(data) + return user @classmethod - async def get(cls, dn): + async def register(cls, credentials): + client = get_client() + username, domain = credentials.email.split("@") + dn = config.ldap.userDN.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 errors.UnwillingToPerform: + raise HTTPException(status_code=409, detail="Can not bind to LDAP") + except errors.AuthenticationError: + raise HTTPException(status_code=409, detail="Can not bind to LDAP") + user = cls( + dn=dn, + cn="Common Name", + sn="Surname", + uid=username, + email=credentials.email, + userClass="disabled", + uidNumber=65535, + gidNumber=65535, + roles=[], + ) + return user + + @classmethod + def from_entry(cls, entry) -> UserSafe: + user = cls( + email=entry["mail"][0], + sn=entry["sn"][0], + cn=entry["cn"][0], + dn=str(entry["dn"]), + uid=entry["uid"][0], + userClass=entry["userClass"][0], + roles=entry.get("memberOf", []), + uidNumber=entry["uidNumber"][0], + gidNumber=entry["gidNumber"][0], + ) + return user + + @classmethod + async def get_all(cls): client = get_client() try: async with client.connect(is_async=True) as conn: res = await conn.search( - dn, LDAPSearchScope.BASE, + "dc=account,dc=ldap", + LDAPSearchScope.SUB, "objectClass=person", ["*", "memberOf"], ) except errors.AuthenticationError: raise HTTPException(status_code=403, detail="Failed to login") + + data = [] + for udata in res: + user = cls.from_entry(udata) + data.append(user) + return data + + @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.BASE, + "objectClass=person", + ["*", config.ldap.userMemberAttr], + ) + except errors.AuthenticationError: + raise HTTPException(status_code=403, detail="Failed to login as a service") + except errors.InvalidDN: + raise HTTPException(status_code=409, detail=f"Invalid DN: {dn}") 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] - user = cls( - email=data["mail"][0], - sn=data["sn"][0], - cn=data["cn"][0], - dn=str(data["dn"]), - uid=data["uid"][0], - userClass=data["userClass"][0], - roles=data.get("memberOf", []), - ) + user = cls.from_entry(data) 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) - - async def update(self, active=False, **kwargs): + @classmethod + async def get_by_uid(cls, uid: int): 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]) + try: + async with client.connect(is_async=True) as conn: + res = await conn.search( + config.ldap.userBase, + LDAPSearchScope.SUB, + f"(&(objectClass=person)(uidNumber={uid}))", + ["*", config.ldap.userMemberAttr], + ) + 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") + user = cls.from_entry(res[0]) + return user @classmethod - async def get_all(cls): + async def get_by_email(cls, email): client = get_client() + username, domain = email.split("@") + dn = config.ldap.userDN.format(username, domain) try: async with client.connect(is_async=True) as conn: res = await conn.search( - f"dc=account,dc=ldap", - LDAPSearchScope.SUB, + dn, + LDAPSearchScope.BASE, "objectClass=person", - ["*", "memberOf"], + ["*", config.ldap.userMemberAttr], ) 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") + user = cls.from_entry(res[0]) + return user - data = [] - for udata in res: - email = udata.get("mail", None) - if email is None: - continue - user = cls( - email=email[0], - sn=udata["sn"][0], - cn=udata["cn"][0], - dn=str(udata["dn"]), - uid=udata["uid"][0], - userClass=udata["userClass"][0], - roles=udata.get("memberOf", []), - ) - data.append(user) - return data + +class User(UserSafe): + password: str = Field("", description=("Password")) + + async def save(self): + try: + uidNext = await next_uid() + gidNext = await next_gid() + _, domain = self.email.split("@") + data = LDAPEntry(config.ldap.roleDN.format(self.uid, domain)) + data["objectClass"] = config.ldap.groupClasses + data["cn"] = self.uid + data["gidNumber"] = gidNext + data["memberUid"] = uidNext + await save_data(data) + + data = LDAPEntry(self.dn) + data["objectClass"] = config.ldap.userClasses + data["uid"] = self.uid + data["cn"] = self.uid + data["sn"] = self.uid + data["uidNumber"] = uidNext + data["gidNumber"] = gidNext + data["userClass"] = self.userClass + 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) + + self.uidNumber = uidNext + self.gidNumber = gidNext + except errors.AuthenticationError: + raise HTTPException(status_code=403, detail="Failed to login") + client = get_client() + async with client.connect(is_async=True) as conn: + await conn.modify_password(self.dn, self.password) + + async def update(self, active=False, **kwargs): + client = get_client() + userClass = "disabled" + if active: + userClass = "enabled" + special = {"password", "roles"} + filtered = {x: kwargs[x] for x in kwargs if x not in special} + async with client.connect(is_async=True) as conn: + res = await conn.search(self.dn, LDAPSearchScope.BASE) + data = res[0] + if len(filtered.keys()) > 0: + for field in filtered: + data[field] = filtered[field] + password = kwargs.get("password", None) + if password is not None: + await conn.modify_password(self.dn, password) + data["userClass"] = userClass + data.change_attribute("userClass", LDAPModOp.REPLACE, userClass) + self.userClass = userClass + await data.modify() + for field in filtered: + setattr(self, field, filtered[field]) + + async def destroy(self): + client = get_client() + async with client.connect(is_async=True) as conn: + classes = class2filter(config.ldap.groupClasses) + filter_exp=f"(&(memberUid={self.uidNumber}){classes})" + res = await conn.search(config.ldap.roleBase, LDAPSearchScope.SUB, filter_exp) + for group in res: + if len(group['memberUid']): + await group.delete() + classes = class2filter(config.ldap.roleClasses) + filter_exp=f"(&(uniqueMember={self.dn}){classes})" + res = await conn.search(config.ldap.roleBase, LDAPSearchScope.SUB, filter_exp) + for role in res: + if len(role['uniqueMember']): + raise ValueError(f"Can not destroy user as it is only member of role {role.cn}!") + res = await conn.search(self.dn, LDAPSearchScope.BASE) + data = res[0] + await data.delete() class UserOptional(User): diff --git a/freenit/models/safe.py b/freenit/models/safe.py index 55c4a17..8e1143c 100644 --- a/freenit/models/safe.py +++ b/freenit/models/safe.py @@ -1,13 +1,20 @@ from typing import List - from freenit.config import getConfig config = getConfig() +auth = config.get_model("user") -class UserSafe(config.get_model("user").User.get_pydantic(exclude={"password"})): - pass +if auth.User.dbtype() == 'sql': + UserBase = auth.User.get_pydantic(exclude={"password"}) + RoleBase = config.get_model("role").BaseRole +elif auth.User.dbtype() == 'ldap': + UserBase = auth.UserSafe + RoleBase = config.get_model("role").Role -class RoleSafe(config.get_model("role").BaseRole): - users: List[UserSafe] +class UserSafe(UserBase): + pass + +class RoleSafe(RoleBase): + users: List[str] diff --git a/freenit/permissions.py b/freenit/permissions.py index 5479e27..53db4b9 100644 --- a/freenit/permissions.py +++ b/freenit/permissions.py @@ -1,6 +1,7 @@ from freenit.auth import permissions role_perms = permissions() +group_perms = permissions() profile_perms = permissions() user_perms = permissions() theme_perms = permissions()