This commit is contained in:
Ky10
2024-11-16 20:20:24 +08:00
15 changed files with 417 additions and 211 deletions

View File

@@ -9,6 +9,7 @@ import com.youlai.boot.core.security.exception.MyAccessDeniedHandler;
import com.youlai.boot.core.security.exception.MyAuthenticationEntryPoint; import com.youlai.boot.core.security.exception.MyAuthenticationEntryPoint;
import com.youlai.boot.core.security.filter.JwtValidationFilter; import com.youlai.boot.core.security.filter.JwtValidationFilter;
import com.youlai.boot.core.security.filter.CaptchaValidationFilter; import com.youlai.boot.core.security.filter.CaptchaValidationFilter;
import com.youlai.boot.shared.auth.service.impl.JwtTokenService;
import com.youlai.boot.system.service.ConfigService; import com.youlai.boot.system.service.ConfigService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
@@ -46,6 +47,7 @@ public class SecurityConfig {
private final CodeGenerator codeGenerator; private final CodeGenerator codeGenerator;
private final SecurityProperties securityProperties; private final SecurityProperties securityProperties;
private final ConfigService configService; private final ConfigService configService;
private final JwtTokenService jwtTokenService;
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
@@ -70,7 +72,7 @@ public class SecurityConfig {
// 验证码校验过滤器 // 验证码校验过滤器
http.addFilterBefore(new CaptchaValidationFilter(redisTemplate, codeGenerator), UsernamePasswordAuthenticationFilter.class); http.addFilterBefore(new CaptchaValidationFilter(redisTemplate, codeGenerator), UsernamePasswordAuthenticationFilter.class);
// JWT 校验过滤器 // JWT 校验过滤器
http.addFilterBefore(new JwtValidationFilter(redisTemplate,securityProperties.getJwt().getKey()), UsernamePasswordAuthenticationFilter.class); http.addFilterBefore(new JwtValidationFilter(jwtTokenService), UsernamePasswordAuthenticationFilter.class);
return http.build(); return http.build();
} }

View File

