From f28781dd1dd718f45bf1cef82d1ce6eb4792b4ca Mon Sep 17 00:00:00 2001 From: Tom van der Lee Date: Wed, 22 Jul 2026 17:37:10 +0200 Subject: Added management interface --- authentication/endpoints.py | 99 +++++++++++++++++++++++++++++----- authentication/templates/base.html | 65 ++-------------------- authentication/templates/register.html | 24 +++++++++ management/__init__.py | 0 management/endpoints.py | 50 +++++++++++++++++ management/templates/base.html | 7 +++ management/templates/dashboard.html | 9 ++++ management/templates/users.html | 11 ++++ templates/global.html | 60 +++++++++++++++++++++ ttun_server/__init__.py | 4 +- 10 files changed, 254 insertions(+), 75 deletions(-) create mode 100644 authentication/templates/register.html create mode 100644 management/__init__.py create mode 100644 management/endpoints.py create mode 100644 management/templates/base.html create mode 100644 management/templates/dashboard.html create mode 100644 management/templates/users.html create mode 100644 templates/global.html diff --git a/authentication/endpoints.py b/authentication/endpoints.py index f94d55b..18f3ba6 100644 --- a/authentication/endpoints.py +++ b/authentication/endpoints.py @@ -1,12 +1,11 @@ import json from base64 import b64encode from functools import partial -from typing import Annotated +from typing import Annotated, Self from uuid import uuid7 -from alembic.testing import requirements from fastapi import FastAPI, Request, Form, Query -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel, ValidationError, model_validator, EmailStr from sqlmodel import select from starlette.authentication import requires from starlette.middleware.authentication import AuthenticationMiddleware @@ -31,24 +30,32 @@ oauth.add_middleware(SessionMiddleware, secret_key=conf.SECRET_KEY) auth_server = AuthorizationServer() code_grant = partial(AuthorizationCodeGrant, server=auth_server) -templates = Jinja2Templates(directory='authentication/templates') +templates = Jinja2Templates(directory=[ + 'templates', + 'authentication/templates' +]) + class LoginForm(BaseModel): username: str 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 get_login(request: Request, next: Annotated[str, Query()] = None): return await base_login(request) + @authentication.post('/login/', response_class=HTMLResponse) -async def post_login(request: Request, username: Annotated[str, Form()] = '', password: Annotated[str, Form()] = '', next: Annotated[str, Query()] = None): +async def post_login(request: Request, username: Annotated[str, Form()] = '', password: Annotated[str, Form()] = '', + next: Annotated[str, Query()] = None): form = None errors = {} @@ -70,35 +77,101 @@ async def post_login(request: Request, username: Annotated[str, Form()] = '', pa request.session.update({'id': str(session.id)}) if next is not None: - return RedirectResponse(next) + return RedirectResponse(next, status_code=303) except ValidationError as exc: - errors.update({ e['loc'][0]: e['msg'] for e in exc.errors() }) + 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, user: User | None = None): - if not conf.IS_LOCAL: + +@authentication.get('/logout/') +def logout(request: Request): + with get_session_context() as db_session: + db_session.query(Session).filter(Session.id == request.session.get('id')).delete() + + request.session.clear() + return RedirectResponse(request.headers.get('Referer', '/management/dashboard/'), status_code=303) + + +class RegisterForm(BaseModel): + email: EmailStr + password: str + verify_password: str + + @model_validator(mode='after') + def check_passwords_match(self) -> Self: + if self.password != self.verify_password: + raise ValueError('Passwords do not match') + return self + + +async def base_register(request: Request, form: RegisterForm | None = None, errors: dict[str, str] | None = None): + return templates.TemplateResponse(request, 'register.html', context={ + 'form': form, + 'errors': errors, + }) + + +@authentication.get('/register/', response_class=HTMLResponse) +async def get_register(request: Request, next: Annotated[str, Query()] = None): + return await base_register(request) + + +@authentication.post('/register/', response_class=HTMLResponse) +async def post_register(request: Request, email: Annotated[str, Form()] = '', password: Annotated[str, Form()] = '', + verify_password: Annotated[str, Form()] = '', next: Annotated[str, Query()] = None): + form = None + errors = {} + + try: + form = RegisterForm.model_validate({ + 'email': email, + 'password': password, + 'verify_password': verify_password + }) + with get_session_context() as db_session: - db_session.query(Session).filter(Session.id == request.session.get('id')).delete() + user = User(username=email, email=email) + user.set_password(password) + db_session.add(user) + + session = Session(id=uuid7(), user=user) + db_session.add(session) + + request.session.update({'id': str(session.id)}) + + if next is not None: + return RedirectResponse(next, status_code=303) + except ValidationError as exc: + errors.update({e['loc'][0]: e['msg'] for e in exc.errors()}) + + return await base_register(request, form, errors) + + +async def base_authorize(request: Request, user: User | None = None): return auth_server.create_authorization_response(request, request.user, grant=None) + @oauth.get('/authorize/') @requires(['authenticated', 'session'], redirect='get_login') async def get_authorize(request: Request): return await base_authorize(request, request.user) + @oauth.post('/authorize/') @requires(['authenticated', 'session'], redirect='get_login') async def post_authorize(request: Request): return await base_authorize(request, request.user) + @oauth.post('/token/') async def post_token(request: Request): await prepare_oauth_request(request) return auth_server.create_token_response(request) + @authentication.get('/connect/') async def connect(request: Request): origin = request.url.scheme + '://' + request.url.netloc @@ -107,16 +180,18 @@ async def connect(request: Request): app = session.exec(select(ClientApplication)).first() return RedirectResponse( - '/oauth' + get_url_path_with_query(oauth, 'get_authorize', { + '/oauth' + get_url_path_with_query(oauth, 'get_authorize', { 'client_id': app.client_id, 'redirect_uri': f'{origin}/oauth' + get_url_path_with_query(oauth, 'callback'), 'response_type': 'code', 'state': b64encode(json.dumps({ 'client_id': app.client_id, }).encode()).decode(), - }) + }), + status_code=303, ) + @oauth.get('/config/') async def get_config(request: Request): with get_session_context() as session: diff --git a/authentication/templates/base.html b/authentication/templates/base.html index 47d007e..9874313 100644 --- a/authentication/templates/base.html +++ b/authentication/templates/base.html @@ -1,63 +1,4 @@ - - - - - TTUN - - - -
- -
+{% extends 'global.html' %} +{% block template %} {% block content %}{% endblock %} - - +{% endblock %} diff --git a/authentication/templates/register.html b/authentication/templates/register.html new file mode 100644 index 0000000..576db78 --- /dev/null +++ b/authentication/templates/register.html @@ -0,0 +1,24 @@ +{% extends "./base.html" %} + +{% block content %} +
+ + + {% if errors and errors.email%} + {{ errors.email }} + {% endif %} + + + + {% if errors and errors.password%} + {{ errors.password }} + {% endif %} + + + {% if errors and errors.password%} + {{ errors.verify_password }} + {% endif %} + + +
+{% endblock %} diff --git a/management/__init__.py b/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/management/endpoints.py b/management/endpoints.py new file mode 100644 index 0000000..47602d6 --- /dev/null +++ b/management/endpoints.py @@ -0,0 +1,50 @@ +from fastapi import FastAPI, Request +from sqlalchemy import func +from sqlmodel import select +from starlette.authentication import requires +from starlette.middleware.authentication import AuthenticationMiddleware +from starlette.middleware.sessions import SessionMiddleware +from starlette.responses import RedirectResponse +from starlette.templating import Jinja2Templates + +import conf +from authentication.backend import SessionAuthBackend +from db.models import User +from db.session import get_session_context + +management = FastAPI() +management.add_middleware(AuthenticationMiddleware, backend=SessionAuthBackend()) +management.add_middleware(SessionMiddleware, secret_key=conf.SECRET_KEY) + +templates = Jinja2Templates(directory=[ + 'templates', + 'management/templates' +]) + +@management.get('/') +async def index(request: Request): + with get_session_context() as session: + query = select(func.count(User.id)) + user_count = session.exec(query) + + if user_count.first() == 0: + return RedirectResponse('/auth/register/?next=/management/dashboard/', status_code=303) + + return RedirectResponse('/management/dashboard/', status_code=303) + + +@management.get('/dashboard/') +@requires(['authenticated', 'session'], redirect='get_login') +async def dashboard(request: Request): + return templates.TemplateResponse(request, 'dashboard.html') + +@management.get('/users/') +@requires(['authenticated', 'session'], redirect='get_login') +async def users(request: Request): + with get_session_context() as session: + query = select(User) + users = session.exec(query).all() + + return templates.TemplateResponse(request, 'users.html', context={ + 'users': users + }) diff --git a/management/templates/base.html b/management/templates/base.html new file mode 100644 index 0000000..0b9da11 --- /dev/null +++ b/management/templates/base.html @@ -0,0 +1,7 @@ +{% extends 'global.html' %} +{% block template %} + + {% block content %}{% endblock %} +{% endblock %} diff --git a/management/templates/dashboard.html b/management/templates/dashboard.html new file mode 100644 index 0000000..201aa48 --- /dev/null +++ b/management/templates/dashboard.html @@ -0,0 +1,9 @@ +{% extends 'base.html' %} + +{% block content %} +
+ +
+{% endblock %} diff --git a/management/templates/users.html b/management/templates/users.html new file mode 100644 index 0000000..272d2b4 --- /dev/null +++ b/management/templates/users.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} + +{% block content %} +
+ +
+{% endblock %} diff --git a/templates/global.html b/templates/global.html new file mode 100644 index 0000000..d9bd29d --- /dev/null +++ b/templates/global.html @@ -0,0 +1,60 @@ + + + + + TTUN + + + + {% block template %}{% endblock %} + + diff --git a/ttun_server/__init__.py b/ttun_server/__init__.py index f5d6b45..388dd9e 100644 --- a/ttun_server/__init__.py +++ b/ttun_server/__init__.py @@ -7,6 +7,7 @@ from starlette.routing import Host, Route, WebSocketRoute, Mount from authentication.backend import BearerTokenAuthBackend from authentication.endpoints import authentication, oauth +from management.endpoints import management from proxy import app as proxy_app from ttun_server.endpoints import health from ttun_server.websockets import tunnel @@ -16,6 +17,7 @@ logging.basicConfig(level=getattr(logging, os.environ.get('LOG_LEVEL', 'INFO'))) app = FastAPI( debug=True, routes=[ + Mount('/management/', app=management), Mount('/auth/', app=authentication), Mount('/oauth/', app=oauth), WebSocketRoute( @@ -24,7 +26,7 @@ app = FastAPI( ), Route('/health/', endpoint=health), Host(f'{{subdomain}}.{os.environ['TUNNEL_DOMAIN']}', app=proxy_app) - ] + ], ) app.add_middleware(AuthenticationMiddleware, backend=BearerTokenAuthBackend()) -- cgit v1.2.3