From b7570e334f398f6f4f6ed2cc14021cda6c48b6dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Trkan?= Date: Fri, 10 Oct 2025 15:51:18 +0200 Subject: [PATCH] feat(auth): add support for OAuth and MojeID --- ..._10_10_1405-7af8f296d089_add_user_oauth.py | 48 +++++++++++++++++++ 7project/backend/app/app.py | 30 ++++++++---- 7project/backend/app/models/user.py | 11 ++++- 7project/backend/app/oauth/__init__.py | 0 7project/backend/app/oauth/moje_id.py | 48 +++++++++++++++++++ 7project/backend/app/services/db.py | 6 ++- 7project/backend/app/services/user_service.py | 6 +++ 7project/backend/requirements.txt | 4 ++ 8 files changed, 140 insertions(+), 13 deletions(-) create mode 100644 7project/backend/alembic/versions/2025_10_10_1405-7af8f296d089_add_user_oauth.py create mode 100644 7project/backend/app/oauth/__init__.py create mode 100644 7project/backend/app/oauth/moje_id.py diff --git a/7project/backend/alembic/versions/2025_10_10_1405-7af8f296d089_add_user_oauth.py b/7project/backend/alembic/versions/2025_10_10_1405-7af8f296d089_add_user_oauth.py new file mode 100644 index 0000000..fe71142 --- /dev/null +++ b/7project/backend/alembic/versions/2025_10_10_1405-7af8f296d089_add_user_oauth.py @@ -0,0 +1,48 @@ +"""add user oauth + +Revision ID: 7af8f296d089 +Revises: 390041bd839e +Create Date: 2025-10-10 14:05:00.153376 + +""" +from typing import Sequence, Union + +import fastapi_users_db_sqlalchemy +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '7af8f296d089' +down_revision: Union[str, Sequence[str], None] = '390041bd839e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('oauth_account', + sa.Column('id', fastapi_users_db_sqlalchemy.generics.GUID(), nullable=False), + sa.Column('user_id', fastapi_users_db_sqlalchemy.generics.GUID(), nullable=False), + sa.Column('oauth_name', sa.String(length=100), nullable=False), + sa.Column('access_token', sa.String(length=1024), nullable=False), + sa.Column('expires_at', sa.Integer(), nullable=True), + sa.Column('refresh_token', sa.String(length=1024), nullable=True), + sa.Column('account_id', sa.String(length=320), nullable=False), + sa.Column('account_email', sa.String(length=320), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='cascade'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_oauth_account_account_id'), 'oauth_account', ['account_id'], unique=False) + op.create_index(op.f('ix_oauth_account_oauth_name'), 'oauth_account', ['oauth_name'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_oauth_account_oauth_name'), table_name='oauth_account') + op.drop_index(op.f('ix_oauth_account_account_id'), table_name='oauth_account') + op.drop_table('oauth_account') + # ### end Alembic commands ### diff --git a/7project/backend/app/app.py b/7project/backend/app/app.py index 6349483..56ef23c 100644 --- a/7project/backend/app/app.py +++ b/7project/backend/app/app.py @@ -1,15 +1,16 @@ from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware +import app.services.user_service from app.models.user import User from app.schemas.user import UserCreate, UserRead, UserUpdate from app.services.user_service import auth_backend, current_active_verified_user, fastapi_users -app = FastAPI() +fastApi = FastAPI() # CORS for frontend dev server -app.add_middleware( +fastApi.add_middleware( CORSMiddleware, allow_origins=[ "http://localhost:5173", @@ -20,37 +21,48 @@ app.add_middleware( allow_headers=["*"], ) -app.include_router( +fastApi.include_router( fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", tags=["auth"] ) -app.include_router( +fastApi.include_router( fastapi_users.get_register_router(UserRead, UserCreate), prefix="/auth", tags=["auth"], ) -app.include_router( +fastApi.include_router( fastapi_users.get_reset_password_router(), prefix="/auth", tags=["auth"], ) -app.include_router( +fastApi.include_router( fastapi_users.get_verify_router(UserRead), prefix="/auth", tags=["auth"], ) -app.include_router( +fastApi.include_router( fastapi_users.get_users_router(UserRead, UserUpdate), prefix="/users", tags=["users"], ) +fastApi.include_router( + fastapi_users.get_oauth_router( + app.services.user_service.mojeid_oauth_service, + auth_backend, + "SECRET", + associate_by_email=True + ), + prefix="/auth/mojeid", + tags=["auth"], +) + # Liveness/root endpoint -@app.get("/", include_in_schema=False) +@fastApi.get("/", include_in_schema=False) async def root(): return {"status": "ok"} -@app.get("/authenticated-route") +@fastApi.get("/authenticated-route") async def authenticated_route(user: User = Depends(current_active_verified_user)): return {"message": f"Hello {user.email}!"} diff --git a/7project/backend/app/models/user.py b/7project/backend/app/models/user.py index 836dddf..6a1865c 100644 --- a/7project/backend/app/models/user.py +++ b/7project/backend/app/models/user.py @@ -1,13 +1,20 @@ +from typing import List + from sqlalchemy import Column, String from sqlalchemy.orm import relationship -from fastapi_users.db import SQLAlchemyBaseUserTableUUID +from fastapi_users.db import SQLAlchemyBaseUserTableUUID, SQLAlchemyBaseOAuthAccountTableUUID from app.core.base import Base +class OAuthAccount(SQLAlchemyBaseOAuthAccountTableUUID, Base): + pass + + class User(SQLAlchemyBaseUserTableUUID, Base): first_name = Column(String(length=100), nullable=True) last_name = Column(String(length=100), nullable=True) + oauth_accounts = relationship("OAuthAccount", lazy="joined") # Relationship transactions = relationship("Transaction", back_populates="user") - categories = relationship("Category", back_populates="user") \ No newline at end of file + categories = relationship("Category", back_populates="user") diff --git a/7project/backend/app/oauth/__init__.py b/7project/backend/app/oauth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/7project/backend/app/oauth/moje_id.py b/7project/backend/app/oauth/moje_id.py new file mode 100644 index 0000000..0c118e3 --- /dev/null +++ b/7project/backend/app/oauth/moje_id.py @@ -0,0 +1,48 @@ +import json +from typing import Optional, Literal + +from httpx_oauth.clients.openid import OpenID +from httpx_oauth.oauth2 import OAuth2Token, GetAccessTokenError, T + + +# claims=%7B%22id_token%22%3A%7B%22birthdate%22%3A%7B%22essential%22%3Atrue%7D%2C%22name%22%3A%7B%22essential%22%3Atrue%7D%2C%22given_name%22%3A%7B%22essential%22%3Atrue%7D%2C%22family_name%22%3A%7B%22essential%22%3Atrue%7D%2C%22email%22%3A%7B%22essential%22%3Atrue%7D%2C%22address%22%3A%7B%22essential%22%3Afalse%7D%2C%22mojeid_valid%22%3A%7B%22essential%22%3Atrue%7D%7D%7D +class MojeIDOAuth(OpenID): + def __init__(self, client_id: str, client_secret: str): + super().__init__( + client_id, + client_secret, + "https://mojeid.regtest.nic.cz/.well-known/openid-configuration/", + "MojeID", + base_scopes=["openid", "email", "profile"], + ) + + async def get_authorization_url( + self, + redirect_uri: str, + state: Optional[str] = None, + scope: Optional[list[str]] = None, + code_challenge: Optional[str] = None, + code_challenge_method: Optional[Literal["plain", "S256"]] = None, + extras_params: Optional[T] = None, + ) -> str: + required_fields = { + 'id_token': { + 'name': {'essential': True}, + 'given_name': {'essential': True}, + 'family_name': {'essential': True}, + 'email': {'essential': True}, + 'mojeid_valid': {'essential': True}, + }} + + if extras_params is None: + extras_params = {} + extras_params["claims"] = json.dumps(required_fields) + + return await super().get_authorization_url( + redirect_uri, + state, + scope, + code_challenge, + code_challenge_method, + extras_params, + ) diff --git a/7project/backend/app/services/db.py b/7project/backend/app/services/db.py index 99929b0..606af8d 100644 --- a/7project/backend/app/services/db.py +++ b/7project/backend/app/services/db.py @@ -4,11 +4,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from fastapi_users.db import SQLAlchemyUserDatabase from ..core.db import async_session_maker -from ..models.user import User +from ..models.user import User, OAuthAccount + async def get_async_session() -> AsyncGenerator[AsyncSession, None]: async with async_session_maker() as session: yield session + async def get_user_db(session: AsyncSession = Depends(get_async_session)): - yield SQLAlchemyUserDatabase(session, User) + yield SQLAlchemyUserDatabase(session, User, OAuthAccount) diff --git a/7project/backend/app/services/user_service.py b/7project/backend/app/services/user_service.py index 1d3857e..dcaf0d9 100644 --- a/7project/backend/app/services/user_service.py +++ b/7project/backend/app/services/user_service.py @@ -12,6 +12,7 @@ from fastapi_users.authentication.strategy.jwt import JWTStrategy from fastapi_users.db import SQLAlchemyUserDatabase from app.models.user import User +from app.oauth.moje_id import MojeIDOAuth from app.services.db import get_user_db from app.core.queue import enqueue_email @@ -19,6 +20,11 @@ SECRET = os.getenv("SECRET", "CHANGE_ME_SECRET") FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173") BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000") +mojeid_oauth_service = MojeIDOAuth( + os.getenv("MOJEID_CLIENT_ID", "CHANGE_ME_CLIENT_ID"), + os.getenv("MOJEID_CLIENT_SECRET", "CHANGE_ME_CLIENT_SECRET"), +) + class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): reset_password_token_secret = SECRET verification_token_secret = SECRET diff --git a/7project/backend/requirements.txt b/7project/backend/requirements.txt index 1f8e9bf..6d41c19 100644 --- a/7project/backend/requirements.txt +++ b/7project/backend/requirements.txt @@ -11,6 +11,7 @@ asyncmy==0.2.9 bcrypt==4.3.0 billiard==4.2.2 celery==5.5.3 +certifi==2025.10.5 cffi==2.0.0 click==8.1.8 click-didyoumean==0.3.1 @@ -25,7 +26,10 @@ fastapi-users==14.0.1 fastapi-users-db-sqlalchemy==7.0.0 greenlet==3.2.4 h11==0.16.0 +httpcore==1.0.9 httptools==0.6.4 +httpx==0.28.1 +httpx-oauth==0.16.1 idna==3.10 kombu==5.5.4 makefun==1.16.0