@@ -25,6 +25,10 @@ public class SecurityProperties {
*/ */
private JwtProperty jwt; private JwtProperty jwt;
/**
* 令牌类型 jwt / redis-token
*/
private String tokenType;
/** /**
* JWT 配置 * JWT 配置

View File

@@ -1,19 +1,14 @@
package com.youlai.boot.core.security.filter; package com.youlai.boot.core.security.filter;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.jwt.JWT;
import cn.hutool.jwt.JWTPayload;
import cn.hutool.jwt.JWTUtil;
import com.youlai.boot.common.constant.SecurityConstants; import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.core.security.util.JwtUtils;
import com.youlai.boot.common.util.ResponseUtils; import com.youlai.boot.common.util.ResponseUtils;
import com.youlai.boot.shared.auth.service.impl.JwtTokenService;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
@@ -29,13 +24,11 @@ import java.io.IOException;
*/ */
public class JwtValidationFilter extends OncePerRequestFilter { public class JwtValidationFilter extends OncePerRequestFilter {
private final RedisTemplate<String, Object> redisTemplate; private final JwtTokenService jwtTokenService;
private final byte[] secretKey;
public JwtValidationFilter(RedisTemplate<String, Object> redisTemplate, String secretKey) { public JwtValidationFilter(JwtTokenService jwtTokenService) {
this.redisTemplate = redisTemplate; this.jwtTokenService = jwtTokenService;
this.secretKey = secretKey.getBytes();
} }
@@ -52,24 +45,14 @@ public class JwtValidationFilter extends OncePerRequestFilter {
if (StrUtil.isNotBlank(token) && token.startsWith(SecurityConstants.JWT_TOKEN_PREFIX)) { if (StrUtil.isNotBlank(token) && token.startsWith(SecurityConstants.JWT_TOKEN_PREFIX)) {
// 去除 Bearer 前缀 // 去除 Bearer 前缀
token = token.substring(SecurityConstants.JWT_TOKEN_PREFIX.length()); token = token.substring(SecurityConstants.JWT_TOKEN_PREFIX.length());
// 解析 Token // 校验 JWT Token ,包括验签和是否过期
JWT jwt = JWTUtil.parseToken(token); boolean isValidate = jwtTokenService.validateToken(token);
// 检查 Token 是否有效(验签 + 是否过期)
boolean isValidate = jwt.setKey(secretKey).validate(0);
if (!isValidate) { if (!isValidate) {
ResponseUtils.writeErrMsg(response, ResultCode.TOKEN_INVALID); ResponseUtils.writeErrMsg(response, ResultCode.TOKEN_INVALID);
return; return;
} }
// 检查 Token 是否已被加入黑名单(注销) // Token 解析为 Authentication 对象,并设置到 Spring Security 上下文中
JSONObject payloads = jwt.getPayloads(); Authentication authentication = jwtTokenService.parseToken(token);
String jti = payloads.getStr(JWTPayload.JWT_ID);
boolean isTokenBlacklisted = Boolean.TRUE.equals(redisTemplate.hasKey(SecurityConstants.BLACKLIST_TOKEN_PREFIX + jti));
if (isTokenBlacklisted) {
ResponseUtils.writeErrMsg(response, ResultCode.TOKEN_INVALID);
return;
}
// Token 有效将其解析为 Authentication 对象,并设置到 Spring Security 上下文中
Authentication authentication = JwtUtils.getAuthentication(payloads);
SecurityContextHolder.getContext().setAuthentication(authentication); SecurityContextHolder.getContext().setAuthentication(authentication);
} }
} catch (Exception e) { } catch (Exception e) {

View File

@@ -1,94 +0,0 @@
package com.youlai.boot.core.security.util;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.jwt.JWTPayload;
import cn.hutool.jwt.JWTUtil;
import com.youlai.boot.common.constant.JwtClaimConstants;
import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.core.security.model.SysUserDetails;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* JWT Token 工具类
*
* @author Ray Hao
* @since 2.6.0
*/
public class JwtUtils {
/**
* 生成 JWT Token
*
* @param authentication 用户认证信息
* @param expiration 有效期(秒)
* @param key HS256(HmacSHA256)密钥
* @return Token 字符串
*/
public static String createToken(Authentication authentication, int expiration,byte[] key) {
SysUserDetails userDetails = (SysUserDetails) authentication.getPrincipal();
Map<String, Object> payload = new HashMap<>();
payload.put(JwtClaimConstants.USER_ID, userDetails.getUserId()); // 用户ID
payload.put(JwtClaimConstants.DEPT_ID, userDetails.getDeptId()); // 部门ID
payload.put(JwtClaimConstants.DATA_SCOPE, userDetails.getDataScope()); // 数据权限范围
// claims 中添加角色信息
Set<String> roles = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toSet());
payload.put(JwtClaimConstants.AUTHORITIES, roles);
Date now = new Date();
payload.put(JWTPayload.ISSUED_AT, now);
// 设置过期时间 -1 表示永不过期
if (expiration != -1) {
Date expiresAt = DateUtil.offsetSecond(now, expiration);
payload.put(JWTPayload.EXPIRES_AT, expiresAt);
}
payload.put(JWTPayload.SUBJECT, authentication.getName());
payload.put(JWTPayload.JWT_ID, IdUtil.simpleUUID());
return JWTUtil.createToken(payload, key);
}
/**
* 从 JWT Token 中解析 Authentication 用户认证信息
*
* @param payloads JWT 载体
* @return 用户认证信息
*/
public static UsernamePasswordAuthenticationToken getAuthentication(JSONObject payloads) {
SysUserDetails userDetails = new SysUserDetails();
userDetails.setUserId(payloads.getLong(JwtClaimConstants.USER_ID)); // 用户ID
userDetails.setDeptId(payloads.getLong(JwtClaimConstants.DEPT_ID)); // 部门ID
userDetails.setDataScope(payloads.getInt(JwtClaimConstants.DATA_SCOPE)); // 数据权限范围
userDetails.setUsername(payloads.getStr(JWTPayload.SUBJECT)); // 用户名
// 角色集合
Set<SimpleGrantedAuthority> authorities = payloads.getJSONArray(JwtClaimConstants.AUTHORITIES)
.stream()
.map(authority -> new SimpleGrantedAuthority(Convert.toStr(authority)))
.collect(Collectors.toSet());
return new UsernamePasswordAuthenticationToken(userDetails, "", authorities);
}
}

View File

@@ -4,8 +4,8 @@ import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.common.result.Result; import com.youlai.boot.common.result.Result;
import com.youlai.boot.shared.auth.model.RefreshTokenRequest; import com.youlai.boot.shared.auth.model.RefreshTokenRequest;
import com.youlai.boot.shared.auth.service.AuthService; import com.youlai.boot.shared.auth.service.AuthService;
import com.youlai.boot.shared.auth.model.CaptchaResult; import com.youlai.boot.shared.auth.model.CaptchaResponse;
import com.youlai.boot.shared.auth.model.LoginResult; import com.youlai.boot.shared.auth.model.AuthTokenResponse;
import com.youlai.boot.common.annotation.Log; import com.youlai.boot.common.annotation.Log;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
@@ -32,12 +32,12 @@ public class AuthController {
@Operation(summary = "登录") @Operation(summary = "登录")
@PostMapping("/login") @PostMapping("/login")
@Log(value = "登录", module = LogModuleEnum.LOGIN) @Log(value = "登录", module = LogModuleEnum.LOGIN)
public Result<LoginResult> login( public Result<AuthTokenResponse> login(
@Parameter(description = "用户名", example = "admin") @RequestParam String username, @Parameter(description = "用户名", example = "admin") @RequestParam String username,
@Parameter(description = "密码", example = "123456") @RequestParam String password @Parameter(description = "密码", example = "123456") @RequestParam String password
) { ) {
LoginResult loginResult = authService.login(username, password); AuthTokenResponse authTokenResponse = authService.login(username, password);
return Result.success(loginResult); return Result.success(authTokenResponse);
} }
@Operation(summary = "注销") @Operation(summary = "注销")
@@ -50,15 +50,15 @@ public class AuthController {
@Operation(summary = "获取验证码") @Operation(summary = "获取验证码")
@GetMapping("/captcha") @GetMapping("/captcha")
public Result<CaptchaResult> getCaptcha() { public Result<CaptchaResponse> getCaptcha() {
CaptchaResult captcha = authService.getCaptcha(); CaptchaResponse captcha = authService.getCaptcha();
return Result.success(captcha); return Result.success(captcha);
} }
@Operation(summary = "刷新token") @Operation(summary = "刷新token")
@PostMapping("/refresh-token") @PostMapping("/refresh-token")
public Result<?> refreshToken(@RequestBody RefreshTokenRequest request) { public Result<?> refreshToken(@RequestBody RefreshTokenRequest request) {
LoginResult loginResult = authService.refreshToken(request); AuthTokenResponse authTokenResponse = authService.refreshToken(request);
return Result.success(loginResult); return Result.success(authTokenResponse);
} }
} }

View File

@@ -4,21 +4,28 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
@Schema(description ="登录响应对象") /**
* 认证令牌响应对象
*
* @author Ray
* @since 0.0.1
*/
@Schema(description = "认证令牌响应对象")
@Data @Data
@Builder @Builder
public class LoginResult { public class AuthTokenResponse {
@Schema(description = "令牌类型", example = "Bearer")
private String tokenType;
@Schema(description = "访问令牌") @Schema(description = "访问令牌")
private String accessToken; private String accessToken;
@Schema(description = "token 类型",example = "Bearer")
private String tokenType;
@Schema(description = "刷新令牌") @Schema(description = "刷新令牌")
private String refreshToken; private String refreshToken;
@Schema(description = "过期时间(单位:秒)") @Schema(description = "过期时间(单位:秒)")
private Integer expiresIn; private Integer expiresIn;
} }

View File

@@ -1,10 +1,8 @@
package com.youlai.boot.shared.auth.model; package com.youlai.boot.shared.auth.model;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor;
/** /**
* 验证码响应对象 * 验证码响应对象
@@ -13,11 +11,9 @@ import lombok.NoArgsConstructor;
* @since 2023/03/24 * @since 2023/03/24
*/ */
@Schema(description = "验证码响应对象") @Schema(description = "验证码响应对象")
@Builder
@Data @Data
@AllArgsConstructor @Builder
@NoArgsConstructor public class CaptchaResponse {
public class CaptchaResult {
@Schema(description = "验证码ID") @Schema(description = "验证码ID")
private String captchaKey; private String captchaKey;

View File

@@ -1,7 +1,7 @@
package com.youlai.boot.shared.auth.service; package com.youlai.boot.shared.auth.service;
import com.youlai.boot.shared.auth.model.CaptchaResult; import com.youlai.boot.shared.auth.model.CaptchaResponse;
import com.youlai.boot.shared.auth.model.LoginResult; import com.youlai.boot.shared.auth.model.AuthTokenResponse;
import com.youlai.boot.shared.auth.model.RefreshTokenRequest; import com.youlai.boot.shared.auth.model.RefreshTokenRequest;
/** /**
@@ -19,7 +19,7 @@ public interface AuthService {
* @param password 密码 * @param password 密码
* @return 登录结果 * @return 登录结果
*/ */
LoginResult login(String username, String password); AuthTokenResponse login(String username, String password);
/** /**
* 登出 * 登出
@@ -31,7 +31,7 @@ public interface AuthService {
* *
* @return 验证码 * @return 验证码
*/ */
CaptchaResult getCaptcha(); CaptchaResponse getCaptcha();
/** /**
* 刷新令牌 * 刷新令牌
@@ -39,5 +39,5 @@ public interface AuthService {
* @param request 刷新令牌请求参数 * @param request 刷新令牌请求参数
* @return 登录结果 * @return 登录结果
*/ */
LoginResult refreshToken(RefreshTokenRequest request); AuthTokenResponse refreshToken(RefreshTokenRequest request);
} }

View File

@@ -0,0 +1,61 @@
package com.youlai.boot.shared.auth.service;
import com.youlai.boot.shared.auth.model.AuthTokenResponse;
import org.springframework.security.core.Authentication;
/**
* 令牌接口
*
* @author Ray
* @since 2.16.0
*/
public interface TokenService {
/**
* 生成认证 Token
*
* @param authentication 用户认证信息
* @return 认证 Token 响应
*/
AuthTokenResponse generateToken(Authentication authentication);
/**
* 解析 Token 获取认证信息
*
* @param token JWT Token
* @return 用户认证信息
*/
Authentication parseToken(String token);
/**
* 校验 Token 是否有效
*
* @param token JWT Token
* @return 是否有效
*/
boolean validateToken(String token);
/**
* 刷新 Token
*
* @param token 刷新令牌
* @return 认证 Token 响应
*/
AuthTokenResponse refreshToken(String token);
/**
* 将 Token 加入黑名单
*
* @param token JWT Token
*/
default void blacklistToken(String token) {
// 默认实现可以是空的,或者抛出不支持的操作异常
// throw new UnsupportedOperationException("Not implemented");
}
}

View File

@@ -5,29 +5,23 @@ import cn.hutool.captcha.CaptchaUtil;
import cn.hutool.captcha.generator.CodeGenerator; import cn.hutool.captcha.generator.CodeGenerator;
import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.jwt.JWT;
import cn.hutool.jwt.JWTPayload;
import cn.hutool.jwt.JWTUtil;
import com.youlai.boot.common.constant.SecurityConstants; import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.common.exception.BusinessException; import com.youlai.boot.common.exception.BusinessException;
import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.config.property.SecurityProperties;
import com.youlai.boot.core.security.util.SecurityUtils; import com.youlai.boot.core.security.util.SecurityUtils;
import com.youlai.boot.shared.auth.enums.CaptchaTypeEnum; import com.youlai.boot.shared.auth.enums.CaptchaTypeEnum;
import com.youlai.boot.shared.auth.model.RefreshTokenRequest; import com.youlai.boot.shared.auth.model.RefreshTokenRequest;
import com.youlai.boot.shared.auth.service.AuthService; import com.youlai.boot.shared.auth.service.AuthService;
import com.youlai.boot.shared.auth.model.CaptchaResult; import com.youlai.boot.shared.auth.model.CaptchaResponse;
import com.youlai.boot.shared.auth.model.LoginResult; import com.youlai.boot.shared.auth.model.AuthTokenResponse;
import com.youlai.boot.config.property.CaptchaProperties; import com.youlai.boot.config.property.CaptchaProperties;
import com.youlai.boot.core.security.util.JwtUtils; import com.youlai.boot.shared.auth.service.TokenService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -50,7 +44,7 @@ public class AuthServiceImpl implements AuthService {
private final CodeGenerator codeGenerator; private final CodeGenerator codeGenerator;
private final Font captchaFont; private final Font captchaFont;
private final CaptchaProperties captchaProperties; private final CaptchaProperties captchaProperties;
private final SecurityProperties securityProperties; private final TokenService tokenService;
/** /**
* 登录 * 登录
@@ -60,27 +54,18 @@ public class AuthServiceImpl implements AuthService {
* @return 登录结果 * @return 登录结果
*/ */
@Override @Override
public LoginResult login(String username, String password) { public AuthTokenResponse login(String username, String password) {
// 创建认证令牌对象 // 创建认证令牌对象
UsernamePasswordAuthenticationToken authenticationToken = UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(username.toLowerCase().trim(), password); new UsernamePasswordAuthenticationToken(username.toLowerCase().trim(), password);
// 执行用户认证认证成功返回的Authentication是SysUserDetailsService#loadUserByUsername获取到的的UserDetails // 执行用户认证认证成功返回的Authentication是SysUserDetailsService#loadUserByUsername获取到的的UserDetails
Authentication authentication = authenticationManager.authenticate(authenticationToken); Authentication authentication = authenticationManager.authenticate(authenticationToken);
// 认证成功后生成JWT令牌 // 认证成功后生成JWT令牌
Integer accessTokenExpiration = securityProperties.getJwt().getAccessTokenExpiration(); AuthTokenResponse authTokenResponse = tokenService.generateToken(authentication);
Integer refreshTokenExpiration = securityProperties.getJwt().getRefreshTokenExpiration();
byte[] key = securityProperties.getJwt().getKey().getBytes();
String accessToken = JwtUtils.createToken(authentication, accessTokenExpiration, key);
String refreshToken = JwtUtils.createToken(authentication, refreshTokenExpiration, key);
// 将认证信息存入Security上下文便于在AOP如日志记录中获取当前用户信息 // 将认证信息存入Security上下文便于在AOP如日志记录中获取当前用户信息
SecurityContextHolder.getContext().setAuthentication(authentication); SecurityContextHolder.getContext().setAuthentication(authentication);
// 返回包含JWT令牌的登录结果 // 返回包含JWT令牌的登录结果
return LoginResult.builder() return authTokenResponse;
.tokenType("Bearer")
.accessToken(accessToken)
.refreshToken(refreshToken)
.expiresIn(accessTokenExpiration)
.build();
} }
/** /**
@@ -91,23 +76,9 @@ public class AuthServiceImpl implements AuthService {
String token = SecurityUtils.getTokenFromRequest(); String token = SecurityUtils.getTokenFromRequest();
if (StrUtil.isNotBlank(token) && token.startsWith(SecurityConstants.JWT_TOKEN_PREFIX)) { if (StrUtil.isNotBlank(token) && token.startsWith(SecurityConstants.JWT_TOKEN_PREFIX)) {
token = token.substring(SecurityConstants.JWT_TOKEN_PREFIX.length()); token = token.substring(SecurityConstants.JWT_TOKEN_PREFIX.length());
JSONObject payloads = JWTUtil.parseToken(token).getPayloads(); // 将JWT令牌加入黑名单
String jti = payloads.getStr(JWTPayload.JWT_ID); tokenService.blacklistToken(token);
Long expiration = payloads.getLong(JWTPayload.EXPIRES_AT); // 清除Security上下文
if (expiration != null) {
long currentTimeSeconds = System.currentTimeMillis() / 1000;
if (expiration < currentTimeSeconds) {
// Token已过期直接返回
return;
}
// 计算Token剩余时间将其加入黑名单
long ttl = expiration - currentTimeSeconds;
redisTemplate.opsForValue().set(SecurityConstants.BLACKLIST_TOKEN_PREFIX + jti, null, ttl, TimeUnit.SECONDS);
} else {
// 永不过期的Token永久加入黑名单
redisTemplate.opsForValue().set(SecurityConstants.BLACKLIST_TOKEN_PREFIX + jti, null);
}
SecurityContextHolder.clearContext(); SecurityContextHolder.clearContext();
} }
} }
@@ -118,7 +89,7 @@ public class AuthServiceImpl implements AuthService {
* @return 验证码 * @return 验证码
*/ */
@Override @Override
public CaptchaResult getCaptcha() { public CaptchaResponse getCaptcha() {
String captchaType = captchaProperties.getType(); String captchaType = captchaProperties.getType();
int width = captchaProperties.getWidth(); int width = captchaProperties.getWidth();
@@ -150,7 +121,7 @@ public class AuthServiceImpl implements AuthService {
redisTemplate.opsForValue().set(SecurityConstants.CAPTCHA_CODE_PREFIX + captchaKey, captchaCode, redisTemplate.opsForValue().set(SecurityConstants.CAPTCHA_CODE_PREFIX + captchaKey, captchaCode,
captchaProperties.getExpireSeconds(), TimeUnit.SECONDS); captchaProperties.getExpireSeconds(), TimeUnit.SECONDS);
return CaptchaResult.builder() return CaptchaResponse.builder()
.captchaKey(captchaKey) .captchaKey(captchaKey)
.captchaBase64(imageBase64Data) .captchaBase64(imageBase64Data)
.build(); .build();
@@ -163,32 +134,18 @@ public class AuthServiceImpl implements AuthService {
* @return 新的访问令牌 * @return 新的访问令牌
*/ */
@Override @Override
public LoginResult refreshToken(RefreshTokenRequest request) { public AuthTokenResponse refreshToken(RefreshTokenRequest request) {
// 验证刷新令牌 // 验证刷新令牌
String refreshToken = request.getRefreshToken(); String refreshToken = request.getRefreshToken();
JWT jwt = JWTUtil.parseToken(refreshToken); boolean isValidate = tokenService.validateToken(refreshToken);
boolean isValidate = jwt.setKey(securityProperties.getJwt().getKey().getBytes()).validate(0);
if (!isValidate || redisTemplate.hasKey(SecurityConstants.BLACKLIST_TOKEN_PREFIX + jwt.getPayloads().getStr(JWTPayload.JWT_ID))) { if (!isValidate) {
throw new BusinessException(ResultCode.REFRESH_TOKEN_INVALID); throw new BusinessException(ResultCode.REFRESH_TOKEN_INVALID);
} }
Authentication authentication = JwtUtils.getAuthentication(jwt.getPayloads()); return tokenService.refreshToken(refreshToken);
// 创建新的访问令牌
Integer accessTokenExpiration = securityProperties.getJwt().getAccessTokenExpiration();
byte[] key = securityProperties.getJwt().getKey().getBytes();
String newAccessToken = JwtUtils.createToken(authentication, accessTokenExpiration, key);
// 返回新的访问令牌
return LoginResult.builder()
.tokenType("Bearer")
.accessToken(newAccessToken)
.refreshToken(refreshToken)
.expiresIn(securityProperties.getJwt().getAccessTokenExpiration())
.build();
} }
} }

View File

@@ -0,0 +1,222 @@
package com.youlai.boot.shared.auth.service.impl;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.jwt.JWT;
import cn.hutool.jwt.JWTPayload;
import cn.hutool.jwt.JWTUtil;
import com.youlai.boot.common.constant.JwtClaimConstants;
import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.common.exception.BusinessException;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.config.property.SecurityProperties;
import com.youlai.boot.core.security.model.SysUserDetails;
import com.youlai.boot.shared.auth.model.AuthTokenResponse;
import com.youlai.boot.shared.auth.service.TokenService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Service;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* JWT 令牌服务实现
*
* @author Ray
* @since 2024/11/15
*/
@ConditionalOnProperty(value = "security.token-type", havingValue = "jwt")
@Service
public class JwtTokenService implements TokenService {
private final SecurityProperties securityProperties;
private final RedisTemplate<String, Object> redisTemplate;
private final byte[] secretKey;
public JwtTokenService(SecurityProperties securityProperties, RedisTemplate<String, Object> redisTemplate) {
this.securityProperties = securityProperties;
this.redisTemplate = redisTemplate;
this.secretKey = securityProperties.getJwt().getKey().getBytes();
}
/**
* 生成令牌
*
* @param authentication 认证信息
* @return 令牌响应对象
*/
@Override
public AuthTokenResponse generateToken(Authentication authentication) {
int accessTokenExpiration = securityProperties.getJwt().getAccessTokenExpiration();
int refreshTokenExpiration = securityProperties.getJwt().getRefreshTokenExpiration();
String accessToken = generateToken(authentication, accessTokenExpiration);
String refreshToken = generateToken(authentication, refreshTokenExpiration);
return AuthTokenResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.tokenType("Bearer")
.expiresIn(accessTokenExpiration)
.build();
}
/**
* 解析令牌
*
* @param token JWT Token
* @return Authentication 对象
*/
@Override
public Authentication parseToken(String token) {
JWT jwt = JWTUtil.parseToken(token);
JSONObject payloads = jwt.getPayloads();
SysUserDetails userDetails = new SysUserDetails();
userDetails.setUserId(payloads.getLong(JwtClaimConstants.USER_ID)); // 用户ID
userDetails.setDeptId(payloads.getLong(JwtClaimConstants.DEPT_ID)); // 部门ID
userDetails.setDataScope(payloads.getInt(JwtClaimConstants.DATA_SCOPE)); // 数据权限范围
userDetails.setUsername(payloads.getStr(JWTPayload.SUBJECT)); // 用户名
// 角色集合
Set<SimpleGrantedAuthority> authorities = payloads.getJSONArray(JwtClaimConstants.AUTHORITIES)
.stream()
.map(authority -> new SimpleGrantedAuthority(Convert.toStr(authority)))
.collect(Collectors.toSet());
return new UsernamePasswordAuthenticationToken(userDetails, "", authorities);
}
/**
* 校验令牌
*
* @param token JWT Token
* @return 是否有效
*/
@Override
public boolean validateToken(String token) {
JWT jwt = JWTUtil.parseToken(token);
// 检查 Token 是否有效(验签 + 是否过期)
boolean isValid = jwt.setKey(secretKey).validate(0);
if (isValid) {
// 检查 Token 是否已被加入黑名单(注销、修改密码等场景)
JSONObject payloads = jwt.getPayloads();
String jti = payloads.getStr(JWTPayload.JWT_ID);
// 判断是否在黑名单中如果在则返回false 标识Token无效
if (Boolean.TRUE.equals(redisTemplate.hasKey(SecurityConstants.BLACKLIST_TOKEN_PREFIX + jti))) {
return false;
}
}
return isValid;
}
/**
* 将令牌加入黑名单
*
* @param token JWT Token
*/
@Override
public void blacklistToken(String token) {
if (token.startsWith(SecurityConstants.JWT_TOKEN_PREFIX)) {
token = token.substring(SecurityConstants.JWT_TOKEN_PREFIX.length());
}
JWT jwt = JWTUtil.parseToken(token);
JSONObject payloads = jwt.getPayloads();
String jti = payloads.getStr(JWTPayload.JWT_ID);
Integer expirationAt = payloads.getInt(JWTPayload.EXPIRES_AT);
if (expirationAt != null) {
int currentTimeSeconds = Convert.toInt(System.currentTimeMillis() / 1000);
if (expirationAt < currentTimeSeconds) {
// Token已过期直接返回
return;
}
// 计算Token剩余时间将其加入黑名单
int expirationIn = expirationAt - currentTimeSeconds;
redisTemplate.opsForValue().set(SecurityConstants.BLACKLIST_TOKEN_PREFIX + jti, null, expirationIn, TimeUnit.SECONDS);
} else {
// 永不过期的Token永久加入黑名单
redisTemplate.opsForValue().set(SecurityConstants.BLACKLIST_TOKEN_PREFIX + jti, null);
}
;
}
/**
* 刷新令牌
*
* @param refreshToken 刷新令牌
* @return 令牌响应对象
*/
@Override
public AuthTokenResponse refreshToken(String refreshToken) {
boolean isValid = validateToken(refreshToken);
if (!isValid) {
throw new BusinessException(ResultCode.REFRESH_TOKEN_INVALID);
}
Authentication authentication = parseToken(refreshToken);
int accessTokenExpiration = securityProperties.getJwt().getAccessTokenExpiration();
String newAccessToken = generateToken(authentication, accessTokenExpiration);
return AuthTokenResponse.builder()
.accessToken(newAccessToken)
.refreshToken(refreshToken)
.tokenType("Bearer")
.expiresIn(accessTokenExpiration)
.build();
}
/**
* 生成 JWT Token
*
* @param authentication
* @param expiration
* @return
*/
private String generateToken(Authentication authentication, int expiration) {
SysUserDetails userDetails = (SysUserDetails) authentication.getPrincipal();
Map<String, Object> payload = new HashMap<>();
payload.put(JwtClaimConstants.USER_ID, userDetails.getUserId()); // 用户ID
payload.put(JwtClaimConstants.DEPT_ID, userDetails.getDeptId()); // 部门ID
payload.put(JwtClaimConstants.DATA_SCOPE, userDetails.getDataScope()); // 数据权限范围
// claims 中添加角色信息
Set<String> roles = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toSet());
payload.put(JwtClaimConstants.AUTHORITIES, roles);
Date now = new Date();
payload.put(JWTPayload.ISSUED_AT, now);
// 设置过期时间 -1 表示永不过期
if (expiration != -1) {
Date expiresAt = DateUtil.offsetSecond(now, expiration);
payload.put(JWTPayload.EXPIRES_AT, expiresAt);
}
payload.put(JWTPayload.SUBJECT, authentication.getName());
payload.put(JWTPayload.JWT_ID, IdUtil.simpleUUID());
return JWTUtil.createToken(payload, secretKey);
}
}

View File

@@ -0,0 +1,62 @@
package com.youlai.boot.shared.auth.service.impl;
import com.youlai.boot.shared.auth.model.AuthTokenResponse;
import com.youlai.boot.shared.auth.service.TokenService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
/**
* JWT 令牌服务实现
*
* @author Ray
* @since 2024/11/15
*/
@ConditionalOnProperty(value = "security.token-type", havingValue = "redis-token")
@Service
public class RedisTokenService implements TokenService {
/**
* 生成令牌
*
* @param authentication 用户认证信息
* @return
*/
@Override
public AuthTokenResponse generateToken(Authentication authentication) {
return null;
}
/**
* 解析令牌
*
* @param token JWT Token
* @return
*/
@Override
public Authentication parseToken(String token) {
return null;
}
/**
* 验证令牌
*
* @param token JWT Token
* @return
*/
@Override
public boolean validateToken(String token) {
return false;
}
/**
* 刷新令牌
*
* @param token 刷新令牌
* @return
*/
@Override
public AuthTokenResponse refreshToken(String token) {
return null;
}
}

View File

@@ -10,8 +10,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.youlai.boot.common.constant.RedisConstants; import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.common.constant.SystemConstants; import com.youlai.boot.common.constant.SystemConstants;
import com.youlai.boot.core.security.util.JwtUtils; import com.youlai.boot.shared.auth.service.TokenService;
import com.youlai.boot.shared.auth.service.AuthService;
import com.youlai.boot.system.enums.ContactType; import com.youlai.boot.system.enums.ContactType;
import com.youlai.boot.common.model.Option; import com.youlai.boot.common.model.Option;
import com.youlai.boot.shared.mail.service.MailService; import com.youlai.boot.shared.mail.service.MailService;
@@ -35,16 +34,11 @@ import com.youlai.boot.system.service.RoleMenuService;
import com.youlai.boot.system.service.RoleService; import com.youlai.boot.system.service.RoleService;
import com.youlai.boot.system.service.UserRoleService; import com.youlai.boot.system.service.UserRoleService;
import com.youlai.boot.system.service.UserService; import com.youlai.boot.system.service.UserService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpHeaders;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
@@ -83,6 +77,8 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
private final StringRedisTemplate redisTemplate; private final StringRedisTemplate redisTemplate;
private final TokenService tokenService;
/** /**
* 获取用户分页列表 * 获取用户分页列表
* *
@@ -322,6 +318,12 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
.eq(User::getId, userId) .eq(User::getId, userId)
.set(User::getPassword, passwordEncoder.encode(newPassword)) .set(User::getPassword, passwordEncoder.encode(newPassword))
); );
if(result){
// 加入黑名单,重新登录
String accessToken = SecurityUtils.getTokenFromRequest();
tokenService.blacklistToken(accessToken);
}
return result; return result;
} }

View File

@@ -75,6 +75,8 @@ mybatis-plus:
# 安全配置 # 安全配置
security: security:
# JWT 认证类型, 支持 jwt、redis-token
token-type: jwt
jwt: jwt:
# JWT 秘钥 # JWT 秘钥
key: SecretKey012345678901234567890123456789012345678901234567890123456789 key: SecretKey012345678901234567890123456789012345678901234567890123456789

View File

@@ -59,6 +59,8 @@ mybatis-plus:
# 安全配置 # 安全配置
security: security:
# JWT 认证类型, 支持 jwt、redis-token
token-type: jwt
# JWT 配置 # JWT 配置
jwt: jwt:
# JWT 秘钥 # JWT 秘钥