对于Restful架构的API服务来讲,与客户端(App、Web等)进行数据交换时,使用无服务状态的通信机制更为方便。所以session和cookie的使用场景越来越少,取而代之的是JWT的兴起。

Shiro框架

在Spring体系中,相比起Spring Security强大却繁杂的功能,Shiro以其精简易用的处理受到更多开发者的青睐。

在使用Shiro框架前,建议读一读官方的极简教程

Shiro的调用有多种方式,其中最简单的是使用标注的方式。

...
class UserController {

    // 需要有指定两种权限才可以调用
    ....
    @RequiresPermissions({"user:add","user:list"})
    public Object add() {
        ...
    }
    
    // 需要角色admin才可以调用
    ...
    @RequiresRoles("admin")
    public Object block() {
        ...
    }
    
    // 需要登录才可以调用
    ...
    @RequiresAuthentication
    public Object list() {
        ...
    }
    
    // 不需要任何权限就可以调用
    ...
    public Object login() {
    
    }
}

Shiro处理流程概要

待补充

Shiro框架集成JWT

整个集成过程包括3个主要步骤:

  1. 自定义Realm来处理鉴权与授权
  2. 自定义Filter来处理登录过程的调用
  3. 自定义Config来配置Filter和Realm的使用

1. 引入shiro包

首先,pom文件中引入shiro包。

pom.xml

...
    <dependencies>
        ...
        <dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-spring</artifactId>
            <version>1.4.0</version>
        </dependency>
    </dependencies>
...

2. 自定义realm

realm意为领域、场所,在shiro框架中它负责取得鉴权(authentication)和授权(authorize)的处理。

简单来说,鉴权指的是判断用户是否已登录的处理,授权指的是用户是否有权限进行操作的处理。对应上面的例子,@RequiresAuthentication将调用鉴权处理,@RequiresRoles和@RequiresPermissions将调用授权处理。

所以,自定义realm实现的最基本的两个方法,一个用以返回鉴权信息,一个用以返回权限角色信息。

JwtRealm.java

@Service
public class JwtReal extends AuthrozingRealm {
    ...
    
    @Autowired
    private UserService userService;
    
    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof JwtToken;
    }
    
    /**
	 * 授权处理
	 * 取得用户的权限、角色信息
	 */
	@Override
	protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
		
		// 取得user数据
		String username = JwtUtil.getUsername(principals.toString());
		User userBean = userService.findByName(username);
		
		// 返回权限、角色信息(本例用不到,这里可以返回空内容)
		SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
		return simpleAuthorizationInfo;
	}
	
	/**
	 * 鉴权处理
	 * 验证用户的JWT是否合法
	 */
	@Override
	protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
		
		// 取得并解密JWTToken
		String token = (String) auth.getCredentials();
		String username = JwtUtil.getUsername(token);
		if (username == null) {
			throw new AuthenticationException("TOKEN不合法");
		}
		
		// 取得user
		User userBean = userService.findByName(username);
		if (userBean == null) {
			throw new AuthenticationException("用户记录不存在");
		}
		
		// 如果校验不通过(修改密码后仍用老token登录)
		if (!JwtUtil.verify(token, username, userBean.getHash())) {
			throw new AuthenticationException("用户名或密码错误");
		}
		
		return new SimpleAuthenticationInfo(token, token, "jwt_realm");
	}
}

3. 自定义filter

filter继承于BasicHttpAuthenticationFilter类,是shiro框架中的一段处理。这个类的几个回调方法将进行登录的相关鉴权处理。

  1. preHandler:处理进行前的预处理
  2. isAccessAllowed:判断是否允许调用,返回false则请求处理终止
  3. isLoginAttempt:判断是否试图登录,通常header中带有JwtToken则表示true
  4. executeLogin:执行登录操作,这里会调用自定义realm的doGetAuthenticationInfo方法

JwtFilter.java

/**
 * 代码的执行流程preHandle->isAccessAllowed
 *                            ->isLoginAttempt
 *                            ->executeLogin
 */
public class JwtFilter extends BasicHttpAuthenticationFilter {

	private Logger LOGGER = LoggerFactory.getLogger(this.getClass());
	
	@Autowired
	JwtProperties jwtProperties;
	
	/**
	 * 判断用户是否想登入(检测header中是否有JWT字段即可)
	 * @param request
	 * @param response
	 * @return
	 */
	@Override
	protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
		HttpServletRequest req = (HttpServletRequest) request;
		String jwtToken = req.getHeader(jwtProperties.getTokenName());
		return jwtToken != null;
	}
	
	@Override
	protected boolean executeLogin(ServletRequest request, ServletResponse response) {
		
		// 取得TOKEN
		HttpServletRequest req = (HttpServletRequest) request;
		String jwtToken = req.getHeader(jwtProperties.getTokenName());
		
		// 提交Realm进行登录验证
		JwtToken token = new JwtToken(jwtToken);
		getSubject(request, response).login(token);
		
		// 如果没有抛异常表示登录成功
		return true;
	}
	
	/**
	 * 如果没有登录请求,返回true
	 */
	@Override
	protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
		
		if (isLoginAttempt(request, response)) {
			try {
				this.executeLogin(request, response);
			} catch (Exception e) {
				e.printStackTrace();
			}
		}
		
		return true;
	}
}

JwtToken.java

public class JwtToken implements AuthenticationToken {
	
	private static final long serialVersionUID = -801799585507148833L;
	
	private String token;
	
	public JwtToken(String token) {
		this.token = token;
	}

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

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

}

4. 设置ShiroConfig进行配置

以上2步骤自定义了realm和filter,需要配置到shiro中去使用。配置的内容主要包括:

  1. 关闭shiro自带的session功能
  2. 添加自定义的filter
  3. 指定shiro使用自定义的realm进行鉴权和授权操作

ShiroConfig.java

@Configuration
public class ShiroConfig {

	@Bean
    public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
        return new LifecycleBeanPostProcessor();
    }
 
    @Bean
    @DependsOn("lifecycleBeanPostProcessor")
    public static DefaultAdvisorAutoProxyCreator getLifecycleBeanPostProcessor() {
        DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
        // 强制使用cglib,防止重复代理和可能引起代理出错的问题
        defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
        return defaultAdvisorAutoProxyCreator;
    }
 
    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultWebSecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
        advisor.setSecurityManager(securityManager);
        return advisor;
    }
 
    @Bean("securityManager")
    public DefaultWebSecurityManager getManager(JwtRealm jwtRealm){

    	// 设定自定义JWTRealm
        DefaultWebSecurityManager securityManager =  new DefaultWebSecurityManager();
        securityManager.setRealm(jwtRealm);
 
        // 关闭shiro自带的session
        DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
        DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
        defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
        subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
        securityManager.setSubjectDAO(subjectDAO);
 
        //自定义缓存管理
        return securityManager;
    }
 
    @Bean
    public ShiroFilterFactoryBean factory(DefaultWebSecurityManager securityManager) {
    	
        ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
        shiroFilter.setSecurityManager(securityManager);
 
        // 添加jwt过滤器
        Map<String, Filter> filterMap = new HashMap<>();
        filterMap.put("jwt", jwtFilter());
//        filterMap.put("logout", new SystemLogoutFilter());
        shiroFilter.setFilters(filterMap);
 
        //拦截器
        Map<String,String> filterRuleMap = new LinkedHashMap<>();
//        filterRuleMap.put("/logout", "logout");
        filterRuleMap.put("/**", "jwt");   // 所有调用都使用jwt filter
        shiroFilter.setFilterChainDefinitionMap(filterRuleMap);
 
        return shiroFilter;
    }
 
    @Bean
    public JwtFilter jwtFilter(){
        return new JwtFilter();  // 此处为AccessToken
    }
}

5. 其它处理

以上1~4步已经将shiro集成jwt的基本内容完成,剩下的辅助处理包括:

  1. 引入jwt包
  2. 添加Jwt加解密处理
  3. 设置Jwt参数属性(定义application.properties)

pom.xml

...
    <dependencies>
        ...
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>3.4.1</version>
        </dependency>
    </dependencies>
...

JwtUtil.java

public class JwtUtil {
	
	/**
	 * 取得TOKEN中的信息
	 * @param token
	 * @return
	 */
	public static String getUsername(String token) {
		
		try {
			DecodedJWT jwt = JWT.decode(token);
			return jwt.getClaim("username").asString();
		} catch (JWTDecodeException e) {
			return null;
		}
	}

