From d3161f90cb9eeff09d43471559ba78f23a5a0f9c Mon Sep 17 00:00:00 2001 From: Nikita Tikhonov Date: Tue, 20 Feb 2024 20:24:41 +0300 Subject: [PATCH] Implement `/api/v1/authorize` endpoint. --- melody/kit/authorization/code.py | 25 ++++++++++++++ melody/kit/authorization/context.py | 6 ++-- melody/kit/dependencies/scopes.py | 20 ++++++++++++ melody/kit/endpoints/v1/auth.py | 49 +++++++++++++++++++++++++--- melody/kit/endpoints/v1/playlists.py | 14 ++++---- melody/kit/endpoints/v1/self.py | 14 ++++---- melody/kit/endpoints/v1/users.py | 5 +-- melody/kit/errors/auth.py | 20 ++++++++++++ melody/kit/errors/core.py | 2 ++ pyproject.toml | 1 - schema/default.esdl | 2 +- 11 files changed, 134 insertions(+), 24 deletions(-) create mode 100644 melody/kit/authorization/code.py create mode 100644 melody/kit/dependencies/scopes.py diff --git a/melody/kit/authorization/code.py b/melody/kit/authorization/code.py new file mode 100644 index 0000000..bf300cc --- /dev/null +++ b/melody/kit/authorization/code.py @@ -0,0 +1,25 @@ +from attrs import frozen +from typing_extensions import Self +from melody.shared.converter import CONVERTER + +from melody.shared.typing import Data + +__all__ = ("AuthorizationCode", "AuthorizationCodeData") + + +class AuthorizationCodeData(Data): + code: str + state: str + + +@frozen() +class AuthorizationCode: + code: str + state: str + + @classmethod + def from_data(cls, data: AuthorizationCodeData) -> Self: + return CONVERTER.structure(data, cls) + + def into_data(self) -> AuthorizationCodeData: + return CONVERTER.unstructure(self) # type: ignore[no-any-return] diff --git a/melody/kit/authorization/context.py b/melody/kit/authorization/context.py index 19cc478..af85c32 100644 --- a/melody/kit/authorization/context.py +++ b/melody/kit/authorization/context.py @@ -16,18 +16,20 @@ class AuthorizationContextData(Data): - client_id: str user_id: str + client_id: str scope: str + redirect_uri: str @register_unstructure_hook_rename_scopes @register_structure_hook_rename_scopes @frozen() class AuthorizationContext: - client_id: UUID user_id: UUID + client_id: UUID scopes: Scopes + redirect_uri: str @classmethod def from_data(cls, data: AuthorizationContextData) -> Self: diff --git a/melody/kit/dependencies/scopes.py b/melody/kit/dependencies/scopes.py new file mode 100644 index 0000000..11a07e6 --- /dev/null +++ b/melody/kit/dependencies/scopes.py @@ -0,0 +1,20 @@ +from fastapi import Depends, Form +from typing_extensions import Annotated + +from melody.shared.tokens import Scopes + +__all__ = ( + # dependencies + "ScopesDependency", + # dependables + "scopes_dependency", +) + +FormScopeDependency = Annotated[str, Form()] + + +def scopes_dependency(scope: FormScopeDependency) -> Scopes: + return Scopes.from_scope(scope) + + +ScopesDependency = Annotated[Scopes, Depends(scopes_dependency)] diff --git a/melody/kit/endpoints/v1/auth.py b/melody/kit/endpoints/v1/auth.py index 7932530..60158dd 100644 --- a/melody/kit/endpoints/v1/auth.py +++ b/melody/kit/endpoints/v1/auth.py @@ -5,16 +5,27 @@ from fastapi import BackgroundTasks, Form from typing_aliases import NormalError from typing_extensions import Annotated +from melody.kit.authorization.code import AuthorizationCode, AuthorizationCodeData +from melody.kit.authorization.context import AuthorizationContext from melody.kit.authorization.dependencies import OptionalAuthorizationCodeDependency -from melody.kit.authorization.operations import delete_authorization_codes_with -from melody.kit.clients.dependencies import OptionalClientCredentialsDependency +from melody.kit.authorization.operations import ( + delete_authorization_codes_with, + generate_authorization_code_with, +) +from melody.kit.clients.dependencies import ( + FormClientCredentialsDependency, + OptionalClientCredentialsDependency, +) from melody.kit.core import config, database, hasher, v1 from melody.kit.dependencies.emails import EmailDeliverabilityDependency, EmailDependency +from melody.kit.dependencies.scopes import ScopesDependency from melody.kit.emails import email_message, send_email_message, support from melody.kit.enums import Tag from melody.kit.errors.auth import ( AuthAuthorizationCodeExpected, + AuthAuthorizationCodeRedirectURIExpected, + AuthAuthorizationCodeRedirectURIMismatch, AuthClientCredentialsExpected, AuthClientCredentialsMismatch, AuthEmailConflict, @@ -119,17 +130,41 @@ async def revoke(bound_token: BoundTokenDependency) -> None: await delete_access_token(bound_token.token) +RedirectURIDependency = Annotated[str, Form()] +StateDependency = Annotated[str, Form()] + + @v1.post("/authorize", tags=[Tag.AUTH], summary="Authorizes the client to access the user's data.") -async def authorize() -> None: - ... +async def authorize( + context: UserTokenDependency, + client_credentials: FormClientCredentialsDependency, + redirect_uri: RedirectURIDependency, + scopes: ScopesDependency, + state: StateDependency, +) -> AuthorizationCodeData: + authorization_context = AuthorizationContext( + user_id=context.user_id, + client_id=client_credentials.id, + scopes=scopes, + redirect_uri=redirect_uri, + ) + + code = await generate_authorization_code_with(authorization_context) + + authorization_code = AuthorizationCode(code, state) + + return authorization_code.into_data() GrantTypeDependency = Annotated[GrantType, Form()] +OptionalRedirectURIDependency = Annotated[Optional[str], Form()] + @v1.post("/tokens", tags=[Tag.AUTH], summary="Returns tokens.") async def tokens( grant_type: GrantTypeDependency, + redirect_uri: OptionalRedirectURIDependency = None, authorization_context: OptionalAuthorizationCodeDependency = None, bound_refresh_token: OptionalBoundRefreshTokenDependency = None, client_credentials: OptionalClientCredentialsDependency = None, @@ -140,12 +175,18 @@ async def tokens( if authorization_context is None: raise AuthAuthorizationCodeExpected() + if redirect_uri is None: + raise AuthAuthorizationCodeRedirectURIExpected() + if client_credentials is None: raise AuthClientCredentialsExpected() if client_credentials.id != authorization_context.client_id: raise AuthClientCredentialsMismatch() + if redirect_uri != authorization_context.redirect_uri: + raise AuthAuthorizationCodeRedirectURIMismatch() + await delete_authorization_codes_with(authorization_context) context = authorization_context.into_context() diff --git a/melody/kit/endpoints/v1/playlists.py b/melody/kit/endpoints/v1/playlists.py index 60dd5e6..145f630 100644 --- a/melody/kit/endpoints/v1/playlists.py +++ b/melody/kit/endpoints/v1/playlists.py @@ -1,7 +1,7 @@ from typing import Optional from uuid import UUID -from aiofiles import open as async_open +from async_extensions.path import Path from fastapi import Body, Depends from fastapi.responses import FileResponse from typing_extensions import Annotated @@ -182,9 +182,9 @@ async def get_playlist_link(playlist_id: UUID) -> FileResponse: async def get_playlist_image(playlist_id: UUID) -> FileResponse: uri = URI(type=EntityType.PLAYLIST, id=playlist_id) - path = config.image.path / uri.image_name + path = Path(config.image.path / uri.image_name) - if not path.exists(): + if not await path.exists(): raise PlaylistImageNotFound(playlist_id) return FileResponse(path) @@ -203,9 +203,9 @@ async def get_playlist_image(playlist_id: UUID) -> FileResponse: async def change_playlist_image(playlist_id: UUID, data: ImageDependency) -> None: uri = URI(type=EntityType.PLAYLIST, id=playlist_id) - path = config.image.path / uri.image_name + path = Path(config.image.path / uri.image_name) - async with async_open(path, WRITE_BINARY) as file: + async with await path.open(WRITE_BINARY) as file: await file.write(data) @@ -218,9 +218,9 @@ async def change_playlist_image(playlist_id: UUID, data: ImageDependency) -> Non async def remove_playlist_image(playlist_id: UUID) -> None: uri = URI(type=EntityType.PLAYLIST, id=playlist_id) - path = config.image.path / uri.image_name + path = Path(config.image.path / uri.image_name) - path.unlink(missing_ok=True) + await path.unlink(missing_ok=True) @v1.get( diff --git a/melody/kit/endpoints/v1/self.py b/melody/kit/endpoints/v1/self.py index 4fba91f..f08a8a1 100644 --- a/melody/kit/endpoints/v1/self.py +++ b/melody/kit/endpoints/v1/self.py @@ -1,7 +1,7 @@ from typing import List, Optional from uuid import UUID -from aiofiles import open as async_open +from async_extensions.path import Path from edgedb import QueryAssertionError from fastapi import Body, Depends from fastapi.responses import FileResponse @@ -127,9 +127,9 @@ async def get_self_image(context: ImageReadTokenDependency) -> FileResponse: uri = URI(type=EntityType.USER, id=self_id) - path = config.image.path / uri.image_name + path = Path(config.image.path / uri.image_name) - if not path.exists(): + if not await path.exists(): raise UserImageNotFound(self_id) return FileResponse(path) @@ -143,9 +143,9 @@ async def get_self_image(context: ImageReadTokenDependency) -> FileResponse: async def change_self_image(context: ImageWriteTokenDependency, data: ImageDependency) -> None: uri = URI(type=EntityType.USER, id=context.user_id) - path = config.image.path / uri.image_name + path = Path(config.image.path / uri.image_name) - async with async_open(path, WRITE_BINARY) as file: + async with await path.open(WRITE_BINARY) as file: await file.write(data) @@ -159,9 +159,9 @@ async def remove_self_image(context: ImageWriteTokenDependency) -> None: uri = URI(type=EntityType.USER, id=self_id) - path = config.image.path / uri.image_name + path = Path(config.image.path / uri.image_name) - path.unlink(missing_ok=True) + await path.unlink(missing_ok=True) @v1.get( diff --git a/melody/kit/endpoints/v1/users.py b/melody/kit/endpoints/v1/users.py index d7913d0..28b7a91 100644 --- a/melody/kit/endpoints/v1/users.py +++ b/melody/kit/endpoints/v1/users.py @@ -1,5 +1,6 @@ from uuid import UUID +from async_extensions.path import Path from fastapi import Depends from fastapi.responses import FileResponse from iters.iters import iter @@ -98,9 +99,9 @@ async def get_user_link(user_id: UUID) -> FileResponse: async def get_user_image(user_id: UUID) -> FileResponse: uri = URI(type=EntityType.USER, id=user_id) - path = config.image.path / uri.image_name + path = Path(config.image.path / uri.image_name) - if not path.exists(): + if not await path.exists(): raise UserImageNotFound(user_id) return FileResponse(path) diff --git a/melody/kit/errors/auth.py b/melody/kit/errors/auth.py index 06aecf6..18e37d9 100644 --- a/melody/kit/errors/auth.py +++ b/melody/kit/errors/auth.py @@ -33,6 +33,8 @@ "AuthAuthorizationCodeError", "AuthAuthorizationCodeExpected", "AuthAuthorizationCodeInvalid", + "AuthAuthorizationCodeRedirectURIExpected", + "AuthAuthorizationCodeRedirectURIMismatch", # verification "AuthVerificationCodeError", "AuthVerificationCodeInvalid", @@ -228,6 +230,24 @@ def __init__(self) -> None: super().__init__(INVALID_AUTHORIZATION_CODE) +REDIRECT_URI_EXPECTED = "redirect URI expected" + + +@default_code(ErrorCode.AUTH_AUTHORIZATION_CODE_REDIRECT_URI_EXPECTED) +class AuthAuthorizationCodeRedirectURIExpected(AuthAuthorizationCodeError): + def __init__(self) -> None: + super().__init__(REDIRECT_URI_EXPECTED) + + +REDIRECT_URI_MISMATCH = "redirect URI mismatch" + + +@default_code(ErrorCode.AUTH_AUTHORIZATION_CODE_REDIRECT_URI_MISMATCH) +class AuthAuthorizationCodeRedirectURIMismatch(AuthAuthorizationCodeError): + def __init__(self) -> None: + super().__init__(REDIRECT_URI_MISMATCH) + + @default_code(ErrorCode.AUTH_VERIFICATION_CODE_ERROR) class AuthVerificationCodeError(AuthError): pass diff --git a/melody/kit/errors/core.py b/melody/kit/errors/core.py index 8ecccc9..62386db 100644 --- a/melody/kit/errors/core.py +++ b/melody/kit/errors/core.py @@ -84,6 +84,8 @@ class ErrorCode(Enum): AUTH_AUTHORIZATION_CODE_ERROR = 13840 AUTH_AUTHORIZATION_CODE_EXPECTED = 13841 AUTH_AUTHORIZATION_CODE_INVALID = 13842 + AUTH_AUTHORIZATION_CODE_REDIRECT_URI_EXPECTED = 13843 + AUTH_AUTHORIZATION_CODE_REDIRECT_URI_MISMATCH = 13844 AUTH_VERIFICATION_CODE_ERROR = 13850 AUTH_VERIFICATION_CODE_INVALID = 13851 diff --git a/pyproject.toml b/pyproject.toml index 5b0b4de..103ce3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,7 +62,6 @@ pyotp = "^2.9.0" jinja2 = "^3.1.3" # melody.shared aiohttp = "^3.9.1" -aiofiles = "^23.2.1" cattrs = "^23.2.3" typing-aliases = "^1.4.1" yarl = "^1.9.4" diff --git a/schema/default.esdl b/schema/default.esdl index c6b987f..ae49305 100644 --- a/schema/default.esdl +++ b/schema/default.esdl @@ -246,7 +246,7 @@ module default { required duration_ms: duration_ms; } - type Client extending Named { + type Client extending Named, RedirectURLs { required creator: User { on target delete delete source; };