SpringBoot集成Shiro、JWT 進行請求認證和鑑權

什麼是JWT?

JSON Web Token(JWT)是一個開放標準(RFC 7519),它定義了一種緊湊且獨立的方式,可以在各方之間作爲JSON對象安全地傳輸信息。此信息可以通過數字簽名進行驗證和信任。JWT可以使用祕密(使用HMAC算法)或使用RSA或ECDSA的公鑰/私鑰對進行簽名。
雖然JWT可以加密以在各方之間提供保密,但我們將專注於簽名令牌。簽名令牌可以驗證其中包含的聲明的完整性,而加密令牌則隱藏其他方的聲明。當使用公鑰/私鑰對簽署令牌時,簽名還證明只有持有私鑰的一方是簽署私鑰的一方。

使用場景

特別適用於分佈式站點的單點登錄(SSO)場景。JWT的聲明一般被用來在身份提供者和服務提供者間傳遞被認證的用戶身份信息, 以便於從資源服務器獲取資源,也可以增加一些額外的其它業務邏輯所必須的聲明信息,該token也可直接被用於認證,也可被加密。
JWT是由三段信息構成的,將這三段信息文本用.鏈接一起就構成了Jwt字符串。
格式如下:

xxxxx.yyyyy.zzzzz

就像這樣:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

JWT的構成

第一部分我們稱它爲頭部(header),第二部分我們稱其爲載荷(payload, 類似於飛機上承載的物品),第三部分是簽證(signature)。

header 頭部

標頭通常由兩部分組成:令牌的類型,即JWT,以及正在使用的簽名算法,例如HMAC SHA256或RSA。

這裏的加密算法是單向函數散列算法,常見的有MD5、SHA、HAMC。這裏使用基於密鑰的Hash算法HMAC生成散列值。

MD5 message-digest algorithm 5 (信息-摘要算法)縮寫,廣泛用於加密和解密技術,常用於文件校驗。校驗?不管文件多大,經過MD5後都能生成唯一的MD5值
SHA (Secure Hash Algorithm,安全散列算法),數字簽名等密碼學應用中重要的工具,安全性高於MD5。

HMAC (Hash Message Authentication Code,散列消息鑑別碼,基於密鑰的Hash算法的認證協議。用公開函數和密鑰產生一個固定長度的值作爲認證標識,用這個標識鑑別消息的完整性。常用於接口簽名驗證
完整的頭部就像下面這樣的JSON:

{
  'typ': 'JWT',
  'alg': 'HS256'
}

然後將頭部進行base64加密(該加密是可以對稱解密的),構成了第一部分

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
playload

payload 載荷

令牌的第二部分是有效負載,其中包含聲明。聲明是關於實體(通常是用戶)和其他數據的聲明。聲明有三種類型:註冊,公開和私人。
載荷就是存放有效信息的地方,這些有效信息包含三個部分:

標準中註冊的聲明
公共的聲明
私有的聲明

標準中註冊的聲明 (建議但不強制使用) :

iss: jwt簽發者
sub: jwt所面向的用戶
aud: 接收jwt的一方
exp: jwt的過期時間,這個過期時間必須要大於簽發時間
nbf: 定義在什麼時間之前,該jwt都是不可用的.
iat: jwt的簽發時間
jti: jwt的唯一身份標識,主要用來作爲一次性token,從而回避重放攻擊。

公共的聲明:
公共的聲明可以添加任何的信息,一般添加用戶的相關信息或其他業務需要的必要信息.但不建議添加敏感信息,因爲該部分在客戶端可解密

私有聲明是提供者和消費者所共同定義的聲明,一般不建議存放敏感信息,因爲base64是對稱解密的,意味着該部分信息可以歸類爲明文信息。

定義一個payload:

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

然後將其進行base64加密,得到Jwt的第二部分:

eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9

請注意,對於簽名令牌,此信息雖然可以防止被篡改,但任何人都可以讀取。除非加密,否則不要將祕密信息放在JWT的有效負載或頭元素中。

signature 簽名

要創建簽名部分,您必須採用編碼標頭,編碼的有效負載,鹽值,標頭中指定的算法,並對其進行簽名。
這個簽證信息由三部分組成:

header (base64後的)  
payload (base64後的)  
secret  

這個部分需要base64加密後的header和base64加密後的payload使用.連接組成的字符串, 然後通過header中聲明的加密方式進行加鹽secret組合加密,然後就構成了jwt的第三部分。
例如,如果要使用HMAC SHA256算法,將按以下方式創建簽名:

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)

