Skip to content

Commit 9eebb5e

Browse files
authored
Auth method refactoring (#183)
## Изменения Метаклассы AuthMethod перенесены в отдельную папку и разделены на несколько ## Детали реализации Теперь `AuthPluginMeta` (ex. `AuthMethodMeta`) – это абстрактный класс минимального плагина аутентификации. Дополнительная функциональность подключается миксинами: - `LoginMixin` – требуется для ручки `/login` - `RegisterMixin` – требуется для ручки `/register` - `UserdataMixin` – требуется для работы с кафкой и отправки событий в Userdata API
1 parent 9606ac2 commit 9eebb5e

21 files changed

Lines changed: 252 additions & 187 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from .base import AUTH_METHODS, AuthPluginMeta
2+
from .method_mixins import LoginableMixin, RegistrableMixin
3+
from .oauth import OauthMeta
4+
from .session import Session
5+
from .userdata_mixin import UserdataMixin
6+
7+
8+
__all__ = [
9+
"Session",
10+
"AUTH_METHODS",
11+
"AuthPluginMeta",
12+
"OauthMeta",
13+
"LoginableMixin",
14+
"RegistrableMixin",
15+
"UserdataMixin",
16+
]
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,29 @@
11
from __future__ import annotations
22

33
import logging
4-
import random
54
import re
6-
import string
7-
from abc import ABCMeta, abstractmethod
5+
from abc import ABCMeta
86
from asyncio import gather
9-
from datetime import datetime
10-
from typing import Annotated, Any, Iterable, final
7+
from typing import Any, Iterable
118

12-
from annotated_types import MinLen
13-
from event_schema.auth import UserLogin, UserLoginKey
14-
from fastapi import APIRouter, Depends
15-
from fastapi_sqlalchemy import db
9+
from fastapi import APIRouter
1610
from sqlalchemy.orm import Session as DbSession
1711

18-
from auth_backend.base import Base
19-
from auth_backend.exceptions import LastAuthMethodDelete
20-
from auth_backend.models.db import AuthMethod, User, UserSession
12+
from auth_backend.auth_method.session import Session
13+
from auth_backend.models.db import User, UserSession
2114
from auth_backend.schemas.types.scopes import Scope as TypeScope
2215
from auth_backend.settings import get_settings
23-
from auth_backend.utils.security import UnionAuth
2416
from auth_backend.utils.user_session_control import create_session
2517

2618

2719
logger = logging.getLogger(__name__)
2820
settings = get_settings()
2921

3022

31-
def random_string(length: int = 32) -> str:
32-
return "".join([random.choice(string.ascii_letters) for _ in range(length)])
23+
AUTH_METHODS: dict[str, type[AuthPluginMeta]] = {}
3324

3425

35-
class Session(Base):
36-
token: Annotated[str, MinLen(1)]
37-
expires: datetime
38-
id: int
39-
user_id: int
40-
session_scopes: list[TypeScope]
41-
42-
43-
AUTH_METHODS: dict[str, type[AuthMethodMeta]] = {}
44-
45-
46-
class AuthMethodMeta(metaclass=ABCMeta):
26+
class AuthPluginMeta(metaclass=ABCMeta):
4727
router: APIRouter
4828
prefix: str
4929
tags: list[str] = []
@@ -58,35 +38,11 @@ def __init__(self):
5838
self.router.add_api_route("/login", self._login, methods=["POST"], response_model=Session)
5939

6040
def __init_subclass__(cls, **kwargs):
61-
if cls.__name__.endswith('Meta'):
41+
if cls.__name__.endswith('Meta') or cls.__name__.endswith('Mixin'):
6242
return
6343
logger.info(f'Init authmethod {cls.__name__}')
6444
AUTH_METHODS[cls.__name__] = cls
6545

66-
@staticmethod
67-
@abstractmethod
68-
async def _register(*args, **kwargs) -> object:
69-
raise NotImplementedError()
70-
71-
@staticmethod
72-
@abstractmethod
73-
async def _login(*args, **kwargs) -> Session:
74-
raise NotImplementedError()
75-
76-
@staticmethod
77-
@final
78-
def generate_kafka_key(user_id: int) -> UserLoginKey:
79-
"""
80-
Мы генерируем ключи так как для сообщений с одинаковыми ключами
81-
Kafka гарантирует последовательность чтений
82-
Args:
83-
user_id: Айди пользователя
84-
85-
Returns:
86-
Ничего
87-
"""
88-
return UserLoginKey.model_validate({"user_id": user_id})
89-
9046
@staticmethod
9147
async def _create_session(
9248
user: User, scopes_list_names: list[TypeScope] | None, session_name: str | None = None, *, db_session: DbSession
@@ -124,11 +80,6 @@ async def _get_user(
12480
return user_session.user
12581
return
12682

127-
@classmethod
128-
@abstractmethod
129-
async def _convert_data_to_userdata_format(cls, data: Any) -> UserLogin:
130-
raise NotImplementedError()
131-
13283
@staticmethod
13384
async def user_updated(
13485
new_user: dict[str, Any] | None,
@@ -173,7 +124,7 @@ async def user_updated(
173124
```
174125
"""
175126
exceptions = await gather(
176-
*[m.on_user_update(new_user, old_user) for m in AuthMethodMeta.active_auth_methods()],
127+
*[m.on_user_update(new_user, old_user) for m in AuthPluginMeta.active_auth_methods()],
177128
return_exceptions=True,
178129
)
179130
if len(exceptions) > 0:
@@ -193,96 +144,7 @@ def is_active(cls):
193144
return settings.ENABLED_AUTH_METHODS is None or cls.get_name() in settings.ENABLED_AUTH_METHODS
194145

195146
@staticmethod
196-
def active_auth_methods() -> Iterable[type['AuthMethodMeta']]:
147+
def active_auth_methods() -> Iterable[type['AuthPluginMeta']]:
197148
for method in AUTH_METHODS.values():
198149
if method.is_active():
199150
yield method
200-
201-
202-
class OauthMeta(AuthMethodMeta):
203-
"""Абстрактная авторизация и аутентификация через OAuth"""
204-
205-
class UrlSchema(Base):
206-
url: str
207-
208-
def __init__(self):
209-
super().__init__()
210-
self.router.add_api_route("/redirect_url", self._redirect_url, methods=["GET"], response_model=self.UrlSchema)
211-
self.router.add_api_route("/auth_url", self._auth_url, methods=["GET"], response_model=self.UrlSchema)
212-
self.router.add_api_route("", self._unregister, methods=["DELETE"])
213-
214-
@staticmethod
215-
@abstractmethod
216-
async def _redirect_url(*args, **kwargs) -> UrlSchema:
217-
"""URL на который происходит редирект после завершения входа на стороне провайдера"""
218-
raise NotImplementedError()
219-
220-
@staticmethod
221-
@abstractmethod
222-
async def _auth_url(*args, **kwargs) -> UrlSchema:
223-
"""URL на который происходит редирект из приложения для авторизации на стороне провайдера"""
224-
raise NotImplementedError()
225-
226-
@classmethod
227-
async def _unregister(cls, user_session: UserSession = Depends(UnionAuth(scopes=[], auto_error=True))):
228-
"""Отключает для пользователя метод входа"""
229-
old_user = {"user_id": user_session.user.id}
230-
new_user = {"user_id": user_session.user.id}
231-
old_user_params = await cls._delete_auth_methods(user_session.user, db_session=db.session)
232-
old_user[cls.get_name()] = old_user_params
233-
await AuthMethodMeta.user_updated(new_user, old_user)
234-
return None
235-
236-
@classmethod
237-
async def _get_user(cls, key: str, value: str | int, *, db_session: DbSession) -> User | None:
238-
auth_method: AuthMethod = (
239-
AuthMethod.query(session=db_session)
240-
.filter(
241-
AuthMethod.param == key,
242-
AuthMethod.value == str(value),
243-
AuthMethod.auth_method == cls.get_name(),
244-
)
245-
.limit(1)
246-
.one_or_none()
247-
)
248-
if auth_method:
249-
return auth_method.user
250-
251-
@classmethod
252-
async def _register_auth_method(cls, key: str, value: str | int, user: User, *, db_session) -> AuthMethod:
253-
"""Добавление пользователю новый AuthMethod"""
254-
return AuthMethod.create(
255-
user_id=user.id,
256-
auth_method=cls.get_name(),
257-
param=key,
258-
value=str(value),
259-
session=db_session,
260-
)
261-
262-
@classmethod
263-
async def _delete_auth_methods(cls, user: User, *, db_session) -> list[AuthMethod]:
264-
"""Удаляет пользователю все AuthMethod конкретной авторизации"""
265-
auth_methods: list[AuthMethod] = (
266-
AuthMethod.query(session=db_session)
267-
.filter(
268-
AuthMethod.user_id == user.id,
269-
AuthMethod.auth_method == cls.get_name(),
270-
)
271-
.all()
272-
)
273-
all_auth_methods = AuthMethod.query(session=db_session).filter(AuthMethod.user_id == user.id).all()
274-
if len(all_auth_methods) - len(auth_methods) == 0:
275-
raise LastAuthMethodDelete()
276-
logger.debug(auth_methods)
277-
for method in auth_methods:
278-
method.is_deleted = True
279-
db_session.flush()
280-
return {m.param: m.value for m in auth_methods}
281-
282-
@classmethod
283-
def userdata_process_empty_strings(cls, userdata: UserLogin) -> UserLogin:
284-
'''Изменяет значения с пустыми строками в параметре категории юзердаты на None'''
285-
for item in userdata.items:
286-
if item.value == '':
287-
item.value = None
288-
return userdata
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from abc import ABCMeta, abstractmethod
2+
3+
from .base import AuthPluginMeta
4+
from .session import Session
5+
6+
7+
class RegistrableMixin(AuthPluginMeta, metaclass=ABCMeta):
8+
"""Сообщает что AuthMethod поддерживает регистрацию
9+
10+
Обязывает AuthMethod иметь метод `_register`, который используется как апи-запрос `/registration`
11+
"""
12+
13+
def __init__(self):
14+
super().__init__()
15+
self.router.add_api_route("/registration", self._register, methods=["POST"])
16+
17+
@staticmethod
18+
@abstractmethod
19+
async def _register(*args, **kwargs) -> object:
20+
raise NotImplementedError()
21+
22+
23+
class LoginableMixin(AuthPluginMeta, metaclass=ABCMeta):
24+
"""Сообщает что AuthMethod поддерживает вход
25+
26+
Обязывает AuthMethod иметь метод `_login`, который используется как апи-запрос `/login`
27+
"""
28+
29+
def __init__(self):
30+
super().__init__()
31+
self.router.add_api_route("/login", self._login, methods=["POST"], response_model=Session)
32+
33+
@staticmethod
34+
@abstractmethod
35+
async def _login(*args, **kwargs) -> Session:
36+
raise NotImplementedError()

auth_backend/auth_method/oauth.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import logging
2+
from abc import abstractmethod
3+
4+
from fastapi import Depends
5+
from fastapi_sqlalchemy import db
6+
from sqlalchemy.orm import Session as DbSession
7+
8+
from auth_backend.base import Base
9+
from auth_backend.exceptions import LastAuthMethodDelete
10+
from auth_backend.models.db import AuthMethod, User, UserSession
11+
from auth_backend.utils.security import UnionAuth
12+
13+
from .base import AuthPluginMeta
14+
from .method_mixins import LoginableMixin, RegistrableMixin
15+
from .userdata_mixin import UserdataMixin
16+
17+
18+
logger = logging.getLogger(__name__)
19+
20+
21+
class OauthMeta(UserdataMixin, LoginableMixin, RegistrableMixin, AuthPluginMeta):
22+
"""Абстрактная авторизация и аутентификация через OAuth"""
23+
24+
class UrlSchema(Base):
25+
url: str
26+
27+
def __init__(self):
28+
super().__init__()
29+
self.router.add_api_route("/redirect_url", self._redirect_url, methods=["GET"], response_model=self.UrlSchema)
30+
self.router.add_api_route("/auth_url", self._auth_url, methods=["GET"], response_model=self.UrlSchema)
31+
self.router.add_api_route("", self._unregister, methods=["DELETE"])
32+
33+
@staticmethod
34+
@abstractmethod
35+
async def _redirect_url(*args, **kwargs) -> UrlSchema:
36+
"""URL на который происходит редирект после завершения входа на стороне провайдера"""
37+
raise NotImplementedError()
38+
39+
@staticmethod
40+
@abstractmethod
41+
async def _auth_url(*args, **kwargs) -> UrlSchema:
42+
"""URL на который происходит редирект из приложения для авторизации на стороне провайдера"""
43+
raise NotImplementedError()
44+
45+
@classmethod
46+
async def _unregister(cls, user_session: UserSession = Depends(UnionAuth(scopes=[], auto_error=True))):
47+
"""Отключает для пользователя метод входа"""
48+
old_user = {"user_id": user_session.user.id}
49+
new_user = {"user_id": user_session.user.id}
50+
old_user_params = await cls._delete_auth_methods(user_session.user, db_session=db.session)
51+
old_user[cls.get_name()] = old_user_params
52+
await AuthPluginMeta.user_updated(new_user, old_user)
53+
return None
54+
55+
@classmethod
56+
async def _get_user(cls, key: str, value: str | int, *, db_session: DbSession) -> User | None:
57+
auth_method: AuthMethod = (
58+
AuthMethod.query(session=db_session)
59+
.filter(
60+
AuthMethod.param == key,
61+
AuthMethod.value == str(value),
62+
AuthMethod.auth_method == cls.get_name(),
63+
)
64+
.limit(1)
65+
.one_or_none()
66+
)
67+
if auth_method:
68+
return auth_method.user
69+
70+
@classmethod
71+
async def _register_auth_method(cls, key: str, value: str | int, user: User, *, db_session) -> AuthMethod:
72+
"""Добавление пользователю новый AuthMethod"""
73+
return AuthMethod.create(
74+
user_id=user.id,
75+
auth_method=cls.get_name(),
76+
param=key,
77+
value=str(value),
78+
session=db_session,
79+
)
80+
81+
@classmethod
82+
async def _delete_auth_methods(cls, user: User, *, db_session) -> list[AuthMethod]:
83+
"""Удаляет пользователю все AuthMethod конкретной авторизации"""
84+
auth_methods: list[AuthMethod] = (
85+
AuthMethod.query(session=db_session)
86+
.filter(
87+
AuthMethod.user_id == user.id,
88+
AuthMethod.auth_method == cls.get_name(),
89+
)
90+
.all()
91+
)
92+
all_auth_methods = AuthMethod.query(session=db_session).filter(AuthMethod.user_id == user.id).all()
93+
if len(all_auth_methods) - len(auth_methods) == 0:
94+
raise LastAuthMethodDelete()
95+
logger.debug(auth_methods)
96+
for method in auth_methods:
97+
method.is_deleted = True
98+
db_session.flush()
99+
return {m.param: m.value for m in auth_methods}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from datetime import datetime
2+
from typing import Annotated
3+
4+
from annotated_types import MinLen
5+
6+
from auth_backend.base import Base
7+
from auth_backend.schemas.types.scopes import Scope as TypeScope
8+
9+
10+
class Session(Base):
11+
token: Annotated[str, MinLen(1)]
12+
expires: datetime
13+
id: int
14+
user_id: int
15+
session_scopes: list[TypeScope]

0 commit comments

Comments
 (0)