使用Shiro+JWT完成的微信小程序的登錄(含講解)

源碼地址https://github.com/Jirath-Liu/shiro-jwt-wx

微信小程序用戶登陸,完整流程可參考下面官方地址,本例中是按此流程開發
https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html

你需要了解的點

微信小程序的登錄流程

https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html

Shiro的基礎知識

https://shiro.apache.org/10-minute-tutorial.html

JWT以及Token

https://jwt.io/introduction/

項目的流程

未命名文件

  1. 調用 wx.login() 獲取 臨時登錄憑證code ,並回傳到開發者服務器。
  2. 訪問login接口,並將code給後臺
  3. 被JwtShirioFilter攔截(在shiro配置中配置的),查看有沒有token在Header
  4. 有則自動執行登錄操作,覈實token的合法性,並刷新token
  5. 沒有則被controller攔截進入service中進行登錄
  6. 使用code獲取用戶信息,默認初始化了一些信息(可以修改的)
  7. 生成token(會存至redis)
  8. 返回token

本項目的結構

項目分包:

  1. conf 項目的配置
    • exceptoionconfig 配置了異常的拋出,使用@ControllerAdvice進行攔截統一處理
    • jwt 包含一個jwt工具類,在使用時會與redis連接,存儲、驗證與生成token
    • shiro 是本項目配置的核心,其中關閉了session管理,使用jwt來完成驗證,包含一個自定的應用於shiro的token
    • RestTemplateConfig 使用Spring
  2. enums 包含了需要的枚舉類
  3. vo
    • wxapi 包含了一個請求微信後臺需要的結果類

不可修改的模塊:有JWT與Shiro的類別以及配置模塊

具體實現

一、shiro基礎配置

1.設置一個自己的realm進行token驗證,用戶登錄會執行你的realm

在DefaultWebSecurityManager 中進行配置

realm是需要自己實現的,先讓他在這裏報錯,當自己的提示也可以

DefaultWebSecurityManager defaultWebSecurityManager=new DefaultWebSecurityManager(tokenRealm);
        //設置realm
        defaultWebSecurityManager.setRealm(tokenRealm);

2.我們需要關閉shiro的session功能

在DefaultWebSecurityManager 中進行配置

 DefaultSubjectDAO subjectDAO = (DefaultSubjectDAO) defaultWebSecurityManager.getSubjectDAO();
        DefaultSessionStorageEvaluator evaluator = (DefaultSessionStorageEvaluator) subjectDAO.getSessionStorageEvaluator();
        evaluator.setSessionStorageEnabled(Boolean.FALSE)

3.設置realm

我們需要定製一個realm,並且爲了能夠被識別,選擇繼承AuthorizingRealm類

需要我們完成的模塊:

  1. realm對請求的識別 我們的方案是驗證token是否爲我們定製的token
  2. 審覈信息 其中有對token的驗證,我們做在工具類中
  3. 身份/角色驗證
  4. 關閉密碼校驗加密
@Component
public class TokenRealm extends AuthorizingRealm {
    @Autowired
    JwtUtil jwtUtil;

    /**
     * 該方法是爲了判斷這個主體能否被本Realm處理,判斷的方法是查看token是否爲同一個類型
     * @param authenticationToken
     * @return
     */
    @Override
    public boolean supports(AuthenticationToken authenticationToken) {
        return authenticationToken instanceof JwtShiroToken;
    }


