Skip to content

Commit e7cb0d6

Browse files
committed
Implement mfa login flow
1 parent 0311c59 commit e7cb0d6

File tree

6 files changed

+294
-75
lines changed

6 files changed

+294
-75
lines changed
Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,53 @@
11
import type { LoaderFunction } from "@remix-run/node";
2+
import { redirect } from "@remix-run/node";
23
import { authenticator } from "~/services/auth.server";
34
import { redirectCookie } from "./auth.github";
5+
import { getUserSession, commitSession } from "~/services/sessionStorage.server";
46
import { logger } from "~/services/logger.server";
7+
import { MfaRequiredError } from "~/services/mfa/multiFactorAuthentication.server";
58

69
export let loader: LoaderFunction = async ({ request }) => {
7-
const cookie = request.headers.get("Cookie");
8-
const redirectValue = await redirectCookie.parse(cookie);
9-
const redirectTo = redirectValue ?? "/";
10+
try {
11+
const cookie = request.headers.get("Cookie");
12+
const redirectValue = await redirectCookie.parse(cookie);
13+
const redirectTo = redirectValue ?? "/";
1014

11-
logger.debug("auth.github.callback loader", {
12-
redirectTo,
13-
});
15+
logger.debug("auth.github.callback loader", {
16+
redirectTo,
17+
});
1418

15-
const authuser = await authenticator.authenticate("github", request, {
16-
successRedirect: redirectTo,
17-
failureRedirect: "/login",
18-
});
19+
const authuser = await authenticator.authenticate("github", request, {
20+
successRedirect: undefined, // Don't auto-redirect, we'll handle it
21+
failureRedirect: undefined, // Don't auto-redirect on failure either
22+
});
1923

20-
logger.debug("auth.github.callback authuser", {
21-
authuser,
22-
});
24+
logger.debug("auth.github.callback authuser", {
25+
authuser,
26+
});
2327

24-
return authuser;
28+
// If we get here, user doesn't have MFA - complete login normally
29+
return redirect(redirectTo);
30+
} catch (error) {
31+
// Check if this is an MFA_REQUIRED error
32+
if (error instanceof MfaRequiredError) {
33+
// User has MFA enabled - store pending user ID and redirect to MFA page
34+
const session = await getUserSession(request);
35+
session.set("pending-mfa-user-id", error.userId);
36+
37+
const cookie = request.headers.get("Cookie");
38+
const redirectValue = await redirectCookie.parse(cookie);
39+
const redirectTo = redirectValue ?? "/";
40+
session.set("pending-mfa-redirect-to", redirectTo);
41+
42+
return redirect("/login/mfa", {
43+
headers: {
44+
"Set-Cookie": await commitSession(session),
45+
},
46+
});
47+
}
48+
49+
// Regular authentication failure, redirect to login page
50+
logger.debug("auth.github.callback error", { error });
51+
return redirect("/login");
52+
}
2553
};

apps/webapp/app/routes/login.mfa/route.tsx

Lines changed: 99 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ import { InputOTP, InputOTPGroup, InputOTPSlot } from "~/components/primitives/I
1515
import { Paragraph } from "~/components/primitives/Paragraph";
1616
import { Spinner } from "~/components/primitives/Spinner";
1717
import { authenticator } from "~/services/auth.server";
18-
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
18+
import { commitSession, getUserSession, sessionStorage } from "~/services/sessionStorage.server";
19+
import { MultiFactorAuthenticationService } from "~/services/mfa/multiFactorAuthentication.server";
20+
import { redirectWithErrorMessage } from "~/models/message.server";
21+
import { ServiceValidationError } from "~/v3/services/baseService.server";
1922

