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 62fb7b3..61f829a 100644 --- a/7project/backend/app/app.py +++ b/7project/backend/app/app.py @@ -7,11 +7,12 @@ from app.services.user_service import current_active_verified_user from app.api.auth import router as auth_router from app.api.categories import router as categories_router from app.api.transactions import router as transactions_router +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", @@ -26,13 +27,35 @@ app.include_router(auth_router) app.include_router(categories_router) app.include_router(transactions_router) +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 diff --git a/7project/tofu/main.tf b/7project/tofu/main.tf index 65d4cd6..2b54f81 100644 --- a/7project/tofu/main.tf +++ b/7project/tofu/main.tf @@ -96,6 +96,13 @@ module "database" { phpmyadmin_enabled = var.phpmyadmin_enabled cloudflare_domain = var.cloudflare_domain + + s3_enabled = var.s3_enabled + s3_bucket = var.s3_bucket + s3_region = var.s3_region + s3_endpoint = var.s3_endpoint + s3_key_id = var.s3_key_id + s3_key_secret = var.s3_key_secret } #module "argocd" { diff --git a/7project/tofu/modules/maxscale/charts/maxscale-helm/Chart.yaml b/7project/tofu/modules/maxscale/charts/maxscale-helm/Chart.yaml index c78c909..e0aa74a 100644 --- a/7project/tofu/modules/maxscale/charts/maxscale-helm/Chart.yaml +++ b/7project/tofu/modules/maxscale/charts/maxscale-helm/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v2 name: maxscale-helm -version: 1.0.8 +version: 1.0.14 description: Helm chart for MaxScale related Kubernetes manifests diff --git a/7project/tofu/modules/maxscale/charts/maxscale-helm/templates/backup.yaml b/7project/tofu/modules/maxscale/charts/maxscale-helm/templates/backup.yaml new file mode 100644 index 0000000..1ff27e3 --- /dev/null +++ b/7project/tofu/modules/maxscale/charts/maxscale-helm/templates/backup.yaml @@ -0,0 +1,42 @@ +{{- if .Values.s3.enabled }} +apiVersion: k8s.mariadb.com/v1alpha1 +kind: Backup +metadata: + name: backup + namespace: mariadb-operator +spec: + mariaDbRef: + name: mariadb-repl + namespace: mariadb-operator + schedule: + cron: "0 */3 * * *" + suspend: false + timeZone: "Europe/Prague" + maxRetention: 720h # 30 days + compression: bzip2 + storage: + s3: + bucket: {{ .Values.s3.bucket | quote }} + endpoint: {{ .Values.s3.endpoint | quote }} + accessKeyIdSecretKeyRef: + name: s3-credentials + key: key_id + secretAccessKeySecretKeyRef: + name: s3-credentials + key: secret_key + region: {{ .Values.s3.region | quote }} + tls: + enabled: true + # Define a PVC to use as staging area for keeping the backups while they are being processed. + stagingStorage: + persistentVolumeClaim: + resources: + requests: + storage: 10Gi + accessModes: + - ReadWriteOnce + args: + - --single-transaction + - --all-databases + logLevel: info +{{- end }} \ No newline at end of file diff --git a/7project/tofu/modules/maxscale/charts/maxscale-helm/templates/garage.yaml b/7project/tofu/modules/maxscale/charts/maxscale-helm/templates/garage.yaml new file mode 100644 index 0000000..7bb6c2f --- /dev/null +++ b/7project/tofu/modules/maxscale/charts/maxscale-helm/templates/garage.yaml @@ -0,0 +1,11 @@ +{{- if .Values.s3.enabled }} +apiVersion: v1 +kind: Secret +metadata: + name: s3-credentials + namespace: mariadb-operator +type: Opaque +stringData: + key_id: "{{ .Values.s3.key_id }}" + secret_key: "{{ .Values.s3.key_secret }}" +{{- end }} \ No newline at end of file diff --git a/7project/tofu/modules/maxscale/charts/maxscale-helm/values.yaml b/7project/tofu/modules/maxscale/charts/maxscale-helm/values.yaml index f7a4a90..2604e20 100644 --- a/7project/tofu/modules/maxscale/charts/maxscale-helm/values.yaml +++ b/7project/tofu/modules/maxscale/charts/maxscale-helm/values.yaml @@ -14,4 +14,12 @@ metallb: phpmyadmin: enabled: true +s3: + enabled: false + endpoint: "" + region: "" + bucket: "" + key_id: "" + key_secret: "" + base_domain: example.com diff --git a/7project/tofu/modules/maxscale/main.tf b/7project/tofu/modules/maxscale/main.tf index de78a3f..fe71b90 100644 --- a/7project/tofu/modules/maxscale/main.tf +++ b/7project/tofu/modules/maxscale/main.tf @@ -59,7 +59,7 @@ resource "helm_release" "mariadb-operator" { resource "helm_release" "maxscale_helm" { name = "maxscale-helm" chart = "${path.module}/charts/maxscale-helm" - version = "1.0.8" + version = "1.0.14" depends_on = [helm_release.mariadb-operator-crds, kubectl_manifest.secrets] timeout = 3600 @@ -71,6 +71,12 @@ resource "helm_release" "maxscale_helm" { { name = "metallb.primary_ip", value = var.primary_ip }, { name = "metallb.secondary_ip", value = var.secondary_ip }, { name = "phpmyadmin.enabled", value = tostring(var.phpmyadmin_enabled) }, - { name = "base_domain", value = var.cloudflare_domain } + { name = "base_domain", value = var.cloudflare_domain }, + { name = "s3.key_id", value = var.s3_key_id }, + { name = "s3.key_secret", value = var.s3_key_secret }, + { name = "s3.enabled", value = var.s3_enabled }, + { name = "s3.endpoint", value = var.s3_endpoint }, + { name = "s3.region", value = var.s3_region }, + { name = "s3.bucket", value = var.s3_bucket }, ] } diff --git a/7project/tofu/modules/maxscale/variables.tf b/7project/tofu/modules/maxscale/variables.tf index fb8c724..8e5f07f 100644 --- a/7project/tofu/modules/maxscale/variables.tf +++ b/7project/tofu/modules/maxscale/variables.tf @@ -52,7 +52,39 @@ variable "mariadb_user_password" { } variable "cloudflare_domain" { - type = string - default = "Base cloudflare domain, e.g. example.com" + type = string + default = "Base cloudflare domain, e.g. example.com" nullable = false -} \ No newline at end of file +} + +variable "s3_key_id" { + description = "S3 Key ID for backups" + type = string + sensitive = true +} + +variable "s3_key_secret" { + description = "S3 Key Secret for backups" + type = string + sensitive = true +} + +variable "s3_enabled" { + description = "Enable S3 backups" + type = bool +} + +variable "s3_endpoint" { + description = "S3 endpoint for backups" + type = string +} + +variable "s3_region" { + description = "S3 region for backups" + type = string +} + +variable "s3_bucket" { + description = "S3 bucket name for backups" + type = string +} diff --git a/7project/tofu/variables.tf b/7project/tofu/variables.tf index 3e59d8b..fe42b76 100644 --- a/7project/tofu/variables.tf +++ b/7project/tofu/variables.tf @@ -108,3 +108,40 @@ variable "rabbitmq-password" { sensitive = true description = "Admin password for RabbitMQ user" } + +variable "s3_key_id" { + description = "S3 Key ID for backups" + type = string + sensitive = true + nullable = false +} + +variable "s3_key_secret" { + description = "S3 Key Secret for backups" + type = string + sensitive = true + nullable = false +} + +variable "s3_enabled" { + description = "Enable S3 backups" + type = bool +} + +variable "s3_endpoint" { + description = "S3 endpoint for backups" + type = string +} + +variable "s3_region" { + description = "S3 region for backups" + type = string +} + +variable "s3_bucket" { + description = "S3 bucket name for backups" + type = string +} + + +