Skip to content

Commit

Permalink
feat(login): obtain user consent when verifying magic link (#1302)
Browse files Browse the repository at this point in the history
Introduce better Anti-CSRF protections by prompting the user to make
sure that every login request is an intentional request.

---



https://github.com/user-attachments/assets/e45b9f94-7246-4601-b63a-bc8a859f85f0
  • Loading branch information
IanKrieger authored Aug 6, 2024
1 parent 5a622da commit 500504a
Show file tree
Hide file tree
Showing 5 changed files with 88 additions and 81 deletions.
20 changes: 7 additions & 13 deletions src/auth/hooks/queries/useAuthorize.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,22 @@
import { useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { useAuthContext } from "@/auth/context/auth.hook";
import { authorize, ResponseUser } from "@/auth/lib";
import { authorize } from "@/auth/lib";

interface Options {
variables: {
code: string;
id: string;
};
onCompleted?: () => void;
onError?: () => void;
}

export function useAuthorize({ variables, onCompleted, onError }: Options) {
export function useAuthorize({ onCompleted, onError }: Options) {
const { setSessionUser } = useAuthContext();
const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const [data, setData] = useState<ResponseUser>();

useEffect(() => {
authorize(variables)
const verify = useCallback((code: string, id: string) => {
authorize({ code, id })
.then((data) => {
if (data) {
setSessionUser(data);
setData(data);
}
if (onCompleted) {
onCompleted();
Expand All @@ -40,5 +34,5 @@ export function useAuthorize({ variables, onCompleted, onError }: Options) {
});
}, []);

return { data, loading, error };
return { loading, error, verify };
}
31 changes: 16 additions & 15 deletions src/auth/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,29 +120,30 @@ export const clearCredentials = async (): Promise<void> => {
};

export const getLink = async (user: { email: string }): Promise<void> => {
const encodedEmail = encodeURIComponent(user.email.trim());
await fetch(
buildAdServerV2Endpoint(`/auth/magic-link?email=${encodedEmail}`),
{
method: "GET",
mode: "cors",
credentials: "include",
await fetch(buildAdServerV2Endpoint(`/auth/magic-link`), {
method: "POST",
mode: "cors",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
);
body: JSON.stringify({ email: user.email.trim() }),
});
};

export const authorize = async (req: {
code: string;
id: string;
}): Promise<ResponseUser> => {
const res = await fetch(
buildAdServerV2Endpoint(`/auth/authorize?code=${req.code}&id=${req.id}`),
{
method: "GET",
mode: "cors",
credentials: "include",
const res = await fetch(buildAdServerV2Endpoint(`/auth/authorize`), {
method: "POST",
mode: "cors",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
);
body: JSON.stringify({ code: req.code, id: req.id }),
});

if (!res.ok) {
throw new Error(t`Invalid Token`);
Expand Down
106 changes: 57 additions & 49 deletions src/auth/views/AuthVerify.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { AuthContainer } from "@/auth/views/components/AuthContainer";
import { useAuthorize } from "@/auth/hooks/queries/useAuthorize";
import { Link as RouterLink, useHistory } from "react-router-dom";
import { CircularProgress, Link, Stack, Typography } from "@mui/material";
import VerifiedIcon from "@mui/icons-material/Verified";
import CancelOutlinedIcon from "@mui/icons-material/CancelOutlined";
import { Link as RouterLink, Switch, useHistory } from "react-router-dom";
import { Alert, AlertTitle, Button, Typography } from "@mui/material";
import { useTrackWithMatomo } from "@/hooks/useTrackWithMatomo";
import { Trans } from "@lingui/macro";
import { LoadingButton } from "@mui/lab";
import logo from "@/assets/images/brave-icon-release-color.svg";

export function AuthVerify() {
const { trackMatomoEvent } = useTrackWithMatomo({
Expand All @@ -14,11 +14,7 @@ export function AuthVerify() {
const history = useHistory();
const params = new URLSearchParams(history.location.search);

const { loading, error } = useAuthorize({
variables: {
code: params.get("code") ?? "",
id: params.get("id") ?? "",
},
const { loading, error, verify } = useAuthorize({
onCompleted() {
history.push("/user/main");
trackMatomoEvent("magic-link", "verified");
Expand All @@ -28,49 +24,61 @@ export function AuthVerify() {
},
});

const id = params.get("id");
const code = params.get("code");

if (!id || !code) {
return (
<Switch>
<RouterLink to="/auth/link" />
</Switch>
);
}

return (
<AuthContainer>
{loading && (
<Stack direction="column" alignItems="center" sx={{ mt: 7 }}>
<Typography variant="h4" sx={{ mb: 7 }}>
<Trans>Logging in</Trans>
</Typography>

<CircularProgress size={100} />
</Stack>
)}
{!loading && !error && (
<Stack direction="column" alignItems="center" sx={{ mt: 5 }}>
<Typography variant="h4" sx={{ mb: 5 }}>
<Trans>Successfully logged in!</Trans>
</Typography>
<VerifiedIcon sx={{ fontSize: "100px", mb: 5 }} color="success" />
<Link
variant="h5"
component={RouterLink}
sx={{ textAlign: "center" }}
to="/user/main"
replace
>
<Trans>
Not automatically redirected? Click this link to go to the
dashboard.
</Trans>
</Link>
</Stack>
)}
<img src={logo} height={50} />
<Typography gutterBottom variant="h6">
<Trans>You are logging into the Brave Ads Manager Dashboard.</Trans>
</Typography>
<Typography gutterBottom variant="subtitle1">
<Trans>
Click the continue button below to complete the login process.
</Trans>
</Typography>
<LoadingButton
loading={loading}
disabled={loading || !!error}
onClick={() => verify(code, id)}
variant="contained"
sx={{ mt: 2, pl: 5, pr: 5, borderRadius: "12px", mb: 2 }}
size="large"
>
<Trans>Continue</Trans>
</LoadingButton>
{!loading && error && (
<Stack direction="column" alignItems="center" sx={{ mt: 5 }}>
<Typography variant="h4" sx={{ textAlign: "center", mb: 5 }}>
<Trans>
Unable to login, link has expired or has already been used.
</Trans>
</Typography>
<CancelOutlinedIcon sx={{ fontSize: "100px", mb: 5 }} color="error" />
<Link variant="h5" component={RouterLink} to="/auth/link" replace>
<Trans>Request another link.</Trans>
</Link>
</Stack>
<Alert
severity="error"
action={
<Button
variant="outlined"
color="inherit"
component={RouterLink}
to="/auth/link"
sx={{ alignSelf: "center" }}
>
<Trans>Return</Trans>
</Button>
}
>
<AlertTitle>
<Trans>Unable to login.</Trans>
</AlertTitle>
<Trans>
The magic link you have requested has either expired or has already
been used. Please return to the login page and try again.
</Trans>
</Alert>
)}
</AuthContainer>
);
Expand Down
10 changes: 7 additions & 3 deletions src/auth/views/MagicLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,21 @@ export function MagicLink() {
</Trans>
</Typography>
<Typography variant="subtitle1">
<Trans>Don&rsquo;t see the email?</Trans>
</Typography>
<Typography variant="subtitle2">
<Trans>
Don&rsquo;t see the email? Check your spam folder or{" "}
Check your spam folder or{" "}
<Link
sx={{ cursor: "pointer" }}
variant="inherit"
onClick={() => {
setRequested(false);
}}
>
try again.
</Link>
return to the login page
</Link>{" "}
to try again.
</Trans>
</Typography>
</AuthContainer>
Expand Down
2 changes: 1 addition & 1 deletion src/auth/views/components/AuthContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function AuthContainer({ children, belowCard, aboveCard }: Props) {
<LandingPageAppBar />
<Box
display="flex"
maxWidth="725px"
maxWidth="750px"
minWidth={{ xs: "400px", sm: "700px" }}
flexDirection="column"
p={1}
Expand Down

0 comments on commit 500504a

Please sign in to comment.