mirror of
https://github.com/maxdorninger/MediaManager.git
synced 2026-04-17 21:54:00 +02:00
feat: implement OAuth2 login
This commit is contained in:
5
.env
Normal file
5
.env
Normal 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
|
||||
@@ -20,4 +20,5 @@ class OAuth2Config(BaseSettings):
|
||||
client_secret: str
|
||||
authorize_endpoint: str
|
||||
access_token_endpoint: str
|
||||
user_info_endpoint: str
|
||||
name: str = "OAuth2"
|
||||
|
||||
33
backend/src/auth/router.py
Normal file
33
backend/src/auth/router.py
Normal 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}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
17
web/src/routes/login/+page.ts
Normal file
17
web/src/routes/login/+page.ts
Normal 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()};
|
||||
};
|
||||
Reference in New Issue
Block a user