个人项目开发(3) 实现基于角色的权限控制及自动刷新token
文章目录
- 前言
- 令牌刷新机制
- 为什么需要Token和RefreshToken
- 具体代码实现
- 权限控制
前言
之前代码中对访问权限的控制是十分模糊的,因此本次我们需要实现基于角色实现的权限控制:比如某些接口(查询全体用户)只能由管理员访问。同时为了优化使用体验,我们可以使用RefreshToken自动刷新用户令牌有效期。由于这俩功能实现都比较简单因此我们放在一起进行
令牌刷新机制
为什么需要Token和RefreshToken
首先我们需要知道这俩在流程中各自的作用是什么:
Token:是 “短期身份凭证”,仅用于 “访问受保护接口”(如查询用户信息、提交订单),每次请求都要带在Authorization头中。它的作用类似 “临时门禁卡”,有效期短(如 1 小时),即使丢失,攻击者能用它作恶的时间也很短。
Refresh Token:是 “长期刷新凭证”,仅用于 “获取新的 Token”,不参与接口访问。
它的作用类似 “门禁卡补办凭证”,只在 Token 快过期时用一次,平时存放在前端(如localStorage或httpOnly Cookie),接触到它的场景更少,泄露风险更低。
从这里我们不难看出可以使用Refresh Token自动对Token进行更新 ,这样当用户长期登录某一网站或者某次登录的Token快要过期时就不用自己输入密码,而是由前端自动检测并发送更新请求,这样用户的使用体验便会好很多。 那既然如此我们为什么不直接把Token的有效期设置长一点呢?这是令牌刷新机制最关键的安全考量,也是它比 “长期 Token” 更安全的核心原因:
假设把 Token 有效期设为 7 天:一旦 Token 被窃取(如通过网络劫持、前端 XSS 攻击),攻击者能在 7 天内随意访问用户的账户(如转账、改资料),风险极高;
用 短期 Token + 长期 RefreshToken:即使 Token 被窃取,攻击者最多只能用 1 小时(Token 有效期),1 小时后 Token 过期,要刷新必须有 RefreshToken—— 而 RefreshToken 通常会做额外安全防护(如存httpOnly Cookie防 XSS、绑定设备信息防跨设备使用),窃取难度更高。
也就是说我们使用Token完成用户鉴权,同时设置更高防护等级的RefreshToken用于对Token的刷新,这样各司其职,既保证了安全性也优化了用户体验。下面我们来看下具体实现:
具体代码实现
首先我们需要在原有的Token工具类中新增生成及验证RefreshToken的方法:
public String generateRefreshToken(Authentication authentication) {UserDetails userDetails = (UserDetails) authentication.getPrincipal();Date now = new Date();Date expiryDate = new Date(now.getTime() + refreshExpirationMs);return Jwts.builder().setSubject(userDetails.getUsername()).setIssuedAt(new Date()).setExpiration(expiryDate).signWith(SignatureAlgorithm.HS512, jwtSecret).compact();}public String getUsernameFromRefreshToken(String token) {return Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token).getBody().getSubject();}public boolean validateRefreshToken(String token) {try {Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token);return true;} catch (Exception e) {// 日志记录无效原因(如过期、签名错误)log.error("Refresh Token无效: {}", e.getMessage());return false;}
这段和之前生成及验证Token的代码几乎是一样的,只不过把到期时间改了下,剩下的也是直接调方法即可。
之后由于我使用Redis对RefreshToken进行存储,因此还需要一个新的类用来操作RefreshToken和Redis的关系:
@Service
@RequiredArgsConstructor
public class RefreshTokenService {private final RedisTemplate<String, String> redisTemplate;private final JwtTokenProvider jwtTokenProvider;// Refresh Token在Redis中的key前缀private static final String REFRESH_TOKEN_KEY_PREFIX = "jwt:refresh:token:";/*** 存储Refresh Token到Redis(键:token,值:用户名,设置过期时间)*/public void saveRefreshToken(String refreshToken, String username) {String redisKey = REFRESH_TOKEN_KEY_PREFIX + refreshToken;// 设置与Refresh Token相同的过期时间(确保Redis自动清理过期Token)long expireSeconds = jwtTokenProvider.getRefreshExpirationInSeconds();redisTemplate.opsForValue().set(redisKey, username, expireSeconds, TimeUnit.SECONDS);}/*** 验证Refresh Token是否有效(存在于Redis且未过期)*/public boolean validateRefreshToken(String refreshToken) {String redisKey = REFRESH_TOKEN_KEY_PREFIX + refreshToken;String username = redisTemplate.opsForValue().get(redisKey);// 1. Redis中存在该Token 2. JWT本身有效(未被篡改)return username != null && jwtTokenProvider.validateRefreshToken(refreshToken);}/*** 删除Refresh Token(刷新成功后使旧Token失效)*/public void deleteRefreshToken(String refreshToken) {String redisKey = REFRESH_TOKEN_KEY_PREFIX + refreshToken;redisTemplate.delete(redisKey);}}
之后我们就需要在原有的登录逻辑上完善对RefreshToken的存储工作:
String refreshToken = jwtTokenProvider.generateRefreshToken(authentication);refreshTokenService.saveRefreshToken(refreshToken,user.getUsername());
之后我们就需要实现一个接口完成对RefreshToken及Token的自动刷新(这个接口实际应该是由前端自动检测并调用发送的,但由于这里没写前端代码因此我们直接自己调接口检验就行)
@PostMapping("/refresh-token")public Result<LoginResponse> refreshToken(@RequestBody RefreshTokenRequest request) {String refreshToken = request.getRefreshToken();// 1. 验证Refresh Token有效性if (!refreshTokenService.validateRefreshToken(refreshToken)) {throw new RuntimeException("Refresh Token无效或已过期");}// 2. 从Refresh Token中提取用户名String username = jwtTokenProvider.getUsernameFromRefreshToken(refreshToken);UserDetails userDetails = userDetailsService.loadUserByUsername(username);// 3. 生成新的Authentication对象Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());// 4. 生成新的Access Token和Refresh TokenString newAccessToken = jwtTokenProvider.generateToken(authentication);String newRefreshToken = jwtTokenProvider.generateRefreshToken(authentication);// 5. 删除旧的Refresh Token,存储新的Refresh Token(防止重复使用)refreshTokenService.deleteRefreshToken(refreshToken);refreshTokenService.saveRefreshToken(newRefreshToken, username);// 6. 返回新Tokenreturn Result.success(LoginResponse.builder().token(newAccessToken).refreshToken(newRefreshToken).expiresIn(jwtTokenProvider.getExpirationInSeconds()).refreshExpiresIn(jwtTokenProvider.getRefreshExpirationInSeconds()).username(username).build());}
最后在补充几个请求体及扩展返回类型即可:
@Data
public class RefreshTokenRequest {private String refreshToken;
}
@Data
@Builder
public class LoginResponse {private String token;private String refreshToken;private long expiresIn; // Access Token过期时间(秒)private long refreshExpiresIn; // 新增:Refresh Token过期时间(秒)private String username;private int loginStatus;private String mfaToken;
}
权限控制
在日常生活中我们不难发现许多接口对不同用户开放的权限是不一样的,因此这里我们可以基于Spring Secruity提供的成熟的组件简单完成该功能,下面来看具体实现:
1.新增字段,我们需要给原来的User类新加一个role字段用来控制每个用户的权限:
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "sys_user") // 系统用户表
public class User {//使用JPA快速搭建有关用户的字段@Id@GeneratedValue(strategy = GenerationType.IDENTITY)private Long id;@Column(unique = true, nullable = false, length = 50)private String username; // 用户名(登录账号)@Column(nullable = false)private String password; // 加密后的密码@Column(unique = true, length = 100)private String email; // 邮箱private String phone; // 手机号private Integer status; // 状态:1-正常,0-禁用private Integer mfaEnabled = 0;private String role; //新增字段,控制权限@CreationTimestampprivate LocalDateTime createTime; // 创建时间@UpdateTimestampprivate LocalDateTime updateTime; // 更新时间}
2.查询用户时我们需要将查到的权限封装入用户信息中:
@Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {// 1. 查询用户User user = userRepository.findByUsername(username).orElseThrow(() -> new UsernameNotFoundException("用户不存在: " + username));log.info("查询到用户:{}", user.getUsername());// 2. 检查用户状态if (user.getStatus() == 0) {throw new RuntimeException("用户已被禁用");}GrantedAuthority authority = new SimpleGrantedAuthority(user.getRole());// 4. 构建UserDetails返回(包含角色信息)return org.springframework.security.core.userdetails.User.builder().username(user.getUsername()).password(user.getPassword()) // 数据库中加密后的密码.authorities(authority) // 传入用户角色(权限).disabled(user.getStatus() != 1).accountExpired(false).accountLocked(false).credentialsExpired(false).build();
}
最后我们只需要在原来的过滤器中添加新的校验逻辑即可:
@Beanpublic SecurityFilterChain filterChain(HttpSecurity http) throws Exception {http.csrf(csrf -> csrf.disable()).sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)).authorizeHttpRequests(auth -> auth// 1. 公开接口:所有人可访问.requestMatchers("/api/auth/login", "/api/auth/verify-mfa", "/api/auth/refresh-token").permitAll().requestMatchers("/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**").permitAll()// 2. 管理员接口:仅ROLE_ADMIN角色可访问.requestMatchers("/api/admin/**").hasRole("ADMIN") // hasRole会自动拼接"ROLE_",等价于hasAuthority("ROLE_ADMIN")// 3. 普通用户接口:仅ROLE_USER角色可访问(或ROLE_ADMIN也可访问,用hasAnyRole).requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN") // hasAnyRole支持多个角色// 4. 其他所有接口:需登录(无论角色).anyRequest().authenticated()).authenticationProvider(authenticationProvider()); // 注册认证提供者http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);return http.build();}
到此全部功能已经完成,为了简单校验我们可以新增两个接口,一个接口用于普通用户访问,一个接口用于管理员用户访问:
@RestController
@RequestMapping("/api/user")
@RequiredArgsConstructor
public class UserController {@GetMapping("/info")public Result<?> getUserInfo(Authentication authentication) {// 从Authentication中获取当前登录用户信息(包含角色)UserDetails userDetails = (UserDetails) authentication.getPrincipal();return Result.success(Map.of("username", userDetails.getUsername(),"roles", userDetails.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList()) // 返回用户角色列表));}
}
@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
public class AdminController {private final UserRepository userRepository;// 管理员接口:仅ROLE_ADMIN可访问@GetMapping("/user-list")public Result<?> getUserList() {// 管理员权限:查询所有用户(普通用户无此权限)List<User> userList = userRepository.findAll();return Result.success(userList.stream().map(user -> Map.of("id", user.getId(),"username", user.getUsername(),"role", user.getRole())).collect(Collectors.toList()));}
}
到此今天的两个功能就全部实现成功了!
