Skip to content

Commit 3d3d11e

Browse files
authored
[Feature]: Create Two-Factor Authentication Login Page (#11)
1 parent 5acf8cf commit 3d3d11e

File tree

10 files changed

+138
-15
lines changed

10 files changed

+138
-15
lines changed

{{cookiecutter.project_slug}}/frontend/app/components/settings/Security.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import { refreshTokens, token } from "../../lib/slices/tokensSlice"
1818
import { addNotice } from "../../lib/slices/toastsSlice"
1919
import { QRCodeSVG } from 'qrcode.react'
2020

21-
2221
const title = "Security"
2322
const redirectTOTP = "/settings"
2423
const qrSize = 200

{{cookiecutter.project_slug}}/frontend/app/lib/api/auth.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,19 @@ import {
88
INewTOTP,
99
IEnableTOTP,
1010
IMsg,
11+
IErrorResponse,
1112
} from "../interfaces"
1213
import { apiCore } from "./core"
1314

1415
const jsonify = async (response: Response) => {
15-
if(response.ok) {
16+
if (response.ok) {
1617
return await response.json()
18+
} else {
19+
throw {
20+
message: `Request failed with ${response.status}: ${response.statusText}`,
21+
code: response.status
22+
} as IErrorResponse
1723
}
18-
throw `Request failed with ${response.status}: ${response.statusText}`
1924
}
2025

2126
export const apiAuth = {

{{cookiecutter.project_slug}}/frontend/app/lib/interfaces/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
ISendEmail,
1414
IMsg,
1515
INotification,
16+
IErrorResponse,
1617
} from "./utilities"
1718

1819
// https://stackoverflow.com/a/64782482/295606
@@ -33,4 +34,5 @@ export type {
3334
ISendEmail,
3435
IMsg,
3536
INotification,
37+
IErrorResponse,
3638
}

{{cookiecutter.project_slug}}/frontend/app/lib/interfaces/utilities.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,8 @@ export interface INotification {
3939
icon?: "success" | "error" | "information"
4040
showProgress?: boolean
4141
}
42+
43+
export interface IErrorResponse {
44+
message: string
45+
code: number
46+
}

{{cookiecutter.project_slug}}/frontend/app/lib/slices/authSlice.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,15 +94,17 @@ export const isAdmin = (state: RootState) => {
9494
return loggedIn(state) && state.auth.is_superuser && state.auth.is_active
9595
}
9696

97-
const handleGenericLogin = (loginAttempt: (payload: any) => any, payload: any) =>
97+
const handleGenericLogin = (loginAttempt: (payload: any) => any, payload: any, getProfile: boolean = true) =>
9898
async (
9999
dispatch: ThunkDispatch<any, void, Action>,
100100
getState: () => RootState,
101101
) => {
102102
try {
103103
await dispatch(loginAttempt(payload))
104104
const token = getState().tokens.access_token
105-
await dispatch(getUserProfile(token))
105+
if (getProfile) {
106+
await dispatch(getUserProfile(token))
107+
}
106108
} catch (error) {
107109
dispatch(
108110
addNotice({
@@ -117,8 +119,11 @@ const handleGenericLogin = (loginAttempt: (payload: any) => any, payload: any) =
117119
}
118120

119121

122+
const isMagicAuthFirstPhase = (providedPassword?: string) => providedPassword === undefined
123+
120124
export const login =
121-
(payload: { username: string; password?: string }) => handleGenericLogin(getTokens, payload)
125+
(payload: { username: string; password?: string }) =>
126+
handleGenericLogin(getTokens, payload, !isMagicAuthFirstPhase(payload.password))
122127

123128
export const magicLogin =
124129
(payload: { token: string }) => handleGenericLogin(validateMagicTokens, payload.token)

{{cookiecutter.project_slug}}/frontend/app/lib/slices/tokensSlice.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ITokenResponse, IWebToken } from "../interfaces"
1+
import { IErrorResponse, ITokenResponse, IWebToken } from "../interfaces"
22
import { apiAuth } from "../api"
33
import { tokenExpired, tokenParser } from "../utilities"
44
import { Dispatch, PayloadAction, createSlice } from "@reduxjs/toolkit"
@@ -102,9 +102,9 @@ export const validateMagicTokens =
102102
}
103103

104104
export const validateTOTPClaim =
105-
(data: string) => async (dispatch: Dispatch, getState: () => TokensState) => {
105+
(data: string) => async (dispatch: Dispatch, getState: () => RootState) => {
106106
try {
107-
const access_token = getState().access_token
107+
const access_token = getState().tokens.access_token
108108
const response = await apiAuth.loginWithTOTP(access_token, {
109109
claim: data,
110110
})
@@ -118,7 +118,6 @@ export const validateTOTPClaim =
118118
icon: "error",
119119
}),
120120
)
121-
dispatch(deleteTokens())
122121
}
123122
}
124123

{{cookiecutter.project_slug}}/frontend/app/login/page.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export default function Page() {
118118
register,
119119
handleSubmit,
120120
unregister,
121-
formState: { errors },
121+
formState: { errors, isSubmitSuccessful },
122122
} = useForm()
123123

124124
async function submit(data: FieldValues) {
@@ -139,10 +139,10 @@ export default function Page() {
139139

140140
useEffect(() => {
141141
if (isLoggedIn) return redirectTo(redirectAfterLogin)
142-
if (accessToken && tokenIsTOTP(accessToken) && !oauth) return redirectTo(redirectTOTP)
142+
if (accessToken && tokenIsTOTP(accessToken) && (!oauth || isSubmitSuccessful)) return redirectTo(redirectTOTP)
143143
if (accessToken && tokenParser(accessToken).hasOwnProperty("fingerprint") && !oauth)
144144
return redirectTo(redirectAfterMagic)
145-
}, [isLoggedIn, accessToken]) // eslint-disable-line react-hooks/exhaustive-deps
145+
}, [isLoggedIn, accessToken, isSubmitSuccessful]) // eslint-disable-line react-hooks/exhaustive-deps
146146

147147
return (
148148
<main className="flex min-h-full">

{{cookiecutter.project_slug}}/frontend/app/moderation/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export default function Moderation() {
5858
if (!isValidAdmin) redirectTo("/settings")
5959
}
6060
checkAdmin()
61-
}, [])
61+
}, [isValidAdmin]) // eslint-disable-line react-hooks/exhaustive-deps
6262

