1. 引入 redis,消除 JWT 无法手动过期的缺陷 2. 保证一个用户同一时间只能保证一个设备登录 3. 支持“记住我”操作,开启后,JWT过期时间默认为7天pull/1/head
@@ -39,6 +39,17 @@ | |||||
<artifactId>spring-boot-starter-data-jpa</artifactId> | <artifactId>spring-boot-starter-data-jpa</artifactId> | ||||
</dependency> | </dependency> | ||||
<dependency> | |||||
<groupId>org.springframework.boot</groupId> | |||||
<artifactId>spring-boot-starter-data-redis</artifactId> | |||||
</dependency> | |||||
<!-- 对象池,使用redis时必须引入 --> | |||||
<dependency> | |||||
<groupId>org.apache.commons</groupId> | |||||
<artifactId>commons-pool2</artifactId> | |||||
</dependency> | |||||
<dependency> | <dependency> | ||||
<groupId>org.springframework.boot</groupId> | <groupId>org.springframework.boot</groupId> | ||||
<artifactId>spring-boot-configuration-processor</artifactId> | <artifactId>spring-boot-configuration-processor</artifactId> | ||||
@@ -16,8 +16,8 @@ import lombok.EqualsAndHashCode; | |||||
* @version: V1.0 | * @version: V1.0 | ||||
* @modified: yangkai.shen | * @modified: yangkai.shen | ||||
*/ | */ | ||||
@Data | |||||
@EqualsAndHashCode(callSuper = true) | @EqualsAndHashCode(callSuper = true) | ||||
@Data | |||||
public class BaseException extends RuntimeException { | public class BaseException extends RuntimeException { | ||||
private Integer code; | private Integer code; | ||||
private String message; | private String message; | ||||
@@ -32,4 +32,9 @@ public interface Consts { | |||||
* 按钮 | * 按钮 | ||||
*/ | */ | ||||
Integer BUTTON = 2; | Integer BUTTON = 2; | ||||
/** | |||||
* JWT 在 Redis 中保存的key前缀 | |||||
*/ | |||||
String REDIS_JWT_KEY_PREFIX = "security:jwt:"; | |||||
} | } |
@@ -18,49 +18,54 @@ import lombok.Getter; | |||||
@Getter | @Getter | ||||
public enum Status implements IStatus { | public enum Status implements IStatus { | ||||
/** | /** | ||||
* 操作成功 | |||||
* 操作成功! | |||||
*/ | */ | ||||
SUCCESS(200, "操作成功"), | |||||
SUCCESS(200, "操作成功!"), | |||||
/** | /** | ||||
* 操作异常 | |||||
* 操作异常! | |||||
*/ | */ | ||||
ERROR(500, "操作异常"), | |||||
ERROR(500, "操作异常!"), | |||||
/** | /** | ||||
* 退出成功 | |||||
* 退出成功! | |||||
*/ | */ | ||||
LOGOUT(200, "退出成功"), | |||||
LOGOUT(200, "退出成功!"), | |||||
/** | /** | ||||
* 请先登录 | |||||
* 请先登录! | |||||
*/ | */ | ||||
UNAUTHORIZED(401, "请先登录"), | |||||
UNAUTHORIZED(401, "请先登录!"), | |||||
/** | /** | ||||
* 暂无权限访问 | |||||
* 暂无权限访问! | |||||
*/ | */ | ||||
ACCESS_DENIED(403, "权限不足"), | |||||
ACCESS_DENIED(403, "权限不足!"), | |||||
/** | /** | ||||
* 请求不存在 | |||||
* 请求不存在! | |||||
*/ | */ | ||||
REQUEST_NOT_FOUND(404, "请求不存在"), | |||||
REQUEST_NOT_FOUND(404, "请求不存在!"), | |||||
/** | /** | ||||
* 请求异常 | |||||
* 请求方式不支持! | |||||
*/ | */ | ||||
BAD_REQUEST(400, "请求异常"), | |||||
HTTP_BAD_METHOD(405, "请求方式不支持!"), | |||||
/** | /** | ||||
* 参数不匹配 | |||||
* 请求异常! | |||||
*/ | */ | ||||
PARAM_NOT_MATCH(400, "参数不匹配"), | |||||
BAD_REQUEST(400, "请求异常!"), | |||||
/** | /** | ||||
* 参数不能为空 | |||||
* 参数不匹配! | |||||
*/ | */ | ||||
PARAM_NOT_NULL(400, "参数不能为空"), | |||||
PARAM_NOT_MATCH(400, "参数不匹配!"), | |||||
/** | |||||
* 参数不能为空! | |||||
*/ | |||||
PARAM_NOT_NULL(400, "参数不能为空!"), | |||||
/** | /** | ||||
* 当前用户已被锁定,请联系管理员解锁! | * 当前用户已被锁定,请联系管理员解锁! | ||||
@@ -68,19 +73,24 @@ public enum Status implements IStatus { | |||||
USER_DISABLED(403, "当前用户已被锁定,请联系管理员解锁!"), | USER_DISABLED(403, "当前用户已被锁定,请联系管理员解锁!"), | ||||
/** | /** | ||||
* 用户名或密码错误 | |||||
* 用户名或密码错误! | |||||
*/ | |||||
USERNAME_PASSWORD_ERROR(5001, "用户名或密码错误!"), | |||||
/** | |||||
* token 已过期,请重新登录! | |||||
*/ | */ | ||||
USERNAME_PASSWORD_ERROR(5001, "用户名或密码错误"), | |||||
TOKEN_EXPIRED(5002, "token 已过期,请重新登录!"), | |||||
/** | /** | ||||
* token 已过期,请重新登录 | |||||
* token 解析失败,请尝试重新登录! | |||||
*/ | */ | ||||
TOKEN_EXPIRED(5002, "token 已过期,请重新登录"), | |||||
TOKEN_PARSE_ERROR(5002, "token 解析失败,请尝试重新登录!"), | |||||
/** | /** | ||||
* token 解析失败,请尝试重新登录 | |||||
* 当前用户已在别处登录,请尝试更改密码或重新登录! | |||||
*/ | */ | ||||
TOKEN_PARSE_ERROR(5002, "token 解析失败,请尝试重新登录"); | |||||
TOKEN_OUT_OF_CTRL(5003,"当前用户已在别处登录,请尝试更改密码或重新登录!"); | |||||
/** | /** | ||||
* 状态码 | * 状态码 | ||||
@@ -50,7 +50,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { | |||||
if (antPathMatcher.match("/**/api/auth/**", request.getRequestURI())) { | if (antPathMatcher.match("/**/api/auth/**", request.getRequestURI())) { | ||||
filterChain.doFilter(request, response); | filterChain.doFilter(request, response); | ||||
} else { | } else { | ||||
String jwt = getJwtFromRequest(request); | |||||
String jwt = jwtUtil.getJwtFromRequest(request); | |||||
if (StrUtil.isNotBlank(jwt)) { | if (StrUtil.isNotBlank(jwt)) { | ||||
try { | try { | ||||
@@ -72,11 +72,4 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { | |||||
} | } | ||||
} | } | ||||
private String getJwtFromRequest(HttpServletRequest request) { | |||||
String bearerToken = request.getHeader("Authorization"); | |||||
if (StrUtil.isNotBlank(bearerToken) && bearerToken.startsWith("Bearer ")) { | |||||
return bearerToken.substring(7); | |||||
} | |||||
return null; | |||||
} | |||||
} | } |
@@ -20,12 +20,17 @@ import org.springframework.boot.context.properties.ConfigurationProperties; | |||||
@Data | @Data | ||||
public class JwtConfig { | public class JwtConfig { | ||||
/** | /** | ||||
* jwt 加密 key, 默认值:xkcoding. | |||||
* jwt 加密 key,默认值:xkcoding. | |||||
*/ | */ | ||||
private String key = "xkcoding"; | private String key = "xkcoding"; | ||||
/** | /** | ||||
* jwt 过期时间, 默认值:600000 {@code 10 分钟}. | |||||
* jwt 过期时间,默认值:600000 {@code 10 分钟}. | |||||
*/ | */ | ||||
private Long ttl = 600000L; | private Long ttl = 600000L; | ||||
/** | |||||
* 开启 记住我 之后 jwt 过期时间,默认值 604800000 {@code 7 天} | |||||
*/ | |||||
private Long remember = 604800000L; | |||||
} | } |
@@ -55,13 +55,16 @@ public class RbacAuthorityService { | |||||
//获取资源,前后端分离,所以过滤页面权限,只保留按钮权限 | //获取资源,前后端分离,所以过滤页面权限,只保留按钮权限 | ||||
List<Permission> btnPerms = permissions.stream() | List<Permission> btnPerms = permissions.stream() | ||||
// 过滤页面权限 | |||||
.filter(permission -> Objects.equals(permission.getType(), Consts.BUTTON)) | .filter(permission -> Objects.equals(permission.getType(), Consts.BUTTON)) | ||||
.filter(permission -> StrUtil.isNotBlank(permission.getPermission())) | |||||
// 过滤 URL 为空 | |||||
.filter(permission -> StrUtil.isNotBlank(permission.getUrl())) | |||||
// 过滤 METHOD 为空 | |||||
.filter(permission -> StrUtil.isNotBlank(permission.getMethod())) | .filter(permission -> StrUtil.isNotBlank(permission.getMethod())) | ||||
.collect(Collectors.toList()); | .collect(Collectors.toList()); | ||||
for (Permission btnPerm : btnPerms) { | for (Permission btnPerm : btnPerms) { | ||||
AntPathRequestMatcher antPathMatcher = new AntPathRequestMatcher(btnPerm.getPermission(), btnPerm.getMethod()); | |||||
AntPathRequestMatcher antPathMatcher = new AntPathRequestMatcher(btnPerm.getUrl(), btnPerm.getMethod()); | |||||
if (antPathMatcher.matches(request)) { | if (antPathMatcher.matches(request)) { | ||||
hasPermission = true; | hasPermission = true; | ||||
break; | break; | ||||
@@ -0,0 +1,44 @@ | |||||
package com.xkcoding.rbac.security.config; | |||||
import org.springframework.boot.autoconfigure.AutoConfigureAfter; | |||||
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration; | |||||
import org.springframework.cache.annotation.EnableCaching; | |||||
import org.springframework.context.annotation.Bean; | |||||
import org.springframework.context.annotation.Configuration; | |||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; | |||||
import org.springframework.data.redis.core.RedisTemplate; | |||||
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; | |||||
import org.springframework.data.redis.serializer.StringRedisSerializer; | |||||
import java.io.Serializable; | |||||
/** | |||||
* <p> | |||||
* redis配置 | |||||
* </p> | |||||
* | |||||
* @package: com.xkcoding.rbac.security.config | |||||
* @description: redis配置 | |||||
* @author: yangkai.shen | |||||
* @date: Created in 2018-12-11 15:16 | |||||
* @copyright: Copyright (c) 2018 | |||||
* @version: V1.0 | |||||
* @modified: yangkai.shen | |||||
*/ | |||||
@Configuration | |||||
@AutoConfigureAfter(RedisAutoConfiguration.class) | |||||
@EnableCaching | |||||
public class RedisConfig { | |||||
/** | |||||
* 默认情况下的模板只能支持RedisTemplate<String, String>,也就是只能存入字符串,因此支持序列化 | |||||
*/ | |||||
@Bean | |||||
public RedisTemplate<String, Serializable> redisCacheTemplate(LettuceConnectionFactory redisConnectionFactory) { | |||||
RedisTemplate<String, Serializable> template = new RedisTemplate<>(); | |||||
template.setKeySerializer(new StringRedisSerializer()); | |||||
template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); | |||||
template.setConnectionFactory(redisConnectionFactory); | |||||
return template; | |||||
} | |||||
} |
@@ -32,9 +32,6 @@ import org.springframework.security.web.util.matcher.AntPathRequestMatcher; | |||||
@Configuration | @Configuration | ||||
@EnableWebSecurity | @EnableWebSecurity | ||||
public class SecurityConfig extends WebSecurityConfigurerAdapter { | public class SecurityConfig extends WebSecurityConfigurerAdapter { | ||||
@Autowired | |||||
private LogoutSuccessHandler logoutSuccessHandler; | |||||
@Autowired | @Autowired | ||||
private AccessDeniedHandler accessDeniedHandler; | private AccessDeniedHandler accessDeniedHandler; | ||||
@@ -70,6 +67,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter { | |||||
.csrf() | .csrf() | ||||
.disable() | .disable() | ||||
// 登录行为由自己实现,参考 AuthController#login | |||||
.formLogin() | .formLogin() | ||||
.disable() | .disable() | ||||
.httpBasic() | .httpBasic() | ||||
@@ -86,17 +84,11 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter { | |||||
.anyRequest() | .anyRequest() | ||||
.access("@rbacAuthorityService.hasPermission(request,authentication)") | .access("@rbacAuthorityService.hasPermission(request,authentication)") | ||||
// 登出处理 | |||||
// 登出行为由自己实现,参考 AuthController#logout | |||||
.and() | .and() | ||||
.logout() | |||||
// 登出请求默认为POST请求,改为GET请求 | |||||
.logoutRequestMatcher(new AntPathRequestMatcher("/**/logout", "GET")) | |||||
// 登出成功处理器 | |||||
.logoutSuccessHandler(logoutSuccessHandler) | |||||
.permitAll() | |||||
.logout().disable() | |||||
// Session 管理 | // Session 管理 | ||||
.and() | |||||
.sessionManagement() | .sessionManagement() | ||||
// 因为使用了JWT,所以这里不管理Session | // 因为使用了JWT,所以这里不管理Session | ||||
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) | .sessionCreationPolicy(SessionCreationPolicy.STATELESS) | ||||
@@ -5,7 +5,6 @@ import com.xkcoding.rbac.security.util.ResponseUtil; | |||||
import org.springframework.context.annotation.Bean; | import org.springframework.context.annotation.Bean; | ||||
import org.springframework.context.annotation.Configuration; | import org.springframework.context.annotation.Configuration; | ||||
import org.springframework.security.web.access.AccessDeniedHandler; | import org.springframework.security.web.access.AccessDeniedHandler; | ||||
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; | |||||
/** | /** | ||||
* <p> | * <p> | ||||
@@ -23,18 +22,9 @@ import org.springframework.security.web.authentication.logout.LogoutSuccessHandl | |||||
@Configuration | @Configuration | ||||
public class SecurityHandlerConfig { | public class SecurityHandlerConfig { | ||||
/** | |||||
* 退出成功处理器 | |||||
* | |||||
* @return 退出成功处理器 | |||||
*/ | |||||
@Bean | |||||
public LogoutSuccessHandler logoutSuccessHandler() { | |||||
return (request, response, authentication) -> ResponseUtil.renderJson(response, Status.LOGOUT, null); | |||||
} | |||||
@Bean | @Bean | ||||
public AccessDeniedHandler accessDeniedHandler() { | public AccessDeniedHandler accessDeniedHandler() { | ||||
return (request, response, accessDeniedException) -> ResponseUtil.renderJson(response, Status.ACCESS_DENIED, null); | return (request, response, accessDeniedException) -> ResponseUtil.renderJson(response, Status.ACCESS_DENIED, null); | ||||
} | } | ||||
} | } |
@@ -1,11 +1,14 @@ | |||||
package com.xkcoding.rbac.security.controller; | package com.xkcoding.rbac.security.controller; | ||||
import com.xkcoding.rbac.security.common.ApiResponse; | import com.xkcoding.rbac.security.common.ApiResponse; | ||||
import com.xkcoding.rbac.security.common.Status; | |||||
import com.xkcoding.rbac.security.exception.SecurityException; | |||||
import com.xkcoding.rbac.security.payload.LoginRequest; | import com.xkcoding.rbac.security.payload.LoginRequest; | ||||
import com.xkcoding.rbac.security.util.JwtUtil; | import com.xkcoding.rbac.security.util.JwtUtil; | ||||
import com.xkcoding.rbac.security.vo.JwtResponse; | import com.xkcoding.rbac.security.vo.JwtResponse; | ||||
import lombok.extern.slf4j.Slf4j; | import lombok.extern.slf4j.Slf4j; | ||||
import org.springframework.beans.factory.annotation.Autowired; | import org.springframework.beans.factory.annotation.Autowired; | ||||
import org.springframework.data.redis.core.StringRedisTemplate; | |||||
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; | ||||
@@ -15,6 +18,7 @@ import org.springframework.web.bind.annotation.RequestBody; | |||||
import org.springframework.web.bind.annotation.RequestMapping; | import org.springframework.web.bind.annotation.RequestMapping; | ||||
import org.springframework.web.bind.annotation.RestController; | import org.springframework.web.bind.annotation.RestController; | ||||
import javax.servlet.http.HttpServletRequest; | |||||
import javax.validation.Valid; | import javax.validation.Valid; | ||||
/** | /** | ||||
@@ -51,7 +55,18 @@ public class AuthController { | |||||
SecurityContextHolder.getContext() | SecurityContextHolder.getContext() | ||||
.setAuthentication(authentication); | .setAuthentication(authentication); | ||||
String jwt = jwtUtil.createJWT(authentication); | |||||
String jwt = jwtUtil.createJWT(authentication,loginRequest.getRememberMe()); | |||||
return ApiResponse.ofSuccess(new JwtResponse(jwt)); | return ApiResponse.ofSuccess(new JwtResponse(jwt)); | ||||
} | } | ||||
@PostMapping("/logout") | |||||
public ApiResponse logout(HttpServletRequest request) { | |||||
try { | |||||
// 设置JWT过期 | |||||
jwtUtil.invalidateJWT(request); | |||||
} catch (SecurityException e) { | |||||
throw new SecurityException(Status.UNAUTHORIZED); | |||||
} | |||||
return ApiResponse.ofStatus(Status.LOGOUT); | |||||
} | |||||
} | } |
@@ -2,7 +2,8 @@ package com.xkcoding.rbac.security.exception; | |||||
import com.xkcoding.rbac.security.common.BaseException; | import com.xkcoding.rbac.security.common.BaseException; | ||||
import com.xkcoding.rbac.security.common.Status; | import com.xkcoding.rbac.security.common.Status; | ||||
import lombok.Getter; | |||||
import lombok.Data; | |||||
import lombok.EqualsAndHashCode; | |||||
/** | /** | ||||
* <p> | * <p> | ||||
@@ -17,7 +18,8 @@ import lombok.Getter; | |||||
* @version: V1.0 | * @version: V1.0 | ||||
* @modified: yangkai.shen | * @modified: yangkai.shen | ||||
*/ | */ | ||||
@Getter | |||||
@EqualsAndHashCode(callSuper = true) | |||||
@Data | |||||
public class SecurityException extends BaseException { | public class SecurityException extends BaseException { | ||||
public SecurityException(Status status) { | public SecurityException(Status status) { | ||||
super(status); | super(status); | ||||
@@ -1,6 +1,7 @@ | |||||
package com.xkcoding.rbac.security.exception.handler; | package com.xkcoding.rbac.security.exception.handler; | ||||
import cn.hutool.core.collection.CollUtil; | import cn.hutool.core.collection.CollUtil; | ||||
import cn.hutool.json.JSONUtil; | |||||
import com.xkcoding.rbac.security.common.ApiResponse; | import com.xkcoding.rbac.security.common.ApiResponse; | ||||
import com.xkcoding.rbac.security.common.BaseException; | import com.xkcoding.rbac.security.common.BaseException; | ||||
import com.xkcoding.rbac.security.common.Status; | import com.xkcoding.rbac.security.common.Status; | ||||
@@ -8,6 +9,7 @@ import lombok.extern.slf4j.Slf4j; | |||||
import org.springframework.http.converter.HttpMessageNotReadableException; | import org.springframework.http.converter.HttpMessageNotReadableException; | ||||
import org.springframework.security.authentication.BadCredentialsException; | import org.springframework.security.authentication.BadCredentialsException; | ||||
import org.springframework.security.authentication.DisabledException; | import org.springframework.security.authentication.DisabledException; | ||||
import org.springframework.web.HttpRequestMethodNotSupportedException; | |||||
import org.springframework.web.bind.MethodArgumentNotValidException; | import org.springframework.web.bind.MethodArgumentNotValidException; | ||||
import org.springframework.web.bind.annotation.ControllerAdvice; | import org.springframework.web.bind.annotation.ControllerAdvice; | ||||
import org.springframework.web.bind.annotation.ExceptionHandler; | import org.springframework.web.bind.annotation.ExceptionHandler; | ||||
@@ -40,6 +42,9 @@ public class GlobalExceptionHandler { | |||||
if (e instanceof NoHandlerFoundException) { | if (e instanceof NoHandlerFoundException) { | ||||
log.error("【全局异常拦截】NoHandlerFoundException: 请求方法 {}, 请求路径 {}", ((NoHandlerFoundException) e).getRequestURL(), ((NoHandlerFoundException) e).getHttpMethod()); | log.error("【全局异常拦截】NoHandlerFoundException: 请求方法 {}, 请求路径 {}", ((NoHandlerFoundException) e).getRequestURL(), ((NoHandlerFoundException) e).getHttpMethod()); | ||||
return ApiResponse.ofStatus(Status.REQUEST_NOT_FOUND); | return ApiResponse.ofStatus(Status.REQUEST_NOT_FOUND); | ||||
} else if (e instanceof HttpRequestMethodNotSupportedException) { | |||||
log.error("【全局异常拦截】HttpRequestMethodNotSupportedException: 当前请求方式 {}, 支持请求方式 {}", ((HttpRequestMethodNotSupportedException) e).getMethod(), JSONUtil.toJsonStr(((HttpRequestMethodNotSupportedException) e).getSupportedHttpMethods())); | |||||
return ApiResponse.ofStatus(Status.HTTP_BAD_METHOD); | |||||
} else if (e instanceof MethodArgumentNotValidException) { | } else if (e instanceof MethodArgumentNotValidException) { | ||||
log.error("【全局异常拦截】MethodArgumentNotValidException", e); | log.error("【全局异常拦截】MethodArgumentNotValidException", e); | ||||
return ApiResponse.of(Status.BAD_REQUEST.getCode(), ((MethodArgumentNotValidException) e).getBindingResult() | return ApiResponse.of(Status.BAD_REQUEST.getCode(), ((MethodArgumentNotValidException) e).getBindingResult() | ||||
@@ -23,13 +23,18 @@ public class LoginRequest { | |||||
/** | /** | ||||
* 用户名或邮箱或手机号 | * 用户名或邮箱或手机号 | ||||
*/ | */ | ||||
@NotBlank | |||||
@NotBlank(message = "用户名不能为空") | |||||
private String usernameOrEmailOrPhone; | private String usernameOrEmailOrPhone; | ||||
/** | /** | ||||
* 密码 | * 密码 | ||||
*/ | */ | ||||
@NotBlank | |||||
@NotBlank(message = "密码不能为空") | |||||
private String password; | private String password; | ||||
/** | |||||
* 记住我 | |||||
*/ | |||||
private Boolean rememberMe = false; | |||||
} | } |
@@ -1,6 +1,8 @@ | |||||
package com.xkcoding.rbac.security.util; | package com.xkcoding.rbac.security.util; | ||||
import cn.hutool.core.date.DateUtil; | import cn.hutool.core.date.DateUtil; | ||||
import cn.hutool.core.util.StrUtil; | |||||
import com.xkcoding.rbac.security.common.Consts; | |||||
import com.xkcoding.rbac.security.common.Status; | import com.xkcoding.rbac.security.common.Status; | ||||
import com.xkcoding.rbac.security.config.JwtConfig; | import com.xkcoding.rbac.security.config.JwtConfig; | ||||
import com.xkcoding.rbac.security.exception.SecurityException; | import com.xkcoding.rbac.security.exception.SecurityException; | ||||
@@ -10,12 +12,16 @@ import lombok.extern.slf4j.Slf4j; | |||||
import org.springframework.beans.factory.annotation.Autowired; | import org.springframework.beans.factory.annotation.Autowired; | ||||
import org.springframework.boot.context.properties.EnableConfigurationProperties; | import org.springframework.boot.context.properties.EnableConfigurationProperties; | ||||
import org.springframework.context.annotation.Configuration; | import org.springframework.context.annotation.Configuration; | ||||
import org.springframework.data.redis.core.StringRedisTemplate; | |||||
import org.springframework.security.core.Authentication; | import org.springframework.security.core.Authentication; | ||||
import org.springframework.security.core.GrantedAuthority; | import org.springframework.security.core.GrantedAuthority; | ||||
import javax.servlet.http.HttpServletRequest; | |||||
import java.util.Collection; | import java.util.Collection; | ||||
import java.util.Date; | import java.util.Date; | ||||
import java.util.List; | import java.util.List; | ||||
import java.util.Objects; | |||||
import java.util.concurrent.TimeUnit; | |||||
/** | /** | ||||
* <p> | * <p> | ||||
@@ -34,23 +40,23 @@ import java.util.List; | |||||
@Configuration | @Configuration | ||||
@Slf4j | @Slf4j | ||||
public class JwtUtil { | public class JwtUtil { | ||||
private final JwtConfig jwtConfig; | |||||
@Autowired | |||||
private JwtConfig jwtConfig; | |||||
@Autowired | @Autowired | ||||
public JwtUtil(JwtConfig jwtConfig) { | |||||
this.jwtConfig = jwtConfig; | |||||
} | |||||
private StringRedisTemplate stringRedisTemplate; | |||||
/** | /** | ||||
* 创建JWT | * 创建JWT | ||||
* | * | ||||
* @param rememberMe 记住我 | |||||
* @param id 用户id | * @param id 用户id | ||||
* @param subject 用户名 | * @param subject 用户名 | ||||
* @param roles 用户角色 | * @param roles 用户角色 | ||||
* @param authorities 用户权限 | * @param authorities 用户权限 | ||||
* @return JWT | * @return JWT | ||||
*/ | */ | ||||
public String createJWT(Long id, String subject, List<String> roles, Collection<? extends GrantedAuthority> authorities) { | |||||
public String createJWT(Boolean rememberMe, Long id, String subject, List<String> roles, Collection<? extends GrantedAuthority> authorities) { | |||||
Date now = new Date(); | Date now = new Date(); | ||||
JwtBuilder builder = Jwts.builder() | JwtBuilder builder = Jwts.builder() | ||||
.setId(id.toString()) | .setId(id.toString()) | ||||
@@ -59,22 +65,30 @@ public class JwtUtil { | |||||
.signWith(SignatureAlgorithm.HS256, jwtConfig.getKey()) | .signWith(SignatureAlgorithm.HS256, jwtConfig.getKey()) | ||||
.claim("roles", roles) | .claim("roles", roles) | ||||
.claim("authorities", authorities); | .claim("authorities", authorities); | ||||
if (jwtConfig.getTtl() > 0) { | |||||
builder.setExpiration(DateUtil.offsetMillisecond(now, jwtConfig.getTtl() | |||||
.intValue())); | |||||
// 设置过期时间 | |||||
Long ttl = rememberMe ? jwtConfig.getRemember() : jwtConfig.getTtl(); | |||||
if (ttl > 0) { | |||||
builder.setExpiration(DateUtil.offsetMillisecond(now, ttl.intValue())); | |||||
} | } | ||||
return builder.compact(); | |||||
String jwt = builder.compact(); | |||||
// 将生成的JWT保存至Redis | |||||
stringRedisTemplate.opsForValue() | |||||
.set(Consts.REDIS_JWT_KEY_PREFIX + subject, jwt, ttl, TimeUnit.MILLISECONDS); | |||||
return jwt; | |||||
} | } | ||||
/** | /** | ||||
* 创建JWT | * 创建JWT | ||||
* | * | ||||
* @param authentication 用户认证信息 | * @param authentication 用户认证信息 | ||||
* @param rememberMe 记住我 | |||||
* @return JWT | * @return JWT | ||||
*/ | */ | ||||
public String createJWT(Authentication authentication) { | |||||
public String createJWT(Authentication authentication, Boolean rememberMe) { | |||||
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal(); | UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal(); | ||||
return createJWT(userPrincipal.getId(), userPrincipal.getUsername(), userPrincipal.getRoles(), userPrincipal.getAuthorities()); | |||||
return createJWT(rememberMe, userPrincipal.getId(), userPrincipal.getUsername(), userPrincipal.getRoles(), userPrincipal.getAuthorities()); | |||||
} | } | ||||
/** | /** | ||||
@@ -85,10 +99,27 @@ public class JwtUtil { | |||||
*/ | */ | ||||
public Claims parseJWT(String jwt) { | public Claims parseJWT(String jwt) { | ||||
try { | try { | ||||
return Jwts.parser() | |||||
Claims claims = Jwts.parser() | |||||
.setSigningKey(jwtConfig.getKey()) | .setSigningKey(jwtConfig.getKey()) | ||||
.parseClaimsJws(jwt) | .parseClaimsJws(jwt) | ||||
.getBody(); | .getBody(); | ||||
String username = claims.getSubject(); | |||||
String redisKey = Consts.REDIS_JWT_KEY_PREFIX + username; | |||||
// 校验redis中的JWT是否存在 | |||||
Long expire = stringRedisTemplate.getExpire(redisKey, TimeUnit.MILLISECONDS); | |||||
if (Objects.isNull(expire) || expire <= 0) { | |||||
throw new SecurityException(Status.TOKEN_EXPIRED); | |||||
} | |||||
// 校验redis中的JWT是否与当前的一致,不一致则代表用户已注销/用户在不同设备登录,均代表JWT已过期 | |||||
String redisToken = stringRedisTemplate.opsForValue() | |||||
.get(redisKey); | |||||
if (!StrUtil.equals(jwt, redisToken)) { | |||||
throw new SecurityException(Status.TOKEN_OUT_OF_CTRL); | |||||
} | |||||
return claims; | |||||
} catch (ExpiredJwtException e) { | } catch (ExpiredJwtException e) { | ||||
log.error("Token 已过期"); | log.error("Token 已过期"); | ||||
throw new SecurityException(Status.TOKEN_EXPIRED); | throw new SecurityException(Status.TOKEN_EXPIRED); | ||||
@@ -107,6 +138,18 @@ public class JwtUtil { | |||||
} | } | ||||
} | } | ||||
/** | |||||
* 设置JWT过期 | |||||
* | |||||
* @param request 请求 | |||||
*/ | |||||
public void invalidateJWT(HttpServletRequest request) { | |||||
String jwt = getJwtFromRequest(request); | |||||
String username = getUsernameFromJWT(jwt); | |||||
// 从redis中清除JWT | |||||
stringRedisTemplate.delete(Consts.REDIS_JWT_KEY_PREFIX + username); | |||||
} | |||||
/** | /** | ||||
* 根据 jwt 获取用户名 | * 根据 jwt 获取用户名 | ||||
* | * | ||||
@@ -118,4 +161,18 @@ public class JwtUtil { | |||||
return claims.getSubject(); | return claims.getSubject(); | ||||
} | } | ||||
/** | |||||
* 从 request 的 header 中获取 JWT | |||||
* | |||||
* @param request 请求 | |||||
* @return JWT | |||||
*/ | |||||
public String getJwtFromRequest(HttpServletRequest request) { | |||||
String bearerToken = request.getHeader("Authorization"); | |||||
if (StrUtil.isNotBlank(bearerToken) && bearerToken.startsWith("Bearer ")) { | |||||
return bearerToken.substring(7); | |||||
} | |||||
return null; | |||||
} | |||||
} | } |
@@ -1,5 +1,6 @@ | |||||
package com.xkcoding.rbac.security.util; | package com.xkcoding.rbac.security.util; | ||||
import cn.hutool.json.JSONObject; | |||||
import cn.hutool.json.JSONUtil; | import cn.hutool.json.JSONUtil; | ||||
import com.xkcoding.rbac.security.common.ApiResponse; | import com.xkcoding.rbac.security.common.ApiResponse; | ||||
import com.xkcoding.rbac.security.common.BaseException; | import com.xkcoding.rbac.security.common.BaseException; | ||||
@@ -39,8 +40,10 @@ public class ResponseUtil { | |||||
response.setContentType("application/json;charset=UTF-8"); | response.setContentType("application/json;charset=UTF-8"); | ||||
response.setStatus(200); | response.setStatus(200); | ||||
// FIXME: hutool 的 BUG:JSONUtil.toJsonStr() | |||||
// 将JSON转为String的时候,忽略null值的时候转成的String存在错误 | |||||
response.getWriter() | response.getWriter() | ||||
.write(JSONUtil.toJsonStr(ApiResponse.ofStatus(status, data))); | |||||
.write(JSONUtil.toJsonStr(new JSONObject(ApiResponse.ofStatus(status, data), true))); | |||||
} catch (IOException e) { | } catch (IOException e) { | ||||
log.error("Response写出JSON异常,", e); | log.error("Response写出JSON异常,", e); | ||||
} | } | ||||
@@ -59,8 +62,10 @@ public class ResponseUtil { | |||||
response.setContentType("application/json;charset=UTF-8"); | response.setContentType("application/json;charset=UTF-8"); | ||||
response.setStatus(200); | response.setStatus(200); | ||||
// FIXME: hutool 的 BUG:JSONUtil.toJsonStr() | |||||
// 将JSON转为String的时候,忽略null值的时候转成的String存在错误 | |||||
response.getWriter() | response.getWriter() | ||||
.write(JSONUtil.toJsonStr(ApiResponse.ofException(exception))); | |||||
.write(JSONUtil.toJsonStr(new JSONObject(ApiResponse.ofException(exception), true))); | |||||
} catch (IOException e) { | } catch (IOException e) { | ||||
log.error("Response写出JSON异常,", e); | log.error("Response写出JSON异常,", e); | ||||
} | } | ||||
@@ -18,7 +18,29 @@ spring: | |||||
properties: | properties: | ||||
hibernate: | hibernate: | ||||
dialect: org.hibernate.dialect.MySQL57InnoDBDialect | dialect: org.hibernate.dialect.MySQL57InnoDBDialect | ||||
resources: | |||||
add-mappings: false | |||||
mvc: | |||||
throw-exception-if-no-handler-found: true | |||||
redis: | |||||
host: localhost | |||||
port: 6379 | |||||
# 连接超时时间(记得添加单位,Duration) | |||||
timeout: 10000ms | |||||
# Redis默认情况下有16个分片,这里配置具体使用的分片 | |||||
# database: 0 | |||||
lettuce: | |||||
pool: | |||||
# 连接池最大连接数(使用负值表示没有限制) 默认 8 | |||||
max-active: 8 | |||||
# 连接池最大阻塞等待时间(使用负值表示没有限制) 默认 -1 | |||||
max-wait: -1ms | |||||
# 连接池中的最大空闲连接 默认 8 | |||||
max-idle: 8 | |||||
# 连接池中的最小空闲连接 默认 0 | |||||
min-idle: 0 | |||||
jwt: | jwt: | ||||
config: | config: | ||||
key: xkcoding | key: xkcoding | ||||
ttl: 600000 | ttl: 600000 | ||||
remember: 604800000 |