Skip to content

NextAuth 集成

🙋 需要支持 GitHub、Google 登录?Auth.js(原 NextAuth)让认证变得简单。

安装配置#

Terminal window
pnpm add next-auth@beta

🔶 注意:Next.js 15 需要使用 Auth.js v5 (beta)。

基础配置#

创建配置文件#

auth.ts
import NextAuth from 'next-auth'
import GitHub from 'next-auth/providers/github'
import Google from 'next-auth/providers/google'
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
GitHub({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
}),
Google({
clientId: process.env.GOOGLE_ID!,
clientSecret: process.env.GOOGLE_SECRET!,
}),
],
})

环境变量#

.env.local
AUTH_SECRET="your-auth-secret" # openssl rand -base64 32
# GitHub OAuth
GITHUB_ID="your-github-client-id"
GITHUB_SECRET="your-github-client-secret"
# Google OAuth
GOOGLE_ID="your-google-client-id"
GOOGLE_SECRET="your-google-client-secret"

API 路由#

app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth'
export const { GET, POST } = handlers

OAuth 提供者#

GitHub#

auth.ts
import GitHub from 'next-auth/providers/github'
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
GitHub({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
// 自定义授权范围
authorization: {
params: {
scope: 'read:user user:email',
},
},
}),
],
})

Google#

import Google from 'next-auth/providers/google'
Google({
clientId: process.env.GOOGLE_ID!,
clientSecret: process.env.GOOGLE_SECRET!,
authorization: {
params: {
prompt: 'consent',
access_type: 'offline',
response_type: 'code',
},
},
})

微信(需要自定义)#

lib/wechat-provider.ts
import type { OAuthConfig } from 'next-auth/providers'
export function Wechat(): OAuthConfig<any> {
return {
id: 'wechat',
name: '微信',
type: 'oauth',
authorization: {
url: 'https://open.weixin.qq.com/connect/qrconnect',
params: {
appid: process.env.WECHAT_APP_ID,
scope: 'snsapi_login',
},
},
token: 'https://api.weixin.qq.com/sns/oauth2/access_token',
userinfo: 'https://api.weixin.qq.com/sns/userinfo',
profile(profile) {
return {
id: profile.unionid || profile.openid,
name: profile.nickname,
image: profile.headimgurl,
}
},
clientId: process.env.WECHAT_APP_ID,
clientSecret: process.env.WECHAT_APP_SECRET,
}
}

凭证登录#

配置#

auth.ts
import NextAuth from 'next-auth'
import Credentials from 'next-auth/providers/credentials'
import { compare } from 'bcryptjs'
import { db } from '@/lib/db'
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Credentials({
name: 'credentials',
credentials: {
email: { label: '邮箱', type: 'email' },
password: { label: '密码', type: 'password' },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null
}
const user = await db.user.findUnique({
where: { email: credentials.email as string },
})
if (!user || !user.password) {
return null
}
const isValid = await compare(
credentials.password as string,
user.password
)
if (!isValid) {
return null
}
return {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
}
},
}),
],
pages: {
signIn: '/login',
},
})

登录表单#

components/LoginForm.tsx
'use client'
import { signIn } from 'next-auth/react'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
export function LoginForm() {
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const router = useRouter()
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setLoading(true)
setError('')
const formData = new FormData(e.currentTarget)
const result = await signIn('credentials', {
email: formData.get('email'),
password: formData.get('password'),
redirect: false,
})
setLoading(false)
if (result?.error) {
setError('邮箱或密码错误')
} else {
router.push('/dashboard')
router.refresh()
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<input
name="email"
type="email"
placeholder="邮箱"
required
className="w-full border rounded px-3 py-2"
/>
</div>
<div>
<input
name="password"
type="password"
placeholder="密码"
required
className="w-full border rounded px-3 py-2"
/>
</div>
{error && <p className="text-red-500">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-2 rounded"
>
{loading ? '登录中...' : '登录'}
</button>
</form>
)
}

数据库适配器#

Prisma 适配器#

Terminal window
pnpm add @auth/prisma-adapter
auth.ts
import NextAuth from 'next-auth'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { prisma } from '@/lib/prisma'
import GitHub from 'next-auth/providers/github'
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
GitHub({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
}),
],
session: {
strategy: 'database', // 使用数据库存储 session
},
})

Prisma Schema#

prisma/schema.prisma
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}

获取会话#

服务端#

app/dashboard/page.tsx
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const session = await auth()
if (!session) {
redirect('/login')
}
return (
<div>
<h1>欢迎,{session.user?.name}</h1>
<img src={session.user?.image || ''} alt="头像" />
</div>
)
}

