feat: implement OAuth2 login

This commit is contained in:
maxDorninger
2025-05-25 19:07:53 +02:00
parent 5f2af624c9
commit 018fa24021
11 changed files with 165 additions and 50 deletions

5
.env Normal file
View File

@@ -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

View File

@@ -20,4 +20,5 @@ class OAuth2Config(BaseSettings):
client_secret: str
authorize_endpoint: str
access_token_endpoint: str
user_info_endpoint: str
name: str = "OAuth2"

View File

@@ -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}

View File

@@ -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",

View File

@@ -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

View File

@@ -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(

View File

@@ -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]

View File

@@ -38,4 +38,18 @@ services:
- 6881:6881/udp
restart: unless-stopped
volumes:
- ./torrent/:/download/:rw
- ./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

View File

@@ -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);
}
}
</script>
{#snippet tabSwitcher()}
<!-- <Tabs.List>-->
<!-- <Tabs.Trigger value="login">Login</Tabs.Trigger>-->
<!-- <Tabs.Trigger value="register">Sign up</Tabs.Trigger>-->
<!-- </Tabs.List>-->
{#snippet oauthLogin()}
{#await oauthProvider}
<LoadingBar/>
{:then result}
{#if result.oauth_name != null}
<Button class="mt-2 w-full" onclick={() => handleOauth()} variant="outline">Login
with {result.oauth_name}</Button>
{/if}
{/await}
{/snippet}
<Tabs.Root class="w-[400px]" value={tabValue}>
<Tabs.Content value="login">
<Card.Root class="mx-auto max-w-sm">
<Card.Header>
{@render tabSwitcher()}
<Card.Title class="text-2xl">Login</Card.Title>
<Card.Description>Enter your email below to login to your account</Card.Description>
</Card.Header>
@@ -158,7 +192,7 @@
</Button>
</form>
<Button class="mt-2 w-full" variant="outline">Login with Google</Button>
{@render oauthLogin()}
<div class="mt-4 text-center text-sm">
<Button onclick={() => (tabValue = 'register')} variant="link">
@@ -172,7 +206,6 @@
<Tabs.Content value="register">
<Card.Root class="mx-auto max-w-sm">
<Card.Header>
{@render tabSwitcher()}
<Card.Title class="text-2xl">Sign up</Card.Title>
<Card.Description>Enter your email and password below to sign up.</Card.Description>
</Card.Header>
@@ -208,7 +241,8 @@
</Button>
</form>
<!-- TODO: dynamically display oauth providers based on config -->
<Button class="mt-2 w-full" variant="outline">Login with Google</Button>
{@render oauthLogin()}
<div class="mt-4 text-center text-sm">
<Button onclick={() => (tabValue = 'login')} variant="link"

View File

@@ -3,6 +3,9 @@
import logo from '$lib/images/logo.svg';
import background from '$lib/images/pawel-czerwinski-NTYYL9Eb9y8-unsplash.jpg';
import {toOptimizedURL} from "sveltekit-image-optimize/components";
import {page} from '$app/state';
let oauthProvider = page.data.oauthProvider;
</script>
<div class="grid min-h-svh lg:grid-cols-2">
@@ -19,7 +22,7 @@
</div>
<div class="flex flex-1 items-center justify-center">
<div class="w-full max-w-xs">
<LoginForm/>
<LoginForm oauthProvider={oauthProvider}/>
</div>
</div>
</div>

View File

@@ -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()};
};