Next.js项目MindAI教程 - 第四章:用户认证系统

1. NextAuth.js 集成

1.1 安装依赖

npm install next-auth bcryptjs

npm install @types/bcryptjs --save-dev

TypeScript 复制代码
npm install next-auth bcryptjs
npm install @types/bcryptjs --save-dev

1.2 配置NextAuth

TypeScript 复制代码
// src/app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth'
import CredentialsProvider from 'next-auth/providers/credentials'
import { compare } from 'bcryptjs'
import { UserService } from '@/services/database/user.service'

const handler = NextAuth({
  providers: [
    CredentialsProvider({
      name: 'Credentials',
      credentials: {
        email: { label: "邮箱", type: "email" },
        password: { label: "密码", type: "password" }
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) {
          throw new Error('请输入邮箱和密码')
        }

        const user = await UserService.findByEmail(credentials.email)
        if (!user) {
          throw new Error('用户不存在')
        }

        const isValid = await compare(credentials.password, user.password)
        if (!isValid) {
          throw new Error('密码错误')
        }

        return {
          id: user.id,
          email: user.email,
          name: user.name,
          role: user.role,
        }
      }
    })
  ],
  session: {
    strategy: 'jwt',
  },
  pages: {
    signIn: '/auth/login',
    signOut: '/auth/logout',
    error: '/auth/error',
  },
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.role = user.role
        token.id = user.id
      }
      return token
    },
    async session({ session, token }) {
      if (session.user) {
        session.user.role = token.role as string
        session.user.id = token.id as string
      }
      return session
    }
  }
})

export { handler as GET, handler as POST }

2. 认证页面实现

2.1 登录页面

TypeScript 复制代码
// src/app/auth/login/page.tsx
'use client'

import { useState } from 'react'
import { signIn } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'

export default function LoginPage() {
  const router = useRouter()
  const [error, setError] = useState('')
  const [loading, setLoading] = useState(false)

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault()
    setLoading(true)
    setError('')

    const formData = new FormData(e.currentTarget)
    const email = formData.get('email') as string
    const password = formData.get('password') as string

    try {
      const result = await signIn('credentials', {
        redirect: false,
        email,
        password,
      })

      if (result?.error) {
        setError(result.error)
      } else {
        router.push('/')
        router.refresh()
      }
    } catch (error) {
      setError('登录失败,请重试')
    } finally {
      setLoading(false)
    }
  }

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
      <div className="max-w-md w-full space-y-8">
        <div>
          <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
            登录账户
          </h2>
        </div>
        <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
          {error && (
            <div className="rounded-md bg-red-50 p-4">
              <div className="text-sm text-red-700">{error}</div>
            </div>
          )}
          <div className="rounded-md shadow-sm -space-y-px">
            <div>
              <label htmlFor="email" className="sr-only">
                邮箱
              </label>
              <input
                id="email"
                name="email"
                type="email"
                required
                className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
                placeholder="邮箱地址"
              />
            </div>
            <div>
              <label htmlFor="password" className="sr-only">
                密码
              </label>
              <input
                id="password"
                name="password"
                type="password"
                required
                className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
                placeholder="密码"
              />
            </div>
          </div>

          <div>
            <button
              type="submit"
              disabled={loading}
              className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
            >
              {loading ? '登录中...' : '登录'}
            </button>
          </div>

          <div className="text-sm text-center">
            <Link
              href="/auth/register"
              className="font-medium text-primary-600 hover:text-primary-500"
            >
              还没有账户?立即注册
            </Link>
          </div>
        </form>
      </div>
    </div>
  )
}

2.2 注册页面

TypeScript 复制代码
// src/app/auth/register/page.tsx
'use client'

import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'

