From 6f77bdebe1d59fdb584a0c97aa98ca2c51c29c43 Mon Sep 17 00:00:00 2001 From: Mawoka Date: Tue, 14 Feb 2023 17:44:19 +0100 Subject: [PATCH 1/5] :sparkles: Added some files and config for openid --- classquiz/config.py | 10 +++++++++- classquiz/oauth/init_oauth.py | 7 +++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/classquiz/config.py b/classquiz/config.py index c3f4392..d3e4eb7 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 diff --git a/classquiz/oauth/init_oauth.py b/classquiz/oauth/init_oauth.py index 8ca3190..85d7d64 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, + client_id=settings.custom_openid_provider.client_id, + client_secret=settings.custom_openid_provider.client_secret, + ) return oauth From 86821e2eb365c2f5abcff6331659b1915e5ebbe6 Mon Sep 17 00:00:00 2001 From: Mawoka Date: Thu, 16 Feb 2023 20:11:57 +0100 Subject: [PATCH 2/5] :wip: Some changes in custom-oauth --- classquiz/config.py | 1 + classquiz/oauth/__init__.py | 3 +- classquiz/oauth/custom.py | 95 +++++++++++++++++++++++++++++++++++ classquiz/oauth/init_oauth.py | 2 +- 4 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 classquiz/oauth/custom.py diff --git a/classquiz/config.py b/classquiz/config.py index d3e4eb7..760979a 100644 --- a/classquiz/config.py +++ b/classquiz/config.py @@ -61,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/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..e166cd2 --- /dev/null +++ b/classquiz/oauth/custom.py @@ -0,0 +1,95 @@ +# 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 + +settings = settings() + +router = APIRouter() + + +@router.get("/login") +async def openid_login(req: Request): + print(settings.custom_openid_provider) + if settings.google_client_secret is None or settings.google_client_id 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="Google-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.google.authorize_access_token(request) + print(user_data) + # try: + # user_data = OauthGoogleResponse(**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.name, + verified=user_data.email_verified, + auth_type=UserAuthTypes.GOOGLE, + google_uid=user_data.sub, + 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.name}{counter}", + verified=user_data.email_verified, + auth_type=UserAuthTypes.GOOGLE, + google_uid=user_data.sub, + 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, auth_type=UserAuthTypes.GOOGLE, verified=True + ) + + 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 85d7d64..1f39e8d 100644 --- a/classquiz/oauth/init_oauth.py +++ b/classquiz/oauth/init_oauth.py @@ -34,7 +34,7 @@ def init_oauth() -> OAuth: oauth.register( name="custom", client_kwargs={"scope": settings.custom_openid_provider.scopes}, - server_metadata_url=settings.custom_openid_provider, + 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, ) From c2a8c36142be612c0d1278fef3ed4943d4870b28 Mon Sep 17 00:00:00 2001 From: Mawoka Date: Fri, 17 Feb 2023 12:18:28 +0100 Subject: [PATCH 3/5] :wip: Nearly finished openid --- classquiz/config.py | 2 +- classquiz/db/models.py | 1 + classquiz/oauth/custom.py | 36 +++++++++++++++++++++++++++++------- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/classquiz/config.py b/classquiz/config.py index 760979a..602c3b2 100644 --- a/classquiz/config.py +++ b/classquiz/config.py @@ -14,7 +14,7 @@ from classquiz.storage import Storage class CustomOpenIDProvider(BaseModel): - scopes: str = "openid email profile" + scopes: str = "openid email" server_metadata_url: str client_id: str client_secret: str 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/custom.py b/classquiz/oauth/custom.py index e166cd2..cabd58b 100644 --- a/classquiz/oauth/custom.py +++ b/classquiz/oauth/custom.py @@ -12,15 +12,37 @@ 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 + + +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): - print(settings.custom_openid_provider) if settings.google_client_secret is None or settings.google_client_id is None: raise HTTPException(status_code=501, detail="Custom-OAuth-Login isn't available on this server") oauth = init_oauth() @@ -45,12 +67,12 @@ async def auth(request: Request, response: Response): return await rememberme_check(rememberme_token=rememberme_token, response=response) oauth = init_oauth() - user_data = await oauth.google.authorize_access_token(request) + 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.") print(user_data) - # try: - # user_data = OauthGoogleResponse(**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 @@ -60,7 +82,7 @@ async def auth(request: Request, response: Response): email=user_data.email, username=user_data.name, verified=user_data.email_verified, - auth_type=UserAuthTypes.GOOGLE, + auth_type=UserAuthTypes.CUSTOM, google_uid=user_data.sub, avatar=gzipped_user_avatar(), ) From dd8be0a772638d5be710e7a9bf47a8796ed200ee Mon Sep 17 00:00:00 2001 From: Mawoka Date: Fri, 17 Feb 2023 15:14:49 +0100 Subject: [PATCH 4/5] :sparkles: Custom OAuth works --- classquiz/config.py | 2 +- classquiz/oauth/custom.py | 21 ++++++++++++--------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/classquiz/config.py b/classquiz/config.py index 602c3b2..760979a 100644 --- a/classquiz/config.py +++ b/classquiz/config.py @@ -14,7 +14,7 @@ from classquiz.storage import Storage class CustomOpenIDProvider(BaseModel): - scopes: str = "openid email" + scopes: str = "openid email profile" server_metadata_url: str client_id: str client_secret: str diff --git a/classquiz/oauth/custom.py b/classquiz/oauth/custom.py index cabd58b..509515f 100644 --- a/classquiz/oauth/custom.py +++ b/classquiz/oauth/custom.py @@ -28,6 +28,8 @@ class Userinfo(BaseModel): nonce: str email: str email_verified: bool + preferred_username: str + name: str class OpenIDResponse(BaseModel): @@ -43,7 +45,7 @@ class OpenIDResponse(BaseModel): @router.get("/login") async def openid_login(req: Request): - if settings.google_client_secret is None or settings.google_client_id is None: + 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() @@ -53,7 +55,7 @@ async def openid_login(req: Request): @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="Google-Login isn't available on this server") + 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: @@ -72,7 +74,7 @@ async def auth(request: Request, response: Response): user_data = OpenIDResponse(**user_data).userinfo except (TypeError, ValidationError): raise HTTPException(status_code=401, detail="Something went wrong.") - print(user_data) + user_in_db = await User.objects.get_or_none(email=user_data.email) if user_in_db is None: # REGISTER USER @@ -80,10 +82,10 @@ async def auth(request: Request, response: Response): await User.objects.create( id=uuid.uuid4(), email=user_data.email, - username=user_data.name, + username=user_data.preferred_username, verified=user_data.email_verified, auth_type=UserAuthTypes.CUSTOM, - google_uid=user_data.sub, + google_uid=user_data.sub.hex, avatar=gzipped_user_avatar(), ) except Exception as e: @@ -95,10 +97,10 @@ async def auth(request: Request, response: Response): await User.objects.create( id=uuid.uuid4(), email=user_data.email, - username=f"{user_data.name}{counter}", + username=f"{user_data.preferred_username}{counter}", verified=user_data.email_verified, - auth_type=UserAuthTypes.GOOGLE, - google_uid=user_data.sub, + auth_type=UserAuthTypes.CUSTOM, + google_uid=user_data.sub.hex, avatar=gzipped_user_avatar(), ) error = False @@ -108,8 +110,9 @@ async def auth(request: Request, response: Response): 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, auth_type=UserAuthTypes.GOOGLE, verified=True + 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") From af5abc8d5bc72a46e453b759e31f5173c90ab481 Mon Sep 17 00:00:00 2001 From: Mawoka Date: Fri, 17 Feb 2023 15:22:13 +0100 Subject: [PATCH 5/5] :sparkles: Added UI for custom OpenID login --- frontend/src/lib/config.ts | 1 + .../routes/account/login/oauth_block.svelte | 67 +++++++++++++++++++ .../routes/account/login/start_window.svelte | 42 +----------- 3 files changed, 70 insertions(+), 40 deletions(-) create mode 100644 frontend/src/routes/account/login/oauth_block.svelte 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} + +{/if} +{#if custom_oauth_name} + +{/if} diff --git a/frontend/src/routes/account/login/start_window.svelte b/frontend/src/routes/account/login/start_window.svelte index a392e37..ba241f2 100644 --- a/frontend/src/routes/account/login/start_window.svelte +++ b/frontend/src/routes/account/login/start_window.svelte @@ -4,8 +4,8 @@ - file, You can obtain one at https://mozilla.org/MPL/2.0/. -->