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

相关推荐
耀耀_很无聊26 分钟前
13_Spring Boot 3.5.8 + Redisson 3.45.1 导致 Sa-Token 登录 StackOverflowError
spring boot·后端·bootstrap
AskHarries31 分钟前
邮件发送方案对比
后端
Ai拆代码的曹操39 分钟前
@Async 注解失效的底层真相:this 调用绕过代理,接口响应翻倍
后端
l1340620823540 分钟前
HarmonyOS应用开发实战:小事记 - 多媒体文件上传:@ohos.net.http 的 multipart/form-data 请求构造
后端·华为·harmonyos·鸿蒙系统
小小猪的春天41 分钟前
AI Code Review 例外决策框架:手动忽略警告之前,先回答4个问题
后端·架构
大卫陈43 分钟前
微信小程序虚拟支付实战:从「支付能力被限制」到沙箱调通的全过程
前端·后端
武子康44 分钟前
vLLM 0.25.1:服务没有报错,为什么仍会生成垃圾 Token(5 级正确性门禁 + 自动回滚条件)
前端·人工智能·后端
Conan在掘金1 小时前
鸿蒙 ArkUI V2 装饰器:@ObservedV2 + @Trace,嵌套对象深层重绘,告别 V1 的「重赋值才更新」
后端
卷无止境1 小时前
Python 的 exec 与 eval :动态代码执行的能力、风险与工程实践
后端·python
Conan在掘金1 小时前
鸿蒙 ArkUI V2 装饰器:AppStorageV2,应用级状态存取,告别 V1 的「set/get 手动同步」
后端