Shiro和SpringBoot简单集成

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(可选)精简代码

思路

  1. 使用Jwt Token实现无状态登录
    平时用户登录后,服务器将会把用户信息存储到Session里,在用户数量很大的时候,服务器负担会很大。而使用token方式登录,服务器不存储用户信息,而是将其加密后生成token发送给请求方,请求方在请求需要权限的资源时,将token带上,服务器解析token即可知道登录用户的信息。

  2. 服务器自动刷新token
    token需要刷新。对于活跃的用户,服务器自动完成刷新token;对于长期不活跃的用户,服务器通过配置的 token有效期 来检查,如果时间超过有效期的两倍,则认为该用户需要重新登录。

  3. 登录流程

    • 用户通过账号密码登录
      用户登录成功后,服务器将用户信息等集合起来做成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 1802226517@qq.com

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<String, Filter> filtersMap = new LinkedHashMap<String, Filter>();
        filtersMap.put("shiroLoginFilter", new ShiroLoginFilter());//登录验证拦截器
        shiroFilterFactoryBean.setFilters(filtersMap);

        // 所有请求给这个拦截器处理
        Map<String,String> filterChainDefinitionMap = new LinkedHashMap<String,String>();
        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<Realm> getRealms(){
        Collection<Realm> 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<java.lang.String,java.lang.Object>
     */
    @SuppressWarnings("unchecked")
    public static Map<String, Object> 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<String> 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文件参考
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.spz.demo</groupId>
    <artifactId>security</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <packaging>jar</packaging>

    <name>security</name>
    <description>登录和权限demo,适用于小项目</description>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>1.5.15.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <java.version>1.8</java.version>
        <fastjson.version>1.2.38</fastjson.version>
        <mybatisplus.version>2.2.0</mybatisplus.version>
    </properties>

    <dependencies>

        <!--json-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>${fastjson.version}</version>
        </dependency>

        <!-- Mybatis Plus -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>${mybatisplus.version}</version>
        </dependency>

        <!-- Mybatis 代码生成器(模板引擎) -->
        <dependency>
            <groupId>org.apache.velocity</groupId>
            <artifactId>velocity</artifactId>
            <version>1.7</version>
        </dependency>
        <dependency>
            <groupId>org.freemarker</groupId>
            <artifactId>freemarker</artifactId>
            <version>2.3.28</version>
        </dependency>

        <!-- redis -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <!-- Kaptcha验证码框架 -->
        <dependency>
            <groupId>com.github.axet</groupId>
            <artifactId>kaptcha</artifactId>
            <version>0.0.9</version>
        </dependency>

        <!-- apache -->
        <dependency>
            <groupId>commons-codec</groupId>
            <artifactId>commons-codec</artifactId>
            <version>1.11</version>
        </dependency>

        <!-- json 用于web层包装请求返回-->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>2.7.4</version>
        </dependency>


        <!-- lombok 精简代码用 -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.0</version>
            <scope>provided</scope>
        </dependency>

        <!-- Jwt -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.0</version>
        </dependency>

        <!-- shiro -->
        <dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-spring-boot-starter</artifactId>
            <version>1.4.0</version>
        </dependency>

        <!-- Mysql -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>5.1.47</version>
        </dependency>

        <!-- AOP -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>


</project>
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<j;i++) {
                byte byte0 = md[i];
                str[k++] = md5String[byte0 >>> 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<String> split(String str) {

        Set<String> 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

上一篇:在Shiro框架内使用缓存注解失效的解决办法


下一篇:使用docker快速搭建服务器环境