    /**
     * 在需要驗證身份進行登錄時,會通過這個接口,調用本方法進行審覈,將身份信息返回,有誤則拋出異常,在外層攔截
     * @param authenticationToken 這裏收到的是自定義的token類型,在JwtShiroToken中,自動向上轉型。得到的getCredentials爲String類型,可以使用toString
     * @return
     * @throws AuthenticationException token異常,可以細化設置
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) {
        String submittedToken=authenticationToken.getCredentials().toString();
        //解析出信息
        String wxOpenId = jwtUtil.getWxOpenIdByToken(submittedToken);
        String sessionKey = jwtUtil.getSessionKeyByToken(submittedToken);
        String userId=jwtUtil.getUserIdByToken(submittedToken);
        //對信息進行辨別
        if (StringUtils.isEmpty(wxOpenId)) {
            throw new TokenException("user account not exits , please check your token");
        }
        if (StringUtils.isEmpty(sessionKey)) {
            throw new TokenException("sessionKey is invalid , please check your token");
        }
        if (StringUtils.isEmpty(userId)) {
            throw new TokenException("userId is invalid , please check your token");
        }
        if (!jwtUtil.verifyToken(submittedToken)) {
            throw new TokenException("token is invalid , please check your token");
        }
        //在這裏將principal換爲用戶的id
        return new SimpleAuthenticationInfo(userId, submittedToken, getName());
    }

    /**
     * 這個方法是用來添加身份信息的,本項目計劃爲管理員提供網站後臺,所以這裏不需要身份信息,返回一個簡單的即可
     * @param principalCollection
     * @return
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        return null;
    }

    /**
     * 注意坑點 : 密碼校驗 , 這裏因爲是JWT形式,就無需密碼校驗和加密,直接讓其返回爲true(如果不設置的話,該值默認爲false,即始終驗證不通過)
     */
    @Override
    public CredentialsMatcher getCredentialsMatcher() {
        return (token, info) -> true;
    }
}

4.定製一個token,繼承AuthenticationToken

默認的token是包含兩個部分的,賬號和密碼(可以這樣理解)

我們將這兩個信息都調整爲token

/**
 * @author Jirath
 * @date 2020/4/9
 * @description: 一個用於Shiro使用的Authentication,因爲使用JWT需要有自己的身份信息,所以使用針對Token定製的信息
 */
@Data
public class JwtShiroToken implements AuthenticationToken {
    /**
     * 封裝,防止誤操作
     */
    private String token;

    /**
     * token作爲兩者進行提交,使用構造方法進行初始化
     * @param token 用戶提交的token
     */
    public JwtShiroToken(String token){
        this.token=token;
    }
    /**
     * 在UserNamePasswordToken中,使用的是賬號和密碼來作爲主體和簽證,這裏我們使用Token登錄
     * 兩者的get都是獲取token
     */
    @Override
    public Object getPrincipal() {
        return token;
    }

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

5.上述提到了jwt的工具類,我們實現一下

  1. 因爲簽名最好是自己定的,以備不時之需,你也可使用jwt框架隨機一個,我們從配置文件中導入進來
  2. token生成模塊,需要緩存redis
  3. token驗證模塊,需要使用redis進行續期
  4. token獲取信息模塊
@Component
public class JwtUtil {
    /**
     * JWT 自定義密鑰 在配置文件進行配置
     */
    @Value("${jwt.secret}")
    private String secretKey;

    /**
     * JWT 過期時間值 這裏寫死爲和小程序時間一致 7200 秒,也就是兩個小時
     */
    private static final long EXPIRE_TIME = 7200;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 根據微信用戶登陸信息創建 token
     * 注 : 這裏的token會被緩存到redis中,用作爲二次驗證
     * redis裏面緩存的時間應該和jwt token的過期時間設置相同
     * @param useInfo 用戶信息
     * @return 返回 jwt token
     */
    public String createTokenByWxAccount(User useInfo) {
        //JWT 隨機ID,做爲驗證的key
        String jwtId = UUID.randomUUID().toString();
        //1 . 加密算法進行簽名得到token
        //生成簽名
        Algorithm algorithm = Algorithm.HMAC256(secretKey);
        //生成token
        String token = JWT.create()
                .withClaim("wxOpenId", useInfo.getWxId())
                .withClaim("user-id",useInfo.getId())
                .withClaim("sessionKey", useInfo.getWxId())
                .withClaim("jwt-id", jwtId)
                //JWT 配置過期時間的正確姿勢,因爲單位是毫秒,所以需要乘1000
                .withExpiresAt(new Date(System.currentTimeMillis() + EXPIRE_TIME * 1000))
                .sign(algorithm);
        //2 . Redis緩存JWT, 注 : 請和JWT過期時間一致
        stringRedisTemplate.opsForValue().set("JWT-SESSION-" + jwtId, token, EXPIRE_TIME, TimeUnit.SECONDS);
        return token;
    }

