Shiro是一种简单的安全框架,可以用来处理系统的登录和权限问题。
本篇记录一下Spring Boot和Shiro集成,并使用Jwt Token进行无状态登录的简单例子。
参考Demo地址,此Demo适合用于SpringBoot小型项目的快速开发。
环境
- SpringBoot 版本 1.5.15.RELEASE
不建议使用2.x版本的Springboot,与1.x相比很多地方代码有所改动,很麻烦。 - Shiro 版本 1.4.0
- IntelliJ IDEA
- jjwt 版本 0.9.0
- lombok(可选)精简代码
思路
使用Jwt Token实现无状态登录
平时用户登录后,服务器将会把用户信息存储到Session里,在用户数量很大的时候,服务器负担会很大。而使用token方式登录,服务器不存储用户信息,而是将其加密后生成token发送给请求方,请求方在请求需要权限的资源时,将token带上,服务器解析token即可知道登录用户的信息。服务器自动刷新token
token需要刷新。对于活跃的用户,服务器自动完成刷新token;对于长期不活跃的用户,服务器通过配置的 token有效期 来检查,如果时间超过有效期的两倍,则认为该用户需要重新登录。-
登录流程
- 用户通过账号密码登录
用户登录成功后,服务器将用户信息等集合起来做成Jwt Token(字符串),然后将其放入Response里的header,并发送请求成功的json给请求方。
请求方接收到请求成功的json信息后,从header中拿出jwt token存储起来。 - 用户请求需要验证的资源
请求方将token放入request的header,并发送请求。
服务器收到请求,检查request里的token,首先验证token合法性,不合法返回token不合法的json给请求方。
如果token合法,则检查token是否过期:
如果token签发时间到现在,已经超过了有效期,却没有超过有效期的两倍,则服务器自动生成新token,将其放入response的header,请求方接收到response后,可以检查header里是否有token,有则更新一下token预备下次请求。
如果token从签发时间到现在,已经超过有效期的两倍,则用户需要重新登录。
- 用户通过账号密码登录
集成步骤
注意
- @Slf4j(topic = "xxx")注解是lombok集成的日志模块,可不使用,参考:日志处理方案
数据库建表
思路:
系统里有多个角色,每个角色对于多个权限。每个权限都是一个请求url,验证权限时,后台拿到用户信息后即可知道该用户的角色,而后去数据库查询该角色所拥有的权限集合,在其中查找是否存在当前请求url,存在说明用户有访问该url的权限,否则没有权限
-- Sql
-- Mysql Version 5.7
-- author [email protected]
drop database if exists `rb_demo`;
CREATE DATABASE rb_demo
DEFAULT CHARACTER SET utf8
COLLATE utf8_general_ci;
USE rb_demo;
-- ------------------------------ 用户部分 ------------------------------
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`account` VARCHAR(50) NOT NULL COMMENT '账号,唯一',
`password` VARCHAR(100) NOT NULL COMMENT '密码',
`name` VARCHAR(100) DEFAULT '默认用户名' COMMENT '昵称',
`role_id` BIGINT UNSIGNED NOT NULL COMMENT '所属角色id',
`status` TINYINT UNSIGNED NOT NULL COMMENT '是否启用',
`is_deleted` TINYINT UNSIGNED NOT NULL COMMENT '是否删除',
`version` BIGINT UNSIGNED NOT NULL COMMENT '版本',
`gmt_create` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`gmt_modified` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_id` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用户表';
DROP TABLE IF EXISTS `role`;
CREATE TABLE `role` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` VARCHAR(200) NOT NULL COMMENT '角色名称',
`version` BIGINT UNSIGNED NOT NULL COMMENT '版本',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='角色表';
DROP TABLE IF EXISTS `permission`;
CREATE TABLE `permission` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`role_id` BIGINT UNSIGNED NOT NULL COMMENT '所属角色id',
`name` VARCHAR(200) NOT NULL COMMENT '权限名称',
`url` VARCHAR(200) NOT NULL COMMENT '匹配url',
`version` BIGINT UNSIGNED NOT NULL COMMENT '版本',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='权限表';
建立Springboot项目
组件选择 web、redis和lombok,Springboot版本选择 1.5.15.RELEASE
连接数据库参考:Mybatis-Plus
编写Shiro配置类
ShiroConfig.java 这个配置类主要配置了Shiro拦截器、自定义的Realm和禁用了Session。
禁用Session方法参考代码注释。
为什么要禁用?因为我们采用Jwt Token方式完成登录验证,不需要存用户信息到Session。
package com.spz.demo.security.shiro.config;
import com.spz.demo.security.shiro.filter.ShiroLoginFilter;
import com.spz.demo.security.shiro.matcher.PasswordCredentialsMatcher;
import com.spz.demo.security.shiro.realm.UserRealm;
import com.spz.demo.security.shiro.token.UserAuthenticationToken;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.mgt.DefaultSessionStorageEvaluator;
import org.apache.shiro.mgt.DefaultSubjectDAO;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.realm.Realm;
import org.apache.shiro.session.mgt.DefaultSessionManager;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.apache.shiro.web.mgt.DefaultWebSubjectFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.servlet.Filter;
import java.util.*;
/**
* Shiro 配置
* 禁用 Shiro Session 步骤:
* 1. SubjectContext 在创建的时候,需要关闭 session 的创建,这个由 DefaultWebSubjectFactory.createSubject 管理。
* 参考自定义类:ASubjectFactory.java
* 2. 禁用使用 Sessions 作为存储策略的实现,这个由 securityManager 的 subjectDao.sessionStorageEvaluator 管理
* 3. 禁用掉会话调度器,这个由 sessionManager 管理
*/
@Slf4j(topic = "SYSTEM_LOG")
@Configuration
public class ShiroConfig {
@Autowired
private UserRealm userRealm;
/**
* Shiro 安全管理器
*/
@Bean
public DefaultWebSecurityManager securityManager() {
DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
// 设置自定义的 SubjectFactory
manager.setSubjectFactory(subjectFactory());
// 设置自定义的 SessionManager
manager.setSessionManager(sessionManager());
// 禁用 Session
((DefaultSessionStorageEvaluator)((DefaultSubjectDAO)manager.getSubjectDAO()).getSessionStorageEvaluator())
.setSessionStorageEnabled(false);
// 设置自定义的 Realm
manager.setRealms(getRealms());
return manager;
}
/**
* 设置过滤规则
*/
@Bean
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);
//自定义拦截器 参考 ShiroLoginFilter.java
Map filtersMap = new LinkedHashMap();
filtersMap.put("shiroLoginFilter", new ShiroLoginFilter());//登录验证拦截器
shiroFilterFactoryBean.setFilters(filtersMap);
// 所有请求给这个拦截器处理
Map filterChainDefinitionMap = new LinkedHashMap();
filterChainDefinitionMap.put("/**", "shiroLoginFilter");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
return shiroFilterFactoryBean;
}
/**
* 自定义的 subjectFactory
* 禁用了 Session
* @return
*/
@Bean
public DefaultWebSubjectFactory subjectFactory(){
ASubjectFactory mySubjectFactory = new ASubjectFactory();
return mySubjectFactory;
}
/**
* session管理器
* 禁用了 Session
* sessionManager通过sessionValidationSchedulerEnabled禁用掉会话调度器,
* @return
*/
@Bean
public DefaultSessionManager sessionManager(){
DefaultSessionManager sessionManager = new DefaultSessionManager();
sessionManager.setSessionValidationSchedulerEnabled(false);
return sessionManager;
}
/**
* 配置自定义的 Realm
* @return
*/
@Bean
public Collection getRealms(){
Collection realms = new ArrayList<>();
// 配置自定义 UserRealm
// 由于UserRealm里使用了自动注入,所以这里需要注入Realm而不是new新建
userRealm.setAuthenticationTokenClass(UserAuthenticationToken.class);
userRealm.setCredentialsMatcher(new PasswordCredentialsMatcher());//使用自定义的密码匹配器
realms.add(userRealm);
return realms;
}
}
ASubjectFactory.java 和ShiroConfig配套使用,用于禁用Session。
package com.spz.demo.security.shiro.config;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.mgt.DefaultSessionStorageEvaluator;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.subject.SubjectContext;
import org.apache.shiro.web.mgt.DefaultWebSubjectFactory;
/**
* 自定义的 SubjectFactory
* 禁用Session
* 对于无状态的TOKEN不创建session 这里都不使用session
*/
public class ASubjectFactory extends DefaultWebSubjectFactory {
@Override
public Subject createSubject(SubjectContext context) {
context.setSessionCreationEnabled(Boolean.FALSE);
return super.createSubject(context);
}
}
编写自定义Shiro拦截器
ShiroLoginFilter.java
- Message类是包装返回给请求方的类,需要将Message实例转为json输出到Response输出流,参考:[SpringMVC] Web层返回值包装JSON
- WebUtil.isPublicRequest()方法判断请求是否为公共请求
建议将不需要验证权限的请求设置一个前缀,比如/public/,这样,isPublicRequest方法就可以检查请求url里是否有/public,有则说明是公共请求,直接放行。 - 所有请求(公共请求除外)都给* onAccessDenied*方法处理
在onAccessDenied方法里,通过检查请求url的方式来得知当前请求是什么类型的请求。
如果是登录请求,则直接放行,因为登录逻辑放在了controller层方法。
如果是其他请求,则需要验证登录和权限。 - 检查用户是否具备权限
将请求url和permission表里的url进行匹配,如果存在匹配,则说明有权限。
package com.spz.demo.security.shiro.filter;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.spz.demo.security.bean.Message;
import com.spz.demo.security.common.MessageCode;
import com.spz.demo.security.common.RequestMappingConst;
import com.spz.demo.security.common.WebConst;
import com.spz.demo.security.entity.Role;
import com.spz.demo.security.exception.custom.RoleException;
import com.spz.demo.security.util.CommonUtil;
import com.spz.demo.security.util.JwtUtil;
import com.spz.demo.security.util.WebUtil;
import com.spz.demo.security.vo.JwtToken;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.web.filter.AccessControlFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* 重写shiro拦截器
* 所有请求由此拦截器拦截
*/
@Slf4j(topic = "USER_LOG")
@Component
public class ShiroLoginFilter extends AccessControlFilter {
//由于项目启动时,Shiro加载比其他bean快,所以这里需要加入Lazy注解,在使用时再加载。否则会出现jwtUtil为null的情况
@Autowired
@Lazy
private JwtUtil jwtUtil;
@Override
protected boolean isAccessAllowed(ServletRequest request,ServletResponse response, Object mappedValue) {
// 判断请求是否是公共请求,通过请求的url判断
if(WebUtil.isPublicRequest((HttpServletRequest) request)){
return true;
}
return false;// 拒绝,统一交给 onAccessDenied 处理
}
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = (HttpServletRequest)request;
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
// ========== 判断是否是登录请求,是就放行,登录处理放在了controller层 ==========
if(WebUtil.isLoginRequest(httpServletRequest)){
return true;
}
// ========== 其他请求,都需要验证 ==========
//验证是否登录(检查json token)
if(CommonUtil.isBlank(httpServletRequest.getHeader(WebConst.TOKEN))){
// 返回JSON给请求方
WebUtil.writeStringToResponse(httpServletResponse,JSON.toJSONString(
new Message()
.setErrorMessage("[" + WebConst.TOKEN + "] 不能为空,请将token存入header")
));
return false;
}
String token = httpServletRequest.getHeader(WebConst.TOKEN);
JwtToken jwtToken;
try {
jwtToken = jwtUtil.parseJwt(token);
}catch (RoleException re){//出现异常,说明验证失败
Message message = new Message();
if(re.getMessage().equals(RoleException.MSG_TOKEN_ERROR)){//token错误异常
message.setMessage(MessageCode.TOKEN_ERROR,RoleException.MSG_TOKEN_ERROR);
}else{//token过期异常
message.setMessage(MessageCode.TOKEN_OVERDUE,RoleException.MSG_TOKEN_OVERDUE);
}
WebUtil.writeStringToResponse((HttpServletResponse) response,JSON.toJSONString(message));//返回json
return false;
}
if(jwtToken.getIsFlushed()){//需要刷新token
httpServletResponse.setHeader(WebConst.TOKEN,jwtToken.getToken());// 更新response
}
// 检查用户是否具备权限
if(!jwtToken.hasUrl(((HttpServletRequest) request).getRequestURI())){
WebUtil.writeStringToResponse((HttpServletResponse) response,JSON.toJSONString(
new Message()
.setPermissionDeniedMessage("没有权限")
));
return false;
}else{//登录验证通过
return true;
}
}
}
编写自定义的 Realm 类
- Realm类用来给shiro注入认证信息和授权信息,我们需要自定义。
- @Value("${jwt.salt}")是从application.yml中读取配置
package com.spz.demo.security.shiro.realm;
import com.spz.demo.security.common.DatabaseConst;
import com.spz.demo.security.entity.User;
import com.spz.demo.security.service.UserService;
import com.spz.demo.security.shiro.token.UserAuthenticationToken;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.util.ByteSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
@Slf4j(topic = "USER_LOG")
@Component("userRealm")
public class UserRealm extends AuthorizingRealm{
@Autowired
private UserService userService;
@Value("${jwt.salt}")
private String jwtSalt;
private static final String DEFAULT_JWT_SALT = "asdfh2738yWsdjDfha";//默认的盐
/**
* 授权处理
* 不使用
* @param principals
* @return
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
return null;
}
/**
* 身份认证
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
// 获取用户
String account = (String) authenticationToken.getPrincipal();//这里的user里只有账号和未加密的密码
User user = userService.getUserByAccount(
account,
DatabaseConst.STATUS_ENABLE,
DatabaseConst.IS_DETETED_NO);
if (user == null) {
return null;
}else{
//这里这样做是因为我需要在web层可以拿到userID
((UserAuthenticationToken)authenticationToken).setUserId(user.getId());//赋值userId
}
return new SimpleAuthenticationInfo(
user,
user.getPassword().toCharArray(),
ByteSource.Util.bytes((jwtSalt == null ? DEFAULT_JWT_SALT: jwtSalt)),//盐
getName()
);
}
}
编写自定义的 Matcher 类
- AuthenticatingRealm使用CredentialsMatcher进行密码匹配,我们需要自定义
package com.spz.demo.security.shiro.matcher;
import com.spz.demo.security.entity.User;
import com.spz.demo.security.shiro.token.UserAuthenticationToken;
import com.spz.demo.security.util.CommonUtil;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.credential.CredentialsMatcher;
/**
* 改写原有的密码匹配器
* 用于账号密码登录时的账密匹配
*/
public class PasswordCredentialsMatcher implements CredentialsMatcher {
@Override
public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
//账号密码登录,则token应该是自定义的 AccountPasswordAuthenticationToken
if(token instanceof UserAuthenticationToken){
//这里检查账号和密码是否匹配
//token是登录接口那里获取的,info是通过account获取到数据里的信息
//密码需要进行md5处理,因为数据库存储的密码为密文
if(info.getPrincipals().getPrimaryPrincipal() instanceof User){
User user = (User)info.getPrincipals().getPrimaryPrincipal();
if(token.getPrincipal().equals(user.getAccount()) &&
CommonUtil.md5((String) token.getCredentials()).equals(user.getPassword())){
return true;
}
}
}
return false;
}
}
编写自定义的AuthenticationToken类
package com.spz.demo.security.shiro.token;
import com.spz.demo.security.entity.User;
import lombok.Data;
import org.apache.shiro.authc.AuthenticationToken;
/**
* 用于登录
* 登录时给此类的account和password(明文)赋值
* 然后在UserRealm里将查询到的userId赋值给此类里的userId。controller层需要id
*/
@Data
public class UserAuthenticationToken implements AuthenticationToken {
private Long userId;//用户在数据库中的id
private String account;
private String password;
public UserAuthenticationToken(String account, String password){
this.account = account;
this.password = password;
}
/**
* 返回 account
* @return
*/
@Override
public Object getPrincipal() {
return this.account;
}
/**
* 返回 password
* @return
*/
@Override
public Object getCredentials() {
return this.password;
}
}
编写Jwt Token工具类
package com.spz.demo.security.util;
import com.alibaba.fastjson.JSONObject;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.spz.demo.security.exception.custom.RoleException;
import com.spz.demo.security.vo.JwtToken;
import io.jsonwebtoken.*;
import io.jsonwebtoken.impl.DefaultHeader;
import io.jsonwebtoken.impl.DefaultJwsHeader;
import io.jsonwebtoken.impl.TextCodec;
import io.jsonwebtoken.impl.compression.DefaultCompressionCodecResolver;
import io.jsonwebtoken.lang.Assert;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import sun.java2d.pipe.AlphaPaintPipe;
import javax.swing.event.CaretListener;
import javax.xml.bind.DatatypeConverter;
import java.io.IOException;
import java.util.*;
/**
* jwt 工具类
*
* @author zp
*/
@Slf4j(topic = "SYSTEM_LOG")
@Component
public class JwtUtil {
@Value("${jwt.appKey}")
private String appKey;//app key,用于加密
@Value("${jwt.period}")
private Long period;//token有效时间
@Value(("${jwt.issuer}"))
private String issuer;//jwt token 签发人
public static final long DEFAULT_PERIOD = 60*60*1000;//token默认有效时间,1小时
public static final String DEFAULT_APPKEY = "defaultAppKey";//默认appkey,配置文件里读不到appKey时用此值
public static final String DEFAULT_ISSUER = "Server-System-2333";//默认签发人
private static final ObjectMapper MAPPER = new ObjectMapper();
private static CompressionCodecResolver codecResolver = new DefaultCompressionCodecResolver();
/**
* 签发 JWT Token Token
* @param id 令牌ID
* @param subject subject 用户ID
* @param issuer 签发人,自定义
* @param roles 角色
* @param permissions 权限集合,建议传入权限集合的json字符串
* @param period 有效时间(ms)
* 1. 在 当前时间-签发时间>有效时间 时携带token访问接口,会重新刷新token
* 在 当前时间-签发时间>有效时间*2 时,则需要重新登录。
* 2. 这样可以分离长时间不活跃的用户和活跃用户
* 活跃用户感受不到token的刷新
* 不活跃用户需要登录才可以重新获取token
* @param algorithm 加密算法
* @return
*/
public String issueJWT(String id,
String subject,
String issuer,
String roles,
String permissions,
Long period,
SignatureAlgorithm algorithm) {
// 需要读取appKey
if(appKey == null || appKey.equals("")){
log.error("appKey无法读取:" + appKey);
appKey = DEFAULT_APPKEY;
}
byte[] secreKeyBytes = DatatypeConverter.parseBase64Binary(appKey);// 秘钥
JwtBuilder jwtBuilder = Jwts.builder();
if (!StringUtils.isEmpty(id)) {
jwtBuilder.setId(id);
}
if (!StringUtils.isEmpty(subject)) {
jwtBuilder.setSubject(subject);
}
if (!StringUtils.isEmpty(issuer)) {
jwtBuilder.setIssuer(issuer);
}
// 设置签发时间
Date now = new Date();
jwtBuilder.setIssuedAt(now);
// 设置到期时间
if (null != period) {
jwtBuilder.setExpiration(
new Date(now.getTime() + period + period)//签发时间+有效期*2
);
}
if (!StringUtils.isEmpty(roles)) {
jwtBuilder.claim("roles",roles);
}
if (!StringUtils.isEmpty(permissions)) {
jwtBuilder.claim("perms",permissions);
}
// 压缩,可选GZIP
jwtBuilder.compressWith(CompressionCodecs.DEFLATE);
// 加密设置
jwtBuilder.signWith(algorithm,secreKeyBytes);
return jwtBuilder.compact();
}
/**
* 验签JWT
*
* @param jwt json web token
* @return 如果验证通过,且刷新了token,则设置 JwtToken.isFlushed 为true
*/
public JwtToken parseJwt(String jwt) throws RoleException {
if(appKey == null || appKey.equals("")){
log.error("appKey无法读取:" + appKey);
appKey = DEFAULT_APPKEY;
}
// 检查 jwt token 合法性
Claims claims;
try{
claims = Jwts.parser()
.setSigningKey(DatatypeConverter.parseBase64Binary(appKey))
.parseClaimsJws(jwt)
.getBody();
}catch (ExpiredJwtException ex){//token过期异常 token已经失效需要重新登录
throw new RoleException(RoleException.MSG_TOKEN_OVERDUE);
}catch (SignatureException | UnsupportedJwtException | MalformedJwtException | IllegalArgumentException e){//不支持的token
throw new RoleException(RoleException.MSG_TOKEN_ERROR);
}catch (Exception e){
log.error("验证token时出现未知错误: " + CommonUtil.getDetailExceptionMsg(e));
throw new RoleException(RoleException.MSG_UNKNOWN_ERROR);
}
JwtToken jwtToken = new JwtToken();
// 检查是否需要刷新 jwt token
long time = claims.getIssuedAt().getTime();//token签发时间
long now = new Date().getTime();//当前时间
period = (period == null ? JwtUtil.DEFAULT_PERIOD : period);
if(time + period >= now){//还在有效期内,不需要刷新token
// log.info("不需要刷新token");
jwtToken.setToken(jwt);
jwtToken.setIsFlushed(false);
}else if(time + period < now &&//超过有效期,但未超过2倍有效期,此时应该刷新token
time + period + period >= now){
// log.info("刷新token");
jwtToken.setToken(issueJWT(// 制作JWT Token
CommonUtil.getRandomString(20),//令牌id
claims.getSubject(),//用户id
(issuer == null ? DEFAULT_ISSUER : issuer),//签发人
claims.get("roles", String.class),//访问角色,设置为null,不使用
claims.get("perms", String.class),//权限集合字符串,json
period,//token有效时间*2
SignatureAlgorithm.HS512
));
jwtToken.setIsFlushed(true);
}else{
log.error("未知错误 - Jwts.parser() 方法未对过期token抛出异常");
}
// 设置其他字段
jwtToken.setId(claims.getSubject());//用户id
jwtToken.setPermissions(
JSONObject.parseObject(
claims.get("perms", String.class),
List.class
)
);//用户权限集合,json转为list集合
return jwtToken;
}
/* *
* @Description
* @Param [val] 从json数据中读取格式化map
* @Return java.util.Map
*/
@SuppressWarnings("unchecked")
public static Map readValue(String val) {
try {
return MAPPER.readValue(val, Map.class);
} catch (IOException e) {
throw new MalformedJwtException("Unable to read JSON value: " + val, e);
}
}
}
controller登录验证
package com.spz.demo.security.controller;
import com.alibaba.fastjson.JSONArray;
import com.spz.demo.security.bean.Message;
import com.spz.demo.security.common.MessageKeyConst;
import com.spz.demo.security.common.RedisConst;
import com.spz.demo.security.common.RequestMappingConst;
import com.spz.demo.security.common.WebConst;
import com.spz.demo.security.entity.Permission;
import com.spz.demo.security.entity.User;
import com.spz.demo.security.service.UserService;
import com.spz.demo.security.shiro.token.UserAuthenticationToken;
import com.spz.demo.security.util.CommonUtil;
import com.spz.demo.security.util.JwtUtil;
import com.spz.demo.security.util.RedisUtil;
import com.spz.demo.security.util.WebUtil;
import com.spz.demo.security.vo.JwtToken;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.subject.Subject;
import org.hibernate.validator.constraints.NotEmpty;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
@Slf4j(topic = "USER_LOG")
@RestController
public class UserController {
@Value("${jwt.period}")
private Long period;//token有效时间(毫秒)
@Value(("${jwt.issuer}"))
private String issuer;//jwt token 签发人
@Autowired
private JwtUtil jwtUtil;
@Autowired
private UserService userService;
/**
* 用户登录
* 验证码校验和请求参数校验功能已去除,完整版参考Demo
* @return
*/
@PostMapping(value = RequestMappingConst.LOGIN)
public Message login(String account,String password,HttpServletRequest request,HttpServletResponse response)throws Exception{
// 使用 Shiro 进行登录
Subject subject = SecurityUtils.getSubject();
UserAuthenticationToken token = new UserAuthenticationToken(account,password);
subject.login(token);
// 登录成功后,获取userid,查询该用户拥有的权限
List permissions = userService.getUserPermissions(token.getUserId());
// 制作JWT Token
String jwtToken = jwtUtil.issueJWT(
CommonUtil.getRandomString(20),//令牌id,必须为整个系统唯一id
token.getUserId() + "",//用户id
(issuer == null ? JwtUtil.DEFAULT_ISSUER : issuer),//签发人,可随便定义
null,//访问角色
JSONArray.toJSONString(permissions),//用户权限集合,json格式
(period == null ? JwtUtil.DEFAULT_PERIOD : period),//token有效时间
SignatureAlgorithm.HS512//签名算法,我也不知道是啥来的
);
//token存入 response里的Header
response.setHeader(WebConst.TOKEN,jwtToken);
// 返回Message的json
Message message = new Message().setSuccessMessage("登录成功,token已存入header");
message.getData().put("account",account);
message.getData().put(MessageKeyConst.LOGIN_TIME,new Date().getTime());
log.info("用户登录成功 ip=" + WebUtil.getIpAdrress(request));
return message;
}
}
POM文件参考
4.0.0
com.spz.demo
security
0.0.1-SNAPSHOT
jar
security
登录和权限demo,适用于小项目
org.springframework.boot
spring-boot-starter-parent
1.5.15.RELEASE
UTF-8
UTF-8
1.8
1.2.38
2.2.0
com.alibaba
fastjson
${fastjson.version}
com.baomidou
mybatis-plus-boot-starter
${mybatisplus.version}
org.apache.velocity
velocity
1.7
org.freemarker
freemarker
2.3.28
org.springframework.boot
spring-boot-starter-data-redis
com.github.axet
kaptcha
0.0.9
commons-codec
commons-codec
1.11
com.fasterxml.jackson.core
jackson-databind
2.7.4
org.projectlombok
lombok
1.18.0
provided
io.jsonwebtoken
jjwt
0.9.0
org.apache.shiro
shiro-spring-boot-starter
1.4.0
mysql
mysql-connector-java
5.1.47
org.springframework.boot
spring-boot-starter-aop
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-test
test
org.springframework.boot
spring-boot-maven-plugin
application.yml参考
spring:
# AOP Config
aop:
auto: true
redis:
host: 127.0.0.1
password:
port: 6379
database: 0
datasource:
url: jdbc:mysql://xxx.xx.xx.xxx:3306/rb_demo?useUnicode=true&characterEncoding=UTF-8
username: root
password:
driver-class-name: com.mysql.jdbc.Driver
# Jwt Token相关配置
jwt:
appKey: ds[W&dsfa:dfhu12a%W@ // app秘钥,随便定义即可
appId: 210293ajkw723o@7eh*db //appId,随便定义即可
period: 120000 # 有效期,单位ms
issuer: Server-System # 签发者,用于制作 jwt token
salt: salt-sdwbhx23i # 盐,随便定义即可, view UserRealm.doGetAuthenticationInfo()
# Mybatis-Plus 配置,请参考官方文档
mybatis-plus:
mapper-locations: classpath:/mapper/*Mapper.xml
typeAliasesPackage: com.spz.demo.security.entity
global-config:
id-type: 2
field-strategy: 0
db-column-underline: true
refresh-mapper: true
configuration:
map-underscore-to-camel-case: true
cache-enabled: true
工具类参考
- 通用工具类
package com.spz.demo.security.util;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import java.security.MessageDigest;
import java.util.HashSet;
import java.util.Random;
import java.util.Set;
/* *
* @Author tomsun28
* @Description 高频方法工具类
* @Date 14:08 2018/3/12
*/
@Slf4j(topic = "SYSTEM_LOG")
public class CommonUtil {
/**
* 获取指定位数的随机数
* @param length
* @return
*/
public static String getRandomString(int length) {
String base = "abcdefghijklmnopqrstuvwxyz0123456789";
Random random = new Random();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < length; i++) {
int number = random.nextInt(base.length());
sb.append(base.charAt(number));
}
return sb.toString();
}
/**
* MD5加密
* @param content
* @return
*/
public static String md5(String content) {
// 用于加密的字符
char[] md5String = {'0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F'};
try {
// 使用平台默认的字符集将md5String编码为byte序列,并将结果存储到一个新的byte数组中
byte[] byteInput = content.getBytes();
// 信息摘要是安全的单向哈希函数,它接收任意大小的数据,并输出固定长度的哈希值
MessageDigest mdInst = MessageDigest.getInstance("MD5");
// MessageDigest对象通过使用update方法处理数据,使用指定的byte数组更新摘要
mdInst.update(byteInput);
//摘要更新后通过调用digest() 执行哈希计算,获得密文
byte[] md = mdInst.digest();
//把密文转换成16进制的字符串形式
int j = md.length;
char[] str = new char[j*2];
int k = 0;
for (int i=0;i>> 4 & 0xf];
str[k++] = md5String[byte0 & 0xf];
}
// 返回加密后的字符串
return new String(str);
}catch (Exception e) {
log.error("加密出现错误:" + e.toString());
return null;
}
}
/**
* 分割字符串进SET
*/
@SuppressWarnings("unchecked")
public static Set split(String str) {
Set set = new HashSet<>();
if (StringUtils.isEmpty(str))
return set;
set.addAll(CollectionUtils.arrayToList(str.split(",")));
return set;
}
/**
* 检查字符串是否为空
* @param str
* @return
*/
public static boolean isBlank(String str){
return (str == null || str.equals("") ? true : false);
}
}
- Web请求工具类
package com.spz.demo.security.util;
import com.spz.demo.security.common.RedisConst;
import com.spz.demo.security.common.RequestMappingConst;
import org.apache.commons.lang.StringUtils;
import javax.servlet.ServletRequest;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;
public class WebUtil {
/**
* 检查url是否需要登录验证
* @param url
* @return false 不需要登录即可访问
* true 需要登录才可以访问
*/
public static boolean needLogin(String url){
if(url.indexOf(RequestMappingConst.V_CODE) >= 0 || //验证码
url.indexOf(RequestMappingConst.LOGIN) >= 0){//登录
return false;
}
return true;
}
/**
* 获取Ip地址
* @param request
* @return
*/
public static String getIpAdrress(HttpServletRequest request) {
String Xip = request.getHeader("X-Real-IP");
String XFor = request.getHeader("X-Forwarded-For");
if (StringUtils.isNotEmpty(XFor) && !"unKnown".equalsIgnoreCase(XFor)) {
//多次反向代理后会有多个ip值,第一个ip才是真实ip
int index = XFor.indexOf(",");
if (index != -1) {
return XFor.substring(0,index);
} else {
return XFor;
}
}
XFor = Xip;
if (StringUtils.isNotEmpty(XFor) && !"unKnown".equalsIgnoreCase(XFor)) {
return XFor;
}
if (StringUtils.isBlank(XFor) || "unknown".equalsIgnoreCase(XFor)) {
XFor = request.getHeader("Proxy-Client-IP");
}
if (StringUtils.isBlank(XFor) || "unknown".equalsIgnoreCase(XFor)) {
XFor = request.getHeader("WL-Proxy-Client-IP");
}
if (StringUtils.isBlank(XFor) || "unknown".equalsIgnoreCase(XFor)) {
XFor = request.getHeader("HTTP_CLIENT_IP");
}
if (StringUtils.isBlank(XFor) || "unknown".equalsIgnoreCase(XFor)) {
XFor = request.getHeader("HTTP_X_FORWARDED_FOR");
}
if (StringUtils.isBlank(XFor) || "unknown".equalsIgnoreCase(XFor)) {
XFor = request.getRemoteAddr();
}
return XFor;
}
/**
* 检查请求是否为登录请求
* @param request
* @return
*/
public static boolean isLoginRequest(HttpServletRequest request) {
if(request.getRequestURI().indexOf(RequestMappingConst.LOGIN) >= 0){
return true;
}
return false;
}
/**
* 检查请求是否为注销请求
* @param request
* @return
*/
public static boolean isLogoutRequest(HttpServletRequest request) {
if(request.getRequestURI().indexOf(RequestMappingConst.LOGOUT) >= 0){
return true;
}
return false;
}
/**
* 检查请求是否为公共请求
* @param request
* @return
*/
public static boolean isPublicRequest(HttpServletRequest request) {
if(request.getRequestURI().indexOf(RequestMappingConst.BASIC_URL_PUBLIC) >= 0){
return true;
}
return false;
}
/**
* 输出json字符串到 HttpServletResponse
* @param response
* @param str : 字符串
*/
public static void writeJSONToResponse(HttpServletResponse response, String str){
PrintWriter jsonOut = null;
response.setContentType("application/json;charset=UTF-8");
try {
jsonOut = response.getWriter();
jsonOut.write(str);
}catch (Exception e){
e.printStackTrace();
}finally{
if(jsonOut != null){
jsonOut.close();
}
}
}
}
参考文章
签发的用户认证token超时刷新策略
shiro实现手机验证码登录
SpringBoot 集成无状态的 Shiro