Phase 6a: local auth — credentials login + password reset flow
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) <noreply@anthropic.com>
This commit is contained in:
parent
7598f4285e
commit
eede696eee
10 changed files with 814 additions and 14 deletions
133
src/app/(auth)/change-password/page.tsx
Normal file
133
src/app/(auth)/change-password/page.tsx
Normal file
|
|
@ -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<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
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 (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[var(--background)] px-8 py-12">
|
||||
<div className="w-full max-w-[360px]">
|
||||
<h1 className="font-heading text-lg font-black tracking-[-0.02em]">
|
||||
{isFirstLogin ? "Set a new password" : "Change password"}
|
||||
</h1>
|
||||
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
|
||||
{isFirstLogin
|
||||
? "This is your first sign-in. Please choose a new password before continuing."
|
||||
: "Use a password you don't use anywhere else."}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="currentPassword" className="text-xs">
|
||||
Current password
|
||||
</Label>
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="newPassword" className="text-xs">
|
||||
New password
|
||||
</Label>
|
||||
<Input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={10}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<p className="text-[10px] text-[var(--muted-foreground)]">
|
||||
Minimum 10 characters.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="confirmPassword" className="text-xs">
|
||||
Confirm new password
|
||||
</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-500" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-9 w-full text-xs font-semibold"
|
||||
disabled={submitting || !newPassword || !confirmPassword}
|
||||
>
|
||||
{submitting ? "Saving…" : "Save new password"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
src/app/(auth)/forgot-password/page.tsx
Normal file
103
src/app/(auth)/forgot-password/page.tsx
Normal file
|
|
@ -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<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
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 (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[var(--background)] px-8 py-12">
|
||||
<div className="w-full max-w-[360px]">
|
||||
<h1 className="font-heading text-lg font-black tracking-[-0.02em]">
|
||||
Forgot your password?
|
||||
</h1>
|
||||
|
||||
{status === "idle" ? (
|
||||
<>
|
||||
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
|
||||
Enter your email and we'll send a reset link.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email" className="text-xs">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting || !email}
|
||||
className="h-9 w-full text-xs font-semibold"
|
||||
>
|
||||
{submitting ? "Sending…" : "Send reset link"}
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<div className="mt-6 space-y-4 text-xs">
|
||||
<p>
|
||||
If that email is registered, you'll receive a reset link shortly.
|
||||
The link expires in one hour.
|
||||
</p>
|
||||
{devResetUrl && (
|
||||
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)]/40 p-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||
Dev mode — copy this link
|
||||
</p>
|
||||
<p className="mt-1 break-all font-mono text-[11px]">
|
||||
{devResetUrl}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 text-[11px] text-[var(--muted-foreground)]">
|
||||
<Link href="/login" className="underline hover:text-[var(--foreground)]">
|
||||
← Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
src/app/(auth)/login/CredentialsLogin.tsx
Normal file
106
src/app/(auth)/login/CredentialsLogin.tsx
Normal file
|
|
@ -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<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
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 (
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email" className="text-xs">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="h-9 text-sm"
|
||||
placeholder="you@oliver.agency"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<Label htmlFor="password" className="text-xs">
|
||||
Password
|
||||
</Label>
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-[10px] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
>
|
||||
Forgot?
|
||||
</Link>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-500" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-9 w-full text-xs font-semibold"
|
||||
disabled={submitting || !email || !password}
|
||||
>
|
||||
{submitting ? "Signing in…" : "Sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="flex min-h-screen bg-[var(--background)]">
|
||||
|
|
@ -28,10 +33,10 @@ export default async function LoginPage() {
|
|||
</div>
|
||||
<div>
|
||||
<h1 className="font-heading text-4xl font-black leading-[1.05] tracking-[-0.03em] text-[var(--primary-foreground)]">
|
||||
HP CG<br />Production<br />Tracker
|
||||
Dow Jones<br />Studio<br />Tracker
|
||||
</h1>
|
||||
<p className="mt-4 text-[11px] font-medium tracking-[0.06em] uppercase text-[var(--primary-foreground)]/60">
|
||||
Pipeline management for CG production
|
||||
Production pipeline for Dow Jones studio
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -59,15 +64,28 @@ export default async function LoginPage() {
|
|||
Sign in
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
|
||||
Use your Oliver or HP work account to continue.
|
||||
Enter your email and password to continue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<Suspense fallback={null}>
|
||||
<MsalLogin config={msalConfig} />
|
||||
</Suspense>
|
||||
</div>
|
||||
<CredentialsLogin />
|
||||
|
||||
{entraEnabled && msalConfig && (
|
||||
<>
|
||||
<div className="my-6 flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-[var(--border)]" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.1em] text-[var(--muted-foreground)]">
|
||||
or
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-[var(--border)]" />
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
<Suspense fallback={null}>
|
||||
<MsalLogin config={msalConfig} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-10 border-t pt-6">
|
||||
<p className="text-[9px] font-semibold tracking-[0.12em] uppercase text-[var(--muted-foreground)]/60">
|
||||
|
|
|
|||
121
src/app/(auth)/reset-password/[token]/page.tsx
Normal file
121
src/app/(auth)/reset-password/[token]/page.tsx
Normal file
|
|
@ -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<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
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 (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[var(--background)] px-8 py-12">
|
||||
<div className="w-full max-w-[360px]">
|
||||
<h1 className="font-heading text-lg font-black tracking-[-0.02em]">
|
||||
Choose a new password
|
||||
</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="newPassword" className="text-xs">
|
||||
New password
|
||||
</Label>
|
||||
<Input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={10}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<p className="text-[10px] text-[var(--muted-foreground)]">
|
||||
Minimum 10 characters.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="confirmPassword" className="text-xs">
|
||||
Confirm password
|
||||
</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-500" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-9 w-full text-xs font-semibold"
|
||||
disabled={submitting || !newPassword || !confirmPassword}
|
||||
>
|
||||
{submitting ? "Saving…" : "Save & sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 text-[11px] text-[var(--muted-foreground)]">
|
||||
<Link href="/login" className="underline hover:text-[var(--foreground)]">
|
||||
← Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
src/app/api/auth/change-password/route.ts
Normal file
76
src/app/api/auth/change-password/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
75
src/app/api/auth/forgot-password/route.ts
Normal file
75
src/app/api/auth/forgot-password/route.ts
Normal file
|
|
@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
90
src/app/api/auth/login/route.ts
Normal file
90
src/app/api/auth/login/route.ts
Normal file
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
75
src/app/api/auth/reset-password/route.ts
Normal file
75
src/app/api/auth/reset-password/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue