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

不使用Spring Security实现Spring Boot认证及多标签页同步登录登出

不依赖Spring Security实现Spring Boot+React多标签页认证同步

一、先修复后端代码的核心问题

你的现有代码存在逻辑错误和线程安全隐患,先修正这些问题才能保证认证功能正常:

  1. 线程安全问题:activeSessions用HashMap在多请求并发场景下会出现线程安全问题,替换为ConcurrentHashMap。
  2. identificateSession方法冗余:直接用Map的get方法即可,无需遍历。
  3. Logout逻辑错误:原代码中activeSessions.remove(exitUser)是按value删除,而你的Map是key为SessionID、value为用户名,这行逻辑完全错误,应该按SessionID删除。

修正后的Controller代码:

import com.cleanyco.weblab4backend.model.User;
import com.cleanyco.weblab4backend.repository.UserRepository;
import com.cleanyco.weblab4backend.util.MD5PasswordEncoder;
import com.google.gson.Gson;
import jakarta.servlet.http.HttpSession;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Logger;

@RestController
public class UserController {
    private static final Logger logger = Logger.getLogger(UserController.class.getName());
    private final UserRepository userRepository;

    public UserController(@Autowired UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    // 替换为线程安全的ConcurrentHashMap
    private final Map<String, String> activeSessions = new ConcurrentHashMap<>();

    // 简化session校验逻辑
    public String identificateSession(String sessionID) {
        return activeSessions.get(sessionID);
    }

    @CrossOrigin(origins = "http://localhost:3000", allowCredentials = "true")
    @PostMapping("/signup")
    public ResponseEntity<?> signup(HttpSession session, @RequestBody User user) {
        User existingUser = userRepository.findUserByUsername(user.getUsername());
        if (existingUser == null) {
            logger.info("User wasn't found. Registering a new one...");
            activeSessions.put(session.getId(), user.getUsername());
            logger.info("Registered an active session. User: " + user.getUsername() + "; SessionID: " + session.getId());
            user.setPassword(MD5PasswordEncoder.hash(user.getPassword()));
            userRepository.save(user);
            logger.info("User was successfully saved");
            return new ResponseEntity<>(HttpStatus.OK);
        } else {
            logger.info("User with the same name already exists");
            return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
        }
    }

    @CrossOrigin(origins = "http://localhost:3000", allowCredentials = "true")
    @PostMapping("/login")
    public ResponseEntity<?> login(HttpSession session, @RequestBody User user) {
        User existingUser = userRepository.findUserByUsername(user.getUsername());
        if (existingUser != null) {
            String userPassword = user.getPassword();
            String hashedPassword = MD5PasswordEncoder.hash(userPassword);
            if (existingUser.getPassword().equals(hashedPassword)) {
                logger.info("User successfully logged in");
                // 如果session未关联用户,添加映射
                if (identificateSession(session.getId()) == null) {
                    activeSessions.put(session.getId(), user.getUsername());
                }
                return new ResponseEntity<>(HttpStatus.OK);
            } else {
                logger.info("Invalid password was provided");
                return new ResponseEntity<>(HttpStatus.FORBIDDEN);
            }
        } else {
            logger.info("User not found");
            return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
        }
    }
    
    private static final Gson gson = new Gson();
    @CrossOrigin(origins = "http://localhost:3000", allowCredentials = "true")
    @GetMapping(value = "/checksession", produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<?> getActiveSession(HttpSession session) {
        String username = identificateSession(session.getId());
        if (username != null) {
            User user = userRepository.findUserByUsername(username);
            return ResponseEntity.ok(gson.toJson(user));
        } else {
            return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
        }
    }

    @CrossOrigin(origins = "http://localhost:3000", allowCredentials = "true")
    @GetMapping("/logout")
    public ResponseEntity<?> logout(HttpSession session) {
        String sessionId = session.getId();
        String exitUser = activeSessions.remove(sessionId);
        if (exitUser == null) {
            logger.info("An error occurred while logging out the user: user already logged out");
            return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
        } else {
            // 可选:使Session失效
            session.invalidate();
            logger.info("User was successfully logged out. User: " + exitUser + "; SessionID: " + sessionId);
            return new ResponseEntity<>(HttpStatus.OK);
        }
    }
}

二、前端实现多标签页同步登录/登出

方式1:轮询方案(快速实现)

在React应用的根组件中,定时调用后端的/checksession接口,校验当前Session的登录状态,同步全局登录状态:

import { useEffect, useState } from 'react';
import axios from 'axios';

function App() {
  const [currentUser, setCurrentUser] = useState(null);

  // 初始化时检查登录状态,之后定时轮询
  useEffect(() => {
    const checkSession = async () => {
      try {
        const response = await axios.get('http://localhost:8080/checksession', {
          withCredentials: true // 必须携带Cookie,否则SessionID无法传递
        });
        setCurrentUser(response.data);
      } catch (error) {
        setCurrentUser(null);
      }
    };

    // 首次加载检查
    checkSession();
    // 每隔30秒检查一次
    const interval = setInterval(checkSession, 30000);

    return () => clearInterval(interval);
  }, []);

  // 登录/登出操作的示例
  const handleLogin = async (userData) => {
    try {
      await axios.post('http://localhost:8080/login', userData, { withCredentials: true });
      // 登录成功后立即检查Session
      const sessionResponse = await axios.get('http://localhost:8080/checksession', { withCredentials: true });
      setCurrentUser(sessionResponse.data);
      // 同步到sessionStorage,供其他标签页监听
      sessionStorage.setItem('currentUser', JSON.stringify(sessionResponse.data));
    } catch (error) {
      console.error('登录失败:', error);
    }
  };

  const handleLogout = async () => {
    try {
      await axios.get('http://localhost:8080/logout', { withCredentials: true });
      setCurrentUser(null);
      // 清除sessionStorage,触发其他标签页的监听
      sessionStorage.removeItem('currentUser');
    } catch (error) {
      console.error('登出失败:', error);
    }
  };

  return (
    <div className="App">
      {currentUser ? (
        <>
          <p>欢迎, {currentUser.username}</p>
          <button onClick={handleLogout}>登出</button>
        </>
      ) : (
        <>
          {/* 登录/注册表单 */}
          <button onClick={() => handleLogin({ username: 'test', password: 'test' })}>登录</button>
        </>
      )}
    </div>
  );
}

export default App;

方式2:Storage事件监听(实时同步,推荐)

利用浏览器的storage事件,同一浏览器的不同标签页可以监听sessionStorage/localStorage的变化,实现实时同步:

import { useEffect, useState } from 'react';
import axios from 'axios';

function App() {
  const [currentUser, setCurrentUser] = useState(null);

  // 初始化检查登录状态
  useEffect(() => {
    const initSession = async () => {
      try {
        const response = await axios.get('http://localhost:8080/checksession', { withCredentials: true });
        setCurrentUser(response.data);
        sessionStorage.setItem('currentUser', JSON.stringify(response.data));
      } catch (error) {
        setCurrentUser(null);
        sessionStorage.removeItem('currentUser');
      }
    };
    initSession();

    // 监听其他标签页的storage变化
    const handleStorageChange = async (e) => {
      if (e.key === 'currentUser') {
        if (e.newValue) {
          setCurrentUser(JSON.parse(e.newValue));
        } else {
          // 验证后端Session是否真的失效,防止前端篡改
          try {
            await axios.get('http://localhost:8080/checksession', { withCredentials: true });
          } catch {
            setCurrentUser(null);
          }
        }
      }
    };

    window.addEventListener('storage', handleStorageChange);
    return () => window.removeEventListener('storage', handleStorageChange);
  }, []);

  const handleLogin = async (userData) => {
    try {
      await axios.post('http://localhost:8080/login', userData, { withCredentials: true });
      const sessionResponse = await axios.get('http://localhost:8080/checksession', { withCredentials: true });
      setCurrentUser(sessionResponse.data);
      sessionStorage.setItem('currentUser', JSON.stringify(sessionResponse.data));
    } catch (error) {
      console.error('登录失败:', error);
    }
  };

  const handleLogout = async () => {
    try {
      await axios.get('http://localhost:8080/logout', { withCredentials: true });
      setCurrentUser(null);
      sessionStorage.removeItem('currentUser');
    } catch (error) {
      console.error('登出失败:', error);
    }
  };

  return (
    <div className="App">
      {currentUser ? (
        <>
          <p>欢迎, {currentUser.username}</p>
          <button onClick={handleLogout}>登出</button>
        </>
      ) : (
        <>
          {/* 登录/注册表单 */}
          <button onClick={() => handleLogin({ username: 'test', password: 'test' })}>登录</button>
        </>
      )}
    </div>
  );
}

export default App;

三、核心原理说明

  1. 浏览器Session共享:同一浏览器的多个标签页默认共享同一个HttpSession,所以第二个标签页打开时,请求会自动携带已登录的SessionID,后端/checksession接口能直接识别用户。
  2. Storage事件同步:当一个标签页完成登录/登出时,修改sessionStorage,其他标签页通过监听storage事件实时更新状态,避免轮询的延迟。
  3. 后端校验兜底:即使前端sessionStorage被篡改,后端的/checksession接口会基于真实的Session状态返回结果,保证认证的安全性。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.28 16:23:08