diff options
| author | 2026-09-04 17:09:24 +0200 | |
|---|---|---|
| committer | 2026-09-04 17:09:24 +0200 | |
| commit | 6b6f20a032eb31d747366c8bc270ca81731eaf6a (patch) | |
| tree | ada48080495267bebde4ee1251bbbe77d629745f /authentication | |
| parent | 2d22158dd95f76399f5716d556be60c77da04a46 (diff) | |
| download | server-v3.tar.gz server-v3.tar.bz2 server-v3.zip | |
Fixed pre-commit issuesv3
Diffstat (limited to 'authentication')
| -rw-r--r-- | authentication/authlib.py | 77 | ||||
| -rw-r--r-- | authentication/backend.py | 49 | ||||
| -rw-r--r-- | authentication/endpoints.py | 177 | ||||
| -rw-r--r-- | authentication/mixins.py | 12 | ||||
| -rw-r--r-- | authentication/utils.py | 10 |
5 files changed, 197 insertions, 128 deletions
diff --git a/authentication/authlib.py b/authentication/authlib.py index 8472062..a37d68f 100644 --- a/authentication/authlib.py +++ b/authentication/authlib.py | |||
| @@ -1,31 +1,37 @@ | |||
| 1 | import json | 1 | import json |
| 2 | from collections import defaultdict | 2 | from collections import defaultdict |
| 3 | from typing import ClassVar | ||
| 3 | from unittest.mock import patch | 4 | from unittest.mock import patch |
| 4 | 5 | ||
| 5 | from authlib.common.security import generate_token as generate_random_token | 6 | from authlib.common.security import generate_token as generate_random_token |
| 6 | from authlib.oauth2 import AuthorizationServer as BaseAuthorizationServer, OAuth2Request | 7 | from authlib.oauth2 import AuthorizationServer as BaseAuthorizationServer |
| 7 | from authlib.oauth2.rfc6749 import AuthorizationCodeGrant as BaseAuthorizationCodeGrant, \ | 8 | from authlib.oauth2 import OAuth2Request |
| 8 | RefreshTokenGrant as BaseRefreshTokenGrant, OAuth2Payload | 9 | from authlib.oauth2.rfc6749 import AuthorizationCodeGrant as BaseAuthorizationCodeGrant |
| 10 | from authlib.oauth2.rfc6749 import OAuth2Payload | ||
| 11 | from authlib.oauth2.rfc6749 import RefreshTokenGrant as BaseRefreshTokenGrant | ||
| 9 | from authlib.oauth2.rfc6750 import BearerTokenGenerator | 12 | from authlib.oauth2.rfc6750 import BearerTokenGenerator |
| 10 | from authlib.oauth2.rfc7636 import CodeChallenge | 13 | from authlib.oauth2.rfc7636 import CodeChallenge |
| 14 | from fastapi import Request, Response | ||
| 11 | from sqlalchemy.exc import NoResultFound | 15 | from sqlalchemy.exc import NoResultFound |
| 12 | |||
| 13 | from sqlmodel import select | 16 | from sqlmodel import select |
| 14 | from fastapi import Request, Response | ||
| 15 | 17 | ||
| 16 | from db.models import ClientApplication, AuthToken, AuthCode, User | 18 | from db.models import AuthCode, AuthToken, ClientApplication, User |
| 17 | from db.session import get_session_context | 19 | from db.session import get_session_context |
| 18 | 20 | ||
| 19 | 21 | ||
| 20 | class AuthorizationCodeGrant(BaseAuthorizationCodeGrant): | 22 | class AuthorizationCodeGrant(BaseAuthorizationCodeGrant): |
| 21 | TOKEN_ENDPOINT_AUTH_METHODS = ['client_secret_basic', 'client_secret_post', 'none'] | 23 | TOKEN_ENDPOINT_AUTH_METHODS: ClassVar[list[str]] = [ |
| 24 | "client_secret_basic", | ||
| 25 | "client_secret_post", | ||
| 26 | "none", | ||
| 27 | ] | ||
| 22 | 28 | ||
| 23 | def save_authorization_code(self, code: str, request: OAuth2Request): | 29 | def save_authorization_code(self, code: str, request: OAuth2Request): |
| 24 | with get_session_context() as db_session: | 30 | with get_session_context() as db_session: |
| 25 | db_session.add(request.user) | 31 | db_session.add(request.user) |
| 26 | 32 | ||
| 27 | code_challenge = request.payload.data.get('code_challenge') | 33 | code_challenge = request.payload.data.get("code_challenge") |
| 28 | code_challenge_method = request.payload.data.get('code_challenge_method') | 34 | code_challenge_method = request.payload.data.get("code_challenge_method") |
| 29 | 35 | ||
| 30 | auth_code = AuthCode( | 36 | auth_code = AuthCode( |
| 31 | code=code, | 37 | code=code, |
| @@ -41,10 +47,16 @@ class AuthorizationCodeGrant(BaseAuthorizationCodeGrant): | |||
| 41 | 47 | ||
| 42 | return auth_code | 48 | return auth_code |
| 43 | 49 | ||
| 44 | def query_authorization_code(self, code: str, client: ClientApplication) -> AuthCode | None: | 50 | def query_authorization_code( |
| 51 | self, code: str, client: ClientApplication | ||
| 52 | ) -> AuthCode | None: | ||
| 45 | try: | 53 | try: |
| 46 | with get_session_context() as db_session: | 54 | 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() | 55 | auth_code = db_session.exec( |
| 56 | select(AuthCode).where( | ||
| 57 | AuthCode.code == code, AuthCode.client_id == client.client_id | ||
| 58 | ) | ||
| 59 | ).one() | ||
| 48 | except NoResultFound: | 60 | except NoResultFound: |
| 49 | return None | 61 | return None |
| 50 | 62 | ||
| @@ -65,7 +77,9 @@ class RefreshTokenGrant(BaseRefreshTokenGrant): | |||
| 65 | def authenticate_refresh_token(self, refresh_token) -> AuthToken | None: | 77 | def authenticate_refresh_token(self, refresh_token) -> AuthToken | None: |
| 66 | try: | 78 | try: |
| 67 | with get_session_context() as db_session: | 79 | with get_session_context() as db_session: |
| 68 | token = db_session.exec(select(AuthToken).where(AuthToken.refresh_token == refresh_token)).one() | 80 | token = db_session.exec( |
| 81 | select(AuthToken).where(AuthToken.refresh_token == refresh_token) | ||
| 82 | ).one() | ||
| 69 | except NoResultFound: | 83 | except NoResultFound: |
| 70 | return None | 84 | return None |
| 71 | 85 | ||
| @@ -82,6 +96,7 @@ class RefreshTokenGrant(BaseRefreshTokenGrant): | |||
| 82 | credential.revoked = True | 96 | credential.revoked = True |
| 83 | db_session.add(credential) | 97 | db_session.add(credential) |
| 84 | 98 | ||
| 99 | |||
| 85 | async def prepare_oauth_request(request: Request) -> Request: | 100 | async def prepare_oauth_request(request: Request) -> Request: |
| 86 | async with request.form() as form: | 101 | async with request.form() as form: |
| 87 | request.state.form_data = dict(form) | 102 | request.state.form_data = dict(form) |
| @@ -96,7 +111,7 @@ class FastApiOAuth2Payload(OAuth2Payload): | |||
| 96 | def data(self): | 111 | def data(self): |
| 97 | return { | 112 | return { |
| 98 | **self._request.query_params, | 113 | **self._request.query_params, |
| 99 | **getattr(self._request.state, 'form_data', {}), | 114 | **getattr(self._request.state, "form_data", {}), |
| 100 | } | 115 | } |
| 101 | 116 | ||
| 102 | @property | 117 | @property |
| @@ -106,9 +121,10 @@ class FastApiOAuth2Payload(OAuth2Payload): | |||
| 106 | values[k].extend([self.data[k]]) | 121 | values[k].extend([self.data[k]]) |
| 107 | return values | 122 | return values |
| 108 | 123 | ||
| 124 | |||
| 109 | class FastApiOAuth2Request(OAuth2Request): | 125 | class FastApiOAuth2Request(OAuth2Request): |
| 110 | def __init__(self, request: Request): | 126 | def __init__(self, request: Request): |
| 111 | with patch('authlib.oauth2.rfc6749.errors.InsecureTransportError'): | 127 | with patch("authlib.oauth2.rfc6749.errors.InsecureTransportError"): |
| 112 | super().__init__( | 128 | super().__init__( |
| 113 | method=request.method, | 129 | method=request.method, |
| 114 | uri=str(request.url), | 130 | uri=str(request.url), |
| @@ -128,7 +144,7 @@ class FastApiOAuth2Request(OAuth2Request): | |||
| 128 | 144 | ||
| 129 | @property | 145 | @property |
| 130 | def form(self): | 146 | def form(self): |
| 131 | return getattr(self._request.state, 'form_data', {}) | 147 | return getattr(self._request.state, "form_data", {}) |
| 132 | 148 | ||
| 133 | 149 | ||
| 134 | class AuthorizationServer(BaseAuthorizationServer): | 150 | class AuthorizationServer(BaseAuthorizationServer): |
| @@ -136,22 +152,33 @@ class AuthorizationServer(BaseAuthorizationServer): | |||
| 136 | super().__init__(*args, **kwargs) | 152 | super().__init__(*args, **kwargs) |
| 137 | self.register_grant(AuthorizationCodeGrant, extensions=[CodeChallenge()]) | 153 | self.register_grant(AuthorizationCodeGrant, extensions=[CodeChallenge()]) |
| 138 | self.register_grant(RefreshTokenGrant) | 154 | self.register_grant(RefreshTokenGrant) |
| 139 | self.register_token_generator("default", BearerTokenGenerator( | 155 | self.register_token_generator( |
| 140 | access_token_generator=lambda *args, **kwargs: generate_random_token(42), | 156 | "default", |
| 141 | refresh_token_generator=lambda *args, **kwargs: generate_random_token(48), | 157 | BearerTokenGenerator( |
| 142 | )) | 158 | access_token_generator=lambda *args, **kwargs: generate_random_token( |
| 159 | 42 | ||
| 160 | ), | ||
| 161 | refresh_token_generator=lambda *args, **kwargs: generate_random_token( | ||
| 162 | 48 | ||
| 163 | ), | ||
| 164 | ), | ||
| 165 | ) | ||
| 143 | 166 | ||
| 144 | def query_client(self, client_id: str) -> ClientApplication: | 167 | def query_client(self, client_id: str) -> ClientApplication: |
| 145 | with get_session_context() as db_session: | 168 | with get_session_context() as db_session: |
| 146 | return db_session.exec(select(ClientApplication).where(ClientApplication.client_id == client_id)).one() | 169 | return db_session.exec( |
| 170 | select(ClientApplication).where( | ||
| 171 | ClientApplication.client_id == client_id | ||
| 172 | ) | ||
| 173 | ).one() | ||
| 147 | 174 | ||
| 148 | def save_token(self, token: dict, request: OAuth2Request): | 175 | def save_token(self, token: dict, request: OAuth2Request): |
| 149 | with get_session_context() as db_session: | 176 | with get_session_context() as db_session: |
| 150 | db_session.add(AuthToken( | 177 | db_session.add( |
| 151 | user_id=request.user.id, | 178 | AuthToken( |
| 152 | client_id=request.client.client_id, | 179 | user_id=request.user.id, client_id=request.client.client_id, **token |
| 153 | **token | 180 | ) |
| 154 | )) | 181 | ) |
| 155 | 182 | ||
| 156 | def create_oauth2_request(self, request: Request) -> OAuth2Request: | 183 | def create_oauth2_request(self, request: Request) -> OAuth2Request: |
| 157 | return FastApiOAuth2Request(request) | 184 | return FastApiOAuth2Request(request) |
diff --git a/authentication/backend.py b/authentication/backend.py index 9434906..58842de 100644 --- a/authentication/backend.py +++ b/authentication/backend.py | |||
| @@ -1,52 +1,49 @@ | |||
| 1 | import re | 1 | import re |
| 2 | from typing import Optional | ||
| 3 | 2 | ||
| 4 | from sqlalchemy import update | 3 | from sqlalchemy import update |
| 5 | from sqlmodel import select | 4 | from sqlmodel import select |
| 6 | from starlette.authentication import AuthenticationBackend, AuthCredentials, BaseUser, UnauthenticatedUser | 5 | from starlette.authentication import ( |
| 6 | AuthCredentials, | ||
| 7 | AuthenticationBackend, | ||
| 8 | BaseUser, | ||
| 9 | UnauthenticatedUser, | ||
| 10 | ) | ||
| 7 | from starlette.requests import HTTPConnection | 11 | from starlette.requests import HTTPConnection |
| 8 | 12 | ||
| 9 | from db.models import Session, User, AuthToken | 13 | from db.models import AuthToken, Session, User |
| 10 | from db.session import get_session_context | 14 | from db.session import get_session_context |
| 11 | 15 | ||
| 12 | 16 | ||
| 13 | class SessionAuthBackend(AuthenticationBackend): | 17 | class SessionAuthBackend(AuthenticationBackend): |
| 14 | async def authenticate(self, request: HTTPConnection) -> Optional[tuple[AuthCredentials, BaseUser]]: | 18 | async def authenticate( |
| 19 | self, request: HTTPConnection | ||
| 20 | ) -> tuple[AuthCredentials, BaseUser] | None: | ||
| 15 | if "id" not in request.session: | 21 | if "id" not in request.session: |
| 16 | return None | 22 | return None |
| 17 | 23 | ||
| 18 | query = ( | 24 | query = select(Session).join(User).where(Session.id == request.session["id"]) |
| 19 | select(Session) | ||
| 20 | .join(User) | ||
| 21 | .where( | ||
| 22 | Session.id == request.session['id'] | ||
| 23 | ) | ||
| 24 | ) | ||
| 25 | |||
| 26 | 25 | ||
| 27 | with get_session_context() as db_session: | 26 | with get_session_context() as db_session: |
| 28 | session: Session = db_session.exec(query).first() | 27 | session: Session = db_session.exec(query).first() |
| 29 | 28 | ||
| 30 | if session is not None: | 29 | if session is not None: |
| 31 | db_session.execute( | 30 | db_session.execute(update(Session).where(Session.id == session.id)) |
| 32 | update(Session).where(Session.id == session.id) | ||
| 33 | ) | ||
| 34 | db_session.commit() | 31 | db_session.commit() |
| 35 | 32 | ||
| 36 | |||
| 37 | return ( | 33 | return ( |
| 38 | AuthCredentials( | 34 | AuthCredentials( |
| 39 | ["authenticated", "session"] | 35 | ["authenticated", "session"] if session is not None else [] |
| 40 | if session is not None | ||
| 41 | else [] | ||
| 42 | ), | 36 | ), |
| 43 | session.user if session is not None else UnauthenticatedUser() | 37 | session.user if session is not None else UnauthenticatedUser(), |
| 44 | ) | 38 | ) |
| 45 | 39 | ||
| 46 | 40 | ||
| 47 | class BearerTokenAuthBackend(AuthenticationBackend): | 41 | class BearerTokenAuthBackend(AuthenticationBackend): |
| 48 | regex = re.compile(r"^[Bb]earer\s(?P<token>\S+)$") | 42 | regex = re.compile(r"^[Bb]earer\s(?P<token>\S+)$") |
| 49 | async def authenticate(self, request: HTTPConnection) -> Optional[tuple[AuthCredentials, BaseUser]]: | 43 | |
| 44 | async def authenticate( | ||
| 45 | self, request: HTTPConnection | ||
| 46 | ) -> tuple[AuthCredentials, BaseUser] | None: | ||
| 50 | token = None | 47 | token = None |
| 51 | if "Authorization" in request.headers: | 48 | if "Authorization" in request.headers: |
| 52 | match = self.regex.match(request.headers["Authorization"]) | 49 | match = self.regex.match(request.headers["Authorization"]) |
| @@ -54,17 +51,13 @@ class BearerTokenAuthBackend(AuthenticationBackend): | |||
| 54 | query = ( | 51 | query = ( |
| 55 | select(AuthToken) | 52 | select(AuthToken) |
| 56 | .join(User) | 53 | .join(User) |
| 57 | .where(AuthToken.access_token == match.group('token')) | 54 | .where(AuthToken.access_token == match.group("token")) |
| 58 | ) | 55 | ) |
| 59 | 56 | ||
| 60 | with get_session_context() as db_session: | 57 | with get_session_context() as db_session: |
| 61 | token: AuthToken = db_session.exec(query).first() | 58 | token: AuthToken = db_session.exec(query).first() |
| 62 | 59 | ||
| 63 | return ( | 60 | return ( |
| 64 | AuthCredentials( | 61 | AuthCredentials(["authenticated", "token"] if token is not None else []), |
| 65 | ["authenticated", "token"] | 62 | token.user if token is not None else UnauthenticatedUser(), |
| 66 | if token is not None | ||
| 67 | else [] | ||
| 68 | ), | ||
| 69 | token.user if token is not None else UnauthenticatedUser() | ||
| 70 | ) | 63 | ) |
diff --git a/authentication/endpoints.py b/authentication/endpoints.py index 3af49fc..17d49d7 100644 --- a/authentication/endpoints.py +++ b/authentication/endpoints.py | |||
| @@ -4,8 +4,8 @@ from functools import partial | |||
| 4 | from typing import Annotated, Self | 4 | from typing import Annotated, Self |
| 5 | from uuid import uuid7 | 5 | from uuid import uuid7 |
| 6 | 6 | ||
| 7 | from fastapi import FastAPI, Request, Form, Query | 7 | from fastapi import FastAPI, Form, Query, Request |
| 8 | from pydantic import BaseModel, ValidationError, model_validator, EmailStr | 8 | from pydantic import BaseModel, EmailStr, ValidationError, model_validator |
| 9 | from sqlalchemy import func | 9 | from sqlalchemy import func |
| 10 | from sqlmodel import select | 10 | from sqlmodel import select |
| 11 | from starlette.authentication import requires | 11 | from starlette.authentication import requires |
| @@ -15,10 +15,14 @@ from starlette.responses import HTMLResponse, RedirectResponse | |||
| 15 | from starlette.templating import Jinja2Templates | 15 | from starlette.templating import Jinja2Templates |
| 16 | 16 | ||
| 17 | import conf | 17 | import conf |
| 18 | from authentication.authlib import AuthorizationServer, AuthorizationCodeGrant, prepare_oauth_request | 18 | from authentication.authlib import ( |
| 19 | AuthorizationCodeGrant, | ||
| 20 | AuthorizationServer, | ||
| 21 | prepare_oauth_request, | ||
| 22 | ) | ||
| 19 | from authentication.backend import SessionAuthBackend | 23 | from authentication.backend import SessionAuthBackend |
| 20 | from authentication.utils import bcrypt_sha256_verify, get_url_path_with_query | 24 | from authentication.utils import bcrypt_sha256_verify, get_url_path_with_query |
| 21 | from db.models import User, Session, ClientApplication | 25 | from db.models import ClientApplication, Session, User |
| 22 | from db.session import get_session_context | 26 | from db.session import get_session_context |
| 23 | 27 | ||
| 24 | authentication = FastAPI() | 28 | authentication = FastAPI() |
| @@ -31,10 +35,7 @@ oauth.add_middleware(SessionMiddleware, secret_key=conf.SECRET_KEY) | |||
| 31 | auth_server = AuthorizationServer() | 35 | auth_server = AuthorizationServer() |
| 32 | code_grant = partial(AuthorizationCodeGrant, server=auth_server) | 36 | code_grant = partial(AuthorizationCodeGrant, server=auth_server) |
| 33 | 37 | ||
| 34 | templates = Jinja2Templates(directory=[ | 38 | templates = Jinja2Templates(directory=["templates", "authentication/templates"]) |
| 35 | 'templates', | ||
| 36 | 'authentication/templates' | ||
| 37 | ]) | ||
| 38 | 39 | ||
| 39 | 40 | ||
| 40 | class LoginForm(BaseModel): | 41 | class LoginForm(BaseModel): |
| @@ -42,57 +43,75 @@ class LoginForm(BaseModel): | |||
| 42 | password: str | 43 | password: str |
| 43 | 44 | ||
| 44 | 45 | ||
| 45 | async def base_login(request: Request, form: LoginForm | None = None, errors: dict[str, str] | None = None): | 46 | async def base_login( |
| 46 | return templates.TemplateResponse(request, 'login.html', context={ | 47 | request: Request, |
| 47 | 'form': form, | 48 | form: LoginForm | None = None, |
| 48 | 'errors': errors, | 49 | errors: dict[str, str] | None = None, |
| 49 | }) | 50 | ): |
| 51 | return templates.TemplateResponse( | ||
| 52 | request, | ||
| 53 | "login.html", | ||
| 54 | context={ | ||
| 55 | "form": form, | ||
| 56 | "errors": errors, | ||
| 57 | }, | ||
| 58 | ) | ||
| 50 | 59 | ||
| 51 | 60 | ||
| 52 | @authentication.get('/login/', response_class=HTMLResponse) | 61 | @authentication.get("/login/", response_class=HTMLResponse) |
| 53 | async def get_login(request: Request, next: Annotated[str, Query()] = None): | 62 | async def get_login(request: Request, next: Annotated[str | None, Query()] = None): |
| 54 | return await base_login(request) | 63 | return await base_login(request) |
| 55 | 64 | ||
| 56 | 65 | ||
| 57 | @authentication.post('/login/', response_class=HTMLResponse) | 66 | @authentication.post("/login/", response_class=HTMLResponse) |
| 58 | async def post_login(request: Request, username: Annotated[str, Form()] = '', password: Annotated[str, Form()] = '', | 67 | async def post_login( |
| 59 | next: Annotated[str, Query()] = None): | 68 | request: Request, |
| 69 | username: Annotated[str, Form()] = "", | ||
| 70 | password: Annotated[str, Form()] = "", | ||
| 71 | next: Annotated[str | None, Query()] = None, | ||
| 72 | ): | ||
| 60 | form = None | 73 | form = None |
| 61 | errors = {} | 74 | errors = {} |
| 62 | 75 | ||
| 63 | try: | 76 | try: |
| 64 | form = LoginForm.model_validate({ | 77 | form = LoginForm.model_validate({"username": username, "password": password}) |
| 65 | 'username': username, | ||
| 66 | 'password': password | ||
| 67 | }) | ||
| 68 | 78 | ||
| 69 | with get_session_context() as db_session: | 79 | with get_session_context() as db_session: |
| 70 | user = db_session.query(User).filter(User.username == form.username).first() | 80 | user = db_session.query(User).filter(User.username == form.username).first() |
| 71 | 81 | ||
| 72 | if not bcrypt_sha256_verify(password, user.password if user is not None else '') or user is None: | 82 | if ( |
| 73 | errors.update({'password': 'Invalid username or password'}) | 83 | not bcrypt_sha256_verify( |
| 84 | password, user.password if user is not None else "" | ||
| 85 | ) | ||
| 86 | or user is None | ||
| 87 | ): | ||
| 88 | errors.update({"password": "Invalid username or password"}) | ||
| 74 | else: | 89 | else: |
| 75 | with get_session_context() as db_session: | 90 | with get_session_context() as db_session: |
| 76 | session = Session(id=uuid7(), user=user) | 91 | session = Session(id=uuid7(), user=user) |
| 77 | db_session.add(session) | 92 | db_session.add(session) |
| 78 | 93 | ||
| 79 | request.session.update({'id': str(session.id)}) | 94 | request.session.update({"id": str(session.id)}) |
| 80 | 95 | ||
| 81 | if next is not None: | 96 | if next is not None: |
| 82 | return RedirectResponse(next, status_code=303) | 97 | return RedirectResponse(next, status_code=303) |
| 83 | except ValidationError as exc: | 98 | except ValidationError as exc: |
| 84 | errors.update({e['loc'][0]: e['msg'] for e in exc.errors()}) | 99 | errors.update({e["loc"][0]: e["msg"] for e in exc.errors()}) |
| 85 | 100 | ||
| 86 | return await base_login(request, form, errors) | 101 | return await base_login(request, form, errors) |
| 87 | 102 | ||
| 88 | 103 | ||
| 89 | @authentication.get('/logout/') | 104 | @authentication.get("/logout/") |
| 90 | def logout(request: Request): | 105 | def logout(request: Request): |
| 91 | with get_session_context() as db_session: | 106 | with get_session_context() as db_session: |
| 92 | db_session.query(Session).filter(Session.id == request.session.get('id')).delete() | 107 | db_session.query(Session).filter( |
| 108 | Session.id == request.session.get("id") | ||
| 109 | ).delete() | ||
| 93 | 110 | ||
| 94 | request.session.clear() | 111 | request.session.clear() |
| 95 | return RedirectResponse(request.headers.get('Referer', '/management/dashboard/'), status_code=303) | 112 | return RedirectResponse( |
| 113 | request.headers.get("Referer", "/management/dashboard/"), status_code=303 | ||
| 114 | ) | ||
| 96 | 115 | ||
| 97 | 116 | ||
| 98 | class RegisterForm(BaseModel): | 117 | class RegisterForm(BaseModel): |
| @@ -100,10 +119,10 @@ class RegisterForm(BaseModel): | |||
| 100 | password: str | 119 | password: str |
| 101 | verify_password: str | 120 | verify_password: str |
| 102 | 121 | ||
| 103 | @model_validator(mode='after') | 122 | @model_validator(mode="after") |
| 104 | def check_passwords_match(self) -> Self: | 123 | def check_passwords_match(self) -> Self: |
| 105 | if self.password != self.verify_password: | 124 | if self.password != self.verify_password: |
| 106 | raise ValueError('Passwords do not match') | 125 | raise ValueError("Passwords do not match") |
| 107 | return self | 126 | return self |
| 108 | 127 | ||
| 109 | 128 | ||
| @@ -113,31 +132,42 @@ def is_first_run() -> bool: | |||
| 113 | return user_count.first() == 0 | 132 | return user_count.first() == 0 |
| 114 | 133 | ||
| 115 | 134 | ||
| 116 | async def base_register(request: Request, form: RegisterForm | None = None, errors: dict[str, str] | None = None): | 135 | async def base_register( |
| 117 | return templates.TemplateResponse(request, 'register.html', context={ | 136 | request: Request, |
| 118 | 'form': form, | 137 | form: RegisterForm | None = None, |
| 119 | 'errors': errors, | 138 | errors: dict[str, str] | None = None, |
| 120 | 'is_first_run': is_first_run(), | 139 | ): |
| 121 | }) | 140 | return templates.TemplateResponse( |
| 141 | request, | ||
| 142 | "register.html", | ||
| 143 | context={ | ||
| 144 | "form": form, | ||
| 145 | "errors": errors, | ||
| 146 | "is_first_run": is_first_run(), | ||
| 147 | }, | ||
| 148 | ) | ||
| 122 | 149 | ||
| 123 | 150 | ||
| 124 | @authentication.get('/register/', response_class=HTMLResponse) | 151 | @authentication.get("/register/", response_class=HTMLResponse) |
| 125 | async def get_register(request: Request, next: Annotated[str, Query()] = None): | 152 | async def get_register(request: Request, next: Annotated[str | None, Query()] = None): |
| 126 | return await base_register(request) | 153 | return await base_register(request) |
| 127 | 154 | ||
| 128 | 155 | ||
| 129 | @authentication.post('/register/', response_class=HTMLResponse) | 156 | @authentication.post("/register/", response_class=HTMLResponse) |
| 130 | async def post_register(request: Request, email: Annotated[str, Form()] = '', password: Annotated[str, Form()] = '', | 157 | async def post_register( |
| 131 | verify_password: Annotated[str, Form()] = '', next: Annotated[str, Query()] = None): | 158 | request: Request, |
| 159 | email: Annotated[str, Form()] = "", | ||
| 160 | password: Annotated[str, Form()] = "", | ||
| 161 | verify_password: Annotated[str, Form()] = "", | ||
| 162 | next: Annotated[str | None, Query()] = None, | ||
| 163 | ): | ||
| 132 | form = None | 164 | form = None |
| 133 | errors = {} | 165 | errors = {} |
| 134 | 166 | ||
| 135 | try: | 167 | try: |
| 136 | form = RegisterForm.model_validate({ | 168 | form = RegisterForm.model_validate( |
| 137 | 'email': email, | 169 | {"email": email, "password": password, "verify_password": verify_password} |
| 138 | 'password': password, | 170 | ) |
| 139 | 'verify_password': verify_password | ||
| 140 | }) | ||
| 141 | 171 | ||
| 142 | with get_session_context() as db_session: | 172 | with get_session_context() as db_session: |
| 143 | user = User(username=email, email=email) | 173 | user = User(username=email, email=email) |
| @@ -147,13 +177,18 @@ async def post_register(request: Request, email: Annotated[str, Form()] = '', pa | |||
| 147 | session = Session(id=uuid7(), user=user) | 177 | session = Session(id=uuid7(), user=user) |
| 148 | db_session.add(session) | 178 | db_session.add(session) |
| 149 | 179 | ||
| 150 | request.session.update({'id': str(session.id)}) | 180 | request.session.update({"id": str(session.id)}) |
| 151 | 181 | ||
| 152 | if next is not None: | 182 | if next is not None: |
| 153 | return RedirectResponse(next, status_code=303) | 183 | return RedirectResponse(next, status_code=303) |
| 154 | 184 | ||
| 155 | except ValidationError as exc: | 185 | except ValidationError as exc: |
| 156 | errors.update({(e['loc'][0] if e['loc'] else 'verify_password'): e['msg'] for e in exc.errors()}) | 186 | errors.update( |
| 187 | { | ||
| 188 | (e["loc"][0] if e["loc"] else "verify_password"): e["msg"] | ||
| 189 | for e in exc.errors() | ||
| 190 | } | ||
| 191 | ) | ||
| 157 | 192 | ||
| 158 | return await base_register(request, form, errors) | 193 | return await base_register(request, form, errors) |
| 159 | 194 | ||
| @@ -162,49 +197,59 @@ async def base_authorize(request: Request, user: User | None = None): | |||
| 162 | return auth_server.create_authorization_response(request, request.user, grant=None) | 197 | return auth_server.create_authorization_response(request, request.user, grant=None) |
| 163 | 198 | ||
| 164 | 199 | ||
| 165 | @oauth.get('/authorize/') | 200 | @oauth.get("/authorize/") |
| 166 | @requires(['authenticated', 'session'], redirect='get_login') | 201 | @requires(["authenticated", "session"], redirect="get_login") |
| 167 | async def get_authorize(request: Request): | 202 | async def get_authorize(request: Request): |
| 168 | return await base_authorize(request, request.user) | 203 | return await base_authorize(request, request.user) |
| 169 | 204 | ||
| 170 | 205 | ||
| 171 | @oauth.post('/authorize/') | 206 | @oauth.post("/authorize/") |
| 172 | @requires(['authenticated', 'session'], redirect='get_login') | 207 | @requires(["authenticated", "session"], redirect="get_login") |
| 173 | async def post_authorize(request: Request): | 208 | async def post_authorize(request: Request): |
| 174 | return await base_authorize(request, request.user) | 209 | return await base_authorize(request, request.user) |
| 175 | 210 | ||
| 176 | 211 | ||
| 177 | @oauth.post('/token/') | 212 | @oauth.post("/token/") |
| 178 | async def post_token(request: Request): | 213 | async def post_token(request: Request): |
| 179 | await prepare_oauth_request(request) | 214 | await prepare_oauth_request(request) |
| 180 | return auth_server.create_token_response(request) | 215 | return auth_server.create_token_response(request) |
| 181 | 216 | ||
| 182 | 217 | ||
| 183 | @authentication.get('/connect/') | 218 | @authentication.get("/connect/") |
| 184 | async def connect(request: Request): | 219 | async def connect(request: Request): |
| 185 | origin = request.url.scheme + '://' + request.url.netloc | 220 | origin = request.url.scheme + "://" + request.url.netloc |
| 186 | 221 | ||
| 187 | with get_session_context() as session: | 222 | with get_session_context() as session: |
| 188 | app = session.exec(select(ClientApplication)).first() | 223 | app = session.exec(select(ClientApplication)).first() |
| 189 | 224 | ||
| 190 | return RedirectResponse( | 225 | return RedirectResponse( |
| 191 | '/oauth' + get_url_path_with_query(oauth, 'get_authorize', { | 226 | "/oauth" |
| 192 | 'client_id': app.client_id, | 227 | + get_url_path_with_query( |
| 193 | 'redirect_uri': f'{origin}/oauth' + get_url_path_with_query(oauth, 'callback'), | 228 | oauth, |
| 194 | 'response_type': 'code', | 229 | "get_authorize", |
| 195 | 'state': b64encode(json.dumps({ | 230 | { |
| 196 | 'client_id': app.client_id, | 231 | "client_id": app.client_id, |
| 197 | }).encode()).decode(), | 232 | "redirect_uri": f"{origin}/oauth" |
| 198 | }), | 233 | + get_url_path_with_query(oauth, "callback"), |
| 234 | "response_type": "code", | ||
| 235 | "state": b64encode( | ||
| 236 | json.dumps( | ||
| 237 | { | ||
| 238 | "client_id": app.client_id, | ||
| 239 | } | ||
| 240 | ).encode() | ||
| 241 | ).decode(), | ||
| 242 | }, | ||
| 243 | ), | ||
| 199 | status_code=303, | 244 | status_code=303, |
| 200 | ) | 245 | ) |
| 201 | 246 | ||
| 202 | 247 | ||
| 203 | @oauth.get('/config/') | 248 | @oauth.get("/config/") |
| 204 | async def get_config(request: Request): | 249 | async def get_config(request: Request): |
| 205 | with get_session_context() as session: | 250 | with get_session_context() as session: |
| 206 | app = session.exec(select(ClientApplication)).first() | 251 | app = session.exec(select(ClientApplication)).first() |
| 207 | 252 | ||
| 208 | return { | 253 | return { |
| 209 | 'client_id': app.client_id, | 254 | "client_id": app.client_id, |
| 210 | } | 255 | } |
diff --git a/authentication/mixins.py b/authentication/mixins.py index 3d6a91f..70816a1 100644 --- a/authentication/mixins.py +++ b/authentication/mixins.py | |||
| @@ -1,7 +1,11 @@ | |||
| 1 | import json | 1 | import json |
| 2 | import time | 2 | import time |
| 3 | 3 | ||
| 4 | from authlib.integrations.sqla_oauth2 import OAuth2ClientMixin as BaseOAuth2ClientMixin, OAuth2AuthorizationCodeMixin as BaseOAuth2AuthorizationCodeMixin, OAuth2TokenMixin as BaseOAuth2TokenMixin | 4 | from authlib.integrations.sqla_oauth2 import ( |
| 5 | OAuth2AuthorizationCodeMixin as BaseOAuth2AuthorizationCodeMixin, | ||
| 6 | ) | ||
| 7 | from authlib.integrations.sqla_oauth2 import OAuth2ClientMixin as BaseOAuth2ClientMixin | ||
| 8 | from authlib.integrations.sqla_oauth2 import OAuth2TokenMixin as BaseOAuth2TokenMixin | ||
| 5 | from sqlalchemy.sql.sqltypes import Text | 9 | from sqlalchemy.sql.sqltypes import Text |
| 6 | from sqlmodel import Field | 10 | from sqlmodel import Field |
| 7 | 11 | ||
| @@ -11,9 +15,9 @@ class OAuth2ClientMixin(BaseOAuth2ClientMixin): | |||
| 11 | client_secret: str = Field(max_length=120, nullable=True) | 15 | client_secret: str = Field(max_length=120, nullable=True) |
| 12 | client_id_issued_at: int = Field(nullable=False, default=0) | 16 | client_id_issued_at: int = Field(nullable=False, default=0) |
| 13 | client_secret_expires_at: int = Field(nullable=False, default=0) | 17 | client_secret_expires_at: int = Field(nullable=False, default=0) |
| 14 | client_metadata_field: str = Field(sa_type=Text, sa_column_kwargs={ | 18 | client_metadata_field: str = Field( |
| 15 | 'name': 'client_metadata' | 19 | sa_type=Text, sa_column_kwargs={"name": "client_metadata"}, nullable=True |
| 16 | }, nullable=True) | 20 | ) |
| 17 | 21 | ||
| 18 | _client_metadata = None | 22 | _client_metadata = None |
| 19 | 23 | ||
diff --git a/authentication/utils.py b/authentication/utils.py index 4f19bc3..b0c9989 100644 --- a/authentication/utils.py +++ b/authentication/utils.py | |||
| @@ -15,11 +15,11 @@ def bcrypt_sha256_hash(password: str) -> str: | |||
| 15 | def bcrypt_sha256_verify(password: str, hashed: str) -> bool: | 15 | def bcrypt_sha256_verify(password: str, hashed: str) -> bool: |
| 16 | digest = hashlib.sha256(password.encode("utf-8")).digest() | 16 | digest = hashlib.sha256(password.encode("utf-8")).digest() |
| 17 | encoded = base64.b64encode(digest) | 17 | encoded = base64.b64encode(digest) |
| 18 | return bcrypt.checkpw(encoded, hashed.encode('utf-8')) | 18 | return bcrypt.checkpw(encoded, hashed.encode("utf-8")) |
| 19 | 19 | ||
| 20 | 20 | ||
| 21 | def get_url_path_with_query(app: FastAPI, name: str, params: dict | None = None, /, **path_params: str): | 21 | def get_url_path_with_query( |
| 22 | app: FastAPI, name: str, params: dict | None = None, /, **path_params: str | ||
| 23 | ): | ||
| 22 | base = app.url_path_for(name, **path_params) | 24 | base = app.url_path_for(name, **path_params) |
| 23 | return base + '?' + urlencode(params) if params is not None else base | 25 | return base + "?" + urlencode(params) if params is not None else base |
| 24 | |||
| 25 | |||
