diff options
| author | 2026-07-03 15:49:08 +0200 | |
|---|---|---|
| committer | 2026-07-03 15:49:14 +0200 | |
| commit | 7784c60c03ec277b456db6e2709383e302d9382b (patch) | |
| tree | a008def666f04041fe77740693d42cfef89af558 | |
| parent | fb28c648390464c75ba4903b0e1e0ff2642c8ffb (diff) | |
| download | server-7784c60c03ec277b456db6e2709383e302d9382b.tar.gz server-7784c60c03ec277b456db6e2709383e302d9382b.tar.bz2 server-7784c60c03ec277b456db6e2709383e302d9382b.zip | |
Added basic oauth flow
| -rw-r--r-- | authentication/backend.py | 48 | ||||
| -rw-r--r-- | authentication/endpoints.py | 87 | ||||
| -rw-r--r-- | authentication/templates/authorize.html | 5 | ||||
| -rw-r--r-- | authentication/templates/base.html | 63 | ||||
| -rw-r--r-- | authentication/templates/login.html | 18 | ||||
| -rw-r--r-- | conf/__init__.py | 1 | ||||
| -rw-r--r-- | conf/local.py | 1 | ||||
| -rw-r--r-- | db/migrations/versions/202607031440-69d8b4938a12_added_more_fields_and_session.py | 56 | ||||
| -rw-r--r-- | db/models.py | 20 | ||||
| -rw-r--r-- | db/session.py | 2 | ||||
| -rw-r--r-- | pyproject.toml | 4 | ||||
| -rw-r--r-- | ttun_server/__init__.py | 7 | ||||
| -rw-r--r-- | ttun_server/endpoints.py | 5 | ||||
| -rw-r--r-- | uv.lock | 79 |
14 files changed, 387 insertions, 9 deletions
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 @@ | |||
| 1 | from datetime import datetime, UTC | ||
| 2 | from typing import Optional | ||
| 3 | |||
| 4 | from sqlalchemy import update | ||
| 5 | from sqlmodel import select | ||
| 6 | from starlette.authentication import AuthenticationBackend, AuthCredentials, BaseUser, UnauthenticatedUser | ||
| 7 | from starlette.requests import HTTPConnection | ||
| 8 | |||
| 9 | from db.models import Session, User | ||
| 10 | from db.session import get_session_context | ||
| 11 | |||
| 12 | |||
| 13 | class AuthBackend(AuthenticationBackend): | ||
| 14 | async def authenticate(self, request: HTTPConnection) -> Optional[tuple[AuthCredentials, BaseUser]]: | ||
| 15 | if "id" not in request.session: | ||
| 16 | return None | ||
| 17 | |||
| 18 | query = ( | ||
| 19 | select(Session) | ||
| 20 | .join(User) | ||
| 21 | .where( | ||
| 22 | Session.id == request.session['id'] | ||
| 23 | ) | ||
| 24 | ) | ||
| 25 | |||
| 26 | |||
| 27 | with get_session_context() as db_session: | ||
| 28 | session: Session = db_session.exec(query).first() | ||
| 29 | |||
| 30 | if session is not None: | ||
| 31 | db_session.execute( | ||
| 32 | update(Session).where(Session.id == session.id) | ||
| 33 | ) | ||
| 34 | db_session.commit() | ||
| 35 | |||
| 36 | return ( | ||
| 37 | AuthCredentials( | ||
| 38 | [ | ||
| 39 | "authenticated", | ||
| 40 | *[ | ||
| 41 | connection.type for connection in session.user.app_connections | ||
| 42 | ] | ||
| 43 | ] | ||
| 44 | if session is not None | ||
| 45 | else [] | ||
| 46 | ), | ||
| 47 | session.user if session is not None else UnauthenticatedUser() | ||
| 48 | ) | ||
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 @@ | |||
| 1 | from typing import Annotated | ||
| 2 | from uuid import uuid7 | ||
| 3 | |||
| 4 | from fastapi import FastAPI, Request, Form, Query, HTTPException | ||
| 5 | from pydantic import BaseModel, EmailStr, ValidationError | ||
| 6 | from starlette.middleware.authentication import AuthenticationMiddleware | ||
| 7 | from starlette.middleware.sessions import SessionMiddleware | ||
| 8 | from starlette.responses import HTMLResponse, RedirectResponse | ||
| 9 | from starlette.templating import Jinja2Templates | ||
| 10 | |||
| 11 | import conf | ||
| 12 | from authentication.backend import AuthBackend | ||
| 13 | from authentication.utils import bcrypt_sha256_verify | ||
| 14 | from db.models import User, Session, ClientApplication | ||
| 15 | from db.session import get_session_context | ||
| 16 | from proxy.utils import get_path_with_query_string | ||
| 17 | |||
| 18 | authentication = FastAPI() | ||
| 19 | authentication.add_middleware(SessionMiddleware, secret_key=conf.SECRET_KEY) | ||
| 20 | |||
| 21 | oauth = FastAPI() | ||
| 22 | oauth.add_middleware(AuthenticationMiddleware, backend=AuthBackend()) | ||
| 23 | oauth.add_middleware(SessionMiddleware, secret_key=conf.SECRET_KEY) | ||
| 24 | |||
| 25 | templates = Jinja2Templates(directory='authentication/templates') | ||
| 26 | |||
| 27 | class LoginForm(BaseModel): | ||
| 28 | username: EmailStr | ||
| 29 | password: str | ||
| 30 | |||
| 31 | async def base_login(request: Request, form: LoginForm | None = None, errors: dict[str, str] | None = None): | ||
| 32 | return templates.TemplateResponse(request, 'login.html', context={ | ||
| 33 | 'form': form, | ||
| 34 | 'errors': errors, | ||
| 35 | }) | ||
| 36 | |||
| 37 | @authentication.get('/login/', response_class=HTMLResponse) | ||
| 38 | async def login(request: Request): | ||
| 39 | return await base_login(request) | ||
| 40 | |||
| 41 | @authentication.post('/login/', response_class=HTMLResponse) | ||
| 42 | async def login(request: Request, username: Annotated[str, Form()] = '', password: Annotated[str, Form()] = '', redirect: Annotated[str, Query()] = None): | ||
| 43 | form = None | ||
| 44 | errors = {} | ||
| 45 | |||
| 46 | try: | ||
| 47 | form = LoginForm.model_validate({ | ||
| 48 | 'username': username, | ||
| 49 | 'password': password | ||
| 50 | }) | ||
| 51 | with get_session_context() as db_session: | ||
| 52 | user = db_session.query(User).filter(User.username == form.username).first() | ||
| 53 | |||
| 54 | if not bcrypt_sha256_verify(password, user.password if user is not None else '') or user is None: | ||
| 55 | errors.update({'password': 'Invalid username or password'}) | ||
| 56 | |||
| 57 | with get_session_context() as db_session: | ||
| 58 | session = Session(id=uuid7(), user=user) | ||
| 59 | db_session.add(session) | ||
| 60 | |||
| 61 | if redirect is not None: | ||
| 62 | return RedirectResponse(redirect) | ||
| 63 | |||
| 64 | except ValidationError as exc: | ||
| 65 | errors.update({ e['loc'][0]: e['msg'] for e in exc.errors() }) | ||
| 66 | |||
| 67 | return await base_login(request, form, errors) | ||
| 68 | |||
| 69 | |||
| 70 | async def base_authorize(request: Request, client_id: str, redirect_uri: str): | ||
| 71 | with get_session_context() as session: | ||
| 72 | app = session.query(ClientApplication).where(ClientApplication.client_id == client_id).first() | ||
| 73 | |||
| 74 | if app is None: | ||
| 75 | raise HTTPException(status_code=404, detail='Invalid client id') | ||
| 76 | |||
| 77 | if not request.user.is_authenticated: | ||
| 78 | return RedirectResponse(f'/auth/login/?redirect={get_path_with_query_string(request)}') | ||
| 79 | |||
| 80 | |||
| 81 | @oauth.get('/authorize/') | ||
| 82 | async def authorize(request: Request, client_id: Annotated[str, Query()], redirect_uri: Annotated[str, Query()]): | ||
| 83 | return await base_authorize(request, client_id, redirect_uri) | ||
| 84 | |||
| 85 | @oauth.post('/authorize/') | ||
| 86 | async def authorize(request: Request, client_id: Annotated[str, Query()], redirect_uri: Annotated[str, Query()]): | ||
| 87 | 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 @@ | |||
| 1 | {% extends "./base.html" %} | ||
| 2 | |||
| 3 | {% block content %} | ||
| 4 | <h1>AUTH</h1> | ||
| 5 | {% 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 @@ | |||
| 1 | <!DOCTYPE html> | ||
| 2 | <html lang="en"> | ||
| 3 | <head> | ||
| 4 | <meta charset="UTF-8"> | ||
| 5 | <title>TTUN</title> | ||
| 6 | <style> | ||
| 7 | /* http://meyerweb.com/eric/tools/css/reset/ | ||
| 8 | v2.0 | 20110126 | ||
| 9 | License: none (public domain) | ||
| 10 | */ | ||
| 11 | |||
| 12 | html, body, div, span, applet, object, iframe, | ||
| 13 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, | ||
| 14 | a, abbr, acronym, address, big, cite, code, | ||
| 15 | del, dfn, em, img, ins, kbd, q, s, samp, | ||
| 16 | small, strike, strong, sub, sup, tt, var, | ||
| 17 | b, u, i, center, | ||
| 18 | dl, dt, dd, ol, ul, li, | ||
| 19 | fieldset, form, label, legend, | ||
| 20 | table, caption, tbody, tfoot, thead, tr, th, td, | ||
| 21 | article, aside, canvas, details, embed, | ||
| 22 | figure, figcaption, footer, header, hgroup, | ||
| 23 | menu, nav, output, ruby, section, summary, | ||
| 24 | time, mark, audio, video { | ||
| 25 | margin: 0; | ||
| 26 | padding: 0; | ||
| 27 | border: 0; | ||
| 28 | font-size: 100%; | ||
| 29 | font: inherit; | ||
| 30 | vertical-align: baseline; | ||
| 31 | } | ||
| 32 | /* HTML5 display-role reset for older browsers */ | ||
| 33 | article, aside, details, figcaption, figure, | ||
| 34 | footer, header, hgroup, menu, nav, section { | ||
| 35 | display: block; | ||
| 36 | } | ||
| 37 | body { | ||
| 38 | line-height: 1; | ||
| 39 | } | ||
| 40 | ol, ul { | ||
| 41 | list-style: none; | ||
| 42 | } | ||
| 43 | blockquote, q { | ||
| 44 | quotes: none; | ||
| 45 | } | ||
| 46 | blockquote:before, blockquote:after, | ||
| 47 | q:before, q:after { | ||
| 48 | content: ''; | ||
| 49 | content: none; | ||
| 50 | } | ||
| 51 | table { | ||
| 52 | border-collapse: collapse; | ||
| 53 | border-spacing: 0; | ||
| 54 | } | ||
| 55 | </style> | ||
| 56 | </head> | ||
| 57 | <body> | ||
| 58 | <div> | ||
| 59 | |||
| 60 | </div> | ||
| 61 | {% block content %}{% endblock %} | ||
| 62 | </body> | ||
| 63 | </html> | ||
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 @@ | |||
| 1 | {% extends "./base.html" %} | ||
