diff --git a/.env b/.env new file mode 100644 index 0000000..5cebe4e --- /dev/null +++ b/.env @@ -0,0 +1,5 @@ +# See the documentation for more information: https://pocket-id.org/docs/configuration/environment-variables +APP_URL=http://localhost:1411 +TRUST_PROXY=false +PUID=1000 +PGID=1000 diff --git a/backend/src/auth/config.py b/backend/src/auth/config.py index 0be560e..6f71732 100644 --- a/backend/src/auth/config.py +++ b/backend/src/auth/config.py @@ -20,4 +20,5 @@ class OAuth2Config(BaseSettings): client_secret: str authorize_endpoint: str access_token_endpoint: str + user_info_endpoint: str name: str = "OAuth2" diff --git a/backend/src/auth/router.py b/backend/src/auth/router.py new file mode 100644 index 0000000..5ff33b6 --- /dev/null +++ b/backend/src/auth/router.py @@ -0,0 +1,33 @@ +from fastapi import APIRouter, Depends +from fastapi import status +from sqlalchemy import select + +from auth.config import OAuth2Config +from auth.db import User +from auth.schemas import UserRead +from auth.users import current_superuser +from database import DbSessionDependency +from auth.users import oauth_client + +users_router = APIRouter() +auth_metadata_router = APIRouter() +oauth_enabled = oauth_client is not None +if oauth_enabled: + oauth_config = OAuth2Config() + + +@users_router.get("/users/all", status_code=status.HTTP_200_OK, dependencies=[Depends(current_superuser)]) +def get_all_users(db: DbSessionDependency) -> list[UserRead]: + stmt = select(User) + result = db.execute(stmt).scalars().unique() + return [UserRead.model_validate(user) for user in result] + + +@auth_metadata_router.get("/auth/metadata", status_code=status.HTTP_200_OK) +def get_auth_metadata() -> dict: + if oauth_enabled: + return { + "oauth_name": oauth_config.name, + } + else: + return {"oauth_name": None} diff --git a/backend/src/auth/users.py b/backend/src/auth/users.py index 1d9e639..2a2fe90 100644 --- a/backend/src/auth/users.py +++ b/backend/src/auth/users.py @@ -2,6 +2,7 @@ import os import uuid from typing import Optional +import httpx from fastapi import Depends, Request from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin, models from fastapi_users.authentication import ( @@ -11,24 +12,43 @@ from fastapi_users.authentication import ( ) from fastapi_users.db import SQLAlchemyUserDatabase from httpx_oauth.oauth2 import OAuth2 - +from fastapi.responses import RedirectResponse, Response import auth.config from auth.db import User, get_user_db from auth.schemas import UserUpdate +from config import BasicConfig config = auth.config.AuthConfig() SECRET = config.token_secret LIFETIME = config.session_lifetime -if os.getenv("OAUTH_ENABLED") == "True": - oauth2_config = auth.config.OAuth2Config() - oauth_client = OAuth2( +class GenericOAuth2(OAuth2): + def __init__(self, user_info_endpoint: str, **kwargs): + super().__init__(**kwargs) + self.user_info_endpoint = user_info_endpoint + + async def get_id_email(self, token: str): + userinfo_endpoint = self.user_info_endpoint + async with httpx.AsyncClient() as client: + resp = await client.get( + userinfo_endpoint, + headers={"Authorization": f"Bearer {token}"} + ) + resp.raise_for_status() + data = resp.json() + return data["sub"], data["email"] + + +if os.getenv("OAUTH_ENABLED") is not None and os.getenv("OAUTH_ENABLED").upper() == "TRUE": + oauth2_config = auth.config.OAuth2Config() + oauth_client = GenericOAuth2( client_id=oauth2_config.client_id, client_secret=oauth2_config.client_secret, name=oauth2_config.name, authorize_endpoint=oauth2_config.authorize_endpoint, access_token_endpoint=oauth2_config.access_token_endpoint, + user_info_endpoint=oauth2_config.user_info_endpoint, ) else: oauth_client = None @@ -72,8 +92,16 @@ def get_jwt_strategy() -> JWTStrategy[models.UP, models.ID]: return JWTStrategy(secret=SECRET, lifetime_seconds=LIFETIME) +# needed because the default CookieTransport does not redirect after login, +# thus the user would be stuck on the OAuth Providers "redirecting" page +class RedirectingCookieTransport(CookieTransport): + async def get_login_response(self, token: str) -> Response: + response = RedirectResponse(str(BasicConfig().FRONTEND_URL) + "dashboard", status_code=302) + return self._set_login_cookie(response, token) + + bearer_transport = BearerTransport(tokenUrl="auth/jwt/login") -cookie_transport = CookieTransport(cookie_max_age=LIFETIME) +cookie_transport = RedirectingCookieTransport(cookie_max_age=LIFETIME) bearer_auth_backend = AuthenticationBackend( name="jwt", diff --git a/backend/src/config.py b/backend/src/config.py index 1de6718..ebb3c05 100644 --- a/backend/src/config.py +++ b/backend/src/config.py @@ -1,5 +1,6 @@ from pathlib import Path +from pydantic import AnyHttpUrl from pydantic_settings import BaseSettings @@ -8,4 +9,6 @@ class BasicConfig(BaseSettings): tv_directory: Path = "./tv" movie_directory: Path = "./movie" torrent_directory: Path = "./torrent" + FRONTEND_URL: AnyHttpUrl + DEVELOPMENT: bool = False diff --git a/backend/src/main.py b/backend/src/main.py index 3cca40a..f2c5970 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -55,7 +55,6 @@ log.info("Database initialized") from auth.users import oauth_client import auth.users -import router from config import BasicConfig import uvicorn @@ -64,7 +63,8 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from auth.schemas import UserCreate, UserRead, UserUpdate from auth.users import bearer_auth_backend, fastapi_users, cookie_auth_backend - +from auth.router import users_router as custom_users_router +from auth.router import auth_metadata_router basic_config = BasicConfig() if basic_config.DEVELOPMENT: basic_config.torrent_directory.mkdir(parents=True, exist_ok=True) @@ -116,11 +116,16 @@ app.include_router( prefix="/auth", tags=["auth"], ) -# Misc Router +# All users route router app.include_router( - router.router, + custom_users_router, tags=["users"] ) +# OAuth Metadata Router +app.include_router( + auth_metadata_router, + tags=["oauth"] +) # User Router app.include_router( fastapi_users.get_users_router(UserRead, UserUpdate), @@ -129,26 +134,15 @@ app.include_router( ) # OAuth2 Routers if oauth_client is not None: - app.include_router( - fastapi_users.get_oauth_router(oauth_client, - bearer_auth_backend, - auth.users.SECRET, - associate_by_email=True, - is_verified_by_default=True - ), - prefix=f"/auth/jwt/{oauth_client.name}", - tags=["oauth"], - ) app.include_router( fastapi_users.get_oauth_router(oauth_client, cookie_auth_backend, auth.users.SECRET, associate_by_email=True, - is_verified_by_default=True + is_verified_by_default=True, ), prefix=f"/auth/cookie/{oauth_client.name}", tags=["oauth"], - ) app.include_router( diff --git a/backend/src/router.py b/backend/src/router.py deleted file mode 100644 index 59c1d48..0000000 --- a/backend/src/router.py +++ /dev/null @@ -1,17 +0,0 @@ -from fastapi import APIRouter, Depends -from fastapi import status -from sqlalchemy import select - -from auth.db import User -from auth.schemas import UserRead -from auth.users import current_superuser -from database import DbSessionDependency - -router = APIRouter() - - -@router.get("/users/all", status_code=status.HTTP_200_OK, dependencies=[Depends(current_superuser)]) -def get_all_users(db: DbSessionDependency) -> list[UserRead]: - stmt = select(User) - result = db.execute(stmt).scalars().unique() - return [UserRead.model_validate(user) for user in result] diff --git a/docker-compose.yml b/docker-compose.yml index fc72084..ce5c6d8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,4 +38,18 @@ services: - 6881:6881/udp restart: unless-stopped volumes: - - ./torrent/:/download/:rw \ No newline at end of file + - ./torrent/:/download/:rw + pocket-id: + image: ghcr.io/pocket-id/pocket-id + restart: unless-stopped + env_file: .env + ports: + - 1411:1411 + volumes: + - "./res/pocket-id:/app/data" + healthcheck: + test: "curl -f http://localhost:1411/healthz" + interval: 1m30s + timeout: 5s + retries: 2 + start_period: 10s \ No newline at end of file diff --git a/web/src/lib/components/login-form.svelte b/web/src/lib/components/login-form.svelte index 50018ff..535e07e 100644 --- a/web/src/lib/components/login-form.svelte +++ b/web/src/lib/components/login-form.svelte @@ -7,9 +7,14 @@ import {env} from '$env/dynamic/public'; import * as Tabs from '$lib/components/ui/tabs/index.js'; import {toast} from 'svelte-sonner'; + import LoadingBar from '$lib/components/loading-bar.svelte'; + import {base} from "$app/paths"; let apiUrl = env.PUBLIC_API_URL; + let {oauthProvider} = $props(); + let oauthProviderName = $derived(oauthProvider.oauth_name) + let email = $state(''); let password = $state(''); let errorMessage = $state(''); @@ -105,20 +110,49 @@ isLoading = false; } } + + async function handleOauth() { + try { + const response = await fetch(apiUrl + "/auth/cookie/" + oauthProviderName + "/authorize?scopes=email", { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + }, + }); + if (response.ok) { + let result = await response.json(); + console.log('Redirecting to OAuth provider:', oauthProviderName, "url: ", result.authorization_url); + toast.success("Redirecting to " + oauthProviderName + " for authentication..."); + window.location = result.authorization_url; + + + } else { + let errorText = await response.text(); + toast.error(errorMessage); + console.error('Login failed:', response.status, errorText); + } + } catch (error) { + console.error('Login request failed:', error); + errorMessage = 'An error occurred during the login request.'; + toast.error(errorMessage); + } + } -{#snippet tabSwitcher()} - - - - +{#snippet oauthLogin()} + {#await oauthProvider} + + {:then result} + {#if result.oauth_name != null} + + {/if} + {/await} {/snippet} - {@render tabSwitcher()} - Login Enter your email below to login to your account @@ -158,7 +192,7 @@ - + {@render oauthLogin()}
- + {@render oauthLogin()} +
diff --git a/web/src/routes/login/+page.ts b/web/src/routes/login/+page.ts new file mode 100644 index 0000000..fdb8d9d --- /dev/null +++ b/web/src/routes/login/+page.ts @@ -0,0 +1,17 @@ +import {env} from '$env/dynamic/public'; +import type {PageLoad} from './$types'; + +const apiUrl = env.PUBLIC_API_URL; + + +export const load: PageLoad = async ({fetch}) => { + const response = await fetch(apiUrl + '/auth/metadata', { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'include' + }); + + return {oauthProvider: await response.json()}; +}; \ No newline at end of file