村长让小王给村里各系统来一套SSO方案做整合,隔壁的陈家村流行使用Session+认证中心方法,但小王想尝试点新鲜的,于是想到了JWT方案,那JWT是啥呢?JavaWebToken简称JWT,就是一个字符串,由点号连接,可以Encoded和Decoded进行明文和密文转换,结构如下:

头部,声明和签名,头部(header)说明加密算法、类型等,声明(payload)内容如账号密码信息或需要传输的内容,签名(signature)即对声明进行加密生成的签名,用于防篡改。这样,SSO就无需认证中心了,也无需服务端进行服务端session存储,甚至不使用cookie传输,无CSRF风险,每次request携带上这个token,服务方通过认证即可,链路简单,仅需各服务使用统一私钥和验证算法即可。

听完小王的SSO方案,村长略显兴奋,看小王才堪大用,于是再提出一项要求,让来套权限控制方案,有了前面的经验,小王也想到了SpringSecurity,但得让村长满意,必须有些与众不同,于是说了他的Shiro方案,村长认真的点点头,高兴地表示可以出面协助解决找对象的问题。在此,我们也来研究下小王的这套技术,说不定还可以解决一些生活问题。

准备: Idea201902/JDK11/ZK3.5.5/Gradle5.4.1/RabbitMQ3.7.13/Mysql8.0.11/Lombok0.26/Erlang21.2/postman7.5.0/Redis3.2/RocketMQ4.5.2

难度:新手--战士--老兵--大师

目标:1.模拟商城系统,实现服务间SSO    2.使用JWT+Shiro实现权限管理

步骤

1.系统整体框架不变,增加admin模块,作为sso认证和权限管理服务,整体思路:首次请求,进行DB用户信息验证,通过后生成一个jwtToken,并获取各类权限,再次访问,则请求头带上这个jwtToken,服务端仅进行token校验,并刷新Token有效期。

2.几个shiro的核心对象:

  • Principal 主体身份标识,必须具有唯一性,如用户名、手机号、邮箱地址等,一个主体可以有多个身份,但是必须有一个主身份(Primary Principal);

  • Subject 请求主体,一个登录用户,一个请求等,在程序中任何地方都可以通过SecurityUtils.getSubject()获取到当前的subject,subject中又可以获取到Principal;

  • credential 凭证信息,只有主体知道的安全信息,如密码等;

  • SecurityManager 权限控制中心,所有请求最终基本上都通过它来代理转发,一般我们程序中不需要直接跟他打交道;

  • Realm 认证领域,不同的数据源使用不同的认证领域,比如从DB取信息对比的可以叫DbRealm ,从Redis取缓存信息对比认证的叫RedisRealm,一般情况下我们对每种数据源定义一个Realm,其中包含了比对器(Matcher);

  • authenticator 认证器,主体进行认证最终通过authenticator进行的;

  • authorizer 授权器,主体进行授权最终通过authorizer进行的;

3.因为要用到JWT,也做个简要说明,使用了auth0包,主要在 com.biao.mall.admin.util.JwtUtils中,其中方法包含生成JwtToken,加解密,签名等,比较清晰。

 public class JwtUtils {

     /**
* 获得token中的信息无需secret解密也能获得
* @return token中包含的签发时间
*/
public static LocalDateTime getIssueAt(String token){
DecodedJWT jwt = JWT.decode(token);
return TimeUtil.convert2LocalTime(jwt.getIssuedAt());
} /**
* 获得token中的信息无需secret解密也能获得
* @return token中包含的用户名
*/
public static String getUsername(String token){
DecodedJWT jwt = JWT.decode(token);
return jwt.getClaim("username").asString();
} /**
* 生成签名,expireTime后过期
* @param username 用户名
* @param expireTime 过期时间s
* @return 加密的token
*/
public static String sign(String username, String salt, long expireTime) {
Date date = new Date(System.currentTimeMillis()+expireTime*1000);
Algorithm algorithm= Algorithm.HMAC256(salt);
//
return JWT.create()
.withClaim("username",username)
.withExpiresAt(date)
.withIssuedAt(new Date())
.sign(algorithm);
} /**
* token是否过期
* @return true:过期
*/
public static boolean isTokenExpired(String token){
Date now = Calendar.getInstance().getTime();
DecodedJWT jwt = JWT.decode(token);
return jwt.getExpiresAt().before(now);
} /**
* 生成随机盐,长度32位
* @return
*/
public static String generateSalt(){
SecureRandomNumberGenerator secureRandom = new SecureRandomNumberGenerator();
String hex = secureRandom.nextBytes(16).toHex();
return hex;
} }

