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原理

相关推荐
wangbing11256 小时前
springboot应用关闭时触发动作
java·spring boot·后端
子兮曰10 小时前
把 649 种文件格式塞进一个网页里:一个「文件不出浏览器」的预览器
前端·javascript·后端
全栈弄潮儿13 小时前
Python实战第2期: 变量与数据类型
后端·python·agent
码事漫谈14 小时前
五道题,测测你有没有真的入门 AI
后端
蜗牛互联网14 小时前
Python + JSON Schema 实现工单结构化输出与本地复核
java·人工智能·后端
大侠归来16 小时前
Spring Boot 2.1 → 3.5 迁移推演:从实战出发的完整路线图
java·spring boot·后端
海马16 小时前
Spring Boot 开发知识整理
java·spring boot·后端
谢亮_vipxieliang16 小时前
Spring Boot 3.x 从零开始——环境搭建与第一个 REST 项目
java·spring boot·后端
念何架构之路16 小时前
go-grpc核心抽象接口
开发语言·后端·golang