1.背景
- 在开发pingss-sys脚手架(项目地址)时,需要在微服务分布式环境中管理权限。
- 上一篇写了基本的jwt无状态权限认证,但是存在两个问题
- 生成的token如果过期时间太短,则每次到期后,都需要用户重新登录
- 生成的token如果过期时间太长,由于token签发后,在有效期内无法注销,存在安全隐患
- 本篇使用RefreshToken+redis增加安全性
- 项目地址
2.思路
- 用户登录成功生成token(生成访问令牌和刷新令牌,刷新令牌保存到redis)
- 用户提交请求,验证访问令牌是否过期
- 如果没有过期,已登录,流程完
- 如果过期,判断刷新令牌是否过期
- 如果过期,没有登录,流程完
- 如果没有过期,重新生成访问令牌
3.步骤
A.新的验证工具
/**
*********************************************************
** @desc : Jwt工作组件
** @author Pings
** @date 2019/3/21
** @version v1.0
* *******************************************************
*/
@Component
public class JwtComponent {
@Value("${sys.jwt.access-token.expire-time}")
private long accessTokenExpireTime;
@Value("${sys.jwt.refresh-token.expire-time}")
private long refreshTokenExpireTime;
@Value("${sys.jwt.secret}")
private String secret;
@Autowired
private RedisTemplate redisTemplate;
//**默认的刷新令牌过期时间60分钟
private static final long DEFAULT_REFRESH_EXPIRE_TIME = 60;
//**缓存中保存refreshToken key的前缀
private static final String REFRESH_TOKEN_PREFIX = "refresh_token_";
//**生成签名后缓存时间(生成签名后在指定时间内不重新生成新的签名,而使用缓存),默认5S
private int tokenSignCacheTime = 5;
//**缓存中保存accessToken key的前缀
private static final String ACCESS_TOKEN_PREFIX = "access_token_";
/**
*********************************************************
** @desc :生成token
** @author Pings
** @date 2019/3/21
** @param userName 用户名
** @return String
* *******************************************************
*/
public String sign(String userName) {
//**同步每个用户的签名请求,不同用户不会同步
synchronized (userName.intern()){
String refreshTokenkey = this.getKey(userName);
String accessTokenKey = ACCESS_TOKEN_PREFIX + userName;
//**refreshToken为当前时间戳
long refreshToken = System.currentTimeMillis();
//**获取access token
String accessToken = JWT.create()
.withClaim(USER_NAME, userName)
.withClaim(REFRESH_TOKEN_PREFIX, refreshToken)
.withExpiresAt(new Date(refreshToken + accessTokenExpireTime * 60 * 1000))
.sign(this.generateAlgorithm(userName));
//**如果没有有效的accessToken,则缓存新的accessToken
Boolean success = this.redisTemplate.opsForValue().setIfAbsent(accessTokenKey, accessToken, tokenSignCacheTime, TimeUnit.SECONDS);
//**如果缓存新的accessToken成功,则缓存新的refreshToken
if(success != null && success){
this.redisTemplate.opsForValue().set(refreshTokenkey, refreshToken, refreshTokenExpireTime, TimeUnit.MINUTES);
} else { //**否则,返回缓存的accessToken
accessToken = this.redisTemplate.opsForValue().get(accessTokenKey) + "";
}
return accessToken;
}
}
/**
*********************************************************
** @desc : 校验token
** @author Pings
** @date 2019/3/21
** @param token 令牌
** @return boolean
* *******************************************************
*/
public boolean verify(String token) {
String key = this.getKey(JwtUtil.getUserName(token));
//**刷新令牌不存在/过期
Boolean hasKey = this.redisTemplate.hasKey(key);
if(hasKey == null || !hasKey)
throw new TokenExpiredException("The Token not existed or expired.");
//**刷新令牌和访问令牌的时间戳不一致
long refreshToken = (long)this.redisTemplate.opsForValue().get(key);
if(refreshToken != JwtUtil.getSignTimeMillis(token)){
throw new TokenExpiredException("The Token has expired.");
}
//**访问令牌校验
return JwtUtil.verify(token, secret);
}
//**获取缓存中保存refreshToken的key
public String getKey(String userName) {
return REFRESH_TOKEN_PREFIX + userName;
}
}
B.jwt工具的改进
/**
*********************************************************
** @desc : JwtUtil
** @author Pings
** @date 2019/1/23
** @version v1.0
* *******************************************************
*/
public class JwtUtil {
//**用户名称的key
private static final String USER_NAME = "userName";
//**签发时间戳的key
private static final String SING_TIME_MILLIS = "signTimeMillis";
//**默认的过期时间5分钟
private static final long DEFAULT_EXPIRE_TIME = 5;
//**默认的jwt加密secret
private static final String DEFAULT_SECRET = "pingssys";
/**
*********************************************************
** @desc :生成访问令牌
** @author Pings
** @date 2019/1/23
** @param secret secret
** @param userName 用户名
** @param expiresTime 过期时间
** @param signTimeMillis 签发时间戳
** @return String
* *******************************************************
*/
public static String sign(String secret, String userName, long expiresTime, long signTimeMillis) {
long currentTimeMillis = System.currentTimeMillis();
//**签发时间戳
signTimeMillis = signTimeMillis > 0 ? signTimeMillis : currentTimeMillis;
//**过期时间
expiresTime = expiresTime > 0 ? expiresTime : DEFAULT_EXPIRE_TIME;
expiresTime = expiresTime * 60 * 1000;
Algorithm algorithm = Algorithm.HMAC256(getSecret(secret, userName));
return JWT.create().withClaim(USER_NAME, userName)
.withClaim(SING_TIME_MILLIS, signTimeMillis)
.withExpiresAt(new Date(currentTimeMillis + expiresTime)).sign(algorithm);
}
/**
*********************************************************
** @desc : 校验token
** @author Pings
** @date 2019/1/23
** @param token 令牌
** @param secret secret
** @return boolean
* *******************************************************
*/
public static boolean verify(String token, String secret) {
Algorithm algorithm = Algorithm.HMAC256(getSecret(secret, JwtUtil.getUserName(token)));
JWTVerifier verifier = JWT.require(algorithm).build();
verifier.verify(token);
return true;
}
/**
*********************************************************
** @desc : 获取用户名称
** @author Pings
** @date 2019/1/23
** @param token 令牌
** @return String
* *******************************************************
*/
public static String getUserName(String token) {
Claim claim = decodeToken(token, jwt -> jwt.getClaim(USER_NAME));
return claim == null ? null : claim.asString();
}
/**
*********************************************************
** @desc : 获取签发时间戳
** @author Pings
** @date 2019/3/21
** @param token 令牌
** @return long
* *******************************************************
*/
public static long getSignTimeMillis(String token) {
Claim claim = decodeToken(token, jwt -> jwt.getClaim(SING_TIME_MILLIS));
return claim == null ? -1 : claim.asLong();
}
/**
*********************************************************
** @desc :把访问令牌存放到响应的头信息中
** @author Pings
** @date 2019/3/21
** @param response 响应
** @param token 令牌
* *******************************************************
*/
public static void setHttpServletResponse(HttpServletResponse response, String token) {
response.setHeader("Authorization", token);
response.setHeader("Access-Control-Expose-Headers", "Authorization");
}
/**
*********************************************************
** @desc : token解码
** @author Pings
** @date 2019/1/23
** @param token 标记
** @return T
* *******************************************************
*/
private static T decodeToken(String token, Function func) {
try {
DecodedJWT jwt = JWT.decode(token);
return func.apply(jwt);
} catch (JWTDecodeException e) {
return null;
}
}
//**获取jwt加密secret
private static String getSecret(String secret, String userName){
return userName + (StringUtils.isNotBlank(secret) ? secret : DEFAULT_SECRET);
}
}
C.shiro filter的改进
/**
*********************************************************
** @desc : JwtFilter
** @author Pings
** @date 2019/1/23
** @version v1.0
* *******************************************************
*/
public class JwtFilter extends BasicHttpAuthenticationFilter {
@Autowired
private JwtComponent jwtComponent;
/**登录认证*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
//**判断用户是否要登入
if (this.isLoginAttempt(request, response)) {
try {
//**登录认证
return this.executeLogin(request, response);
} catch (Exception e) {
//**访问令牌过期 and 刷新令牌未过期则重新生成访问令牌
try {
if (e.getCause() instanceof TokenExpiredException) {
String userName = JwtUtil.getUserName(this.getAuthzHeader(request));
String token = jwtComponent.sign(userName);
this.executeLogin(token, request, response);
//**修改响应头的访问令牌
JwtUtil.setHttpServletResponse((HttpServletResponse) response, token);
return true;
}
} catch (Exception ex){
ex.printStackTrace();
}
e.printStackTrace();
this.response401(request, response, e.getMessage());
return false;
}
}
return true;
}
/**去掉调用executeLogin,避免循环调用doGetAuthenticationInfo方法*/
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
this.sendChallenge(request, response);
return false;
}
/**检测Header里面是否包含Authorization字段*/
@Override
protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
String token = this.getAuthzHeader(request);
return token != null;
}
/**调用JwtRealm进行登录认证*/
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
return this.executeLogin(this.getAuthzHeader(request), request, response);
}
/**支持跨域*/
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.setHeader("Access-Control-Allow-Credentials", "true");
httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
//**跨域时会首先发送一个OPTIONS请求,返回正常状态
if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
httpServletResponse.setStatus(HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}
/**401时直接返回Response信息*/
private void response401(ServletRequest req, ServletResponse resp, String msg) {
HttpServletResponse httpServletResponse = (HttpServletResponse) resp;
httpServletResponse.setStatus(HttpStatus.UNAUTHORIZED.value());
httpServletResponse.setCharacterEncoding("UTF-8");
httpServletResponse.setContentType("application/json; charset=utf-8");
ApiResponse response = new ApiResponse(HttpStatus.UNAUTHORIZED.value(), "Unauthorized: " + msg, null);
String data = JSONObject.toJSONString(response);
try(PrintWriter out = httpServletResponse.getWriter()) {
out.append(data);
} catch (IOException e) {
throw new RuntimeException(e.getMessage());
}
}
/**调用JwtRealm进行登录认证*/
private boolean executeLogin(String token, ServletRequest request, ServletResponse response) throws Exception {
//**创建JwtToken
JwtToken jwtToken = new JwtToken(token);
//**提交给JwtRealm认证
this.getSubject(request, response).login(jwtToken);
//**没有抛出异常则代表登入成功
return true;
}
}
D.shiro realm的改进
/**
*********************************************************
** @desc : 自定义Realm
** @author Pings
** @date 2019/1/23
** @version v1.0
* *******************************************************
*/
public class JwtRealm extends AuthorizingRealm {
@Reference(version = "${sys.service.version}")
private UserService userService;
@Autowired
private JwtComponent jwtComponent;
/**必须重写此方法,不然Shiro会报错*/
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JwtToken;
}
/**权限验证*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
String userName = JwtUtil.getUserName(principals.toString());
//**获取用户
User user = this.userService.getByUserName(userName);
//**用户角色
Set roles = user.getRoles().stream().map(Role::getCode).collect(toSet());
authorizationInfo.addRoles(roles);
//**用户权限
Set rights = user.getRoles().stream().map(Role::getRights).flatMap(List::stream).map(Right::getCode).collect(toSet());
authorizationInfo.addStringPermissions(rights);
return authorizationInfo;
}
/**登录验证*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
String token = (String) auth.getCredentials();
//**获取用户名称
String userName = JwtUtil.getUserName(token);
//**用户名称为空
if (StringUtils.isBlank(userName)) {
throw new AuthenticationException("The account in Token is empty.");
}
//**获取用户
User user = this.userService.getByUserName(userName);
if (user == null) {
throw new AuthenticationException("The account does not exist.");
}
//**登录认证
if (jwtComponent.verify(token)) {
return new SimpleAuthenticationInfo(token, token, "jwtRealm");
}
throw new AuthenticationException("Username or password error.");
}
/**管理员不验证权限*/
@Override
public boolean isPermitted(PrincipalCollection principal, String permission){
AuthorizationInfo info = this.getAuthorizationInfo(principal);
return info.getRoles().contains("admin") || super.isPermitted(principal, permission);
}
/**管理员不验证角色*/
@Override
public boolean hasRole(PrincipalCollection principal, String roleIdentifier) {
AuthorizationInfo info = this.getAuthorizationInfo(principal);
return info.getRoles().contains("admin") || super.hasRole(principal, roleIdentifier);
}
}
E.配置shrio
/**
*********************************************************
** @desc : Shiro配置
** @author Pings
** @date 2019/1/23
** @version v1.0
* *******************************************************
*/
@Configuration
public class ShiroConfig {
@Bean
public JwtRealm jwtRealm(){
return new JwtRealm();
}
@Bean
@Scope("prototype")
public JwtFilter jwtFilter(){
return new JwtFilter();
}
@Bean("securityManager")
public DefaultWebSecurityManager securityManager(JwtRealm jwtRealm) {
DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
//**使用自定义JwtRealm
manager.setRealm(jwtRealm);
DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
manager.setSubjectDAO(subjectDAO);
return manager;
}
@Bean("shiroFilter")
public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager, JwtFilter jwtFilter) {
ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
//**添加自定义过滤器jwt
Map filterMap = new LinkedHashMap<>();
filterMap.put("jwt", jwtFilter);
factoryBean.setFilters(filterMap);
factoryBean.setSecurityManager(securityManager);
//**自定义url规则
Map filterRuleMap = new LinkedHashMap<>();
//不拦截请求swagger-ui页面请求
filterRuleMap.put("/webjars/**", "anon");
//jwt过滤器拦截请求
filterRuleMap.put("/**", "jwt");
factoryBean.setFilterChainDefinitionMap(filterRuleMap);
return factoryBean;
}
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}
@Bean
public DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator creator = new DefaultAdvisorAutoProxyCreator();
creator.setProxyTargetClass(true);
return creator;
}
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
}
F.LoginController中编写登录方法
/**
*********************************************************
** @desc : 登录
** @author Pings
** @date 2019/1/22
** @param userName 用户名称
** @param password 用户密码
** @return ApiResponse
* *******************************************************
*/
@ApiOperation(value="登录", notes="验证用户名和密码")
@PostMapping(value = "/account")
public ApiResponse account(String userName, String password, HttpServletResponse response){
if(StringUtils.isBlank(userName) || StringUtils.isBlank(password))
throw new UnauthorizedException("用户名/密码不能为空");
//**md5加密
password = DigestUtils.md5DigestAsHex(password.getBytes());
User user = this.userService.getByUserName(userName);
if(user != null && user.getPassword().equals(password)) {
JwtUtil.setHttpServletResponse(response, jwtComponent.sign(userName));
//**用户权限
Set rights = user.getRoles().stream().map(Role::getRights).flatMap(List::stream).map(Right::getCode).collect(toSet());
return new ApiResponse(200, "登录成功", rights);
} else
return new ApiResponse(500, "用户名/密码错误");
}
G.LoginController中编写登出方法
/**
*********************************************************
** @desc : 退出登录
** @author Pings
** @date 2019/3/26
** @return ApiResponse
* *******************************************************
*/
@ApiOperation(value="退出登录", notes="退出登录")
@GetMapping(value = "/logout")
public ApiResponse account(){
//**删除refresh token
this.redisTemplate.delete(this.jwtComponent.getKey(this.getCurrentUserName()));
//**退出登录
SecurityUtils.getSubject().logout();
return new ApiResponse(200, "退出登录成功");
}
4.说明
- dubbo分布式系统权限认证
- 使用redis保存RefreshToken,是为了在每个子系统都可以访问到。某个子系统签发的token即可访问所有其它的子系统
- 存在的问题
- 依赖redis实现多个子系统之间RefreshToken共享,实际和session序列化实现共享效果差不多
- 代码没有封装好,每个子系统都需要一套重复的逻辑代码
- 不能在基于AccessToken的方式和结合RefreshToken、AccessToken的认证方式之间进行切换
- 下一篇把上述两种认证方式封装成一个jar包,并能对两种认证方式进行切换