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/auth_plugins/email.py b/auth_backend/auth_plugins/email.py index 4de9902b..34c0f7f1 100644 --- a/auth_backend/auth_plugins/email.py +++ b/auth_backend/auth_plugins/email.py @@ -133,6 +133,20 @@ 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 await 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 = ( diff --git a/auth_backend/exceptions.py b/auth_backend/exceptions.py index 4fa01572..9500f911 100644 --- a/auth_backend/exceptions.py +++ b/auth_backend/exceptions.py @@ -33,8 +33,11 @@ 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 expired or not exists", + f"Срок действия токена истёк или токен не существует", + ) class AuthFailed(AuthAPIError): @@ -68,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/models/db.py b/auth_backend/models/db.py index 05693772..3cab9a2e 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() @@ -64,6 +65,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() @@ -149,10 +154,6 @@ 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")) @@ -179,6 +180,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)) @@ -217,13 +222,14 @@ def create(cls, *, session: Session, **kwargs) -> Scope: @classmethod def get_by_name(cls, name: str, *, with_deleted: bool = False, session: Session) -> Scope: - scope = ( - cls.query(with_deleted=with_deleted, session=session) - .filter(func.lower(cls.name) == name.lower()) - .one_or_none() - ) - if not scope: - raise ObjectNotFound(cls, name) + 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).in_(names)).all() + if len(scope) < len(names): + raise ObjectNotFound(cls, names) return scope 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/exc_handlers.py b/auth_backend/routes/exc_handlers.py index e58f21b7..8e2c0a72 100644 --- a/auth_backend/routes/exc_handlers.py +++ b/auth_backend/routes/exc_handlers.py @@ -10,6 +10,8 @@ OauthAuthFailed, OauthCredentialsIncorrect, ObjectNotFound, + OidcGrantTypeClientNotSupported, + OidcGrantTypeNotImplementedError, SessionExpired, TooManyEmailRequests, ) @@ -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 new file mode 100644 index 00000000..c5a978f6 --- /dev/null +++ b/auth_backend/routes/oidc.py @@ -0,0 +1,109 @@ +import logging +from datetime import datetime +from typing import Annotated, Optional + +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 +from auth_backend.settings import get_settings +from auth_backend.utils.jwt import create_jwks +from auth_backend.utils.oidc_token import OidcGrantType, token_by_client_credentials, token_by_refresh_token + + +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"], + "grant_types_supported": [ + "refresh_token", + "client_credentials", + ], + } + + +@router.get("/.well_known/jwks") +def jwks(): + """Публичные ключи для проверки JWT токенов""" + return {"keys": [create_jwks()]} + + +@router.post("/token") +async def token( + background_tasks: BackgroundTasks, + # Общие OIDC параметры + 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 + 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 OidcGrantTypeClientNotSupported(grant_type, client_id) + if grant_type == OidcGrantType.authorization_code: + raise OidcGrantTypeNotImplementedError("authorization_code") + + # Разные методы обмена токенов + if grant_type == OidcGrantType.refresh_token: + new_session = await token_by_refresh_token(refresh_token, scopes) + 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) + + 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/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 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..9221910f --- /dev/null +++ b/auth_backend/utils/jwt.py @@ -0,0 +1,115 @@ +import base64 +import hashlib +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 +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() -> rsa.RSAPrivateKey: + # Если использование отключено – используем отсебятину + if not settings.JWT_ENABLED: + return rsa.generate_private_key(public_exponent=65537, key_size=2048, backend=default_backend()) + 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() + else: + raise Exception("JWT private key not provided") + return serialization.load_pem_private_key(key_bytes, password=None) + + +def to_base64url(value: int) -> str: + """Функция для преобразования числа в Base64URL""" + # Преобразуем число в байты + 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() -> dict[str, str]: + 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) -> str: + 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) -> dict[str, Any]: + jwt_settings = ensure_jwt_settings() + return jwt.decode( + token, + jwt_settings.pem_public_key, + algorithms=["RS256"], + ) diff --git a/auth_backend/utils/oidc_token.py b/auth_backend/utils/oidc_token.py new file mode 100644 index 00000000..921c66bb --- /dev/null +++ b/auth_backend/utils/oidc_token.py @@ -0,0 +1,82 @@ +from datetime import datetime +from enum import Enum + +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 +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, +) -> 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: + 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 + 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: + if not username or not password: + raise AuthFailed("Incorrect login or password", "Некорректный логин или пароль") + + return await Email.login( + username, + password, + Scope.get_by_names(scopes, session=db.session), + session_name=user_agent, + background_tasks=background_tasks, + ) diff --git a/auth_backend/utils/security.py b/auth_backend/utils/security.py index 1119f33b..aa3eb103 100644 --- a/auth_backend/utils/security.py +++ b/auth_backend/utils/security.py @@ -7,7 +7,13 @@ 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_basics import session_expires_date +from auth_backend.utils.user_session_control import SESSION_UPDATE_SCOPE + + +settings = get_settings() class UnionAuth(SecurityBase): @@ -21,7 +27,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__() @@ -53,13 +58,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 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_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 11389315..4f3b5255 100644 --- a/auth_backend/utils/user_session_control.py +++ b/auth_backend/utils/user_session_control.py @@ -9,9 +9,13 @@ 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.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' + settings = get_settings() @@ -30,11 +34,19 @@ 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, + is_unbounded=is_unbounded, ) - 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: 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 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/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_routes/test_oidc.py b/tests/test_routes/test_oidc.py new file mode 100644 index 00000000..3e9519ef --- /dev/null +++ b/tests/test_routes/test_oidc.py @@ -0,0 +1,157 @@ +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 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_unit/test_jwt.py b/tests/test_unit/test_jwt.py new file mode 100644 index 00000000..714b5cf7 --- /dev/null +++ b/tests/test_unit/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 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