SpringBoot 多人协作平台实战(8):Cookie 与登录状态维持

SpringBoot 多人协作平台实战(8):Cookie 与登录状态维持

一、背景:HTTP 是无状态协议

我们在使用淘宝时,登录一次之后就无需反复登录,即使刷新页面、重新打开浏览器,依然保持登录状态。这背后的实现原理是什么?

根本原因在于:HTTP 协议本身是无状态的 。每一次 HTTP 请求对服务器来说都是全新的,服务器并不会自动"记住"你是谁。因此,需要一种额外机制来维持用户的登录状态,这就是 Cookie


二、什么是 Cookie?

Cookie 是维持 HTTP 会话状态的一种常见方式,其工作流程如下:

  1. 用户登录成功后,服务器在 HTTP 响应头中写入 Set-Cookie 字段;
  2. 浏览器读取到 Set-Cookie 后,会将该信息保存在本地;
  3. 此后,浏览器对同一域名发起的请求,都会自动在请求头中携带该 Cookie;
  4. 服务器读取 Cookie 中的认证信息,从而识别当前用户身份。

注意HttpOnly 属性表示该 Cookie 只有浏览器主动发起 HTTP 请求时才会携带,无法通过 JavaScript 脚本读取,可有效防御 XSS 攻击。


三、实现登录状态判断:/auth 接口

在 Spring Security 中,已认证的用户信息保存在 SecurityContextHolder 上下文中。通过读取上下文来判断当前请求是否已登录:

typescript 复制代码
@GetMapping("/auth")
public Object auth() {
    String userName = SecurityContextHolder.getContext().getAuthentication().getName();
    User loggedInUser = userService.getUserByUsername(userName);

    if (loggedInUser == null) {
        return new Result("ok", "用户没有登录", false);
    } else {
        return new Result("ok", null, true, loggedInUser);
    }
}

逻辑说明:

  • SecurityContextHolder 取出当前认证对象的用户名;
  • 通过 UserService 根据用户名查找用户实体;
  • 若查找结果为 null,则返回未登录状态;否则返回用户信息。

早期版本通过判断用户名是否包含 "anonymous" 字符串来区分,但这种方式不够健壮。改为直接查询用户对象是否为 null 更为严谨。


四、完善 UserService

UserService 需要同时承担两个职责:

  1. 实现 Spring Security 的 UserDetailsService 接口,供认证框架调用;
  2. 提供业务层面的用户查询方法(如 getUserByUsername)。

4.1 完整代码

typescript 复制代码
@Service
public class UserService implements UserDetailsService {

    private final BCryptPasswordEncoder bCryptPasswordEncoder;
    private final Map<String, User> users = new ConcurrentHashMap<>();

    @Inject
    public UserService(BCryptPasswordEncoder bCryptPasswordEncoder) {
        this.bCryptPasswordEncoder = bCryptPasswordEncoder;
        save("admin", "123456");
    }

    public User getUserByUsername(String username) {
        return users.get(username);
    }

    public void save(String username, String password) {
        users.put(username, new User(1, username, bCryptPasswordEncoder.encode(password)));
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        if (!users.containsKey(username)) {
            throw new UsernameNotFoundException(username + " 不存在!");
        }
        User user = users.get(username);
        return new org.springframework.security.core.userdetails.User(
                username,
                user.getEncryptedPassword(),
                Collections.emptyList()
        );
    }
}

4.2 方法职责对照

方法 用途
getUserByUsername 业务层查询,返回自定义 User 实体
loadUserByUsername Spring Security 框架调用,返回 UserDetails 对象
save 注册新用户,密码经 BCrypt 加密后存储
BCryptPasswordEncoder Spring Security 推荐的密码哈希算法,不可逆,安全性高

为什么需要两个"用户"概念? Spring Security 内部使用 UserDetails 接口进行认证,而业务层通常有自己的 User 实体类(含 id、业务字段等)。loadUserByUsername 负责认证框架的桥接,getUserByUsername 则供业务逻辑使用,两者分工明确。


五、实现登录接口:/auth/login

typescript 复制代码
@PostMapping("/auth/login")
public Result login(@RequestBody Map<String, String> usernameAndPasswordJson) {
    String username = usernameAndPasswordJson.get("username");
    String password = usernameAndPasswordJson.get("password");

    // 第一步:根据用户名加载用户详情
    UserDetails userDetails;
    try {
        userDetails = userService.loadUserByUsername(username);
    } catch (UsernameNotFoundException e) {
        return new Result("fail", "用户不存在", false);
    }

    // 第二步:构造认证 Token 并交由 AuthenticationManager 验证密码
    UsernamePasswordAuthenticationToken token =
            new UsernamePasswordAuthenticationToken(userDetails, password, userDetails.getAuthorities());

    try {
        authenticationManager.authenticate(token);

        // 第三步:认证成功,将用户信息写入 SecurityContext(状态通过 Session/Cookie 维持)
        SecurityContextHolder.getContext().setAuthentication(token);

        return new Result("ok", "登录成功", true, userService.getUserByUsername(username));
    } catch (BadCredentialsException e) {
        return new Result("fail", "密码不正确", false);
    }
}

