diff --git a/backend/alembic/versions/c8009ed33089_init_users.py b/backend/alembic/versions/c8009ed33089_init_users.py index 94f450b4..40fc50d1 100644 --- a/backend/alembic/versions/c8009ed33089_init_users.py +++ b/backend/alembic/versions/c8009ed33089_init_users.py @@ -16,6 +16,9 @@ branch_labels = None depends_on = None +#: Long tokens -- 64kbytes should be enough for everyone +TOKEN_SIZE = 64 * 1024 + def upgrade(): # ### commands auto generated by Alembic - please adjust! ### @@ -35,9 +38,9 @@ def upgrade(): 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("access_token", sa.String(length=TOKEN_SIZE), nullable=False), sa.Column("expires_at", sa.Integer(), nullable=True), - sa.Column("refresh_token", sa.String(length=1024), nullable=True), + sa.Column("refresh_token", sa.String(length=TOKEN_SIZE), 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"), diff --git a/backend/app/api/api_v1/api.py b/backend/app/api/api_v1/api.py index 8f447cfb..ff35ef8a 100644 --- a/backend/app/api/api_v1/api.py +++ b/backend/app/api/api_v1/api.py @@ -1,8 +1,10 @@ from fastapi import APIRouter +from httpx_oauth.clients.openid import OpenID -from app.api.api_v1.endpoints import adminmsgs +from app.api.api_v1.endpoints import adminmsgs, auth from app.core.auth import auth_backend_bearer, auth_backend_cookie, fastapi_users -from app.schemas.user import UserCreate, UserRead, UserUpdate +from app.core.config import settings +from app.schemas.user import UserRead, UserUpdate api_router = APIRouter() api_router.include_router(adminmsgs.router, prefix="/adminmsgs", tags=["adminmsgs"]) @@ -13,6 +15,7 @@ api_router.include_router( fastapi_users.get_auth_router(auth_backend_cookie), prefix="/auth/cookie", tags=["auth"] ) +api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) # api_router.include_router( # fastapi_users.get_register_router(UserRead, UserCreate), # prefix="/auth", @@ -33,3 +36,22 @@ prefix="/users", tags=["users"], ) + +# For now, we only provide oauth clients for cookie-based authentication. +for config in settings.OAUTH2_PROVIDERS: + oauth_client = OpenID( + client_id=config.client_id, + client_secret=config.client_secret, + openid_configuration_endpoint=str(config.config_url), + ) + oauth_router = fastapi_users.get_oauth_router( + oauth_client=oauth_client, + backend=auth_backend_cookie, + state_secret=settings.SECRET_KEY, + associate_by_email=True, + ) + api_router.include_router( + oauth_router, + prefix=f"/auth/external/cookie/{config.name}", + tags=["auth"], + ) diff --git a/backend/app/api/api_v1/endpoints/auth.py b/backend/app/api/api_v1/endpoints/auth.py new file mode 100644 index 00000000..82bfcb23 --- /dev/null +++ b/backend/app/api/api_v1/endpoints/auth.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app import crud, models, schemas +from app.api import deps +from app.core import config + +router = APIRouter() + + +@router.get("/oauth2-providers", response_model=list[schemas.OAuth2ProviderPublic]) +async def list_oauth2_providers() -> list[schemas.OAuth2ProviderPublic]: + """Retrieve all admin messages""" + providers = [ + schemas.OAuth2ProviderPublic.model_validate(obj.model_dump()) + for obj in config.settings.OAUTH2_PROVIDERS + ] + return providers diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index 691574c1..51db2ee8 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -1,7 +1,8 @@ import uuid +from typing import Any import redis.asyncio -from fastapi import Depends, Request +from fastapi import Depends, Request, Response from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin from fastapi_users.authentication import ( AuthenticationBackend, @@ -43,7 +44,16 @@ async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db bearer_transport = BearerTransport(tokenUrl=f"{settings.API_V1_STR}/auth/login") -cookie_transport = CookieTransport(cookie_max_age=settings.SESSION_EXPIRE_MINUTES * 60) + +class CookieRedirectTransport(CookieTransport): + async def get_login_response(self, token: str) -> Response: + response = await super().get_login_response(token) + response.status_code = 302 + response.headers["Location"] = "/profile" + return response + + +cookie_transport = CookieRedirectTransport(cookie_max_age=settings.SESSION_EXPIRE_MINUTES * 60) redis_obj = redis.asyncio.from_url(settings.REDIS_URL, decode_responses=True) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 2c8cc07b..7eea713f 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -2,10 +2,12 @@ import secrets from typing import Any -from pydantic import AnyHttpUrl, EmailStr, HttpUrl, PostgresDsn, field_validator +from pydantic import AnyHttpUrl, BaseModel, EmailStr, HttpUrl, PostgresDsn, field_validator from pydantic_core.core_schema import ValidationInfo from pydantic_settings import BaseSettings, SettingsConfigDict +from app.schemas import OAuth2ProviderConfig + class Settings(BaseSettings): model_config = SettingsConfigDict( @@ -95,6 +97,9 @@ def assemble_cors_origins(cls, v: str | list[str]) -> list[str] | str: # pragma #: Email of test users, ignored. EMAIL_TEST_USER: EmailStr = "test@example.com" # type: ignore + #: OAuth2 providers + OAUTH2_PROVIDERS: list[OAuth2ProviderConfig] = [] + # -- Database Configuration ---------------------------------------------- # Note that when os.environ["CI"] is "true" then we will use an in-memory diff --git a/backend/app/main.py b/backend/app/main.py index 48fed40a..3e04ccb1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,6 +11,9 @@ from app.core.config import settings from app.db.init_db import create_superuser +if settings.DEBUG: + logging.basicConfig(level=logging.DEBUG) + logger = logging.getLogger(__name__) app = FastAPI( diff --git a/backend/app/models/user.py b/backend/app/models/user.py index f7141116..947ea537 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,16 +1,26 @@ -from typing import List +from typing import TYPE_CHECKING, List, Optional from fastapi_users_db_sqlalchemy import ( SQLAlchemyBaseOAuthAccountTableUUID, SQLAlchemyBaseUserTableUUID, ) -from sqlalchemy.orm import Mapped, relationship +from sqlalchemy import Integer, String +from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db.base import Base +#: Long tokens -- 64kbytes should be enough for everyone +TOKEN_SIZE = 64 * 1024 + class OAuthAccount(SQLAlchemyBaseOAuthAccountTableUUID, Base): - pass + if TYPE_CHECKING: # pragma: no cover + access_token: str + refresh_token: Optional[str] + else: + # We need to increase the token size for the OAuthAccount table. + access_token: Mapped[str] = mapped_column(String(TOKEN_SIZE), nullable=False) + refresh_token: Mapped[Optional[str]] = mapped_column(String(TOKEN_SIZE), nullable=True) class User(SQLAlchemyBaseUserTableUUID, Base): diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index d5735761..6005cb57 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -1,2 +1,3 @@ from app.schemas.adminmsg import AdminMessageCreate, AdminMessageRead, AdminMessageUpdate # noqa +from app.schemas.auth import OAuth2ProviderConfig, OAuth2ProviderPublic # noqa from app.schemas.user import UserCreate, UserRead, UserUpdate # noqa diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 00000000..3417aa8c --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel, HttpUrl + + +class OAuth2ProviderBase(BaseModel): + """Base class for OAuth2 providers infos.""" + + #: Name of the identity provider. + name: str + #: Label to display to users + label: str + + +class OAuth2ProviderPublic(OAuth2ProviderBase): + """Information exposed via API.""" + + +class OAuth2ProviderConfig(OAuth2ProviderBase): + """OAuth2 provider configuration with client secrets.""" + + #: Configuration URL of the provider. + config_url: HttpUrl + #: Client ID to use. + client_id: str + #: Client secret to use. + client_secret: str diff --git a/backend/env.dev b/backend/env.dev index 05de635c..dd36a4a1 100644 --- a/backend/env.dev +++ b/backend/env.dev @@ -1,6 +1,6 @@ # Application configuration SERVER_NAME=localhost -SERVER_HOST=http://localhost:8080 +SERVER_HOST=http://localhost:8081 BACKEND_CORS_ORIGINS=["http://localhost:8081"] DEBUG=1 @@ -17,6 +17,9 @@ BACKEND_PREFIX_MEHARI=http://localhost:3002 BACKEND_PREFIX_VIGUNO=http://localhost:3003 BACKEND_PREFIX_NGINX=http://localhost:3004 +# Access to redis as it runs Docker Compose. +REDIS_URL=redis://localhost:3030 + # Superuser to setup on startup FIRST_SUPERUSER_EMAIL=admin@example.com FIRST_SUPERUSER_PASSWORD=password diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 67b04b2b..03fc692c 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -1,5 +1,15 @@ import { API_V1_BASE_PREFIX } from '@/api/common' +export interface OAuth2Provider { + name: string + label: string + url: string +} + +interface OAuth2LoginUrlResponse { + authorization_url: string +} + /** Access to the authentication-related part of the API. */ export class AuthClient { @@ -37,4 +47,21 @@ export class AuthClient { }) return await response.text() } + + async fetchOAuth2Providers(): Promise { + const response = await fetch(`${this.apiBaseUrl}auth/oauth2-providers`, { + method: 'GET' + }) + return await response.json() + } + + async fetchOAuth2LoginUrl(provider: OAuth2Provider, redirectTo?: string | null): Promise { + let url = `${this.apiBaseUrl}/auth/external/cookie/${provider.name}/authorize` + if (redirectTo) { + url += `?redirect_to=${encodeURIComponent(redirectTo)}` + } + const response = await fetch(url, { method: 'GET' }) + const response_json: OAuth2LoginUrlResponse = await response.json() + return response_json.authorization_url + } } diff --git a/frontend/src/stores/user.ts b/frontend/src/stores/user.ts index 6b620b79..600e0fa4 100644 --- a/frontend/src/stores/user.ts +++ b/frontend/src/stores/user.ts @@ -4,6 +4,7 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' +import { AuthClient, type OAuth2Provider } from '@/api/auth' import { UnauthenticatedError, UsersClient } from '@/api/users' import { StoreState } from '@/stores/misc' @@ -19,6 +20,9 @@ export const useUserStore = defineStore('user', () => { /* The current store state. */ const storeState = ref(StoreState.Initial) + /* The available OAuth2 providers. */ + const oauth2Providers = ref([]) + /* The current user, null if none, undefined if not set already */ const currentUser = ref(undefined) @@ -33,7 +37,12 @@ export const useUserStore = defineStore('user', () => { return // do not initialize twice } - await loadCurrentUser() + await Promise.all([loadOAuth2Endpoints(), loadCurrentUser()]) + } + + const loadOAuth2Endpoints = async () => { + const client = new AuthClient() + oauth2Providers.value = await client.fetchOAuth2Providers() } const loadCurrentUser = async () => { @@ -57,6 +66,7 @@ export const useUserStore = defineStore('user', () => { return { storeState, + oauth2Providers, currentUser, isAuthenticated, loadCurrentUser, diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index cfdcdf6f..04a0ead6 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -1,8 +1,8 @@