天天看點

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 [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<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