跳至主要內容

item3 - 3 用户管理模块1

codejava_itemPaiSmart约 2852 字大约 10 分钟

解决用户注册、登录以及权限控制

目的

  • 确保用户身份安全性
  • 有灵活权限管理机制,基于角色的访问控制,通过 RBAC 实现不同角色(管理员|普通用户)功能权限区分
  • 为其他模块提供信息支持

实现功能

用户注册

允许用户使用用户名和密码进行注册,成功注册后默认分配普通用户的角色

流程

  • 接收用户注册请求,验证用户名和密码
  • 检查用户名是否已存在
  • 使用 BCrypt 加密密码
  • 创建用户记录,设置默认角色为USER => 存 users 表
  • 创建用户私人组织标签(PRIVATE_username) => 存 organization_tag 表
  • 将私人组织标签设置为用户的主组织标签
  • 返回注册成功响应
alt text
alt text

接口设计

  • URL: /api/v1/users/register

  • Method: POST

  • Request Body

      {
        "username": "string",   // 用户名,唯一
        "password": "string"    // 密码(明文传输,后端加密存储)
      }
    
  • Response:

    {
      "code": 200, // 成功
      "message": "User registered successfully"
    }
    
    {
      "code": 400, // 失败
      "message": "Username already exists"
    }
    

具体实现

由于这个项目在数据库交互 (数据访问) 用的是 Spring Data JPA,所以不是 Mybatis 那样分的是 Mapper 层

这里更常用的三层是

  • UserController (Controller层)
  • UserService (Service层)
  • UserRepository (Repository层)
UserController
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody UserRequest request) {
    LogUtils.PerformanceMonitor monitor = LogUtils.startPerformanceMonitor("USER_REGISTER");
    try {
        // 用户不合规检验
        if (request.username() == null || request.username().isEmpty() ||
                request.password() == null || request.password().isEmpty()) {
            LogUtils.logUserOperation("anonymous", "REGISTER", "validation", "FAILED_EMPTY_PARAMS");
            monitor.end("注册失败:参数为空");
            return ResponseEntity.badRequest().body(Map.of("code", 400, "message", "Username and password cannot be empty"));
        }
        
        // 进入 Service 层
        userService.registerUser(request.username(), request.password());
        LogUtils.logUserOperation(request.username(), "REGISTER", "user_creation", "SUCCESS");
        monitor.end("注册成功");
        
        return ResponseEntity.ok(Map.of("code", 200, "message", "User registered successfully"));
    } catch (CustomException e) {
        LogUtils.logBusinessError("USER_REGISTER", request.username(), "用户注册失败: %s", e, e.getMessage());
        monitor.end("注册失败: " + e.getMessage());
        return ResponseEntity.status(e.getStatus()).body(Map.of("code", e.getStatus().value(), "message", e.getMessage()));
    } catch (Exception e) {
        LogUtils.logBusinessError("USER_REGISTER", request.username(), "用户注册异常: %s", e, e.getMessage());
        monitor.end("注册异常: " + e.getMessage());
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("code", 500, "message", "Internal server error"));
    }
}

整理流程还行,主要日志记录倒是没有见过,需要了解下

LogUtils工具类

暂时简单了解下,之前是直接再对应类上加上 Lombok 的注解 @Slf4j 然后就可以用 log 来快速日志

本质上在类上使用 @Slf4j 时,Lombok 默认生成的代码其实就是: private static final Logger log = LoggerFactory.getLogger(当前类名.class)

而这里是集成到了一个工具类上 LogUtils

public class LogUtils {
    
  // 业务日志记录器
  private static final Logger BUSINESS_LOGGER = LoggerFactory.getLogger("com.yizhaoqi.smartpai.business");
  
  // 性能日志记录器
  private static final Logger PERFORMANCE_LOGGER = LoggerFactory.getLogger("com.yizhaoqi.smartpai.performance");

  // MDC键名常量
  public static final String USER_ID = "userId";
  public static final String REQUEST_ID = "requestId";
  public static final String SESSION_ID = "sessionId";
  public static final String OPERATION = "operation";

  /**
   * 记录业务日志
   */
  public static void logBusiness(String operation, String userId, String message, Object... args) {
      try {
          MDC.put(OPERATION, operation);
          MDC.put(USER_ID, userId);
          BUSINESS_LOGGER.info("[{}] [用户:{}] {}", operation, userId, formatMessage(message, args));
      } finally {
          MDC.clear();
      }
  }

