From eede696eeea7ba92035279e3834ee3ba4a77e130 Mon Sep 17 00:00:00 2001 From: DJP Date: Mon, 20 Apr 2026 19:00:15 -0400 Subject: [PATCH] =?UTF-8?q?Phase=206a:=20local=20auth=20=E2=80=94=20creden?= =?UTF-8?q?tials=20login=20+=20password=20reset=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MVP auth is email + password (Entra SSO stays coded but env-gated via NEXT_PUBLIC_AUTH_ENTRA_ENABLED for when the redirect URI is ready). Uses a custom DB-session endpoint to mirror the existing MSAL pattern at /api/auth/sso — no NextAuth strategy refactor needed. API routes: - POST /api/auth/login — email+password, bcrypt.compare, creates Auth.js-compatible DB session + Secure cookie. Constant-time behaviour (dummy-hash compare on missing user) to not leak account existence. Returns { ok, mustChangePassword } so the client can route first-login users to /change-password. - POST /api/auth/forgot-password — issues a 1-hour single-use reset token. Never leaks enumerability (always 200). In dev, returns the reset URL in the response so admins can hand it over before SMTP is wired up. In prod, the token is only logged server-side. - POST /api/auth/reset-password — validates token, bcrypt-hashes new password, clears token, flips mustChangePassword=false, and revokes all existing sessions so a stolen cookie can't linger. - POST /api/auth/change-password — authenticated user changes their own password. Skips the current-password check for users without a passwordHash (covers first-time setup for SSO-seeded accounts). Clears mustChangePassword. UI pages: - (auth)/login — rewrote for email+password form. Entra SSO button only renders when NEXT_PUBLIC_AUTH_ENTRA_ENABLED=true. Dow brand block on the left ("Dow Jones Studio Tracker"). - (auth)/login/CredentialsLogin — client form, routes first-login users to /change-password?first=1. - (auth)/change-password — forced password change after first login; also usable as a plain change-password screen. - (auth)/forgot-password — email form → reset link. Shows dev link in-page when available. - (auth)/reset-password/[token] — set new password from email link. Middleware: /forgot-password and /reset-password added to the authn-bypass allow-list alongside /login. Minimum password length enforced at 10 chars. All API endpoints return generic messaging to avoid information disclosure. Verified: tsc --noEmit ✓ zero errors. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/(auth)/change-password/page.tsx | 133 ++++++++++++++++++ src/app/(auth)/forgot-password/page.tsx | 103 ++++++++++++++ src/app/(auth)/login/CredentialsLogin.tsx | 106 ++++++++++++++ src/app/(auth)/login/page.tsx | 44 ++++-- .../(auth)/reset-password/[token]/page.tsx | 121 ++++++++++++++++ src/app/api/auth/change-password/route.ts | 76 ++++++++++ src/app/api/auth/forgot-password/route.ts | 75 ++++++++++ src/app/api/auth/login/route.ts | 90 ++++++++++++ src/app/api/auth/reset-password/route.ts | 75 ++++++++++ src/middleware.ts | 5 +- 10 files changed, 814 insertions(+), 14 deletions(-) create mode 100644 src/app/(auth)/change-password/page.tsx create mode 100644 src/app/(auth)/forgot-password/page.tsx create mode 100644 src/app/(auth)/login/CredentialsLogin.tsx create mode 100644 src/app/(auth)/reset-password/[token]/page.tsx create mode 100644 src/app/api/auth/change-password/route.ts create mode 100644 src/app/api/auth/forgot-password/route.ts create mode 100644 src/app/api/auth/login/route.ts create mode 100644 src/app/api/auth/reset-password/route.ts diff --git a/src/app/(auth)/change-password/page.tsx b/src/app/(auth)/change-password/page.tsx new file mode 100644 index 0000000..9f2cbd6 --- /dev/null +++ b/src/app/(auth)/change-password/page.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + +export default function ChangePasswordPage() { + const router = useRouter(); + const search = useSearchParams(); + const isFirstLogin = search.get("first") === "1"; + + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + + if (newPassword !== confirmPassword) { + setError("New passwords don't match."); + return; + } + if (newPassword.length < 10) { + setError("Password must be at least 10 characters."); + return; + } + + setSubmitting(true); + try { + const res = await fetch(`${BASE_PATH}/api/auth/change-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ currentPassword, newPassword }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setError(data?.error ?? "Change failed"); + setSubmitting(false); + return; + } + router.push("/dashboard"); + router.refresh(); + } catch { + setError("Network error — please try again."); + setSubmitting(false); + } + } + + return ( +
+
+

