From 7784c60c03ec277b456db6e2709383e302d9382b Mon Sep 17 00:00:00 2001 From: Tom van der Lee Date: Fri, 3 Jul 2026 15:49:08 +0200 Subject: Added basic oauth flow --- authentication/backend.py | 48 ++++++++++++++++++ authentication/endpoints.py | 87 +++++++++++++++++++++++++++++++++ authentication/templates/authorize.html | 5 ++ authentication/templates/base.html | 63 ++++++++++++++++++++++++ authentication/templates/login.html | 18 +++++++ 5 files changed, 221 insertions(+) create mode 100644 authentication/backend.py create mode 100644 authentication/endpoints.py create mode 100644 authentication/templates/authorize.html create mode 100644 authentication/templates/base.html create mode 100644 authentication/templates/login.html (limited to 'authentication') diff --git a/authentication/backend.py b/authentication/backend.py new file mode 100644 index 0000000..29408e3 --- /dev/null +++ b/authentication/backend.py @@ -0,0 +1,48 @@ +from datetime import datetime, UTC +from typing import Optional + +from sqlalchemy import update +from sqlmodel import select +from starlette.authentication import AuthenticationBackend, AuthCredentials, BaseUser, UnauthenticatedUser +from starlette.requests import HTTPConnection + +from db.models import Session, User +from db.session import get_session_context + + +class AuthBackend(AuthenticationBackend): + async def authenticate(self, request: HTTPConnection) -> Optional[tuple[AuthCredentials, BaseUser]]: + if "id" not in request.session: + return None + + query = ( + select(Session) + .join(User) + .where( + Session.id == request.session['id'] + ) + ) + + + with get_session_context() as db_session: + session: Session = db_session.exec(query).first() + + if session is not None: + db_session.execute( + update(Session).where(Session.id == session.id) + ) + db_session.commit() + + return ( + AuthCredentials( + [ + "authenticated", + *[ + connection.type for connection in session.user.app_connections + ] + ] + if session is not None + else [] + ), + session.user if session is not None else UnauthenticatedUser() + ) diff --git a/authentication/endpoints.py b/authentication/endpoints.py new file mode 100644 index 0000000..ebdc7a1 --- /dev/null +++ b/authentication/endpoints.py @@ -0,0 +1,87 @@ +from typing import Annotated +from uuid import uuid7 + +from fastapi import FastAPI, Request, Form, Query, HTTPException +from pydantic import BaseModel, EmailStr, ValidationError +from starlette.middleware.authentication import AuthenticationMiddleware +from starlette.middleware.sessions import SessionMiddleware +from starlette.responses import HTMLResponse, RedirectResponse +from starlette.templating import Jinja2Templates + +import conf +from authentication.backend import AuthBackend +from authentication.utils import bcrypt_sha256_verify +from db.models import User, Session, ClientApplication +from db.session import get_session_context +from proxy.utils import get_path_with_query_string + +authentication = FastAPI() +authentication.add_middleware(SessionMiddleware, secret_key=conf.SECRET_KEY) + +oauth = FastAPI() +oauth.add_middleware(AuthenticationMiddleware, backend=AuthBackend()) +oauth.add_middleware(SessionMiddleware, secret_key=conf.SECRET_KEY) + +templates = Jinja2Templates(directory='authentication/templates') + +class LoginForm(BaseModel): + username: EmailStr + password: str + +async def base_login(request: Request, form: LoginForm | None = None, errors: dict[str, str] | None = None): + return templates.TemplateResponse(request, 'login.html', context={ + 'form': form, + 'errors': errors, + }) + +@authentication.get('/login/', response_class=HTMLResponse) +async def login(request: Request): + return await base_login(request) + +@authentication.post('/login/', response_class=HTMLResponse) +async def login(request: Request, username: Annotated[str, Form()] = '', password: Annotated[str, Form()] = '', redirect: Annotated[str, Query()] = None): + form = None + errors = {} + + try: + form = LoginForm.model_validate({ + 'username': username, + 'password': password + }) + with get_session_context() as db_session: + user = db_session.query(User).filter(User.username == form.username).first() + + if not bcrypt_sha256_verify(password, user.password if user is not None else '') or user is None: + errors.update({'password': 'Invalid username or password'}) + + with get_session_context() as db_session: + session = Session(id=uuid7(), user=user) + db_session.add(session) + + if redirect is not None: + return RedirectResponse(redirect) + + except ValidationError as exc: + errors.update({ e['loc'][0]: e['msg'] for e in exc.errors() }) + + return await base_login(request, form, errors) + + +async def base_authorize(request: Request, client_id: str, redirect_uri: str): + with get_session_context() as session: + app = session.query(ClientApplication).where(ClientApplication.client_id == client_id).first() + + if app is None: + raise HTTPException(status_code=404, detail='Invalid client id') + + if not request.user.is_authenticated: + return RedirectResponse(f'/auth/login/?redirect={get_path_with_query_string(request)}') + + +@oauth.get('/authorize/') +async def authorize(request: Request, client_id: Annotated[str, Query()], redirect_uri: Annotated[str, Query()]): + return await base_authorize(request, client_id, redirect_uri) + +@oauth.post('/authorize/') +async def authorize(request: Request, client_id: Annotated[str, Query()], redirect_uri: Annotated[str, Query()]): + return await base_authorize(request, client_id, redirect_uri) diff --git a/authentication/templates/authorize.html b/authentication/templates/authorize.html new file mode 100644 index 0000000..1ed6506 --- /dev/null +++ b/authentication/templates/authorize.html @@ -0,0 +1,5 @@ +{% extends "./base.html" %} + +{% block content %} +

AUTH

+{% endblock %} diff --git a/authentication/templates/base.html b/authentication/templates/base.html new file mode 100644 index 0000000..47d007e --- /dev/null +++ b/authentication/templates/base.html @@ -0,0 +1,63 @@ + + + + + TTUN + + + +
+ +
+ {% block content %}{% endblock %} + + diff --git a/authentication/templates/login.html b/authentication/templates/login.html new file mode 100644 index 0000000..fcba5cd --- /dev/null +++ b/authentication/templates/login.html @@ -0,0 +1,18 @@ +{% extends "./base.html" %} + +{% block content %} +
+ + + {% if errors and errors.username %} + {{ errors.username }} + {% endif %} + + + + {% if errors and errors.password%} + {{ errors.password }} + {% endif %} + +
+{% endblock %} -- cgit v1.2.3