You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Firebase直链导航时AuthGuard未正确跳转未认证用户至首页

自定义AuthGuard权限控制问题与修复

问题描述

我在使用自定义AuthGuard实现权限控制,需求是当用户不具备访问页面所需权限时自动跳转至首页,但当前实现存在以下问题:

  • 若启用注释掉的router.push('/home')代码,无论是已认证还是未认证用户,通过直链导航页面时都会被跳转至首页;
  • 注释该代码后,已认证用户可正常直链访问受保护页面,但未认证用户无法跳转至首页,页面持续加载。

现有代码

AuthGuard组件

import { ReactNode, useEffect, useState } from 'react';
import { useRouter } from 'next/router';

import { useFirebaseAuth } from '@/contexts/FirebaseAuthContext';
import { can } from '@/lib/acl';
import { Loader } from '@/components/Loader';

export type ChildrenType = {
  children: ReactNode;
  requiredPermission?: { action: string; subject: string };
};

export default function AuthGuard({
                                    children,
                                    requiredPermission
                                  }: ChildrenType) {
  const { user, role, loading } = useFirebaseAuth();
  const router = useRouter();
  const [isAuthorized, setIsAuthorized] = useState(false);

  useEffect(() => {
    console.log('Loading', loading);
    console.log('AuthGuard user', user);
    if (!loading) {
      if (user && role && requiredPermission) {
        if (!can(role, requiredPermission.action, requiredPermission.subject)) {
          router.push('/dashboard');
          setIsAuthorized(false);
        } else {
          setIsAuthorized(true);
        }
      }
      // else {
      //   // Redirect to home if he's not logged in (no user)
      //   router.push('/home');
      // }
    }
  }, [role, loading, requiredPermission, router, user]);

  if (loading || !isAuthorized) {
    return <Loader />;
  }

  return <> {children} </>;
}

useFirebaseAuth Hook

import React, { createContext, useContext, useEffect, useState } from 'react';
import { onAuthStateChanged, sendPasswordResetEmail, signOut, User } from 'firebase/auth';
import { auth } from '@/config/firebase/utils';
import { Role } from '@/store/types';
import { useRouter } from 'next/router';
import { clearUserSubscriptionData } from '@/store/slices/userSubscriptionSlice';
import { useDispatch } from 'react-redux';
import { AppDispatch } from '@/store';
import { useUserSubscription } from '@/hooks/useUserSubscription';

interface AuthContextType {
  user: User | null;
  role: Role;
  loading: boolean;
  logout: () => Promise<void>;
  sendForgotPasswordEmail: (email: string) => Promise<void>;
}

const FirebaseAuthContext = createContext<AuthContextType | null>(null);

