service_packages 4.0.22__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- auth/__init__.py +4 -0
- auth/api/__init__.py +18 -0
- auth/api/account_controller.py +120 -0
- auth/api/e2e_controller.py +16 -0
- auth/api/permission_controller.py +50 -0
- auth/api/role_controller.py +50 -0
- auth/api/user_controller.py +55 -0
- auth/factories.py +19 -0
- auth/fixtures.yaml +3 -0
- auth/guards.py +9 -0
- auth/loaders.py +30 -0
- auth/middleware.py +43 -0
- auth/models/__init__.py +16 -0
- auth/models/auth_code_model.py +10 -0
- auth/models/permission_model.py +8 -0
- auth/models/role_model.py +14 -0
- auth/models/role_permission_model.py +10 -0
- auth/models/user_model.py +18 -0
- auth/models/user_role_model.py +10 -0
- auth/plugin.py +50 -0
- auth/services/__init__.py +45 -0
- auth/services/auth_service.py +309 -0
- auth/services/permission_service.py +18 -0
- auth/services/role_service.py +21 -0
- auth/services/user_service.py +21 -0
- core/cli.py +10 -0
- core/database.py +16 -0
- core/mail.py +31 -0
- core/openapi.py +26 -0
- core/settings.py +46 -0
- core/storage.py +54 -0
- service_packages-4.0.22.dist-info/METADATA +63 -0
- service_packages-4.0.22.dist-info/RECORD +35 -0
- service_packages-4.0.22.dist-info/WHEEL +4 -0
- service_packages-4.0.22.dist-info/licenses/LICENSE.txt +19 -0
auth/__init__.py
ADDED
auth/api/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from litestar import Router
|
|
2
|
+
|
|
3
|
+
from .account_controller import AccountController
|
|
4
|
+
from .permission_controller import PermissionController
|
|
5
|
+
from .role_controller import RoleController
|
|
6
|
+
from .user_controller import UserController
|
|
7
|
+
|
|
8
|
+
auth_router = Router(
|
|
9
|
+
path="/api/auth",
|
|
10
|
+
route_handlers=[
|
|
11
|
+
UserController,
|
|
12
|
+
RoleController,
|
|
13
|
+
PermissionController,
|
|
14
|
+
AccountController,
|
|
15
|
+
],
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = ["auth_router"]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from litestar import Controller, Request, get, post
|
|
5
|
+
from litestar.datastructures import State
|
|
6
|
+
from litestar.di import Provide
|
|
7
|
+
from litestar.exceptions import HTTPException
|
|
8
|
+
from litestar.status_codes import HTTP_400_BAD_REQUEST
|
|
9
|
+
import msgspec
|
|
10
|
+
|
|
11
|
+
from auth.services import (
|
|
12
|
+
AuthService,
|
|
13
|
+
AccountDTO,
|
|
14
|
+
LoginRequestDTO,
|
|
15
|
+
LogoutRequestDTO,
|
|
16
|
+
SignUpRequestDTO,
|
|
17
|
+
UserNotEnabledError,
|
|
18
|
+
provide_auth_service,
|
|
19
|
+
provide_user_service,
|
|
20
|
+
InvalidEmailError,
|
|
21
|
+
InvalidPasswordError,
|
|
22
|
+
)
|
|
23
|
+
from auth.services.auth_service import AccountUserDTO
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SignUpRequestScheme(msgspec.Struct):
|
|
27
|
+
email: str
|
|
28
|
+
password: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SignUpResponseScheme(msgspec.Struct):
|
|
32
|
+
message: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LoginResponseScheme(AccountDTO): ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ActivateAccountResponseScheme(AccountDTO): ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class LoginRequestScheme(msgspec.Struct):
|
|
42
|
+
email: str
|
|
43
|
+
password: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ActivateAccountRequestScheme(msgspec.Struct):
|
|
47
|
+
code: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AccountMeResponseScheme(msgspec.Struct):
|
|
51
|
+
session_id: UUID
|
|
52
|
+
user: AccountUserDTO
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AccountController(Controller):
|
|
56
|
+
path = "/account"
|
|
57
|
+
|
|
58
|
+
dependencies = {
|
|
59
|
+
"user_service": Provide(provide_user_service),
|
|
60
|
+
"auth_service": Provide(provide_auth_service),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@get("/me")
|
|
64
|
+
async def account(self, request: Request[AccountDTO, Any, State]) -> AccountMeResponseScheme:
|
|
65
|
+
return AccountMeResponseScheme(session_id=request.user.session_id, user=request.user.user)
|
|
66
|
+
|
|
67
|
+
@post("/login", exclude_from_auth=True)
|
|
68
|
+
async def login(self, request: Request, data: LoginRequestScheme, auth_service: AuthService) -> LoginResponseScheme:
|
|
69
|
+
device = request.headers.get("User-Agent")
|
|
70
|
+
try:
|
|
71
|
+
login_user = await auth_service.login(
|
|
72
|
+
LoginRequestDTO(
|
|
73
|
+
email=data.email,
|
|
74
|
+
password=data.password,
|
|
75
|
+
device=device,
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
except UserNotEnabledError:
|
|
79
|
+
raise HTTPException(f"User {data.email} is not enabled", status_code=HTTP_400_BAD_REQUEST)
|
|
80
|
+
except InvalidEmailError:
|
|
81
|
+
raise HTTPException(f"User with email {data.email} not found", status_code=HTTP_400_BAD_REQUEST)
|
|
82
|
+
except InvalidPasswordError:
|
|
83
|
+
raise HTTPException("Invalid password", status_code=HTTP_400_BAD_REQUEST)
|
|
84
|
+
|
|
85
|
+
return LoginResponseScheme(
|
|
86
|
+
token=login_user.token,
|
|
87
|
+
session_id=login_user.session_id,
|
|
88
|
+
user=login_user.user,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
@post("/signup", exclude_from_auth=True)
|
|
92
|
+
async def sign_up(self, data: SignUpRequestScheme, auth_service: AuthService) -> SignUpResponseScheme:
|
|
93
|
+
await auth_service.signup(SignUpRequestDTO(email=data.email, password=data.password))
|
|
94
|
+
return SignUpResponseScheme(message="success")
|
|
95
|
+
|
|
96
|
+
@post("/logout")
|
|
97
|
+
async def logout(self, auth_service: AuthService, request: Request[AccountDTO, Any, State]) -> None:
|
|
98
|
+
await auth_service.logout(
|
|
99
|
+
LogoutRequestDTO(
|
|
100
|
+
user_id=request.user.user.id,
|
|
101
|
+
session_id=request.user.session_id,
|
|
102
|
+
device=request.headers.get("User-Agent"),
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
@post("/activate", exclude_from_auth=True)
|
|
107
|
+
async def activate(
|
|
108
|
+
self,
|
|
109
|
+
data: ActivateAccountRequestScheme,
|
|
110
|
+
auth_service: AuthService,
|
|
111
|
+
request: Request,
|
|
112
|
+
) -> ActivateAccountResponseScheme:
|
|
113
|
+
device = request.headers.get("User-Agent")
|
|
114
|
+
activated_account = await auth_service.verify_user_email(data.code, device)
|
|
115
|
+
|
|
116
|
+
return ActivateAccountResponseScheme(
|
|
117
|
+
token=activated_account.token,
|
|
118
|
+
session_id=activated_account.session_id,
|
|
119
|
+
user=activated_account.user,
|
|
120
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from litestar import Controller, get
|
|
2
|
+
import msgspec
|
|
3
|
+
|
|
4
|
+
from auth.guards import is_debug_guard
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class E2EActivateCodeSchemeResponse(msgspec.Struct):
|
|
8
|
+
code: str
|
|
9
|
+
|
|
10
|
+
class E2EController(Controller):
|
|
11
|
+
guards = [is_debug_guard]
|
|
12
|
+
path = "e2e"
|
|
13
|
+
|
|
14
|
+
@get('/activate-code')
|
|
15
|
+
async def get_activate_code(self) -> E2EActivateCodeSchemeResponse:
|
|
16
|
+
return E2EActivateCodeSchemeResponse(code='super code')
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from advanced_alchemy.extensions.litestar.dto import SQLAlchemyDTO, SQLAlchemyDTOConfig
|
|
4
|
+
from advanced_alchemy.extensions.litestar.providers import create_service_dependencies
|
|
5
|
+
from advanced_alchemy.filters import FilterTypes
|
|
6
|
+
from advanced_alchemy.service import OffsetPagination
|
|
7
|
+
from litestar import delete, get, post
|
|
8
|
+
from litestar.controller import Controller
|
|
9
|
+
from litestar.params import Dependency
|
|
10
|
+
import msgspec
|
|
11
|
+
|
|
12
|
+
from ..models import PermissionModel
|
|
13
|
+
from ..services import PermissionService
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PermissionCreateRequest(msgspec.Struct):
|
|
17
|
+
name: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PermissionDTO(SQLAlchemyDTO[PermissionModel]):
|
|
21
|
+
config = SQLAlchemyDTOConfig()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PermissionController(Controller):
|
|
25
|
+
dependencies = create_service_dependencies(
|
|
26
|
+
PermissionService,
|
|
27
|
+
key="service",
|
|
28
|
+
filters={
|
|
29
|
+
"created_at": True,
|
|
30
|
+
"updated_at": True,
|
|
31
|
+
},
|
|
32
|
+
)
|
|
33
|
+
return_dto = PermissionDTO
|
|
34
|
+
|
|
35
|
+
@get(operation_id="ListPermissions", path="/permissions")
|
|
36
|
+
async def list_permissions(
|
|
37
|
+
self,
|
|
38
|
+
service: PermissionService,
|
|
39
|
+
filters: Annotated[list[FilterTypes], Dependency(skip_validation=True)],
|
|
40
|
+
) -> OffsetPagination[PermissionModel]:
|
|
41
|
+
results, total = await service.list_and_count(*filters)
|
|
42
|
+
return service.to_schema(data=results, total=total, filters=filters)
|
|
43
|
+
|
|
44
|
+
@post(operation_id="CreatePermission", path="/permissions")
|
|
45
|
+
async def create_permission(self, service: PermissionService, data: PermissionCreateRequest) -> PermissionModel:
|
|
46
|
+
return await service.create(PermissionModel(**msgspec.to_builtins(data)), auto_commit=True)
|
|
47
|
+
|
|
48
|
+
@delete(operation_id="DeletePermission", path="/permissions/{item_id:str}")
|
|
49
|
+
async def delete_permission(self, service: PermissionService, item_id: str) -> None:
|
|
50
|
+
await service.delete(item_id, auto_commit=True)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from advanced_alchemy.extensions.litestar.dto import SQLAlchemyDTO, SQLAlchemyDTOConfig
|
|
4
|
+
from advanced_alchemy.extensions.litestar.providers import create_service_dependencies
|
|
5
|
+
from advanced_alchemy.filters import FilterTypes
|
|
6
|
+
from advanced_alchemy.service import OffsetPagination
|
|
7
|
+
from litestar import delete, get, post
|
|
8
|
+
from litestar.controller import Controller
|
|
9
|
+
from litestar.params import Dependency
|
|
10
|
+
import msgspec
|
|
11
|
+
|
|
12
|
+
from ..models import RoleModel
|
|
13
|
+
from ..services import RoleService
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RoleCreateRequest(msgspec.Struct):
|
|
17
|
+
name: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RoleDTO(SQLAlchemyDTO[RoleModel]):
|
|
21
|
+
config = SQLAlchemyDTOConfig()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RoleController(Controller):
|
|
25
|
+
dependencies = create_service_dependencies(
|
|
26
|
+
RoleService,
|
|
27
|
+
key="service",
|
|
28
|
+
filters={
|
|
29
|
+
"created_at": True,
|
|
30
|
+
"updated_at": True,
|
|
31
|
+
},
|
|
32
|
+
)
|
|
33
|
+
return_dto = RoleDTO
|
|
34
|
+
|
|
35
|
+
@get(operation_id="ListRoles", path="/roles")
|
|
36
|
+
async def list_roles(
|
|
37
|
+
self,
|
|
38
|
+
service: RoleService,
|
|
39
|
+
filters: Annotated[list[FilterTypes], Dependency(skip_validation=True)],
|
|
40
|
+
) -> OffsetPagination[RoleModel]:
|
|
41
|
+
results, total = await service.list_and_count(*filters)
|
|
42
|
+
return service.to_schema(data=results, total=total, filters=filters)
|
|
43
|
+
|
|
44
|
+
@post(operation_id="CreateRole", path="/roles")
|
|
45
|
+
async def create_role(self, service: RoleService, data: RoleCreateRequest) -> RoleModel:
|
|
46
|
+
return await service.create(RoleModel(**msgspec.to_builtins(data)), auto_commit=True)
|
|
47
|
+
|
|
48
|
+
@delete(operation_id="DeleteRole", path="/roles/{item_id:str}")
|
|
49
|
+
async def delete_role(self, service: RoleService, item_id: str) -> None:
|
|
50
|
+
await service.delete(item_id, auto_commit=True)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from advanced_alchemy.extensions.litestar.dto import SQLAlchemyDTO, SQLAlchemyDTOConfig
|
|
4
|
+
from advanced_alchemy.extensions.litestar.providers import create_service_dependencies
|
|
5
|
+
from advanced_alchemy.filters import FilterTypes
|
|
6
|
+
from advanced_alchemy.service import OffsetPagination
|
|
7
|
+
from litestar import delete, get, post
|
|
8
|
+
from litestar.controller import Controller
|
|
9
|
+
from litestar.params import Dependency
|
|
10
|
+
import msgspec
|
|
11
|
+
|
|
12
|
+
from ..models import UserModel
|
|
13
|
+
from ..services import UserService
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UserCreateRequest(msgspec.Struct):
|
|
17
|
+
email: str
|
|
18
|
+
password: str
|
|
19
|
+
is_email_verified: bool
|
|
20
|
+
is_enabled: bool
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UserDTO(SQLAlchemyDTO[UserModel]):
|
|
24
|
+
config = SQLAlchemyDTOConfig(exclude={"password"})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class UserController(Controller):
|
|
28
|
+
dependencies = create_service_dependencies(
|
|
29
|
+
UserService,
|
|
30
|
+
key="service",
|
|
31
|
+
filters={
|
|
32
|
+
"created_at": True,
|
|
33
|
+
"updated_at": True,
|
|
34
|
+
"sort_field": "email",
|
|
35
|
+
"search": "email",
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
return_dto = UserDTO
|
|
39
|
+
|
|
40
|
+
@get(operation_id="ListUsers", path="/users")
|
|
41
|
+
async def list_users(
|
|
42
|
+
self,
|
|
43
|
+
service: UserService,
|
|
44
|
+
filters: Annotated[list[FilterTypes], Dependency(skip_validation=True)],
|
|
45
|
+
) -> OffsetPagination[UserModel]:
|
|
46
|
+
results, total = await service.list_and_count(*filters)
|
|
47
|
+
return service.to_schema(data=results, total=total, filters=filters)
|
|
48
|
+
|
|
49
|
+
@post(operation_id="CreateUser", path="/users")
|
|
50
|
+
async def create_user(self, service: UserService, data: UserCreateRequest) -> UserModel:
|
|
51
|
+
return await service.create(UserModel(**msgspec.to_builtins(data)), auto_commit=True)
|
|
52
|
+
|
|
53
|
+
@delete(operation_id="DeleteUser", path="/users/{item_id:str}")
|
|
54
|
+
async def delete_user(self, service: UserService, item_id: str) -> None:
|
|
55
|
+
await service.delete(item_id, auto_commit=True)
|
auth/factories.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from faker import Faker
|
|
2
|
+
from polyfactory.factories.sqlalchemy_factory import SQLAlchemyFactory
|
|
3
|
+
from polyfactory.fields import Use
|
|
4
|
+
|
|
5
|
+
from .models import PermissionModel, RoleModel, UserModel
|
|
6
|
+
|
|
7
|
+
faker = Faker()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UserFactory(SQLAlchemyFactory[UserModel]):
|
|
11
|
+
email = Use(faker.unique.email)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RoleFactory(SQLAlchemyFactory[RoleModel]):
|
|
15
|
+
name = Use(faker.unique.word)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PermissionFactory(SQLAlchemyFactory[PermissionModel]):
|
|
19
|
+
name = Use(faker.unique.word)
|
auth/fixtures.yaml
ADDED
auth/guards.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from litestar.connection import ASGIConnection
|
|
2
|
+
from litestar.exceptions import HTTPException
|
|
3
|
+
from litestar.handlers.base import BaseRouteHandler
|
|
4
|
+
from litestar.status_codes import HTTP_403_FORBIDDEN
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def is_debug_guard(connection: ASGIConnection, _: BaseRouteHandler) -> None:
|
|
8
|
+
if not connection.app.debug:
|
|
9
|
+
raise HTTPException(detail="only in debug mode", status_code=HTTP_403_FORBIDDEN)
|
auth/loaders.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from auth.models import UserModel
|
|
4
|
+
from auth.services import AuthService, PermissionService, RoleService, UserService
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AuthLoader:
|
|
8
|
+
def __init__(
|
|
9
|
+
self,
|
|
10
|
+
user_service: UserService,
|
|
11
|
+
role_service: RoleService,
|
|
12
|
+
permission_service: PermissionService,
|
|
13
|
+
):
|
|
14
|
+
self.user_service = user_service
|
|
15
|
+
self.role_service = role_service
|
|
16
|
+
self.permission_service = permission_service
|
|
17
|
+
|
|
18
|
+
async def load(self, data: dict[str, Any]):
|
|
19
|
+
await self.user_service.create_many(
|
|
20
|
+
[
|
|
21
|
+
UserModel(
|
|
22
|
+
email=user["email"],
|
|
23
|
+
password=AuthService.hash_password(user["password"]),
|
|
24
|
+
is_enabled=True,
|
|
25
|
+
is_email_verified=True,
|
|
26
|
+
)
|
|
27
|
+
for user in data.get("users", [])
|
|
28
|
+
],
|
|
29
|
+
auto_commit=True,
|
|
30
|
+
)
|
auth/middleware.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from litestar.connection import ASGIConnection
|
|
2
|
+
from litestar.exceptions import NotAuthorizedException
|
|
3
|
+
from litestar.middleware import (
|
|
4
|
+
AbstractAuthenticationMiddleware,
|
|
5
|
+
AuthenticationResult,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
from core.storage import Storage
|
|
9
|
+
|
|
10
|
+
from .services import provide_auth_service, provide_user_service
|
|
11
|
+
from .services.auth_service import TOKEN_PREFIX, AuthService
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class JWTAuthMiddleware(AbstractAuthenticationMiddleware):
|
|
15
|
+
async def authenticate_request(self, connection: ASGIConnection) -> AuthenticationResult:
|
|
16
|
+
auth_header = connection.headers.get("Authorization")
|
|
17
|
+
if not auth_header:
|
|
18
|
+
raise NotAuthorizedException()
|
|
19
|
+
|
|
20
|
+
token = auth_header.replace(f"{TOKEN_PREFIX} ", "")
|
|
21
|
+
auth_service: AuthService = await self._get_auth_service(connection)
|
|
22
|
+
|
|
23
|
+
return AuthenticationResult(
|
|
24
|
+
user=await auth_service.check_token(token, connection.headers.get("User-Agent")),
|
|
25
|
+
auth=token,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
@staticmethod
|
|
29
|
+
async def _get_auth_service(connection: ASGIConnection) -> AuthService:
|
|
30
|
+
storage: Storage = await connection.app.dependencies.get("storage")()
|
|
31
|
+
db_session = await connection.app.dependencies.get("db_session")(
|
|
32
|
+
state=connection.app.state,
|
|
33
|
+
scope=connection.scope,
|
|
34
|
+
)
|
|
35
|
+
mail_client = await connection.app.dependencies.get("mail_client")()
|
|
36
|
+
user_service = await provide_user_service(db_session=db_session)
|
|
37
|
+
async for auth_service in provide_auth_service(
|
|
38
|
+
db_session=db_session,
|
|
39
|
+
mail_client=mail_client,
|
|
40
|
+
storage=storage,
|
|
41
|
+
user_service=user_service,
|
|
42
|
+
):
|
|
43
|
+
return auth_service
|
auth/models/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from .role_permission_model import RolePermissionModel
|
|
2
|
+
from .user_role_model import UserRoleModel
|
|
3
|
+
|
|
4
|
+
from .auth_code_model import AuthCodeModel
|
|
5
|
+
from .permission_model import PermissionModel
|
|
6
|
+
from .role_model import RoleModel
|
|
7
|
+
from .user_model import UserModel
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"AuthCodeModel",
|
|
11
|
+
"RoleModel",
|
|
12
|
+
"PermissionModel",
|
|
13
|
+
"RolePermissionModel",
|
|
14
|
+
"UserModel",
|
|
15
|
+
"UserRoleModel",
|
|
16
|
+
]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from advanced_alchemy.base import UUIDAuditBase
|
|
2
|
+
from sqlalchemy import ForeignKey
|
|
3
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AuthCodeModel(UUIDAuditBase):
|
|
7
|
+
__tablename__ = "auth_codes"
|
|
8
|
+
|
|
9
|
+
code: Mapped[str] = mapped_column()
|
|
10
|
+
user_id = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), unique=True)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from advanced_alchemy.base import UUIDAuditBase
|
|
2
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class RoleModel(UUIDAuditBase):
|
|
6
|
+
__tablename__ = "roles"
|
|
7
|
+
|
|
8
|
+
name: Mapped[str] = mapped_column(unique=True)
|
|
9
|
+
users = relationship(
|
|
10
|
+
"UserModel",
|
|
11
|
+
secondary="users_roles",
|
|
12
|
+
back_populates="roles",
|
|
13
|
+
lazy="selectin",
|
|
14
|
+
)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from advanced_alchemy.base import UUIDAuditBase
|
|
2
|
+
from sqlalchemy import ForeignKey
|
|
3
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RolePermissionModel(UUIDAuditBase):
|
|
7
|
+
__tablename__ = "roles_permissions"
|
|
8
|
+
|
|
9
|
+
role_id: Mapped[int] = mapped_column(ForeignKey("roles.id"), primary_key=True)
|
|
10
|
+
permission_id: Mapped[int] = mapped_column(ForeignKey("permissions.id"), primary_key=True)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from advanced_alchemy.base import UUIDAuditBase
|
|
2
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class UserModel(UUIDAuditBase):
|
|
6
|
+
__tablename__ = "users"
|
|
7
|
+
|
|
8
|
+
email: Mapped[str] = mapped_column(unique=True)
|
|
9
|
+
password: Mapped[str]
|
|
10
|
+
is_email_verified: Mapped[bool]
|
|
11
|
+
is_enabled: Mapped[bool]
|
|
12
|
+
|
|
13
|
+
roles = relationship(
|
|
14
|
+
"RoleModel",
|
|
15
|
+
secondary="users_roles",
|
|
16
|
+
back_populates="users",
|
|
17
|
+
lazy="selectin",
|
|
18
|
+
)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from advanced_alchemy.base import UUIDAuditBase
|
|
2
|
+
from sqlalchemy import ForeignKey
|
|
3
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class UserRoleModel(UUIDAuditBase):
|
|
7
|
+
__tablename__ = "users_roles"
|
|
8
|
+
|
|
9
|
+
role_id: Mapped[int] = mapped_column(ForeignKey("roles.id"), primary_key=True)
|
|
10
|
+
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), primary_key=True)
|
auth/plugin.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from click import Group
|
|
5
|
+
from litestar.plugins import CLIPlugin
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from core.cli import coro
|
|
9
|
+
from core.database import session_maker
|
|
10
|
+
from core.mail import MailClient
|
|
11
|
+
from core.settings import settings
|
|
12
|
+
|
|
13
|
+
from .loaders import AuthLoader
|
|
14
|
+
from .services import provide_permission_service, provide_role_service, provide_user_service
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthPlugin(CLIPlugin):
|
|
18
|
+
def on_cli_init(self, cli: Group) -> None:
|
|
19
|
+
@cli.group(help="Manage auth, load data with ``load`` command")
|
|
20
|
+
@click.version_option(prog_name="auth")
|
|
21
|
+
def auth(): ...
|
|
22
|
+
|
|
23
|
+
@auth.command(help="load auth data")
|
|
24
|
+
@coro
|
|
25
|
+
async def load():
|
|
26
|
+
async with session_maker() as session:
|
|
27
|
+
click.echo("Loading auth data... ")
|
|
28
|
+
loader = AuthLoader(
|
|
29
|
+
user_service=await provide_user_service(session),
|
|
30
|
+
role_service=await provide_role_service(session),
|
|
31
|
+
permission_service=await provide_permission_service(session),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
with open(f"{Path(__file__).parent.resolve()}/fixtures.yaml") as f:
|
|
35
|
+
data = yaml.safe_load(f)
|
|
36
|
+
await loader.load(data)
|
|
37
|
+
|
|
38
|
+
@auth.command(help="load auth data")
|
|
39
|
+
@coro
|
|
40
|
+
async def clear():
|
|
41
|
+
async with session_maker() as session:
|
|
42
|
+
user_service = await provide_user_service(session)
|
|
43
|
+
await user_service.delete_where()
|
|
44
|
+
click.echo("Clear auth data")
|
|
45
|
+
|
|
46
|
+
@auth.command(help="Send test mail")
|
|
47
|
+
@click.argument("recipient")
|
|
48
|
+
def send_mail(recipient):
|
|
49
|
+
mail_controller = MailClient(settings.mail_config)
|
|
50
|
+
mail_controller.send([recipient], "test", "test")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from .auth_service import (
|
|
2
|
+
API_KEY_HEADER,
|
|
3
|
+
TOKEN_PREFIX,
|
|
4
|
+
AccountDTO,
|
|
5
|
+
AuthService,
|
|
6
|
+
DecodeTokenError,
|
|
7
|
+
InvalidEmailError,
|
|
8
|
+
InvalidPasswordError,
|
|
9
|
+
LoginRequestDTO,
|
|
10
|
+
LogoutRequestDTO,
|
|
11
|
+
SignUpRequestDTO,
|
|
12
|
+
UserNotEnabledError,
|
|
13
|
+
UserEmailNotVerifiedError,
|
|
14
|
+
WrongAuthCodeError,
|
|
15
|
+
provide_auth_service,
|
|
16
|
+
)
|
|
17
|
+
from .permission_service import PermissionService, provide_permission_service
|
|
18
|
+
from .role_service import RoleService, provide_role_service
|
|
19
|
+
from .user_service import UserService, provide_user_service
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"UserService",
|
|
23
|
+
"AuthService",
|
|
24
|
+
"PermissionService",
|
|
25
|
+
"RoleService",
|
|
26
|
+
"AccountDTO",
|
|
27
|
+
"LogoutRequestDTO",
|
|
28
|
+
"LoginRequestDTO",
|
|
29
|
+
"SignUpRequestDTO",
|
|
30
|
+
# providers
|
|
31
|
+
"provide_user_service",
|
|
32
|
+
"provide_auth_service",
|
|
33
|
+
"provide_permission_service",
|
|
34
|
+
"provide_role_service",
|
|
35
|
+
# errors
|
|
36
|
+
"InvalidPasswordError",
|
|
37
|
+
"UserNotEnabledError",
|
|
38
|
+
"UserEmailNotVerifiedError",
|
|
39
|
+
"InvalidEmailError",
|
|
40
|
+
"DecodeTokenError",
|
|
41
|
+
"WrongAuthCodeError",
|
|
42
|
+
# constants
|
|
43
|
+
"API_KEY_HEADER",
|
|
44
|
+
"TOKEN_PREFIX",
|
|
45
|
+
]
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import random
|
|
2
|
+
from typing import Any, AsyncGenerator, List
|
|
3
|
+
from uuid import UUID, uuid4
|
|
4
|
+
|
|
5
|
+
import advanced_alchemy
|
|
6
|
+
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
|
|
7
|
+
import bcrypt
|
|
8
|
+
import jwt
|
|
9
|
+
from litestar.exceptions import NotAuthorizedException
|
|
10
|
+
import msgspec
|
|
11
|
+
from nats.js.errors import KeyNotFoundError
|
|
12
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
13
|
+
|
|
14
|
+
from core.mail import MailClient
|
|
15
|
+
from core.settings import settings
|
|
16
|
+
from core.storage import Storage
|
|
17
|
+
|
|
18
|
+
from ..models import AuthCodeModel, UserModel
|
|
19
|
+
from .user_service import UserService
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"UserService",
|
|
23
|
+
"AuthService",
|
|
24
|
+
"LoginRequestDTO",
|
|
25
|
+
"LogoutRequestDTO",
|
|
26
|
+
"SignUpRequestDTO",
|
|
27
|
+
"AccountCacheDTO",
|
|
28
|
+
"AccountCacheSessionDTO",
|
|
29
|
+
"provide_auth_service",
|
|
30
|
+
"InvalidPasswordError",
|
|
31
|
+
"InvalidEmailError",
|
|
32
|
+
"DecodeTokenError",
|
|
33
|
+
"UserNotEnabledError",
|
|
34
|
+
"UserEmailNotVerifiedError",
|
|
35
|
+
"API_KEY_HEADER",
|
|
36
|
+
"TOKEN_PREFIX",
|
|
37
|
+
"AccountDTO",
|
|
38
|
+
"WrongAuthCodeError",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
API_KEY_HEADER = "Authorization"
|
|
43
|
+
TOKEN_PREFIX = "Bearer"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class InvalidPasswordError(Exception):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class InvalidEmailError(Exception):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class UserNotEnabledError(Exception):
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class UserEmailNotVerifiedError(Exception):
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class DecodeTokenError(Exception):
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class WrongAuthCodeError(Exception):
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AccountRoleDTO(msgspec.Struct):
|
|
71
|
+
id: UUID
|
|
72
|
+
name: str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class AccountUserDTO(msgspec.Struct):
|
|
76
|
+
id: UUID
|
|
77
|
+
email: str
|
|
78
|
+
is_email_verified: bool
|
|
79
|
+
is_enabled: bool
|
|
80
|
+
roles: list[AccountRoleDTO] = msgspec.field(default_factory=list)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class AccountDTO(msgspec.Struct):
|
|
84
|
+
token: str
|
|
85
|
+
session_id: UUID
|
|
86
|
+
user: AccountUserDTO
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TokenAccountDataDTO(msgspec.Struct):
|
|
90
|
+
session_id: UUID
|
|
91
|
+
user: AccountUserDTO
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class LoginRequestDTO(msgspec.Struct):
|
|
95
|
+
email: str
|
|
96
|
+
password: str
|
|
97
|
+
device: str
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class LogoutRequestDTO(msgspec.Struct):
|
|
101
|
+
user_id: UUID
|
|
102
|
+
session_id: UUID
|
|
103
|
+
device: str
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class SignUpRequestDTO(msgspec.Struct):
|
|
107
|
+
email: str
|
|
108
|
+
password: str
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class AccountCacheSessionDTO(msgspec.Struct):
|
|
112
|
+
device: str
|
|
113
|
+
session_id: UUID
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class AccountCacheDTO(msgspec.Struct):
|
|
117
|
+
sessions: list[AccountCacheSessionDTO]
|
|
118
|
+
user: AccountUserDTO
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class LoginResponseDTO(AccountDTO): ...
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ActivateUserResponseDTO(AccountDTO): ...
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class AuthCodeRepository(SQLAlchemyAsyncRepository[AuthCodeModel]):
|
|
128
|
+
model_type = AuthCodeModel
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class AuthService:
|
|
132
|
+
def __init__(
|
|
133
|
+
self,
|
|
134
|
+
session: AsyncSession,
|
|
135
|
+
mail_client: MailClient,
|
|
136
|
+
storage: Storage,
|
|
137
|
+
user_service: UserService,
|
|
138
|
+
):
|
|
139
|
+
self.session = session
|
|
140
|
+
self.mail_client = mail_client
|
|
141
|
+
self.storage = storage
|
|
142
|
+
self.user_service = user_service
|
|
143
|
+
self.auth_code_repository = AuthCodeRepository(session=session)
|
|
144
|
+
|
|
145
|
+
async def signup(self, user: SignUpRequestDTO) -> AuthCodeModel:
|
|
146
|
+
signup_user = await self.user_service.create(
|
|
147
|
+
UserModel(
|
|
148
|
+
email=user.email,
|
|
149
|
+
password=self.hash_password(user.password),
|
|
150
|
+
is_email_verified=False,
|
|
151
|
+
is_enabled=True,
|
|
152
|
+
),
|
|
153
|
+
auto_commit=True,
|
|
154
|
+
)
|
|
155
|
+
auth_code = await self.auth_code_repository.add(
|
|
156
|
+
AuthCodeModel(
|
|
157
|
+
user_id=signup_user.id,
|
|
158
|
+
code=self.generate_activate_code(),
|
|
159
|
+
),
|
|
160
|
+
auto_commit=True,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
self.mail_client.send([user.email], "Sign up", auth_code.code)
|
|
164
|
+
return auth_code
|
|
165
|
+
|
|
166
|
+
async def verify_user_email(self, code: str, device: str) -> LoginResponseDTO:
|
|
167
|
+
auth_code = await self.auth_code_repository.get_one_or_none(AuthCodeModel.code == code)
|
|
168
|
+
if not auth_code:
|
|
169
|
+
raise WrongAuthCodeError
|
|
170
|
+
|
|
171
|
+
user = await self.user_service.update(
|
|
172
|
+
UserModel(id=auth_code.user_id, is_enabled=True, is_email_verified=True),
|
|
173
|
+
auto_commit=True,
|
|
174
|
+
)
|
|
175
|
+
return await self._add_account_session(user, device)
|
|
176
|
+
|
|
177
|
+
async def login(self, login_data: LoginRequestDTO) -> LoginResponseDTO:
|
|
178
|
+
try:
|
|
179
|
+
login_user = await self.user_service.get_one(UserModel.email == login_data.email)
|
|
180
|
+
|
|
181
|
+
except advanced_alchemy.exceptions.NotFoundError:
|
|
182
|
+
raise InvalidEmailError(f"Email {login_data.email} not found")
|
|
183
|
+
|
|
184
|
+
if not login_user.is_enabled:
|
|
185
|
+
raise UserNotEnabledError(f"User {login_data.email} not enabled")
|
|
186
|
+
|
|
187
|
+
if not login_user.is_email_verified:
|
|
188
|
+
raise UserEmailNotVerifiedError(f"User {login_data.email} not verified")
|
|
189
|
+
|
|
190
|
+
if not self._check_password(login_data.password, login_user.password):
|
|
191
|
+
raise InvalidPasswordError
|
|
192
|
+
|
|
193
|
+
return await self._add_account_session(login_user, login_data.device)
|
|
194
|
+
|
|
195
|
+
async def _add_account_session(self, user: UserModel, device: str) -> LoginResponseDTO:
|
|
196
|
+
login_session = AccountCacheSessionDTO(
|
|
197
|
+
session_id=uuid4(),
|
|
198
|
+
device=device,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
account_user = AccountUserDTO(
|
|
202
|
+
id=user.id,
|
|
203
|
+
email=user.email,
|
|
204
|
+
is_email_verified=user.is_email_verified,
|
|
205
|
+
is_enabled=user.is_enabled,
|
|
206
|
+
roles=[AccountRoleDTO(id=role.id, name=role.name) for role in user.roles],
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
cached_account = await self.storage.get("sessions", str(user.id), model_type=AccountCacheDTO)
|
|
211
|
+
cached_account.sessions.append(login_session)
|
|
212
|
+
except KeyNotFoundError:
|
|
213
|
+
cached_account = AccountCacheDTO(user=account_user, sessions=[login_session])
|
|
214
|
+
|
|
215
|
+
jwt_token = self.encode_token(
|
|
216
|
+
TokenAccountDataDTO(
|
|
217
|
+
session_id=login_session.session_id,
|
|
218
|
+
user=account_user,
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
await self.storage.save("sessions", str(cached_account.user.id), cached_account)
|
|
222
|
+
|
|
223
|
+
return LoginResponseDTO(
|
|
224
|
+
token=jwt_token,
|
|
225
|
+
session_id=login_session.session_id,
|
|
226
|
+
user=account_user,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
async def start_reset_password(self, user_id: UUID):
|
|
230
|
+
raise NotImplementedError()
|
|
231
|
+
|
|
232
|
+
async def logout(self, logout_request: LogoutRequestDTO) -> None:
|
|
233
|
+
cached_user = await self.storage.get("sessions", str(logout_request.user_id), model_type=AccountCacheDTO)
|
|
234
|
+
cached_user.sessions = [
|
|
235
|
+
session
|
|
236
|
+
for session in cached_user.sessions
|
|
237
|
+
if not (session.session_id == logout_request.session_id and session.device == logout_request.device)
|
|
238
|
+
]
|
|
239
|
+
await self.storage.save("sessions", str(logout_request.user_id), cached_user)
|
|
240
|
+
|
|
241
|
+
async def check_token(self, token: str, user_agent: str) -> AccountDTO:
|
|
242
|
+
try:
|
|
243
|
+
account = await self.get_account(token)
|
|
244
|
+
except DecodeTokenError:
|
|
245
|
+
raise NotAuthorizedException("Decode token fail")
|
|
246
|
+
|
|
247
|
+
await self.check_session(account, user_agent)
|
|
248
|
+
return account
|
|
249
|
+
|
|
250
|
+
async def check_session(self, account: AccountDTO, device: str):
|
|
251
|
+
try:
|
|
252
|
+
cached_account = await self.storage.get("sessions", str(account.user.id), model_type=AccountCacheDTO)
|
|
253
|
+
for session in cached_account.sessions:
|
|
254
|
+
if session.session_id == account.session_id:
|
|
255
|
+
if session.device != device:
|
|
256
|
+
raise NotAuthorizedException("Wrong device")
|
|
257
|
+
return
|
|
258
|
+
raise NotAuthorizedException("Session not found")
|
|
259
|
+
except KeyNotFoundError:
|
|
260
|
+
raise NotAuthorizedException()
|
|
261
|
+
|
|
262
|
+
@staticmethod
|
|
263
|
+
def _check_password(password: str, hashed_password: str) -> bool:
|
|
264
|
+
return bcrypt.checkpw(password.encode("utf-8"), hashed_password.encode("utf-8"))
|
|
265
|
+
|
|
266
|
+
@staticmethod
|
|
267
|
+
def hash_password(password: str) -> str:
|
|
268
|
+
return bcrypt.hashpw(password.encode("utf8"), bcrypt.gensalt()).decode("utf8")
|
|
269
|
+
|
|
270
|
+
async def get_account(self, token: str) -> AccountDTO:
|
|
271
|
+
try:
|
|
272
|
+
account_data = self.decode_token(token)
|
|
273
|
+
token_account_data = msgspec.convert(account_data, type=TokenAccountDataDTO)
|
|
274
|
+
return AccountDTO(
|
|
275
|
+
token=token,
|
|
276
|
+
session_id=token_account_data.session_id,
|
|
277
|
+
user=token_account_data.user,
|
|
278
|
+
)
|
|
279
|
+
except Exception:
|
|
280
|
+
raise DecodeTokenError
|
|
281
|
+
|
|
282
|
+
@staticmethod
|
|
283
|
+
def encode_token(token_account_data: TokenAccountDataDTO) -> str:
|
|
284
|
+
return jwt.encode(msgspec.to_builtins(token_account_data), settings.jwt_secret, algorithm="HS256")
|
|
285
|
+
|
|
286
|
+
@staticmethod
|
|
287
|
+
def decode_token(token: str) -> dict[str, Any]:
|
|
288
|
+
try:
|
|
289
|
+
return jwt.decode(token, settings.jwt_secret, algorithms=["HS256"])
|
|
290
|
+
except jwt.exceptions.InvalidSignatureError:
|
|
291
|
+
raise DecodeTokenError
|
|
292
|
+
|
|
293
|
+
@staticmethod
|
|
294
|
+
def generate_activate_code() -> str:
|
|
295
|
+
return "".join(str(random.randint(0, 9)) for _ in range(8))
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
async def provide_auth_service(
|
|
299
|
+
db_session: AsyncSession,
|
|
300
|
+
mail_client: MailClient,
|
|
301
|
+
storage: Storage,
|
|
302
|
+
user_service: UserService,
|
|
303
|
+
) -> AsyncGenerator[AuthService, None]:
|
|
304
|
+
yield AuthService(
|
|
305
|
+
session=db_session,
|
|
306
|
+
mail_client=mail_client,
|
|
307
|
+
storage=storage,
|
|
308
|
+
user_service=user_service,
|
|
309
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
|
|
2
|
+
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService
|
|
3
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
4
|
+
|
|
5
|
+
from ..models import PermissionModel
|
|
6
|
+
|
|
7
|
+
__all__ = ["PermissionService", "provide_permission_service"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PermissionService(SQLAlchemyAsyncRepositoryService):
|
|
11
|
+
class Repository(SQLAlchemyAsyncRepository[PermissionModel]):
|
|
12
|
+
model_type = PermissionModel
|
|
13
|
+
|
|
14
|
+
repository_type = Repository
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def provide_permission_service(db_session: AsyncSession) -> PermissionService:
|
|
18
|
+
return PermissionService(session=db_session)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
|
|
2
|
+
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService
|
|
3
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
4
|
+
|
|
5
|
+
from ..models import RoleModel
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"RoleService",
|
|
9
|
+
"provide_role_service",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RoleService(SQLAlchemyAsyncRepositoryService):
|
|
14
|
+
class Repository(SQLAlchemyAsyncRepository[RoleModel]):
|
|
15
|
+
model_type = RoleModel
|
|
16
|
+
|
|
17
|
+
repository_type = Repository
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def provide_role_service(db_session: AsyncSession) -> RoleService:
|
|
21
|
+
return RoleService(session=db_session)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
|
|
2
|
+
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService
|
|
3
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
4
|
+
|
|
5
|
+
from ..models import UserModel
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"UserService",
|
|
9
|
+
"provide_user_service",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UserService(SQLAlchemyAsyncRepositoryService):
|
|
14
|
+
class Repository(SQLAlchemyAsyncRepository[UserModel]):
|
|
15
|
+
model_type = UserModel
|
|
16
|
+
|
|
17
|
+
repository_type = Repository
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def provide_user_service(db_session: AsyncSession) -> UserService:
|
|
21
|
+
return UserService(session=db_session)
|
core/cli.py
ADDED
core/database.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from advanced_alchemy.base import UUIDAuditBase
|
|
2
|
+
from litestar.plugins.sqlalchemy import SQLAlchemyAsyncConfig, SQLAlchemyPlugin
|
|
3
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
4
|
+
|
|
5
|
+
from .settings import settings
|
|
6
|
+
|
|
7
|
+
config = SQLAlchemyAsyncConfig(
|
|
8
|
+
connection_string=settings.db_url,
|
|
9
|
+
metadata=UUIDAuditBase.metadata,
|
|
10
|
+
create_all=settings.db_create_all,
|
|
11
|
+
)
|
|
12
|
+
sqlalchemy_plugin = SQLAlchemyPlugin(config=config)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
engine = create_async_engine(settings.db_url, echo=False)
|
|
16
|
+
session_maker = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
core/mail.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from email.message import EmailMessage
|
|
2
|
+
import smtplib
|
|
3
|
+
|
|
4
|
+
from .settings import MailSettings, settings
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MailClient:
|
|
8
|
+
def __init__(self, config: MailSettings):
|
|
9
|
+
self.settings = config
|
|
10
|
+
|
|
11
|
+
def send(self, recipients: list[str], subject: str, body: str):
|
|
12
|
+
msg = EmailMessage()
|
|
13
|
+
msg.set_content(body)
|
|
14
|
+
server = smtplib.SMTP(self.settings.host, self.settings.port, timeout=5)
|
|
15
|
+
server.starttls()
|
|
16
|
+
server.login(self.settings.login, self.settings.password)
|
|
17
|
+
|
|
18
|
+
msg = EmailMessage()
|
|
19
|
+
msg["Subject"] = subject
|
|
20
|
+
msg.set_content(body)
|
|
21
|
+
msg["From"] = self.settings.login
|
|
22
|
+
|
|
23
|
+
for recipient in recipients:
|
|
24
|
+
msg["To"] = recipient
|
|
25
|
+
server.send_message(msg)
|
|
26
|
+
|
|
27
|
+
server.quit()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def provide_mail_client():
|
|
31
|
+
return MailClient(settings.mail_config)
|
core/openapi.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from litestar.openapi import OpenAPIConfig
|
|
2
|
+
from litestar.openapi.plugins import ScalarRenderPlugin
|
|
3
|
+
from litestar.openapi.spec import Components, SecurityScheme
|
|
4
|
+
|
|
5
|
+
from .settings import settings
|
|
6
|
+
|
|
7
|
+
openapi_config = OpenAPIConfig(
|
|
8
|
+
components=[
|
|
9
|
+
Components(
|
|
10
|
+
security_schemes={
|
|
11
|
+
"JWT": SecurityScheme(
|
|
12
|
+
type="http",
|
|
13
|
+
scheme="Bearer",
|
|
14
|
+
name="Authorization",
|
|
15
|
+
security_scheme_in="cookie",
|
|
16
|
+
bearer_format="JWT",
|
|
17
|
+
description="Authorization",
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
)
|
|
21
|
+
],
|
|
22
|
+
title=settings.open_api_config.title,
|
|
23
|
+
version=settings.open_api_config.version,
|
|
24
|
+
path=settings.open_api_config.path,
|
|
25
|
+
render_plugins=[ScalarRenderPlugin()],
|
|
26
|
+
)
|
core/settings.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from pydantic import Field
|
|
2
|
+
from pydantic_settings import BaseSettings
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class StorageSettings(BaseSettings):
|
|
6
|
+
url: str = Field(default="nats://localhost:4222")
|
|
7
|
+
buckets: list[str] = Field()
|
|
8
|
+
|
|
9
|
+
class Config:
|
|
10
|
+
env_prefix = "STORAGE_"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MailSettings(BaseSettings):
|
|
14
|
+
host: str = Field(default="smtp.yandex.com")
|
|
15
|
+
port: int = Field(default=587)
|
|
16
|
+
login: str = Field(default="info@service-laboratory.online")
|
|
17
|
+
password: str = Field(default="superPassword")
|
|
18
|
+
|
|
19
|
+
class Config:
|
|
20
|
+
env_prefix = "MAIL_"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OpenApiSettings(BaseSettings):
|
|
24
|
+
title: str = Field(deafult="title")
|
|
25
|
+
version: str = Field(deafult="0.1.2")
|
|
26
|
+
path: str = Field(deafult="/api/docs")
|
|
27
|
+
|
|
28
|
+
class Config:
|
|
29
|
+
env_prefix = "OPEN_API_"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Settings(BaseSettings):
|
|
33
|
+
debug: bool = Field(default=False)
|
|
34
|
+
jwt_secret: str = Field(default="secret_for_jwt")
|
|
35
|
+
|
|
36
|
+
# database config
|
|
37
|
+
db_url: str = Field(default="postgresql+asyncpg://user:password@localhost:5432/db")
|
|
38
|
+
db_create_all: bool = Field(default=False)
|
|
39
|
+
|
|
40
|
+
# services config
|
|
41
|
+
open_api_config: OpenApiSettings = Field(default_factory=OpenApiSettings)
|
|
42
|
+
mail_config: MailSettings = Field(default_factory=MailSettings)
|
|
43
|
+
storage_config: StorageSettings = Field(default_factory=StorageSettings)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
settings = Settings()
|
core/storage.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from typing import TypeVar
|
|
2
|
+
|
|
3
|
+
from faststream import FastStream
|
|
4
|
+
from faststream.nats import NatsBroker
|
|
5
|
+
from litestar import Litestar
|
|
6
|
+
import msgspec
|
|
7
|
+
|
|
8
|
+
from .settings import StorageSettings, settings
|
|
9
|
+
|
|
10
|
+
T = TypeVar("T")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Storage:
|
|
14
|
+
def __init__(self, config: StorageSettings):
|
|
15
|
+
self.broker = NatsBroker(servers=config.url)
|
|
16
|
+
self.app = FastStream(self.broker)
|
|
17
|
+
self.settings = config
|
|
18
|
+
self.buckets = {}
|
|
19
|
+
self.is_connected = False
|
|
20
|
+
|
|
21
|
+
async def connect(self):
|
|
22
|
+
if not self.is_connected:
|
|
23
|
+
await self.broker.connect()
|
|
24
|
+
for bucket in self.settings.buckets:
|
|
25
|
+
await self.init_bucket(bucket)
|
|
26
|
+
self.is_connected = True
|
|
27
|
+
|
|
28
|
+
async def init_bucket(self, name: str):
|
|
29
|
+
self.buckets[name] = await self.broker.key_value(name)
|
|
30
|
+
|
|
31
|
+
async def save(self, bucket: str, key: str, data: msgspec.Struct):
|
|
32
|
+
await self.buckets[bucket].put(key, msgspec.json.encode(data))
|
|
33
|
+
|
|
34
|
+
async def get(self, bucket: str, key: str, model_type: T) -> T:
|
|
35
|
+
data = await self.buckets[bucket].get(key)
|
|
36
|
+
return msgspec.json.decode(data.value, type=model_type)
|
|
37
|
+
|
|
38
|
+
async def delete(self, bucket: str, key: str):
|
|
39
|
+
await self.buckets[bucket].delete(key)
|
|
40
|
+
|
|
41
|
+
async def disconnect(self):
|
|
42
|
+
await self.broker.stop()
|
|
43
|
+
self.is_connected = False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def provide_storage() -> Storage:
|
|
47
|
+
storage = Storage(settings.storage_config)
|
|
48
|
+
await storage.connect()
|
|
49
|
+
return storage
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def close_storage(app: Litestar) -> None:
|
|
53
|
+
storage: Storage = await app.dependencies.get("storage")()
|
|
54
|
+
await storage.disconnect()
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: service_packages
|
|
3
|
+
Version: 4.0.22
|
|
4
|
+
Summary: Service laboratory packages
|
|
5
|
+
Author-email: Alexei Rysev <alexeirysev@gmail.com>
|
|
6
|
+
Maintainer-email: Alexei Rysev <alexeirysev@gmail.com>
|
|
7
|
+
License: Copyright (c) 2018 The Python Packaging Authority
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
License-File: LICENSE.txt
|
|
27
|
+
Keywords: development,prototype,setuptools
|
|
28
|
+
Classifier: Development Status :: 3 - Alpha
|
|
29
|
+
Classifier: Intended Audience :: Developers
|
|
30
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
31
|
+
Classifier: Programming Language :: Python :: 3
|
|
32
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
35
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
36
|
+
Requires-Python: >=3.12
|
|
37
|
+
Requires-Dist: advanced-alchemy==1.4.4
|
|
38
|
+
Requires-Dist: asyncpg>=0.30.0
|
|
39
|
+
Requires-Dist: bcrypt>=5.0.0
|
|
40
|
+
Requires-Dist: faststream[nats]>=0.6.3
|
|
41
|
+
Requires-Dist: litestar[standard]>=2.15.2
|
|
42
|
+
Requires-Dist: pydantic-settings>=2.8.1
|
|
43
|
+
Requires-Dist: pydantic>=2.11.3
|
|
44
|
+
Requires-Dist: pyjwt>=2.10.1
|
|
45
|
+
Requires-Dist: pytest-asyncio>=0.24.0
|
|
46
|
+
Requires-Dist: pytest-cov>=7.0.0
|
|
47
|
+
Requires-Dist: pytest-html>=4.1.1
|
|
48
|
+
Requires-Dist: pytest>=8.4.2
|
|
49
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
50
|
+
Requires-Dist: sqlalchemy>=2.0.40
|
|
51
|
+
Requires-Dist: timer-context>=1.1.1
|
|
52
|
+
Requires-Dist: uvicorn>=0.34.1
|
|
53
|
+
Provides-Extra: dev
|
|
54
|
+
Requires-Dist: check-manifest; extra == 'dev'
|
|
55
|
+
Provides-Extra: test
|
|
56
|
+
Requires-Dist: coverage; extra == 'test'
|
|
57
|
+
Requires-Dist: pytest-asyncio==0.24.0; extra == 'test'
|
|
58
|
+
Requires-Dist: pytest>=8.2.1; extra == 'test'
|
|
59
|
+
Description-Content-Type: text/markdown
|
|
60
|
+
|
|
61
|
+
# service laboratory package
|
|
62
|
+
|
|
63
|
+
- auth module
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
auth/__init__.py,sha256=a9OT6y15U8RGLH5CQRkb4irAOdZAjoTzC6-pCW1ArG8,101
|
|
2
|
+
auth/factories.py,sha256=aBAd50v_wfFVsBw70J78C0CCfxmjRI0tzYw5UtSWlOM,475
|
|
3
|
+
auth/fixtures.yaml,sha256=K4b2cfy7lsshSqATMte_HYCACqSVzOyZTQkW4BuQzKQ,55
|
|
4
|
+
auth/guards.py,sha256=hjpjDYr19I57VM-kQ2Kh5M81Pxr5yUIoiwkfth75gnY,399
|
|
5
|
+
auth/loaders.py,sha256=GiiHdKqNGNyUIYbWSHFMTQZfOjX_lP9oP4JGuvg2lSI,906
|
|
6
|
+
auth/middleware.py,sha256=-fYkpWcjWWGNr0FCWWoiv6d571flCqCvqDzDJo_bXuU,1677
|
|
7
|
+
auth/plugin.py,sha256=tYsX_HdDumN1IBrUewsQRLC7xvVK74d2tu87V9MpERE,1780
|
|
8
|
+
auth/api/__init__.py,sha256=5b8giem7j-uIRZQleEtW3kM4yuMj3xPay0j6ussw0_c,430
|
|
9
|
+
auth/api/account_controller.py,sha256=4xXKeNDJLSoDn7OGv2VKCopMru4s1e7yncjgmSEEj_Y,3756
|
|
10
|
+
auth/api/e2e_controller.py,sha256=EcvStn8i-Qtj563nifEFXOyUIqHqU7_O5uJQx--ntWs,406
|
|
11
|
+
auth/api/permission_controller.py,sha256=c0Tm4Df054_1DT_vGaHzlRY4OT2WGWhSqVAAKF5LAPM,1865
|
|
12
|
+
auth/api/role_controller.py,sha256=vjH9KaDWTAFFRhHAxW3h17iOib2KgAohkHJm9BC7z_8,1721
|
|
13
|
+
auth/api/user_controller.py,sha256=hGlK81Bop0TUSWO4n1x5eEi1OgOkq7DF3HFMTQ-9mjk,1875
|
|
14
|
+
auth/models/__init__.py,sha256=Kg2UEpg_8k3mIJH5TpLTwh6om92BajUTdOX1TGgJa3Q,397
|
|
15
|
+
auth/models/auth_code_model.py,sha256=tqyhGKfsZxtnKxPw08yi7RZPpwjurNQXnSKYPY50sjQ,328
|
|
16
|
+
auth/models/permission_model.py,sha256=ZRRkU62yXWYGZQZEwjcXiA1RBlSv3H0KtG-aWv2gE78,223
|
|
17
|
+
auth/models/role_model.py,sha256=jnRWHc1TQCLSWpd_bVRyYoowy4NboEkEbjzpAvzFJ1k,368
|
|
18
|
+
auth/models/role_permission_model.py,sha256=qFGiuhrWWSFGaiXZ3oF2gZz2KZrFiOp9r5IxgVeCTCY,394
|
|
19
|
+
auth/models/user_model.py,sha256=mo7prCyxJmw-E0USACdzyt-gVGWnDczEOOuyF3zBg04,461
|
|
20
|
+
auth/models/user_role_model.py,sha256=bq82Ml1evYnz87QTGkHK3CTAIadqBW462MEHK6cBqUU,370
|
|
21
|
+
auth/services/__init__.py,sha256=BofpESzGDllOtdB64gf5A04YDv6ZX2hdFay0SVBKebU,1095
|
|
22
|
+
auth/services/auth_service.py,sha256=gCKU5OnWJ7ra_rMJhQWN6ZYQXRF-VOCZzZKFko8peWY,9178
|
|
23
|
+
auth/services/permission_service.py,sha256=k6xb_decsFH_PVj1PBOaB75Xht2qusqRHMP9FjnJWXU,619
|
|
24
|
+
auth/services/role_service.py,sha256=dNU2Dm3rwoPoytWSxWMPtSOp9gxGQzwbeWvRXxhLe0w,576
|
|
25
|
+
auth/services/user_service.py,sha256=VxEBUckhUpiFXcM1-53rFTYyH0hwMtup6CAgedzS4iw,576
|
|
26
|
+
core/cli.py,sha256=bblQvTjmn5wiYsDSBdyqwN9xnsuRwJrlFenjStHJJ5s,173
|
|
27
|
+
core/database.py,sha256=QUsr9TDVfjnIPAHq8UtnWmRVfQz228KYZis6fSjDVzw,599
|
|
28
|
+
core/mail.py,sha256=50LWGDhN8w2JNZJQMdKsefoCWxmGEdCVEIbl5Q6kMEs,844
|
|
29
|
+
core/openapi.py,sha256=IsmcepeUgIT7Rk9qY2jCDkurOGeJmf0bmnJhDnvye_k,805
|
|
30
|
+
core/settings.py,sha256=CbRF-uUFF6DCKtpvFGyICzEL61rQ6iiwyq309S1DjN0,1300
|
|
31
|
+
core/storage.py,sha256=EMANUfmr14foq7Oq1qkBRWfynNFayQjLDzPa3T0UCSY,1617
|
|
32
|
+
service_packages-4.0.22.dist-info/METADATA,sha256=M3ZwU87zy8pmJ8OSroqFEksdLLTLs1texJhNqzcN8PI,2785
|
|
33
|
+
service_packages-4.0.22.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
34
|
+
service_packages-4.0.22.dist-info/licenses/LICENSE.txt,sha256=2bm9uFabQZ3Ykb_SaSU_uUbAj2-htc6WJQmS_65qD00,1073
|
|
35
|
+
service_packages-4.0.22.dist-info/RECORD,,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright (c) 2018 The Python Packaging Authority
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|