  ...

}

分别设置了两个记录器来记录业务和性能

有一个概念 MDC (Mapped Diagnostic Context),可以粗略的理解成是一个线程安全的存放诊断日志的容器

简单了解是 将类中的对应静态变量(如 userId, requestId)绑定到当前线程,这样就能有具体信息对应,并且多线程下可以对应

此外还有一个性能监视的内部类

/**
 * 性能监控装饰器
 */
public static class PerformanceMonitor {
  private final String operation;
  private final long startTime;
  
  public PerformanceMonitor(String operation) {
      this.operation = operation;
      this.startTime = System.currentTimeMillis();
  }
  
  public void end() {
      end("");
  }
  
  public void end(String details) {
      long duration = System.currentTimeMillis() - startTime;
      logPerformance(operation, duration, details);
  }
}

/**
 * 创建性能监控器
 */
public static PerformanceMonitor startPerformanceMonitor(String operation) {
  return new PerformanceMonitor(operation);
}
UserService
/**
 * 注册新用户。
 *
 * @param username 要注册的用户名
 * @param password 要注册的用户密码
 * @throws CustomException 如果用户名已存在,则抛出异常
 */
@Transactional
public void registerUser(String username, String password) {
    // 检查数据库中是否已存在该用户名
    if (userRepository.findByUsername(username).isPresent()) {
        // 若用户名已存在,抛出自定义异常,状态码为 400 Bad Request
        throw new CustomException("Username already exists", HttpStatus.BAD_REQUEST);
    }
    
    // 确保默认组织标签存在(系统内部使用)
    ensureDefaultOrgTagExists();
    
    User user = new User();
    user.setUsername(username);
    // 对密码进行加密处理并设置到 User 对象中
    user.setPassword(PasswordUtil.encode(password));
    // 设置用户角色为普通用户
    user.setRole(User.Role.USER);
    
    // 保存用户以生成ID
    userRepository.save(user);
    
    // 创建用户的私人组织标签
    String privateTagId = PRIVATE_TAG_PREFIX + username;
    createPrivateOrgTag(privateTagId, username, user);
    
    // 只分配私人组织标签
    user.setOrgTags(privateTagId);
    
    // 设置私人组织标签为主组织标签
    user.setPrimaryOrg(privateTagId);
    
    userRepository.save(user);
    
    // 缓存组织标签信息
    orgTagCacheService.cacheUserOrgTags(username, List.of(privateTagId));
    orgTagCacheService.cacheUserPrimaryOrg(username, privateTagId);
    
    logger.info("User registered successfully with private organization tag: {}", username);
}

用户登录

根据传来的用户信息进行校验,成功就生成包含用户信息和组织标签的 JWT Token,来认证用户信息,支持后续操作

流程

  • 接收用户登录请求,获取用户名和密码
  • 查询用户记录并验证密码
  • 加载用户组织标签信息
  • 基于用户信息和组织标签,生成包含用户信息和组织标签的 JWT Token
  • 返回登录成功响应和 Token
alt text
alt text

接口设计

  • URL: /api/v1/users/login

  • Method: POST

  • Request Body:

    {
      "username": "string",
      "password": "string"
    }
    
  • Response:

    {
      "code": 200, // 成功
      "message": "Login successful",
      "data": {
        "token": "JWT_TOKEN_STRING"
      }
    }
    
    {
      "code": 401, // 失败
      "message": "Invalid username or password"
    }
    

具体实现

同样的,类似还是分了三层来进行实现

UserController

先在 Controller 层来处理请求