JWT加解密示例请看这里:https://jwt.io/#debugger-io

4.基础组件com.biao.mall.admin.service.UserService,也比较简单清晰,“加密盐”,即对加密对象加入的一些干扰数据,增加复杂度,要注意加解密的盐要一致:

@Service
public class UserService { private final static Logger lgger = LoggerFactory.getLogger(UserService.class);
//加密用户信息的盐
private static final String encryptSalt = "510fdb7f28534fb584af25697826c203";
private StringRedisTemplate stringRedisTemplate; @Autowired
public UserService(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
} public String generateJwtToken(String username){
//加密JWT的盐
String salt = "0805c99fd2634c80b2cde8c7e4124468";
//redis缓存salt
stringRedisTemplate.opsForValue().set("token:"+username, salt, 3600, TimeUnit.SECONDS);
return JwtUtils.sign(username,salt,60*60);//生成jwt token,设置过期时间为1小时
} /*
* 获取上次token生成时的salt值和登录用户信息*/
public UserDto getJwtToken(String username) {
// String salt = "9723612f53";
//从数据库或者缓存中取出jwt token生成时用的salt
String salt = stringRedisTemplate.opsForValue().get("token:"+username);
UserDto userDto = this.getUserInfo(username);
userDto.setSalt(salt);
return userDto;
} /**
* 获取数据库中保存的用户信息,主要是加密后的密码.这里省去了DB操作,直接生成了用户信息
* @param username
* @return
*/
public UserDto getUserInfo(String username){
UserDto user = new UserDto();
user.setUserId(1L);
user.setUsername("admin");
//模拟对密码加密
user.setEncryptPwd(new Sha256Hash("admin123",encryptSalt).toHex());
lgger.debug("UserService: [{}]",user.toString());
return user;
} /**清除token信息*/
public void deleteLogInfo(String username){
// 删除数据库或者缓存中保存的salt
// stringRedisTemplate.delete("token:"+username);
} /**获取用户角色列表,强烈建议从缓存中获取*/
public List<String> getUserRoles(Long userId){
//模拟admin角色
return Arrays.asList("admin");
}
}

5.配置类 com.biao.mall.admin.conf.ShiroConf功能就是:

  • 通过FilterRegistrationBean注入自定义的权限Filter和认证Filter
  • 注册Authenticator,关联定义的多个Realm
  • 注册ShiroFilterChainDefinition
  • 注册sessionStorageEvaluator禁用session
@Configuration
public class ShiroConf { /**注册shiro的Filter 拦截请求*/
@Bean
public FilterRegistrationBean<Filter> filterRegistrationBean(SecurityManager securityManager, UserService userService) throws Exception {
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter((Filter) Objects.requireNonNull(this.shiroFilter(securityManager, userService).getObject()));
filterRegistrationBean.addInitParameter("targetFilterLifecycle","true");
//bean注入开启异步方式
filterRegistrationBean.setAsyncSupported(true);
filterRegistrationBean.setEnabled(true);
filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST);
return filterRegistrationBean;
} /**设置过滤器,将自定义的Filter加入*/
@Bean(name = "shiroFilter")
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager, UserService userService) {
ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
//必需属性,指定一个SecurityManager的实例,
factoryBean.setSecurityManager(securityManager);
Map<String,Filter> filterMap = factoryBean.getFilters();
filterMap.put("authcToken",this.createAuthFilter(userService));
filterMap.put("anyRole",this.createRolesFilter());
factoryBean.setFilters(filterMap);
factoryBean.setFilterChainDefinitionMap(this.shiroFilterChainDefinition().getFilterChainMap());
return factoryBean;
} @Bean
protected ShiroFilterChainDefinition shiroFilterChainDefinition() {
DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
chainDefinition.addPathDefinition("/login", "noSessionCreation,anon");
chainDefinition.addPathDefinition("/logout", "noSessionCreation,authcToken[permissive]");
chainDefinition.addPathDefinition("/image/**", "anon");
//只允许admin或manager角色的用户访问
chainDefinition.addPathDefinition("/admin/**", "noSessionCreation,authcToken,anyRole[admin,manager]");
chainDefinition.addPathDefinition("/**", "noSessionCreation,authcToken");
return chainDefinition;
} /**注意不要加@Bean注解,不然spring会自动注册成filter*/
private AnyRolesAuthorizationFilter createRolesFilter() {
return new AnyRolesAuthorizationFilter();
} /**注意不要加@Bean注解,不然spring会自动注册成filter*/
private JwtAuthFilter createAuthFilter(UserService userService) {
return new JwtAuthFilter(userService);
} /**初始化authenticator*/
@Bean
public Authenticator authenticator(UserService userService){
ModularRealmAuthenticator authenticator = new ModularRealmAuthenticator();
authenticator.setRealms(Arrays.asList(this.jwtShiroRealm(userService),this.dbShiroRealm(userService)));
//如果有多个Realms才需要指定realm匹配策略
authenticator.setAuthenticationStrategy(new FirstSuccessfulStrategy());
return authenticator;
} /**DB认证的realm*/
@Bean("dbRealm")
public Realm dbShiroRealm(UserService userService){
DbShiroRealm dbShiroRealm = new DbShiroRealm(userService);
return dbShiroRealm;
} /**JWT 认证的realm*/
@Bean("jwtRealm")
public Realm jwtShiroRealm(UserService userService) {
JWTShiroRealm myShiroRealm = new JWTShiroRealm(userService);
return myShiroRealm;
} /**禁用session,不保存用户状态,每次请求都重新认证,
* 要完全禁用session,需使用下面的filter来实现*/
@Bean
protected SessionStorageEvaluator sessionStorageEvaluator(){
DefaultWebSessionStorageEvaluator sessionStorageEvaluator = new DefaultWebSessionStorageEvaluator();
sessionStorageEvaluator.setSessionStorageEnabled(false);
return sessionStorageEvaluator;
} }

