From dde2b828cb527d629a797dbaf15d89120e7dda73 Mon Sep 17 00:00:00 2001 From: Hrishikesh Vaipurkar Date: Tue, 16 Jul 2024 14:38:57 +0200 Subject: [PATCH] feat: SW-162 MFA for Profile implemented --- app/[lang]/(live)/(protected)/logout/route.ts | 4 +- .../(live)/(protected)/mfa-login/route.ts | 81 +++++++++++++++++++ auth.ts | 23 ++++++ constants/routes/authRequired.ts | 2 + constants/routes/handleAuth.js | 16 +++- middlewares/authRequired.ts | 62 +++++++++++--- types/auth.d.ts | 1 + 7 files changed, 177 insertions(+), 12 deletions(-) create mode 100644 app/[lang]/(live)/(protected)/mfa-login/route.ts diff --git a/app/[lang]/(live)/(protected)/logout/route.ts b/app/[lang]/(live)/(protected)/logout/route.ts index 38b98361a..3bc860ec1 100644 --- a/app/[lang]/(live)/(protected)/logout/route.ts +++ b/app/[lang]/(live)/(protected)/logout/route.ts @@ -1,5 +1,5 @@ import { createActionURL } from "@auth/core" -import { headers as nextHeaders } from "next/headers" +import { cookies,headers as nextHeaders } from "next/headers" import { NextRequest, NextResponse } from "next/server" import { AuthError } from "next-auth" @@ -63,6 +63,8 @@ export async function GET( console.log({ logout_NEXTAUTH_URL: process.env.NEXTAUTH_URL }) console.log({ logout_env: process.env }) + const cookieStore = cookies() + cookieStore.set("_SecureMFA-token", "", { maxAge: 0 }) const headers = new Headers(nextHeaders()) const signOutURL = createActionURL( "signout", diff --git a/app/[lang]/(live)/(protected)/mfa-login/route.ts b/app/[lang]/(live)/(protected)/mfa-login/route.ts new file mode 100644 index 000000000..626da9abc --- /dev/null +++ b/app/[lang]/(live)/(protected)/mfa-login/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from "next/server" +import { AuthError } from "next-auth" + +import { Lang } from "@/constants/languages" +import { env } from "@/env/server" +import { internalServerError } from "@/server/errors/next" + +import { signIn } from "@/auth" + +export async function GET( + request: NextRequest, + context: { params: { lang: Lang } } +) { + let redirectHeaders: Headers | undefined = undefined + let redirectTo: string + + const returnUrl = request.headers.get("x-returnurl") + + if (returnUrl) { + // Seamless login request from Current web + redirectTo = returnUrl + } else { + // Normal login request from New web + redirectTo = + request.cookies.get("redirectTo")?.value || // Cookie gets set by authRequired middleware + request.nextUrl.searchParams.get("redirectTo") || + "/" + + // Make relative URL to absolute URL + if (redirectTo.startsWith("/")) { + if (!env.PUBLIC_URL) { + throw internalServerError("No value for env.PUBLIC_URL") + } + redirectTo = new URL(redirectTo, env.PUBLIC_URL).href + } + + // Clean up cookie from authRequired middleware + redirectHeaders = new Headers() + redirectHeaders.append( + "set-cookie", + "redirectTo=; Expires=Thu, 01 Jan 1970 00:00:00 UTC; Path=/; HttpOnly; SameSite=Lax" + ) + } + + try { + /** + * Passing `redirect: false` to `signIn` will return the URL instead of + * automatically redirecting to it inside of `signIn`. + * https://github.com/nextauthjs/next-auth/blob/3c035ec/packages/next-auth/src/lib/actions.ts#L76 + */ + const redirectUrl = await signIn( + "curity", + { + redirectTo, + redirect: false, + }, + { + ui_locales: context.params.lang, + scope: "profile_update openid", + // The below acr value is required as for New Web same Curity Client is used for MFA + // while in current web it is being setup using different Curity Client ID and secret + acr_values: + "urn:se:curity:authentication:otp-authenticator:OTP-Authenticator_web", + } + ) + + if (redirectUrl) { + return NextResponse.redirect(redirectUrl, { + headers: redirectHeaders, + }) + } + } catch (error) { + if (error instanceof AuthError) { + console.error({ signInAuthError: error }) + } else { + console.error({ signInError: error }) + } + } + + return internalServerError() +} diff --git a/auth.ts b/auth.ts index 2245d7613..a5393514b 100644 --- a/auth.ts +++ b/auth.ts @@ -1,3 +1,5 @@ +import { decode,encode } from "@auth/core/jwt" +import { cookies } from "next/headers" import NextAuth from "next-auth" import { env } from "@/env/server" @@ -55,6 +57,7 @@ const customProvider = { sub: profile.sub, given_name: profile.given_name, login_with: profile.login_with, + acr: profile.acr, } }, } satisfies OIDCConfig @@ -114,6 +117,26 @@ export const config = { return true }, async jwt({ account, session, token, trigger, user }) { + if ( + user?.acr == + "urn:se:curity:authentication:otp-authenticator:OTP-Authenticator_web" + ) { + const cookieStore = cookies() + const value = token.access_token + const secret = env.NEXTAUTH_SECRET + const maxAge = 60 * 15 + const name = "_SecureMFA-token" + const mfaCookie = await encode({ + secret, + maxAge, + token: value, + salt: name, + }) + cookieStore.set("_SecureMFA-token", mfaCookie.toString(), { + maxAge: maxAge, + }) + } + const loginType = getLoginType(user) if (account) { return { diff --git a/constants/routes/authRequired.ts b/constants/routes/authRequired.ts index dcfcc2bd3..95016f53c 100644 --- a/constants/routes/authRequired.ts +++ b/constants/routes/authRequired.ts @@ -22,3 +22,5 @@ export const authRequired = [ ...Object.values(stays), ...Object.values(points), ] + +export const mfaRequired = [...Object.values(profileEdit)] diff --git a/constants/routes/handleAuth.js b/constants/routes/handleAuth.js index 042ffba6d..dda9924f7 100644 --- a/constants/routes/handleAuth.js +++ b/constants/routes/handleAuth.js @@ -22,4 +22,18 @@ export const logout = { sv: "/sv/logga-ut", } -export const handleAuth = [...Object.values(login), ...Object.values(logout)] +/** @type {import('@/types/routes').LangRoute} */ +export const mfaLogin = { + da: "/da/mfa-log-pa", + de: "/de/mfa-anmeldung", + en: "/en/mfa-login", + fi: "/fi/mfa-kirjaudu-sisaan", + no: "/no/mfa-logg-inn", + sv: "/sv/mfa-logga-in", +} + +export const handleAuth = [ + ...Object.values(login), + ...Object.values(logout), + ...Object.values(mfaLogin), +] diff --git a/middlewares/authRequired.ts b/middlewares/authRequired.ts index e103804c1..a258cc422 100644 --- a/middlewares/authRequired.ts +++ b/middlewares/authRequired.ts @@ -1,7 +1,9 @@ +import { decode } from "@auth/core/jwt" +import { cookies } from "next/headers" import { NextResponse } from "next/server" -import { authRequired } from "@/constants/routes/authRequired" -import { login } from "@/constants/routes/handleAuth" +import { authRequired, mfaRequired } from "@/constants/routes/authRequired" +import { login, mfaLogin } from "@/constants/routes/handleAuth" import { env } from "@/env/server" import { internalServerError } from "@/server/errors/next" @@ -44,14 +46,6 @@ export const middleware = auth(async (request) => { const isLoggedIn = !!request.auth const hasError = request.auth?.error - if (isLoggedIn && !hasError) { - const headers = new Headers(request.headers) - headers.set("x-continue", "1") - return NextResponse.next({ - headers, - }) - } - if (!env.PUBLIC_URL) { throw internalServerError("Missing value for env.PUBLIC_URL") } @@ -61,6 +55,54 @@ export const middleware = auth(async (request) => { nextUrlClone.host = publicUrl.host nextUrlClone.hostname = publicUrl.hostname + const isMFAValid = async function () { + const cookieStore = cookies() + const mfaCookieValue = cookieStore.get("_SecureMFA-token")?.value + if (mfaCookieValue) { + try { + const mfaToken = await decode({ + token: mfaCookieValue, + secret: env.NEXTAUTH_SECRET, + salt: "_SecureMFA-token", + }) + if (mfaToken?.exp) { + return true + } + } catch (e) { + console.log("JWT decode failed", e) + cookieStore.set("_SecureMFA-token", "", { maxAge: 0 }) + return false + } + } else { + return false + } + } + + if (isLoggedIn && !hasError) { + const isMFAPath = mfaRequired.includes(nextUrl.pathname) + const mfaValid = isMFAPath ? await isMFAValid() : true + if (!mfaValid) { + const mfaLoginUrl = mfaLogin[lang] + const nextUrlClone = nextUrl.clone() + nextUrlClone.host = publicUrl.host + nextUrlClone.hostname = publicUrl.hostname + const headers = new Headers() + headers.append( + "set-cookie", + `redirectTo=${encodeURIComponent(nextUrlClone.href)}; Path=/; HttpOnly; SameSite=Lax` + ) + return NextResponse.redirect(new URL(mfaLoginUrl, nextUrlClone), { + headers, + }) + } + + const headers = new Headers(request.headers) + headers.set("x-continue", "1") + return NextResponse.next({ + headers, + }) + } + const headers = new Headers() headers.append( "set-cookie", diff --git a/types/auth.d.ts b/types/auth.d.ts index e3ac612d2..7755e7800 100644 --- a/types/auth.d.ts +++ b/types/auth.d.ts @@ -27,5 +27,6 @@ declare module "next-auth" { sub: string email?: string login_with: string + acr?: string } }