@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody UserRequest request) {
  LogUtils.PerformanceMonitor monitor = LogUtils.startPerformanceMonitor("USER_LOGIN");
  try {
      if (request.username() == null || request.username().isEmpty() ||
              request.password() == null || request.password().isEmpty()) {
          LogUtils.logUserOperation("anonymous", "LOGIN", "validation", "FAILED_EMPTY_PARAMS");
          return ResponseEntity.badRequest().body(Map.of("code", 400, "message", "Username and password cannot be empty"));
      }
      
      String username = userService.authenticateUser(request.username(), request.password());
      if (username == null) {
          LogUtils.logUserOperation(request.username(), "LOGIN", "authentication", "FAILED_INVALID_CREDENTIALS");
          return ResponseEntity.status(401).body(Map.of("code", 401, "message", "Invalid credentials"));
      }
      
      String token = jwtUtils.generateToken(username);
      String refreshToken = jwtUtils.generateRefreshToken(username);
      LogUtils.logUserOperation(username, "LOGIN", "token_generation", "SUCCESS");
      monitor.end("登录成功");
      
      return ResponseEntity.ok(Map.of("code", 200, "message", "Login successful", "data", Map.of(
          "token", token,
          "refreshToken", refreshToken
      )));
  } catch (CustomException e) {
      LogUtils.logBusinessError("USER_LOGIN", request.username(), "登录失败: %s", e, e.getMessage());
      monitor.end("登录失败: " + e.getMessage());
      return ResponseEntity.status(e.getStatus()).body(Map.of("code", e.getStatus().value(), "message", e.getMessage()));
  } catch (Exception e) {
      LogUtils.logBusinessError("USER_LOGIN", request.username(), "登录异常: %s", e, e.getMessage());
      monitor.end("登录异常: " + e.getMessage());
      return ResponseEntity.status(500).body(Map.of("code", 500, "message", "Internal server error"));
  }
}

先判断账号名和密码的合理性,然后传到 userService 层进行验证

根据返回的 username 来判断是否成功

成功就开始建立 JWT token

UserService

对应 Service 中的实现方法就比较简单了,就是查询下用户是否在数据库里

通过与 Repository 进行数据库交互

public String authenticateUser(String username, String password) {
  User user = userRepository.findByUsername(username)
          .orElseThrow(() -> new CustomException("Invalid username or password", HttpStatus.UNAUTHORIZED));
  // 比较输入的密码和数据库中存储的加密密码是否匹配
  if (!PasswordUtil.matches(password, user.getPassword())) {
      // 若不匹配,抛出自定义异常,状态码为 401 Unauthorized
      throw new CustomException("Invalid username or password", HttpStatus.UNAUTHORIZED);
  }
  // 认证成功,返回用户的用户名
  return user.getUsername();
}
JWT Token

在 Service 层完成交互返回用户名字后,Controller层还需要继续完成逻辑,先校验用户是否存在,存在再将其放到 JWT Token 传回

if (username == null) {
  LogUtils.logUserOperation(request.username(), "LOGIN", "authentication", "FAILED_INVALID_CREDENTIALS");
  return ResponseEntity.status(401).body(Map.of("code", 401, "message", "Invalid credentials"));
}

String token = jwtUtils.generateToken(username);
String refreshToken = jwtUtils.generateRefreshToken(username);
LogUtils.logUserOperation(username, "LOGIN", "token_generation", "SUCCESS");
monitor.end("登录成功");

return ResponseEntity.ok(Map.of("code", 200, "message", "Login successful", "data", Map.of(
  "token", token,
  "refreshToken", refreshToken
)));

对应的 JWT 生成工具封装在 JwtUtils 下

一个JWT令牌由3部分组成:标头(Header)、有效载荷(Payload)和签名(Signature)

在传输的时候,会将JWT的前2部分分别进行Base64编码后用.进行连接形成最终需要传输的字符串。

  • 标头:包含一些元数据信息,比如JWT签名所使用的加密算法,还有类型,这里统一都是JWT。

  • 有效载荷:包括用户名称、令牌发布时间、过期时间、JWT ID等,当然我们也可以自定义添加字段,我们的用户信息一般都在这里存放。

  • 签名:首先需要指定一个密钥,该密钥仅仅保存在服务器中,保证不能让其他用户知道。然后使用Header中指定的算法对Header和Payload进行base64加密之后的结果通过密钥计算哈希值,然后就得出一个签名哈希。这个会用于之后验证内容是否被篡改。这样即使别人改了前面两个的内容,传回来通过秘钥加密对比就不一样了

标头和载荷直接通过 base64 编/解码,但要验证有效性,必须根据标头和载荷的base64编码值利用对应加密算法和指定秘钥加密后,与签名比对

双 Token 机制

这里登录采用了双 Token 机制,来解决 Token 被别人恶意截获的情况