客户端#

components/UserMenu.tsx
'use client'
import { useSession, signOut } from 'next-auth/react'
export function UserMenu() {
const { data: session, status } = useSession()
if (status === 'loading') {
return <div>加载中...</div>
}
if (!session) {
return <a href="/login">登录</a>
}
return (
<div className="flex items-center gap-4">
<img
src={session.user?.image || ''}
alt="头像"
className="w-8 h-8 rounded-full"
/>
<span>{session.user?.name}</span>
<button onClick={() => signOut()}>退出</button>
</div>
)
}

SessionProvider#

app/providers.tsx
'use client'
import { SessionProvider } from 'next-auth/react'
export function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>
}
app/layout.tsx
import { Providers } from './providers'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh-CN">
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}

回调配置#

自定义 JWT#

auth.ts
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [...],
callbacks: {
async jwt({ token, user, account }) {
// 首次登录时添加用户信息
if (user) {
token.id = user.id
token.role = user.role
}
// 添加 OAuth 访问令牌
if (account) {
token.accessToken = account.access_token
}
return token
},
async session({ session, token }) {
// 将 token 信息添加到 session
session.user.id = token.id as string
session.user.role = token.role as string
session.accessToken = token.accessToken as string
return session
},
},
})

类型扩展#

types/next-auth.d.ts
import { DefaultSession } from 'next-auth'
declare module 'next-auth' {
interface Session {
user: {
id: string
role: string
} & DefaultSession['user']
accessToken?: string
}
interface User {
role?: string
}
}
declare module 'next-auth/jwt' {
interface JWT {
id: string
role: string
accessToken?: string
}
}

登录回调#

callbacks: {
async signIn({ user, account, profile }) {
// 限制特定域名邮箱
if (account?.provider === 'google') {
return profile?.email?.endsWith('@company.com') ?? false
}
// 检查用户是否被禁用
const dbUser = await db.user.findUnique({
where: { email: user.email! },
})
if (dbUser?.banned) {
return false
}
return true
},
}

中间件保护#

middleware.ts
import { auth } from '@/auth'
export default auth((req) => {
const isLoggedIn = !!req.auth
const isAuthPage = req.nextUrl.pathname.startsWith('/login')
const isProtected = req.nextUrl.pathname.startsWith('/dashboard')
if (isProtected && !isLoggedIn) {
return Response.redirect(new URL('/login', req.url))
}
if (isAuthPage && isLoggedIn) {
return Response.redirect(new URL('/dashboard', req.url))
}
})
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}

自定义登录页#

app/login/page.tsx
import { signIn } from '@/auth'
export default function LoginPage() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="max-w-md w-full space-y-8 p-8">
<h1 className="text-2xl font-bold text-center">登录</h1>
{/* OAuth 登录 */}
<div className="space-y-4">
<form
action={async () => {
'use server'
await signIn('github', { redirectTo: '/dashboard' })
}}
>
<button
type="submit"
className="w-full flex items-center justify-center gap-2 border rounded py-2"
>
<GitHubIcon />
使用 GitHub 登录
</button>
</form>
<form
action={async () => {
'use server'
await signIn('google', { redirectTo: '/dashboard' })
}}
>
<button
type="submit"
className="w-full flex items-center justify-center gap-2 border rounded py-2"
>
<GoogleIcon />
使用 Google 登录
</button>
</form>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500"></span>
</div>
</div>
{/* 凭证登录表单 */}
<CredentialsForm />
</div>
</div>
)
}

退出登录#

// 服务端 Action
import { signOut } from '@/auth'
export default function LogoutButton() {
return (
<form
action={async () => {
'use server'
await signOut({ redirectTo: '/' })
}}
>
<button type="submit">退出登录</button>
</form>
)
}
// 客户端
;('use client')
import { signOut } from 'next-auth/react'
export function ClientLogoutButton() {
return <button onClick={() => signOut({ callbackUrl: '/' })}>退出登录</button>
}

常见问题#

🤔 Q: OAuth 回调地址怎么配置?

在 OAuth 提供商后台配置:

开发环境: http://localhost:3000/api/auth/callback/github
生产环境: https://yourdomain.com/api/auth/callback/github

🤔 Q: 如何同时支持 JWT 和数据库 Session?

session: {
strategy: 'jwt', // 或 'database'
}

JWT 更适合 serverless,数据库 Session 更易管理。

🤔 Q: 如何刷新 OAuth Access Token?

使用 @auth/core 的 rotation 功能或在 jwt 回调中处理。


下一篇将介绍环境变量与配置,学习如何安全管理应用配置。

-EOF-