aboutsummaryrefslogtreecommitdiffstats
path: root/authentication
diff options
context:
space:
mode:
authorGravatar Tom van der Lee <tom@vanderlee.io>2026-07-22 14:51:44 +0200
committerGravatar Tom van der Lee <tom@vanderlee.io>2026-07-22 14:51:44 +0200
commit498c79f434856aa68e9a883247ea69a22256fce6 (patch)
tree4561647b7f8592f783b4dc07143c60730393b4a1 /authentication
parent7784c60c03ec277b456db6e2709383e302d9382b (diff)
downloadserver-498c79f434856aa68e9a883247ea69a22256fce6.tar.gz
server-498c79f434856aa68e9a883247ea69a22256fce6.tar.bz2
server-498c79f434856aa68e9a883247ea69a22256fce6.zip
Added auth layer
Diffstat (limited to 'authentication')
-rw-r--r--authentication/authlib.py167
-rw-r--r--authentication/backend.py40
-rw-r--r--authentication/endpoints.py88
-rw-r--r--authentication/mixins.py54
-rw-r--r--authentication/templates/authorize.html5
-rw-r--r--authentication/templates/login.html2
-rw-r--r--authentication/utils.py10
7 files changed, 327 insertions, 39 deletions
diff --git a/authentication/authlib.py b/authentication/authlib.py
new file mode 100644
index 0000000..8472062
--- /dev/null
+++ b/authentication/authlib.py
@@ -0,0 +1,167 @@
1import json
2from collections import defaultdict
3from unittest.mock import patch
4
5from authlib.common.security import generate_token as generate_random_token
6from authlib.oauth2 import AuthorizationServer as BaseAuthorizationServer, OAuth2Request
7from authlib.oauth2.rfc6749 import AuthorizationCodeGrant as BaseAuthorizationCodeGrant, \
8 RefreshTokenGrant as BaseRefreshTokenGrant, OAuth2Payload
9from authlib.oauth2.rfc6750 import BearerTokenGenerator
10from authlib.oauth2.rfc7636 import CodeChallenge
11from sqlalchemy.exc import NoResultFound
12
13from sqlmodel import select
14from fastapi import Request, Response
15
16from db.models import ClientApplication, AuthToken, AuthCode, User
17from db.session import get_session_context
18
19
20class AuthorizationCodeGrant(BaseAuthorizationCodeGrant):
21 TOKEN_ENDPOINT_AUTH_METHODS = ['client_secret_basic', 'client_secret_post', 'none']
22
23 def save_authorization_code(self, code: str, request: OAuth2Request):
24 with get_session_context() as db_session:
25 db_session.add(request.user)
26
27 code_challenge = request.payload.data.get('code_challenge')
28 code_challenge_method = request.payload.data.get('code_challenge_method')
29
30 auth_code = AuthCode(
31 code=code,
32 client_id=request.client.client_id,
33 redirect_uri=request.payload.redirect_uri,
34 response_type=request.payload.response_type,
35 scope=request.payload.scope,
36 user_id=request.user.id,
37 code_challenge=code_challenge,
38 code_challenge_method=code_challenge_method,
39 )
40 db_session.add(auth_code)
41
42 return auth_code
43
44 def query_authorization_code(self, code: str, client: ClientApplication) -> AuthCode | None:
45 try:
46 with get_session_context() as db_session:
47 auth_code = db_session.exec(select(AuthCode).where(AuthCode.code == code, AuthCode.client_id == client.client_id)).one()
48 except NoResultFound:
49 return None
50
51 if auth_code.is_expired():
52 return None
53
54 return auth_code
55
56 def delete_authorization_code(self, code: AuthCode):
57 with get_session_context() as db_session:
58 db_session.delete(code)
59
60 def authenticate_user(self, authentication_code: AuthCode) -> User:
61 return authentication_code.user
62
63
64class RefreshTokenGrant(BaseRefreshTokenGrant):
65 def authenticate_refresh_token(self, refresh_token) -> AuthToken | None:
66 try:
67 with get_session_context() as db_session:
68 token = db_session.exec(select(AuthToken).where(AuthToken.refresh_token == refresh_token)).one()
69 except NoResultFound:
70 return None
71
72 if not token.is_refresh_token_active():
73 return None
74
75 return token
76
77 def authenticate_user(self, credential: AuthToken):
78 return credential.user
79
80 def revoke_old_credential(self, credential: AuthToken):
81 with get_session_context() as db_session:
82 credential.revoked = True
83 db_session.add(credential)
84
85async def prepare_oauth_request(request: Request) -> Request:
86 async with request.form() as form:
87 request.state.form_data = dict(form)
88 return request
89
90
91class FastApiOAuth2Payload(OAuth2Payload):
92 def __init__(self, request: Request):
93 self._request = request
94
95 @property
96 def data(self):
97 return {
98 **self._request.query_params,
99 **getattr(self._request.state, 'form_data', {}),
100 }
101
102 @property
103 def datalist(self):
104 values = defaultdict(list)
105 for k in self.data:
106 values[k].extend([self.data[k]])
107 return values
108
109class FastApiOAuth2Request(OAuth2Request):
110 def __init__(self, request: Request):
111 with patch('authlib.oauth2.rfc6749.errors.InsecureTransportError'):
112 super().__init__(
113 method=request.method,
114 uri=str(request.url),
115 headers=request.headers,
116 )
117 self.method = request.method
118 self.uri = str(request.url)
119 self.headers = request.headers
120 self.payload = FastApiOAuth2Payload(request)
121 self.user = request.user
122
123 self._request = request
124
125 @property
126 def args(self):
127 return self._request.query_params
128
129 @property
130 def form(self):
131 return getattr(self._request.state, 'form_data', {})
132
133
134class AuthorizationServer(BaseAuthorizationServer):
135 def __init__(self, *args, **kwargs):
136 super().__init__(*args, **kwargs)
137 self.register_grant(AuthorizationCodeGrant, extensions=[CodeChallenge()])
138 self.register_grant(RefreshTokenGrant)
139 self.register_token_generator("default", BearerTokenGenerator(
140 access_token_generator=lambda *args, **kwargs: generate_random_token(42),
141 refresh_token_generator=lambda *args, **kwargs: generate_random_token(48),
142 ))
143
144 def query_client(self, client_id: str) -> ClientApplication:
145 with get_session_context() as db_session:
146 return db_session.exec(select(ClientApplication).where(ClientApplication.client_id == client_id)).one()
147
148 def save_token(self, token: dict, request: OAuth2Request):
149 with get_session_context() as db_session:
150 db_session.add(AuthToken(
151 user_id=request.user.id,
152 client_id=request.client.client_id,
153 **token
154 ))
155
156 def create_oauth2_request(self, request: Request) -> OAuth2Request:
157 return FastApiOAuth2Request(request)
158
159 def handle_response(self, status, body, headers) -> Response:
160 return Response(
161 status_code=status,
162 content=json.dumps(body) if isinstance(body, dict) else body,
163 headers=dict(headers),
164 )
165
166 def send_signal(self, name, *args, **kwargs):
167 pass
diff --git a/authentication/backend.py b/authentication/backend.py
index 29408e3..9434906 100644
--- a/authentication/backend.py
+++ b/authentication/backend.py
@@ -1,4 +1,4 @@
1from datetime import datetime, UTC 1import re
2from typing import Optional 2from typing import Optional
3 3
4from sqlalchemy import update 4from sqlalchemy import update
@@ -6,11 +6,11 @@ from sqlmodel import select
6from starlette.authentication import AuthenticationBackend, AuthCredentials, BaseUser, UnauthenticatedUser 6from starlette.authentication import AuthenticationBackend, AuthCredentials, BaseUser, UnauthenticatedUser
7from starlette.requests import HTTPConnection 7from starlette.requests import HTTPConnection
8 8
9from db.models import Session, User 9from db.models import Session, User, AuthToken
10from db.session import get_session_context 10from db.session import get_session_context
11 11
12 12
13class AuthBackend(AuthenticationBackend): 13class SessionAuthBackend(AuthenticationBackend):
14 async def authenticate(self, request: HTTPConnection) -> Optional[tuple[AuthCredentials, BaseUser]]: 14 async def authenticate(self, request: HTTPConnection) -> Optional[tuple[AuthCredentials, BaseUser]]:
15 if "id" not in request.session: 15 if "id" not in request.session:
16 return None 16 return None
@@ -33,16 +33,38 @@ class AuthBackend(AuthenticationBackend):
33 ) 33 )
34 db_session.commit() 34 db_session.commit()
35 35
36
36 return ( 37 return (
37 AuthCredentials( 38 AuthCredentials(
38 [ 39 ["authenticated", "session"]
39 "authenticated",
40 *[
41 connection.type for connection in session.user.app_connections
42 ]
43 ]
44 if session is not None 40 if session is not None
45 else [] 41 else []
46 ), 42 ),
47 session.user if session is not None else UnauthenticatedUser() 43 session.user if session is not None else UnauthenticatedUser()
48 ) 44 )
45
46
47class BearerTokenAuthBackend(AuthenticationBackend):
48 regex = re.compile(r"^[Bb]earer\s(?P<token>\S+)$")
49 async def authenticate(self, request: HTTPConnection) -> Optional[tuple[AuthCredentials, BaseUser]]:
50 token = None
51 if "Authorization" in request.headers:
52 match = self.regex.match(request.headers["Authorization"])
53
54 query = (
55 select(AuthToken)
56 .join(User)
57 .where(AuthToken.access_token == match.group('token'))
58 )
59
60 with get_session_context() as db_session:
61 token: AuthToken = db_session.exec(query).first()
62
63 return (
64 AuthCredentials(
65 ["authenticated", "token"]
66 if token is not None
67 else []
68 ),
69 token.user if token is not None else UnauthenticatedUser()
70 )
diff --git a/authentication/endpoints.py b/authentication/endpoints.py
index ebdc7a1..f94d55b 100644
--- a/authentication/endpoints.py
+++ b/authentication/endpoints.py
@@ -1,31 +1,40 @@
1import json
2from base64 import b64encode
3from functools import partial
1from typing import Annotated 4from typing import Annotated
2from uuid import uuid7 5from uuid import uuid7
3 6
4from fastapi import FastAPI, Request, Form, Query, HTTPException 7from alembic.testing import requirements
5from pydantic import BaseModel, EmailStr, ValidationError 8from fastapi import FastAPI, Request, Form, Query
9from pydantic import BaseModel, ValidationError
10from sqlmodel import select
11from starlette.authentication import requires
6from starlette.middleware.authentication import AuthenticationMiddleware 12from starlette.middleware.authentication import AuthenticationMiddleware
7from starlette.middleware.sessions import SessionMiddleware 13from starlette.middleware.sessions import SessionMiddleware
8from starlette.responses import HTMLResponse, RedirectResponse 14from starlette.responses import HTMLResponse, RedirectResponse
9from starlette.templating import Jinja2Templates 15from starlette.templating import Jinja2Templates
10 16
11import conf 17import conf
12from authentication.backend import AuthBackend 18from authentication.authlib import AuthorizationServer, AuthorizationCodeGrant, prepare_oauth_request
13from authentication.utils import bcrypt_sha256_verify 19from authentication.backend import SessionAuthBackend
20from authentication.utils import bcrypt_sha256_verify, get_url_path_with_query
14from db.models import User, Session, ClientApplication 21from db.models import User, Session, ClientApplication
15from db.session import get_session_context 22from db.session import get_session_context
16from proxy.utils import get_path_with_query_string
17 23
18authentication = FastAPI() 24authentication = FastAPI()
19authentication.add_middleware(SessionMiddleware, secret_key=conf.SECRET_KEY) 25authentication.add_middleware(SessionMiddleware, secret_key=conf.SECRET_KEY)
20 26
21oauth = FastAPI() 27oauth = FastAPI()
22oauth.add_middleware(AuthenticationMiddleware, backend=AuthBackend()) 28oauth.add_middleware(AuthenticationMiddleware, backend=SessionAuthBackend())
23oauth.add_middleware(SessionMiddleware, secret_key=conf.SECRET_KEY) 29oauth.add_middleware(SessionMiddleware, secret_key=conf.SECRET_KEY)
24 30
31auth_server = AuthorizationServer()
32code_grant = partial(AuthorizationCodeGrant, server=auth_server)
33
25templates = Jinja2Templates(directory='authentication/templates') 34templates = Jinja2Templates(directory='authentication/templates')
26 35
27class LoginForm(BaseModel): 36class LoginForm(BaseModel):
28 username: EmailStr 37 username: str
29 password: str 38 password: str
30 39
31async def base_login(request: Request, form: LoginForm | None = None, errors: dict[str, str] | None = None): 40async def base_login(request: Request, form: LoginForm | None = None, errors: dict[str, str] | None = None):
@@ -35,11 +44,11 @@ async def base_login(request: Request, form: LoginForm | None = None, errors: di
35 }) 44 })
36 45
37@authentication.get('/login/', response_class=HTMLResponse) 46@authentication.get('/login/', response_class=HTMLResponse)
38async def login(request: Request): 47async def get_login(request: Request, next: Annotated[str, Query()] = None):
39 return await base_login(request) 48 return await base_login(request)
40 49
41@authentication.post('/login/', response_class=HTMLResponse) 50@authentication.post('/login/', response_class=HTMLResponse)
42async def login(request: Request, username: Annotated[str, Form()] = '', password: Annotated[str, Form()] = '', redirect: Annotated[str, Query()] = None): 51async def post_login(request: Request, username: Annotated[str, Form()] = '', password: Annotated[str, Form()] = '', next: Annotated[str, Query()] = None):
43 form = None 52 form = None
44 errors = {} 53 errors = {}
45 54
@@ -58,30 +67,61 @@ async def login(request: Request, username: Annotated[str, Form()] = '', passwor
58 session = Session(id=uuid7(), user=user) 67 session = Session(id=uuid7(), user=user)
59 db_session.add(session) 68 db_session.add(session)
60 69
61 if redirect is not None: 70 request.session.update({'id': str(session.id)})
62 return RedirectResponse(redirect) 71
72 if next is not None:
73 return RedirectResponse(next)
63 74
64 except ValidationError as exc: 75 except ValidationError as exc:
65 errors.update({ e['loc'][0]: e['msg'] for e in exc.errors() }) 76 errors.update({ e['loc'][0]: e['msg'] for e in exc.errors() })
66 77
67 return await base_login(request, form, errors) 78 return await base_login(request, form, errors)
68 79
80async def base_authorize(request: Request, user: User | None = None):
81 if not conf.IS_LOCAL:
82 with get_session_context() as db_session:
83 db_session.query(Session).filter(Session.id == request.session.get('id')).delete()
69 84
70async def base_authorize(request: Request, client_id: str, redirect_uri: str): 85 return auth_server.create_authorization_response(request, request.user, grant=None)
71 with get_session_context() as session:
72 app = session.query(ClientApplication).where(ClientApplication.client_id == client_id).first()
73 86
74 if app is None: 87@oauth.get('/authorize/')
75 raise HTTPException(status_code=404, detail='Invalid client id') 88@requires(['authenticated', 'session'], redirect='get_login')
89async def get_authorize(request: Request):
90 return await base_authorize(request, request.user)
76 91
77 if not request.user.is_authenticated: 92@oauth.post('/authorize/')
78 return RedirectResponse(f'/auth/login/?redirect={get_path_with_query_string(request)}') 93@requires(['authenticated', 'session'], redirect='get_login')
94async def post_authorize(request: Request):
95 return await base_authorize(request, request.user)
79 96
97@oauth.post('/token/')
98async def post_token(request: Request):
99 await prepare_oauth_request(request)
100 return auth_server.create_token_response(request)
80 101
81@oauth.get('/authorize/') 102@authentication.get('/connect/')
82async def authorize(request: Request, client_id: Annotated[str, Query()], redirect_uri: Annotated[str, Query()]): 103async def connect(request: Request):
83 return await base_authorize(request, client_id, redirect_uri) 104 origin = request.url.scheme + '://' + request.url.netloc
84 105
85@oauth.post('/authorize/') 106 with get_session_context() as session:
86async def authorize(request: Request, client_id: Annotated[str, Query()], redirect_uri: Annotated[str, Query()]): 107 app = session.exec(select(ClientApplication)).first()
87 return await base_authorize(request, client_id, redirect_uri) 108
109 return RedirectResponse(
110 '/oauth' + get_url_path_with_query(oauth, 'get_authorize', {
111 'client_id': app.client_id,
112 'redirect_uri': f'{origin}/oauth' + get_url_path_with_query(oauth, 'callback'),
113 'response_type': 'code',
114 'state': b64encode(json.dumps({
115 'client_id': app.client_id,
116 }).encode()).decode(),
117 })
118 )
119
120@oauth.get('/config/')
121async def get_config(request: Request):
122 with get_session_context() as session:
123 app = session.exec(select(ClientApplication)).first()
124
125 return {
126 'client_id': app.client_id,
127 }
diff --git a/authentication/mixins.py b/authentication/mixins.py
new file mode 100644
index 0000000..3d6a91f
--- /dev/null
+++ b/authentication/mixins.py
@@ -0,0 +1,54 @@
1import json
2import time
3
4from authlib.integrations.sqla_oauth2 import OAuth2ClientMixin as BaseOAuth2ClientMixin, OAuth2AuthorizationCodeMixin as BaseOAuth2AuthorizationCodeMixin, OAuth2TokenMixin as BaseOAuth2TokenMixin
5from sqlalchemy.sql.sqltypes import Text
6from sqlmodel import Field
7
8
9class OAuth2ClientMixin(BaseOAuth2ClientMixin):
10 client_id: str = Field(max_length=48, index=True, nullable=True)
11 client_secret: str = Field(max_length=120, nullable=True)
12 client_id_issued_at: int = Field(nullable=False, default=0)
13 client_secret_expires_at: int = Field(nullable=False, default=0)
14 client_metadata_field: str = Field(sa_type=Text, sa_column_kwargs={
15 'name': 'client_metadata'
16 }, nullable=True)
17
18 _client_metadata = None
19
20 @property
21 def client_metadata(self) -> dict:
22 if self.client_metadata_field:
23 return json.loads(self.client_metadata_field)
24 return {}
25
26 def set_client_metadata(self, metadata: dict):
27 self.client_metadata_field = json.dumps(metadata)
28
29
30class OAuth2AuthorizationCodeMixin(BaseOAuth2AuthorizationCodeMixin):
31 code: str = Field(max_length=120, unique=True, nullable=False)
32 client_id: str = Field(max_length=48, nullable=True)
33 redirect_uri: str = Field(sa_type=Text, default="", nullable=True)
34 response_type: str = Field(sa_type=Text, default="", nullable=True)
35 scope: str = Field(sa_type=Text, default="", nullable=True)
36 nonce: str = Field(sa_type=Text, nullable=True)
37 auth_time: int = Field(nullable=False, default_factory=lambda: int(time.time()))
38 acr: str = Field(sa_type=Text, nullable=True)
39 amr: str = Field(sa_type=Text, nullable=True)
40
41 code_challenge: str = Field(sa_type=Text, nullable=True)
42 code_challenge_method: str = Field(max_length=48, nullable=True)
43
44
45class OAuth2TokenMixin(BaseOAuth2TokenMixin):
46 client_id: str = Field(max_length=48, nullable=True)
47 token_type: str = Field(max_length=40, nullable=True)
48 access_token: str = Field(max_length=255, unique=True, nullable=False)
49 refresh_token: str = Field(max_length=255, index=True, nullable=True)
50 scope: str = Field(sa_type=Text, default="", nullable=True)
51 issued_at: int = Field(nullable=False, default_factory=lambda: int(time.time()))
52 access_token_revoked_at: int = Field(nullable=False, default=0)
53 refresh_token_revoked_at: int = Field(nullable=False, default=0)
54 expires_in: int = Field(nullable=False, default=0)
diff --git a/authentication/templates/authorize.html b/authentication/templates/authorize.html
deleted file mode 100644
index 1ed6506..0000000
--- a/authentication/templates/authorize.html
+++ /dev/null
@@ -1,5 +0,0 @@
1{% extends "./base.html" %}
2
3{% block content %}
4 <h1>AUTH</h1>
5{% endblock %}
diff --git a/authentication/templates/login.html b/authentication/templates/login.html
index fcba5cd..2db2512 100644
--- a/authentication/templates/login.html
+++ b/authentication/templates/login.html
@@ -3,7 +3,7 @@
3{% block content %} 3{% block content %}
4 <form method="post"> 4 <form method="post">
5 5
6 <input type="email" name="username" {% if form %}value="{{ form.username }}"{% endif %} placeholder="username" /> 6 <input type="text" name="username" {% if form %}value="{{ form.username }}"{% endif %} placeholder="username" />
7 {% if errors and errors.username %} 7 {% if errors and errors.username %}
8 {{ errors.username }} 8 {{ errors.username }}
9 {% endif %} 9 {% endif %}
diff --git a/authentication/utils.py b/authentication/utils.py
index f383969..4f19bc3 100644
--- a/authentication/utils.py
+++ b/authentication/utils.py
@@ -1,6 +1,9 @@
1import base64 1import base64
2import hashlib 2import hashlib
3from urllib.parse import urlencode
4
3import bcrypt 5import bcrypt
6from fastapi import FastAPI
4 7
5 8
6def bcrypt_sha256_hash(password: str) -> str: 9def bcrypt_sha256_hash(password: str) -> str:
@@ -13,3 +16,10 @@ def bcrypt_sha256_verify(password: str, hashed: str) -> bool:
13 digest = hashlib.sha256(password.encode("utf-8")).digest() 16 digest = hashlib.sha256(password.encode("utf-8")).digest()
14 encoded = base64.b64encode(digest) 17 encoded = base64.b64encode(digest)
15 return bcrypt.checkpw(encoded, hashed.encode('utf-8')) 18 return bcrypt.checkpw(encoded, hashed.encode('utf-8'))
19
20
21def get_url_path_with_query(app: FastAPI, name: str, params: dict | None = None, /, **path_params: str):
22 base = app.url_path_for(name, **path_params)
23 return base + '?' + urlencode(params) if params is not None else base
24
25