-
Notifications
You must be signed in to change notification settings - Fork 36
feat: Add OTP login endpoints and service abstraction #233
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| import { Button } from "@/components/ui/button.tsx"; | ||
| import { useEffect, useState } from "react"; | ||
| import { authenticationProviderInstance } from "@/lib/authentication-provider.ts"; | ||
| import { ApiClient } from "@/lib/api.ts"; | ||
| import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx"; | ||
| import { useNavigate } from "react-router-dom"; | ||
|
|
||
| const api = new ApiClient(authenticationProviderInstance); | ||
|
|
||
| interface OtpLoginFormProps { | ||
| onSuccess: (token: string, api: ApiClient) => Promise<void>; | ||
| } | ||
|
|
||
| export function OtpLoginForm({ onSuccess }: OtpLoginFormProps) { | ||
| const navigate = useNavigate(); | ||
| const [errorMessage, setErrorMessage] = useState<string | null>(null); | ||
| const onClick = () => { | ||
| window.location.href = `${import.meta.env.VITE_API_BASE}/api/auth/otp/start` | ||
| } | ||
| useEffect(() => { | ||
| const params = new URLSearchParams(window.location.search); | ||
| const token = params.get("token"); | ||
| const error = params.get("error") | ||
|
|
||
| if (token) { | ||
| onSuccess(token, api); | ||
| window.history.replaceState({}, document.title, window.location.pathname); | ||
| } | ||
| if (error) { | ||
| setErrorMessage(error); | ||
| window.history.replaceState({}, document.title, window.location.pathname); | ||
| } | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [navigate]); | ||
| return ( | ||
| <div className="grid gap-1"> | ||
| {errorMessage && ( | ||
| <Alert variant="destructive"> | ||
| <AlertTitle>Login failed</AlertTitle> | ||
| <AlertDescription>{errorMessage}</AlertDescription> | ||
| </Alert> | ||
| )} | ||
| <Button | ||
| variant="outline" | ||
| onClick={onClick} | ||
| > | ||
| OTP Login | ||
| </Button> | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,12 +1,20 @@ | ||
| import logging | ||
| import urllib.parse | ||
|
|
||
| from django.conf import settings | ||
| from django.contrib.auth import authenticate | ||
| from django.shortcuts import redirect | ||
| from drf_yasg import openapi | ||
| from drf_yasg.utils import swagger_auto_schema | ||
| from rest_framework.authtoken.models import Token | ||
| from rest_framework.response import Response | ||
| from rest_framework.views import APIView | ||
| from utils.functions import import_from_string | ||
|
|
||
| from account.serializers import TokenResponseSerializer | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class LoginView(APIView): | ||
| @swagger_auto_schema( | ||
|
|
@@ -30,3 +38,73 @@ def post(self, request): | |
| serializer = TokenResponseSerializer({"token": token.key}) | ||
| return Response(serializer.data) | ||
| return Response({}, status=403) | ||
|
|
||
|
|
||
| class OTPCallbackView(APIView): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't really see any use case for an one time password login flow besides our demo server - why is that in the core of the server?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I thought if we can enable it on FE(It points to specific endpoints) it will be consistent to have that also implemented by default on BE side, so we have clear messages even if it's not configured. But, if you think that's redundant I can move it away from this repo. Please let me know |
||
| DEFAULT_ERROR_MESSAGE = "Could not sign in. Please try again." | ||
|
|
||
| @swagger_auto_schema( | ||
| operation_description="Callback endpoint for authentication provider hosted OTP login. Exchanges code for access token and redirects.", | ||
| manual_parameters=[ | ||
| openapi.Parameter( | ||
| "code", | ||
| openapi.IN_QUERY, | ||
| description="Authorization code from auth provider", | ||
| type=openapi.TYPE_STRING, | ||
| required=True, | ||
| ), | ||
| ], | ||
| responses={ | ||
| 200: openapi.Response(description="User authenticated successfully"), | ||
| 400: "Bad Request", | ||
| 401: "Unauthorized", | ||
| }, | ||
| ) | ||
| def get(self, request): | ||
| code = request.query_params.get("code") | ||
| redirect_uri = f"{settings.FRONTEND_BASE_URL}/login" | ||
| try: | ||
| otp_service_class = import_from_string(settings.OTP_AUTH_SERVICE) | ||
| otp_service = otp_service_class() | ||
| if not otp_service.is_enabled(): | ||
| return redirect( | ||
| f"{settings.FRONTEND_BASE_URL}/login?{urllib.parse.urlencode({'error': otp_service.NOT_CONFIGURED_ERROR_MESSAGE})}" | ||
| ) | ||
| token = otp_service.get_token(code) | ||
| user_email = otp_service.get_email_from_token(token) | ||
| user = otp_service.create_user(email=user_email) | ||
| token = otp_service.create_token(user=user) | ||
| return redirect(f"{redirect_uri}?{urllib.parse.urlencode({'token': token.key})}") | ||
| except Exception as e: | ||
| logger.error(e, exc_info=True) | ||
| return redirect(f"{redirect_uri}?{urllib.parse.urlencode({'error': self.DEFAULT_ERROR_MESSAGE})}") | ||
|
|
||
|
|
||
| class OTPStartView(APIView): | ||
| DEFAULT_ERROR_MESSAGE = "Service unavailable, please try again later." | ||
|
|
||
| @swagger_auto_schema( | ||
| operation_description="Start the OTP login flow. Redirects user to the authentication provider hosted login page.", | ||
| responses={ | ||
| 302: openapi.Response(description="Redirect to authentication provider or frontend login with error"), | ||
| 400: "Bad Request", | ||
| 503: "Service Unavailable", | ||
| }, | ||
| ) | ||
| def get(self, request): | ||
| try: | ||
| otp_service_class = import_from_string(settings.OTP_AUTH_SERVICE) | ||
| otp_service = otp_service_class() | ||
| if not otp_service.is_enabled(): | ||
| return redirect( | ||
| f"{settings.FRONTEND_BASE_URL}/login?{urllib.parse.urlencode({'error': otp_service.NOT_CONFIGURED_ERROR_MESSAGE})}" | ||
| ) | ||
| auth_url = otp_service.get_redirect_url() | ||
|
|
||
| return redirect(auth_url) | ||
|
|
||
| except Exception as e: | ||
| logger.error(e, exc_info=True) | ||
| return redirect( | ||
| f"{settings.FRONTEND_BASE_URL}/login?{urllib.parse.urlencode({'error': self.DEFAULT_ERROR_MESSAGE})}" | ||
| ) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm a bit confused - is that SSO or OTP? These are two different concepts
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's OTP, I need to change old button label if that's what you mean.