diff --git a/src/main/java/com/youlai/boot/config/SecurityConfig.java b/src/main/java/com/youlai/boot/config/SecurityConfig.java index 88e4d375..98f12c3f 100644 --- a/src/main/java/com/youlai/boot/config/SecurityConfig.java +++ b/src/main/java/com/youlai/boot/config/SecurityConfig.java @@ -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.filter.JwtValidationFilter; 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 lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; @@ -46,6 +47,7 @@ public class SecurityConfig { private final CodeGenerator codeGenerator; private final SecurityProperties securityProperties; private final ConfigService configService; + private final JwtTokenService jwtTokenService; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @@ -70,7 +72,7 @@ public class SecurityConfig { // 验证码校验过滤器 http.addFilterBefore(new CaptchaValidationFilter(redisTemplate, codeGenerator), UsernamePasswordAuthenticationFilter.class); // JWT 校验过滤器 - http.addFilterBefore(new JwtValidationFilter(redisTemplate,securityProperties.getJwt().getKey()), UsernamePasswordAuthenticationFilter.class); + http.addFilterBefore(new JwtValidationFilter(jwtTokenService), UsernamePasswordAuthenticationFilter.class); return http.build(); } diff --git a/src/main/java/com/youlai/boot/config/property/SecurityProperties.java b/src/main/java/com/youlai/boot/config/property/SecurityProperties.java index 44022a23..ac338947 100644 --- a/src/main/java/com/youlai/boot/config/property/SecurityProperties.java +++ b/src/main/java/com/youlai/boot/config/property/SecurityProperties.java @@ -25,6 +25,10 @@ public class SecurityProperties { */ private JwtProperty jwt; + /** + * 令牌类型 jwt / redis-token + */ + private String tokenType; /** * JWT 配置 diff --git a/src/main/java/com/youlai/boot/core/security/filter/JwtValidationFilter.java b/src/main/java/com/youlai/boot/core/security/filter/JwtValidationFilter.java index 17403cc7..11f3230b 100644 --- a/src/main/java/com/youlai/boot/core/security/filter/JwtValidationFilter.java +++ b/src/main/java/com/youlai/boot/core/security/filter/JwtValidationFilter.java @@ -1,19 +1,14 @@ package com.youlai.boot.core.security.filter; 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.result.ResultCode; -import com.youlai.boot.core.security.util.JwtUtils; import com.youlai.boot.common.util.ResponseUtils; +import com.youlai.boot.shared.auth.service.impl.JwtTokenService; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.HttpHeaders; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -29,13 +24,11 @@ import java.io.IOException; */ public class JwtValidationFilter extends OncePerRequestFilter { - private final RedisTemplate redisTemplate; + private final JwtTokenService jwtTokenService; - private final byte[] secretKey; - public JwtValidationFilter(RedisTemplate redisTemplate, String secretKey) { - this.redisTemplate = redisTemplate; - this.secretKey = secretKey.getBytes(); + public JwtValidationFilter(JwtTokenService jwtTokenService) { + this.jwtTokenService = jwtTokenService; } @@ -52,24 +45,14 @@ public class JwtValidationFilter extends OncePerRequestFilter { if (StrUtil.isNotBlank(token) && token.startsWith(SecurityConstants.JWT_TOKEN_PREFIX)) { // 去除 Bearer 前缀 token = token.substring(SecurityConstants.JWT_TOKEN_PREFIX.length()); - // 解析 Token - JWT jwt = JWTUtil.parseToken(token); - // 检查 Token 是否有效(验签 + 是否过期) - boolean isValidate = jwt.setKey(secretKey).validate(0); + // 校验 JWT Token ,包括验签和是否过期 + boolean isValidate = jwtTokenService.validateToken(token); if (!isValidate) { ResponseUtils.writeErrMsg(response, ResultCode.TOKEN_INVALID); return; } - // 检查 Token 是否已被加入黑名单(注销) - JSONObject payloads = jwt.getPayloads(); - 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); + // 将 Token 解析为 Authentication 对象,并设置到 Spring Security 上下文中 + Authentication authentication = jwtTokenService.parseToken(token); SecurityContextHolder.getContext().setAuthentication(authentication); } } catch (Exception e) { diff --git a/src/main/java/com/youlai/boot/core/security/util/JwtUtils.java b/src/main/java/com/youlai/boot/core/security/util/JwtUtils.java deleted file mode 100644 index fb439a84..00000000 --- a/src/main/java/com/youlai/boot/core/security/util/JwtUtils.java +++ /dev/null @@ -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 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 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 authorities = payloads.getJSONArray(JwtClaimConstants.AUTHORITIES) - .stream() - .map(authority -> new SimpleGrantedAuthority(Convert.toStr(authority))) - .collect(Collectors.toSet()); - - return new UsernamePasswordAuthenticationToken(userDetails, "", authorities); - } - - -} diff --git a/src/main/java/com/youlai/boot/shared/auth/controller/AuthController.java b/src/main/java/com/youlai/boot/shared/auth/controller/AuthController.java index e1a82e07..15713583 100644 --- a/src/main/java/com/youlai/boot/shared/auth/controller/AuthController.java +++ b/src/main/java/com/youlai/boot/shared/auth/controller/AuthController.java @@ -4,8 +4,8 @@ import com.youlai.boot.common.enums.LogModuleEnum; import com.youlai.boot.common.result.Result; import com.youlai.boot.shared.auth.model.RefreshTokenRequest; import com.youlai.boot.shared.auth.service.AuthService; -import com.youlai.boot.shared.auth.model.CaptchaResult; -import com.youlai.boot.shared.auth.model.LoginResult; +import com.youlai.boot.shared.auth.model.CaptchaResponse; +import com.youlai.boot.shared.auth.model.AuthTokenResponse; import com.youlai.boot.common.annotation.Log; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -32,12 +32,12 @@ public class AuthController { @Operation(summary = "登录") @PostMapping("/login") @Log(value = "登录", module = LogModuleEnum.LOGIN) - public Result login( + public Result login( @Parameter(description = "用户名", example = "admin") @RequestParam String username, @Parameter(description = "密码", example = "123456") @RequestParam String password ) { - LoginResult loginResult = authService.login(username, password); - return Result.success(loginResult); + AuthTokenResponse authTokenResponse = authService.login(username, password); + return Result.success(authTokenResponse); } @Operation(summary = "注销") @@ -50,15 +50,15 @@ public class AuthController { @Operation(summary = "获取验证码") @GetMapping("/captcha") - public Result getCaptcha() { - CaptchaResult captcha = authService.getCaptcha(); + public Result getCaptcha() { + CaptchaResponse captcha = authService.getCaptcha(); return Result.success(captcha); } @Operation(summary = "刷新token") @PostMapping("/refresh-token") public Result refreshToken(@RequestBody RefreshTokenRequest request) { - LoginResult loginResult = authService.refreshToken(request); - return Result.success(loginResult); + AuthTokenResponse authTokenResponse = authService.refreshToken(request); + return Result.success(authTokenResponse); } } diff --git a/src/main/java/com/youlai/boot/shared/auth/model/LoginResult.java b/src/main/java/com/youlai/boot/shared/auth/model/AuthTokenResponse.java similarity index 57% rename from src/main/java/com/youlai/boot/shared/auth/model/LoginResult.java rename to src/main/java/com/youlai/boot/shared/auth/model/AuthTokenResponse.java index 7ae334a9..719623c5 100644 --- a/src/main/java/com/youlai/boot/shared/auth/model/LoginResult.java +++ b/src/main/java/com/youlai/boot/shared/auth/model/AuthTokenResponse.java @@ -4,21 +4,28 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Data; -@Schema(description ="登录响应对象") +/** + * 认证令牌响应对象 + * + * @author Ray + * @since 0.0.1 + */ +@Schema(description = "认证令牌响应对象") @Data @Builder -public class LoginResult { +public class AuthTokenResponse { + + @Schema(description = "令牌类型", example = "Bearer") + private String tokenType; @Schema(description = "访问令牌") private String accessToken; - @Schema(description = "token 类型",example = "Bearer") - private String tokenType; @Schema(description = "刷新令牌") private String refreshToken; - @Schema(description = "过期时间(单位:毫秒)") + @Schema(description = "过期时间(单位:秒)") private Integer expiresIn; } diff --git a/src/main/java/com/youlai/boot/shared/auth/model/CaptchaResult.java b/src/main/java/com/youlai/boot/shared/auth/model/CaptchaResponse.java similarity index 76% rename from src/main/java/com/youlai/boot/shared/auth/model/CaptchaResult.java rename to src/main/java/com/youlai/boot/shared/auth/model/CaptchaResponse.java index 4837b708..e636bb54 100644 --- a/src/main/java/com/youlai/boot/shared/auth/model/CaptchaResult.java +++ b/src/main/java/com/youlai/boot/shared/auth/model/CaptchaResponse.java @@ -1,10 +1,8 @@ package com.youlai.boot.shared.auth.model; import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; -import lombok.NoArgsConstructor; /** * 验证码响应对象 @@ -13,11 +11,9 @@ import lombok.NoArgsConstructor; * @since 2023/03/24 */ @Schema(description = "验证码响应对象") -@Builder @Data -@AllArgsConstructor -@NoArgsConstructor -public class CaptchaResult { +@Builder +public class CaptchaResponse { @Schema(description = "验证码ID") private String captchaKey; diff --git a/src/main/java/com/youlai/boot/shared/auth/service/AuthService.java b/src/main/java/com/youlai/boot/shared/auth/service/AuthService.java index 7c4dda13..ca462a54 100644 --- a/src/main/java/com/youlai/boot/shared/auth/service/AuthService.java +++ b/src/main/java/com/youlai/boot/shared/auth/service/AuthService.java @@ -1,7 +1,7 @@ package com.youlai.boot.shared.auth.service; -import com.youlai.boot.shared.auth.model.CaptchaResult; -import com.youlai.boot.shared.auth.model.LoginResult; +import com.youlai.boot.shared.auth.model.CaptchaResponse; +import com.youlai.boot.shared.auth.model.AuthTokenResponse; import com.youlai.boot.shared.auth.model.RefreshTokenRequest; /** @@ -19,7 +19,7 @@ public interface AuthService { * @param password 密码 * @return 登录结果 */ - LoginResult login(String username, String password); + AuthTokenResponse login(String username, String password); /** * 登出 @@ -31,7 +31,7 @@ public interface AuthService { * * @return 验证码 */ - CaptchaResult getCaptcha(); + CaptchaResponse getCaptcha(); /** * 刷新令牌 @@ -39,5 +39,5 @@ public interface AuthService { * @param request 刷新令牌请求参数 * @return 登录结果 */ - LoginResult refreshToken(RefreshTokenRequest request); + AuthTokenResponse refreshToken(RefreshTokenRequest request); } diff --git a/src/main/java/com/youlai/boot/shared/auth/service/TokenService.java b/src/main/java/com/youlai/boot/shared/auth/service/TokenService.java new file mode 100644 index 00000000..674539f7 --- /dev/null +++ b/src/main/java/com/youlai/boot/shared/auth/service/TokenService.java @@ -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"); + } + + + +} diff --git a/src/main/java/com/youlai/boot/shared/auth/service/impl/AuthServiceImpl.java b/src/main/java/com/youlai/boot/shared/auth/service/impl/AuthServiceImpl.java index cfd4d43a..5ef814a9 100644 --- a/src/main/java/com/youlai/boot/shared/auth/service/impl/AuthServiceImpl.java +++ b/src/main/java/com/youlai/boot/shared/auth/service/impl/AuthServiceImpl.java @@ -5,29 +5,23 @@ import cn.hutool.captcha.CaptchaUtil; import cn.hutool.captcha.generator.CodeGenerator; import cn.hutool.core.util.IdUtil; 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.exception.BusinessException; 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.shared.auth.enums.CaptchaTypeEnum; import com.youlai.boot.shared.auth.model.RefreshTokenRequest; import com.youlai.boot.shared.auth.service.AuthService; -import com.youlai.boot.shared.auth.model.CaptchaResult; -import com.youlai.boot.shared.auth.model.LoginResult; +import com.youlai.boot.shared.auth.model.CaptchaResponse; +import com.youlai.boot.shared.auth.model.AuthTokenResponse; 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.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; @@ -50,7 +44,7 @@ public class AuthServiceImpl implements AuthService { private final CodeGenerator codeGenerator; private final Font captchaFont; private final CaptchaProperties captchaProperties; - private final SecurityProperties securityProperties; + private final TokenService tokenService; /** * 登录 @@ -60,27 +54,18 @@ public class AuthServiceImpl implements AuthService { * @return 登录结果 */ @Override - public LoginResult login(String username, String password) { + public AuthTokenResponse login(String username, String password) { // 创建认证令牌对象 UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username.toLowerCase().trim(), password); // 执行用户认证,认证成功返回的Authentication是SysUserDetailsService#loadUserByUsername获取到的的UserDetails Authentication authentication = authenticationManager.authenticate(authenticationToken); // 认证成功后生成JWT令牌 - Integer accessTokenExpiration = securityProperties.getJwt().getAccessTokenExpiration(); - 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); + AuthTokenResponse authTokenResponse = tokenService.generateToken(authentication); // 将认证信息存入Security上下文,便于在AOP(如日志记录)中获取当前用户信息 SecurityContextHolder.getContext().setAuthentication(authentication); // 返回包含JWT令牌的登录结果 - return LoginResult.builder() - .tokenType("Bearer") - .accessToken(accessToken) - .refreshToken(refreshToken) - .expiresIn(accessTokenExpiration) - .build(); + return authTokenResponse; } /** @@ -91,23 +76,9 @@ public class AuthServiceImpl implements AuthService { String token = SecurityUtils.getTokenFromRequest(); if (StrUtil.isNotBlank(token) && token.startsWith(SecurityConstants.JWT_TOKEN_PREFIX)) { token = token.substring(SecurityConstants.JWT_TOKEN_PREFIX.length()); - JSONObject payloads = JWTUtil.parseToken(token).getPayloads(); - String jti = payloads.getStr(JWTPayload.JWT_ID); - Long expiration = payloads.getLong(JWTPayload.EXPIRES_AT); - - 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); - } + // 将JWT令牌加入黑名单 + tokenService.blacklistToken(token); + // 清除Security上下文 SecurityContextHolder.clearContext(); } } @@ -118,7 +89,7 @@ public class AuthServiceImpl implements AuthService { * @return 验证码 */ @Override - public CaptchaResult getCaptcha() { + public CaptchaResponse getCaptcha() { String captchaType = captchaProperties.getType(); int width = captchaProperties.getWidth(); @@ -150,7 +121,7 @@ public class AuthServiceImpl implements AuthService { redisTemplate.opsForValue().set(SecurityConstants.CAPTCHA_CODE_PREFIX + captchaKey, captchaCode, captchaProperties.getExpireSeconds(), TimeUnit.SECONDS); - return CaptchaResult.builder() + return CaptchaResponse.builder() .captchaKey(captchaKey) .captchaBase64(imageBase64Data) .build(); @@ -163,32 +134,18 @@ public class AuthServiceImpl implements AuthService { * @return 新的访问令牌 */ @Override - public LoginResult refreshToken(RefreshTokenRequest request) { + public AuthTokenResponse refreshToken(RefreshTokenRequest request) { // 验证刷新令牌 String refreshToken = request.getRefreshToken(); - JWT jwt = JWTUtil.parseToken(refreshToken); - boolean isValidate = jwt.setKey(securityProperties.getJwt().getKey().getBytes()).validate(0); + boolean isValidate = tokenService.validateToken(refreshToken); - if (!isValidate || redisTemplate.hasKey(SecurityConstants.BLACKLIST_TOKEN_PREFIX + jwt.getPayloads().getStr(JWTPayload.JWT_ID))) { + if (!isValidate) { throw new BusinessException(ResultCode.REFRESH_TOKEN_INVALID); } - Authentication authentication = JwtUtils.getAuthentication(jwt.getPayloads()); - - // 创建新的访问令牌 - 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(); + return tokenService.refreshToken(refreshToken); } } diff --git a/src/main/java/com/youlai/boot/shared/auth/service/impl/JwtTokenService.java b/src/main/java/com/youlai/boot/shared/auth/service/impl/JwtTokenService.java new file mode 100644 index 00000000..9331b3d4 --- /dev/null +++ b/src/main/java/com/youlai/boot/shared/auth/service/impl/JwtTokenService.java @@ -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 redisTemplate; + private final byte[] secretKey; + + + public JwtTokenService(SecurityProperties securityProperties, RedisTemplate 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 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 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 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); + } +} diff --git a/src/main/java/com/youlai/boot/shared/auth/service/impl/RedisTokenService.java b/src/main/java/com/youlai/boot/shared/auth/service/impl/RedisTokenService.java new file mode 100644 index 00000000..7da240a5 --- /dev/null +++ b/src/main/java/com/youlai/boot/shared/auth/service/impl/RedisTokenService.java @@ -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; + } +} diff --git a/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java index 5aea9fb9..36da5402 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java +++ b/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java @@ -10,8 +10,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.youlai.boot.common.constant.RedisConstants; import com.youlai.boot.common.constant.SystemConstants; -import com.youlai.boot.core.security.util.JwtUtils; -import com.youlai.boot.shared.auth.service.AuthService; +import com.youlai.boot.shared.auth.service.TokenService; import com.youlai.boot.system.enums.ContactType; import com.youlai.boot.common.model.Option; 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.UserRoleService; import com.youlai.boot.system.service.UserService; -import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; 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.stereotype.Service; 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.Collections; @@ -83,6 +77,8 @@ public class UserServiceImpl extends ServiceImpl implements Us private final StringRedisTemplate redisTemplate; + private final TokenService tokenService; + /** * 获取用户分页列表 * @@ -322,6 +318,12 @@ public class UserServiceImpl extends ServiceImpl implements Us .eq(User::getId, userId) .set(User::getPassword, passwordEncoder.encode(newPassword)) ); + + if(result){ + // 加入黑名单,重新登录 + String accessToken = SecurityUtils.getTokenFromRequest(); + tokenService.blacklistToken(accessToken); + } return result; } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 6869b154..c4f6ea39 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -75,6 +75,8 @@ mybatis-plus: # 安全配置 security: + # JWT 认证类型, 支持 jwt、redis-token + token-type: jwt jwt: # JWT 秘钥 key: SecretKey012345678901234567890123456789012345678901234567890123456789 diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index cd66de2c..664f0a8d 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -59,6 +59,8 @@ mybatis-plus: # 安全配置 security: + # JWT 认证类型, 支持 jwt、redis-token + token-type: jwt # JWT 配置 jwt: # JWT 秘钥