export default function RegisterPage() {
  const router = useRouter()
  const [error, setError] = useState('')
  const [loading, setLoading] = useState(false)

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault()
    setLoading(true)
    setError('')

    const formData = new FormData(e.currentTarget)
    const data = {
      email: formData.get('email') as string,
      password: formData.get('password') as string,
      name: formData.get('name') as string,
    }

    try {
      const res = await fetch('/api/users', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data),
      })

      if (!res.ok) {
        const error = await res.json()
        throw new Error(error.message || '注册失败')
      }

      router.push('/auth/login')
    } catch (error) {
      setError(error instanceof Error ? error.message : '注册失败,请重试')
    } finally {
      setLoading(false)
    }
  }

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
      <div className="max-w-md w-full space-y-8">
        <div>
          <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
            创建新账户
          </h2>
        </div>
        <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
          {error && (
            <div className="rounded-md bg-red-50 p-4">
              <div className="text-sm text-red-700">{error}</div>
            </div>
          )}
          <div className="rounded-md shadow-sm -space-y-px">
            <div>
              <label htmlFor="name" className="sr-only">
                姓名
              </label>
              <input
                id="name"
                name="name"
                type="text"
                required
                className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
                placeholder="姓名"
              />
            </div>
            <div>
              <label htmlFor="email" className="sr-only">
                邮箱
              </label>
              <input
                id="email"
                name="email"
                type="email"
                required
                className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
                placeholder="邮箱地址"
              />
            </div>
            <div>
              <label htmlFor="password" className="sr-only">
                密码
              </label>
              <input
                id="password"
                name="password"
                type="password"
                required
                className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
                placeholder="密码"
              />
            </div>
          </div>

          <div>
            <button
              type="submit"
              disabled={loading}
              className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
            >
              {loading ? '注册中...' : '注册'}
            </button>
          </div>

          <div className="text-sm text-center">
            <Link
              href="/auth/login"
              className="font-medium text-primary-600 hover:text-primary-500"
            >
              已有账户?立即登录
            </Link>
          </div>
        </form>
      </div>
    </div>
  )
}

3. 权限控制实现

3.1 创建权限中间件

TypeScript 复制代码
// src/middleware.ts
import { withAuth } from 'next-auth/middleware'
import { NextResponse } from 'next/server'

export default withAuth(
  function middleware(req) {
    const token = req.nextauth.token
    const path = req.nextUrl.pathname

    // 管理员路由保护
    if (path.startsWith('/admin') && token?.role !== 'ADMIN') {
      return NextResponse.redirect(new URL('/auth/login', req.url))
    }

    // 咨询师路由保护
    if (path.startsWith('/counselor') && token?.role !== 'COUNSELOR') {
      return NextResponse.redirect(new URL('/auth/login', req.url))
    }

    return NextResponse.next()
  },
  {
    callbacks: {
      authorized: ({ token }) => !!token
    },
  }
)

export const config = {
  matcher: ['/admin/:path*', '/counselor/:path*', '/profile/:path*']
}

3.2 创建权限Hook

TypeScript 复制代码
// src/hooks/useAuth.ts
'use client'

import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'

export function useAuth(requiredRole?: string) {
  const { data: session, status } = useSession()
  const router = useRouter()

  useEffect(() => {
    if (status === 'loading') return

    if (!session) {
      router.push('/auth/login')
      return
    }

    if (requiredRole && session.user.role !== requiredRole) {
      router.push('/')
    }
  }, [session, status, requiredRole, router])

  return { session, status }
}

3.3 创建受保护的组件包装器

TypeScript 复制代码
// src/components/auth/ProtectedRoute.tsx
'use client'

import { useAuth } from '@/hooks/useAuth'
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'

interface ProtectedRouteProps {
  children: React.ReactNode
  requiredRole?: string
}

export function ProtectedRoute({ children, requiredRole }: ProtectedRouteProps) {
  const { status } = useAuth(requiredRole)

  if (status === 'loading') {
    return (
      <div className="min-h-screen flex items-center justify-center">
        <LoadingSpinner />
      </div>
    )
  }

  return <>{children}</>
}

4. 用户状态管理

4.1 创建用户状态Store

TypeScript 复制代码
// src/store/useUserStore.ts
import { create } from 'zustand'
import { UserProfile } from '@/types'

