源码地址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/
项目分包:
不可修改的模块:有JWT与Shiro的类别以及配置模块
在DefaultWebSecurityManager 中进行配置
realm是需要自己实现的,先让他在这里报错,当自己的提示也可以
DefaultWebSecurityManager defaultWebSecurityManager=new DefaultWebSecurityManager(tokenRealm);
//设置realm
defaultWebSecurityManager.setRealm(tokenRealm);
在DefaultWebSecurityManager 中进行配置
DefaultSubjectDAO subjectDAO = (DefaultSubjectDAO) defaultWebSecurityManager.getSubjectDAO();
DefaultSessionStorageEvaluator evaluator = (DefaultSessionStorageEvaluator) subjectDAO.getSessionStorageEvaluator();
evaluator.setSessionStorageEnabled(Boolean.FALSE)
我们需要定制一个realm,并且为了能够被识别,选择继承AuthorizingRealm类
需要我们完成的模块:
@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;
}
}
默认的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;
}
}
@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();
}
}
除了自带的拦截器以外,我们希望能自动扫描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;
}
拦截器同样采用继承BasicHttpAuthenticationFilter,我们只需要进行微调即可使用
其中包含:
/**
* BasicHttpAuthenticationFilter
* shiro会扫描项目中所有的filter并加入manager中
* 所有的请求都会被拦截,在请求头前标了shiro定制的header的请求会被识别
* JwtFilter
* 这里定制一个filter,使得我们可以识别出有token的请求
* 注意!登录是在这里进行的!isAccessAllowed方法,主要完成了登录
* 因为小程序的访问不是同一次访问,所以对于系统来说,若把session替换为了token,就要每次登录
*
* @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);
}
}
@Configuration
public class ShiroConf {
/**
* FactoryBean
* FactoryBean to be used in Spring-based web applications for defining the master Shiro Filter.
* factoryBean.setFilters
* 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.
*
*
* Map filterMap = new HashMap<>();
* filterMap.put("jwt", new JwtFilter());
* factoryBean.setFilters(filterMap);
*
* 上述代码的目的是生成自定义的filter用来过滤请求
* @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,我们没必要去检查密码,若不是微信小程序,可以使用密码判断
比较简单不多赘述
我们计划使用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();
}
}
@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;
}
}
@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;
}