export const FirebaseAuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const [user, setUser] = useState<User | null>(null);
  const [role, setRole] = useState<Role>(Role.VISITOR);
  const dispatch = useDispatch<AppDispatch>();
  const router = useRouter();
  const [loading, setLoading] = useState(false);

  const { fetchUserSubscription } = useUserSubscription();

  useEffect(() => {
    const authStateChange = onAuthStateChanged(auth, async (user) => {
      setLoading(true);
      if (user) {
        setUser(user);
        const token = await user.getIdTokenResult();
        await fetchUserSubscription();
        const role = token.claims.role
          ? (token.claims.role as Role)
          : Role.GUEST;
        setRole(role);
      } else {
        setUser(null);
        setRole(Role.VISITOR);
        dispatch(clearUserSubscriptionData());
      }
      setLoading(false);
    });

    return () => authStateChange();
  }, [auth, user, dispatch]);

  const sendForgotPasswordEmail = async (email: string) => {
    try {
      await sendPasswordResetEmail(auth, email);
    } catch (error) {
      throw new Error('Unable to send password reset email. Please try again.');
    }
  };

  const logout = async () => {
    setLoading(true);
    try {
      await signOut(auth);
      setUser(null);
      setRole(Role.VISITOR);
      dispatch(clearUserSubscriptionData());
      router.push('/home');
    } catch (error) {
      console.error('Logout Error:', error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <FirebaseAuthContext.Provider
      value={{
        user,
        role,
        loading,
        logout,
        sendForgotPasswordEmail
      }}
    >
      {children}
    </FirebaseAuthContext.Provider>
  );
};

export const useFirebaseAuth = () => {
  const context = useContext(FirebaseAuthContext);
  if (!context) {
    throw new Error(
      'useFirebaseAuth must be used within a FirebaseAuthProvider'
    );
  }
  return context;
};

控制台日志

已认证用户访问受保护页面(正常)

Loading false AuthGuard.tsx:22:12
AuthGuard user null AuthGuard.tsx:23:12
Loading false AuthGuard.tsx:22:12
AuthGuard user null AuthGuard.tsx:23:12
Loading true AuthGuard.tsx:22:12
AuthGuard user Object { … }
AuthGuard.tsx:23:12
Loading true AuthGuard.tsx:22:12
AuthGuard user Object {  … }
AuthGuard.tsx:23:12
Loading false AuthGuard.tsx:22:12
AuthGuard user Object { … }
AuthGuard.tsx:23:12
Loading false AuthGuard.tsx:22:12
AuthGuard user Object { … }

未认证用户访问受保护页面(异常,持续加载)

Loading false AuthGuard.tsx:22:12
AuthGuard user null AuthGuard.tsx:23:12
Loading false AuthGuard.tsx:22:12
AuthGuard user null AuthGuard.tsx:23:12
Loading false AuthGuard.tsx:22:12
AuthGuard user null

问题分析

  1. 启用跳转代码时的问题:useEffect依赖数组包含user,组件初始化时user为null,会立即触发跳转逻辑。而Firebase的auth状态初始化是异步的,已认证用户的user会从null变为有效值,但此时跳转已经执行,导致用户被强制跳走。
  2. 未启用跳转代码时的问题:未认证用户的isAuthorized始终为false,且loading已经变为false,组件一直返回<Loader />,没有触发跳转,也没有更新状态终止加载。
  3. 额外问题:useFirebaseAuth中的useEffect依赖包含user,会导致循环触发(因为onAuthStateChanged回调中会调用setUser,进而触发useEffect重新执行)。

修复方案

1. 修正useFirebaseAuth的useEffect依赖

移除依赖中的user,避免循环触发:

useEffect(() => {
  const authStateChange = onAuthStateChanged(auth, async (user) => {
    setLoading(true);
    if (user) {
      setUser(user);
      const token = await user.getIdTokenResult();
      await fetchUserSubscription();
      const role = token.claims.role
        ? (token.claims.role as Role)
        : Role.GUEST;
      setRole(role);
    } else {
      setUser(null);
      setRole(Role.VISITOR);
      dispatch(clearUserSubscriptionData());
    }
    setLoading(false);
  });

  return () => authStateChange();
}, [auth, dispatch]); // 移除user依赖

2. 修正AuthGuard的逻辑

  • 仅在auth状态加载完成后处理权限和跳转
  • 未认证用户直接跳转并终止加载状态
  • 避免重复跳转(检查当前路由)

修复后的AuthGuard代码:

import { ReactNode, useEffect, useState } from 'react';
import { useRouter } from 'next/router';

import { useFirebaseAuth } from '@/contexts/FirebaseAuthContext';
import { can } from '@/lib/acl';
import { Loader } from '@/components/Loader';

export type ChildrenType = {
  children: ReactNode;
  requiredPermission?: { action: string; subject: string };
};

export default function AuthGuard({
                                    children,
                                    requiredPermission
                                  }: ChildrenType) {
  const { user, role, loading } = useFirebaseAuth();
  const router = useRouter();
  const [isAuthorized, setIsAuthorized] = useState(false);
  const [hasRedirected, setHasRedirected] = useState(false); // 标记是否已跳转

  useEffect(() => {
    if (loading || hasRedirected) return; // 加载中或已跳转则不处理

    // 未登录用户直接跳转首页
    if (!user) {
      if (router.pathname !== '/home') {
        router.push('/home');
        setHasRedirected(true);
      }
      setIsAuthorized(false);
      return;
    }

    // 已登录但无权限
    if (role && requiredPermission) {
      const hasPermission = can(role, requiredPermission.action, requiredPermission.subject);
      if (!hasPermission) {
        if (router.pathname !== '/dashboard') {
          router.push('/dashboard');
          setHasRedirected(true);
        }
        setIsAuthorized(false);
      } else {
        setIsAuthorized(true);
      }
    } else {
      // 无权限要求时直接授权
      setIsAuthorized(true);
    }
  }, [role, loading, requiredPermission, router, user, hasRedirected]);

  if (loading || !isAuthorized) {
    return <Loader />;
  }

  return <> {children} </>;
}

修复说明

  • 给AuthGuard添加hasRedirected状态,避免重复触发跳转
  • 处理逻辑拆分:先判断未登录情况,再处理已登录的权限校验
  • 跳转前检查当前路由,避免不必要的路由跳转
  • 修复useFirebaseAuth的循环触发问题,确保auth状态只初始化一次

内容的提问来源于stack exchange,提问作者user2426691

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.15 15:50:01