javascript例子如下:

// javascript
var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload);
var signature = HMACSHA256(encodedString, 'secret'); // TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

將這三部分用.連接成一個完整的字符串,構成了最終的jwt:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

注意:secret是保存在服務器端的,jwt的簽發生成也是在服務器端的,secret就是用來進行jwt的簽發和jwt的驗證, 所以,它就是你服務端的私鑰,在任何場景都不應該流露出去。一旦客戶端得知這個secret, 那就意味着客戶端是可以自我簽發jwt了。

與SpringBoot shiro集成

  1. 導入所需jar包
 compile 'com.auth0:java-jwt:3.4.0'
compile 'org.apache.shiro:shiro-spring:1.4.0'
compile 'org.springframework.boot:spring-boot-starter-aop:2.0.4.RELEASE'
compile group: 'com.alibaba', name: 'druid', version: '1.1.10'
compile group: 'mysql', name: 'mysql-connector-java', version: '5.1.46'
compile group: 'org.mybatis.spring.boot', name: 'mybatis-spring-boot-starter', version: '1.3.2'
compile group: 'org.springframework.boot', name: 'spring-boot-devtools', version: '2.0.4.RELEASE'

使用Mybatis+Shiro做權限驗證,這裏有一個坑要注意下,AOP jar包一定要導入,不然驗證權限註解將失效。
導致不會進入doGetAuthorizationInfo()方法。

  1. 實現JWT 請求驗證
    主要思路

首先用戶登錄成功後,利用官方的JWT包,配置生成並返回一段token,接着配置JWT的檢驗token過濾器,讓請求都需要驗證是否加上此token在請求頭上。
沒有則會跳到無授權。

利用JWT包,構造生成TOKEN和檢驗token的方法。

public class JWTUtil {
    /**
     * 過期時間 24 小時
     */
    private static final long EXPIRE_TIME = 60 * 24 * 60 * 1000;
    /**
     * 密鑰,注意這裏如果真實用到,應當設置到複雜點,相當於私鑰的存在。如果被人拿到,想到於它可以自己製造token了。
     */
    private static final String SECRET = "LIAODASHUAI";

    /**
     * 生成 token, 5min後過期
     *
     * @param username 用戶名
     * @return 加密的token string
     */
    public static String createToken(String username) {
        Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
        Algorithm algorithm = Algorithm.HMAC256(SECRET);
        // 附帶username信息
        return JWT.create()
                .withClaim("username", username)
                //到期時間
                .withExpiresAt(date)
                //創建一個新的JWT,並使用給定的算法進行標記
                .sign(algorithm);
    }

    /**
     * 校驗 token 是否正確
     *
     * @param token    密鑰
     * @param username 用戶名
     * @return 是否正確 boolean
     */
    public static boolean verify(String token, String username) {
        try {
            Algorithm algorithm = Algorithm.HMAC256(SECRET);
            //在token中附帶了username信息
            JWTVerifier verifier = JWT.require(algorithm)
                    .withClaim("username", username)
                    .build();
            //驗證 token
            verifier.verify(token);
            return true;
        } catch (Exception exception) {
            return false;
        }
    }

    /**
     * 獲得token中的信息,無需secret解密也能獲得
     *
     * @param token the token
     * @return token中包含的用戶名 username
     */
    public static String getUsername(String token) {
        try {
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim("username").asString();
        } catch (JWTDecodeException e) {
            return null;
        }
    }
}

重新實現AuthenticationToken類,讓其存放token,便於校驗。

public class JWTToken implements AuthenticationToken {
    private String token;

    /**
     * Instantiates a new Jwt token.
     *
     * @param token the token
     */
    public JWTToken(String token) {
        this.token = token;
    }

    @Override
    public Object getPrincipal() {
        return token;
    }

    @Override
    public Object getCredentials() {
        return token;
    }
}

接着到了關鍵的JWT過濾器實現,此過濾器繼承實現了BasicHttpAuthenticationFilter的部分方法。
主要作用是:

檢驗請求頭是否帶有 token,req.getHeader("token")!=null
如果帶有 token,執行 shiro 的 login() 方法,將 token 提交到 Realm 中進行檢驗;如果沒有 token,說明當前狀態爲遊客狀態(或者其他一些不需要進行認證的接口)
如果在 token 校驗的過程中出現錯誤,如 token 校驗失敗,那麼我會將該請求視爲認證不通過,則重定向到 /unauthorized/**
public class JWTFilter extends BasicHttpAuthenticationFilter {
    private Logger logger = LoggerFactory.getLogger(this.getClass());

    /**
     * 如果帶有 token,則對 token 進行檢查,否則直接通過
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws UnauthorizedException {
        //判斷請求的請求頭是否帶上 "token"
        if (isLoginAttempt(request, response)) {
            //如果存在,則進入 executeLogin 方法執行登入,檢查 token 是否正確
            try {
                executeLogin(request, response);
                return true;
            } catch (Exception e) {
                //token 錯誤
                responseError(response, e.getMessage());
            }
        }
        //如果請求頭不存在 Token,則可能是執行登陸操作或者是遊客狀態訪問,無需檢查 token,直接返回 true
        return true;
    }

    /**
     * 判斷用戶是否想要登入。
     * 檢測 header 裏面是否包含 Token 字段
     */
    @Override
    protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
        HttpServletRequest req = (HttpServletRequest) request;
        String token = req.getHeader("token");
        return token != null;
    }

    /**
     * 執行登陸操作
     */
    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String token = httpServletRequest.getHeader("token");
        JWTToken jwtToken = new JWTToken(token);
        // 提交給realm進行登入,如果錯誤他會拋出異常並被捕獲
        getSubject(request, response).login(jwtToken);
        // 如果沒有拋出異常則代表登入成功,返回true
        return true;
    }

    /**
     * 對跨域提供支持
     */
    @Override
    protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        HttpServletResponse httpServletResponse = (HttpServletResponse) response;
        httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
        httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
        httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
        // 跨域時會首先發送一個option請求,這裏我們給option請求直接返回正常狀態
        if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
            httpServletResponse.setStatus(HttpStatus.OK.value());
            return false;
        }
        return super.preHandle(request, response);
    }

    /**
     * 將非法請求跳轉到 /unauthorized/**
     */
    private void responseError(ServletResponse response, String message) {
        try {
            HttpServletResponse httpServletResponse = (HttpServletResponse) response;
            //設置編碼,否則中文字符在重定向時會變爲空字符串
            message = URLEncoder.encode(message, "UTF-8");
            httpServletResponse.sendRedirect("/unauthorized/" + message);
        } catch (IOException e) {
            logger.error(e.getMessage());
        }
    }
}

繼承AuthorizingRealm,實現用戶授權的驗證和權限的驗證

public class CustomRealm extends AuthorizingRealm {
    @Autowired
    UserInfoMapper userInfoMapper;
    @Autowired
    RoleMapper roleMapper;

