aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Tom van der Lee <tom@vanderlee.io>2026-07-22 17:37:10 +0200
committerGravatar Tom van der Lee <tom@vanderlee.io>2026-07-22 17:37:10 +0200
commitf28781dd1dd718f45bf1cef82d1ce6eb4792b4ca (patch)
treeed5bd923bca11cc75e0568245ea3518a6713041b
parent498c79f434856aa68e9a883247ea69a22256fce6 (diff)
downloadserver-f28781dd1dd718f45bf1cef82d1ce6eb4792b4ca.tar.gz
server-f28781dd1dd718f45bf1cef82d1ce6eb4792b4ca.tar.bz2
server-f28781dd1dd718f45bf1cef82d1ce6eb4792b4ca.zip
Added management interface
-rw-r--r--authentication/endpoints.py99
-rw-r--r--authentication/templates/base.html65
-rw-r--r--authentication/templates/register.html24
-rw-r--r--management/__init__.py0
-rw-r--r--management/endpoints.py50
-rw-r--r--management/templates/base.html7
-rw-r--r--management/templates/dashboard.html9
-rw-r--r--management/templates/users.html11
-rw-r--r--templates/global.html60
-rw-r--r--ttun_server/__init__.py4
10 files changed, 254 insertions, 75 deletions
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 @@
1import json 1import json
2from base64 import b64encode 2from base64 import b64encode
3from functools import partial 3from functools import partial
4from typing import Annotated 4from typing import Annotated, Self
5from uuid import uuid7 5from uuid import uuid7
6 6
7from alembic.testing import requirements
8from fastapi import FastAPI, Request, Form, Query 7from fastapi import FastAPI, Request, Form, Query
9from pydantic import BaseModel, ValidationError 8from pydantic import BaseModel, ValidationError, model_validator, EmailStr
10from sqlmodel import select 9from sqlmodel import select
11from starlette.authentication import requires 10from starlette.authentication import requires
12from starlette.middleware.authentication import AuthenticationMiddleware 11from starlette.middleware.authentication import AuthenticationMiddleware
@@ -31,24 +30,32 @@ oauth.add_middleware(SessionMiddleware, secret_key=conf.SECRET_KEY)
31auth_server = AuthorizationServer() 30auth_server = AuthorizationServer()
32code_grant = partial(AuthorizationCodeGrant, server=auth_server) 31code_grant = partial(AuthorizationCodeGrant, server=auth_server)
33 32
34templates = Jinja2Templates(directory='authentication/templates') 33templates = Jinja2Templates(directory=[
34 'templates',
35 'authentication/templates'
36])
37
35 38
36class LoginForm(BaseModel): 39class LoginForm(BaseModel):
37 username: str 40 username: str
38 password: str 41 password: str
39 42
43
40async def base_login(request: Request, form: LoginForm | None = None, errors: dict[str, str] | None = None): 44async def base_login(request: Request, form: LoginForm | None = None, errors: dict[str, str] | None = None):
41 return templates.TemplateResponse(request, 'login.html', context={ 45 return templates.TemplateResponse(request, 'login.html', context={
42 'form': form, 46 'form': form,
43 'errors': errors, 47 'errors': errors,
44 }) 48 })
45 49
50
46@authentication.get('/login/', response_class=HTMLResponse) 51@authentication.get('/login/', response_class=HTMLResponse)
47async def get_login(request: Request, next: Annotated[str, Query()] = None): 52async def get_login(request: Request, next: Annotated[str, Query()] = None):
48 return await base_login(request) 53 return await base_login(request)
49 54
55
50@authentication.post('/login/', response_class=HTMLResponse) 56@authentication.post('/login/', response_class=HTMLResponse)
51async def post_login(request: Request, username: Annotated[str, Form()] = '', password: Annotated[str, Form()] = '', next: Annotated[str, Query()] = None): 57async def post_login(request: Request, username: Annotated[str, Form()] = '', password: Annotated[str, Form()] = '',
58 next: Annotated[str, Query()] = None):
52 form = None 59 form = None
53 errors = {} 60 errors = {}
54 61
@@ -70,35 +77,101 @@ async def post_login(request: Request, username: Annotated[str, Form()] = '', pa
70 request.session.update({'id': str(session.id)}) 77 request.session.update({'id': str(session.id)})
71 78
72 if next is not None: 79 if next is not None:
73 return RedirectResponse(next) 80 return RedirectResponse(next, status_code=303)
74 81
75 except ValidationError as exc: 82 except ValidationError as exc:
76 errors.update({ e['loc'][0]: e['msg'] for e in exc.errors() }) 83 errors.update({e['loc'][0]: e['msg'] for e in exc.errors()})
77 84
78 return await base_login(request, form, errors) 85 return await base_login(request, form, errors)
79 86
80async def base_authorize(request: Request, user: User | None = None): 87
81 if not conf.IS_LOCAL: 88@authentication.get('/logout/')
89def logout(request: Request):
90 with get_session_context() as db_session:
91 db_session.query(Session).filter(Session.id == request.session.get('id')).delete()
92
93 request.session.clear()
94 return RedirectResponse(request.headers.get('Referer', '/management/dashboard/'), status_code=303)
95
96
97class RegisterForm(BaseModel):
98 email: EmailStr
99 password: str
100 verify_password: str
101
102 @model_validator(mode='after')
103 def check_passwords_match(self) -> Self:
104 if self.password != self.verify_password:
105 raise ValueError('Passwords do not match')
106 return self
107
108
109async def base_register(request: Request, form: RegisterForm | None = None, errors: dict[str, str] | None = None):
110 return templates.TemplateResponse(request, 'register.html', context={
111 'form': form,
112 'errors': errors,
113 })
114
115
116@authentication.get('/register/', response_class=HTMLResponse)
117async def get_register(request: Request, next: Annotated[str, Query()] = None):
118 return await base_register(request)
119
120
121@authentication.post('/register/', response_class=HTMLResponse)
122async def post_register(request: Request, email: Annotated[str, Form()] = '', password: Annotated[str, Form()] = '',
123 verify_password: Annotated[str, Form()] = '', next: Annotated[str, Query()] = None):
124 form = None
125 errors = {}
126
127 try:
128 form = RegisterForm.model_validate({
129 'email': email,
130 'password': password,
131 'verify_password': verify_password
132 })
133
82 with get_session_context() as db_session: 134 with get_session_context() as db_session:
83 db_session.query(Session).filter(Session.id == request.session.get('id')).delete() 135 user = User(username=email, email=email)
136 user.set_password(password)
137 db_session.add(user)
138
139 session = Session(id=uuid7(), user=user)
140 db_session.add(session)
141
142 request.session.update({'id': str(session.id)})
143
144 if next is not None:
145 return RedirectResponse(next, status_code=303)
84 146
147 except ValidationError as exc:
148 errors.update({e['loc'][0]: e['msg'] for e in exc.errors()})
149
150 return await base_register(request, form, errors)
151
152
153async def base_authorize(request: Request, user: User | None = None):
85 return auth_server.create_authorization_response(request, request.user, grant=None) 154 return auth_server.create_authorization_response(request, request.user, grant=None)
86 155
156
87@oauth.get('/authorize/') 157@oauth.get('/authorize/')
88@requires(['authenticated', 'session'], redirect='get_login') 158@requires(['authenticated', 'session'], redirect='get_login')
89async def get_authorize(request: Request): 159async def get_authorize(request: Request):
90 return await base_authorize(request, request.user) 160 return await base_authorize(request, request.user)
91 161
162
92@oauth.post('/authorize/') 163@oauth.post('/authorize/')
93@requires(['authenticated', 'session'], redirect='get_login') 164@requires(['authenticated', 'session'], redirect='get_login')
94async def post_authorize(request: Request): 165async def post_authorize(request: Request):
95 return await base_authorize(request, request.user) 166 return await base_authorize(request, request.user)
96 167
168
97@oauth.post('/token/') 169@oauth.post('/token/')
98async def post_token(request: Request): 170async def post_token(request: Request):
99 await prepare_oauth_request(request) 171 await prepare_oauth_request(request)
100 return auth_server.create_token_response(request) 172 return auth_server.create_token_response(request)
101 173
174
102@authentication.get('/connect/') 175@authentication.get('/connect/')
103async def connect(request: Request): 176async def connect(request: Request):
104 origin = request.url.scheme + '://' + request.url.netloc 177 origin = request.url.scheme + '://' + request.url.netloc
@@ -107,16 +180,18 @@ async def connect(request: Request):
107 app = session.exec(select(ClientApplication)).first() 180 app = session.exec(select(ClientApplication)).first()
108 181
109 return RedirectResponse( 182 return RedirectResponse(
110 '/oauth' + get_url_path_with_query(oauth, 'get_authorize', { 183 '/oauth' + get_url_path_with_query(oauth, 'get_authorize', {
111 'client_id': app.client_id, 184 'client_id': app.client_id,
112 'redirect_uri': f'{origin}/oauth' + get_url_path_with_query(oauth, 'callback'), 185 'redirect_uri': f'{origin}/oauth' + get_url_path_with_query(oauth, 'callback'),
113 'response_type': 'code', 186 'response_type': 'code',
114 'state': b64encode(json.dumps({ 187 'state': b64encode(json.dumps({
115 'client_id': app.client_id, 188 'client_id': app.client_id,
116 }).encode()).decode(), 189 }).encode()).decode(),
117 }) 190 }),
191 status_code=303,
118 ) 192 )
119 193
194
120@oauth.get('/config/') 195@oauth.get('/config/')
121async def get_config(request: Request): 196async def get_config(request: Request):
122 with get_session_context() as session: 197 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 @@
1<!DOCTYPE html> 1{% extends 'global.html' %}
2<html lang="en"> 2{% block template %}
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
12html, body, div, span, applet, object, iframe,
13h1, h2, h3, h4, h5, h6, p, blockquote, pre,
14a, abbr, acronym, address, big, cite, code,
15del, dfn, em, img, ins, kbd, q, s, samp,
16small, strike, strong, sub, sup, tt, var,
17b, u, i, center,
18dl, dt, dd, ol, ul, li,
19fieldset, form, label, legend,
20table, caption, tbody, tfoot, thead, tr, th, td,
21article, aside, canvas, details, embed,
22figure, figcaption, footer, header, hgroup,
23menu, nav, output, ruby, section, summary,
24time, 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 */
33article, aside, details, figcaption, figure,
34footer, header, hgroup, menu, nav, section {
35 display: block;
36}
37body {
38 line-height: 1;
39}
40ol, ul {
41 list-style: none;
42}
43blockquote, q {
44 quotes: none;
45}
46blockquote:before, blockquote:after,
47q:before, q:after {
48 content: '';
49 content: none;
50}
51table {
52 border-collapse: collapse;
53 border-spacing: 0;
54}
55 </style>
56</head>
57<body>
58 <div>
59
60 </div>
61 {% block content %}{% endblock %} 3 {% block content %}{% endblock %}
62</body> 4{% endblock %}
63</html>
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 @@
1{% extends "./base.html" %}
2
3{% block content %}
4 <form method="post">
5
6 <input type="email" name="email" {% if form %}value="{{ form.email }}"{% endif %} placeholder="email" />
7 {% if errors and errors.email%}
8 {{ errors.email }}
9 {% endif %}
10
11
12 <input type="password" name="password" />
13 {% if errors and errors.password%}
14 {{ errors.password }}
15 {% endif %}
16
17 <input type="password" name="verify_password" />
18 {% if errors and errors.password%}
19 {{ errors.verify_password }}
20 {% endif %}
21
22 <button type="submit">Register</button>
23 </form>
24{% endblock %}
diff --git a/management/__init__.py b/management/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/management/__init__.py
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 @@
1from fastapi import FastAPI, Request
2from sqlalchemy import func
3from sqlmodel import select
4from starlette.authentication import requires
5from starlette.middleware.authentication import AuthenticationMiddleware
6from starlette.middleware.sessions import SessionMiddleware
7from starlette.responses import RedirectResponse
8from starlette.templating import Jinja2Templates
9
10import conf
11from authentication.backend import SessionAuthBackend
12from db.models import User
13from db.session import get_session_context
14
15management = FastAPI()
16management.add_middleware(AuthenticationMiddleware, backend=SessionAuthBackend())
17management.add_middleware(SessionMiddleware, secret_key=conf.SECRET_KEY)
18
19templates = Jinja2Templates(directory=[
20 'templates',
21 'management/templates'
22])
23
24@management.get('/')
25async def index(request: Request):
26 with get_session_context() as session:
27 query = select(func.count(User.id))
28 user_count = session.exec(query)
29
30 if user_count.first() == 0:
31 return RedirectResponse('/auth/register/?next=/management/dashboard/', status_code=303)
32
33 return RedirectResponse('/management/dashboard/', status_code=303)
34
35
36@management.get('/dashboard/')
37@requires(['authenticated', 'session'], redirect='get_login')
38async def dashboard(request: Request):
39 return templates.TemplateResponse(request, 'dashboard.html')
40
41@management.get('/users/')
42@requires(['authenticated', 'session'], redirect='get_login')
43async def users(request: Request):
44 with get_session_context() as session:
45 query = select(User)
46 users = session.exec(query).all()
47
48 return templates.TemplateResponse(request, 'users.html', context={
49 'users': users
50 })
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 @@
1{% extends 'global.html' %}
2{% block template %}
3 <nav>
4 <a href="/auth/logout/">Logout</a>
5 </nav>
6 {% block content %}{% endblock %}
7{% 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 @@
1{% extends 'base.html' %}
2
3{% block content %}
4<div>
5 <ul>
6 <li><a href="/management/users/">Users</a></li>
7 </ul>
8</div>
9{% 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 @@
1{% extends 'base.html' %}
2
3{% block content %}
4<div>
5 <ul>
6 {% for user in users %}
7 <li>{{ user.username }}</li>
8 {% endfor %}
9 </ul>
10</div>
11{% 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 @@
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
12html, body, div, span, applet, object, iframe,
13h1, h2, h3, h4, h5, h6, p, blockquote, pre,
14a, abbr, acronym, address, big, cite, code,
15del, dfn, em, img, ins, kbd, q, s, samp,
16small, strike, strong, sub, sup, tt, var,
17b, u, i, center,
18dl, dt, dd, ol, ul, li,
19fieldset, form, label, legend,
20table, caption, tbody, tfoot, thead, tr, th, td,
21article, aside, canvas, details, embed,
22figure, figcaption, footer, header, hgroup,
23menu, nav, output, ruby, section, summary,
24time, 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 */
33article, aside, details, figcaption, figure,
34footer, header, hgroup, menu, nav, section {
35 display: block;
36}
37body {
38 line-height: 1;
39}
40ol, ul {
41 list-style: none;
42}
43blockquote, q {
44 quotes: none;
45}
46blockquote:before, blockquote:after,
47q:before, q:after {
48 content: '';
49 content: none;
50}
51table {
52 border-collapse: collapse;
53 border-spacing: 0;
54}
55 </style>
56</head>
57<body>
58 {% block template %}{% endblock %}
59</body>
60</html>
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
7 7
8from authentication.backend import BearerTokenAuthBackend 8from authentication.backend import BearerTokenAuthBackend
9from authentication.endpoints import authentication, oauth 9from authentication.endpoints import authentication, oauth
10from management.endpoints import management
10from proxy import app as proxy_app 11from proxy import app as proxy_app
11from ttun_server.endpoints import health 12from ttun_server.endpoints import health
12from ttun_server.websockets import tunnel 13from ttun_server.websockets import tunnel
@@ -16,6 +17,7 @@ logging.basicConfig(level=getattr(logging, os.environ.get('LOG_LEVEL', 'INFO')))
16app = FastAPI( 17app = FastAPI(
17 debug=True, 18 debug=True,
18 routes=[ 19 routes=[
20 Mount('/management/', app=management),
19 Mount('/auth/', app=authentication), 21 Mount('/auth/', app=authentication),
20 Mount('/oauth/', app=oauth), 22 Mount('/oauth/', app=oauth),
21 WebSocketRoute( 23 WebSocketRoute(
@@ -24,7 +26,7 @@ app = FastAPI(
24 ), 26 ),
25 Route('/health/', endpoint=health), 27 Route('/health/', endpoint=health),
26 Host(f'{{subdomain}}.{os.environ['TUNNEL_DOMAIN']}', app=proxy_app) 28 Host(f'{{subdomain}}.{os.environ['TUNNEL_DOMAIN']}', app=proxy_app)
27 ] 29 ],
28) 30)
29 31
30app.add_middleware(AuthenticationMiddleware, backend=BearerTokenAuthBackend()) 32app.add_middleware(AuthenticationMiddleware, backend=BearerTokenAuthBackend())