From 49e6966ac4634dadd1f440b1827567e4ae93d6fd Mon Sep 17 00:00:00 2001 From: everysoftware Date: Sat, 24 May 2025 00:45:49 +0300 Subject: [PATCH 1/3] feat: add password recovery --- examples/httpx.py | 1 + fastid/auth/dependencies.py | 26 ++++-- fastid/auth/permissions.py | 10 +-- fastid/auth/use_cases.py | 4 +- fastid/notify/clients/smtp.py | 3 +- fastid/notify/router.py | 37 ++++----- fastid/notify/schemas.py | 25 ++++-- fastid/notify/use_cases.py | 47 ++++++++--- fastid/oauth/dependencies.py | 14 ---- fastid/oauth/router.py | 19 +++-- fastid/pages/dependencies.py | 8 +- fastid/pages/router.py | 65 ++++++++------- fastid/profile/router.py | 14 ++-- static/js/change_email.js | 2 +- static/js/restore_account.js | 30 +++++++ static/js/verify_action.js | 7 +- templates/pages/authorize.html | 12 ++- templates/pages/change-password.html | 22 ++++-- templates/pages/restore-account.html | 79 +++++++++++++++++++ templates/pages/telegram-redirect.html | 11 --- tests/api/conftest.py | 4 +- tests/api/notify/test_notify_otp.py | 38 +++++++-- ...get_verify_token.py => test_verify_otp.py} | 18 +++-- tests/api/profile/test_delete_user.py | 8 +- tests/api/profile/test_update_user_email.py | 12 +-- .../api/profile/test_update_user_password.py | 8 +- 26 files changed, 353 insertions(+), 171 deletions(-) create mode 100644 static/js/restore_account.js create mode 100644 templates/pages/restore-account.html delete mode 100644 templates/pages/telegram-redirect.html rename tests/api/notify/{test_get_verify_token.py => test_verify_otp.py} (75%) diff --git a/examples/httpx.py b/examples/httpx.py index aa3d24a..10e53dc 100644 --- a/examples/httpx.py +++ b/examples/httpx.py @@ -16,6 +16,7 @@ def login(request: Request) -> Any: "response_type": "code", "client_id": settings.client_id, "redirect_uri": request.url_for("callback"), + "scope": ["openid"], } url = f"{settings.fastid_url}/authorize?{urlencode(params)}" return RedirectResponse(url=url) diff --git a/fastid/auth/dependencies.py b/fastid/auth/dependencies.py index da3ece6..93cf19f 100644 --- a/fastid/auth/dependencies.py +++ b/fastid/auth/dependencies.py @@ -18,11 +18,6 @@ header_transport = HeaderTransport() cookie_transport = CookieTransport(name="fastidaccesstoken", max_age=auth_settings.jwt_access_expires_in) -verify_token_transport = CookieTransport( - name="fastidverifytoken", - scheme_name="VerifyTokenCookie", - max_age=auth_settings.jwt_verify_token_expires_in, -) auth_bus = AuthBus(header_transport, cookie_transport) @@ -36,8 +31,24 @@ async def get_user( user_dep = Depends(get_user) UserDep = Annotated[User, user_dep] +vt_transport = CookieTransport( + name="fastidverifytoken", + scheme_name="VerifyTokenCookie", + max_age=auth_settings.jwt_verify_token_expires_in, +) + -async def get_optional_user(service: AuthDep, request: Request) -> User | None: +async def get_user_by_vt( + service: AuthDep, + token: Annotated[str, Depends(vt_transport)], +) -> User: + return await service.get_userinfo(token, token_type="verify") # noqa: S106 + + +UserVTDep = Annotated[User, Depends(get_user_by_vt)] + + +async def get_user_or_none(service: AuthDep, request: Request) -> User | None: token = auth_bus.parse_request(request, auto_error=False) if token is None: return None @@ -45,3 +56,6 @@ async def get_optional_user(service: AuthDep, request: Request) -> User | None: return await service.get_userinfo(token) except ClientError: return None + + +UserOrNoneDep = Annotated[User | None, Depends(get_user_or_none)] diff --git a/fastid/auth/permissions.py b/fastid/auth/permissions.py index 49e9596..ea007c5 100644 --- a/fastid/auth/permissions.py +++ b/fastid/auth/permissions.py @@ -1,6 +1,4 @@ -from fastapi import Request - -from fastid.auth.dependencies import UserDep, verify_token_transport +from fastid.auth.dependencies import UserDep from fastid.auth.exceptions import NoPermissionError from fastid.auth.models import User from fastid.security.jwt import jwt_backend @@ -13,26 +11,20 @@ def __init__( superuser: bool | None = None, email_verified: bool | None = None, active: bool | None = None, - action_verified: bool | None = None, ) -> None: self.superuser = superuser self.email_verified = email_verified self.active = active - self.action_verified = action_verified self.token_backend = jwt_backend async def __call__( self, user: UserDep, - request: Request, ) -> User: - verify_token = verify_token_transport.get_token(request) if self.superuser is not None and user.is_superuser != self.superuser: raise NoPermissionError if self.email_verified is not None and user.is_verified != self.email_verified: # pragma: nocover raise NoPermissionError if self.active is not None and user.is_active != self.active: # pragma: nocover raise NoPermissionError - if self.action_verified and (verify_token is None or not self.token_backend.validate("verify", verify_token)): - raise NoPermissionError return user diff --git a/fastid/auth/use_cases.py b/fastid/auth/use_cases.py index 7a0e53b..3ee0ac6 100644 --- a/fastid/auth/use_cases.py +++ b/fastid/auth/use_cases.py @@ -27,9 +27,9 @@ async def register(self, dto: UserCreate) -> User: await self.uow.commit() return user - async def get_userinfo(self, token: str) -> User: + async def get_userinfo(self, token: str, *, token_type: str = "access") -> User: # noqa: S107 try: - payload = jwt_backend.validate("access", token) + payload = jwt_backend.validate(token_type, token) except FastLinkError as e: raise InvalidTokenError from e try: diff --git a/fastid/notify/clients/smtp.py b/fastid/notify/clients/smtp.py index e316b44..b37cb2b 100644 --- a/fastid/notify/clients/smtp.py +++ b/fastid/notify/clients/smtp.py @@ -32,8 +32,7 @@ def _send(self, notification: Notification) -> None: self._client.send_message(msg) async def __aenter__(self) -> Self: - self._client.__enter__() return self async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: - self._client.__exit__(exc_type, exc_val, exc_tb) + pass diff --git a/fastid/notify/router.py b/fastid/notify/router.py index b313e9e..b870cb4 100644 --- a/fastid/notify/router.py +++ b/fastid/notify/router.py @@ -3,34 +3,31 @@ from fastapi import APIRouter, BackgroundTasks, Depends from starlette.responses import JSONResponse -from fastid.auth.dependencies import UserDep, verify_token_transport +from fastid.auth.dependencies import UserOrNoneDep, vt_transport from fastid.notify.dependencies import NotifyDep -from fastid.notify.schemas import OTPRequest, VerificationNotification, VerifyTokenRequest +from fastid.notify.schemas import SendOTPRequest, VerifyOTPRequest -router = APIRouter(prefix="/notify", tags=["Notify"]) +router = APIRouter() -@router.post("/otp") -def otp_request( - user: UserDep, - service: NotifyDep, - dto: Annotated[OTPRequest, Depends()], +@router.post("/otp/send", tags=["OTP"]) +async def send_otp( + user: UserOrNoneDep, + notify_service: NotifyDep, + dto: Annotated[SendOTPRequest, Depends()], background: BackgroundTasks, ) -> None: - if dto.new_email is None: - notification = VerificationNotification(user) - else: - notification = VerificationNotification(user, method="email", new_email=dto.new_email) - background.add_task(service.push_code, notification) + notification = await notify_service.get_otp_notification(user, dto) + background.add_task(notify_service.push, notification) -@router.post("/verify-token") -async def verify_token_request( - user: UserDep, - service: NotifyDep, - dto: VerifyTokenRequest, +@router.post("/otp/verify", tags=["OTP"]) +async def verify_otp( + user: UserOrNoneDep, + notify_service: NotifyDep, + dto: VerifyOTPRequest, ) -> JSONResponse: - token = await service.authorize_with_code(user, dto) + token = await notify_service.verify_otp(user, dto) response = JSONResponse(content={"verify_token": token}) - verify_token_transport.set_token(response, token) + vt_transport.set_token(response, token) return response diff --git a/fastid/notify/schemas.py b/fastid/notify/schemas.py index 16bf5d5..d2ec857 100644 --- a/fastid/notify/schemas.py +++ b/fastid/notify/schemas.py @@ -1,16 +1,27 @@ from dataclasses import dataclass +from enum import StrEnum from fastid.core.schemas import BaseModel from fastid.notify.clients.schemas import Notification from fastid.notify.config import notify_settings -class OTPRequest(BaseModel): - new_email: str | None = None +class UnsafeAction(StrEnum): + change_email = "change-email" + change_password = "change-password" # noqa: S105 # pragma: allowlist secret + delete_account = "delete-account" + recover_password = "recover-password" # noqa: S105 # pragma: allowlist secret -class VerifyTokenRequest(BaseModel): +class SendOTPRequest(BaseModel): + action: UnsafeAction + email: str | None = None + + +class VerifyOTPRequest(BaseModel): + action: UnsafeAction code: str + email: str | None = None # Templates @@ -23,13 +34,13 @@ class WelcomeNotification(Notification): @dataclass -class VerificationNotification(Notification): - new_email: str | None = None +class OTPNotification(Notification): + email_override: str | None = None subject: str = "Your verification code" template: str = "code" @property def user_email(self) -> str: - if self.new_email is not None: - return self.new_email + if self.email_override is not None: + return self.email_override return super().user_email diff --git a/fastid/notify/use_cases.py b/fastid/notify/use_cases.py index fb18faf..5e51225 100644 --- a/fastid/notify/use_cases.py +++ b/fastid/notify/use_cases.py @@ -3,14 +3,18 @@ from fastlink.jwt.schemas import JWTPayload from fastid.auth.config import auth_settings +from fastid.auth.exceptions import EmailNotFoundError from fastid.auth.models import User +from fastid.auth.repositories import UserEmailSpecification from fastid.cache.dependencies import CacheDep from fastid.cache.exceptions import KeyNotFoundError from fastid.core.base import UseCase +from fastid.database.dependencies import UOWDep +from fastid.database.exceptions import NoResultFoundError from fastid.notify.clients.dependencies import MailDep, TelegramDep from fastid.notify.clients.schemas import Notification from fastid.notify.exceptions import WrongCodeError -from fastid.notify.schemas import VerifyTokenRequest +from fastid.notify.schemas import OTPNotification, SendOTPRequest, UnsafeAction, VerifyOTPRequest from fastid.security.crypto import generate_otp from fastid.security.jwt import jwt_backend @@ -18,14 +22,28 @@ class NotificationUseCases(UseCase): def __init__( self, + uow: UOWDep, mail: MailDep, telegram: TelegramDep, cache: CacheDep, ) -> None: + self.uow = uow self.mail = mail self.telegram = telegram self.cache = cache + async def get_otp_notification(self, user: User | None, dto: SendOTPRequest) -> OTPNotification: + if dto.action == UnsafeAction.recover_password: + assert dto.email is not None + user = await self._get_user_by_email(dto.email) + assert user is not None + if dto.action in UnsafeAction.change_email: + notification = OTPNotification(user, method="email", email_override=dto.email) + else: + notification = OTPNotification(user) + notification.extra["code"] = await self._get_otp(user) + return notification + async def push(self, notification: Notification) -> None: method: str method = notification.user.notification_method if notification.method == "auto" else notification.method @@ -37,24 +55,33 @@ async def push(self, notification: Notification) -> None: case _: # pragma: nocover raise ValueError(f"Unknown method: {method}") - async def push_code(self, notification: Notification) -> None: + async def verify_otp(self, user: User | None, dto: VerifyOTPRequest) -> str: + if dto.action == UnsafeAction.recover_password: + assert dto.email is not None + user = await self._get_user_by_email(dto.email) + assert user is not None + await self._validate_otp(user, dto.code) + return jwt_backend.create("verify", JWTPayload(sub=str(user.id))) + + async def _get_user_by_email(self, email: str) -> User: + try: + return await self.uow.users.find(UserEmailSpecification(email)) + except NoResultFoundError as e: + raise EmailNotFoundError from e + + async def _get_otp(self, user: User) -> str: code = generate_otp() - notification.extra["code"] = code await self.cache.set( - f"otp:users:{notification.user.id}", + f"otp:users:{user.id}", code, expire=auth_settings.verification_code_expires_in, ) - await self.push(notification) + return code - async def validate_code(self, user: User, code: str) -> None: + async def _validate_otp(self, user: User, code: str) -> None: try: user_code = await self.cache.pop(f"otp:users:{user.id}") except KeyNotFoundError as e: raise WrongCodeError from e if not secrets.compare_digest(user_code, code): raise WrongCodeError - - async def authorize_with_code(self, user: User, request: VerifyTokenRequest) -> str: - await self.validate_code(user, request.code) - return jwt_backend.create("verify", JWTPayload(sub=str(user.id))) diff --git a/fastid/oauth/dependencies.py b/fastid/oauth/dependencies.py index 0c6aabe..35459bb 100644 --- a/fastid/oauth/dependencies.py +++ b/fastid/oauth/dependencies.py @@ -1,21 +1,7 @@ from typing import Annotated from fastapi import Depends -from fastlink.schemas import OAuth2Callback -from fastlink.telegram.schemas import TelegramCallback -from starlette.requests import Request -from fastid.core.dependencies import log from fastid.oauth.use_cases import OAuthUseCases OAuthAccountsDep = Annotated[OAuthUseCases, Depends()] - - -def valid_callback(request: Request) -> OAuth2Callback: - log.info("OAuth callback received: request_url=%s", str(request.url)) - return OAuth2Callback.model_validate(request.query_params) - - -def valid_telegram_callback(request: Request) -> TelegramCallback: - log.info("OAuth callback received: request_url=%s", str(request.url)) - return TelegramCallback.model_validate(request.query_params) diff --git a/fastid/oauth/router.py b/fastid/oauth/router.py index 02b5f55..04315b0 100644 --- a/fastid/oauth/router.py +++ b/fastid/oauth/router.py @@ -6,14 +6,15 @@ from fastlink.schemas import OAuth2Callback from fastlink.telegram.schemas import TelegramCallback from starlette import status +from starlette.requests import Request from starlette.responses import HTMLResponse from fastid.auth.config import auth_settings -from fastid.auth.dependencies import UserDep, cookie_transport, get_optional_user -from fastid.auth.models import User +from fastid.auth.dependencies import UserDep, UserOrNoneDep, cookie_transport +from fastid.core.dependencies import log from fastid.database.schemas import PageDTO from fastid.oauth.clients.dependencies import get_telegram_sso -from fastid.oauth.dependencies import OAuthAccountsDep, valid_callback, valid_telegram_callback +from fastid.oauth.dependencies import OAuthAccountsDep from fastid.oauth.schemas import InspectProviderResponse, OAuthAccountDTO router = APIRouter(prefix="/oauth", tags=["OAuth"]) @@ -51,9 +52,11 @@ async def oauth_login( ) async def telegram_callback( service: OAuthAccountsDep, - user: Annotated[User | None, Depends(get_optional_user)], - callback: Annotated[TelegramCallback, Depends(valid_telegram_callback)], + request: Request, + user: UserOrNoneDep, + callback: Annotated[TelegramCallback, Depends()], ) -> Any: + log.info("OAuth callback received: request_url=%s", str(request.url)) response: Response = RedirectResponse(url=auth_settings.authorization_endpoint) if user is not None: await service.connect(user, "telegram", callback) @@ -70,10 +73,12 @@ async def telegram_callback( ) async def oauth_callback( service: OAuthAccountsDep, - user: Annotated[User | None, Depends(get_optional_user)], + request: Request, + user: UserOrNoneDep, provider: str, - callback: Annotated[OAuth2Callback, Depends(valid_callback)], + callback: Annotated[OAuth2Callback, Depends()], ) -> Any: + log.info("OAuth callback received: request_url=%s", str(request.url)) response: Response = RedirectResponse(url=auth_settings.authorization_endpoint) if user is not None: await service.connect(user, provider, callback) diff --git a/fastid/pages/dependencies.py b/fastid/pages/dependencies.py index bdd17de..dd2e161 100644 --- a/fastid/pages/dependencies.py +++ b/fastid/pages/dependencies.py @@ -5,7 +5,7 @@ from starlette.requests import Request from fastid.api.exceptions import ClientError, UnauthorizedError -from fastid.auth.dependencies import AuthDep, cookie_transport, verify_token_transport +from fastid.auth.dependencies import AuthDep, cookie_transport, vt_transport from fastid.auth.grants import AuthorizationCodeGrant from fastid.auth.models import User from fastid.auth.schemas import OAuth2ConsentRequest @@ -14,7 +14,7 @@ ) -async def get_optional_user( +async def get_user_or_none( auth: AuthDep, request: Request, ) -> User | None: @@ -40,10 +40,10 @@ async def get_user( raise UnauthorizedError from e -def action_verified( +def is_action_verified( request: Request, ) -> bool: - token = verify_token_transport.get_token(request) + token = vt_transport.get_token(request) if token is None: return False try: diff --git a/fastid/pages/router.py b/fastid/pages/router.py index 095114b..63a749a 100644 --- a/fastid/pages/router.py +++ b/fastid/pages/router.py @@ -1,4 +1,4 @@ -from typing import Annotated, Any, Literal +from typing import Annotated, Any from fastapi import APIRouter, Depends, Request, Response, status from fastapi.responses import RedirectResponse @@ -8,11 +8,12 @@ from fastid.auth.grants import AuthorizationCodeGrant from fastid.auth.models import User from fastid.auth.schemas import OAuth2ConsentRequest +from fastid.notify.schemas import UnsafeAction from fastid.oauth.dependencies import OAuthAccountsDep from fastid.pages.dependencies import ( - action_verified, - get_optional_user, get_user, + get_user_or_none, + is_action_verified, valid_consent, ) from fastid.pages.openid import discovery_document, jwks @@ -29,7 +30,7 @@ def index() -> Response: @router.get("/register") def register( request: Request, - user: Annotated[User | None, Depends(get_optional_user)], + user: Annotated[User | None, Depends(get_user_or_none)], ) -> Response: if user: return RedirectResponse(url="/profile") @@ -39,7 +40,7 @@ def register( @router.get("/login") def login( request: Request, - user: Annotated[User | None, Depends(get_optional_user)], + user: Annotated[User | None, Depends(get_user_or_none)], ) -> Response: if user: return RedirectResponse(url="/profile") @@ -49,13 +50,15 @@ def login( @router.get("/authorize") async def authorize( request: Request, - user: Annotated[User | None, Depends(get_optional_user)], + user: Annotated[User | None, Depends(get_user_or_none)], consent: Annotated[OAuth2ConsentRequest, Depends(valid_consent)], authorization_code_grant: Annotated[AuthorizationCodeGrant, Depends()], ) -> Response: if user is None: + assert consent.client_id is not None + app = await authorization_code_grant.validate_client(consent.client_id) request.session["consent"] = consent.model_dump(mode="json") - return templates.TemplateResponse("authorize.html", {"request": request}) + return templates.TemplateResponse("authorize.html", {"request": request, "app": app}) # User is authenticated, redirect to specified redirect URI with code request.session.clear() redirect_uri = await authorization_code_grant.approve_consent(consent, user) @@ -76,45 +79,53 @@ async def profile( ) +@router.get("/restore") +def restore_account( + request: Request, +) -> Response: + return templates.TemplateResponse( + "restore-account.html", + {"request": request}, + ) + + @router.get("/verify-action") def verify_action( request: Request, - user: Annotated[User, Depends(get_user)], - verified: Annotated[bool, Depends(action_verified)], - action: Literal["change-email", "change-password", "delete-account"], + verified: Annotated[bool, Depends(is_action_verified)], + action: UnsafeAction, ) -> Response: if verified: return RedirectResponse(f"/{action}") return templates.TemplateResponse( "verify-action.html", - {"request": request, "user": user}, + {"request": request}, ) -@router.get("/change-email") -def change_email( +@router.get("/change-password") +def change_password( request: Request, - user: Annotated[User, Depends(get_user)], - verified: Annotated[bool, Depends(action_verified)], -) -> Any: + verified: Annotated[bool, Depends(is_action_verified)], +) -> Response: if not verified: - return RedirectResponse("/verify-action?action=change-email") + return RedirectResponse(f"/verify-action?action={UnsafeAction.change_password}") return templates.TemplateResponse( - "change-email.html", - {"request": request, "user": user}, + "change-password.html", + {"request": request}, ) -@router.get("/change-password") -def change_password( +@router.get("/change-email") +def change_email( request: Request, user: Annotated[User, Depends(get_user)], - verified: Annotated[bool, Depends(action_verified)], -) -> Response: + verified: Annotated[bool, Depends(is_action_verified)], +) -> Any: if not verified: - return RedirectResponse("/verify-action?action=change-password") + return RedirectResponse(f"/verify-action?action={UnsafeAction.change_email}") return templates.TemplateResponse( - "change-password.html", + "change-email.html", {"request": request, "user": user}, ) @@ -123,10 +134,10 @@ def change_password( def delete_account( request: Request, user: Annotated[User, Depends(get_user)], - verified: Annotated[bool, Depends(action_verified)], + verified: Annotated[bool, Depends(is_action_verified)], ) -> Response: if not verified: - return RedirectResponse("/verify-action?action=delete-account") + return RedirectResponse(f"/verify-action?action={UnsafeAction.delete_account}") return templates.TemplateResponse( "delete-account.html", {"request": request, "user": user}, diff --git a/fastid/profile/router.py b/fastid/profile/router.py index c7670b6..0ea3d73 100644 --- a/fastid/profile/router.py +++ b/fastid/profile/router.py @@ -1,9 +1,8 @@ from typing import Any -from fastapi import APIRouter, Depends, status +from fastapi import APIRouter, status -from fastid.auth.dependencies import UserDep -from fastid.auth.permissions import Requires +from fastid.auth.dependencies import UserDep, UserVTDep from fastid.auth.schemas import ( UserChangeEmail, UserChangePassword, @@ -26,29 +25,26 @@ async def patch( @router.patch( "/users/me/email", - dependencies=[Depends(Requires(action_verified=True))], response_model=UserDTO, status_code=status.HTTP_200_OK, ) -async def change_email(service: ProfilesDep, user: UserDep, dto: UserChangeEmail) -> Any: +async def change_email(service: ProfilesDep, user: UserVTDep, dto: UserChangeEmail) -> Any: return await service.change_email(user, dto) @router.patch( "/users/me/password", - dependencies=[Depends(Requires(action_verified=True))], response_model=UserDTO, status_code=status.HTTP_200_OK, ) -async def change_password(service: ProfilesDep, user: UserDep, dto: UserChangePassword) -> Any: +async def change_password(service: ProfilesDep, user: UserVTDep, dto: UserChangePassword) -> Any: return await service.change_password(user, dto) @router.delete( "/users/me", - dependencies=[Depends(Requires(action_verified=True))], response_model=UserDTO, status_code=status.HTTP_200_OK, ) -async def delete(service: ProfilesDep, user: UserDep) -> Any: +async def delete(service: ProfilesDep, user: UserVTDep) -> Any: return await service.delete_account(user) diff --git a/static/js/change_email.js b/static/js/change_email.js index 49b07de..0e12736 100644 --- a/static/js/change_email.js +++ b/static/js/change_email.js @@ -10,7 +10,7 @@ document.addEventListener("DOMContentLoaded", () => { e.preventDefault(); const email = document.getElementById("newEmail").value; const params = {new_email: email}; - const response = await profileClient.post("/notify/otp", params); + const response = await profileClient.post("/otp/send?action=change-email", params); if (response.ok) { codeInputContainer.classList.remove("d-none"); confirmButton.disabled = false; diff --git a/static/js/restore_account.js b/static/js/restore_account.js new file mode 100644 index 0000000..caf020e --- /dev/null +++ b/static/js/restore_account.js @@ -0,0 +1,30 @@ +'use strict'; + +import {profileClient} from "./dependencies.js"; + +document.addEventListener("DOMContentLoaded", () => { + const codeInputContainer = document.getElementById("codeInputContainer"); + const confirmButton = document.getElementById("confirmButton"); + + document.getElementById("sendCodeForm").addEventListener("submit", async (e) => { + e.preventDefault(); + const email = document.getElementById("email").value; + const params = {action: "recover-password", email: email}; + const response = await profileClient.post("/otp/send", params); + if (response.ok) { + codeInputContainer.classList.remove("d-none"); + confirmButton.disabled = false; + } + }); + + document.getElementById("restoreAccountForm").addEventListener("submit", async (e) => { + e.preventDefault(); + const email = document.getElementById("email").value; + const code = document.getElementById("fullCodeInput").value; + const body = {action: "recover-password", code: code, email: email}; + const response = await profileClient.post("/otp/verify", {}, body); + if (response.ok) { + location.assign("/verify-action?action=change-password"); + } + }); +}); diff --git a/static/js/verify_action.js b/static/js/verify_action.js index 3dcb8a3..6c6efb8 100644 --- a/static/js/verify_action.js +++ b/static/js/verify_action.js @@ -3,7 +3,9 @@ import {profileClient} from "./dependencies.js"; document.addEventListener("DOMContentLoaded", async () => { - await profileClient.post("/notify/otp"); + const urlParams = new URLSearchParams(window.location.search); + const action = urlParams.get('action'); + await profileClient.post(`/otp/send?action=${action}`); document.getElementById("nextButton").addEventListener("click", async (e) => { e.preventDefault(); const code = document.getElementById("fullCodeInput").value; @@ -12,9 +14,10 @@ document.addEventListener("DOMContentLoaded", async () => { return; } const body = { + action: action, code: code, }; - const response = await profileClient.post("/notify/verify-token", {}, body); + const response = await profileClient.post("/otp/verify", {}, body); if (response.ok) { location.reload(); } diff --git a/templates/pages/authorize.html b/templates/pages/authorize.html index 1381d21..2c6d41b 100644 --- a/templates/pages/authorize.html +++ b/templates/pages/authorize.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}Login | {{ app_title }} {% endblock %} +{% block title %}Sign in | {{ app_title }} {% endblock %} {% block style %} .divider:after, @@ -29,7 +29,13 @@
-

Sign in

+ {% if app %} +

Sign in to {{ app.name }}

+

By continuing, {{ app_title }} will share your name, email address, and telegram with {{ app.name }}. See {{ app.name }}’s Privacy Policy and Terms of Service.

+ {% else %} +

Sign in

+ {% endif %} +
@@ -49,7 +55,7 @@

Sign in

{% if providers_meta.any_enabled %} diff --git a/templates/pages/change-password.html b/templates/pages/change-password.html index cf3b565..3ce259d 100644 --- a/templates/pages/change-password.html +++ b/templates/pages/change-password.html @@ -3,14 +3,24 @@ {% block title %}Change Password | {{ app_title }}{% endblock %} {% block nav %} - - + {% if user %} + + + {% else %} + + + {% endif %} {% endblock %} + {% block scripts %} {% endblock %} diff --git a/templates/pages/restore-account.html b/templates/pages/restore-account.html new file mode 100644 index 0000000..0b98992 --- /dev/null +++ b/templates/pages/restore-account.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} + +{% block title %}Restore account | {{ app_title }}{% endblock %} + +{% block nav %} + + +{% endblock %} + +{% block scripts %} + +{% endblock %} + +{% block main %} +
+
+
+
+
+