+ {isFirstLogin ? "Set a new password" : "Change password"} +

+

+ {isFirstLogin + ? "This is your first sign-in. Please choose a new password before continuing." + : "Use a password you don't use anywhere else."} +

+ +
+
+ + setCurrentPassword(e.target.value)} + className="h-9 text-sm" + /> +
+
+ + setNewPassword(e.target.value)} + className="h-9 text-sm" + /> +

+ Minimum 10 characters. +

+
+
+ + setConfirmPassword(e.target.value)} + className="h-9 text-sm" + /> +
+ + {error && ( +

+ {error} +

+ )} + + +
+
+
+ ); +} diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..7e59a5f --- /dev/null +++ b/src/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + +export default function ForgotPasswordPage() { + const [email, setEmail] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [status, setStatus] = useState<"idle" | "sent">("idle"); + const [devResetUrl, setDevResetUrl] = useState(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSubmitting(true); + + try { + const res = await fetch(`${BASE_PATH}/api/auth/forgot-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + const data = await res.json().catch(() => ({})); + setStatus("sent"); + if (data?.devResetUrl) { + setDevResetUrl(data.devResetUrl); + } + } catch { + // Fall through to success state — this endpoint never reveals failure. + setStatus("sent"); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+

+ Forgot your password? +

+ + {status === "idle" ? ( + <> +

+ Enter your email and we'll send a reset link. +

+
+
+ + setEmail(e.target.value)} + className="h-9 text-sm" + /> +
+ +
+ + ) : ( +
+

+ If that email is registered, you'll receive a reset link shortly. + The link expires in one hour. +

+ {devResetUrl && ( +
+

+ Dev mode — copy this link +

+

+ {devResetUrl} +

+
+ )} +
+ )} + +
+ + ← Back to sign in + +
+
+
+ ); +} diff --git a/src/app/(auth)/login/CredentialsLogin.tsx b/src/app/(auth)/login/CredentialsLogin.tsx new file mode 100644 index 0000000..587ca3f --- /dev/null +++ b/src/app/(auth)/login/CredentialsLogin.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + +export function CredentialsLogin() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setSubmitting(true); + + try { + const res = await fetch(`${BASE_PATH}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setError(data?.error ?? "Sign in failed"); + setSubmitting(false); + return; + } + + // Route first-time users to the forced password change. + if (data.mustChangePassword) { + router.push("/change-password?first=1"); + } else { + router.push("/dashboard"); + } + router.refresh(); + } catch { + setError("Network error — please try again."); + setSubmitting(false); + } + } + + return ( +
+
+ + setEmail(e.target.value)} + className="h-9 text-sm" + placeholder="you@oliver.agency" + /> +
+
+
+ + + Forgot? + +
+ setPassword(e.target.value)} + className="h-9 text-sm" + /> +
+ + {error && ( +

+ {error} +

+ )} + + +
+ ); +} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index a327d73..c1ad34b 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -2,6 +2,7 @@ import { auth } from "@/lib/auth"; import { redirect } from "next/navigation"; import { Suspense } from "react"; import { MsalLogin } from "./MsalLogin"; +import { CredentialsLogin } from "./CredentialsLogin"; import type { MsalLoginConfig } from "@/lib/msal-config"; export default async function LoginPage() { @@ -11,11 +12,15 @@ export default async function LoginPage() { redirect("/dashboard"); } - const msalConfig: MsalLoginConfig = { - clientId: process.env.AZURE_CLIENT_ID!, - tenantId: process.env.AZURE_TENANT_ID!, - redirectUri: process.env.AZURE_REDIRECT_URI!, - }; + const entraEnabled = process.env.NEXT_PUBLIC_AUTH_ENTRA_ENABLED === "true"; + + const msalConfig: MsalLoginConfig | null = entraEnabled + ? { + clientId: process.env.AZURE_CLIENT_ID!, + tenantId: process.env.AZURE_TENANT_ID!, + redirectUri: process.env.AZURE_REDIRECT_URI!, + } + : null; return (
@@ -28,10 +33,10 @@ export default async function LoginPage() {

- HP CG
Production
Tracker + Dow Jones
Studio
Tracker

- Pipeline management for CG production + Production pipeline for Dow Jones studio

@@ -59,15 +64,28 @@ export default async function LoginPage() { Sign in

- Use your Oliver or HP work account to continue. + Enter your email and password to continue.

-
- - - -
+ + + {entraEnabled && msalConfig && ( + <> +
+
+ + or + +
+
+
+ + + +
+ + )}

diff --git a/src/app/(auth)/reset-password/[token]/page.tsx b/src/app/(auth)/reset-password/[token]/page.tsx new file mode 100644 index 0000000..86a349a --- /dev/null +++ b/src/app/(auth)/reset-password/[token]/page.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { use, useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + +interface Props { + params: Promise<{ token: string }>; +} + +export default function ResetPasswordPage({ params }: Props) { + const { token } = use(params); + const router = useRouter(); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + + if (newPassword !== confirmPassword) { + setError("Passwords don't match."); + return; + } + if (newPassword.length < 10) { + setError("Password must be at least 10 characters."); + return; + } + + setSubmitting(true); + try { + const res = await fetch(`${BASE_PATH}/api/auth/reset-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, newPassword }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setError(data?.error ?? "Reset failed"); + setSubmitting(false); + return; + } + router.push("/login?reset=1"); + } catch { + setError("Network error — please try again."); + setSubmitting(false); + } + } + + return ( +

+
+

+ Choose a new password +

+ +
+
+ + setNewPassword(e.target.value)} + className="h-9 text-sm" + /> +

+ Minimum 10 characters. +

+
+
+ + setConfirmPassword(e.target.value)} + className="h-9 text-sm" + /> +
+ + {error && ( +

+ {error} +

+ )} + + +
+ +
+ + ← Back to sign in + +
+
+
+ ); +} diff --git a/src/app/api/auth/change-password/route.ts b/src/app/api/auth/change-password/route.ts new file mode 100644 index 0000000..aa51403 --- /dev/null +++ b/src/app/api/auth/change-password/route.ts @@ -0,0 +1,76 @@ +import { type NextRequest, NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; +import { prisma } from "@/lib/prisma"; +import { requireAuth } from "@/lib/rbac/require-auth"; + +/** + * POST /api/auth/change-password + * + * Body: { currentPassword: string, newPassword: string } + * + * Lets an authenticated user change their own password. Required to + * complete the mustChangePassword flow that the seed admin + freshly + * invited users land in on first sign-in. + * + * If the user has no existing passwordHash (e.g. an SSO-seeded account + * that later opted into local auth), the `currentPassword` check is + * skipped — this covers first-time password setup. + */ + +const MIN_PASSWORD_LEN = 10; + +export async function POST(request: NextRequest) { + const { session, error } = await requireAuth(); + if (error) return error; + + const body = await request.json().catch(() => null); + const currentPassword = body?.currentPassword as string | undefined; + const newPassword = body?.newPassword as string | undefined; + + if (!newPassword) { + return NextResponse.json( + { error: "New password is required" }, + { status: 400 } + ); + } + + if (newPassword.length < MIN_PASSWORD_LEN) { + return NextResponse.json( + { error: `Password must be at least ${MIN_PASSWORD_LEN} characters` }, + { status: 400 } + ); + } + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { passwordHash: true }, + }); + + if (user?.passwordHash) { + if (!currentPassword) { + return NextResponse.json( + { error: "Current password is required" }, + { status: 400 } + ); + } + const ok = await bcrypt.compare(currentPassword, user.passwordHash); + if (!ok) { + return NextResponse.json( + { error: "Current password is incorrect" }, + { status: 401 } + ); + } + } + + const passwordHash = await bcrypt.hash(newPassword, 10); + + await prisma.user.update({ + where: { id: session.user.id }, + data: { + passwordHash, + mustChangePassword: false, + }, + }); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/auth/forgot-password/route.ts b/src/app/api/auth/forgot-password/route.ts new file mode 100644 index 0000000..29af308 --- /dev/null +++ b/src/app/api/auth/forgot-password/route.ts @@ -0,0 +1,75 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { randomBytes } from "crypto"; +import { prisma } from "@/lib/prisma"; + +/** + * POST /api/auth/forgot-password + * + * Issues a single-use password-reset token for the given email. + * + * Always returns 200 — we don't leak whether an email is registered. + * When SMTP is configured the reset link is emailed; until it is, the + * reset URL is returned in the response so an admin can hand it over + * out-of-band. That falls back cleanly once email is wired up. + * + * Token: 32 bytes url-safe. Expires in 1 hour. + */ + +const TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour + +export async function POST(request: NextRequest) { + const body = await request.json().catch(() => null); + const email = (body?.email as string | undefined)?.trim().toLowerCase(); + + if (!email) { + return NextResponse.json( + { error: "Email is required" }, + { status: 400 } + ); + } + + const user = await prisma.user.findUnique({ + where: { email }, + select: { id: true }, + }); + + // Soft-fail response always — don't leak enumerability. + const response = { ok: true as const }; + + if (!user) { + return NextResponse.json(response); + } + + const token = randomBytes(24).toString("base64url"); + const expires = new Date(Date.now() + TOKEN_TTL_MS); + + await prisma.user.update({ + where: { id: user.id }, + data: { + passwordResetToken: token, + passwordResetExpires: expires, + }, + }); + + const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + const origin = + request.headers.get("origin") ?? + process.env.NEXT_PUBLIC_APP_URL ?? + ""; + const resetUrl = `${origin}${basePath}/reset-password/${token}`; + + // Placeholder for SMTP send — for now, log to the server console and + // return the URL in dev so an admin can copy/paste. + const devMode = process.env.NODE_ENV !== "production"; + console.info( + "[auth] Password reset issued", + JSON.stringify({ userId: user.id, expires: expires.toISOString() }) + ); + + return NextResponse.json({ + ...response, + // Only return the reset URL in dev — in prod we rely on SMTP so the + // token isn't inadvertently leaked to the requester. + ...(devMode ? { devResetUrl: resetUrl } : {}), + }); +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..ebbac33 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,90 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { randomUUID } from "crypto"; +import { cookies } from "next/headers"; +import bcrypt from "bcryptjs"; +import { prisma } from "@/lib/prisma"; + +/** + * POST /api/auth/login + * + * Local email + password authentication — the MVP path while Entra SSO is + * pending its redirect URI. Mirrors the existing MSAL session-cookie + * pattern in /api/auth/sso: verifies the password with bcrypt, creates an + * Auth.js-compatible DB session, sets the session cookie. The existing + * middleware + auth() calls don't need to change. + * + * Response: + * 200 { ok: true, mustChangePassword: boolean } + * 400 { error: "..." } — missing fields or user has no password set + * 401 { error: "..." } — bad credentials + */ + +const GENERIC_AUTH_ERROR = "Invalid email or password"; + +export async function POST(request: NextRequest) { + const body = await request.json().catch(() => null); + const email = (body?.email as string | undefined)?.trim().toLowerCase(); + const password = body?.password as string | undefined; + + if (!email || !password) { + return NextResponse.json( + { error: "Email and password are required" }, + { status: 400 } + ); + } + + const user = await prisma.user.findUnique({ + where: { email }, + select: { + id: true, + passwordHash: true, + mustChangePassword: true, + organizationId: true, + }, + }); + + // Constant-time-ish: always run a bcrypt compare even when the user + // doesn't exist, so response timing doesn't leak account existence. + const dummyHash = + "$2a$10$CwTycUXWue0Thq9StjUM0uJ8PqWn3WlxFlxxVvV8j/kHrXFNz2xKy"; + const hashToCompare = user?.passwordHash ?? dummyHash; + const passwordValid = await bcrypt.compare(password, hashToCompare); + + if (!user || !user.passwordHash || !passwordValid) { + return NextResponse.json({ error: GENERIC_AUTH_ERROR }, { status: 401 }); + } + + // Create a DB session matching PrismaAdapter.createSession format + const sessionToken = randomUUID(); + const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days + await prisma.session.create({ + data: { sessionToken, userId: user.id, expires }, + }); + + await prisma.user.update({ + where: { id: user.id }, + data: { lastLoginAt: new Date() }, + }); + + // Auth.js-compatible cookie so auth() reads it unchanged + const forwarded = request.headers.get("x-forwarded-proto"); + const isSecure = + forwarded === "https" || request.url.startsWith("https://"); + const cookieName = isSecure + ? "__Secure-authjs.session-token" + : "authjs.session-token"; + + const cookieStore = await cookies(); + cookieStore.set(cookieName, sessionToken, { + httpOnly: true, + secure: isSecure, + sameSite: "lax", + path: "/", + expires, + }); + + return NextResponse.json({ + ok: true, + mustChangePassword: user.mustChangePassword, + }); +} diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/auth/reset-password/route.ts new file mode 100644 index 0000000..39280fb --- /dev/null +++ b/src/app/api/auth/reset-password/route.ts @@ -0,0 +1,75 @@ +import { type NextRequest, NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; +import { prisma } from "@/lib/prisma"; + +/** + * POST /api/auth/reset-password + * + * Body: { token: string, newPassword: string } + * + * Validates the reset token, bcrypt-hashes the new password, stores it, + * clears the token and flips mustChangePassword=false. Also revokes all + * existing sessions for the user so anyone with a stolen cookie is + * logged out. + * + * Responses: + * 200 { ok: true } + * 400 { error } — missing fields, weak password, invalid/expired token + */ + +const MIN_PASSWORD_LEN = 10; + +export async function POST(request: NextRequest) { + const body = await request.json().catch(() => null); + const token = body?.token as string | undefined; + const newPassword = body?.newPassword as string | undefined; + + if (!token || !newPassword) { + return NextResponse.json( + { error: "Token and new password are required" }, + { status: 400 } + ); + } + + if (newPassword.length < MIN_PASSWORD_LEN) { + return NextResponse.json( + { error: `Password must be at least ${MIN_PASSWORD_LEN} characters` }, + { status: 400 } + ); + } + + const user = await prisma.user.findUnique({ + where: { passwordResetToken: token }, + select: { id: true, passwordResetExpires: true }, + }); + + if ( + !user || + !user.passwordResetExpires || + user.passwordResetExpires < new Date() + ) { + return NextResponse.json( + { error: "Reset link is invalid or expired" }, + { status: 400 } + ); + } + + const passwordHash = await bcrypt.hash(newPassword, 10); + + await prisma.$transaction([ + prisma.user.update({ + where: { id: user.id }, + data: { + passwordHash, + mustChangePassword: false, + passwordResetToken: null, + passwordResetExpires: null, + }, + }), + // Nuke any existing sessions so a leaked cookie can't linger after + // a password reset. + prisma.session.deleteMany({ where: { userId: user.id } }), + ]); + + return NextResponse.json({ ok: true }); +} diff --git a/src/middleware.ts b/src/middleware.ts index 17693ef..bc126f6 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -33,7 +33,10 @@ export function middleware(request: NextRequest) { } } - const isAuthPage = pathname.startsWith("/login"); + const isAuthPage = + pathname.startsWith("/login") || + pathname.startsWith("/forgot-password") || + pathname.startsWith("/reset-password"); const isPendingPage = pathname.startsWith("/pending"); const isApiAuth = pathname.startsWith("/api/auth"); const isHealthCheck = pathname === "/api/health";