diff --git a/bin/common.sh b/bin/common.sh index 8c492fb..436a2f2 100644 --- a/bin/common.sh +++ b/bin/common.sh @@ -21,14 +21,9 @@ setup() { fi . ${HOME}/.virtualenvs/${VIRTUALENV}/bin/activate - INSTALL_TARGET=".[${DB_TYPE}" - if [ "${FREENIT_ENV}" != "prod" ]; then - INSTALL_TARGET="${INSTALL_TARGET},${FREENIT_ENV}" - fi - INSTALL_TARGET="${INSTALL_TARGET}]" if [ "${1}" != "no" -a "${OFFLINE}" != "yes" ]; then ${PIP_INSTALL} pip wheel - ${PIP_INSTALL} -e "${INSTALL_TARGET}" + ${PIP_INSTALL} -e ".[${DB_TYPE},${FREENIT_ENV}]" fi fi diff --git a/bin/devel.sh b/bin/devel.sh index 6fe059d..e0c0b0a 100755 --- a/bin/devel.sh +++ b/bin/devel.sh @@ -1,7 +1,7 @@ #!/bin/sh BIN_DIR=`dirname $0` -export FREENIT_ENV="dev" +export FREENIT_ENV="dev,all" export OFFLINE=${OFFLINE:="no"} diff --git a/bin/freenit.sh b/bin/freenit.sh index 994711c..c42094a 100755 --- a/bin/freenit.sh +++ b/bin/freenit.sh @@ -45,8 +45,8 @@ export SED_CMD="sed -i" backend() { PROJECT_ROOT=`python${PY_VERSION} -c 'import os; import freenit; print(os.path.dirname(os.path.abspath(freenit.__file__)))'` - mkdir backend - cd backend + mkdir "${NAME}" + cd "${NAME}" cp -r ${PROJECT_ROOT}/project/* . case `uname` in *BSD) @@ -215,6 +215,7 @@ echo "========" cd "\${PROJECT_ROOT}" rm -rf build yarn run build +touch build/.keep EOF chmod +x collect.sh @@ -395,8 +396,7 @@ EOF svelte() { yarn create svelte "${NAME}" - mv "${NAME}" frontend - cd frontend + cd "${NAME}" yarn install frontend_common yarn add --dev @zerodevx/svelte-toast @freenit-framework/svelte-base @@ -677,14 +677,13 @@ services/ vars.mk EOF - echo "DEVEL_MODE = YES" >vars.mk - echo "Creating services" mkdir services cd services echo "Creating backend" backend + mv "${NAME}" backend echo "Creating frontend" FRONTEND_TYPE=${FRONTEND_TYPE:=svelte} @@ -696,6 +695,7 @@ EOF help >&2 exit 1 fi + mv "${NAME}" frontend cd .. } diff --git a/freenit/api/auth.py b/freenit/api/auth.py index 7a81951..1ba8593 100644 --- a/freenit/api/auth.py +++ b/freenit/api/auth.py @@ -1,7 +1,5 @@ from email.mime.text import MIMEText -import ormar -import ormar.exceptions import pydantic from fastapi import Header, HTTPException, Request, Response @@ -36,39 +34,34 @@ class Verification(pydantic.BaseModel): @api.post("/auth/login", response_model=LoginResponse, tags=["auth"]) async def login(credentials: LoginInput, response: Response): - try: - user = await User.objects.get(email=credentials.email, active=True) - valid = user.check(credentials.password) - if valid: - access = encode(user) - refresh = encode(user) - response.set_cookie( - "access", - access, - httponly=True, - secure=config.auth.secure, - ) - response.set_cookie( - "refresh", - refresh, - httponly=True, - secure=config.auth.secure, - ) - return { - "user": user.dict(exclude={"password"}), - "expire": { - "access": config.auth.expire, - "refresh": config.auth.refresh_expire, - }, - } - except ormar.exceptions.NoMatch: - pass - raise HTTPException(status_code=403, detail="Failed to login") + user = await User.login(credentials) + access = encode(user) + refresh = encode(user) + response.set_cookie( + "access", + access, + httponly=True, + secure=config.auth.secure, + ) + response.set_cookie( + "refresh", + refresh, + httponly=True, + secure=config.auth.secure, + ) + return { + "user": user, + "expire": { + "access": config.auth.expire, + "refresh": config.auth.refresh_expire, + }, + } @api.post("/auth/register", tags=["auth"]) async def register(credentials: LoginInput, host=Header(default="")): - print("host", host) + import ormar.exceptions + try: user = await User.objects.get(email=credentials.email) raise HTTPException(status_code=409, detail="User already registered") diff --git a/freenit/api/user.py b/freenit/api/user.py index 423adaf..762649a 100644 --- a/freenit/api/user.py +++ b/freenit/api/user.py @@ -4,6 +4,7 @@ from freenit.api.router import route from freenit.auth import encrypt +from freenit.config import getConfig from freenit.decorators import description from freenit.models.pagination import Page, paginate from freenit.models.safe import UserSafe @@ -12,6 +13,8 @@ tags = ["user"] +config = getConfig() + @route("/users", tags=tags) class UserListAPI: @@ -22,7 +25,40 @@ async def get( perpage: int = Header(default=10), _: User = Depends(user_perms), ) -> Page[UserSafe]: - return await paginate(User.objects, page, perpage) + if User.Meta.type == "ormar": + return await paginate(User.objects, page, perpage) + elif User.Meta.type == "bonsai": + import bonsai + + client = bonsai.LDAPClient(f"ldap://{config.ldap.host}", config.ldap.tls) + try: + async with client.connect(is_async=True) as conn: + res = await conn.search( + f"dc=account,dc=ldap", + bonsai.LDAPSearchScope.SUB, + "objectClass=person", + ) + except bonsai.errors.AuthenticationError: + raise HTTPException(status_code=403, detail="Failed to login") + + data = [] + for udata in res: + email = udata.get("mail", None) + if email is None: + continue + user = User( + email=email[0], + sn=udata["sn"][0], + cn=udata["cn"][0], + dn=str(udata["dn"]), + uid=udata["uid"][0], + ) + data.append(user) + + total = len(res) + page = Page(total=total, page=1, pages=1, perpage=total, data=data) + return page + raise HTTPException(status_code=409, detail="Unknown user type") @route("/users/{id}", tags=tags) diff --git a/freenit/auth.py b/freenit/auth.py index 059b0a6..0b44667 100644 --- a/freenit/auth.py +++ b/freenit/auth.py @@ -1,6 +1,4 @@ import jwt -import ormar -import ormar.exceptions from fastapi import HTTPException, Request from passlib.hash import pbkdf2_sha256 @@ -17,16 +15,44 @@ async def decode(token): pk = data.get("pk", None) if pk is None: raise HTTPException(status_code=403, detail="Unauthorized") - try: - user = await User.objects.get(pk=pk) + if User.Meta.type == "ormar": + import ormar + import ormar.exceptions + + try: + user = await User.objects.get(pk=pk) + return user + except ormar.exceptions.NoMatch: + raise HTTPException(status_code=403, detail="Unauthorized") + elif User.Meta.type == "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], + ) return user - except ormar.exceptions.NoMatch: - raise HTTPException(status_code=403, detail="Unauthorized") + raise HTTPException(status_code=409, detail="Unknown user type") def encode(user): config = getConfig() - payload = {"pk": user.pk} + payload = {} + if user.Meta.type == "ormar": + payload = {"pk": user.pk, "type": user.Meta.type} + elif user.Meta.type == "bonsai": + payload = {"pk": user.dn, "type": user.Meta.type} return jwt.encode(payload, config.secret, algorithm="HS256") @@ -35,27 +61,31 @@ async def authorize(request: Request, roles=[], allof=[], cookie="access"): if not token: raise HTTPException(status_code=403, detail="Unauthorized") user = await decode(token) - await user.load_all() - if not user.active: - raise HTTPException(status_code=403, detail="Permission denied") - if user.admin: - return user - if len(user.roles) == 0: - if len(roles) > 0 or len(allof) > 0: + if user.Meta.type == "ormar": + await user.load_all() + if not user.active: raise HTTPException(status_code=403, detail="Permission denied") - else: - if len(roles) > 0: - found = False - for role in user.roles: - if role.name in roles: - found = True - break - if not found: + if user.admin: + return user + if len(user.roles) == 0: + if len(roles) > 0 or len(allof) > 0: raise HTTPException(status_code=403, detail="Permission denied") - if len(allof) > 0: - for role in user.roles: - if role.name not in allof: + else: + if len(roles) > 0: + found = False + for role in user.roles: + if role.name in roles: + found = True + break + if not found: raise HTTPException(status_code=403, detail="Permission denied") + if len(allof) > 0: + for role in user.roles: + if role.name not in allof: + raise HTTPException(status_code=403, detail="Permission denied") + return user + # elif user.Meta.type == "bonsai": + # pass return user diff --git a/freenit/base_config.py b/freenit/base_config.py index 0f8a01d..dbc64ad 100644 --- a/freenit/base_config.py +++ b/freenit/base_config.py @@ -9,6 +9,15 @@ hour = 60 * minute day = 24 * hour year = 365 * day +register_message = """Hello, + +Please confirm user registration by following this link + +{} + +Regards, +Freenit +""" class Auth: @@ -28,14 +37,7 @@ def __init__( tls=True, from_addr="no-reply@mail.com", register_subject="[Freenit] User Registration", - register_message="""Hello, - -Please confirm user registration by following this link - -{} - -Regards, -Freenit""", + register_message=register_message, ) -> None: self.server = server self.user = user @@ -47,6 +49,15 @@ def __init__( self.register_message = register_message +class LDAP: + def __init__( + self, host="ldap.example.com", tls=True, base="uid={},ou={},dc=account,dc=ldap" + ): + self.host = host + self.tls = tls + self.base = base + + class BaseConfig: name = "Freenit" version = "0.0.1" @@ -66,6 +77,7 @@ class BaseConfig: meta = None auth = Auth() mail = Mail() + ldap = LDAP() def __init__(self): self.database = databases.Database(self.dburl) diff --git a/freenit/models/ldap/__init__.py b/freenit/models/ldap/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/freenit/models/ldap/base.py b/freenit/models/ldap/base.py new file mode 100644 index 0000000..544687b --- /dev/null +++ b/freenit/models/ldap/base.py @@ -0,0 +1,19 @@ +from typing import Generic, TypeVar + +from pydantic import EmailStr, Field, generics + +T = TypeVar("T") + + +class LDAPBaseModel(generics.GenericModel, Generic[T]): + class Meta: + type = "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")) diff --git a/freenit/models/ldap/user.py b/freenit/models/ldap/user.py new file mode 100644 index 0000000..5dae20f --- /dev/null +++ b/freenit/models/ldap/user.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from bonsai import LDAPClient, LDAPSearchScope, errors +from fastapi import HTTPException +from pydantic import Field + +from freenit.config import getConfig +from freenit.models.ldap.base import LDAPBaseModel, LDAPUserMixin + +config = getConfig() + + +class UserSafe(LDAPBaseModel, LDAPUserMixin): + @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) + 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") + + 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, + sn=data["sn"][0], + cn=data["cn"][0], + uid=data["uid"][0], + ) + return user + + +class User(UserSafe): + password: str = Field("", description=("Password")) + + +class UserOptional(User): + pass + + +UserOptionalPydantic = UserOptional diff --git a/freenit/models/ormar.py b/freenit/models/ormar.py deleted file mode 100644 index fe20c48..0000000 --- a/freenit/models/ormar.py +++ /dev/null @@ -1,37 +0,0 @@ -import ormar -import pydantic - -from ..auth import verify -from ..config import getConfig -from .metaclass import AllOptional - -config = getConfig() - - -class OrmarBaseModel(ormar.Model): - async def patch(self, fields): - result = {} - data = fields.dict() - for k in data: - if data[k] is not None: - result[k] = data[k] - return await self.update(**result) - - -class OrmarUserMixin: - id: int = ormar.Integer(primary_key=True) - email: pydantic.EmailStr = ormar.Text(unique=True) - password: str = ormar.Text() - active: bool = ormar.Boolean(default=False) - - -class User(OrmarBaseModel, OrmarUserMixin): - class Meta(config.meta): - tablename = "users" - - def check(self, password: str) -> bool: - return verify(password, self.password) - - -class UserOptional(User, metaclass=AllOptional): - pass diff --git a/freenit/models/ormar/base.py b/freenit/models/ormar/base.py index 4ccd7d1..a92d4e3 100644 --- a/freenit/models/ormar/base.py +++ b/freenit/models/ormar/base.py @@ -1,8 +1,5 @@ import ormar import pydantic -from freenit.config import getConfig - -config = getConfig() class OrmarBaseModel(ormar.Model): diff --git a/freenit/models/ormar/user.py b/freenit/models/ormar/user.py index a93d917..ef4e61f 100644 --- a/freenit/models/ormar/user.py +++ b/freenit/models/ormar/user.py @@ -1,4 +1,8 @@ +from __future__ import annotations + import ormar +import ormar.exceptions +from fastapi import HTTPException from freenit.auth import verify from freenit.config import getConfig @@ -9,15 +13,31 @@ config = getConfig() +class BaseUser(OrmarBaseModel, OrmarUserMixin): + def check(self, password: str) -> bool: + return verify(password, self.password) + + @classmethod + async def login(cls, credentials) -> BaseUser: + try: + user = await cls.objects.get(email=credentials.email, active=True) + except ormar.exceptions.NoMatch: + raise HTTPException(status_code=403, detail="Failed to login") + if user.check(credentials.password): + return user + raise HTTPException(status_code=403, detail="Failed to login") + + class User(OrmarBaseModel, OrmarUserMixin): class Meta(config.meta): + type = "ormar" tablename = "users" roles = ormar.ManyToMany(Role, unique=True) - def check(self, password: str) -> bool: - return verify(password, self.password) - class UserOptional(User, metaclass=AllOptional): pass + + +UserOptionalPydantic = UserOptional.get_pydantic(exclude={"admin", "active"}) diff --git a/freenit/models/safe.py b/freenit/models/safe.py index 77b4b67..73c9d1b 100644 --- a/freenit/models/safe.py +++ b/freenit/models/safe.py @@ -1,14 +1,23 @@ -from ormar.queryset.field_accessor import FieldAccessor - from freenit.models.role import Role from freenit.models.user import User -RoleSafe = Role.get_pydantic(exclude={"users__password"}) +if User.Meta.type == "ormar": + from ormar.queryset.field_accessor import FieldAccessor + + RoleSafe = Role.get_pydantic(exclude={"users__password"}) + + include_fields = set() + for attr in dir(User): + a = getattr(User, attr, None) + if isinstance(a, FieldAccessor): + include_fields.add(attr) + + UserSafe = User.get_pydantic(exclude={"password"}, include=include_fields) +elif User.Meta.type == "bonsai": + from freenit.config import getConfig -include_fields = set() -for attr in dir(User): - a = getattr(User, attr, None) - if isinstance(a, FieldAccessor): - include_fields.add(attr) + config = getConfig() + auth = config.get_model("user") -UserSafe = User.get_pydantic(exclude={"password"}, include=include_fields) + UserSafe = auth.UserSafe + RoleSafe = Role diff --git a/freenit/models/user.py b/freenit/models/user.py index 006066d..c3658c4 100644 --- a/freenit/models/user.py +++ b/freenit/models/user.py @@ -1,10 +1,6 @@ -from typing import List - -from pydantic import BaseModel - from freenit.config import getConfig config = getConfig() auth = config.get_model("user") User = auth.User -UserOptional = auth.UserOptional.get_pydantic(exclude={"admin", "active"}) +UserOptional = auth.UserOptionalPydantic diff --git a/requirements.yml b/requirements.yml index 5d2428b..3e10573 100644 --- a/requirements.yml +++ b/requirements.yml @@ -1 +1,4 @@ - onelove-roles.freebsd-common +- onelove-roles.freebsd_freenit +- onelove-roles.freebsd_freenit_sql +- onelove-roles.freebsd_freenit_ldap diff --git a/setup.py b/setup.py index 70e98a7..1f5d768 100644 --- a/setup.py +++ b/setup.py @@ -9,6 +9,40 @@ PROJECT_ROOT = pathlib.Path(__file__).parent README = (PROJECT_ROOT / "README.md").read_text() +extras_require = { + "beanie": [ + "beanie", + ], + "build": [ + "twine", + ], + "dev": [ + "aiosqlite", + "black", + "isort", + "uvicorn", + ], + "ldap": [ + "bonsai", + ], + "ormar": [ + "alembic", + "ormar", + ], + "test": [ + "aiosqlite", + "black", + "isort", + "pytest-asyncio", + "pytest-factoryboy", + "requests", + ], +} + +extras_require["all"] = ( + extras_require["beanie"] + extras_require["ldap"] + extras_require["ormar"] +) + setup( name="freenit", version=version, @@ -44,32 +78,7 @@ "pydantic[email]", "pyjwt", ], - extras_require={ - "beanie": [ - "beanie", - ], - "build": [ - "twine", - ], - "dev": [ - "aiosqlite", - "black", - "isort", - "uvicorn", - ], - "ormar": [ - "alembic", - "ormar", - ], - "test": [ - "aiosqlite", - "black", - "isort", - "pytest-asyncio", - "pytest-factoryboy", - "requests", - ], - }, + extras_require=extras_require, include_package_data=True, package_data={ "": [ diff --git a/templates/site.yml.tpl b/templates/site.yml.tpl index b071eab..8cf589c 100644 --- a/templates/site.yml.tpl +++ b/templates/site.yml.tpl @@ -6,4 +6,7 @@ hosts: SERVICE roles: - onelove-roles.freebsd-common + - onelove-roles.freebsd_freenit + - onelove-roles.freebsd_freenit_sql + - onelove-roles.freebsd_freenit_ldap - devel