Restore account

+

+ Enter your email address. If it exists in {{ app_title }}, we will send a one-time code to it. +

+ +
+ + +
+ + + + +
+
+ +
+ + + + + + +
+ + +
+
+ +
+
Tips
+
    +
  • + Check your spam folder if you don’t receive the code immediately. +
  • +
+
+
+
+
+
+
+{% endblock %} diff --git a/templates/pages/telegram-redirect.html b/templates/pages/telegram-redirect.html deleted file mode 100644 index 1a01256..0000000 --- a/templates/pages/telegram-redirect.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{{ app_title }}{% endblock %} -{% block scripts %} - -{% endblock %} - -{% block main %} -

Redirecting...

-{% endblock %} diff --git a/tests/api/conftest.py b/tests/api/conftest.py index 3956989..31c5ead 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -12,6 +12,7 @@ from fastid.cache.storage import CacheStorage from fastid.core.dependencies import log_provider from fastid.database.uow import SQLAlchemyUOW +from fastid.notify.schemas import UnsafeAction from fastid.security.crypto import generate_otp from tests import mocks from tests.utils.auth import authorize_password_grant, register_user @@ -71,8 +72,9 @@ async def verify_token(client: AsyncClient, cache: CacheStorage, user: UserDTO, await cache.set(f"otp:users:{user.id}", code) response = await client.post( - "/notify/verify-token", + "/otp/verify", json={ + "action": UnsafeAction.change_password, "code": code, }, headers={"Authorization": f"Bearer {user_token.access_token}"}, diff --git a/tests/api/notify/test_notify_otp.py b/tests/api/notify/test_notify_otp.py index cc0627b..2fbec6e 100644 --- a/tests/api/notify/test_notify_otp.py +++ b/tests/api/notify/test_notify_otp.py @@ -4,33 +4,55 @@ from fastid.auth.schemas import UserDTO from fastid.cache.storage import CacheStorage +from fastid.notify.schemas import UnsafeAction from tests.mocks import faker -async def test_notify_otp_email( - client: AsyncClient, cache: CacheStorage, user: UserDTO, user_token: TokenResponse -) -> None: - response = await client.post("/notify/otp", headers={"Authorization": f"Bearer {user_token.access_token}"}) +async def test_send_otp(client: AsyncClient, cache: CacheStorage, user: UserDTO, user_token: TokenResponse) -> None: + response = await client.post( + f"/otp/send?action={UnsafeAction.change_password}", + headers={"Authorization": f"Bearer {user_token.access_token}"}, + ) assert response.status_code == status.HTTP_200_OK await cache.get(f"otp:users:{user.id}") -async def test_notify_otp_new_email( +async def test_send_otp_change_email( client: AsyncClient, cache: CacheStorage, user: UserDTO, user_token: TokenResponse ) -> None: response = await client.post( - f"/notify/otp?new_email={faker.email()}", headers={"Authorization": f"Bearer {user_token.access_token}"} + f"/otp/send?action={UnsafeAction.change_email}&email={faker.email()}", + headers={"Authorization": f"Bearer {user_token.access_token}"}, ) assert response.status_code == status.HTTP_200_OK await cache.get(f"otp:users:{user.id}") -async def test_notify_otp_telegram( +async def test_send_otp_recover_password(client: AsyncClient, cache: CacheStorage, user: UserDTO) -> None: + response = await client.post( + f"/otp/send?action={UnsafeAction.recover_password}&email={user.email}", + ) + assert response.status_code == status.HTTP_200_OK + + await cache.get(f"otp:users:{user.id}") + + +async def test_send_otp_recover_password_not_exists(client: AsyncClient, cache: CacheStorage, user: UserDTO) -> None: + response = await client.post( + f"/otp/send?action={UnsafeAction.recover_password}&email={faker.email()}", + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +async def test_send_otp_telegram( client: AsyncClient, cache: CacheStorage, user_tg: UserDTO, user_tg_token: TokenResponse ) -> None: - response = await client.post("/notify/otp", headers={"Authorization": f"Bearer {user_tg_token.access_token}"}) + response = await client.post( + f"/otp/send?action={UnsafeAction.change_password}", + headers={"Authorization": f"Bearer {user_tg_token.access_token}"}, + ) assert response.status_code == status.HTTP_200_OK await cache.get(f"otp:users:{user_tg.id}") diff --git a/tests/api/notify/test_get_verify_token.py b/tests/api/notify/test_verify_otp.py similarity index 75% rename from tests/api/notify/test_get_verify_token.py rename to tests/api/notify/test_verify_otp.py index c619240..611bdd3 100644 --- a/tests/api/notify/test_get_verify_token.py +++ b/tests/api/notify/test_verify_otp.py @@ -4,18 +4,18 @@ from fastid.auth.schemas import UserDTO from fastid.cache.storage import CacheStorage +from fastid.notify.schemas import UnsafeAction from fastid.security.crypto import generate_otp -async def test_get_verify_token( - client: AsyncClient, cache: CacheStorage, user: UserDTO, user_token: TokenResponse -) -> None: +async def test_verify_otp(client: AsyncClient, cache: CacheStorage, user: UserDTO, user_token: TokenResponse) -> None: code = generate_otp() await cache.set(f"otp:users:{user.id}", code) response = await client.post( - "/notify/verify-token", + "/otp/verify", json={ + "action": UnsafeAction.change_password, "code": code, }, headers={"Authorization": f"Bearer {user_token.access_token}"}, @@ -25,13 +25,14 @@ async def test_get_verify_token( assert content["verify_token"] is not None -async def test_get_verify_token_not_exists( +async def test_verify_otp_not_exists( client: AsyncClient, cache: CacheStorage, user: UserDTO, user_token: TokenResponse ) -> None: code = generate_otp() response = await client.post( - "/notify/verify-token", + "/otp/verify", json={ + "action": UnsafeAction.change_password, "code": code, }, headers={"Authorization": f"Bearer {user_token.access_token}"}, @@ -39,15 +40,16 @@ async def test_get_verify_token_not_exists( assert response.status_code == status.HTTP_400_BAD_REQUEST -async def test_get_verify_token_wrong_code( +async def test_verify_otp_wrong_code( client: AsyncClient, cache: CacheStorage, user: UserDTO, user_token: TokenResponse ) -> None: code = generate_otp() fake_code = generate_otp() await cache.set(f"otp:users:{user.id}", code) response = await client.post( - "/notify/verify-token", + "/otp/verify", json={ + "action": UnsafeAction.change_password, "code": fake_code, }, headers={"Authorization": f"Bearer {user_token.access_token}"}, diff --git a/tests/api/profile/test_delete_user.py b/tests/api/profile/test_delete_user.py index cd7ab98..11173b0 100644 --- a/tests/api/profile/test_delete_user.py +++ b/tests/api/profile/test_delete_user.py @@ -2,17 +2,17 @@ from httpx import AsyncClient from starlette import status -from fastid.auth.dependencies import verify_token_transport +from fastid.auth.dependencies import vt_transport from fastid.auth.schemas import UserDTO async def test_delete_user(client: AsyncClient, user: UserDTO, user_token: TokenResponse, verify_token: str) -> None: - client.cookies.set(verify_token_transport.name, verify_token) + client.cookies.set(vt_transport.name, verify_token) response = await client.delete( "/users/me", headers={"Authorization": f"Bearer {user_token.access_token}"}, ) - client.cookies.delete(verify_token_transport.name) + client.cookies.delete(vt_transport.name) assert response.status_code == status.HTTP_200_OK UserDTO.model_validate_json(response.content) @@ -22,4 +22,4 @@ async def test_delete_user_not_verified(client: AsyncClient, user: UserDTO, user "/users/me", headers={"Authorization": f"Bearer {user_token.access_token}"}, ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_401_UNAUTHORIZED diff --git a/tests/api/profile/test_update_user_email.py b/tests/api/profile/test_update_user_email.py index d2dd461..82f78ff 100644 --- a/tests/api/profile/test_update_user_email.py +++ b/tests/api/profile/test_update_user_email.py @@ -2,7 +2,7 @@ from httpx import AsyncClient from starlette import status -from fastid.auth.dependencies import verify_token_transport +from fastid.auth.dependencies import vt_transport from fastid.auth.schemas import UserDTO from tests.mocks import faker @@ -11,13 +11,13 @@ async def test_update_user_email( client: AsyncClient, user: UserDTO, user_token: TokenResponse, verify_token: str ) -> None: new_email = faker.email() - client.cookies.set(verify_token_transport.name, verify_token) + client.cookies.set(vt_transport.name, verify_token) response = await client.patch( "/users/me/email", headers={"Authorization": f"Bearer {user_token.access_token}"}, json={"new_email": new_email}, ) - client.cookies.delete(verify_token_transport.name) + client.cookies.delete(vt_transport.name) assert response.status_code == status.HTTP_200_OK user = UserDTO.model_validate_json(response.content) assert user.email == new_email @@ -27,13 +27,13 @@ async def test_update_user_email_already_exists( client: AsyncClient, user: UserDTO, user_token: TokenResponse, user_tg: UserDTO, verify_token: str ) -> None: new_email = user_tg.email - client.cookies.set(verify_token_transport.name, verify_token) + client.cookies.set(vt_transport.name, verify_token) response = await client.patch( "/users/me/email", headers={"Authorization": f"Bearer {user_token.access_token}"}, json={"new_email": new_email}, ) - client.cookies.delete(verify_token_transport.name) + client.cookies.delete(vt_transport.name) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -44,4 +44,4 @@ async def test_update_user_email_not_verified(client: AsyncClient, user: UserDTO headers={"Authorization": f"Bearer {user_token.access_token}"}, json={"new_email": new_email}, ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_401_UNAUTHORIZED diff --git a/tests/api/profile/test_update_user_password.py b/tests/api/profile/test_update_user_password.py index 503df66..900e8b1 100644 --- a/tests/api/profile/test_update_user_password.py +++ b/tests/api/profile/test_update_user_password.py @@ -2,7 +2,7 @@ from httpx import AsyncClient from starlette import status -from fastid.auth.dependencies import verify_token_transport +from fastid.auth.dependencies import vt_transport from fastid.auth.schemas import UserDTO from tests.mocks import faker @@ -11,13 +11,13 @@ async def test_update_user_password( client: AsyncClient, user: UserDTO, user_token: TokenResponse, verify_token: str ) -> None: new_password = faker.password() - client.cookies.set(verify_token_transport.name, verify_token) + client.cookies.set(vt_transport.name, verify_token) response = await client.patch( "/users/me/password", headers={"Authorization": f"Bearer {user_token.access_token}"}, json={"password": new_password}, ) - client.cookies.delete(verify_token_transport.name) + client.cookies.delete(vt_transport.name) assert response.status_code == status.HTTP_200_OK UserDTO.model_validate_json(response.content) @@ -29,4 +29,4 @@ async def test_update_user_password_not_verified(client: AsyncClient, user: User headers={"Authorization": f"Bearer {user_token.access_token}"}, json={"password": new_password}, ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_401_UNAUTHORIZED From 186ace24c2adbe74b6d71dc57dfd84c6c05667e0 Mon Sep 17 00:00:00 2001 From: everysoftware Date: Sat, 24 May 2025 01:46:46 +0300 Subject: [PATCH 2/3] feat: add fastlink example to docs --- README.md | 31 ++++---------- docs/docs/tutorial/get_started.md | 71 +++++++++++++++++++++---------- examples/fastlink.py | 7 +-- examples/httpx.py | 26 +++-------- fastid/oauth/schemas.py | 3 +- fastid/oauth/use_cases.py | 1 + poetry.lock | 14 +++--- pyproject.toml | 2 +- 8 files changed, 75 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index a6d84b8..dbf1b76 100644 --- a/README.md +++ b/README.md @@ -99,11 +99,11 @@ Here is an example of how to use FastID for authentication in a Python applicati [FastAPI](https://fastapi.tiangolo.com/) framework and the [httpx](https://www.python-httpx.org/) library. ```python -from typing import Any, Annotated +from typing import Any from urllib.parse import urlencode import httpx -from fastapi import FastAPI, Response, Request, Depends, HTTPException, status +from fastapi import FastAPI, Request from fastapi.responses import RedirectResponse FASTID_URL = "http://localhost:8012" @@ -119,6 +119,7 @@ def login(request: Request) -> Any: "response_type": "code", "client_id": FASTID_CLIENT_ID, "redirect_uri": request.url_for("callback"), + "scope": "openid", } url = f"{FASTID_URL}/authorize?{urlencode(params)}" return RedirectResponse(url=url) @@ -126,7 +127,7 @@ def login(request: Request) -> Any: @app.get("/callback") def callback(code: str) -> Any: - token_data = httpx.post( + response = httpx.post( f"{FASTID_URL}/api/v1/token", headers={"Content-Type": "application/x-www-form-urlencoded"}, data={ @@ -136,35 +137,20 @@ def callback(code: str) -> Any: "code": code, }, ) - token = token_data.json() - response = Response(content="You are now logged in!") - response.set_cookie("access_token", token["access_token"]) - return response - - -def current_user(request: Request) -> dict[str, Any]: - token = request.cookies.get("access_token") - if not token: - raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No access token") + token = response.json()["access_token"] response = httpx.get( f"{FASTID_URL}/api/v1/userinfo", headers={"Authorization": f"Bearer {token}"}, ) return response.json() - -@app.get("/test") -def test(user: Annotated[dict[str, Any], Depends(current_user)]) -> Any: - return user ``` -In this example, we define three routes: +In this example, we define two routes: 1. `/login`: Redirects the user to the FastID authorization page. 2. `/callback`: Handles the callback from FastID after the user has logged in. It exchanges the authorization code for - an access token and sets it as a cookie. -3. `/test`: A protected route that requires the user to be logged in. It retrieves the user's information from FastID - using the access token. + an access token and retrieves the user's information. Run the FastAPI application: @@ -173,8 +159,7 @@ fastapi dev examples/httpx.py ``` Visit [http://localhost:8000/login](http://localhost:8000/login) to start the authentication process. After logging in, -you will be redirected to the `/callback` route, where the access token will be set as a cookie. You can then -access the `/test` route to retrieve the user's information. +you will be redirected to the `/callback` route, where you can see the user's information. ![Test Response](img/test_response.png) diff --git a/docs/docs/tutorial/get_started.md b/docs/docs/tutorial/get_started.md index 6807815..c0075e5 100644 --- a/docs/docs/tutorial/get_started.md +++ b/docs/docs/tutorial/get_started.md @@ -1,5 +1,7 @@ # Get Started +## Create App + To start using FastID, you need to [create](http://localhost:8012/admin/app/create) an application in the admin panel. This will allow you to use FastID for authentication in your application. @@ -9,15 +11,17 @@ authentication in your application. Once you have created an application, you can use the standard OAuth 2.0 flow to authenticate users. FastID supports the authorization code flow, which is the most secure and recommended way to authenticate users. +## HTTPX example + Here is an example of how to use FastID for authentication in a Python application using the [FastAPI](https://fastapi.tiangolo.com/) framework and the [httpx](https://www.python-httpx.org/) library. ```python -from typing import Any, Annotated +from typing import Any from urllib.parse import urlencode import httpx -from fastapi import FastAPI, Response, Request, Depends, HTTPException, status +from fastapi import FastAPI, Request from fastapi.responses import RedirectResponse FASTID_URL = "http://localhost:8012" @@ -33,6 +37,7 @@ def login(request: Request) -> Any: "response_type": "code", "client_id": FASTID_CLIENT_ID, "redirect_uri": request.url_for("callback"), + "scope": "openid", } url = f"{FASTID_URL}/authorize?{urlencode(params)}" return RedirectResponse(url=url) @@ -40,7 +45,7 @@ def login(request: Request) -> Any: @app.get("/callback") def callback(code: str) -> Any: - token_data = httpx.post( + response = httpx.post( f"{FASTID_URL}/api/v1/token", headers={"Content-Type": "application/x-www-form-urlencoded"}, data={ @@ -50,35 +55,56 @@ def callback(code: str) -> Any: "code": code, }, ) - token = token_data.json() - response = Response(content="You are now logged in!") - response.set_cookie("access_token", token["access_token"]) - return response - - -def current_user(request: Request) -> dict[str, Any]: - token = request.cookies.get("access_token") - if not token: - raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No access token") + token = response.json()["access_token"] response = httpx.get( f"{FASTID_URL}/api/v1/userinfo", headers={"Authorization": f"Bearer {token}"}, ) return response.json() +``` + +## FastLink example + +You can also use the [FastLink](https://github.com/everysoftware/fastlink) as a faster and safer way: -@app.get("/test") -def test(user: Annotated[dict[str, Any], Depends(current_user)]) -> Any: - return user +```python +from typing import Annotated, Any + +from fastapi import Depends, FastAPI +from fastapi.responses import RedirectResponse +from fastlink import FastLink +from fastlink.schemas import OAuth2Callback, ProviderMeta + +app = FastAPI() +fastid = FastLink( + ProviderMeta(server_url="http://localhost:8012", scope=["openid"]), + ..., # Client ID + ..., # Client Secret + "http://localhost:8000/callback", +) + + +@app.get("/login") +async def login() -> Any: + async with fastid: + url = await fastid.login_url() + return RedirectResponse(url=url) + + +@app.get("/callback") +async def callback(call: Annotated[OAuth2Callback, Depends()]) -> Any: + async with fastid: + return await fastid.callback_raw(call) ``` -In this example, we define three routes: +## Results + +In this example, we define two routes: 1. `/login`: Redirects the user to the FastID authorization page. 2. `/callback`: Handles the callback from FastID after the user has logged in. It exchanges the authorization code for - an access token and sets it as a cookie. -3. `/test`: A protected route that requires the user to be logged in. It retrieves the user's information from FastID - using the access token. + an access token and retrieves the user's information. Run the FastAPI application: @@ -87,7 +113,6 @@ fastapi dev examples/httpx.py ``` Visit [http://localhost:8000/login](http://localhost:8000/login) to start the authentication process. After logging in, -you will be redirected to the `/callback` route, where the access token will be set as a cookie. You can then -access the `/test` route to retrieve the user's information. +you will be redirected to the `/callback` route, where you can see the user's information. -![Sign In](../img/test_response.png) +![Test Response](../img/test_response.png) diff --git a/examples/fastlink.py b/examples/fastlink.py index b4d73e3..e9f5c18 100644 --- a/examples/fastlink.py +++ b/examples/fastlink.py @@ -4,7 +4,6 @@ from fastapi.responses import RedirectResponse from fastlink import FastLink from fastlink.schemas import OAuth2Callback, ProviderMeta -from starlette.responses import JSONResponse from examples.config import settings @@ -25,8 +24,6 @@ async def login() -> Any: @app.get("/callback") -async def oauth_callback(callback: Annotated[OAuth2Callback, Depends()]) -> Any: +async def callback(call: Annotated[OAuth2Callback, Depends()]) -> Any: async with fastid: - await fastid.login(callback) - user = await fastid.userinfo() - return JSONResponse(content=user) + return await fastid.callback_raw(call) diff --git a/examples/httpx.py b/examples/httpx.py index 10e53dc..854e757 100644 --- a/examples/httpx.py +++ b/examples/httpx.py @@ -1,8 +1,8 @@ -from typing import Annotated, Any +from typing import Any from urllib.parse import urlencode import httpx -from fastapi import Depends, FastAPI, HTTPException, Request, Response, status +from fastapi import FastAPI, Request from fastapi.responses import RedirectResponse from examples.config import settings @@ -16,7 +16,7 @@ def login(request: Request) -> Any: "response_type": "code", "client_id": settings.client_id, "redirect_uri": request.url_for("callback"), - "scope": ["openid"], + "scope": "openid", } url = f"{settings.fastid_url}/authorize?{urlencode(params)}" return RedirectResponse(url=url) @@ -24,7 +24,7 @@ def login(request: Request) -> Any: @app.get("/callback") def callback(code: str) -> Any: - token_data = httpx.post( + response = httpx.post( f"{settings.fastid_url}/api/v1/token", headers={"Content-Type": "application/x-www-form-urlencoded"}, data={ @@ -34,23 +34,9 @@ def callback(code: str) -> Any: "code": code, }, ) - token = token_data.json() - response = Response(content="You are now logged in!") - response.set_cookie("access_token", token["access_token"]) - return response - - -def current_user(request: Request) -> dict[str, Any]: - token = request.cookies.get("access_token") - if not token: - raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No access token") + token = response.json()["access_token"] response = httpx.get( f"{settings.fastid_url}/api/v1/userinfo", headers={"Authorization": f"Bearer {token}"}, ) - return response.json() # type: ignore[no-any-return] - - -@app.get("/test") -def test(user: Annotated[dict[str, Any], Depends(current_user)]) -> Any: - return user + return response.json() diff --git a/fastid/oauth/schemas.py b/fastid/oauth/schemas.py index 8287286..565813b 100644 --- a/fastid/oauth/schemas.py +++ b/fastid/oauth/schemas.py @@ -1,6 +1,6 @@ from collections.abc import MutableMapping, Sequence -from fastlink.schemas import OpenID, ProviderMeta, TokenResponse +from fastlink.schemas import DiscoveryDocument, OpenID, ProviderMeta, TokenResponse from pydantic import Field from fastid.core.schemas import BaseModel @@ -36,6 +36,7 @@ class OAuthAccountDTO(EntityDTO, OAuthAccountBase): class InspectProviderResponse(BaseModel): meta: ProviderMeta + discovery: DiscoveryDocument login_url: str diff --git a/fastid/oauth/use_cases.py b/fastid/oauth/use_cases.py index 65cc8af..c24d982 100644 --- a/fastid/oauth/use_cases.py +++ b/fastid/oauth/use_cases.py @@ -34,6 +34,7 @@ async def inspect(self, provider: str) -> InspectProviderResponse: login_url = await session.login_url() return InspectProviderResponse( meta=session.meta, + discovery=session.discovery, login_url=login_url, ) diff --git a/poetry.lock b/poetry.lock index 5d3c2eb..68af424 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1005,14 +1005,14 @@ standard = ["uvicorn[standard] (>=0.15.0)"] [[package]] name = "fastlink" -version = "0.1.5" +version = "0.1.6" description = "FastLink OAuth 2.0 client for various platforms, asynchronous, easy-to-use, extensible" optional = false python-versions = "<4.0,>=3.12" groups = ["main"] files = [ - {file = "fastlink-0.1.5-py3-none-any.whl", hash = "sha256:e02f8d2bccaaa0875cf0bdd14be4701d5680885543f95e0f976fae307b60d880"}, - {file = "fastlink-0.1.5.tar.gz", hash = "sha256:3e0d73e2b667d0e56b06a6fed91a11a05ef3f3e797255c0dac58faeabff3496b"}, + {file = "fastlink-0.1.6-py3-none-any.whl", hash = "sha256:77f1ceb8af24145f4dce43d24569b019305d5a76053702dc94ffdf84885fb239"}, + {file = "fastlink-0.1.6.tar.gz", hash = "sha256:0c95a19a1042acfaf4f01d0371fd22fc291bbe323f0e320eddf6c14170d66471"}, ] [package.dependencies] @@ -1488,14 +1488,14 @@ tests = ["freezegun", "pytest", "pytest-cov"] [[package]] name = "identify" -version = "2.6.10" +version = "2.6.12" description = "File identification library for Python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "identify-2.6.10-py2.py3-none-any.whl", hash = "sha256:5f34248f54136beed1a7ba6a6b5c4b6cf21ff495aac7c359e1ef831ae3b8ab25"}, - {file = "identify-2.6.10.tar.gz", hash = "sha256:45e92fd704f3da71cc3880036633f48b4b7265fd4de2b57627cb157216eb7eb8"}, + {file = "identify-2.6.12-py2.py3-none-any.whl", hash = "sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2"}, + {file = "identify-2.6.12.tar.gz", hash = "sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6"}, ] [package.extras] @@ -4273,4 +4273,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "51059f57905e1e9f44b55cc265867699c0f1f973206f4eeae55e7f225a022164" +content-hash = "ed1fff8e511e95e0297ac2d82bd22c1cb34f915a906db1752c6a23659eaccc33" diff --git a/pyproject.toml b/pyproject.toml index 3f249ec..9c41f4b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ opentelemetry-instrumentation-httpx = "^0.52b1" fast-depends = "^2.4.12" uuid-utils = "^0.9.0" typer = "^0.14.0" -fastlink = "^0.1.3" +fastlink = "^0.1.6" [tool.poetry.group.dev.dependencies] From ab8832f7345424844cccd6026e078791ef8963fc Mon Sep 17 00:00:00 2001 From: everysoftware Date: Sat, 24 May 2025 02:07:24 +0300 Subject: [PATCH 3/3] feat: code validation in change email flow --- fastid/auth/schemas.py | 1 + fastid/notify/use_cases.py | 18 ++++++------ fastid/profile/router.py | 4 ++- static/js/change_email.js | 4 +-- tests/api/profile/test_update_user_email.py | 32 ++++++++++++++++----- 5 files changed, 40 insertions(+), 19 deletions(-) diff --git a/fastid/auth/schemas.py b/fastid/auth/schemas.py index f5b5824..0ce2e97 100644 --- a/fastid/auth/schemas.py +++ b/fastid/auth/schemas.py @@ -37,6 +37,7 @@ class UserUpdate(BaseModel): class UserChangeEmail(BaseModel): new_email: str + code: str class UserChangePassword(BaseModel): diff --git a/fastid/notify/use_cases.py b/fastid/notify/use_cases.py index 5e51225..d8572dd 100644 --- a/fastid/notify/use_cases.py +++ b/fastid/notify/use_cases.py @@ -55,12 +55,20 @@ async def push(self, notification: Notification) -> None: case _: # pragma: nocover raise ValueError(f"Unknown method: {method}") + async def validate_otp(self, user: User, code: str) -> None: + try: + user_code = await self.cache.pop(f"otp:users:{user.id}") + except KeyNotFoundError as e: + raise WrongCodeError from e + if not secrets.compare_digest(user_code, code): + raise WrongCodeError + async def verify_otp(self, user: User | None, dto: VerifyOTPRequest) -> str: if dto.action == UnsafeAction.recover_password: assert dto.email is not None user = await self._get_user_by_email(dto.email) assert user is not None - await self._validate_otp(user, dto.code) + await self.validate_otp(user, dto.code) return jwt_backend.create("verify", JWTPayload(sub=str(user.id))) async def _get_user_by_email(self, email: str) -> User: @@ -77,11 +85,3 @@ async def _get_otp(self, user: User) -> str: expire=auth_settings.verification_code_expires_in, ) return code - - async def _validate_otp(self, user: User, code: str) -> None: - try: - user_code = await self.cache.pop(f"otp:users:{user.id}") - except KeyNotFoundError as e: - raise WrongCodeError from e - if not secrets.compare_digest(user_code, code): - raise WrongCodeError diff --git a/fastid/profile/router.py b/fastid/profile/router.py index 0ea3d73..6c3a765 100644 --- a/fastid/profile/router.py +++ b/fastid/profile/router.py @@ -9,6 +9,7 @@ UserDTO, UserUpdate, ) +from fastid.notify.dependencies import NotifyDep from fastid.profile.dependencies import ProfilesDep router = APIRouter(tags=["Users"]) @@ -28,7 +29,8 @@ async def patch( response_model=UserDTO, status_code=status.HTTP_200_OK, ) -async def change_email(service: ProfilesDep, user: UserVTDep, dto: UserChangeEmail) -> Any: +async def change_email(service: ProfilesDep, notify_service: NotifyDep, user: UserVTDep, dto: UserChangeEmail) -> Any: + await notify_service.validate_otp(user, dto.code) return await service.change_email(user, dto) diff --git a/static/js/change_email.js b/static/js/change_email.js index 0e12736..5f80518 100644 --- a/static/js/change_email.js +++ b/static/js/change_email.js @@ -9,8 +9,8 @@ document.addEventListener("DOMContentLoaded", () => { document.getElementById("sendCodeForm").addEventListener("submit", async (e) => { e.preventDefault(); const email = document.getElementById("newEmail").value; - const params = {new_email: email}; - const response = await profileClient.post("/otp/send?action=change-email", params); + const params = {action: "change-email", email: email}; + const response = await profileClient.post("/otp/send", params); if (response.ok) { codeInputContainer.classList.remove("d-none"); confirmButton.disabled = false; diff --git a/tests/api/profile/test_update_user_email.py b/tests/api/profile/test_update_user_email.py index 82f78ff..36facfe 100644 --- a/tests/api/profile/test_update_user_email.py +++ b/tests/api/profile/test_update_user_email.py @@ -4,18 +4,23 @@ from fastid.auth.dependencies import vt_transport from fastid.auth.schemas import UserDTO +from fastid.cache.storage import CacheStorage +from fastid.security.crypto import generate_otp from tests.mocks import faker async def test_update_user_email( - client: AsyncClient, user: UserDTO, user_token: TokenResponse, verify_token: str + client: AsyncClient, cache: CacheStorage, user: UserDTO, user_token: TokenResponse, verify_token: str ) -> None: + code = generate_otp() + await cache.set(f"otp:users:{user.id}", code) + new_email = faker.email() client.cookies.set(vt_transport.name, verify_token) response = await client.patch( "/users/me/email", headers={"Authorization": f"Bearer {user_token.access_token}"}, - json={"new_email": new_email}, + json={"new_email": new_email, "code": code}, ) client.cookies.delete(vt_transport.name) assert response.status_code == status.HTTP_200_OK @@ -23,25 +28,38 @@ async def test_update_user_email( assert user.email == new_email -async def test_update_user_email_already_exists( - client: AsyncClient, user: UserDTO, user_token: TokenResponse, user_tg: UserDTO, verify_token: str +async def test_update_user_email_already_exists( # noqa: PLR0913 + client: AsyncClient, + cache: CacheStorage, + user: UserDTO, + user_token: TokenResponse, + user_tg: UserDTO, + verify_token: str, ) -> None: + code = generate_otp() + await cache.set(f"otp:users:{user.id}", code) + new_email = user_tg.email client.cookies.set(vt_transport.name, verify_token) response = await client.patch( "/users/me/email", headers={"Authorization": f"Bearer {user_token.access_token}"}, - json={"new_email": new_email}, + json={"new_email": new_email, "code": code}, ) client.cookies.delete(vt_transport.name) assert response.status_code == status.HTTP_400_BAD_REQUEST -async def test_update_user_email_not_verified(client: AsyncClient, user: UserDTO, user_token: TokenResponse) -> None: +async def test_update_user_email_not_verified( + client: AsyncClient, cache: CacheStorage, user: UserDTO, user_token: TokenResponse +) -> None: + code = generate_otp() + await cache.set(f"otp:users:{user.id}", code) + new_email = faker.email() response = await client.patch( "/users/me/email", headers={"Authorization": f"Bearer {user_token.access_token}"}, - json={"new_email": new_email}, + json={"new_email": new_email, "code": code}, ) assert response.status_code == status.HTTP_401_UNAUTHORIZED