从以上内容并结合其他部分可整理出shiro内部组件关系图,或者说大致的处理流程:

6.然后我们看首次登录流程,从com.biao.mall.admin.controller.AdminController开始,看其核心部分:

@PostMapping(value = "/login")
public ResponseEntity<Void> login(@RequestBody UserDto loginInfo, HttpServletRequest request, HttpServletResponse response){
//获取请求主体
Subject subject = SecurityUtils.getSubject();
try {
//将用户请求参数封装
UsernamePasswordToken token = new UsernamePasswordToken(loginInfo.getUsername(), loginInfo.getPassword());
/**直接提交给Shiro处理,进入内部验证,如果验证失败,返回AuthenticationException,如果通过,就将全部认证信息关联到
* 此Subject上,subject.getPrincipal()将非空,且subject.isAuthenticated()为True*/
subject.login(token);
logger.info(">>AdminController.login OK!");
UserDto user = (UserDto) subject.getPrincipal();
String newToken = userService.generateJwtToken(user.getUsername());
//写入响应信息返回
response.setHeader("x-auth-token", newToken);
return ResponseEntity.ok().build();
} catch (AuthenticationException e) {
// 如果校验失败,shiro会抛出异常,返回客户端失败
logger.error("User {} login fail, Reason:{}", loginInfo.getUsername(), e.getMessage());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
} @GetMapping("/logout")
public ResponseEntity logout(){
Subject subject = SecurityUtils.getSubject();
if (subject.getPrincipals() != null){
UserDto userDto = (UserDto) subject.getPrincipals().getPrimaryPrincipal();
userService.deleteLogInfo(userDto.getUsername());
}
//务必不能少
SecurityUtils.getSubject().logout();
return ResponseEntity.ok().build();
}

先封装一个UsernamePasswordToken,此类实现接口HostAuthenticationToken和RememberMeAuthenticationToken,前一个接口用于记住认证请求的HostName或IP,后一个接口用于实现跨session的“记住密码”功能,另一细节是此类用char[]而不是String来存pwd,为啥?因为String是不可变的,会放到常量池中,留存较长时间,某些场合如memory dump时,可直接被输出访问。username/password模式认证场景最为常见,故shiro特意设计了UsernamePasswordToken来使用的。重点是以下一行:

subject.login(token);

就能将认证工作交给shiro去处理:进入内部自动验证,如果验证失败,返回AuthenticationException;如果通过,就将全部认证信息关联到此Subject上,subject.getPrincipal()将非空,且subject.isAuthenticated()为True。 最后是如果验证成功,将生成一个newToken,并写入响应的头。

7.再进一步,看shiro如何内部自动验证:shiro调用已注册的Authenticator,Authenticator自动选择对应的Realm。Realm的实现一般直接继承AuthorizingRealm即可:

public class DbShiroRealm extends AuthorizingRealm {
private final Logger logger = LoggerFactory.getLogger(JWTShiroRealm.class);
//生产环境盐值不可硬编码在代码中,注意与前面设置的一致
private static final String encrySalt = "510fdb7f28534fb584af25697826c203";//对比登录信息的salt
private UserService userService; public DbShiroRealm(UserService userService) {
this.userService = userService;
this.setCredentialsMatcher(new HashedCredentialsMatcher(Sha256Hash.ALGORITHM_NAME));
} @Override
public boolean supports(AuthenticationToken token){
logger.info(">>DbShiroRealm.supports");
return token instanceof UsernamePasswordToken;
} /**权限*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
//获取主身份标识
UserDto userDto = (UserDto) principals.getPrimaryPrincipal();
//获取权限角色
List<String> roles = userDto.getRoles();
if (roles == null){
roles = userService.getUserRoles(userDto.getUserId());
userDto.setRoles(roles);
}
if (roles != null){
simpleAuthorizationInfo.addRoles(roles);
}
return simpleAuthorizationInfo;
} /**认证*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) token;
String userName = usernamePasswordToken.getUsername();
UserDto userDto = userService.getUserInfo(userName);
if (userDto == null){
throw new AuthenticationException("userName or pwd error!");
}
return new SimpleAuthenticationInfo(userDto,userDto.getEncryptPwd(), ByteSource.Util.bytes(encrySalt),"dbRealm");
}
}

方法之一 :supports(AuthenticationToken token),即根据token判断此Authenticator是否使用该realm,

@Override
public boolean supports(AuthenticationToken token){
return token instanceof UsernamePasswordToken;
}

方法之二:doGetAuthorizationInfo,做权限处理,需注意这里两次使用了roles获取逻辑,因为Shiro默认不会缓存角色信息,所以这里调用service的方法获取角色,且强烈建议service中从缓存中获取。

/**权限*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
//获取主身份标识
UserDto userDto = (UserDto) principals.getPrimaryPrincipal();
//获取权限角色
List<String> roles = userDto.getRoles();
if (roles == null){
roles = userService.getUserRoles(userDto.getUserId());
userDto.setRoles(roles);
}
if (roles != null){
simpleAuthorizationInfo.addRoles(roles);
}
return simpleAuthorizationInfo;
}

方法之三:doGetAuthenticationInfo,做认证,此处是首次认证,故强转为UsernamePasswordToken,再去DB中使用userService.getUserInfo(userName)取得存储的账户信息,最后构造成SimpleAuthenticationInfo扔给shiro。

  /**认证*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) token;
String userName = usernamePasswordToken.getUsername();
UserDto userDto = userService.getUserInfo(userName);
if (userDto == null){
throw new AuthenticationException("userName or pwd error!");
}
return new SimpleAuthenticationInfo(userDto,userDto.getEncryptPwd(), ByteSource.Util.bytes(encrySalt),"dbRealm");
}

那究竟是如何对比的呢?最后是落到了HashedCredentialsMatcher头上,并使用Hash算法,因为这个user/pwd比对比较简单固定,所以shiro已经有了matcher,直接引用即可!至此,首次登录认证结束!

  public DbShiroRealm(UserService userService) {
this.userService = userService;
this.setCredentialsMatcher(new HashedCredentialsMatcher(Sha256Hash.ALGORITHM_NAME));
}

8.非首次登录,先是com.biao.mall.admin.filter.JwtAuthFilter处理,事实上无论哪次请求,都会经过这个Filter处理:

@Slf4j
public class JwtAuthFilter extends AuthenticatingFilter {
private final Logger logger = LoggerFactory.getLogger(JwtAuthFilter.class);
private static final int tokenRefreshInterval = 300;
private UserService userService; public JwtAuthFilter(UserService userService){
this.userService = userService;
this.setLoginUrl("/login");
} @Override
protected boolean preHandle(ServletRequest request,ServletResponse response) throws Exception {
logger.info("JwtAuthFilter.preHandle");
HttpServletRequest httpServletRequest = WebUtils.toHttp(request);
//对于OPTION请求做拦截,不做token校验
if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())){
return false;
}
return super.preHandle(request,response);
} @Override
protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception {
logger.info("JwtAuthFilter.createToken");
String jwtToken = this.getAuthzHeader(request);
if (StringUtils.isNotBlank(jwtToken) && !JwtUtils.isTokenExpired(jwtToken)){
return new JWTToken(jwtToken);
}
return null;
} private String getAuthzHeader(ServletRequest request) {
logger.info("JwtAuthFilter.getAuthzHeader");
HttpServletRequest httpServletRequest = WebUtils.toHttp(request);
String header = httpServletRequest.getHeader("x-auth-token");
return StringUtils.remove(header,"Bearer");
} //cors 跨域设置
private void fillCorsHeader(HttpServletRequest toHttp, HttpServletResponse httpServletResponse) {
httpServletResponse.setHeader("Access-control-Allow-Origin",toHttp.getHeader("Origin"));
httpServletResponse.setHeader("Access-Control-Allow-Methods","GET,POST,OPTIONS,HEAD");
httpServletResponse.setHeader("Access-Control-Allow-Headers",toHttp.getHeader("Access-Control-Request-Headers"));
} @Override
protected boolean isAccessAllowed(ServletRequest request,ServletResponse response,Object mappedValue){
logger.info(">>JwtAuthFilter.isAccessAllowed");
if (this.isLoginRequest(request,response)){
return true;
}
Boolean afterFiltered = (Boolean) request.getAttribute("anyRolesAuthFilter.FILTERED");
if (BooleanUtils.isTrue(afterFiltered)){
return true;
}
boolean allowed = false;
try{
allowed = executeLogin(request,response);
}catch (IllegalStateException e){
logger.error("Not found any token");
}catch (Exception e){
logger.error("Error occurs when login",e);
}
return allowed || super.isPermissive(mappedValue);
} //isAccessAllowed返回 false进入此方法
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
HttpServletResponse httpServletResponse = WebUtils.toHttp(response);
httpServletResponse.setCharacterEncoding("UTF-8");
httpServletResponse.setContentType("application/json;charset=UTF-8");
httpServletResponse.setStatus(HttpStatus.SC_NON_AUTHORITATIVE_INFORMATION);
this.fillCorsHeader(WebUtils.toHttp(request),httpServletResponse);
return false;
} @Override
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response){
logger.error("Validate token fail, token:{}, error:{}",token.toString(),e.getMessage());
return false;
} @Override
protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, ServletRequest request, ServletResponse response){
HttpServletResponse httpServletResponse = WebUtils.toHttp(response);
String newToken = null;
if (token instanceof JWTToken){
JWTToken jwtToken = (JWTToken) token;
UserDto userDto = (UserDto) subject.getPrincipals().getPrimaryPrincipal();
boolean shouldRefresh = this.shouldTokenRefresh(JwtUtils.getIssueAt(jwtToken.getToken()));
if (shouldRefresh){
newToken = userService.generateJwtToken(userDto.getUsername());
}
}
if (StringUtils.isNotBlank(newToken)){
httpServletResponse.setHeader("x-auth-token",newToken);
}
return true;
} private boolean shouldTokenRefresh(LocalDateTime issueAt) {
// LocalDateTime issueTime = LocalDateTime.ofInstant(issueAt.toInstant(), ZoneId.systemDefault());
return LocalDateTime.now().minusSeconds(tokenRefreshInterval).isAfter(issueAt);
} @Override
protected void postHandle(ServletRequest request,ServletResponse response){
this.fillCorsHeader(WebUtils.toHttp(request),WebUtils.toHttp(response));
request.setAttribute("jwtShiroFilter.FILTERED", true);
} }

展开,isAccessAllowed见名知意,逻辑:如果是首次,通过;如果已FILTERED,通过;如果都不是,则调用父类executeLogin方法,跟进一下,这里面再调用subject.login(token),其实就是前面首次登录逻辑了!父类会在请求进入拦截器后调用该方法,返回true则继续,返回false则会调用onAccessDenied()。不通过时,还会调用了isPermissive()方法。

  @Override
protected boolean isAccessAllowed(ServletRequest request,ServletResponse response,Object mappedValue){
if (this.isLoginRequest(request,response)){
return true;
}
Boolean afterFiltered = (Boolean) request.getAttribute("anyRolesAuthFilter.FILTERED");
if (BooleanUtils.isTrue(afterFiltered)){
return true;
}
boolean allowed = false;
try{
allowed = executeLogin(request,response);
}catch (IllegalStateException e){
logger.error("Not found any token");
}catch (Exception e){
logger.error("Error occurs when login",e);
}
return allowed || super.isPermissive(mappedValue);
}

关于父类的isPermissive()方法:对参数进行搜索,看是否有PERMISSIVE = "permissive"字符串,

protected boolean isPermissive(Object mappedValue) {
if(mappedValue != null) {
String[] values = (String[]) mappedValue;
return Arrays.binarySearch(values, PERMISSIVE) >= 0;
}
return false;
}

那为啥要加上"||super.isPermissive(mappedValue)",因为比如/logout请求,就能继续处理,这里也对应了前面ShiroFilterChainDefinition中的:

chainDefinition.addPathDefinition("/logout", "noSessionCreation,authcToken[permissive]");

这种场景同样适用于其他未登录,但又可以操作的场景,比如只是阅读内容不做评论,或者查询操作等。 来看方法createToken,

  @Override
protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception {
String jwtToken = this.getAuthzHeader(request);
if (StringUtils.isNotBlank(jwtToken) && !JwtUtils.isTokenExpired(jwtToken)){
return new JWTToken(jwtToken);
}
return null;
}

重写了父类的方法,使用我们自己定义的Token类,提交给shiro。这个方法返回null的话会直接抛出异常,进入isAccessAllowed()的异常处理逻辑 。

9.再看方法:onLoginSuccess,如果Login认证成功,会进入该方法,等同于用户名密码登录成功,这里还判断了是否要刷新Token,为啥要刷新token?因为每个token都有设置过期时间,刷新,可防止旧token被非法使用,如果是安全性要求高的系统,可以在update类操作后就刷新token,降低风险。

@Override
protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, ServletRequest request, ServletResponse response){
HttpServletResponse httpServletResponse = WebUtils.toHttp(response);
String newToken = null;
if (token instanceof JWTToken){
JWTToken jwtToken = (JWTToken) token;
UserDto userDto = (UserDto) subject.getPrincipal();
boolean shouldRefresh = this.shouldTokenRefresh(JwtUtils.getIssueAt(jwtToken.getToken()));
if (shouldRefresh){
newToken = userService.generateJwtToken(userDto.getUsername());
}
}
if (StringUtils.isNotBlank(newToken)){
httpServletResponse.setHeader("x-auth-token",newToken);
}
return true;
}

另一方法:onLoginFailure,如果调用shiro的Login认证失败,会回调这个方法,这里直接返回false,因为逻辑放到了onAccessDenied()中,

@Override
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response){
logger.error("Validate token fail, token:{}, error:{}",token.toString(),e.getMessage());
return false;
}

如果调用shiro的login认证失败,会回调这个方法,这里我们什么都不做,因为逻辑放到了onAccessDenied()中。

10.关于自定义的:com.biao.mall.admin.dto.JWTToken,很简单,略,

//@Data
public class JWTToken implements HostAuthenticationToken {
private static final long serialVersionUID = 8765431346463134621L; private String token;
private String host; public JWTToken(String token,String host){
this.token = token;
this.host = host;
} public JWTToken(String token){
//借用全变量构造函数
this(token,null);
} public void setToken(String token) {
this.token = token;
} public void setHost(String host) {
this.host = host;
} public String getToken() {
return this.token;
} public String getHost() {
return this.host;
} /**注意这里的重写方法,后续使用中,以此处返回值为准*/
@Override
public Object getPrincipal() {
return this.token;
} /**注意这里的重写方法,后续使用中,以此处返回值为准*/
@Override
public Object getCredentials() {
return this.token;
} @Override
public String toString(){
return token + ':' + host;
}
}

既然shiro将JWTToken交给Realm处理,先看会使用到的 com.biao.mall.admin.conf.JWTShiroRealm

/**
* @Classname JWTShiroRealm 自定义身份认证
* * 基于HMAC( 散列消息认证码)的控制域
* @Description TODO
* @Author xiexiaobiao
* @Date 2019-09-05 22:48
* @Version 1.0
**/
public class JWTShiroRealm extends AuthorizingRealm {
private static final Logger logger = LoggerFactory.getLogger(JWTShiroRealm.class);
private UserService userService; public JWTShiroRealm(UserService userService) {
this.userService = userService;
this.setCredentialsMatcher(new JWTCredentialsMatcher());
} @Override
public boolean supports(AuthenticationToken token){
logger.debug("token instanceof JWTToken >> {}", (token instanceof JWTToken));
return (token instanceof JWTToken);
} //首次登录已经处理权限角色,故这里不需处理
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
return new SimpleAuthorizationInfo();
} //
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
JWTToken jwtToken = (JWTToken) token;
String tokenStr = jwtToken.getToken();
UserDto userDto = userService.getJwtToken(JwtUtils.getUsername(tokenStr));
if (userDto == null){
throw new AuthenticationException("token expired ,please login");
}
return new SimpleAuthenticationInfo(userDto,userDto.getSalt(),"jwtRealm");
}
}