    /**
     * 必須重寫此方法,不然會報錯
     */
    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof JWTToken;
    }

    /**
     * 默認使用此方法進行用戶名正確與否驗證,錯誤拋出異常即可。
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        System.out.println("————身份認證方法————");
        String token = (String) authenticationToken.getCredentials();
        // 解密獲得username,用於和數據庫進行對比
        String username = JWTUtil.getUsername(token);
        if (username == null || !JWTUtil.verify(token, username)) {
            throw new AuthenticationException("token認證失敗!");
        }
        UserInfo userInfo = userInfoMapper.selectByName(username);
        if (userInfo == null) {
            throw new AuthenticationException("該用戶不存在!");
        }
        if (userInfo.getState() == 1) {
            throw new AuthenticationException("該用戶已被封號!");
        }
        return new SimpleAuthenticationInfo(token, token, "MyRealm");
    }

    /**
     * 只有當需要檢測用戶權限的時候纔會調用此方法,例如checkRole,checkPermission之類的
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        System.out.println("————權限認證————");
        String username = JWTUtil.getUsername(principals.toString());
        SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
        // 此處最好使用緩存提升速度
        UserInfo userInfo = userInfoMapper.selectByName(username);
        userInfo = userInfoMapper.selectUserOfRole(userInfo.getUid());
        if (userInfo == null || userInfo.getRoleList().isEmpty()) {
            return authorizationInfo;
        }
        for (Role role : userInfo.getRoleList()) {
            authorizationInfo.addRole(role.getRole());
            role = roleMapper.selectRoleOfPerm(role.getId());
            if (role == null || role.getPermissions().isEmpty()) {
                continue;
            }
            for (Permission p : role.getPermissions()) {
                authorizationInfo.addStringPermission(p.getPermission());
            }
        }
        return authorizationInfo;
    }
}

配置ShiroConfig,將自定義的過濾器設置進去

@Configuration
public class ShiroConfig {
    /**
     * 先走 filter ,然後 filter 如果檢測到請求頭存在 token,則用 token 去 login,走 Realm 去驗證
     *
     * @param securityManager the security manager
     * @return the shiro filter factory bean
     */
    @Bean
    public ShiroFilterFactoryBean factory(SecurityManager securityManager) {
        ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
        Map<String, Filter> filterMap = new HashMap<>();
        //設置我們自定義的JWT過濾器
        filterMap.put("jwt", new JWTFilter());
        factoryBean.setFilters(filterMap);
        factoryBean.setSecurityManager(securityManager);
        // 設置無權限時跳轉的 url;
        factoryBean.setUnauthorizedUrl("/unauthorized/無權限");
        Map<String, String> filterRuleMap = new HashMap<>();
        //訪問/login和/unauthorized 不需要經過過濾器
        filterRuleMap.put("/login", "anon");
        filterRuleMap.put("/unauthorized/**", "anon");
        // 所有請求通過我們自己的JWT Filter
        filterRuleMap.put("/**", "jwt");
        // 訪問 /unauthorized/** 不通過JWTFilter
        factoryBean.setFilterChainDefinitionMap(filterRuleMap);
        return factoryBean;
    }

    /**
     * 注入 securityManager
     *
     * @return the security manager
     */
    @Bean
    public SecurityManager securityManager() {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        // 設置自定義 realm.
        securityManager.setRealm(customRealm());
        /*
         * 關閉shiro自帶的session,詳情見文檔
         * http://shiro.apache.org/session-management.html#SessionManagement-StatelessApplications%28Sessionless%29
         */
        DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
        DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
        defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
        subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
        securityManager.setSubjectDAO(subjectDAO);
        return securityManager;
    }

    @Bean
    public CustomRealm customRealm() {
        return new CustomRealm();
    }

    /**
     * 開啓shiro aop註解支持. 使用代理方式; 所以需要開啓代碼支持;
     *
     * @param securityManager 安全管理器
     * @return 授權Advisor
     */
    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
        advisor.setSecurityManager(securityManager);
        return advisor;
    }
}

配值好後,接入swagger2,方便測試接口,配置swagger時,設置一個header參數的token,方便我們調用。

@Configuration
@EnableSwagger2
public class SwaggerConfig {

    /**
     * Create rest api docket.
     *
     * @return the docket
     */
    @Bean
    public Docket createRestApi() {
        ParameterBuilder tokenPar = new ParameterBuilder();
        List<Parameter> pars = new ArrayList<>();
        //header中的token參數非必填,傳空也可以
        tokenPar.name("token").description("請求接口所需Token")
                .modelRef(new ModelRef("string")).parameterType("header")
                .required(false).build();
        pars.add(tokenPar.build());
        return new Docket(DocumentationType.SWAGGER_2)
                .apiInfo(metaData())
                .select()
                .apis(RequestHandlerSelectors.basePackage("com.dashuai.learning.jwt.api"))
                .paths(PathSelectors.any())
                .build()
                .globalOperationParameters(pars);
    }

    private ApiInfo metaData() {
        return new ApiInfoBuilder()
                .title("集成JWT API文檔")
                .description("描述")
                .termsOfServiceUrl("")
                .contact(new Contact("dashuai", "https://github.com/liaozihong", "[email protected]"))
                .version("1.0")
                .build();
    }
}

調用授權api,登錄成功,會返回token:

拿到返回的token,調用接口,可以看到成功調用:

去掉token或使用錯誤的token將會報token認證失敗:

Demo源碼:https://github.com/liaozihong/SpringBoot-Learning/tree/master/SpringBoot-JWT.git

參考鏈接:
JWT官方鏈接
使用JWt帶來的一些問題
https://www.xncoding.com/2017/07/09/spring/sb-jwt.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章