    /**
     * 校驗token是否正確
     * 1 . 根據token解密,解密出jwt-id , 先從redis中查找出redisToken,匹配是否相同
     * 2 . 然後再對redisToken進行解密,解密成功則 繼續流程 和 進行token續期
     *
     * @param token 密鑰
     * @return 返回是否校驗通過
     */
    public boolean verifyToken(String token) {
        try {
            //1 . 根據token解密,解密出jwt-id , 先從redis中查找出redisToken,匹配是否相同
            String redisToken = stringRedisTemplate.opsForValue().get("JWT-SESSION-" + getJwtIdByToken(token));
            if (!redisToken.equals(token)) {
                return false;
            }
            //2 . 得到算法相同的JWTVerifier
            Algorithm algorithm = Algorithm.HMAC256(secretKey);
            JWTVerifier verifier = JWT.require(algorithm)
                    .withClaim("wxOpenId", getWxOpenIdByToken(redisToken))
                    .withClaim("user-id",getUserIdByToken(token))
                    .withClaim("sessionKey", getSessionKeyByToken(redisToken))
                    .withClaim("jwt-id", getJwtIdByToken(redisToken))
                    //續期
                    .acceptExpiresAt(System.currentTimeMillis() + EXPIRE_TIME * 1000)
                    .build();
            //3 . 驗證token
            verifier.verify(redisToken);
            //4 . Redis緩存JWT續期
            stringRedisTemplate.opsForValue().set("JWT-SESSION-" + getJwtIdByToken(token), redisToken, EXPIRE_TIME, TimeUnit.SECONDS);
            return true;
        } catch (Exception e) { //捕捉到任何異常都視爲校驗失敗
            return false;
        }
    }

    /**
     * 根據Token獲取wxOpenId(注意坑點 : 就算token不正確,也有可能解密出wxOpenId,同下)
     */
    public String getWxOpenIdByToken(String token)  {
        return JWT.decode(token).getClaim("wxOpenId").asString();
    }

    /**
     * 根據Token獲取sessionKey
     */
    public String getSessionKeyByToken(String token)  {
        return JWT.decode(token).getClaim("sessionKey").asString();
    }

    /**
     * 根據Token 獲取jwt-id
     */
    public String getJwtIdByToken(String token)  {
        return JWT.decode(token).getClaim("jwt-id").asString();
    }
    /**
     * 根據Token 獲取user-id
     */
    public String getUserIdByToken(String token)  {
        return JWT.decode(token).getClaim("user-id").asString();
    }

}

6.完成了jwt工具類的實現,我們回頭繼續進行shiro攔截器的配置

除了自帶的攔截器以外,我們希望能自動掃描token,所以我們選擇新建一個自己的攔截器,加入進來掃描所有的接口並放行,達到識別token並標記登錄的需求。

@Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager){
        ShiroFilterFactoryBean shiroFilterFactoryBean=new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        //註冊攔截方案
        Map<String, Filter> filterMap = new HashMap<>();
        filterMap.put("token", new JwtShiroFilter());
        shiroFilterFactoryBean.setFilters(filterMap);
        //定義攔截規則
        Map<String, String> filterRuleMap = new HashMap<>();
        //登陸相關api不需要被過濾器攔截
        filterRuleMap.put("/api/wx/user/login/**", "anon");
        filterRuleMap.put("/api/response/**", "anon");
        // 所有請求通過JWT Filter
        filterRuleMap.put("/**", "token");
        return shiroFilterFactoryBean;
    }

7.編寫攔截器

攔截器同樣採用繼承BasicHttpAuthenticationFilter,我們只需要進行微調即可使用

其中包含:

  1. 判斷是否進行攔截,這裏建議查看BasicHttpAuthenticationFilter的源碼,來理解getAuthzHeader所做的修改
  2. 驗證邏輯,被識別的請求會運行這個方法,在這裏我們可以進行登錄操作
  3. 添加跨域支持
