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

22 阅读5分钟

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 上下文中。通过读取上下文来判断当前请求是否已登录:

@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 完整代码

@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 实体
loadUserByUsernameSpring Security 框架调用,返回 UserDetails 对象
save注册新用户,密码经 BCrypt 加密后存储
BCryptPasswordEncoderSpring Security 推荐的密码哈希算法,不可逆,安全性高

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


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

@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);
    }
}

登录流程分解:

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

六、统一响应体 Result

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

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:

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

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

七、完整 AuthController

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