summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--authentication/backend.py48
-rw-r--r--authentication/endpoints.py87
-rw-r--r--authentication/templates/authorize.html5
-rw-r--r--authentication/templates/base.html63
-rw-r--r--authentication/templates/login.html18
-rw-r--r--conf/__init__.py1
-rw-r--r--conf/local.py1
-rw-r--r--db/migrations/versions/202607031440-69d8b4938a12_added_more_fields_and_session.py56
-rw-r--r--db/models.py20
-rw-r--r--db/session.py2
-rw-r--r--pyproject.toml4
-rw-r--r--ttun_server/__init__.py7
-rw-r--r--ttun_server/endpoints.py5
-rw-r--r--uv.lock79
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 @@
1from datetime import datetime, UTC
2from typing import Optional
3
4from sqlalchemy import update
5from sqlmodel import select
6from starlette.authentication import AuthenticationBackend, AuthCredentials, BaseUser, UnauthenticatedUser
7from starlette.requests import HTTPConnection
8
9from db.models import Session, User
10from db.session import get_session_context
11
12
13class 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 @@
1from typing import Annotated
2from uuid import uuid7
3
4from fastapi import FastAPI, Request, Form, Query, HTTPException
5from pydantic import BaseModel, EmailStr, ValidationError
6from starlette.middleware.authentication import AuthenticationMiddleware
7from starlette.middleware.sessions import SessionMiddleware
8from starlette.responses import HTMLResponse, RedirectResponse
9from starlette.templating import Jinja2Templates
10
11import conf
12from authentication.backend import AuthBackend
13from authentication.utils import bcrypt_sha256_verify
14from db.models import User, Session, ClientApplication
15from db.session import get_session_context
16from proxy.utils import get_path_with_query_string
17
18authentication = FastAPI()
19authentication.add_middleware(SessionMiddleware, secret_key=conf.SECRET_KEY)
20
21oauth = FastAPI()
22oauth.add_middleware(AuthenticationMiddleware, backend=AuthBackend())
23oauth.add_middleware(SessionMiddleware, secret_key=conf.SECRET_KEY)
24
25templates = Jinja2Templates(directory='authentication/templates')
26
27class LoginForm(BaseModel):
28 username: EmailStr
29 password: str
30
31async 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)
38async def login(request: Request):
39 return await base_login(request)
40
41@authentication.post('/login/', response_class=HTMLResponse)
42async 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
70async 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/')
82async 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/')
86async 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
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 %}
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" %}
2
3{% block content %}
4 <form method="post">
5