登录流程分解:

scss 复制代码
POST /auth/login
      │
      ▼
loadUserByUsername(username)               ← 查用户是否存在
      │
      ▼
AuthenticationManager.authenticate(token)  ← 验证密码是否正确
      │
      ▼
SecurityContextHolder.setAuthentication()  ← 写入上下文,状态通过 Session/Cookie 维持
      │
      ▼
返回登录成功 + 用户信息

六、统一响应体 Result

为了前后端交互规范,定义统一的响应结构:

typescript 复制代码
public static class Result {
    String status;   // "ok" / "fail"
    String msg;      // 提示信息
    boolean isLogin; // 是否已登录
    Object data;     // 返回数据(可选)

    public Result(String status, String msg, boolean isLogin) {
        this(status, msg, isLogin, null);
    }

    public Result(String status, String msg, boolean isLogin, Object data) {
        this.status = status;
        this.msg = msg;
        this.isLogin = isLogin;
        this.data = data;
    }

    public String getStatus() { return status; }
    public String getMsg() { return msg; }
    public boolean isLogin() { return isLogin; }
    public Object getData() { return data; }
}

示例响应 JSON:

json 复制代码
// 未登录
{
  "status": "ok",
  "msg": "用户没有登录",
  "isLogin": false,
  "data": null
}

// 登录成功
{
  "status": "ok",
  "msg": "登录成功",
  "isLogin": true,
  "data": { "id": 1, "username": "admin" }
}

七、完整 AuthController

typescript 复制代码
@RestController
public class AuthController {

    private final UserService userService;
    private final AuthenticationManager authenticationManager;

    public AuthController(UserService userService,
                          AuthenticationManager authenticationManager) {
        this.userService = userService;
        this.authenticationManager = authenticationManager;
    }

    @GetMapping("/auth")
    public Object auth() {
        String userName = SecurityContextHolder.getContext().getAuthentication().getName();
        User loggedInUser = userService.getUserByUsername(userName);

        if (loggedInUser == null) {
            return new Result("ok", "用户没有登录", false);
        } else {
            return new Result("ok", null, true, loggedInUser);
        }
    }

    @PostMapping("/auth/login")
    public Result login(@RequestBody Map<String, String> usernameAndPasswordJson) {
        String username = usernameAndPasswordJson.get("username");
        String password = usernameAndPasswordJson.get("password");

        UserDetails userDetails;
        try {
            userDetails = userService.loadUserByUsername(username);
        } catch (UsernameNotFoundException e) {
            return new Result("fail", "用户不存在", false);
        }

        UsernamePasswordAuthenticationToken token =
                new UsernamePasswordAuthenticationToken(userDetails, password, userDetails.getAuthorities());
        try {
            authenticationManager.authenticate(token);
            SecurityContextHolder.getContext().setAuthentication(token);
            return new Result("ok", "登录成功", true, userService.getUserByUsername(username));
        } catch (BadCredentialsException e) {
            return new Result("fail", "密码不正确", false);
        }
    }
}

八、阶段小结

本节我们完成了以下内容:

  • 理解了 HTTP 无状态特性与 Cookie 的工作原理;
  • 实现了 /auth 接口,判断用户是否处于登录状态;
  • 完善了 UserService,整合 Spring Security 的 UserDetailsService 接口;
  • 实现了 /auth/login 接口,完成用户名密码认证并维持 Session;
  • 定义了统一的 Result 响应体。

下一步 :目前用户数据存储在内存 Map 中,下节课将接入真实数据库(MySQL),使用 MyBatis 完成持久化存储。


系列课程:Java SpringBoot 多人协作平台实战 · 第八章 · SpringBoot登录状态维持与Cookie原理

相关推荐
代码北人生1 小时前
后端工程师开始用 Claude Code 了,Stripe 4天完成了本来要10个工程师周的迁移
后端·claude
小江的记录本2 小时前
【Java基础】核心关键字:final、static、volatile、synchronized、transient(附《思维导图》+《面试高频考点清单》)
java·前端·数据结构·后端·ai·面试·ai编程
fliter2 小时前
Rust 泛型 vs Java 泛型:它们看起来相似,但骨子里截然不同
后端
文心快码BaiduComate2 小时前
520,Comate Mission模式跨越界限,和你达成最「深」联动
前端·数据库·后端
_Evan_Yao3 小时前
限流的艺术:令牌桶与滑动窗口的博弈,以及我为何在 AI 项目中选择了后者
java·后端·架构
iccb10133 小时前
详解 Docker 环境变量技术,以及如何通过环境变量一键部署客服系统
后端
吴声子夜歌3 小时前
状态机——Spring State Machine
java·后端·spring