	/**
	 * 校验TOKEN是否正确
	 * @param token
	 * @param name
	 * @param secret
	 * @param expiryTime
	 * @return
	 */
	public static boolean verify(String token, String username, String secret) {
		
		try {
			
			Algorithm algorithm = Algorithm.HMAC256(secret);
			JWTVerifier verifier = JWT.require(algorithm)
					.withClaim("username", username)
					.build();
			verifier.verify(token);
			return true;
		} catch (Exception e) {
			return false;
		}
	}
	
	/**
	 * 生成JWT签名
	 * @param username 登录名
	 * @param secret 密码
	 * @return 加密的TOKEN
	 */
	public static String sign(String username, String secret, Integer expiryTime) {
		
		Date date = new Date(System.currentTimeMillis() + expiryTime);
		Algorithm algorithm = Algorithm.HMAC256(secret);
		
		return JWT.create()
				.withClaim("username", username)
				.withExpiresAt(date)
				.sign(algorithm);
	}
}

JwtProperties.java

@ConfigurationProperties(prefix="jwt")
public class JwtProperties {

	private Integer expireTime;   // 过期时间
	
	private String secretKey;     // 加密密钥
	
	private String tokenName;     // Token名

	public Integer getExpireTime() {
		return expireTime;
	}

	public void setExpireTime(Integer expireTime) {
		this.expireTime = expireTime;
	}

	public String getSecretKey() {
		return secretKey;
	}

	public void setSecretKey(String secretKey) {
		this.secretKey = secretKey;
	}

	public String getTokenName() {
		return tokenName;
	}

	public void setTokenName(String tokenName) {
		this.tokenName = tokenName;
	}
	
}

application.properties

...
jwt.expireTime=3600000  // 60*60*1000=1小时
jwt.secretKey=jwtkey    // 自定义字符串
jwt.tokenName=token     // header中的key名
...

Application.java

...
@EnableConfigurationProperties({JwtProperties.class})
public class Application extends SpringBootServletInitializer {
    ...
}

经过以上步骤,jwt的校验处理已经基本集成到shiro框架中。

处理改进

上述处理已经基本完成了对jwt权限校验的处理,但仍有不足之处,包括:

  1. Shiro框架的没有Exception处理。
  2. 鉴权完成后,controller中如何取得登录用户信息?

Shiro异常处理

在filter和realm的处理中,所有的异常都被抛给了Spring框架,最后返回的异常很难看。框架需要有一个统一格式的异常处理。

ShiroExceptionHandler.java

@ControllerAdvice
public class ShiroExceptionHandler {

	private final static Logger logger = LoggerFactory.getLogger(ShiroExceptionHandler.class);
	
    /**
     * 这里的ShiroException可以根据子类类型来分别做异常处理
     */
	@ExceptionHandler(ShiroException.class)
	@ResponseBody
	public Object doHandleShiroException(ShiroException e) {
		e.printStackTrace();
		return ResponseUtil.badJwtToken();  // 此处为框架自定义的统一处理
	}
	
}

建立登录用户的上下文

在上述的鉴权处理中,框架已经通过UserService获得了登录用户信息,我们需要把它注入到上下文中,方便Controller去使用。

LoginUser.java

public class LoginUser implements Serializable {

	private static final long serialVersionUID = 3830542577385426821L;
	
	private Integer userId;       // 用户ID
	private String name;          // 登录名
	private String nickname;      // 用户昵称
	
	public LoginUser() {}
	
	public LoginUser(String name) {
		this.name = name;
	}
	
	public LoginUser(Integer userId, String name, String nickname) {
		this.userId = userId;
		this.name = name;
		this.nickname = nickname;
	}

	public Integer getUserId() {
		return userId;
	}

	public void setUserId(Integer userId) {
		this.userId = userId;
	}

	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}

	public String getNickname() {
		return nickname;
	}

	public void setNickname(String nickname) {
		this.nickname = nickname;
	}
}

UserContext.java

public class UserContext implements AutoCloseable {

	static final ThreadLocal<LoginUser> current = new ThreadLocal<>();
	
	public UserContext(LoginUser user) {
		current.set(user);
	}
	
	public static LoginUser getCurrentUser() {
		return current.get();
	}
	
	public void close() {
		current.remove();
	}
}

JwtRealm.java

...
@Service
public class JwtRealm extends AuthorizingRealm {
    ...
    @Override
	protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
        ...
        UserContext userContext = new UserContext(new LoginUser(userBean.getId(), userBean.getName(), userBean.getNickname()));
        ...
    }
}

通过以上配置,在调用时只需UserContext.getCurrentUser().getId()就可取得登录用户的ID。