diff --git a/classquiz/config.py b/classquiz/config.py index c3f4392..760979a 100644 --- a/classquiz/config.py +++ b/classquiz/config.py @@ -6,13 +6,20 @@ from functools import lru_cache from redis import asyncio as redis_lib import redis as redis_base_lib -from pydantic import BaseSettings, RedisDsn, PostgresDsn +from pydantic import BaseSettings, RedisDsn, PostgresDsn, BaseModel import meilisearch as MeiliSearch from typing import Optional from classquiz.storage import Storage +class CustomOpenIDProvider(BaseModel): + scopes: str = "openid email profile" + server_metadata_url: str + client_id: str + client_secret: str + + class Settings(BaseSettings): """ Settings class for the shop app. @@ -39,6 +46,7 @@ class Settings(BaseSettings): google_client_secret: Optional[str] github_client_id: Optional[str] github_client_secret: Optional[str] + custom_openid_provider: CustomOpenIDProvider | None = None telemetry_enabled: bool = True # storage_backend @@ -53,6 +61,7 @@ class Settings(BaseSettings): class Config: env_file = ".env" env_file_encoding = "utf-8" + env_nested_delimiter = "__" @lru_cache() diff --git a/classquiz/db/models.py b/classquiz/db/models.py index 83b3e9f..0935360 100644 --- a/classquiz/db/models.py +++ b/classquiz/db/models.py @@ -18,6 +18,7 @@ class UserAuthTypes(Enum): LOCAL = "LOCAL" GOOGLE = "GOOGLE" GITHUB = "GITHUB" + CUSTOM = "CUSTOM" class User(ormar.Model): diff --git a/classquiz/oauth/__init__.py b/classquiz/oauth/__init__.py index af8115a..4cbf500 100644 --- a/classquiz/oauth/__init__.py +++ b/classquiz/oauth/__init__.py @@ -9,7 +9,7 @@ from jose import jws, jwt, JWTError, JWSError from classquiz.auth import ACCESS_TOKEN_EXPIRE_MINUTES, create_access_token from classquiz.db.models import UserSession -from classquiz.oauth import google, github +from classquiz.oauth import google, github, custom from classquiz.config import settings settings = settings() @@ -17,6 +17,7 @@ settings = settings() router = APIRouter() router.include_router(google.router, prefix="/google") router.include_router(github.router, prefix="/github") +router.include_router(custom.router, prefix="/custom") async def rememberme_middleware(request: Request, call_next): diff --git a/classquiz/oauth/custom.py b/classquiz/oauth/custom.py new file mode 100644 index 0000000..509515f --- /dev/null +++ b/classquiz/oauth/custom.py @@ -0,0 +1,120 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +import uuid + +import asyncpg +from fastapi import APIRouter, Request, HTTPException, Response +from classquiz.config import settings + +from classquiz.db.models import User, UserAuthTypes +from classquiz.auth import check_token +from classquiz.helpers.avatar import gzipped_user_avatar +from classquiz.oauth.authenticate_user import log_user_in, rememberme_check +from classquiz.oauth.init_oauth import init_oauth +from pydantic import BaseModel, ValidationError + +settings = settings() + +router = APIRouter() + + +class Userinfo(BaseModel): + exp: int + iat: int + iss: str + aud: str + sub: uuid.UUID + nonce: str + email: str + email_verified: bool + preferred_username: str + name: str + + +class OpenIDResponse(BaseModel): + access_token: str + expires_in: int + token_type: str + scope: str + refresh_token: str + id_token: str + expires_at: int + userinfo: Userinfo + + +@router.get("/login") +async def openid_login(req: Request): + if settings.custom_openid_provider.client_id is None or settings.custom_openid_provider.client_secret is None: + raise HTTPException(status_code=501, detail="Custom-OAuth-Login isn't available on this server") + oauth = init_oauth() + + return await oauth.custom.authorize_redirect(req, f"{settings.root_address}/api/v1/users/oauth/custom/auth") + + +@router.get("/auth") +async def auth(request: Request, response: Response): + if settings.custom_openid_provider.client_id is None or settings.custom_openid_provider.client_secret is None: + raise HTTPException(status_code=501, detail="Custom-OAuth-Login isn't available on this server") + access_token = request.cookies.get("access_token") + rememberme_token = request.cookies.get("rememberme_token") + if access_token is not None: + try: + data = await check_token(access_token) + if data is not None: + return + except HTTPException: + pass + if rememberme_token is not None: + return await rememberme_check(rememberme_token=rememberme_token, response=response) + oauth = init_oauth() + + user_data = await oauth.custom.authorize_access_token(request) + try: + user_data = OpenIDResponse(**user_data).userinfo + except (TypeError, ValidationError): + raise HTTPException(status_code=401, detail="Something went wrong.") + + user_in_db = await User.objects.get_or_none(email=user_data.email) + if user_in_db is None: + # REGISTER USER + try: + await User.objects.create( + id=uuid.uuid4(), + email=user_data.email, + username=user_data.preferred_username, + verified=user_data.email_verified, + auth_type=UserAuthTypes.CUSTOM, + google_uid=user_data.sub.hex, + avatar=gzipped_user_avatar(), + ) + except Exception as e: + if type(e) == asyncpg.exceptions.UniqueViolationError: + error = True + counter = 1 + while error: + try: + await User.objects.create( + id=uuid.uuid4(), + email=user_data.email, + username=f"{user_data.preferred_username}{counter}", + verified=user_data.email_verified, + auth_type=UserAuthTypes.CUSTOM, + google_uid=user_data.sub.hex, + avatar=gzipped_user_avatar(), + ) + error = False + except asyncpg.exceptions.UniqueViolationError: + counter += 1 + error = True + else: + raise HTTPException(status_code=500, detail=str(e)) + user = await User.objects.get_or_none( + email=user_data.email, google_uid=user_data.sub.hex, auth_type=UserAuthTypes.CUSTOM, verified=True + ) + print(user_data) + + await log_user_in(user=user, request=request, response=response) + response.headers.append("Location", "/account/login") + response.status_code = 302 + return response diff --git a/classquiz/oauth/init_oauth.py b/classquiz/oauth/init_oauth.py index 8ca3190..1f39e8d 100644 --- a/classquiz/oauth/init_oauth.py +++ b/classquiz/oauth/init_oauth.py @@ -31,4 +31,11 @@ def init_oauth() -> OAuth: client_id=settings.github_client_id, client_secret=settings.github_client_secret, ) + oauth.register( + name="custom", + client_kwargs={"scope": settings.custom_openid_provider.scopes}, + server_metadata_url=settings.custom_openid_provider.server_metadata_url, + client_id=settings.custom_openid_provider.client_id, + client_secret=settings.custom_openid_provider.client_secret, + ) return oauth diff --git a/frontend/src/lib/config.ts b/frontend/src/lib/config.ts index 9b85372..101402c 100644 --- a/frontend/src/lib/config.ts +++ b/frontend/src/lib/config.ts @@ -7,3 +7,4 @@ export const google_auth_enabled = import.meta.env.VITE_GOOGLE_AUTH_ENABLED === 'true'; export const github_auth_enabled = import.meta.env.VITE_GITHUB_AUTH_ENABLED === 'true'; export const captcha_enabled = import.meta.env.VITE_CAPTCHA_ENABLED === 'true'; +export const custom_oauth_name = import.meta.env.VITE_CUSTOM_OAUTH_NAME; diff --git a/frontend/src/routes/account/login/oauth_block.svelte b/frontend/src/routes/account/login/oauth_block.svelte new file mode 100644 index 0000000..30b72b5 --- /dev/null +++ b/frontend/src/routes/account/login/oauth_block.svelte @@ -0,0 +1,67 @@ + + + +{#if google_auth_enabled} +
+{/if} +{#if github_auth_enabled} +