2023
export const meta: MetaFunction = ({ matches }) => {
2124
const parentMeta = matches
@@ -37,11 +40,20 @@ export const meta: MetaFunction = ({ matches }) => {
3740
};
3841

3942
export async function loader({ request }: LoaderFunctionArgs) {
43+
// Check if user is already fully authenticated
4044
await authenticator.isAuthenticated(request, {
4145
successRedirect: "/",
4246
});
4347

4448
const session = await getUserSession(request);
49+
50+
// Check if there's a pending MFA user ID
51+
const pendingUserId = session.get("pending-mfa-user-id");
52+
if (!pendingUserId) {
53+
// No pending MFA, redirect to login
54+
return redirect("/login");
55+
}
56+
4557
const error = session.get("auth:error");
4658

4759
let mfaError: string | undefined;
@@ -64,42 +76,100 @@ export async function loader({ request }: LoaderFunctionArgs) {
6476
}
6577

6678
export async function action({ request }: ActionFunctionArgs) {
67-
const clonedRequest = request.clone();
79+
try {
80+
const session = await getUserSession(request);
81+
const pendingUserId = session.get("pending-mfa-user-id");
82+
83+
if (!pendingUserId) {
84+
return redirect("/login");
85+
}
6886

69-
const payload = Object.fromEntries(await clonedRequest.formData());
87+
const payload = Object.fromEntries(await request.formData());
7088

71-
const { action } = z
72-
.object({
73-
action: z.enum(["verify-recovery", "verify-mfa"]),
74-
})
75-
.parse(payload);
89+
const { action } = z
90+
.object({
91+
action: z.enum(["verify-recovery", "verify-mfa"]),
92+
})
93+
.parse(payload);
7694

77-
if (action === "verify-recovery") {
78-
// TODO: Implement recovery code verification logic
79-
const recoveryCode = payload.recoveryCode;
95+
const mfaService = new MultiFactorAuthenticationService();
8096

81-
// For now, just redirect to dashboard
82-
return redirect("/");
83-
} else if (action === "verify-mfa") {
84-
// TODO: Implement MFA code verification logic
85-
const mfaCode = payload.mfaCode;
97+
if (action === "verify-recovery") {
98+
const recoveryCode = payload.recoveryCode as string;
99+
100+
if (!recoveryCode) {
101+
session.set("auth:error", { message: "Recovery code is required" });
102+
return redirect("/login/mfa", {
103+
headers: { "Set-Cookie": await commitSession(session) },
104+
});
105+
}
86106

87-
// For now, just redirect to dashboard
88-
return redirect("/");
89-
} else {
90-
const session = await getUserSession(request);
91-
session.unset("triggerdotdev:magiclink");
107+
const result = await mfaService.verifyRecoveryCodeForLogin(pendingUserId, recoveryCode);
108+
109+
if (!result.success) {
110+
session.set("auth:error", { message: result.error });
111+
return redirect("/login/mfa", {
112+
headers: { "Set-Cookie": await commitSession(session) },
113+
});
114+
}
92115

93-
return redirect("/login/magic", {
94-
headers: {
95-
"Set-Cookie": await commitSession(session),
96-
},
97-
});
116+
// Recovery code verified - complete the login
117+
return await completeLogin(request, session, pendingUserId);
118+
119+
} else if (action === "verify-mfa") {
120+
const mfaCode = payload.mfaCode as string;
121+
122+
if (!mfaCode || mfaCode.length !== 6) {
123+
session.set("auth:error", { message: "Valid 6-digit code is required" });
124+
return redirect("/login/mfa", {
125+
headers: { "Set-Cookie": await commitSession(session) },
126+
});
127+
}
128+
129+
const result = await mfaService.verifyTotpForLogin(pendingUserId, mfaCode);
130+
131+
if (!result.success) {
132+
session.set("auth:error", { message: result.error });
133+
return redirect("/login/mfa", {
134+
headers: { "Set-Cookie": await commitSession(session) },
135+
});
136+
}
137+
138+
// TOTP code verified - complete the login
139+
return await completeLogin(request, session, pendingUserId);
140+
}
141+
142+
return redirect("/login");
143+
144+
} catch (error) {
145+
if (error instanceof ServiceValidationError) {
146+
return redirectWithErrorMessage("/login", request, error.message);
147+
}
148+
throw error;
98149
}
99150
}
100151

152+
async function completeLogin(request: Request, session: any, userId: string) {
153+
// Create a new authenticated session
154+
const authSession = await sessionStorage.getSession(request.headers.get("Cookie"));
155+
authSession.set(authenticator.sessionKey, { userId });
156+
157+
// Get the redirect URL and clean up pending MFA data
158+
const redirectTo = session.get("pending-mfa-redirect-to") ?? "/";
159+
session.unset("pending-mfa-user-id");
160+
session.unset("pending-mfa-redirect-to");
161+
session.unset("auth:error");
162+
163+
return redirect(redirectTo, {
164+
headers: {
165+
"Set-Cookie": await sessionStorage.commitSession(authSession),
166+
},
167+
});
168+
}
169+
101170
export default function LoginMfaPage() {
102-
const { mfaError } = useTypedLoaderData<typeof loader>();
171+
const data = useTypedLoaderData<typeof loader>();
172+
const mfaError = 'mfaError' in data ? data.mfaError : undefined;
103173
const navigate = useNavigation();
104174
const [showRecoveryCode, setShowRecoveryCode] = useState(false);
105175
const [mfaCode, setMfaCode] = useState("");
@@ -151,7 +221,7 @@ export default function LoginMfaPage() {
151221
<span className="text-text-bright">Verify</span>
152222
)}
153223
</Button>
154-
{mfaError && <FormError>{mfaError}</FormError>}
224+
{typeof mfaError === 'string' && <FormError>{mfaError}</FormError>}
155225
</Fieldset>
156226
<Button
157227
type="button"
@@ -203,7 +273,7 @@ export default function LoginMfaPage() {
203273
<span className="text-text-bright">Verify</span>
204274
)}
205275
</Button>
206-
{mfaError && <FormError>{mfaError}</FormError>}
276+
{typeof mfaError === 'string' && <FormError>{mfaError}</FormError>}
207277
</Fieldset>
208278
<Button
209279
type="button"

apps/webapp/app/routes/magic.tsx

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,39 @@
11
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
2+
import { redirect } from "@remix-run/server-runtime";
23
import { authenticator } from "~/services/auth.server";
4+
import { MfaRequiredError } from "~/services/mfa/multiFactorAuthentication.server";
35
import { getRedirectTo } from "~/services/redirectTo.server";
6+
import { getUserSession, commitSession } from "~/services/sessionStorage.server";
47

58
export async function loader({ request }: LoaderFunctionArgs) {
6-
const redirectTo = await getRedirectTo(request);
9+
try {
10+
// Attempt to authenticate the user with email-link
11+
const authUser = await authenticator.authenticate("email-link", request, {
12+
successRedirect: undefined, // Don't auto-redirect, we'll handle it
13+
failureRedirect: undefined, // Don't auto-redirect on failure either
14+
});
715

8-
await authenticator.authenticate("email-link", request, {
9-
successRedirect: redirectTo ?? "/",
10-
failureRedirect: "/login/magic",
11-
});
16+
// If we get here, user doesn't have MFA - complete login normally
17+
const redirectTo = await getRedirectTo(request);
18+
return redirect(redirectTo ?? "/");
19+
} catch (error) {
20+
// Check if this is an MFA_REQUIRED error
21+
if (error instanceof MfaRequiredError) {
22+
// User has MFA enabled - store pending user ID and redirect to MFA page
23+
const session = await getUserSession(request);
24+
session.set("pending-mfa-user-id", error.userId);
25+
26+
const redirectTo = await getRedirectTo(request);
27+
session.set("pending-mfa-redirect-to", redirectTo ?? "/");
28+
29+
return redirect("/login/mfa", {
30+
headers: {
31+
"Set-Cookie": await commitSession(session),
32+
},
33+
});
34+
}
35+
36+
// Regular authentication failure, redirect to magic link page
37+
return redirect("/login/magic");
38+
}
1239
}

apps/webapp/app/services/emailAuth.server.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { env } from "~/env.server";
66
import { sendMagicLinkEmail } from "~/services/email.server";
77
import { postAuthentication } from "./postAuth.server";
88
import { logger } from "./logger.server";
9+
import { MfaRequiredError } from "./mfa/multiFactorAuthentication.server";
910

1011
let secret = env.MAGIC_LINK_SECRET;
1112
if (!secret) throw new Error("Missing MAGIC_LINK_SECRET env variable.");
@@ -36,8 +37,19 @@ const emailStrategy = new EmailLinkStrategy(
3637

3738
await postAuthentication({ user, isNewUser, loginMethod: "MAGIC_LINK" });
3839

40+
// Check if user has MFA enabled
41+
if (user.mfaEnabledAt) {
42+
// Throw a special error that will be caught by the magic route
43+
throw new MfaRequiredError(user.id);
44+
}
45+
3946
return { userId: user.id };
4047
} catch (error) {
48+
// Skip logging the error if it's a MfaRequiredError
49+
if (error instanceof MfaRequiredError) {
50+
throw error;
51+
}
52+
4153
logger.debug("Magic link user failed to authenticate", { error: JSON.stringify(error) });
4254
throw error;
4355
}

apps/webapp/app/services/gitHubAuth.server.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { findOrCreateUser } from "~/models/user.server";
55
import type { AuthUser } from "./authUser";
66
import { postAuthentication } from "./postAuth.server";
77
import { logger } from "./logger.server";
8+
import { MfaRequiredError } from "./mfa/multiFactorAuthentication.server";
89

910
export function addGitHubStrategy(
1011
authenticator: Authenticator<AuthUser>,
@@ -40,10 +41,21 @@ export function addGitHubStrategy(
4041

4142
await postAuthentication({ user, isNewUser, loginMethod: "GITHUB" });
4243

44+
// Check if user has MFA enabled
45+
if (user.mfaEnabledAt) {
46+
// Throw a special error that will be caught by the callback route
47+
throw new MfaRequiredError(user.id);
48+
}
49+
4350
return {
4451
userId: user.id,
4552
};
4653
} catch (error) {
54+
// Skip logging the error if it's a MfaRequiredError
55+
if (error instanceof MfaRequiredError) {
56+
throw error;
57+
}
58+
4759
console.error(error);
4860
throw error;
4961
}

0 commit comments

Comments
 (0)