这里可以通过和DbShiroRealm对比分析:supports方法看此realm是否匹配,符合才进入处理

 @Override
public boolean supports(AuthenticationToken token){
logger.debug("token instanceof JWTToken >> {}", (token instanceof JWTToken));
return (token instanceof JWTToken);
}

看相同名称的doGetAuthorizationInfo方法:首次登录已经处理权限角色,故这里不需处理,JWTtoken中也不包含角色信息。

@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
return new SimpleAuthorizationInfo();
}

看另一相同名称的doGetAuthenticationInfo方法:取得token后,直接交给jwtRealm处理。

   @Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
JWTToken jwtToken = (JWTToken) token;
String tokenStr = jwtToken.getToken();
UserDto userDto = userService.getJwtToken(JwtUtils.getUsername(tokenStr));
if (userDto == null){
throw new AuthenticationException("token expired ,please login");
}
SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(userDto,userDto.getSalt(),"jwtRealm");
return authenticationInfo;
}

同理,jwtRealm要指定Matcher,这里的jwtRealm,通过构造函数指定了JWTCredentialsMatcher,

public JWTShiroRealm(UserService userService) {
this.userService = userService;
this.setCredentialsMatcher(new JWTCredentialsMatcher());
}

既然使用到了CredentialsMatcher,看定义,用指定的算法做匹配验证:

public class JWTCredentialsMatcher implements CredentialsMatcher {
private final Logger logger = LoggerFactory.getLogger(JWTCredentialsMatcher.class); @Override
public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
String tokenStr = (String) token.getCredentials();
Object stored = info.getCredentials();
String salt = stored.toString(); UserDto userDto = (UserDto) info.getPrincipals().getPrimaryPrincipal();
try{
Algorithm algorithm = Algorithm.HMAC256(salt);
JWTVerifier verifier = JWT.require(algorithm)
.withClaim("username",userDto.getUsername())
.build();
verifier.verify(tokenStr);
return true;
}catch (JWTVerificationException e){
logger.error("Token Error:{}", e.getMessage());
}
return false;
}
}

至此,非首次登录逻辑也结束了!

11.说了这么多,似乎还没说到角色咋回事,先看前面的ShiroFilterChainDefinition内容:

 chainDefinition.addPathDefinition("/admin/**", "noSessionCreation,authcToken,anyRole[admin,manager]");
chainDefinition.addPathDefinition("/**", "noSessionCreation,authcToken");

shiro中是通过AuthorizationFilter来进行角色过滤,逻辑就是在请求进入这个filter后,shiro会调用所有配置的Realm获取用户的角色信息,然后和Filter中配置的角色做对比,匹配就可以通过,也就是各realm中的doGetAuthorizationInfo方法返回的AuthorizationInfo对象,注意默认的Filter只提供‘并’比对,比如‘Role[admin,manager]’即表示要具备admin和manager角色,上面的'authcToken'即表示要通过用户认证,项目中自定义了AnyRolesAuthorizationFilter,故‘anyRole[admin,manager]’表示要具备admin或manager角色,其实,shiro还提供了注解模式,比如@RequiresRoles("admin"),即表示需要admin角色:

@RequiresRoles("admin")
@GetMapping("/test")
public ResponseEntity test(){
return null;
}

再来看AnyRolesAuthorizationFilter,重写了isAccessAllowed方法,其中实现了role的‘或’比对,

public class AnyRolesAuthorizationFilter extends AuthorizationFilter {

    @Override
protected void postHandle(ServletRequest request, ServletResponse response){
request.setAttribute("anyRolesAuthFilter.FILTERED", true);
}
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
Boolean afterFiltered = (Boolean) request.getAttribute("anyRolesAuthFilter.FILTERED");
if (BooleanUtils.isTrue(afterFiltered)){
return true;
}
Subject subject = getSubject(request,response);
String[] rolesArray = (String[]) mappedValue;
//没有角色限制,有权限访问
if (rolesArray == null || rolesArray.length == 0 ){
return true;
}
for (String role : rolesArray
) {
if (subject.hasRole(role)){
return true;
}
}
return false;
} @Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws IOException {
HttpServletResponse httpServletResponse = WebUtils.toHttp(response);
httpServletResponse.setCharacterEncoding("UTF-8");
httpServletResponse.setContentType("application/json;charset=utf-8");
httpServletResponse.setStatus(HttpStatus.SC_UNAUTHORIZED);
return false;
}
}