interface UserState {
  profile: UserProfile | null
  setProfile: (profile: UserProfile | null) => void
  updateProfile: (data: Partial<UserProfile>) => void
}

export const useUserStore = create<UserState>((set) => ({
  profile: null,
  setProfile: (profile) => set({ profile }),
  updateProfile: (data) =>
    set((state) => ({
      profile: state.profile ? { ...state.profile, ...data } : null,
    })),
}))

4.2 创建用户Provider

TypeScript 复制代码
// src/components/providers/UserProvider.tsx
'use client'

import { useSession } from 'next-auth/react'
import { useEffect } from 'react'
import { useUserStore } from '@/store/useUserStore'

export function UserProvider({ children }: { children: React.ReactNode }) {
  const { data: session } = useSession()
  const setProfile = useUserStore((state) => state.setProfile)

  useEffect(() => {
    if (session?.user) {
      // 获取用户详细信息
      fetch(`/api/users/profile?userId=${session.user.id}`)
        .then((res) => res.json())
        .then((data) => {
          setProfile(data)
        })
        .catch(console.error)
    } else {
      setProfile(null)
    }
  }, [session, setProfile])

  return <>{children}</>
}

5.集成到应用

5.1更新根布局

TypeScript 复制代码
// src/app/layout.tsx
import { Inter } from 'next/font/google'
import { SessionProvider } from 'next-auth/react'
import { UserProvider } from '@/components/providers/UserProvider'
import PageLayout from '@/components/layout/PageLayout'
import './globals.css'

const inter = Inter({ subsets: ['latin'] })

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="zh-CN">
      <body className={inter.className}>
        <SessionProvider>
          <UserProvider>
            <PageLayout>{children}</PageLayout>
          </UserProvider>
        </SessionProvider>
      </body>
    </html>
  )
}

5.2更新导航栏

TypeScript 复制代码
// src/components/layout/Navbar.tsx
'use client'

import { useSession, signOut } from 'next-auth/react'
import Link from 'next/link'
import { useUserStore } from '@/store/useUserStore'

export default function Navbar() {
  const { data: session } = useSession()
  const profile = useUserStore((state) => state.profile)

  return (
    <nav className="bg-white shadow">
      {/* ... 其他导航代码 ... */}
      
      <div className="flex items-center">
        {session ? (
          <div className="relative ml-3">
            <div className="flex items-center">
              <span className="text-gray-700 mr-4">
                {profile?.name || session.user.email}
              </span>
              <button
                onClick={() => signOut()}
                className="text-gray-600 hover:text-gray-900"
              >
                退出
              </button>
            </div>
          </div>
        ) : (
          <Link
            href="/auth/login"
            className="text-gray-600 hover:text-gray-900"
          >
            登录
          </Link>
        )}
      </div>
    </nav>
  )
}

6. 下一步计划

  • 集成OpenAI API
  • 实现心理测评功能
  • 开发情绪检测系统
  • 构建在线咨询功能
相关推荐
四念处茫茫5 分钟前
【C语言系列】C语言内存函数
c语言·开发语言·算法·visual studio
不爱敲代码吖7 分钟前
Python 数据可视化创意工坊:从交互到艺术,解锁数据展示新灵感
开发语言·python·信息可视化
boJIke9 分钟前
分库分表与NewSQL数据库的区别及适用场景
数据库·new sql
阿拉保10 分钟前
卷积神经网络(知识点)
人工智能·神经网络·cnn
H2X7_11 分钟前
C++之list类(超详细)
开发语言·c++
Bigger24 分钟前
终端美化神器——打造高颜值命令行输出
前端·node.js·命令行
stevenzqzq33 分钟前
java常量池
java·开发语言
“抚琴”的人35 分钟前
C#—【在不同的场景该用哪种线程?】
开发语言·c#·多线程
Moshow郑锴36 分钟前
基于SpringBoot3+Druid数据库连接池与外部PostgreSQL的Kubernetes Pod YAML全解析
数据库·容器·kubernetes