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.  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. - + 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 aa3d24a..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,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) @@ -23,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={ @@ -33,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/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/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/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..d8572dd 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,17 +55,7 @@ async def push(self, notification: Notification) -> None: case _: # pragma: nocover raise ValueError(f"Unknown method: {method}") - async def push_code(self, notification: Notification) -> None: - code = generate_otp() - notification.extra["code"] = code - await self.cache.set( - f"otp:users:{notification.user.id}", - code, - expire=auth_settings.verification_code_expires_in, - ) - await self.push(notification) - - 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: @@ -55,6 +63,25 @@ async def validate_code(self, user: User, code: str) -> None: 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) + 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() + await self.cache.set( + f"otp:users:{user.id}", + code, + expire=auth_settings.verification_code_expires_in, + ) + return code 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/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/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..6c3a765 100644 --- a/fastid/profile/router.py +++ b/fastid/profile/router.py @@ -1,15 +1,15 @@ 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, UserDTO, UserUpdate, ) +from fastid.notify.dependencies import NotifyDep from fastid.profile.dependencies import ProfilesDep router = APIRouter(tags=["Users"]) @@ -26,29 +26,27 @@ 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, notify_service: NotifyDep, user: UserVTDep, dto: UserChangeEmail) -> Any: + await notify_service.validate_otp(user, dto.code) 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/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] diff --git a/static/js/change_email.js b/static/js/change_email.js index 49b07de..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("/notify/otp", 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/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 @@