JWT 是无状态的,后端不保存它。如果 Token 被偷且有效期很长(比如 7 天),那黑客就能横行 7 天。

  • 双 Token 机制(Access + Refresh):
    • Access Token:有效期极短(如 15 分钟),负责日常接口访问。
    • Refresh Token:有效期较长(如 7 天),仅用于换取新的 Access Token。

效果:即使 Access Token 被截获,黑客也只能操作 15 分钟。由于 Refresh Token 的调用频率极低,被截获的概率也小得多

String token = jwtUtils.generateToken(username);
String refreshToken = jwtUtils.generateRefreshToken(username);

分别创了两个 token,均保存到 redis 中

第一个 token 有效期是 1 h,包含了用户的信息+组织+主组织以及 uuid生成的唯一tokenId,因此有效期短,负责日常接口访问

第二个 token 是 refreshToken,就包含了 uuid生成的唯一tokenId, 以及用户名字,主要目的是可以基于这个来刷新获取 第一个token

即使 Access Token 被截获,黑客也只能操作 15 分钟。由于 Refresh Token 的调用频率极低,被截获的概率也小得多

为了让这个机制真正生效,具体流程应该是:

  • 平时:前端在请求头(如 Authorization: Bearer token)中携带 token。后端校验这个短效 Token。

  • 过期时:当 token 过期,后端返回 401 Unauthorized。

  • 静默刷新:前端拦截到 401 后,不跳转登录页,而是自动调用另一个 /refresh 接口,把 refreshToken 发给后端。

  • 续期:后端校验 refreshToken 有效后,重新生成一个新的 token 返回。前端拿到新 Token 后重试刚才失败的请求。用户全程无感知。

用户注销

用户退出,如果 token 没过期,需要主动将其放入黑名单里

// 用户登出接口
@PostMapping("/logout")
public ResponseEntity<?> logout(@RequestHeader("Authorization") String token) {
  LogUtils.PerformanceMonitor monitor = LogUtils.startPerformanceMonitor("USER_LOGOUT");
  String username = null;
  try {
      if (token == null || !token.startsWith("Bearer ")) {
          LogUtils.logUserOperation("anonymous", "LOGOUT", "validation", "FAILED_INVALID_TOKEN");
          monitor.end("登出失败:token格式无效");
          return ResponseEntity.badRequest().body(Map.of("code", 400, "message", "Invalid token format"));
      }

      String jwtToken = token.replace("Bearer ", "");
      username = jwtUtils.extractUsernameFromToken(jwtToken);
      
      if (username == null || username.isEmpty()) {
          LogUtils.logUserOperation("anonymous", "LOGOUT", "token_extraction", "FAILED_NO_USERNAME");
          monitor.end("登出失败:无法提取用户名");
          return ResponseEntity.status(401).body(Map.of("code", 401, "message", "Invalid token"));
      }

      // 使当前token失效
      jwtUtils.invalidateToken(jwtToken);
      
      LogUtils.logUserOperation(username, "LOGOUT", "token_invalidation", "SUCCESS");
      monitor.end("登出成功");

      return ResponseEntity.ok(Map.of("code", 200, "message", "Logout successful"));
  } catch (Exception e) {
      LogUtils.logBusinessError("USER_LOGOUT", username, "登出异常: %s", e, e.getMessage());
      monitor.end("登出异常: " + e.getMessage());
      return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("code", 500, "message", "Internal server error"));
  }
}

首先会根据传来的 JWT Token 解析用户名来判断是否正确,然后登出,会将 token 拉入黑名单 jwtUtils.invalidateToken(jwtToken)

而对应的黑名单的有效期就是这个token剩余的时长即可

存在 redis 对应的 key 为jwt:blacklist:token_id String类型,value为对应 System.currentTimeMillis()

public void invalidateToken(String token) {
  try {
      String tokenId = extractTokenIdFromToken(token);
      if (tokenId != null) {
          Claims claims = extractClaimsIgnoreExpiration(token);
          if (claims != null) {
              long expireTime = claims.getExpiration().getTime();
              String userId = claims.get("userId", String.class);
              
              // 加入黑名单
              tokenCacheService.blacklistToken(tokenId, expireTime);
              // 从缓存中移除
              tokenCacheService.removeToken(tokenId, userId);
              
              logger.info("Token invalidated: {}", tokenId);
          }
      }
  } catch (Exception e) {
      logger.error("Error invalidating token", e);
  }
}
上次编辑于: