集成 Supabase 认证

March 2, 2025

集成 Supabase 认证实现评论系统

在构建现代 Web 应用时,用户认证是一个基础但复杂的功能。本文将详细介绍如何利用 Supabase 提供的认证服务,为博客评论系统实现完整的用户认证流程。

1. 认证架构概述

我们的评论系统采用了基于 JWT 的身份验证机制,通过 React Context API 提供全局用户状态管理,实现与 UI 组件的无缝集成。整个认证架构基于 Supabase Auth 构建,主要支持 GitHub OAuth 登录。

认证流程图

2. 核心组件实现

2.1 Supabase 客户端配置

首先,需要创建 Supabase 客户端并配置类型支持:

// lib/supabase.ts
import { createClient } from "@supabase/supabase-js";
 
// 定义数据库类型
export type Database = {
  public: {
    Tables: {
      comments: {
        Row: {
          id: string;
          created_at: string;
          user_id: string;
          post_slug: string;
          content: string;
          parent_id: string | null;
          likes_count: number;
          liked_by: string[];
        };
        // 其他类型定义...
      };
      profiles: {
        Row: {
          id: string;
          username: string | null;
          full_name: string | null;
          avatar_url: string | null;
          // 其他字段...
        };
        // 其他类型定义...
      };
    };
  };
};
 
// 创建Supabase客户端实例
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || "";
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "";
 
export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey);

2.2 认证上下文提供者

通过 Context API 创建全局认证状态管理:

// components/auth/AuthProvider.tsx
"use client";
 
import { createContext, useContext, useEffect, useState } from "react";
import { Session, User } from "@supabase/supabase-js";
import { supabase } from "@/lib/supabase";
 
type AuthContextType = {
  user: User | null;
  session: Session | null;
  isLoading: boolean;
  signInWithGithub: (redirectTo?: string) => Promise<void>;
  signOut: () => Promise<void>;
};
 
const AuthContext = createContext<AuthContextType | undefined>(undefined);
 
export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [session, setSession] = useState<Session | null>(null);
  const [isLoading, setIsLoading] = useState(true);
 
  useEffect(() => {
    // 获取初始会话状态
    const getInitialSession = async () => {
      try {
        setIsLoading(true);
        const {
          data: { session },
        } = await supabase.auth.getSession();
        setSession(session);
        setUser(session?.user || null);
      } catch (error) {
        console.error("Error getting initial session:", error);
      } finally {
        setIsLoading(false);
      }
    };
 
    getInitialSession();
 
    // 设置认证状态监听器
    const {
      data: { subscription },
    } = supabase.auth.onAuthStateChange((_event, session) => {
      setSession(session);
      setUser(session?.user || null);
      setIsLoading(false);
    });
 
    return () => {
      subscription.unsubscribe();
    };
  }, []);
 
  // GitHub登录
  const signInWithGithub = async (redirectTo?: string) => {
    const redirectUrl = redirectTo
      ? `${window.location.origin}${redirectTo}`
      : undefined;
 
    const { error } = await supabase.auth.signInWithOAuth({
      provider: "github",
      options: {
        redirectTo: redirectUrl,
      },
    });
 
    if (error) {
      console.error("GitHub 登录错误:", error);
      throw error;
    }
  };
 
  // 登出
  const signOut = async () => {
    try {
      await supabase.auth.signOut();
    } catch (error) {
      console.error("登出失败:", error);
    }
  };
 
  const value = {
    user,
    session,
    isLoading,
    signInWithGithub,
    signOut,
  };
 
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
 
// 自定义Hook,用于在组件中使用认证上下文
export function useAuth() {
  const context = useContext(AuthContext);
 
  if (context === undefined) {
    throw new Error("useAuth必须在AuthProvider内部使用");
  }
 
  return context;
}

2.3 登录对话框组件

创建用户友好的登录界面:

// components/auth/LoginDialog.tsx
"use client";
 
import { useState } from "react";
import { X } from "lucide-react";
import {
  Dialog,
  DialogContent,
  DialogTitle,
  DialogClose,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useAuth } from "./AuthProvider";
import { usePathname } from "next/navigation";
 
interface LoginDialogProps {
  isOpen: boolean;
  onClose: () => void;
}
 
export default function LoginDialog({ isOpen, onClose }: LoginDialogProps) {
  const { signInWithGithub } = useAuth();
  const [isLoading, setIsLoading] = useState(false);
  const pathname = usePathname();
 
  const handleGithubLogin = async () => {
    try {
      setIsLoading(true);
      await signInWithGithub(pathname);
      onClose();
    } catch (error) {
      console.error("GitHub登录失败:", error);
    } finally {
      setIsLoading(false);
    }
  };
 
  return (
    <Dialog open={isOpen} onOpenChange={onClose}>
      <DialogContent className="sm:max-w-md p-6 gap-6">
        <DialogClose className="absolute right-4 top-4">
          <X className="h-4 w-4" />
          <span className="sr-only">关闭</span>
        </DialogClose>
 
        <div>
          <DialogTitle className="text-xl font-bold mb-2">登入</DialogTitle>
          <p className="text-sm text-muted-foreground">以继续使用评论系统</p>
        </div>
 
        <div className="flex flex-col space-y-3">
          <Button
            className="w-full bg-black hover:bg-gray-800 text-white"
            onClick={handleGithubLogin}
            disabled={isLoading}
          >
            <svg
              viewBox="0 0 24 24"
              className="h-5 w-5 mr-2"
              fill="currentColor"
            >
              <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
            </svg>
            使用 GitHub 继续
          </Button>
        </div>
      </DialogContent>
    </Dialog>
  );
}

3. 认证流程详解

3.1 初始化与会话管理

当应用加载时,AuthProvider 组件会:

  1. 检查本地存储中的会话信息
  2. 设置认证状态监听器,响应登录状态变化
  3. 向所有子组件提供统一的认证上下文

认证状态变更(如登录或登出)时,会自动更新全局状态并反映到 UI 中。

3.2 用户登录流程

当用户点击登录按钮时:

  1. 触发handleGithubLogin函数
  2. 调用 Supabase 的 OAuth 接口,重定向到 GitHub 授权页面
  3. 用户在 GitHub 上授权后,被重定向回应用
  4. Supabase 处理返回的授权码,获取访问令牌
  5. 创建会话并通过onAuthStateChange事件通知应用
  6. AuthProvider 更新用户状态,UI 随之更新

3.3 会话持久化

Supabase 使用多种机制确保会话的持久性:

  • localStorage:存储会话数据,实现跨页面导航的状态保持
  • 自动令牌刷新:JWT 接近过期时自动刷新
  • 同步机制:确保多个标签页中的认证状态一致

4. 与评论系统集成

4.1 评论提交与用户关联

// CommentSection.tsx中的评论提交函数
const handleCommentSubmit = async (content: string, parentId?: string) => {
  // 检查用户登录状态
  if (!user) {
    setIsLoginDialogOpen(true);
    return;
  }
 
  try {
    // 显示提交中状态
    toast.loading("正在提交评论...", {
      position: "bottom-right",
      id: "comment-submit",
    });
 
    // 提交到数据库
    const { error } = await supabase.from("comments").insert({
      user_id: user.id, // 使用当前登录用户ID
      post_slug: postSlug,
      content,
      parent_id: parentId || null,
    });
 
    if (error) {
      throw error;
    }
 
    // 显示成功提示
    toast.success("评论发布成功!", {
      position: "bottom-right",
      id: "comment-submit",
    });
 
    // 刷新评论列表
    loadComments();
  } catch (error) {
    console.error("提交评论失败:", error);
    toast.error("评论发布失败,请稍后重试", {
      position: "bottom-right",
      id: "comment-submit",
    });
  }
};

4.2 评论显示与用户信息

在 CommentItem 组件中展示用户信息:

const profile = comment.profiles;
const username = profile.username || profile.full_name || "匿名用户";
 
return (
  <Card className="border-gray-100 dark:border-gray-800 shadow-sm">
    <CardHeader className="flex flex-row items-start space-y-0 pt-4 pb-2">
      <div className="flex items-center space-x-3">
        <Avatar className="h-10 w-10 border">
          <AvatarImage src={profile.avatar_url || ""} alt={username} />
          <AvatarFallback>
            <UserIcon className="h-5 w-5" />
          </AvatarFallback>
        </Avatar>
        <div>
          <div className="font-medium text-sm">{username}</div>
          <div className="text-xs text-muted-foreground">
            {formatDistanceToNow(createdAt)}
          </div>
        </div>
      </div>
 
      {/* 只有评论作者可以看到删除选项 */}
      {isOwnComment && (
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button variant="ghost" size="icon" className="ml-auto">
              <MoreVertical className="h-4 w-4" />
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end">
            <DropdownMenuItem
              onClick={() => onDelete(comment.id)}
              className="text-destructive"
            >
              <Trash2 className="mr-2 h-4 w-4" />
              删除
            </DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      )}
    </CardHeader>
 
    {/* 评论内容和操作按钮 */}
    {/* ... */}
  </Card>
);

5. 数据库配置与安全

5.1 数据模型

评论系统涉及两个主要的数据表:

profiles 表:存储用户资料

create table profiles (
  id uuid references auth.users on delete cascade not null primary key,
  username text,
  full_name text,
  avatar_url text,
  updated_at timestamp with time zone
);

comments 表:存储评论内容

create table comments (
  id uuid default uuid_generate_v4() primary key,
  user_id uuid references auth.users on delete cascade not null,
  post_slug text not null,
  content text not null,
  parent_id uuid references comments(id) on delete cascade,
  created_at timestamp with time zone default now(),
  likes_count integer default 0,
  liked_by uuid[] default '{}'::uuid[]
);

5.2 行级安全策略(RLS)

为确保数据安全,我们设置了精细的 RLS 策略:

-- profiles表策略
create policy "公开读取用户资料" on profiles
  for select using (true);
 
create policy "用户可更新自己的资料" on profiles
  for update using (auth.uid() = id);
 
-- comments表策略
create policy "公开读取评论" on comments
  for select using (true);
 
create policy "已登录用户可创建评论" on comments
  for insert with check (auth.uid() = user_id);
 
create policy "用户可更新自己的评论" on comments
  for update using (auth.uid() = user_id);
 
create policy "用户可删除自己的评论" on comments
  for delete using (auth.uid() = user_id);
 
-- 特殊策略:允许用户点赞任何评论
create policy "用户可更新评论点赞" on comments
  for update using (auth.uid() is not null)
  with check (
    -- 只允许更新likes_count和liked_by字段
    (auth.uid() = user_id) or
    (
      coalesce(current_setting('app.column_check', true), '') = 'liked_by,likes_count'
    )
  );

5.3 用户资料自动同步

通过数据库触发器自动同步用户信息:

-- 触发器函数:在用户注册时创建资料
create or replace function public.handle_new_user()
returns trigger as $$
begin
  insert into public.profiles (id, username, full_name, avatar_url)
  values (
    new.id,
    new.raw_user_meta_data->>'user_name',
    new.raw_user_meta_data->>'full_name',
    new.raw_user_meta_data->>'avatar_url'
  );
  return new;
end;
$$ language plpgsql security definer;
 
-- 绑定触发器
create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();

6. 评论系统的实时更新

6.1 实时订阅配置

// 设置实时订阅评论变化
const commentsSubscription = supabase
  .channel("comments-changes")
  .on(
    "postgres_changes",
    {
      event: "*",
      schema: "public",
      table: "comments",
      filter: `post_slug=eq.${postSlug}`,
    },
    (payload) => {
      // 处理实时更新...
    }
  )
  .subscribe();

6.2 处理实时更新

// 处理点赞更新
if (isOnlyLikeUpdate && updatedComment.id) {
  console.log("检测到点赞更新:", updatedComment);
 
  // 局部更新评论列表中的点赞信息
  setComments((prevComments) =>
    prevComments.map((comment) => {
      // 更新主评论
      if (comment.id === updatedComment.id) {
        return {
          ...comment,
          likes_count: updatedComment.likes_count,
          liked_by: updatedComment.liked_by,
        };
      }
 
      // 更新回复
      if (comment.replies) {
        return {
          ...comment,
          replies: comment.replies.map((reply) =>
            reply.id === updatedComment.id
              ? {
                  ...reply,
                  likes_count: updatedComment.likes_count,
                  liked_by: updatedComment.liked_by,
                }
              : reply
          ),
        };
      }
 
      return comment;
    })
  );
} else {
  // 对于其他更新,刷新整个列表
  loadComments();
}

7. 性能优化与最佳实践

7.1 减少不必要的渲染

使用 React 的 useCallback 和 useMemo 优化性能:

// 使用useCallback缓存函数
const loadComments = useCallback(async () => {
  // 加载评论的逻辑...
}, [postSlug, sortOrder, commentsInitialized]);

7.2 错误处理与用户反馈

使用 toast 提供友好的用户反馈:

try {
  // 操作代码...
  toast.success("操作成功");
} catch (error) {
  console.error("错误:", error);
  toast.error("操作失败,请稍后重试");
}

8. 流程总结

整个认证流程可以概括为以下步骤:

  1. 用户点击登录按钮,打开登录对话框
  2. 选择 GitHub 登录方式,重定向到 GitHub 授权页面
  3. 授权成功后返回应用,Supabase 处理回调并创建会话
  4. AuthProvider 更新全局用户状态
  5. UI 根据用户状态显示评论功能、个人信息等
  6. 用户可以发表评论、点赞、删除自己的评论
  7. 所有操作都经过 RLS 策略验证,确保安全性
  8. 实时订阅保证多用户同时在线时的数据一致性

9. 总结与拓展

Supabase 提供了强大而简洁的认证解决方案,让我们能够快速实现功能齐全的用户认证系统。这种架构的优势在于:

  • 简化开发:无需自建认证服务器
  • 开箱即用:社交登录功能完善
  • 安全可靠:JWT 认证与 RLS 策略结合
  • 可扩展性:易于添加更多认证提供商
  • 实时功能:内置的实时数据同步

未来可以考虑的拓展方向:

  • 添加更多第三方登录选项(如 Google、Twitter 等)
  • 实现邮箱密码登录
  • 添加用户资料编辑功能
  • 实现评论审核机制
  • 添加通知系统,提醒用户收到回复或点赞

通过以上步骤,我们成功构建了一个完整的评论系统,集成了 Supabase 的认证功能。这不仅提升了用户体验,还确保了数据的安全与完整性。