diff options
| author | 2026-07-22 14:51:44 +0200 | |
|---|---|---|
| committer | 2026-07-22 14:51:44 +0200 | |
| commit | 498c79f434856aa68e9a883247ea69a22256fce6 (patch) | |
| tree | 4561647b7f8592f783b4dc07143c60730393b4a1 | |
| parent | 7784c60c03ec277b456db6e2709383e302d9382b (diff) | |
| download | server-498c79f434856aa68e9a883247ea69a22256fce6.tar.gz server-498c79f434856aa68e9a883247ea69a22256fce6.tar.bz2 server-498c79f434856aa68e9a883247ea69a22256fce6.zip | |
Added auth layer
| -rw-r--r-- | authentication/authlib.py | 167 | ||||
| -rw-r--r-- | authentication/backend.py | 40 | ||||
| -rw-r--r-- | authentication/endpoints.py | 88 | ||||
| -rw-r--r-- | authentication/mixins.py | 54 | ||||
| -rw-r--r-- | authentication/templates/authorize.html | 5 | ||||
| -rw-r--r-- | authentication/templates/login.html | 2 | ||||
| -rw-r--r-- | authentication/utils.py | 10 | ||||
| -rw-r--r-- | conf/__init__.py | 3 | ||||
| -rw-r--r-- | conf/local.py | 3 | ||||
| -rw-r--r-- | db/models.py | 18 | ||||
| -rw-r--r-- | ttun_server/__init__.py | 9 | ||||
| -rw-r--r-- | ttun_server/websockets.py | 3 |
12 files changed, 360 insertions, 42 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 @@ | |||
| 1 | import json | ||
| 2 | from collections import defaultdict | ||
| 3 | from unittest.mock import patch | ||
| 4 | |||
| 5 | from authlib.common.security import generate_token as generate_random_token | ||
| 6 | from authlib.oauth2 import AuthorizationServer as BaseAuthorizationServer, OAuth2Request | ||
| 7 | from authlib.oauth2.rfc6749 import AuthorizationCodeGrant as BaseAuthorizationCodeGrant, \ | ||
| 8 | RefreshTokenGrant as BaseRefreshTokenGrant, OAuth2Payload | ||
| 9 | from authlib.oauth2.rfc6750 import BearerTokenGenerator | ||
| 10 | from authlib.oauth2.rfc7636 import CodeChallenge | ||
| 11 | from sqlalchemy.exc import NoResultFound | ||
| 12 | |||
| 13 | from sqlmodel import select | ||
| 14 | from fastapi import Request, Response | ||
| 15 | |||
| 16 | from db.models import ClientApplication, AuthToken, AuthCode, User | ||
| 17 | from db.session import get_session_context | ||
| 18 | |||
| 19 | |||
| 20 | class 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 | |||
| 64 | class 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 | |||
| 85 | async 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 | |||
| 91 | class 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 | |||
| 109 | class 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 | |||
| 134 | class 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 @@ | |||
| 1 | from datetime import datetime, UTC | 1 | import re |
| 2 | from typing import Optional | 2 | from typing import Optional |
| 3 | 3 | ||
| 4 | from sqlalchemy import update | 4 | from sqlalchemy import update |
| @@ -6,11 +6,11 @@ from sqlmodel import select | |||
| 6 | from starlette.authentication import AuthenticationBackend, AuthCredentials, BaseUser, UnauthenticatedUser | 6 | from starlette.authentication import AuthenticationBackend, AuthCredentials, BaseUser, UnauthenticatedUser |
| 7 | from starlette.requests import HTTPConnection | 7 | from starlette.requests import HTTPConnection |
| 8 | 8 | ||
| 9 | from db.models import Session, User | 9 | from db.models import Session, User, AuthToken |
| 10 | from db.session import get_session_context | 10 | from db.session import get_session_context |
| 11 | 11 | ||
| 12 | 12 | ||
| 13 | class AuthBackend(AuthenticationBackend): | 13 | class 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 | |||