6363
return (
6464
<main className="flex min-h-full mx-auto max-w-7xl px-2 sm:px-6 lg:px-8">

{{cookiecutter.project_slug}}/frontend/app/settings/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export default function Settings() {
5555
if (!isLoggedIn) redirectTo("/")
5656
}
5757
checkLoggedIn()
58-
}, [])
58+
}, [isLoggedIn]) // eslint-disable-line react-hooks/exhaustive-deps
5959

6060

6161
return (
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"use client"
2+
3+
import { LinkIcon } from "@heroicons/react/24/outline"
4+
import { useAppDispatch, useAppSelector } from "../lib/hooks"
5+
import Link from "next/link"
6+
import { useForm } from "react-hook-form"
7+
import { useRouter } from "next/navigation"
8+
import { loggedIn, logout, totpLogin } from "../lib/slices/authSlice"
9+
import { tokenIsTOTP } from "../lib/utilities"
10+
import { useEffect } from "react"
11+
import { FieldValues } from "react-hook-form"
12+
import { RootState } from "../lib/store"
13+
14+
const totpSchema = {
15+
claim: { required: true }
16+
}
17+
18+
const redirectAfterLogin = "/"
19+
const loginPage = "/login"
20+
21+
export default function Totp() {
22+
const dispatch = useAppDispatch()
23+
const accessToken = useAppSelector((state: RootState) => state.tokens.access_token)
24+
const isLoggedIn = useAppSelector((state: RootState) => loggedIn(state))
25+
26+
const router = useRouter()
27+
28+
const {
29+
register,
30+
handleSubmit,
31+
formState: { errors },
32+
} = useForm()
33+
34+
async function submit(values: FieldValues) {
35+
await dispatch(totpLogin({ claim: values.claim }))
36+
}
37+
38+
const removeFingerprint = async () => await dispatch(logout())
39+
40+
useEffect(() => {
41+
if (isLoggedIn) router.push(redirectAfterLogin)
42+
// if there is no access token, nor is it totp, send the user back to login
43+
if (!(accessToken && tokenIsTOTP(accessToken))) router.push(loginPage)
44+
}, [isLoggedIn, accessToken]) // eslint-disable-line react-hooks/exhaustive-deps
45+
46+
return (
47+
<main className="flex min-h-full">
48+
<div className="flex flex-1 flex-col justify-center py-12 px-4 sm:px-6 lg:flex-none lg:px-20 xl:px-24">
49+
<div className="mx-auto w-full max-w-sm lg:w-96">
50+
<div>
51+
<img className="h-12 w-auto" src="https://tailwindui.com/img/logos/mark.svg?color=rose&shade=500" alt="Your Company" />
52+
<h2 className="mt-6 text-3xl font-bold tracking-tight text-gray-900">Two-factor authentication</h2>
53+
<p className="text-sm font-medium text-rose-500 hover:text-rose-600 mt-6">
54+
Enter the 6-digit verification code from your app.
55+
</p>
56+
</div>
57+
58+
<div className="mt-8">
59+
<div className="mt-6">
60+
<form onSubmit={handleSubmit(submit)} className="space-y-6">
61+
<div>
62+
<label
63+
htmlFor="claim"
64+
className="block text-sm font-medium text-gray-700"
65+
>
66+
Verification code
67+
</label>
68+
<div className="mt-1 group relative inline-block w-full">
69+
<input
70+
{...register("claim", totpSchema.claim)}
71+
id="claim"
72+
name="claim"
73+
type="text"
74+
autoComplete="off"
75+
className="block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-rose-600 focus:outline-none focus:ring-rose-600 sm:text-sm"
76+
/>
77+
{errors.claim && (
78+
<div className="absolute left-5 top-5 translate-y-full w-48 px-2 py-1 bg-gray-700 rounded-lg text-center text-white text-sm after:content-[''] after:absolute after:left-1/2 after:bottom-[100%] after:-translate-x-1/2 after:border-8 after:border-x-transparent after:border-t-transparent after:border-b-gray-700">
79+
This field is required.
80+
</div>
81+
)}
82+
</div>
83+
</div>
84+
<div>
85+
<button type="submit" className="flex w-full justify-center rounded-md border border-transparent bg-rose-500 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-rose-600 focus:ring-offset-2">
86+
Submit
87+
</button>
88+
</div>
89+
</form>
90+
</div>
91+
</div>
92+
</div>
93+
<Link href="/login?oauth=true" className="mt-8 flex" onClick={removeFingerprint}>
94+
<LinkIcon
95+
className="text-rose-500 h-4 w-4 mr-1"
96+
aria-hidden="true"
97+
/>
98+
<p className="text-sm text-rose-500 align-middle">
99+
Log in another way.
100+
</p>
101+
</Link>
102+
</div>
103+
<div className="relative hidden w-0 flex-1 lg:block">
104+
<img className="absolute inset-0 h-full w-full object-cover" src="https://images.unsplash.com/photo-1561487138-99ccf59b135c?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=764&q=80" alt="" />
105+
</div>
106+
</main>
107+
)
108+
}

0 commit comments

Comments
 (0)