提一下session禁用:因为用了jwt的访问认证,所以要把默认session支持关掉,前面conf中通过sessionStorageEvaluator禁用,还需要加上以下配置,因为有些请求,并没有通过认证但也可以继续访问,因此这里对所有URL做设置;

chainDefinition.addPathDefinition("/**", "noSessionCreation,authcToken");

12.SSO改造:即在admin模块设计一个专门的登录认证服务,供其他服务RPC调用,具体在com.biao.mall.admin.service.AuthServiceImpl,其他服务使用filter或interceptor,过滤后直接调用此方法,二次登录,可以在各自服务内实现,后续我再完善。

@Override
public String loginAuth(UserDto loginInfo) {
Subject subject = SecurityUtils.getSubject();
try{
UsernamePasswordToken token = new UsernamePasswordToken(loginInfo.getUsername(),loginInfo.getPassword());
subject.login(token);
UserDto userDto = (UserDto) subject.getPrincipals().getPrimaryPrincipal();
String newToken = userService.generateJwtToken(userDto.getUsername());
return newToken;
} catch (AuthenticationException e) {
logger.error("User {} loginAuth fail, Reason:{}", loginInfo.getUsername(), e.getMessage());
} catch (Exception e) {
logger.error("User {} loginAuth fail, Reason:{}", loginInfo.getUsername(), e.getMessage());
}
return null;
}

