diff --git a/7project/backend/Dockerfile b/7project/backend/Dockerfile index b517161..c958447 100644 --- a/7project/backend/Dockerfile +++ b/7project/backend/Dockerfile @@ -5,4 +5,4 @@ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8000 -CMD alembic upgrade head && uvicorn app.app:app --host 0.0.0.0 --port 8000 +CMD alembic upgrade head && uvicorn app.app:fastApi --host 0.0.0.0 --port 8000 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/alembic/versions/2025_10_11_2107-5ab2e654c96e_change_token_lenght.py b/7project/backend/alembic/versions/2025_10_11_2107-5ab2e654c96e_change_token_lenght.py new file mode 100644 index 0000000..d4cebb0 --- /dev/null +++ b/7project/backend/alembic/versions/2025_10_11_2107-5ab2e654c96e_change_token_lenght.py @@ -0,0 +1,38 @@ +"""change token length + +Revision ID: 5ab2e654c96e +Revises: 7af8f296d089 +Create Date: 2025-10-11 21:07:41.930470 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = '5ab2e654c96e' +down_revision: Union[str, Sequence[str], None] = '7af8f296d089' +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.alter_column('oauth_account', 'access_token', + existing_type=mysql.VARCHAR(length=1024), + type_=sa.String(length=4096), + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('oauth_account', 'access_token', + existing_type=sa.String(length=4096), + type_=mysql.VARCHAR(length=1024), + existing_nullable=False) + # ### end Alembic commands ### diff --git a/7project/backend/app/app.py b/7project/backend/app/app.py index 6349483..423ab2c 100644 --- a/7project/backend/app/app.py +++ b/7project/backend/app/app.py @@ -4,12 +4,12 @@ from fastapi.middleware.cors import CORSMiddleware 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 +from app.services.user_service import auth_backend, current_active_verified_user, fastapi_users, get_oauth_provider -app = FastAPI() +fastApi = FastAPI() # CORS for frontend dev server -app.add_middleware( +fastApi.add_middleware( CORSMiddleware, allow_origins=[ "http://localhost:5173", @@ -20,37 +20,59 @@ 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( + get_oauth_provider("MojeID"), + auth_backend, + "SECRET", + associate_by_email=True, + ), + prefix="/auth/mojeid", + tags=["auth"], +) + +fastApi.include_router( + fastapi_users.get_oauth_router( + get_oauth_provider("BankID"), + auth_backend, + "SECRET", + associate_by_email=True, + ), + prefix="/auth/bankid", + 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..3a47034 100644 --- a/7project/backend/app/models/user.py +++ b/7project/backend/app/models/user.py @@ -1,13 +1,19 @@ from sqlalchemy import Column, String -from sqlalchemy.orm import relationship -from fastapi_users.db import SQLAlchemyBaseUserTableUUID +from sqlalchemy.orm import relationship, mapped_column, Mapped +from fastapi_users.db import SQLAlchemyBaseUserTableUUID, SQLAlchemyBaseOAuthAccountTableUUID from app.core.base import Base +class OAuthAccount(SQLAlchemyBaseOAuthAccountTableUUID, Base): + # BankID token is longer than default + access_token: Mapped[str] = mapped_column(String(length=4096), nullable=False) + + 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/bank_id.py b/7project/backend/app/oauth/bank_id.py new file mode 100644 index 0000000..fa2e55f --- /dev/null +++ b/7project/backend/app/oauth/bank_id.py @@ -0,0 +1,50 @@ +import secrets +from typing import Optional, Literal + +from httpx_oauth.oauth2 import T + +from app.oauth.custom_openid import CustomOpenID + + +class BankID(CustomOpenID): + def __init__(self, client_id: str, client_secret: str): + super().__init__( + client_id, + client_secret, + "https://oidc.sandbox.bankid.cz/.well-known/openid-configuration", + "BankID", + base_scopes=["openid", "profile.email", "profile.name"], + ) + + async def get_user_info(self, token: str) -> dict: + info = await self.get_profile(token) + + return { + "first_name": info.get("given_name"), + "last_name": info.get("family_name"), + } + + 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: + if extras_params is None: + extras_params = {} + + # BankID requires random nonce parameter for security + # https://developer.bankid.cz/docs/security_sep + extras_params["nonce"] = secrets.token_urlsafe() + + return await super().get_authorization_url( + redirect_uri, + state, + scope, + code_challenge, + code_challenge_method, + extras_params, + ) diff --git a/7project/backend/app/oauth/custom_openid.py b/7project/backend/app/oauth/custom_openid.py new file mode 100644 index 0000000..226682d --- /dev/null +++ b/7project/backend/app/oauth/custom_openid.py @@ -0,0 +1,6 @@ +from httpx_oauth.clients.openid import OpenID + + +class CustomOpenID(OpenID): + async def get_user_info(self, token: str) -> dict: + raise NotImplementedError() \ No newline at end of file diff --git a/7project/backend/app/oauth/moje_id.py b/7project/backend/app/oauth/moje_id.py new file mode 100644 index 0000000..7e1cd45 --- /dev/null +++ b/7project/backend/app/oauth/moje_id.py @@ -0,0 +1,56 @@ +import json +from typing import Optional, Literal, Any + +from httpx_oauth.oauth2 import T + +from app.oauth.custom_openid import CustomOpenID + + +class MojeIDOAuth(CustomOpenID): + 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_user_info(self, token: str) -> Optional[Any]: + info = await self.get_profile(token) + + return { + "first_name": info.get("given_name"), + "last_name": info.get("family_name"), + } + + 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..afee558 100644 --- a/7project/backend/app/services/user_service.py +++ b/7project/backend/app/services/user_service.py @@ -3,26 +3,66 @@ import uuid from typing import Optional from fastapi import Depends, Request -from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin +from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin, models from fastapi_users.authentication import ( AuthenticationBackend, BearerTransport, ) from fastapi_users.authentication.strategy.jwt import JWTStrategy from fastapi_users.db import SQLAlchemyUserDatabase +from httpx_oauth.oauth2 import BaseOAuth2 from app.models.user import User +from app.oauth.bank_id import BankID +from app.oauth.custom_openid import CustomOpenID +from app.oauth.moje_id import MojeIDOAuth from app.services.db import get_user_db from app.core.queue import enqueue_email 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") +providers = { + "MojeID": MojeIDOAuth( + os.getenv("MOJEID_CLIENT_ID", "CHANGE_ME_CLIENT_ID"), + os.getenv("MOJEID_CLIENT_SECRET", "CHANGE_ME_CLIENT_SECRET"), + ), + "BankID": BankID( + os.getenv("BANKID_CLIENT_ID", "CHANGE_ME_CLIENT_ID"), + os.getenv("BANKID_CLIENT_SECRET", "CHANGE_ME_CLIENT_SECRET"), + ) +} + + +def get_oauth_provider(name: str) -> Optional[BaseOAuth2]: + if name not in providers: + return None + return providers[name] + + class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): reset_password_token_secret = SECRET verification_token_secret = SECRET + async def oauth_callback(self: "BaseUserManager[models.UOAP, models.ID]", oauth_name: str, access_token: str, + account_id: str, account_email: str, expires_at: Optional[int] = None, + refresh_token: Optional[str] = None, request: Optional[Request] = None, *, + associate_by_email: bool = False, is_verified_by_default: bool = False) -> models.UOAP: + + user = await super().oauth_callback(oauth_name, access_token, account_id, account_email, expires_at, + refresh_token, request, associate_by_email=associate_by_email, + is_verified_by_default=is_verified_by_default) + + # set additional user info from the OAuth provider + provider = get_oauth_provider(oauth_name) + if provider is not None and isinstance(provider, CustomOpenID): + update_dict = await provider.get_user_info(access_token) + await self.user_db.update(user, update_dict) + + return user + async def on_after_register(self, user: User, request: Optional[Request] = None): await self.request_verify(user, request) @@ -52,14 +92,18 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): print("[Email Fallback] Subject:", subject) print("[Email Fallback] Body:\n", body) + async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)): yield UserManager(user_db) + bearer_transport = BearerTransport(tokenUrl="auth/jwt/login") + def get_jwt_strategy() -> JWTStrategy: return JWTStrategy(secret=SECRET, lifetime_seconds=3600) + auth_backend = AuthenticationBackend( name="jwt", transport=bearer_transport, @@ -70,4 +114,3 @@ fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [auth_backend]) current_active_user = fastapi_users.current_user(active=True) current_active_verified_user = fastapi_users.current_user(active=True, verified=True) - 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