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
  • 实现心理测评功能
  • 开发情绪检测系统
  • 构建在线咨询功能
相关推荐
叁沐1 分钟前
MySQL 08 详解read view:事务到底是隔离的还是不隔离的?
mysql
张晓~183399481217 分钟前
数字人源码部署流程分享--- PC+小程序融合方案
javascript·小程序·矩阵·aigc·文心一言·html5
爱喝水的小周10 分钟前
AJAX vs axios vs fetch
前端·javascript·ajax
Jinxiansen021113 分钟前
unplugin-vue-components 最佳实践手册
前端·javascript·vue.js
几道之旅17 分钟前
介绍electron
前端·javascript·electron
周胡杰19 分钟前
鸿蒙arkts使用关系型数据库,使用DB Browser for SQLite连接和查看数据库数据?使用TaskPool进行频繁数据库操作
前端·数据库·华为·harmonyos·鸿蒙·鸿蒙系统
wkj00122 分钟前
navicate如何设置数据库引擎
数据库·mysql
ladymorgana22 分钟前
【Spring Boot】HikariCP 连接池 YAML 配置详解
spring boot·后端·mysql·连接池·hikaricp
赵渝强老师25 分钟前
【赵渝强老师】Oracle RMAN的目录数据库
数据库·oracle
暖暖木头27 分钟前
Oracle注释详解
数据库·oracle