From 6a3ebd8c08b58a7bf082dbcec4e6d883d7abf6b6 Mon Sep 17 00:00:00 2001 From: Roman Dyakov Date: Wed, 12 Mar 2025 02:58:10 +0000 Subject: [PATCH 01/18] JWT utils --- auth_backend/routes/base.py | 18 +++--- auth_backend/routes/oidc.py | 38 ++++++++++++ auth_backend/settings.py | 6 ++ auth_backend/utils/jwt.py | 114 ++++++++++++++++++++++++++++++++++++ tests/private-key.pem | 28 +++++++++ tests/test_jwt.py | 37 ++++++++++++ 6 files changed, 233 insertions(+), 8 deletions(-) create mode 100644 auth_backend/routes/oidc.py create mode 100644 auth_backend/utils/jwt.py create mode 100644 tests/private-key.pem create mode 100644 tests/test_jwt.py diff --git a/auth_backend/routes/base.py b/auth_backend/routes/base.py index 65eb6c2d..efff10ac 100644 --- a/auth_backend/routes/base.py +++ b/auth_backend/routes/base.py @@ -9,10 +9,11 @@ from auth_backend.kafka.kafka import get_kafka_producer from auth_backend.settings import get_settings -from .groups import groups -from .scopes import scopes -from .user import user -from .user_session import user_session +from .groups import groups as groups_router +from .oidc import router as openid_router +from .scopes import scopes as scopes_router +from .user import user as user_router +from .user_session import user_session as user_session_router @asynccontextmanager @@ -50,10 +51,11 @@ async def lifespan(app: FastAPI): allow_headers=settings.CORS_ALLOW_HEADERS, ) -app.include_router(user_session) -app.include_router(groups) -app.include_router(scopes) -app.include_router(user) +app.include_router(groups_router) +app.include_router(scopes_router) +app.include_router(user_router) +app.include_router(user_session_router) +app.include_router(openid_router) for method in AuthPluginMeta.active_auth_methods(): app.include_router(router=method().router, prefix=method.prefix, tags=[method.get_name()]) diff --git a/auth_backend/routes/oidc.py b/auth_backend/routes/oidc.py new file mode 100644 index 00000000..7daca00b --- /dev/null +++ b/auth_backend/routes/oidc.py @@ -0,0 +1,38 @@ +import logging + +from fastapi import APIRouter +from fastapi_sqlalchemy import db + +from auth_backend.models.db import Scope +from auth_backend.settings import get_settings +from auth_backend.utils.jwt import create_jwks + + +settings = get_settings() +router = APIRouter(prefix="/openid", tags=["OpenID"]) +logger = logging.getLogger(__name__) + + +@router.get("/.well_known/openid_configuration") +def openid_configuration(): + """Конфигурация для подключения OpenID Connect совместимых приложений + + **Attention:** ручка соответствует спецификации не полностью, не все OIDC приложения смогут ей пользоваться + """ + return { + "issuer": f"{settings.APPLICATION_HOST}", + "token_endpoint": f"{settings.APPLICATION_HOST}/openid/token", + "userinfo_endpoint": f"{settings.APPLICATION_HOST}/me", + "jwks_uri": f"{settings.APPLICATION_HOST}/.well-known/jwks", + "scopes_supported": list(x[0] for x in db.session.query(Scope.name).all()), + "response_types_supported": ["token"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"], + "claims_supported": ["sub", "iss", "exp", "iat"], + # "authorization_endpoint": f"{settings.APPLICATION_HOST}/auth", + } + + +@router.get("/.well_known/jwks") +def jwks(): + return {"keys": [create_jwks()]} diff --git a/auth_backend/settings.py b/auth_backend/settings.py index c80d3f76..4a1bca08 100644 --- a/auth_backend/settings.py +++ b/auth_backend/settings.py @@ -2,10 +2,12 @@ import random import string from functools import lru_cache +from pathlib import Path from typing import Annotated from annotated_types import Gt from pydantic import PostgresDsn +from pydantic.types import PathType from pydantic_settings import BaseSettings, SettingsConfigDict @@ -49,6 +51,10 @@ class Settings(BaseSettings): EMAIL_DELAY_COUNT: int = 3 model_config = SettingsConfigDict(case_sensitive=True, env_file=".env", extra='ignore') + JWT_ENABLED: bool = False + JWT_PRIVATE_KEY_FILE: Annotated[Path, PathType('file')] | None = './tests/private-key.pem' + JWT_PRIVATE_KEY: bytes | None = None + @lru_cache def get_settings(): diff --git a/auth_backend/utils/jwt.py b/auth_backend/utils/jwt.py new file mode 100644 index 00000000..22db9973 --- /dev/null +++ b/auth_backend/utils/jwt.py @@ -0,0 +1,114 @@ +import base64 +import hashlib +from dataclasses import dataclass +from datetime import datetime +from functools import lru_cache + +import jwt +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +from auth_backend.settings import get_settings + + +settings = get_settings() + + +@dataclass +class JwtSettings: + private_key: rsa.RSAPrivateKey + public_key: rsa.RSAPublicKey + pem_private_key: bytes + pem_public_key: bytes + n: str + e: str + kid: str + + +@lru_cache(1) +def get_private_key(): + # Если использование отключено – используем отсебятину + if not settings.JWT_ENABLED: + return rsa.generate_private_key(public_exponent=65537, key_size=2048, backend=default_backend()) + if settings.JWT_PRIVATE_KEY_FILE: + with open(settings.JWT_PRIVATE_KEY_FILE, "rb") as key_file: + key_bytes = key_file.read() + elif settings.JWT_PRIVATE_KEY: + key_bytes = settings.JWT_PRIVATE_KEY + else: + raise Exception("JWT private key not provided") + return serialization.load_pem_private_key(key_bytes, password=None) + + +# Функция для преобразования числа в Base64URL +def to_base64url(value): + # Преобразуем число в байты + byte_length = (value.bit_length() + 7) // 8 + byte_data = value.to_bytes(byte_length, byteorder='big') + # Кодируем в Base64 и удаляем padding (=) + return base64.urlsafe_b64encode(byte_data).rstrip(b'=').decode('utf-8') + + +@lru_cache(1) +def ensure_jwt_settings() -> JwtSettings: + private_key = get_private_key() + public_key = private_key.public_key() + pem_private_key = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + pem_public_key = public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + public_numbers = public_key.public_numbers() + n = to_base64url(public_numbers.n) + e = to_base64url(public_numbers.e) + kid = hashlib.sha256(pem_public_key).hexdigest()[:16] + return JwtSettings( + private_key=private_key, + public_key=public_key, + pem_private_key=pem_private_key, + pem_public_key=pem_public_key, + n=n, + e=e, + kid=kid, + ) + + +@lru_cache(1) +def create_jwks(): + jwt_settings = ensure_jwt_settings() + return { + "kty": "RSA", + "use": "sig", + "kid": jwt_settings.kid, + "alg": "RS256", + "n": jwt_settings.n, + "e": jwt_settings.e, + } + + +def generate_jwt(user_id: int, create_ts: datetime, expire_ts: datetime): + jwt_settings = ensure_jwt_settings() + return jwt.encode( + { + "sub": f"{user_id}", + "iss": f"{settings.APPLICATION_HOST}", + "iat": int(create_ts.timestamp()), + "exp": int(expire_ts.timestamp()), + }, + jwt_settings.pem_private_key, + algorithm="RS256", + ) + + +def decode_jwt(token: str): + jwt_settings = ensure_jwt_settings() + return jwt.decode( + token, + jwt_settings.pem_public_key, + algorithms=["RS256"], + ) diff --git a/tests/private-key.pem b/tests/private-key.pem new file mode 100644 index 00000000..e4985d67 --- /dev/null +++ b/tests/private-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCfWLqUHxEH6jYx +FZzTpOhN9F/RmeVALfLcbb4hzwWF1dbsT0PcYUUGyIGIGPS2nJmeCPfb0j+xl/EP +vs3Nu6KvLAHx0IrrWH8Q6IOXpPO5a93oxIL/vCpx5rGJ7q1prHqJ63T1Ixmihs7W +iAfnZOAwTFsjTQu4yeNwVOycNH8+YokwOi0VAC/96lM+vC65VP4xBFpli6X952+j +InAWHuK2vm1AsQMny7uRjgqFM24/4qliGF2/RuSSL+SUVBbK+wIvRU8v7eSGkiFr +7F+IfCSv2UvqUxMYQye769tZ8vkHddAnG0laPBWbDdT9a2PZcOjB4R6zAXwXaNd0 +B55wWpXzAgMBAAECggEAEbmS94sFH/ZDlO4shbZgSNuYFP6ja6Iw06g5cBVRLfP9 +dkfS6p6/SOPg1LzB69Y7mEKzH3ahsyWNoQy+Y9YtYILqrHVgHpG3gK/8g0/L9KI1 +CwFg+QV7SzQ4J3mvPIP9FX5lgicnYNbSBzcWefzUm0rDEIgvd5ytef9YWn/Ub6Hl +UzpTHIyinLIVYqpVZfqr9qobOPB4DaQ/3CzfDLdRTf4i1g+BxlkWrGshWpIt8+v/ +bBL/+rHDFcoMG7XigBa95arTdXbLaWcmycx0i46tdHsi95bMkZvKitaKR2dvl3zc +GD+3oafdhiyvOSlPc+r246U8CEiVNMMZWryF3QH1wQKBgQDVyPsj5gUIPAesEK4W +iexcFjWs84S4INCncVk9QpPiZ38Yvyl0N4FrYkX5NGpXb4rh7GzQ0QQqatl8Bj47 +am852viW8U/yURnxHmWZWO3iwMDWCvUOojvvS/8m9qnD/WeitFV0+ySoKAj7Yf7S +VV+f/895doGUtI+M6zOcRAtgYwKBgQC+z9X0gRezDaR+axj6dy9BNMqJqPXT35tU +XXH/Ajwy5tSL6b5YlU7Xro0LbS0VMeYGPt+XZ/ventWssJHzm8d7Muncr3kK/ocA +TNnHGS24ulRjhCR9q1KTS/9Y8jh060cCxyEjDf1mWrX/0cQOstQDNK9miPZDxApz +RohGqrxBMQKBgQCV/uOmNldFhcjkQvfCPJcnrTWP2XQ/NFbxhKfWQYY9Ddyw4j8V +mXQmgdcSmGIcYtiQ1y8p+9zuXfWl/UNgsLbFYwuT7E/pdlm7QVaLl0ehFxi1lQ6H +a/CdXzbwgZRvPLagA+MJpsP7b8uNhR4jOV9UhUlusWUNjvpBJy0Y8O0CfQKBgErL +OwmpZHnKGjV3k4XyG/LKV63YLewMFV3fdyTHYoNtWdkyGWutswb2I9FbzTUmpwzB +rnEx0Fe2GPmlCMDdyjavgV4A5kh59r8WYLMbWoGzgAq2LHuaITcdrgzWfWzPILml +BocwH6j0W6zYM6qzTEmpaCuf+jAb8yC2gAp7OGmxAoGAXP9aXhpTpGO383daObeV +HNg5fCvTbMYPNOiLNs98Ial9rE/MybIHu3uqD2xL3precreviZH2BY6UgOOf0/Rv +oBrOcYn6vQJMamUhs59Yk07BHcgFwBfByi3nrHO2cbvhqXCzHSuBy/0vcRPummOg +5kdo5Iz4GSyRvEAu3HhwvRg= +-----END PRIVATE KEY----- diff --git a/tests/test_jwt.py b/tests/test_jwt.py new file mode 100644 index 00000000..714b5cf7 --- /dev/null +++ b/tests/test_jwt.py @@ -0,0 +1,37 @@ +from datetime import datetime, timedelta + +import jwt + +from auth_backend.settings import get_settings +from auth_backend.utils.jwt import create_jwks, decode_jwt, generate_jwt + + +settings = get_settings() + + +def test_decode(): + uid = 123 + iat = datetime.now() + exp = iat + timedelta(days=5) + token = generate_jwt(uid, iat, exp) + dct = decode_jwt(token) + assert dct["sub"] == f"{uid}" + assert dct["iat"] == int(iat.timestamp()) + assert dct["exp"] == int(exp.timestamp()) + assert dct["iss"] == settings.APPLICATION_HOST + + +def test_decode_jwks(): + uid = 123 + iat = datetime.now() + exp = iat + timedelta(days=5) + token = generate_jwt(uid, iat, exp) + dct = jwt.decode( + token, + jwt.PyJWK(create_jwks()), + algorithms=["RS256"], + ) + assert dct["sub"] == f"{uid}" + assert dct["iat"] == int(iat.timestamp()) + assert dct["exp"] == int(exp.timestamp()) + assert dct["iss"] == settings.APPLICATION_HOST From 087649e1c523ebd725c222de1343dec22a31ec4a Mon Sep 17 00:00:00 2001 From: Roman Dyakov Date: Wed, 12 Mar 2025 03:07:40 +0000 Subject: [PATCH 02/18] Generate JWT tokens instead general ones --- auth_backend/models/db.py | 6 +----- auth_backend/utils/user_session_control.py | 20 ++++++++++++++++---- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/auth_backend/models/db.py b/auth_backend/models/db.py index 05693772..4834da98 100644 --- a/auth_backend/models/db.py +++ b/auth_backend/models/db.py @@ -149,14 +149,10 @@ class AuthMethod(BaseDbModel): ) -def session_expires_date(): - return datetime.datetime.utcnow() + datetime.timedelta(days=settings.SESSION_TIME_IN_DAYS) - - class UserSession(BaseDbModel): session_name: Mapped[str] = mapped_column(String, nullable=True) user_id: Mapped[int] = mapped_column(Integer, sqlalchemy.ForeignKey("user.id")) - expires: Mapped[datetime.datetime] = mapped_column(DateTime, default=session_expires_date) + expires: Mapped[datetime.datetime] = mapped_column(DateTime) token: Mapped[str] = mapped_column(String, unique=True) is_unbounded: Mapped[bool] = mapped_column(Boolean, default=False) last_activity: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow) diff --git a/auth_backend/utils/user_session_control.py b/auth_backend/utils/user_session_control.py index 11389315..854b969d 100644 --- a/auth_backend/utils/user_session_control.py +++ b/auth_backend/utils/user_session_control.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timedelta from fastapi import HTTPException from fastapi_sqlalchemy import db @@ -10,11 +10,16 @@ from auth_backend.schemas.types.scopes import Scope as TypeScope from auth_backend.settings import get_settings from auth_backend.utils.string import random_string +from auth_backend.utils.jwt import generate_jwt settings = get_settings() +def session_expires_date(): + return datetime.utcnow() + timedelta(days=settings.SESSION_TIME_IN_DAYS) + + async def create_session( user: User, scopes_list_names: list[TypeScope] | None, @@ -30,11 +35,18 @@ async def create_session( else: scopes = await create_scopes_set_by_names(scopes_list_names) await check_scopes(scopes, user) + create_ts = datetime.utcnow() + expire_ts = expires or session_expires_date() + token=random_string(length=settings.TOKEN_LENGTH) + if settings.JWT_ENABLED: + token = generate_jwt(user.id, create_ts, expire_ts) user_session = UserSession( - user_id=user.id, token=random_string(length=settings.TOKEN_LENGTH), session_name=session_name + user_id=user.id, + token=token, + session_name=session_name, + create_ts=create_ts, + expires=expire_ts, ) - user_session.expires = expires or user_session.expires - user_session.is_unbounded = is_unbounded db_session.add(user_session) db_session.flush() if not user_session.is_unbounded: From 5766e361c14d26f4650b8ca1ddf26448b1919fe9 Mon Sep 17 00:00:00 2001 From: Roman Dyakov Date: Wed, 12 Mar 2025 03:12:48 +0000 Subject: [PATCH 03/18] Do not update JWT tokens expire ts --- auth_backend/utils/security.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/auth_backend/utils/security.py b/auth_backend/utils/security.py index 1119f33b..33df5701 100644 --- a/auth_backend/utils/security.py +++ b/auth_backend/utils/security.py @@ -7,7 +7,12 @@ from starlette.requests import Request from starlette.status import HTTP_403_FORBIDDEN -from auth_backend.models.db import UserSession, session_expires_date +from auth_backend.models.db import UserSession +from auth_backend.settings import get_settings +from auth_backend.utils.user_session_control import session_expires_date + + +settings = get_settings() class UnionAuth(SecurityBase): @@ -59,7 +64,7 @@ async def __call__( for scope in (user_session.user.scopes if user_session.is_unbounded else user_session.scopes) ] ) - if self._SESSION_UPDATE_SCOPE in session_scopes: + if not settings.JWT_ENABLED and self._SESSION_UPDATE_SCOPE in session_scopes: user_session.expires = session_expires_date() db.session.commit() if len(set([_scope.lower() for _scope in self._scopes]) & session_scopes) != len(set(self._scopes)): From c31e87d0791f5d4fe08865c0e55e35251ba9d50a Mon Sep 17 00:00:00 2001 From: Roman Dyakov Date: Wed, 12 Mar 2025 03:39:38 +0000 Subject: [PATCH 04/18] Token endpoint --- auth_backend/exceptions.py | 4 +- auth_backend/models/db.py | 8 +++ auth_backend/routes/oidc.py | 77 +++++++++++++++++++++- auth_backend/utils/security.py | 12 +--- auth_backend/utils/user_session_control.py | 6 +- requirements.txt | 1 + 6 files changed, 92 insertions(+), 16 deletions(-) diff --git a/auth_backend/exceptions.py b/auth_backend/exceptions.py index 4fa01572..4fc1795c 100644 --- a/auth_backend/exceptions.py +++ b/auth_backend/exceptions.py @@ -33,8 +33,8 @@ def __init__(self): class SessionExpired(AuthAPIError): - def __init__(self, token: str): - super().__init__(f"Session that matches {token} expired", f"Срок действия токена {token} истёк") + def __init__(self, token: str = ""): + super().__init__(f"Session that matches expired or not exists", f"Срок действия токена истёк или токен не существует") class AuthFailed(AuthAPIError): diff --git a/auth_backend/models/db.py b/auth_backend/models/db.py index 4834da98..ea9358f2 100644 --- a/auth_backend/models/db.py +++ b/auth_backend/models/db.py @@ -64,6 +64,10 @@ def scopes(self) -> set[Scope]: _scopes.update(group.indirect_scopes) return _scopes + @hybrid_property + def scope_names(self) -> set[str]: + return set(s.name.lower() for s in self.scopes) + @hybrid_property def indirect_groups(self) -> set[Group]: _groups = set() @@ -175,6 +179,10 @@ class UserSession(BaseDbModel): def expired(self) -> bool: return self.expires <= datetime.datetime.utcnow() + @hybrid_property + def scope_names(self) -> set[str]: + return set(s.name.lower() for s in self.scopes) + class Scope(BaseDbModel): creator_id: Mapped[int] = mapped_column(Integer, ForeignKey(User.id)) diff --git a/auth_backend/routes/oidc.py b/auth_backend/routes/oidc.py index 7daca00b..8a896b61 100644 --- a/auth_backend/routes/oidc.py +++ b/auth_backend/routes/oidc.py @@ -1,18 +1,30 @@ import logging +from datetime import datetime +from typing import Literal, Annotated, Optional -from fastapi import APIRouter +from fastapi import APIRouter, Depends, Form from fastapi_sqlalchemy import db +from pydantic import AnyHttpUrl, BaseModel -from auth_backend.models.db import Scope +from auth_backend.exceptions import SessionExpired +from auth_backend.models.db import Scope, UserSession from auth_backend.settings import get_settings from auth_backend.utils.jwt import create_jwks - +from auth_backend.utils.security import UnionAuth +from auth_backend.utils.user_session_control import SESSION_UPDATE_SCOPE, create_session settings = get_settings() router = APIRouter(prefix="/openid", tags=["OpenID"]) logger = logging.getLogger(__name__) +class PostTokenResponse(BaseModel): + access_token: str + token_type: str + expires_in: int + refresh_token: str + + @router.get("/.well_known/openid_configuration") def openid_configuration(): """Конфигурация для подключения OpenID Connect совместимых приложений @@ -35,4 +47,63 @@ def openid_configuration(): @router.get("/.well_known/jwks") def jwks(): + """Публичные ключи для проверки JWT токенов""" return {"keys": [create_jwks()]} + + +@router.post("/token") +async def token( + grant_type: Annotated[Literal['refresh_token'], Form()], + client_id: Annotated[Literal['app'], Form()], # Тут должна быть любая строка, которую проверяем в БД + client_secret: Annotated[Optional[str], Form()] = None, + refresh_token: Annotated[Optional[str], Form()] = None, + old_session: UserSession = Depends(UnionAuth()), +) -> PostTokenResponse: + """Ручка для получения токена доступа + + Позволяет: + - Обменять старый не-JWT токен на новый c таким же набором доступов и таким же сроком давности + - Обменять JWT токен на новый, если у него есть SESSION_UPDATE_SCOPE + + Потенциально будет позволять: + - Обменивать Refresh Token на пару Access Token + Refresh Token + - Обменивать Code (см. Oauth Authorization Code Flow) на пару Access Token + Refresh Token + """ + if grant_type == 'authorization_code': + raise NotImplementedError("Authorization Code Flow not implemented yet") + if grant_type == "refresh_token": + # Все токены автоматически считаем refresh-токенами + if not refresh_token: + raise TypeError("refresh_token required for refresh_token grant_type ") + old_session: UserSession = ( + UserSession.query(session=db.session).filter(UserSession.token == refresh_token).one_or_none() + ) + if not old_session or old_session.expired: + raise SessionExpired() + + # Продлеваем только те токены, которые явно разрешено продлевать + # Остальные просто заменяем на новые с тем же сроком действия + session_scopes = old_session.user.scope_names if old_session.is_unbounded else old_session.scope_names + expire_ts = None + if SESSION_UPDATE_SCOPE not in session_scopes: + expire_ts = old_session.expires + + new_session = await create_session( + old_session.user, + session_scopes, + expire_ts, + old_session.session_name, + old_session.is_unbounded, + db_session=db.session, + ) + + # Старую сессию убиваем + old_session.expires = datetime.utcnow() + db.session.commit() + + return PostTokenResponse( + access_token=new_session.token, + token_type="Bearer", + expires_in=int((new_session.expires - datetime.utcnow()).total_seconds()), + refresh_token=new_session.token, + ) diff --git a/auth_backend/utils/security.py b/auth_backend/utils/security.py index 33df5701..17d5364c 100644 --- a/auth_backend/utils/security.py +++ b/auth_backend/utils/security.py @@ -9,7 +9,7 @@ from auth_backend.models.db import UserSession from auth_backend.settings import get_settings -from auth_backend.utils.user_session_control import session_expires_date +from auth_backend.utils.user_session_control import SESSION_UPDATE_SCOPE, session_expires_date settings = get_settings() @@ -26,7 +26,6 @@ class UnionAuth(SecurityBase): auto_error: bool allow_none: bool _scopes: list[str] = [] - _SESSION_UPDATE_SCOPE = 'auth.session.update' def __init__(self, scopes: list[str] = None, allow_none=False, auto_error=False) -> None: super().__init__() @@ -58,13 +57,8 @@ async def __call__( if user_session.expired: self._except() - session_scopes = set( - [ - scope.name.lower() - for scope in (user_session.user.scopes if user_session.is_unbounded else user_session.scopes) - ] - ) - if not settings.JWT_ENABLED and self._SESSION_UPDATE_SCOPE in session_scopes: + session_scopes = user_session.user.scope_names if user_session.is_unbounded else user_session.scope_names + if not settings.JWT_ENABLED and SESSION_UPDATE_SCOPE in session_scopes: user_session.expires = session_expires_date() db.session.commit() if len(set([_scope.lower() for _scope in self._scopes]) & session_scopes) != len(set(self._scopes)): diff --git a/auth_backend/utils/user_session_control.py b/auth_backend/utils/user_session_control.py index 854b969d..21b786b3 100644 --- a/auth_backend/utils/user_session_control.py +++ b/auth_backend/utils/user_session_control.py @@ -9,9 +9,11 @@ from auth_backend.schemas.models import Session from auth_backend.schemas.types.scopes import Scope as TypeScope from auth_backend.settings import get_settings -from auth_backend.utils.string import random_string from auth_backend.utils.jwt import generate_jwt +from auth_backend.utils.string import random_string + +SESSION_UPDATE_SCOPE = 'auth.session.update' settings = get_settings() @@ -37,7 +39,7 @@ async def create_session( await check_scopes(scopes, user) create_ts = datetime.utcnow() expire_ts = expires or session_expires_date() - token=random_string(length=settings.TOKEN_LENGTH) + token = random_string(length=settings.TOKEN_LENGTH) if settings.JWT_ENABLED: token = generate_jwt(user.id, create_ts, expire_ts) user_session = UserSession( diff --git a/requirements.txt b/requirements.txt index ad745ddd..5e444fe2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,6 +15,7 @@ pytest-asyncio confluent-kafka event-schema-profcomff aiocache +python-multipart # Google Auth Method google-api-python-client From 02be52ce3c15edf532dbb00160fb4cf96ed3c8bd Mon Sep 17 00:00:00 2001 From: Roman Dyakov Date: Wed, 12 Mar 2025 04:37:01 +0000 Subject: [PATCH 05/18] Deploy test with JWT tokens --- .github/workflows/build_and_publish.yml | 2 ++ auth_backend/routes/oidc.py | 1 - auth_backend/utils/jwt.py | 6 +++--- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build_and_publish.yml b/.github/workflows/build_and_publish.yml index 22539e7a..2d860fab 100644 --- a/.github/workflows/build_and_publish.yml +++ b/.github/workflows/build_and_publish.yml @@ -135,6 +135,8 @@ jobs: --env KAFKA_PASSWORD='${{ secrets.KAFKA_PASSWORD }}' \ --env KAFKA_USER_LOGIN_TOPIC_NAME='${{ secrets.KAFKA_USER_LOGIN_TOPIC_NAME }}' \ --env GUNICORN_CMD_ARGS='--log-config logging_test.conf' \ + --env JWT_ENABLED=true \ + --env JWT_PRIVATE_KEY='${{ secrets.JWT_PRIVATE_KEY }}' \ --name ${{ env.CONTAINER_NAME }} \ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:test docker network connect web ${{ env.CONTAINER_NAME }} diff --git a/auth_backend/routes/oidc.py b/auth_backend/routes/oidc.py index 8a896b61..10c1592c 100644 --- a/auth_backend/routes/oidc.py +++ b/auth_backend/routes/oidc.py @@ -57,7 +57,6 @@ async def token( client_id: Annotated[Literal['app'], Form()], # Тут должна быть любая строка, которую проверяем в БД client_secret: Annotated[Optional[str], Form()] = None, refresh_token: Annotated[Optional[str], Form()] = None, - old_session: UserSession = Depends(UnionAuth()), ) -> PostTokenResponse: """Ручка для получения токена доступа diff --git a/auth_backend/utils/jwt.py b/auth_backend/utils/jwt.py index 22db9973..8bc23936 100644 --- a/auth_backend/utils/jwt.py +++ b/auth_backend/utils/jwt.py @@ -31,11 +31,11 @@ def get_private_key(): # Если использование отключено – используем отсебятину if not settings.JWT_ENABLED: return rsa.generate_private_key(public_exponent=65537, key_size=2048, backend=default_backend()) - if settings.JWT_PRIVATE_KEY_FILE: + if settings.JWT_PRIVATE_KEY: + key_bytes = settings.JWT_PRIVATE_KEY + elif settings.JWT_PRIVATE_KEY_FILE: with open(settings.JWT_PRIVATE_KEY_FILE, "rb") as key_file: key_bytes = key_file.read() - elif settings.JWT_PRIVATE_KEY: - key_bytes = settings.JWT_PRIVATE_KEY else: raise Exception("JWT private key not provided") return serialization.load_pem_private_key(key_bytes, password=None) From 51421a64c26e379e77ac914723fd3cd09723fb79 Mon Sep 17 00:00:00 2001 From: Roman Dyakov Date: Wed, 12 Mar 2025 04:49:49 +0000 Subject: [PATCH 06/18] Style --- auth_backend/exceptions.py | 5 ++++- auth_backend/routes/oidc.py | 8 ++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/auth_backend/exceptions.py b/auth_backend/exceptions.py index 4fc1795c..36c2a759 100644 --- a/auth_backend/exceptions.py +++ b/auth_backend/exceptions.py @@ -34,7 +34,10 @@ def __init__(self): class SessionExpired(AuthAPIError): def __init__(self, token: str = ""): - super().__init__(f"Session that matches expired or not exists", f"Срок действия токена истёк или токен не существует") + super().__init__( + f"Session that matches expired or not exists", + f"Срок действия токена истёк или токен не существует", + ) class AuthFailed(AuthAPIError): diff --git a/auth_backend/routes/oidc.py b/auth_backend/routes/oidc.py index 10c1592c..92e79092 100644 --- a/auth_backend/routes/oidc.py +++ b/auth_backend/routes/oidc.py @@ -1,18 +1,18 @@ import logging from datetime import datetime -from typing import Literal, Annotated, Optional +from typing import Annotated, Literal, Optional -from fastapi import APIRouter, Depends, Form +from fastapi import APIRouter, Form from fastapi_sqlalchemy import db -from pydantic import AnyHttpUrl, BaseModel +from pydantic import BaseModel from auth_backend.exceptions import SessionExpired from auth_backend.models.db import Scope, UserSession from auth_backend.settings import get_settings from auth_backend.utils.jwt import create_jwks -from auth_backend.utils.security import UnionAuth from auth_backend.utils.user_session_control import SESSION_UPDATE_SCOPE, create_session + settings = get_settings() router = APIRouter(prefix="/openid", tags=["OpenID"]) logger = logging.getLogger(__name__) From c74f38f7b0ba8bd1d27137a5a26dd2d866184146 Mon Sep 17 00:00:00 2001 From: Roman Dyakov Date: Wed, 12 Mar 2025 04:56:37 +0000 Subject: [PATCH 07/18] Fix tests --- auth_backend/models/db.py | 3 ++- auth_backend/utils/security.py | 3 ++- auth_backend/utils/user_session_basics.py | 10 ++++++++++ auth_backend/utils/user_session_control.py | 7 ++----- 4 files changed, 16 insertions(+), 7 deletions(-) create mode 100644 auth_backend/utils/user_session_basics.py diff --git a/auth_backend/models/db.py b/auth_backend/models/db.py index ea9358f2..3af0ba79 100644 --- a/auth_backend/models/db.py +++ b/auth_backend/models/db.py @@ -13,6 +13,7 @@ from auth_backend.models.base import BaseDbModel from auth_backend.models.dynamic_settings import DynamicOption from auth_backend.settings import get_settings +from auth_backend.utils.user_session_basics import session_expires_date settings = get_settings() @@ -156,7 +157,7 @@ class AuthMethod(BaseDbModel): class UserSession(BaseDbModel): session_name: Mapped[str] = mapped_column(String, nullable=True) user_id: Mapped[int] = mapped_column(Integer, sqlalchemy.ForeignKey("user.id")) - expires: Mapped[datetime.datetime] = mapped_column(DateTime) + expires: Mapped[datetime.datetime] = mapped_column(DateTime, default=session_expires_date) token: Mapped[str] = mapped_column(String, unique=True) is_unbounded: Mapped[bool] = mapped_column(Boolean, default=False) last_activity: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow) diff --git a/auth_backend/utils/security.py b/auth_backend/utils/security.py index 17d5364c..aa3eb103 100644 --- a/auth_backend/utils/security.py +++ b/auth_backend/utils/security.py @@ -9,7 +9,8 @@ from auth_backend.models.db import UserSession from auth_backend.settings import get_settings -from auth_backend.utils.user_session_control import SESSION_UPDATE_SCOPE, session_expires_date +from auth_backend.utils.user_session_basics import session_expires_date +from auth_backend.utils.user_session_control import SESSION_UPDATE_SCOPE settings = get_settings() diff --git a/auth_backend/utils/user_session_basics.py b/auth_backend/utils/user_session_basics.py new file mode 100644 index 00000000..9bfb938d --- /dev/null +++ b/auth_backend/utils/user_session_basics.py @@ -0,0 +1,10 @@ +from datetime import datetime, timedelta + +from auth_backend.settings import get_settings + + +settings = get_settings() + + +def session_expires_date(): + return datetime.utcnow() + timedelta(days=settings.SESSION_TIME_IN_DAYS) diff --git a/auth_backend/utils/user_session_control.py b/auth_backend/utils/user_session_control.py index 21b786b3..c0da9a11 100644 --- a/auth_backend/utils/user_session_control.py +++ b/auth_backend/utils/user_session_control.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import datetime from fastapi import HTTPException from fastapi_sqlalchemy import db @@ -11,6 +11,7 @@ from auth_backend.settings import get_settings from auth_backend.utils.jwt import generate_jwt from auth_backend.utils.string import random_string +from auth_backend.utils.user_session_basics import session_expires_date SESSION_UPDATE_SCOPE = 'auth.session.update' @@ -18,10 +19,6 @@ settings = get_settings() -def session_expires_date(): - return datetime.utcnow() + timedelta(days=settings.SESSION_TIME_IN_DAYS) - - async def create_session( user: User, scopes_list_names: list[TypeScope] | None, From 7ec7704dec73c761751c7655abfd04828a250739 Mon Sep 17 00:00:00 2001 From: Roman Dyakov Date: Wed, 12 Mar 2025 12:01:59 +0000 Subject: [PATCH 08/18] OIDC schema separate --- auth_backend/routes/oidc.py | 9 +-------- auth_backend/schemas/oidc.py | 8 ++++++++ 2 files changed, 9 insertions(+), 8 deletions(-) create mode 100644 auth_backend/schemas/oidc.py diff --git a/auth_backend/routes/oidc.py b/auth_backend/routes/oidc.py index 92e79092..2597190e 100644 --- a/auth_backend/routes/oidc.py +++ b/auth_backend/routes/oidc.py @@ -4,10 +4,10 @@ from fastapi import APIRouter, Form from fastapi_sqlalchemy import db -from pydantic import BaseModel from auth_backend.exceptions import SessionExpired from auth_backend.models.db import Scope, UserSession +from auth_backend.schemas.oidc import PostTokenResponse from auth_backend.settings import get_settings from auth_backend.utils.jwt import create_jwks from auth_backend.utils.user_session_control import SESSION_UPDATE_SCOPE, create_session @@ -18,13 +18,6 @@ logger = logging.getLogger(__name__) -class PostTokenResponse(BaseModel): - access_token: str - token_type: str - expires_in: int - refresh_token: str - - @router.get("/.well_known/openid_configuration") def openid_configuration(): """Конфигурация для подключения OpenID Connect совместимых приложений diff --git a/auth_backend/schemas/oidc.py b/auth_backend/schemas/oidc.py new file mode 100644 index 00000000..5ad89a8a --- /dev/null +++ b/auth_backend/schemas/oidc.py @@ -0,0 +1,8 @@ +from auth_backend.base import Base + + +class PostTokenResponse(Base): + access_token: str + token_type: str + expires_in: int + refresh_token: str From 73142f4a99aa6d3bd32cf74097db7f613d4fb1c5 Mon Sep 17 00:00:00 2001 From: Roman Dyakov Date: Wed, 12 Mar 2025 12:04:58 +0000 Subject: [PATCH 09/18] Typing --- auth_backend/utils/jwt.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/auth_backend/utils/jwt.py b/auth_backend/utils/jwt.py index 8bc23936..9221910f 100644 --- a/auth_backend/utils/jwt.py +++ b/auth_backend/utils/jwt.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from datetime import datetime from functools import lru_cache +from typing import Any import jwt from cryptography.hazmat.backends import default_backend @@ -27,7 +28,7 @@ class JwtSettings: @lru_cache(1) -def get_private_key(): +def get_private_key() -> rsa.RSAPrivateKey: # Если использование отключено – используем отсебятину if not settings.JWT_ENABLED: return rsa.generate_private_key(public_exponent=65537, key_size=2048, backend=default_backend()) @@ -41,8 +42,8 @@ def get_private_key(): return serialization.load_pem_private_key(key_bytes, password=None) -# Функция для преобразования числа в Base64URL -def to_base64url(value): +def to_base64url(value: int) -> str: + """Функция для преобразования числа в Base64URL""" # Преобразуем число в байты byte_length = (value.bit_length() + 7) // 8 byte_data = value.to_bytes(byte_length, byteorder='big') @@ -79,7 +80,7 @@ def ensure_jwt_settings() -> JwtSettings: @lru_cache(1) -def create_jwks(): +def create_jwks() -> dict[str, str]: jwt_settings = ensure_jwt_settings() return { "kty": "RSA", @@ -91,7 +92,7 @@ def create_jwks(): } -def generate_jwt(user_id: int, create_ts: datetime, expire_ts: datetime): +def generate_jwt(user_id: int, create_ts: datetime, expire_ts: datetime) -> str: jwt_settings = ensure_jwt_settings() return jwt.encode( { @@ -105,7 +106,7 @@ def generate_jwt(user_id: int, create_ts: datetime, expire_ts: datetime): ) -def decode_jwt(token: str): +def decode_jwt(token: str) -> dict[str, Any]: jwt_settings = ensure_jwt_settings() return jwt.decode( token, From e8cd7bc7b2c2da2cc89abf46078c38146fb09a68 Mon Sep 17 00:00:00 2001 From: Roman Dyakov Date: Thu, 13 Mar 2025 16:10:59 +0000 Subject: [PATCH 10/18] get_by_names for scope --- auth_backend/models/db.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/auth_backend/models/db.py b/auth_backend/models/db.py index 3af0ba79..2d9a13d6 100644 --- a/auth_backend/models/db.py +++ b/auth_backend/models/db.py @@ -222,13 +222,18 @@ def create(cls, *, session: Session, **kwargs) -> Scope: @classmethod def get_by_name(cls, name: str, *, with_deleted: bool = False, session: Session) -> Scope: + return cls.get_by_names([name], with_deleted=with_deleted, session=session)[0] + + @classmethod + def get_by_names(cls, names: list[str], *, with_deleted: bool = False, session: Session) -> list[Scope]: + names = [name.lower() for name in names] scope = ( cls.query(with_deleted=with_deleted, session=session) - .filter(func.lower(cls.name) == name.lower()) - .one_or_none() + .filter(func.lower(cls.name).in_(names)) + .all() ) - if not scope: - raise ObjectNotFound(cls, name) + if len(scope) < len(names): + raise ObjectNotFound(cls, names) return scope From 8d2303bebf18dd28ecab809c90fc24b63b7a6a6a Mon Sep 17 00:00:00 2001 From: Roman Dyakov Date: Thu, 13 Mar 2025 16:11:10 +0000 Subject: [PATCH 11/18] Email public login method --- auth_backend/auth_plugins/email.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/auth_backend/auth_plugins/email.py b/auth_backend/auth_plugins/email.py index 4de9902b..3357e908 100644 --- a/auth_backend/auth_plugins/email.py +++ b/auth_backend/auth_plugins/email.py @@ -133,6 +133,13 @@ def __init__(self): ) self.tags = ["Email"] + @classmethod + async def login(cls, email: str, password: str, scopes: list[Scope], session_name: str | None, background_tasks: BackgroundTasks) -> Session: + return cls._login( + EmailLogin(email=email, password=password, scopes=scopes, session_name=session_name), + background_tasks, + ) + @classmethod async def _login(cls, user_inp: EmailLogin, background_tasks: BackgroundTasks) -> Session: query = ( From 1b4226bdf9f9cce7bcf1c0bc5f5d18d2bd485a89 Mon Sep 17 00:00:00 2001 From: Roman Dyakov Date: Thu, 13 Mar 2025 16:22:29 +0000 Subject: [PATCH 12/18] Token by password --- auth_backend/routes/oidc.py | 88 +++++++++++++++++--------------- auth_backend/utils/oidc_token.py | 74 +++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 42 deletions(-) create mode 100644 auth_backend/utils/oidc_token.py diff --git a/auth_backend/routes/oidc.py b/auth_backend/routes/oidc.py index 2597190e..5c1a825f 100644 --- a/auth_backend/routes/oidc.py +++ b/auth_backend/routes/oidc.py @@ -2,15 +2,14 @@ from datetime import datetime from typing import Annotated, Literal, Optional -from fastapi import APIRouter, Form +from fastapi import APIRouter, BackgroundTasks, Form, Header from fastapi_sqlalchemy import db -from auth_backend.exceptions import SessionExpired from auth_backend.models.db import Scope, UserSession from auth_backend.schemas.oidc import PostTokenResponse from auth_backend.settings import get_settings from auth_backend.utils.jwt import create_jwks -from auth_backend.utils.user_session_control import SESSION_UPDATE_SCOPE, create_session +from auth_backend.utils.oidc_token import token_by_refresh_token, token_by_client_credentials settings = get_settings() @@ -34,7 +33,10 @@ def openid_configuration(): "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["RS256"], "claims_supported": ["sub", "iss", "exp", "iat"], - # "authorization_endpoint": f"{settings.APPLICATION_HOST}/auth", + "grant_types_supported": [ + "refresh_token", + "client_credentials", + ], } @@ -46,56 +48,58 @@ def jwks(): @router.post("/token") async def token( - grant_type: Annotated[Literal['refresh_token'], Form()], - client_id: Annotated[Literal['app'], Form()], # Тут должна быть любая строка, которую проверяем в БД + background_tasks: BackgroundTasks, + # Общие OIDC параметры + user_agent: Annotated[str, Header()], + grant_type: Annotated[Literal['refresh_token', 'client_credentials'], Form()], + client_id: Annotated[str, Form()], # Тут должна быть любая строка, которую проверяем в БД client_secret: Annotated[Optional[str], Form()] = None, + scopes: Annotated[list[str] | None, Form()] = None, + # grant_type=refresh_token refresh_token: Annotated[Optional[str], Form()] = None, + # grant_type=client_credentials + username: Annotated[Optional[str], Form()] = None, + password: Annotated[Optional[str], Form()] = None, ) -> PostTokenResponse: """Ручка для получения токена доступа - Позволяет: + ## Позволяет - Обменять старый не-JWT токен на новый c таким же набором доступов и таким же сроком давности - Обменять JWT токен на новый, если у него есть SESSION_UPDATE_SCOPE Потенциально будет позволять: - Обменивать Refresh Token на пару Access Token + Refresh Token - Обменивать Code (см. Oauth Authorization Code Flow) на пару Access Token + Refresh Token + + ## Параметры: + Для всех запросов + - `grant_type` – refresh_token/client_credentials (см. список в `/.well_known/openid_configuration` в поле `grant_types_supported`) + - `client_id` – строка, по которой проверяется принадлежность к проекту (сейчас только app) + - `scopes` – список прав для нового токена + + ### `grant_type=refresh_token` + - refresh_token – токен, выданный этой ручкой или ручкой `/login` в методе авторизации + + ### `grant_type=client_credentials` + - `username` – логин пользователя + - `password` – пароль пользователя """ + scopes = scopes or [] + + if client_id != 'app': + raise NotImplementedError("Only app client id supported") if grant_type == 'authorization_code': raise NotImplementedError("Authorization Code Flow not implemented yet") + + # Разные методы обмена токенов + if grant_type == "refresh_token": + new_session = await token_by_refresh_token(refresh_token, scopes) if grant_type == "refresh_token": - # Все токены автоматически считаем refresh-токенами - if not refresh_token: - raise TypeError("refresh_token required for refresh_token grant_type ") - old_session: UserSession = ( - UserSession.query(session=db.session).filter(UserSession.token == refresh_token).one_or_none() - ) - if not old_session or old_session.expired: - raise SessionExpired() - - # Продлеваем только те токены, которые явно разрешено продлевать - # Остальные просто заменяем на новые с тем же сроком действия - session_scopes = old_session.user.scope_names if old_session.is_unbounded else old_session.scope_names - expire_ts = None - if SESSION_UPDATE_SCOPE not in session_scopes: - expire_ts = old_session.expires - - new_session = await create_session( - old_session.user, - session_scopes, - expire_ts, - old_session.session_name, - old_session.is_unbounded, - db_session=db.session, - ) - - # Старую сессию убиваем - old_session.expires = datetime.utcnow() - db.session.commit() - - return PostTokenResponse( - access_token=new_session.token, - token_type="Bearer", - expires_in=int((new_session.expires - datetime.utcnow()).total_seconds()), - refresh_token=new_session.token, - ) + new_session = await token_by_client_credentials(username, password, scopes, user_agent, background_tasks) + + return PostTokenResponse( + access_token=new_session.token, + token_type="Bearer", + expires_in=int((new_session.expires - datetime.utcnow()).total_seconds()), + refresh_token=new_session.token, + ) diff --git a/auth_backend/utils/oidc_token.py b/auth_backend/utils/oidc_token.py new file mode 100644 index 00000000..7f5084d2 --- /dev/null +++ b/auth_backend/utils/oidc_token.py @@ -0,0 +1,74 @@ +from datetime import datetime +from fastapi import BackgroundTasks +from fastapi_sqlalchemy import db +from auth_backend.models.db import UserSession, Scope +from auth_backend.exceptions import SessionExpired, AuthFailed +from auth_backend.utils.user_session_control import SESSION_UPDATE_SCOPE, create_session +from auth_backend.schemas.models import Session as SessionSchema + + +async def token_by_refresh_token( + refresh_token: str | None, + requested_scopes: list[str] | None, + _: BackgroundTasks, +) -> SessionSchema: + # Все токены автоматически считаем refresh-токенами + if not refresh_token: + raise TypeError("refresh_token required for refresh_token grant_type ") + old_session: UserSession = ( + UserSession.query(session=db.session).filter(UserSession.token == refresh_token).one_or_none() + ) + if not old_session or old_session.expired: + raise SessionExpired() + + # Продлеваем только те токены, которые явно разрешено продлевать + # Остальные просто заменяем на новые с тем же сроком действия + session_scopes = old_session.user.scope_names if old_session.is_unbounded else old_session.scope_names + + # Если запрошены скоупы, то выдать новый токен с запрошенными скоупами, если у текущего хватает прав + if requested_scopes: + requested_scopes = set(requested_scopes) + if requested_scopes > session_scopes: + raise AuthFailed("Don't have enough permissions to get scopes: ", requested_scopes - session_scopes) + session_scopes = requested_scopes - session_scopes + + # Продлить действие токена, если сессия это позволяет + expire_ts = None + if SESSION_UPDATE_SCOPE not in session_scopes: + expire_ts = old_session.expires + + new_session = await create_session( + old_session.user, + session_scopes, + expire_ts, + old_session.session_name, + old_session.is_unbounded, + db_session=db.session, + ) + + # Старую сессию убиваем + old_session.expires = datetime.utcnow() + db.session.commit() + + return new_session + + +async def token_by_client_credentials( + username: str | None, + password: str | None, + scopes: list[str] | None, + user_agent: str, + background_tasks: BackgroundTasks, +) -> SessionSchema: + from auth_backend.auth_plugins.email import Email + + if not username or not password: + raise Exception + + return await Email.login( + username, + password, + Scope.get_by_names(scopes), + session_name=user_agent, + background_tasks=background_tasks, + ) From 824264f7c54b60d6dd4be63157667fee6efe8473 Mon Sep 17 00:00:00 2001 From: Roman Dyakov Date: Thu, 13 Mar 2025 16:27:32 +0000 Subject: [PATCH 13/18] Fixes old behaviour --- auth_backend/exceptions.py | 2 +- auth_backend/utils/user_session_control.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/auth_backend/exceptions.py b/auth_backend/exceptions.py index 36c2a759..e390bfe2 100644 --- a/auth_backend/exceptions.py +++ b/auth_backend/exceptions.py @@ -35,7 +35,7 @@ def __init__(self): class SessionExpired(AuthAPIError): def __init__(self, token: str = ""): super().__init__( - f"Session that matches expired or not exists", + f"Session expired or not exists", f"Срок действия токена истёк или токен не существует", ) diff --git a/auth_backend/utils/user_session_control.py b/auth_backend/utils/user_session_control.py index c0da9a11..4f3b5255 100644 --- a/auth_backend/utils/user_session_control.py +++ b/auth_backend/utils/user_session_control.py @@ -45,6 +45,7 @@ async def create_session( session_name=session_name, create_ts=create_ts, expires=expire_ts, + is_unbounded=is_unbounded, ) db_session.add(user_session) db_session.flush() From b7b43dd3c488073d15356bb509586d4c1d79182e Mon Sep 17 00:00:00 2001 From: Roman Dyakov Date: Thu, 13 Mar 2025 16:28:50 +0000 Subject: [PATCH 14/18] Move tests --- tests/{ => test_routes}/conftest.py | 0 tests/test_unit/__init__.py | 0 tests/{ => test_unit}/test_jwt.py | 0 tests/{ => test_unit}/test_outermeta.py | 0 tests/{ => test_unit}/test_update_user.py | 0 5 files changed, 0 insertions(+), 0 deletions(-) rename tests/{ => test_routes}/conftest.py (100%) create mode 100644 tests/test_unit/__init__.py rename tests/{ => test_unit}/test_jwt.py (100%) rename tests/{ => test_unit}/test_outermeta.py (100%) rename tests/{ => test_unit}/test_update_user.py (100%) diff --git a/tests/conftest.py b/tests/test_routes/conftest.py similarity index 100% rename from tests/conftest.py rename to tests/test_routes/conftest.py diff --git a/tests/test_unit/__init__.py b/tests/test_unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_jwt.py b/tests/test_unit/test_jwt.py similarity index 100% rename from tests/test_jwt.py rename to tests/test_unit/test_jwt.py diff --git a/tests/test_outermeta.py b/tests/test_unit/test_outermeta.py similarity index 100% rename from tests/test_outermeta.py rename to tests/test_unit/test_outermeta.py diff --git a/tests/test_update_user.py b/tests/test_unit/test_update_user.py similarity index 100% rename from tests/test_update_user.py rename to tests/test_unit/test_update_user.py From 58d002eae32a91dde5c2ad29df059b491dce08c8 Mon Sep 17 00:00:00 2001 From: Roman Dyakov Date: Thu, 13 Mar 2025 17:21:21 +0000 Subject: [PATCH 15/18] Tests --- auth_backend/auth_plugins/email.py | 2 +- auth_backend/exceptions.py | 13 +++ auth_backend/routes/exc_handlers.py | 20 ++++ auth_backend/routes/oidc.py | 15 +-- auth_backend/utils/oidc_token.py | 10 +- tests/test_routes/test_oidc.py | 156 ++++++++++++++++++++++++++++ 6 files changed, 204 insertions(+), 12 deletions(-) create mode 100644 tests/test_routes/test_oidc.py diff --git a/auth_backend/auth_plugins/email.py b/auth_backend/auth_plugins/email.py index 3357e908..5fa8e412 100644 --- a/auth_backend/auth_plugins/email.py +++ b/auth_backend/auth_plugins/email.py @@ -135,7 +135,7 @@ def __init__(self): @classmethod async def login(cls, email: str, password: str, scopes: list[Scope], session_name: str | None, background_tasks: BackgroundTasks) -> Session: - return cls._login( + return await cls._login( EmailLogin(email=email, password=password, scopes=scopes, session_name=session_name), background_tasks, ) diff --git a/auth_backend/exceptions.py b/auth_backend/exceptions.py index e390bfe2..9500f911 100644 --- a/auth_backend/exceptions.py +++ b/auth_backend/exceptions.py @@ -71,3 +71,16 @@ def __init__(self, dtime: datetime.timedelta): class LastAuthMethodDelete(AuthAPIError): def __init__(self): super().__init__('Unable to remove last authentication method', 'Нельзя удалить последний метод входа') + + +class OidcGrantTypeNotImplementedError(AuthAPIError): + def __init__(self, method: str): + super().__init__(f'Grant type {method} not implemented', f'Метод {method} не реализован') + + +class OidcGrantTypeClientNotSupported(AuthAPIError): + def __init__(self, method: str, client_id: str): + super().__init__( + f'Grant type {method} not supported by {client_id}', + f'Метод {method} не поддерживается приложением {client_id}', + ) diff --git a/auth_backend/routes/exc_handlers.py b/auth_backend/routes/exc_handlers.py index e58f21b7..930449b6 100644 --- a/auth_backend/routes/exc_handlers.py +++ b/auth_backend/routes/exc_handlers.py @@ -12,6 +12,8 @@ ObjectNotFound, SessionExpired, TooManyEmailRequests, + OidcGrantTypeClientNotSupported, + OidcGrantTypeNotImplementedError, ) from .base import app @@ -100,6 +102,24 @@ async def last_auth_method_delete_handler(req: starlette.requests.Request, exc: ) +@app.exception_handler( + OidcGrantTypeClientNotSupported, +) +async def oidc_grant_type_client_not_supported_handler(req: starlette.requests.Request, exc: Exception): + return JSONResponse( + StatusResponseModel(status="Error", message=exc.eng, ru=exc.ru).model_dump(), + status_code=400, + ) + + +@app.exception_handler(OidcGrantTypeNotImplementedError) +async def oidc_grant_type_not_implemented_error_handler(req: starlette.requests.Request, exc: Exception): + return JSONResponse( + StatusResponseModel(status="Error", message=exc.eng, ru=exc.ru).model_dump(), + status_code=400, + ) + + @app.exception_handler(Exception) async def http_error_handler(req: starlette.requests.Request, exc: Exception): return JSONResponse( diff --git a/auth_backend/routes/oidc.py b/auth_backend/routes/oidc.py index 5c1a825f..cd4282a1 100644 --- a/auth_backend/routes/oidc.py +++ b/auth_backend/routes/oidc.py @@ -5,7 +5,8 @@ from fastapi import APIRouter, BackgroundTasks, Form, Header from fastapi_sqlalchemy import db -from auth_backend.models.db import Scope, UserSession +from auth_backend.models.db import Scope +from auth_backend.exceptions import OidcGrantTypeClientNotSupported, OidcGrantTypeNotImplementedError from auth_backend.schemas.oidc import PostTokenResponse from auth_backend.settings import get_settings from auth_backend.utils.jwt import create_jwks @@ -50,11 +51,11 @@ def jwks(): async def token( background_tasks: BackgroundTasks, # Общие OIDC параметры - user_agent: Annotated[str, Header()], - grant_type: Annotated[Literal['refresh_token', 'client_credentials'], Form()], + grant_type: Annotated[str, Form()], client_id: Annotated[str, Form()], # Тут должна быть любая строка, которую проверяем в БД client_secret: Annotated[Optional[str], Form()] = None, scopes: Annotated[list[str] | None, Form()] = None, + user_agent: Annotated[str | None, Header()] = None, # grant_type=refresh_token refresh_token: Annotated[Optional[str], Form()] = None, # grant_type=client_credentials @@ -87,15 +88,17 @@ async def token( scopes = scopes or [] if client_id != 'app': - raise NotImplementedError("Only app client id supported") + raise OidcGrantTypeClientNotSupported(grant_type, client_id) if grant_type == 'authorization_code': - raise NotImplementedError("Authorization Code Flow not implemented yet") + raise OidcGrantTypeNotImplementedError("authorization_code") # Разные методы обмена токенов if grant_type == "refresh_token": new_session = await token_by_refresh_token(refresh_token, scopes) - if grant_type == "refresh_token": + elif grant_type == "client_credentials": new_session = await token_by_client_credentials(username, password, scopes, user_agent, background_tasks) + else: + raise OidcGrantTypeClientNotSupported(grant_type, client_id) return PostTokenResponse( access_token=new_session.token, diff --git a/auth_backend/utils/oidc_token.py b/auth_backend/utils/oidc_token.py index 7f5084d2..c174f58e 100644 --- a/auth_backend/utils/oidc_token.py +++ b/auth_backend/utils/oidc_token.py @@ -10,7 +10,6 @@ async def token_by_refresh_token( refresh_token: str | None, requested_scopes: list[str] | None, - _: BackgroundTasks, ) -> SessionSchema: # Все токены автоматически считаем refresh-токенами if not refresh_token: @@ -29,8 +28,9 @@ async def token_by_refresh_token( if requested_scopes: requested_scopes = set(requested_scopes) if requested_scopes > session_scopes: - raise AuthFailed("Don't have enough permissions to get scopes: ", requested_scopes - session_scopes) - session_scopes = requested_scopes - session_scopes + not_found_scopes = ', '.join(session_scopes - requested_scopes) + raise AuthFailed("Can't get scopes: " + not_found_scopes, "Невозможно получить права: " + not_found_scopes) + session_scopes = requested_scopes # Продлить действие токена, если сессия это позволяет expire_ts = None @@ -63,12 +63,12 @@ async def token_by_client_credentials( from auth_backend.auth_plugins.email import Email if not username or not password: - raise Exception + raise AuthFailed("Incorrect login or password", "Некорректный логин или пароль") return await Email.login( username, password, - Scope.get_by_names(scopes), + Scope.get_by_names(scopes, session=db.session), session_name=user_agent, background_tasks=background_tasks, ) diff --git a/tests/test_routes/test_oidc.py b/tests/test_routes/test_oidc.py new file mode 100644 index 00000000..fe2ff3b9 --- /dev/null +++ b/tests/test_routes/test_oidc.py @@ -0,0 +1,156 @@ +from datetime import datetime +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session +from starlette import status + +from auth_backend.models.db import AuthMethod +from auth_backend.settings import get_settings + + +settings = get_settings() + + +def test_oidc(client_auth: TestClient): + response = client_auth.get("/openid/.well_known/openid_configuration") + assert response.status_code == status.HTTP_200_OK + assert list(response.json().keys()) == [ + "issuer", + "token_endpoint", + "userinfo_endpoint", + "jwks_uri", + "scopes_supported", + "response_types_supported", + "subject_types_supported", + "id_token_signing_alg_values_supported", + "claims_supported", + "grant_types_supported", + ] + + +def test_jwks(client_auth: TestClient): + response = client_auth.get("/openid/.well_known/jwks") + assert response.status_code == status.HTTP_200_OK + data = response.json()["keys"][0] + assert data["kty"] == "RSA" + assert data["use"] == "sig" + assert data["alg"] == "RS256" + assert set(["n", "e", "kid"]) < set(data.keys()) + + +def test_token_from_token_ok(client_auth: TestClient, dbsession: Session): + # Подготовка к тесту + body = {"email": f"user{datetime.utcnow()}@example.com", "password": "string", "scopes": []} + user_response = client_auth.post("/email/registration", json=body) + query = ( + dbsession.query(AuthMethod) + .filter(AuthMethod.auth_method == "email", AuthMethod.param == "email", AuthMethod.value == body["email"]) + .one() + ) + id = query.user_id + auth_token = ( + dbsession.query(AuthMethod) + .filter( + AuthMethod.user_id == query.user.id, + AuthMethod.param == "confirmation_token", + AuthMethod.auth_method == "email", + ) + .one() + ) + response = client_auth.get(f"/email/approve?token={auth_token.value}") + assert response.status_code == status.HTTP_200_OK, response.json() + response = client_auth.post("/email/login", json=body) + assert response.status_code == status.HTTP_200_OK, response.json() + token = response.json()['token'] + + # Сам тест + response = client_auth.post( + "/openid/token", + headers={"User-Agent": "TestAgent"}, + data={ + "grant_type": "refresh_token", + "client_id": "app", + "refresh_token": token, + }, + ) + assert response.status_code == status.HTTP_200_OK, response.json() + + data = response.json() + assert list(data.keys()) == [ + "access_token", + "token_type", + "expires_in", + "refresh_token", + ], list(data.keys()) + assert data["token_type"] == "Bearer", data["token_type"] + assert ( # Длительность токена отличается не более, чем на 10 секунд + abs(data["expires_in"] - settings.SESSION_TIME_IN_DAYS * 24 * 60 * 60) < 10 + ), data["expires_in"] + + +def test_token_from_token_wrong(client_auth: TestClient): + response = client_auth.post( + "/openid/token", + headers={"User-Agent": "TestAgent"}, + data={ + "grant_type": "refresh_token", + "client_id": "app", + "refresh_token": "123123", + }, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN # Wrong refresh token + + +def test_token_from_creds_ok(client_auth: TestClient, user): + response = client_auth.post( + "/openid/token", + headers={"User-Agent": "TestAgent"}, + data={ + "grant_type": "client_credentials", + "client_id": "app", + "username": user["body"]["email"], + "password": "string", + }, + ) + assert response.status_code == status.HTTP_200_OK + + +def test_token_from_creds_wrong_pass(client_auth: TestClient): + response = client_auth.post( + "/openid/token", + headers={"User-Agent": "TestAgent"}, + data={ + "grant_type": "client_credentials", + "client_id": "app", + "username": "admin@profcomff.com", + "password": "password", + }, + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED # Wrong pass + + +def test_token_from_creds_wrong_client_id(client_auth: TestClient): + response = client_auth.post( + "/openid/token", + headers={"User-Agent": "TestAgent"}, + data={ + "grant_type": "client_credentials", + "client_id": "not-app", + "username": "admin@profcomff.com", + "password": "password", + }, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_token_from_creds_wrong_grant_type(client_auth: TestClient): + response = client_auth.post( + "/openid/token", + headers={"User-Agent": "TestAgent"}, + data={ + "grant_type": "code", + "client_id": "app", + "username": "admin@profcomff.com", + "password": "password", + }, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST From aeaa0ed59e1e5235d7a421508cf2c8154d207dc6 Mon Sep 17 00:00:00 2001 From: Roman Dyakov Date: Thu, 13 Mar 2025 17:25:01 +0000 Subject: [PATCH 16/18] Formatting --- auth_backend/auth_plugins/email.py | 9 ++++++++- auth_backend/models/db.py | 6 +----- auth_backend/routes/exc_handlers.py | 4 ++-- auth_backend/routes/oidc.py | 6 +++--- auth_backend/utils/oidc_token.py | 8 +++++--- tests/test_routes/test_oidc.py | 1 + 6 files changed, 20 insertions(+), 14 deletions(-) diff --git a/auth_backend/auth_plugins/email.py b/auth_backend/auth_plugins/email.py index 5fa8e412..34c0f7f1 100644 --- a/auth_backend/auth_plugins/email.py +++ b/auth_backend/auth_plugins/email.py @@ -134,7 +134,14 @@ def __init__(self): self.tags = ["Email"] @classmethod - async def login(cls, email: str, password: str, scopes: list[Scope], session_name: str | None, background_tasks: BackgroundTasks) -> Session: + async def login( + cls, + email: str, + password: str, + scopes: list[Scope], + session_name: str | None, + background_tasks: BackgroundTasks, + ) -> Session: return await cls._login( EmailLogin(email=email, password=password, scopes=scopes, session_name=session_name), background_tasks, diff --git a/auth_backend/models/db.py b/auth_backend/models/db.py index 2d9a13d6..3cab9a2e 100644 --- a/auth_backend/models/db.py +++ b/auth_backend/models/db.py @@ -227,11 +227,7 @@ def get_by_name(cls, name: str, *, with_deleted: bool = False, session: Session) @classmethod def get_by_names(cls, names: list[str], *, with_deleted: bool = False, session: Session) -> list[Scope]: names = [name.lower() for name in names] - scope = ( - cls.query(with_deleted=with_deleted, session=session) - .filter(func.lower(cls.name).in_(names)) - .all() - ) + scope = cls.query(with_deleted=with_deleted, session=session).filter(func.lower(cls.name).in_(names)).all() if len(scope) < len(names): raise ObjectNotFound(cls, names) return scope diff --git a/auth_backend/routes/exc_handlers.py b/auth_backend/routes/exc_handlers.py index 930449b6..8e2c0a72 100644 --- a/auth_backend/routes/exc_handlers.py +++ b/auth_backend/routes/exc_handlers.py @@ -10,10 +10,10 @@ OauthAuthFailed, OauthCredentialsIncorrect, ObjectNotFound, - SessionExpired, - TooManyEmailRequests, OidcGrantTypeClientNotSupported, OidcGrantTypeNotImplementedError, + SessionExpired, + TooManyEmailRequests, ) from .base import app diff --git a/auth_backend/routes/oidc.py b/auth_backend/routes/oidc.py index cd4282a1..0029b94c 100644 --- a/auth_backend/routes/oidc.py +++ b/auth_backend/routes/oidc.py @@ -1,16 +1,16 @@ import logging from datetime import datetime -from typing import Annotated, Literal, Optional +from typing import Annotated, Optional from fastapi import APIRouter, BackgroundTasks, Form, Header from fastapi_sqlalchemy import db -from auth_backend.models.db import Scope from auth_backend.exceptions import OidcGrantTypeClientNotSupported, OidcGrantTypeNotImplementedError +from auth_backend.models.db import Scope from auth_backend.schemas.oidc import PostTokenResponse from auth_backend.settings import get_settings from auth_backend.utils.jwt import create_jwks -from auth_backend.utils.oidc_token import token_by_refresh_token, token_by_client_credentials +from auth_backend.utils.oidc_token import token_by_client_credentials, token_by_refresh_token settings = get_settings() diff --git a/auth_backend/utils/oidc_token.py b/auth_backend/utils/oidc_token.py index c174f58e..ad3f791d 100644 --- a/auth_backend/utils/oidc_token.py +++ b/auth_backend/utils/oidc_token.py @@ -1,10 +1,12 @@ from datetime import datetime + from fastapi import BackgroundTasks from fastapi_sqlalchemy import db -from auth_backend.models.db import UserSession, Scope -from auth_backend.exceptions import SessionExpired, AuthFailed -from auth_backend.utils.user_session_control import SESSION_UPDATE_SCOPE, create_session + +from auth_backend.exceptions import AuthFailed, SessionExpired +from auth_backend.models.db import Scope, UserSession from auth_backend.schemas.models import Session as SessionSchema +from auth_backend.utils.user_session_control import SESSION_UPDATE_SCOPE, create_session async def token_by_refresh_token( diff --git a/tests/test_routes/test_oidc.py b/tests/test_routes/test_oidc.py index fe2ff3b9..3e9519ef 100644 --- a/tests/test_routes/test_oidc.py +++ b/tests/test_routes/test_oidc.py @@ -1,4 +1,5 @@ from datetime import datetime + from fastapi.testclient import TestClient from sqlalchemy.orm import Session from starlette import status From 0c5be48a50873b51a80f52ea2b26fc31b81eb300 Mon Sep 17 00:00:00 2001 From: Roman Dyakov Date: Thu, 13 Mar 2025 17:51:25 +0000 Subject: [PATCH 17/18] OidcGrantType enum --- auth_backend/routes/oidc.py | 8 ++++---- auth_backend/utils/oidc_token.py | 7 +++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/auth_backend/routes/oidc.py b/auth_backend/routes/oidc.py index 0029b94c..d490e803 100644 --- a/auth_backend/routes/oidc.py +++ b/auth_backend/routes/oidc.py @@ -10,7 +10,7 @@ from auth_backend.schemas.oidc import PostTokenResponse from auth_backend.settings import get_settings from auth_backend.utils.jwt import create_jwks -from auth_backend.utils.oidc_token import token_by_client_credentials, token_by_refresh_token +from auth_backend.utils.oidc_token import OidcGrantType, token_by_client_credentials, token_by_refresh_token settings = get_settings() @@ -89,13 +89,13 @@ async def token( if client_id != 'app': raise OidcGrantTypeClientNotSupported(grant_type, client_id) - if grant_type == 'authorization_code': + if grant_type == OidcGrantType.authorization_code: raise OidcGrantTypeNotImplementedError("authorization_code") # Разные методы обмена токенов - if grant_type == "refresh_token": + if grant_type == OidcGrantType.refresh_token: new_session = await token_by_refresh_token(refresh_token, scopes) - elif grant_type == "client_credentials": + elif grant_type == OidcGrantType.client_credentials: new_session = await token_by_client_credentials(username, password, scopes, user_agent, background_tasks) else: raise OidcGrantTypeClientNotSupported(grant_type, client_id) diff --git a/auth_backend/utils/oidc_token.py b/auth_backend/utils/oidc_token.py index ad3f791d..db950066 100644 --- a/auth_backend/utils/oidc_token.py +++ b/auth_backend/utils/oidc_token.py @@ -1,4 +1,5 @@ from datetime import datetime +from enum import Enum from fastapi import BackgroundTasks from fastapi_sqlalchemy import db @@ -9,6 +10,12 @@ from auth_backend.utils.user_session_control import SESSION_UPDATE_SCOPE, create_session +class OidcGrantType(str, Enum): + authorization_code = 'authorization_code' + refresh_token = 'refresh_token' + client_credentials = 'client_credentials' + + async def token_by_refresh_token( refresh_token: str | None, requested_scopes: list[str] | None, From c55d22f0bfcd010a449c8409ab68620cc38c3f9d Mon Sep 17 00:00:00 2001 From: Roman Dyakov Date: Thu, 13 Mar 2025 18:02:29 +0000 Subject: [PATCH 18/18] AuthMethod enabled --- auth_backend/routes/oidc.py | 3 ++- auth_backend/utils/oidc_token.py | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/auth_backend/routes/oidc.py b/auth_backend/routes/oidc.py index d490e803..c5a978f6 100644 --- a/auth_backend/routes/oidc.py +++ b/auth_backend/routes/oidc.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, BackgroundTasks, Form, Header from fastapi_sqlalchemy import db +from auth_backend.auth_plugins.email import Email from auth_backend.exceptions import OidcGrantTypeClientNotSupported, OidcGrantTypeNotImplementedError from auth_backend.models.db import Scope from auth_backend.schemas.oidc import PostTokenResponse @@ -95,7 +96,7 @@ async def token( # Разные методы обмена токенов if grant_type == OidcGrantType.refresh_token: new_session = await token_by_refresh_token(refresh_token, scopes) - elif grant_type == OidcGrantType.client_credentials: + elif grant_type == OidcGrantType.client_credentials and Email.is_active(): new_session = await token_by_client_credentials(username, password, scopes, user_agent, background_tasks) else: raise OidcGrantTypeClientNotSupported(grant_type, client_id) diff --git a/auth_backend/utils/oidc_token.py b/auth_backend/utils/oidc_token.py index db950066..921c66bb 100644 --- a/auth_backend/utils/oidc_token.py +++ b/auth_backend/utils/oidc_token.py @@ -4,6 +4,7 @@ from fastapi import BackgroundTasks from fastapi_sqlalchemy import db +from auth_backend.auth_plugins.email import Email from auth_backend.exceptions import AuthFailed, SessionExpired from auth_backend.models.db import Scope, UserSession from auth_backend.schemas.models import Session as SessionSchema @@ -69,8 +70,6 @@ async def token_by_client_credentials( user_agent: str, background_tasks: BackgroundTasks, ) -> SessionSchema: - from auth_backend.auth_plugins.email import Email - if not username or not password: raise AuthFailed("Incorrect login or password", "Некорректный логин или пароль")