Gate signup behind an invitation and revoke sessions on suspend
Registration was open to anyone who could reach the app. Signup now requires an invite: /invite/<token> validates the link and parks the token in an HttpOnly cookie, /join re-checks it and renders the form, and the register route consumes a use inside the same transaction that creates the user - so a duplicate email rolls the use back instead of burning it. The token never reaches the client, so the form cannot forge or replay one. The jwt callback now revalidates the user on every call and returns null when the account is missing or suspended, which clears the session cookie. Every API route and server component already branches on session?.user?.id, so this revokes access everywhere without editing any of them. The try/catch around that lookup is load-bearing: Auth.js treats a throw in this callback the same as a null return, so an unguarded transient database error would sign out every user at once. authorize() rejects non-ACTIVE users too, so a suspended account cannot sign in again to mint a fresh token. /register redirects to /join; it is linked from elsewhere and may be bookmarked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { cookies } from "next/headers"
|
||||
import { z } from "zod"
|
||||
import bcrypt from "bcryptjs"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { rateLimit } from "@/lib/rate-limit"
|
||||
import { INVITE_COOKIE, InviteError, claimInvite } from "@/lib/invites"
|
||||
|
||||
const registerSchema = z.object({
|
||||
name: z
|
||||
@@ -48,30 +50,69 @@ export async function POST(request: Request) {
|
||||
|
||||
const { name, email, password } = result.data
|
||||
|
||||
const existingUser = await prisma.user.findUnique({ where: { email } })
|
||||
if (existingUser) {
|
||||
// Signup is invite only. The token is read from the HttpOnly cookie set by
|
||||
// /invite/<token>, never from the request body, so a caller cannot supply one.
|
||||
const inviteToken = cookies().get(INVITE_COOKIE)?.value
|
||||
if (!inviteToken) {
|
||||
return NextResponse.json(
|
||||
{ error: "An account with this email already exists" },
|
||||
{ status: 409 }
|
||||
{ error: "An invitation is required to sign up." },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10)
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
emailVerified: new Date(),
|
||||
},
|
||||
// One transaction so a duplicate email rolls back the consumed invite use
|
||||
// rather than burning it on a failed signup.
|
||||
const user = await prisma.$transaction(async (tx) => {
|
||||
const invite = await claimInvite(tx, inviteToken)
|
||||
if (!invite) {
|
||||
throw new InviteError("This invitation link is no longer valid.", 403)
|
||||
}
|
||||
|
||||
const existingUser = await tx.user.findUnique({ where: { email } })
|
||||
if (existingUser) {
|
||||
throw new InviteError("An account with this email already exists", 409)
|
||||
}
|
||||
|
||||
const created = await tx.user.create({
|
||||
data: {
|
||||
name,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
// The invite is the verification - there is no email infrastructure to
|
||||
// send a confirmation, and the link was delivered out of band.
|
||||
emailVerified: new Date(),
|
||||
role: "MEMBER",
|
||||
status: "ACTIVE",
|
||||
invitedById: invite.createdById,
|
||||
},
|
||||
})
|
||||
|
||||
await tx.inviteRedemption.create({
|
||||
data: {
|
||||
inviteId: invite.id,
|
||||
userId: created.id,
|
||||
email,
|
||||
provider: "credentials",
|
||||
},
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
const response = NextResponse.json(
|
||||
{ id: user.id, name: user.name, email: user.email },
|
||||
{ status: 201 }
|
||||
)
|
||||
} catch {
|
||||
// Spent - do not leave it lying around for a second signup attempt.
|
||||
response.cookies.delete(INVITE_COOKIE)
|
||||
return response
|
||||
} catch (error) {
|
||||
if (error instanceof InviteError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
console.error("Registration error:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Something went wrong. Please try again." },
|
||||
{ status: 500 }
|
||||
|
||||
Reference in New Issue
Block a user