/**
 * <h1>BasicHttpAuthenticationFilter</h1>
 * <p>shiro會掃描項目中所有的filter並加入manager中</p>
 * <p>所有的請求都會被攔截,在請求頭前標了shiro定製的header的請求會被識別</p>
 * <h1>JwtFilter</h1>
 * <p>這裏定製一個filter,使得我們可以識別出有token的請求</p>
 * <p><b>注意!登錄是在這裏進行的!isAccessAllowed方法,主要完成了登錄</b></p>
 *  <p>因爲小程序的訪問不是同一次訪問,所以對於系統來說,若把session替換爲了token,就要每次登錄
 * </p>
 * @author Jirath
 * @date 2020/4/9
 * @description: 定製一個使用jwt的filter
 */
public class JwtShiroFilter extends BasicHttpAuthenticationFilter {
    /**
     * 判斷用戶是否想要進行 需要驗證的操作
     * 檢測header裏面是否包含token字段即可\
     * 調用情況請查看BasicHttpAuthenticationFilter源碼
     */
    @Override
    protected String getAuthzHeader(ServletRequest request) {
        HttpServletRequest httpRequest = WebUtils.toHttp(request);
        return httpRequest.getHeader("token");
    }

    @Override
    protected boolean isLoginAttempt(String authzHeader) {
        return authzHeader != null;
    }

    /**
     * 此方法調用登陸,驗證邏輯
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        if (isLoginAttempt(request, response)) {
            JwtShiroToken token = new JwtShiroToken(getAuthzHeader(request));
            getSubject(request, response).login(token);
        }
        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);
    }
}

8.完善shiro配置,加入註解等

@Configuration
public class ShiroConf {
    /**
     * <h1>FactoryBean</h1>
     * FactoryBean to be used in Spring-based web applications for defining the master Shiro Filter.
     * <h1>factoryBean.setFilters</h1>
     * <p>Sets the filterName-to-Filter map of filters available for reference when creating filter chain definitions.
     * Note: This property is optional: this FactoryBean implementation will discover all beans in the web application context that implement the Filter interface and automatically add them to this filter map under their bean name.
     * </p>
     * <code>
     *  Map<String, Filter> filterMap = new HashMap<>();
     *  filterMap.put("jwt", new JwtFilter());
     *  factoryBean.setFilters(filterMap);
     * </code></br>
     * <p><b>上述代碼的目的是生成自定義的filter用來過濾請求</b></p>
     * @param securityManager
     * @return
     */
    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager){
        ShiroFilterFactoryBean shiroFilterFactoryBean=new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        //註冊攔截方案
        Map<String, Filter> filterMap = new HashMap<>();
        filterMap.put("token", new JwtShiroFilter());
        shiroFilterFactoryBean.setFilters(filterMap);
        //定義攔截規則
        Map<String, String> filterRuleMap = new HashMap<>();
        //登陸相關api不需要被過濾器攔截
        filterRuleMap.put("/api/wx/user/login/**", "anon");
        filterRuleMap.put("/api/response/**", "anon");
        // 所有請求通過JWT Filter
        filterRuleMap.put("/**", "token");
        return shiroFilterFactoryBean;
    }

    /**
     * 因爲本項目只用了一個Realm,所以使用了構造器進行初始化,該構造器只適合單Realm的情況
     * @param tokenRealm
     * @return
     */
    @Bean
    public DefaultWebSecurityManager securityManager(TokenRealm tokenRealm){
        DefaultWebSecurityManager defaultWebSecurityManager=new DefaultWebSecurityManager(tokenRealm);
        //設置realm
        defaultWebSecurityManager.setRealm(tokenRealm);
        //關閉session
        DefaultSubjectDAO subjectDAO = (DefaultSubjectDAO) defaultWebSecurityManager.getSubjectDAO();
        DefaultSessionStorageEvaluator evaluator = (DefaultSessionStorageEvaluator) subjectDAO.getSessionStorageEvaluator();
        evaluator.setSessionStorageEnabled(Boolean.FALSE);
        subjectDAO.setSessionStorageEvaluator(evaluator);
        return defaultWebSecurityManager;
    }

