Skip to content

Commit ee638ce

Browse files
authored
Outer auth meta (#184)
## Изменения Добавил метакласс для плагинов синхронизации пароля. Их можно применять чтобы менять пароль внешних инфраструктурных сервисов из одного места. ## Детали реализации Создан класс OuterAuthMeta, реализующий функции управления внешним сервисом: - Линковки и отлинковки внешних сервисов **администратором** - Обработки событий по смене пароля И предоставляющий интерфейс: - Создания/удаления/изменеия пользователя во внешнем сервисе
1 parent 9eebb5e commit ee638ce

26 files changed

Lines changed: 437 additions & 87 deletions

Makefile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
SHELL := /bin/bash
2+
13
run:
24
source ./venv/bin/activate && uvicorn --reload --log-config logging_dev.conf auth_backend.routes.base:app
35

auth_backend/auth_method/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from .base import AUTH_METHODS, AuthPluginMeta
22
from .method_mixins import LoginableMixin, RegistrableMixin
33
from .oauth import OauthMeta
4+
from .outer import OuterAuthMeta
45
from .session import Session
56
from .userdata_mixin import UserdataMixin
67

@@ -10,6 +11,7 @@
1011
"AUTH_METHODS",
1112
"AuthPluginMeta",
1213
"OauthMeta",
14+
"OuterAuthMeta",
1315
"LoginableMixin",
1416
"RegistrableMixin",
1517
"UserdataMixin",

auth_backend/auth_method/base.py

Lines changed: 43 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,8 @@
99
from fastapi import APIRouter
1010
from sqlalchemy.orm import Session as DbSession
1111

12-
from auth_backend.auth_method.session import Session
13-
from auth_backend.models.db import User, UserSession
14-
from auth_backend.schemas.types.scopes import Scope as TypeScope
12+
from auth_backend.models.db import AuthMethod, User, UserSession
1513
from auth_backend.settings import get_settings
16-
from auth_backend.utils.user_session_control import create_session
1714

1815

1916
logger = logging.getLogger(__name__)
@@ -34,30 +31,13 @@ def get_name(cls) -> str:
3431

3532
def __init__(self):
3633
self.router = APIRouter()
37-
self.router.add_api_route("/registration", self._register, methods=["POST"])
38-
self.router.add_api_route("/login", self._login, methods=["POST"], response_model=Session)
3934

4035
def __init_subclass__(cls, **kwargs):
4136
if cls.__name__.endswith('Meta') or cls.__name__.endswith('Mixin'):
4237
return
4338
logger.info(f'Init authmethod {cls.__name__}')
4439
AUTH_METHODS[cls.__name__] = cls
4540

46-
@staticmethod
47-
async def _create_session(
48-
user: User, scopes_list_names: list[TypeScope] | None, session_name: str | None = None, *, db_session: DbSession
49-
) -> Session:
50-
"""Создает сессию пользователя"""
51-
return await create_session(user, scopes_list_names, db_session=db_session, session_name=session_name)
52-
53-
@staticmethod
54-
async def _create_user(*, db_session: DbSession) -> User:
55-
"""Создает пользователя"""
56-
user = User()
57-
db_session.add(user)
58-
db_session.flush()
59-
return user
60-
6141
async def _get_user(
6242
*,
6343
db_session: DbSession,
@@ -130,10 +110,11 @@ async def user_updated(
130110
if len(exceptions) > 0:
131111
logger.error("Following errors occurred during on_user_update: ")
132112
for exc in exceptions:
133-
logger.error(exc)
113+
if exc:
114+
logger.error(exc)
134115

135-
@staticmethod
136-
async def on_user_update(new_user: dict[str, Any], old_user: dict[str, Any] | None = None):
116+
@classmethod
117+
async def on_user_update(cls, new_user: dict[str, Any], old_user: dict[str, Any] | None = None):
137118
"""Произвести действия на обновление пользователя, в т.ч. обновление в других провайдерах
138119
139120
Описания входных параметров соответствует параметрам `AuthMethodMeta.user_updated`.
@@ -148,3 +129,41 @@ def active_auth_methods() -> Iterable[type['AuthPluginMeta']]:
148129
for method in AUTH_METHODS.values():
149130
if method.is_active():
150131
yield method
132+
133+
@classmethod
134+
def create_auth_method_param(
135+
cls,
136+
key: str,
137+
value: str | int,
138+
user_id: int,
139+
*,
140+
db_session: DbSession,
141+
) -> AuthMethod:
142+
"""Добавление пользователю новый AuthMethod"""
143+
return AuthMethod.create(
144+
user_id=user_id,
145+
auth_method=cls.get_name(),
146+
param=key,
147+
value=str(value),
148+
session=db_session,
149+
)
150+
151+
@classmethod
152+
def get_auth_method_params(
153+
cls,
154+
user_id: int,
155+
*,
156+
session: DbSession,
157+
) -> dict[str, AuthMethod]:
158+
retval: dict[str, AuthMethod] = {}
159+
methods: list[AuthMethod] = (
160+
AuthMethod.query(session=session)
161+
.filter(
162+
AuthMethod.user_id == user_id,
163+
AuthMethod.auth_method == cls.get_name(),
164+
)
165+
.all()
166+
)
167+
for method in methods:
168+
retval[method.param] = method
169+
return retval

auth_backend/auth_method/method_mixins.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
from abc import ABCMeta, abstractmethod
22

3+
from sqlalchemy.orm import Session as DbSession
4+
5+
from auth_backend.auth_method.session import Session
6+
from auth_backend.models.db import User
7+
from auth_backend.schemas.types.scopes import Scope as TypeScope
8+
from auth_backend.utils.user_session_control import create_session
9+
310
from .base import AuthPluginMeta
411
from .session import Session
512

@@ -19,6 +26,13 @@ def __init__(self):
1926
async def _register(*args, **kwargs) -> object:
2027
raise NotImplementedError()
2128

29+
@staticmethod
30+
async def _create_user(*, db_session: DbSession) -> User:
31+
"""Создает пользователя"""
32+
user = User.create(session=db_session)
33+
db_session.flush()
34+
return user
35+
2236

2337
class LoginableMixin(AuthPluginMeta, metaclass=ABCMeta):
2438
"""Сообщает что AuthMethod поддерживает вход
@@ -34,3 +48,10 @@ def __init__(self):
3448
@abstractmethod
3549
async def _login(*args, **kwargs) -> Session:
3650
raise NotImplementedError()
51+
52+
@staticmethod
53+
async def _create_session(
54+
user: User, scopes_list_names: list[TypeScope] | None, session_name: str | None = None, *, db_session: DbSession
55+
) -> Session:
56+
"""Создает сессию пользователя"""
57+
return await create_session(user, scopes_list_names, db_session=db_session, session_name=session_name)

auth_backend/auth_method/oauth.py

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -67,17 +67,6 @@ async def _get_user(cls, key: str, value: str | int, *, db_session: DbSession) -
6767
if auth_method:
6868
return auth_method.user
6969

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-
8170
@classmethod
8271
async def _delete_auth_methods(cls, user: User, *, db_session) -> list[AuthMethod]:
8372
"""Удаляет пользователю все AuthMethod конкретной авторизации"""

auth_backend/auth_method/outer.py

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import logging
2+
from abc import ABCMeta, abstractmethod
3+
from typing import Any
4+
5+
from fastapi import Depends
6+
from fastapi.exceptions import HTTPException
7+
from fastapi_sqlalchemy import db
8+
from starlette.status import HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT
9+
10+
from auth_backend.auth_method.base import AuthPluginMeta
11+
from auth_backend.base import Base
12+
from auth_backend.models.db import AuthMethod, UserSession
13+
from auth_backend.utils.security import UnionAuth
14+
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
class OuterAuthException(Exception):
20+
"""Базовый класс для исключений внешнего сервиса"""
21+
22+
23+
class UserLinkingConflict(HTTPException, OuterAuthException):
24+
"""Пользователь уже привязан к другому аккаунту"""
25+
26+
def __init__(self, user_id):
27+
super().__init__(status_code=HTTP_409_CONFLICT, detail=f"User id={user_id} already linked")
28+
29+
30+
class UserNotLinked(HTTPException, OuterAuthException):
31+
"""Пользователь не привязан к аккаунту"""
32+
33+
def __init__(self, user_id):
34+
super().__init__(status_code=HTTP_404_NOT_FOUND, detail=f"User id={user_id} not linked")
35+
36+
37+
class UserLinkingForbidden(HTTPException, OuterAuthException):
38+
"""У пользователя недостаточно прав для привязки аккаунта к внешнему сервису"""
39+
40+
def __init__(self):
41+
super().__init__(status_code=HTTP_403_FORBIDDEN, detail="Not authorized")
42+
43+
44+
class GetOuterAccount(Base):
45+
username: str
46+
47+
48+
class LinkOuterAccount(Base):
49+
username: str
50+
51+
52+
class OuterAuthMeta(AuthPluginMeta, metaclass=ABCMeta):
53+
"""Позволяет подключить внешний сервис для синхронизации пароля"""
54+
55+
__BASE_SCOPE: str
56+
57+
def __init__(self):
58+
super().__init__()
59+
self.router.add_api_route("/{user_id}/link", self._get_link, methods=["GET"])
60+
self.router.add_api_route("/{user_id}/link", self._link, methods=["POST"])
61+
self.router.add_api_route("/{user_id}/unlink", self._unlink, methods=["DELETE"])
62+
self.__BASE_SCOPE = f"auth.{self.get_name()}.link"
63+
64+
@classmethod
65+
def get_scope(cls):
66+
"""Права, необходимые пользователю для получения данных о внешнем аккаунте"""
67+
return cls.__BASE_SCOPE + ".read"
68+
69+
@classmethod
70+
def post_scope(cls):
71+
"""Права, необходимые пользователю для создания данных о внешнем аккаунте"""
72+
return cls.__BASE_SCOPE + ".create"
73+
74+
@classmethod
75+
def delete_scope(cls):
76+
"""Права, необходимые пользователю для удаления данных о внешнем аккаунте"""
77+
return cls.__BASE_SCOPE + ".delete"
78+
79+
@classmethod
80+
@abstractmethod
81+
async def _is_outer_user_exists(cls, username: str) -> bool:
82+
"""Проверяет наличие пользователя во внешнем сервисе"""
83+
raise NotImplementedError()
84+
85+
@classmethod
86+
@abstractmethod
87+
async def _update_outer_user_password(cls, username: str, password: str):
88+
"""Устанавливает пользователю новый пароль в внешнем сервисе"""
89+
raise NotImplementedError()
90+
91+
@classmethod
92+
async def __get_username(cls, user_id: int) -> AuthMethod:
93+
auth_params = cls.get_auth_method_params(user_id, session=db.session)
94+
username = auth_params.get("username")
95+
if not username:
96+
logger.debug("User user_id=%d have no username in outer service %s", user_id, cls.get_name())
97+
return
98+
return username
99+
100+
@classmethod
101+
async def on_user_update(cls, new_user: dict[str, Any], old_user: dict[str, Any] | None = None):
102+
"""Произвести действия на обновление пользователя, в т.ч. обновление в других провайдерах
103+
104+
Описания входных параметров соответствует параметрам `AuthMethodMeta.user_updated`.
105+
"""
106+
if not new_user or not old_user:
107+
# Пользователь был только что создан или удален
108+
# Тут не будет дополнительных методов
109+
return
110+
111+
user_id = new_user.get("user_id")
112+
password = new_user.get("email", {}).get("password")
113+
if not password:
114+
# В этом событии пароль не обновлялся, ничего не делаем
115+
return
116+
117+
username = await cls.__get_username(user_id)
118+
if not username:
119+
# У пользователя нет имени во внешнем сервисе
120+
return
121+
122+
if await cls._is_outer_user_exists(username.value):
123+
await cls._update_outer_user_password(username.value, password)
124+
else:
125+
# Мы не нашли этого пользователя во внешнем сервисе
126+
# Разорвем связку и кинем лог
127+
username.is_deleted = True
128+
logger.error(
129+
"User id=%d has username %s, which can't be found in %s",
130+
user_id,
131+
username.value,
132+
cls.get_name(),
133+
)
134+
135+
@classmethod
136+
async def _get_link(
137+
cls,
138+
user_id: int,
139+
request_user: UserSession = Depends(UnionAuth()),
140+
) -> GetOuterAccount:
141+
"""Получить данные внешнего аккаунт пользователя
142+
143+
Получить данные может администратор или сам пользователь
144+
"""
145+
if cls.get_scope() not in (s.name for s in request_user.scopes) and request_user.id != user_id:
146+
raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Not authorized")
147+
username = await cls.__get_username(user_id)
148+
if not username:
149+
raise UserNotLinked(user_id)
150+
return GetOuterAccount(username=username.value)
151+
152+
@classmethod
153+
async def _link(
154+
cls,
155+
user_id: int,
156+
outer: LinkOuterAccount,
157+
request_user: UserSession = Depends(UnionAuth()),
158+
) -> GetOuterAccount:
159+
"""Привязать пользователю внешний аккаунт
160+
161+
Привязать аккаунт может только администратор
162+
"""
163+
if cls.post_scope() not in (s.name for s in request_user.scopes):
164+
raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Not authorized")
165+
username = await cls.__get_username(user_id)
166+
if username:
167+
raise UserLinkingConflict(user_id)
168+
param = cls.create_auth_method_param("username", outer.username, user_id, db_session=db.session)
169+
return GetOuterAccount(username=param.value)
170+
171+
@classmethod
172+
async def _unlink(
173+
cls,
174+
user_id: int,
175+
request_user: UserSession = Depends(UnionAuth()),
176+
):
177+
"""Отвязать внешний аккаунт пользователю
178+
179+
Удалить данные может администратор
180+
"""
181+
if cls.delete_scope() not in (s.name for s in request_user.scopes):
182+
raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Not authorized")
183+
username = await cls.__get_username(user_id)
184+
if not username:
185+
raise UserNotLinked(user_id)
186+
username.is_deleted = True

0 commit comments

Comments
 (0)