13.终于到了测试了,写的都快晕了,启动:ZK-->Redis-->Rocket-->Stock-->Business-->Logistic-->Admin, 模拟login:

提交后,获得JWT:

做个jwt合法验证, 如果填写错误的jwt加密盐:

填写正确的salt后:

输入错误的username和pwd,会提示:

2019-09-07 18:49:27.509 ERROR 15816 --- [nio-8087-exec-4] c.b.m.admin.controller.AdminController   : User admin login fail, Reason:No account information found for authentication token [org.apache.shiro.authc.UsernamePasswordToken - admin, rememberMe=false] by this Authenticator instance.  Please check that it is configured correctly.

14.二次登录测试权限,controller中写两个测试URL,并配上角色权限要求:

@RequiresRoles("manager")
@GetMapping("/manager")
public ResponseEntity test(HttpServletRequest request, HttpServletResponse response){
return ResponseEntity.ok(request.getHeader("x-auth-token"));
} @RequiresRoles("admin")
@GetMapping("/admin")
public ResponseEntity test2(HttpServletRequest request, HttpServletResponse response){
return ResponseEntity.ok(request.getHeader("x-auth-token"));
}

首次访问生成JWT:

携带正确的JWT访问,但无"manager"权限情况:

携带正确的JWT访问,有"admin"权限:

15.项目代码地址:其中的day12 https://github.com/xiexiaobiao/dubbo-project.git

后记:

1.JWT的优缺点:JWT不仅可用于认证,还可用于信息交换,优点就是简单,保存在客户端,可减轻服务端负载,最大缺点就是服务器无状态,所以在使用期间,无法取消或更改token权限,即jwt一旦签发,有效期内将一直有效。另外,jwt本身包含身份验证信息,一旦泄漏,将可非法获得token的所有权限。

2.shiro比较springSecurity:shiro优点就是轻量级,完全不依赖spring,适用于常见的权限管理场景,springSecurity对spring整合较好,实现了一些组件功能。很多概念两者相通或近似,springSecurity更为复杂。

3.权限控制使用拦截器Interceptor也是可以的,

4.本项目代码,参考了他人简书上博文代码,免得重复造轮子,

往期文章推荐:

我的个人公众号:

最新文章

  1. myeclipse如何修改Web项目名称,eclipse如何修改项目名字
  2. Java微信公众号开发
  3. 代码片段:基于 JDK 8 time包的时间工具类 TimeUtil
  4. Ajaxupload插件超级简单使用(php的ci框架)
  5. html 表单初步学习
  6. CCLayer在Touch事件(Standard Touch Delegate和Targeted Touch Delegate)
  7. (中等) POJ 2482 Stars in Your Window,静态二叉树。
  8. The Go Programming Language. Notes.
  9. WPF 自定义DataGrid控件样式
  10. PHP中的 $_SERVER 函数说明详解
  11. Asp.Net Core中利用Seq组件展示结构化日志功能
  12. Fiddler忽略捕捉大文件流
  13. Nginx try_files 指令
  14. [转]bitcoin: 通过 rpc 请求节点数据
  15. linux shell 脚本攻略学习8---md5校验,sort排序,uniq命令详解
  16. svn搭建多版本共存记录
  17. layer子窗口与父窗口传值
  18. vue 报错./lib/html5-entities.js, this relative module was not found
  19. Python入门-深浅拷贝
  20. KRBTabControl

热门文章

  1. switch语句(下)(转载)
  2. ImportError: cannot import name &#39;_obtain_input_shape&#39; from &#39;keras.applications.imagenet_utils&#39;
  3. 使用Python爬取淘宝两千款套套
  4. 【强联通图 | 强联通分量】HDU 1269 迷宫城堡 【Kosaraju或Tarjan算法】
  5. 模板汇总——ST(暂)
  6. 牛客小白月赛6 I 公交线路 最短路 模板题
  7. yzoj2057 x 题解
  8. ☆1003 Dijstra
  9. android CTS 介绍
  10. 调度系统Airflow的第一个DAG