/**
 * ============================= Shiro註解設置  ===============================================
 */
    /**
     *  開啓Shiro的註解(如@RequiresRoles,@RequiresPermissions),需藉助SpringAOP掃描使用Shiro註解的類,並在必要時進行安全邏輯驗證
     * 配置以下兩個bean(DefaultAdvisorAutoProxyCreator和AuthorizationAttributeSourceAdvisor)即可實現此功能
     * @return
     */
    @Bean
    public DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator(){
        DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
        advisorAutoProxyCreator.setProxyTargetClass(true);
        return advisorAutoProxyCreator;
    }

    /**
     * 開啓aop註解支持
     * @param securityManager
     * @return
     */
    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultWebSecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
        authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
        return authorizationAttributeSourceAdvisor;
    }
}

二、登錄配置

我們希望用戶在訪問登錄時獲得一個token,微信使用的是code,我們沒必要去檢查密碼,若不是微信小程序,可以使用密碼判斷

1.編寫controller,調用loginService

比較簡單不多贅述

2.編寫loginService

我們計劃使用spring提供的http工具,需要進行配置,在下個部份講解

@Service
public class LoginServiceImpl implements LoginService {
    @Value("${app.id}")
    private String appid;
    @Value("${app.secret}")
    private String appSecret;
    @Autowired
    RestTemplate restTemplate;
    @Autowired
    UserDao userDao;
    @Autowired
    JwtUtil jwtUtil;

    /**
     * @param code
     * @return
     */
    @Override
    public String login(String code) {
        String resultJson = analysisInfo(code);
        WxLoginResponseVo wxResponse = JSONObject.toJavaObject(JSONObject.parseObject(resultJson), WxLoginResponseVo.class);
        if (!wxResponse.getErrcode().equals("0")) {
            throw new WxApiException("請求微信api失敗 : " + wxResponse.getErrmsg());
        } else {
            //3 . 先從本地數據庫中查找用戶是否存在
            User userInfo = userDao.findByWxOpenid(wxResponse.getOpenid());
            String sessionKey = wxResponse.getSession_key();
            //不存在就新建用戶
            if (userInfo == null) {
                userInfo = new User(wxResponse.getOpenid(), "佚名", "0000-00-00", "未知", sessionKey);
                userDao.newUser(userInfo);
            } else {
                //4 . 更新sessionKey和 登陸時間
                userInfo.setSessionKey(sessionKey);
                userDao.fixSessionKeyById(userInfo);
            }
            //5 . JWT 返回自定義登陸態 Token
            String token = jwtUtil.createTokenByWxAccount(userInfo);
            return token;
        }

    }

    /**
     * 使用code獲得微信api的用戶json信息
     *
     * @param code
     * @return
     */
    private String analysisInfo(String code) {
        String code2SessionUrl = WxApiEnum.LOGIN_URL.getString();
        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("appid", appid);
        params.add("secret", appSecret);
        params.add("js_code", code);
        params.add("grant_type", "authorization_code");
        URI code2Session = getURIwithParams(code2SessionUrl, params);
        return restTemplate.exchange(code2Session, HttpMethod.GET, new HttpEntity<String>(new HttpHeaders()), String.class).getBody();
    }

    /**
     * URI工具類
     *
     * @param url    url
     * @param params 參數
     * @return URI
     */
    private URI getURIwithParams(String url, MultiValueMap<String, String> params) {
        UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url).queryParams(params);
        return builder.build().encode().toUri();
    }
}

3.編寫SpringHttp工具類RestTemplate

@Configuration
public class RestTemplateConfig {

    @Bean
    public RestTemplate restTemplate(ClientHttpRequestFactory factory) {
        return new RestTemplate(factory);
    }

    @Bean
    public ClientHttpRequestFactory simpleClientHttpRequestFactory() {
        SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
        factory.setReadTimeout(1000 * 60);
        //讀取超時時間爲單位爲60秒
        factory.setConnectTimeout(1000 * 10);
        //連接超時時間設置爲10秒
        return factory;
    }
}

4.使用RestTemplate時,需要我們設定一個結果類進行映射,我們實現一個

@Data
public class WxLoginResponseVo {
    private String openid;
    private String session_key;
    private String unionid;
    private String errcode = "0";
    private String errmsg;
    private int expires_in;
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章