diff --git a/backend/app/rest/auth_routes.py b/backend/app/rest/auth_routes.py index d8fbc1a3..6dbded24 100644 --- a/backend/app/rest/auth_routes.py +++ b/backend/app/rest/auth_routes.py @@ -1,4 +1,5 @@ import os +import pyotp from ..utilities.exceptions.firebase_exceptions import ( InvalidPasswordException, TooManyLoginAttemptsException, @@ -6,7 +7,6 @@ from ..utilities.exceptions.auth_exceptions import EmailAlreadyInUseException from flask import Blueprint, current_app, jsonify, request -from twilio.rest import Client from ..middlewares.auth import ( require_authorization_by_user_id, @@ -44,7 +44,7 @@ blueprint = Blueprint("auth", __name__, url_prefix="/auth") -client = Client(os.getenv("TWILIO_ACCOUNT_SID"), os.getenv("TWILIO_AUTH_TOKEN")) +totp = pyotp.TOTP(os.getenv("TWO_FA_SECRET")) @blueprint.route("/login", methods=["POST"], strict_slashes=False) @@ -63,7 +63,7 @@ def login(): ) response = {"requires_two_fa": False, "auth_user": None} - if os.getenv("TWILIO_ENABLED") == "True" and auth_dto.role == "Relief Staff": + if os.getenv("TWO_FA_ENABLED") == "True" and auth_dto.role == "Relief Staff": response["requires_two_fa"] = True return jsonify(response), 200 @@ -100,27 +100,15 @@ def two_fa(): returns access token in response body and sets refreshToken as an httpOnly cookie only """ - passcode = request.args.get("passcode") - - if not passcode: - return ( - jsonify({"error": "Must supply passcode as a query parameter.t"}), - 400, - ) + passcode = request.args.get("passcode") if request.args.get("passcode") else "" try: - challenge = ( - client.verify.v2.services(os.getenv("TWILIO_SERVICE_SID")) - .entities(os.getenv("TWILIO_ENTITY_ID")) - .challenges.create( - auth_payload=passcode, factor_sid=os.getenv("TWILIO_FACTOR_SID") - ) - ) + verified = totp.verify(passcode) - if challenge.status != "approved": + if not verified: return ( - jsonify({"error": "Invalid passcode."}), - 400, + jsonify({"error": "Invalid passcode. Please try again."}), + 401, ) auth_dto = None @@ -131,7 +119,13 @@ def two_fa(): request.json["email"], request.json["password"] ) - auth_service.send_email_verification_link(request.json["email"]) + is_authorized_by_token = auth_service.is_authorized_by_token( + auth_dto.access_token + ) + + if not is_authorized_by_token: + auth_service.send_email_verification_link(request.json["email"]) + sign_in_logs_service.create_sign_in_log(auth_dto.id) response = jsonify( @@ -142,7 +136,7 @@ def two_fa(): "last_name": auth_dto.last_name, "email": auth_dto.email, "role": auth_dto.role, - "verified": auth_service.is_authorized_by_token(auth_dto.access_token), + "verified": is_authorized_by_token, } ) response.set_cookie( diff --git a/backend/requirements.txt b/backend/requirements.txt index 787afb20..a85c696e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -12,5 +12,5 @@ pytz alembic pytest black -twilio +pyotp urllib3==1.26.15 \ No newline at end of file diff --git a/frontend/src/APIClients/AuthAPIClient.ts b/frontend/src/APIClients/AuthAPIClient.ts index f71ae912..bc3bf0e9 100644 --- a/frontend/src/APIClients/AuthAPIClient.ts +++ b/frontend/src/APIClients/AuthAPIClient.ts @@ -7,7 +7,7 @@ import { AxiosError } from "axios"; import { getAuthErrMessage } from "../helper/error"; import AUTHENTICATED_USER_KEY from "../constants/AuthConstants"; import { AuthenticatedUser, AuthTokenResponse } from "../types/AuthTypes"; -import { AuthErrorResponse } from "../types/ErrorTypes"; +import { AuthErrorResponse, ErrorResponse } from "../types/ErrorTypes"; import baseAPIClient from "./BaseAPIClient"; import { getLocalStorageObjProperty, @@ -44,16 +44,25 @@ const twoFa = async ( passcode: string, email: string, password: string, -): Promise => { +): Promise => { try { - const { data } = await baseAPIClient.post( + const { data } = await baseAPIClient.post( `/auth/twoFa?passcode=${passcode}`, { email, password }, { withCredentials: true }, ); return data; } catch (error) { - return null; + const axiosErr = (error as any) as AxiosError; + if (axiosErr.response && axiosErr.response.status === 401) { + return { + errMessage: + axiosErr.response.data.error ?? "Invalid passcode. Please try again.", + }; + } + return { + errMessage: "Unable to authenticate. Please try again.", + }; } }; diff --git a/frontend/src/components/auth/Authy.tsx b/frontend/src/components/auth/TwoFa.tsx similarity index 61% rename from frontend/src/components/auth/Authy.tsx rename to frontend/src/components/auth/TwoFa.tsx index 4c4805ba..ecd74d60 100644 --- a/frontend/src/components/auth/Authy.tsx +++ b/frontend/src/components/auth/TwoFa.tsx @@ -1,45 +1,66 @@ import React, { useContext, useState, useRef } from "react"; import { Redirect } from "react-router-dom"; -import { Box, Button, Flex, Input, Text, VStack } from "@chakra-ui/react"; +import { + Box, + Button, + Center, + Flex, + Input, + Spinner, + Text, + VStack, +} from "@chakra-ui/react"; import authAPIClient from "../../APIClients/AuthAPIClient"; import AUTHENTICATED_USER_KEY from "../../constants/AuthConstants"; import { HOME_PAGE } from "../../constants/Routes"; import AuthContext from "../../contexts/AuthContext"; import { AuthenticatedUser } from "../../types/AuthTypes"; +import CreateToast from "../common/Toasts"; +import { isErrorResponse } from "../../helper/error"; -type AuthyProps = { +type TwoFaProps = { email: string; password: string; token: string; toggle: boolean; }; -const Authy = ({ +const TwoFa = ({ email, password, token, toggle, -}: AuthyProps): React.ReactElement => { +}: TwoFaProps): React.ReactElement => { + const newToast = CreateToast(); const { authenticatedUser, setAuthenticatedUser } = useContext(AuthContext); - const [error, setError] = useState(""); const [authCode, setAuthCode] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const inputRef = useRef(null); - const onAuthySubmit = async () => { - let authUser: AuthenticatedUser | null; + const twoFaSubmit = async () => { + // Uncomment this if Google/Outlook sign in is ever needed + // authUser = await authAPIClient.twoFaWithGoogle(authCode, token); - if (token) { - authUser = await authAPIClient.twoFaWithGoogle(authCode, token); - } else { - authUser = await authAPIClient.twoFa(authCode, email, password); + if (authCode.length < 6) { + newToast( + "Authentication Failed", + "Please enter a 6 digit authentication code.", + "error", + ); + return; } - if (authUser) { + setIsLoading(true); + const authUser = await authAPIClient.twoFa(authCode, email, password); + + if (isErrorResponse(authUser)) { + setIsLoading(false); + newToast("Authentication Failed", authUser.errMessage, "error"); + } else { localStorage.setItem(AUTHENTICATED_USER_KEY, JSON.stringify(authUser)); setAuthenticatedUser(authUser); - } else { - setError("Error: Invalid token"); } }; @@ -76,8 +97,8 @@ const Authy = ({ One last step! - In order to protect your account, please enter the authorization - code from the Twilio Authy application. + In order to protect your account, please enter the 6 digit + authentication code from the Authenticator extension. {boxIndexes.map((boxIndex) => { @@ -98,17 +119,30 @@ const Authy = ({ ); })} - + {isLoading ? ( + + {" "} + + ) : ( + + )} ; }; -export default Authy; +export default TwoFa; diff --git a/frontend/src/components/pages/LoginPage.tsx b/frontend/src/components/pages/LoginPage.tsx index 65a16ddb..9b3c8aa1 100644 --- a/frontend/src/components/pages/LoginPage.tsx +++ b/frontend/src/components/pages/LoginPage.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; import Login from "../forms/Login"; -import Authy from "../auth/Authy"; +import TwoFa from "../auth/TwoFa"; const LoginPage = (): React.ReactElement => { const [email, setEmail] = useState(""); @@ -19,7 +19,7 @@ const LoginPage = (): React.ReactElement => { toggle={toggle} setToggle={setToggle} /> - + ); }; diff --git a/frontend/src/components/pages/SignupPage.tsx b/frontend/src/components/pages/SignupPage.tsx index 208e907f..1ecaf6be 100644 --- a/frontend/src/components/pages/SignupPage.tsx +++ b/frontend/src/components/pages/SignupPage.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; import Signup from "../forms/Signup"; -import Authy from "../auth/Authy"; +import TwoFa from "../auth/TwoFa"; const SignupPage = (): React.ReactElement => { const [toggle, setToggle] = useState(true); @@ -23,7 +23,7 @@ const SignupPage = (): React.ReactElement => { toggle={toggle} setToggle={setToggle} /> - + ); }; diff --git a/frontend/src/helper/error.ts b/frontend/src/helper/error.ts index 05f79440..b408fafe 100644 --- a/frontend/src/helper/error.ts +++ b/frontend/src/helper/error.ts @@ -1,5 +1,9 @@ import { AxiosError } from "axios"; -import { AuthTokenResponse, AuthFlow } from "../types/AuthTypes"; +import { + AuthTokenResponse, + AuthFlow, + AuthenticatedUser, +} from "../types/AuthTypes"; import { AuthErrorResponse, ErrorResponse } from "../types/ErrorTypes"; export const getAuthErrMessage = ( @@ -21,7 +25,7 @@ export const isAuthErrorResponse = ( }; export const isErrorResponse = ( - res: boolean | string | ErrorResponse, + res: boolean | string | AuthenticatedUser | ErrorResponse, ): res is ErrorResponse => { return ( typeof res !== "boolean" && typeof res !== "string" && "errMessage" in res