Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/build_and_publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
14 changes: 14 additions & 0 deletions auth_backend/auth_plugins/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
20 changes: 18 additions & 2 deletions auth_backend/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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}',
)
28 changes: 17 additions & 11 deletions auth_backend/models/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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"))
Expand All @@ -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))
Expand Down Expand Up @@ -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


Expand Down
18 changes: 10 additions & 8 deletions auth_backend/routes/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()])
20 changes: 20 additions & 0 deletions auth_backend/routes/exc_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
OauthAuthFailed,
OauthCredentialsIncorrect,
ObjectNotFound,
OidcGrantTypeClientNotSupported,
OidcGrantTypeNotImplementedError,
SessionExpired,
TooManyEmailRequests,
)
Expand Down Expand Up @@ -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(
Expand Down
109 changes: 109 additions & 0 deletions auth_backend/routes/oidc.py
Original file line number Diff line number Diff line change
@@ -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,
)
8 changes: 8 additions & 0 deletions auth_backend/schemas/oidc.py
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions auth_backend/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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():
Expand Down
Loading