1. 前言
最近项目中使用到了shiro作为用户身份验证以及访问权限控制的安全框架,本文主要简单介绍一下使用过程。
2. shiro简介
shiro是apache上的一个java实现的开源安全框架,提供了以下基本功能:
- anthentication,身份认证,比如username/password的验证。
- authorization,权限验证,即访问控制,比如restful api中判断某个用户是否拥有GET的权限。
- session manager,会话管理,和web中session概念类似。
- Cryptography,数据加密传输。
shiro的架构图如下:
说明
-
Subject, shiro Subject可以理解成访问当前程序(当前程序也就是需要使用安全框架保护的程序)的用户,用户可以是web上登录的用户,也可以某个正在试图访问当前程序的调度程序等等。shiro提供的功能可以通过Subject一组方法完成,比如(只列举了一部分):
- boolean isPermitted(String permission)
- boolean isPermitted(Permission permission)
属于shiro的authorization功能,string类型的权限最终也会转换成具体的Permission类,isPermitted用来判断用户(subject)是否具有perssion权限,也就是说当前subject拥有的权限是否是permission的超集。 - public void login(AuthenticationToken token)
- public void logout()
shiro的authentication功能。提供用户身份认证。
登录,比如用户使用username/password登录时,可以这样调用://获取当前user的Subject Subject currentUser = SecurityUtils.getSubject(); currentUser.login(new new UsernamePasswordToken(username, password)).
- Session getSession();
获得会话,没有会创建新的。
SecurityManager
SecurityManager时shiro的核心,从上图可以看出SecurityManager包含了一些组件共同提供了shiro的所有功能,Subject类似更像一个shell,Subject的调用都会走向SecurityManager来完成核心的authentication/authorization/sessionmanager等功能。Realm
上图中Pluggable Realms,shiro中唯一需要我们自己实现的部分,SecurityManager通过authenticato/authorizer替我们完成用户认证以及权限控制,但是验证用户身份时的用户身份信息,验证用户权限时用户的权限信息都需要我们自己定义好告诉shiro,这部分工作就是realm应该完成的,realm因此可以理解成数据源,比如你的用户信息存储在mysql里,那么你自定义的realm就需要从mysql里获得用户省份信息,以及权限信息。
3. 和spring boot结合
在和spring boot结合的过程中,我需要做的主要有以下几个部分:
- 自定义realm,继承抽象类AuthorizingRealm实现它的几个抽象方法获取用户身份信息,以及用户权限信息。我的用户身份信息保存在mysql中。
- 自定义Permission,实现Permission接口。Permission对应用户的访问权限信息。
- 实现ShiroConfig类,由于spring boot不像springMVC中从“applicationContext.xml”加载所有bean信息,因此定义了ShiroConfig,并使用
@Configuration
,通过java标注的方式加载装配shiro各个组件
注:(关于使用xml配置文件和springMVC结合可以参考apache shiro:integrate with spring 和 shiro 与spring集成) - 实现Filter,在springMVC中,通过DispactherServlet来完成对不同url的处理(包括调用filter以及使用其他servlet处理等), 和springboot结合中,想要shiro 的authorizition/anthentication组件发挥作用,需要定义filter来对不同的url做出处理。
3.1 自定义realm
自定义realm,继承AuthorizingRealm
并实现它的抽象方法:
public class ShiroMysqlRealm extends AuthorizingRealm {
@Autowired
private UserDAO userDAO;
public ShiroMysqlRealm() {
}
/**
实现这个方法获取用户权限,这里不需要你检查权限,只需要获取用户权限返回就行了,当调用Subject#isPermitted(Permission permission)时,
会调用这个方法获得用户拥有的权限,然后对用户拥有的每一个Permission
都会调用Permission # public boolean implies(Permission permission)。
implies的参数是permission是要检查的权限(或者叫用户本次操作需要拥有的权限),调用implies检查用户拥有的权限是否包含要检查的permission。
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
String username = (String) principalCollection.getPrimaryPrincipal();
// 通过userDAO获取到用户拥有的所有权限
List<Permission> permissions = userDAO.getUserPermissions(username);
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
authorizationInfo.addObjectPermissions(permissions);
return authorizationInfo;
}
/*
实现这个方法,完成用户身份验证,当调用Subject#login时,最终会走向这个方法.
方法参数‘ authenticationToken’是当前用户使用的username/password,我们 需要验证username/password是否有效。
验证过程很简单,通过userDAO从mysql获取用户username/password, 然后和参数传过来的比较一下就行了。
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) {
// 调用subject#login时,我们使用的是UsernamePasswordToken,这里校验一下,不是就反回null,返回null就意味着用户身份验证不通过。
if(!(authenticationToken instanceof UsernamePasswordToken)){
return null;
}
UsernamePasswordToken login = (UsernamePasswordToken) authenticationToken;
// 通过userDAO获取到存在mysql里的username/password
User user = userDAO.getUserByName(login.getUsername()
)
// 判断一下和数据库存的password是不是一样的,一样表示验证通过。
if (user != null && StringUtils.equals(login.getPassword(), user.getPassword))) {
return new SimpleAuthenticationInfo(login.getUsername(), String.valueOf(login.getPassword()), getName());
} else {
return null;
}
}
}
------------------------------------------------
关于doGetAuthenticationInfo这个方法,它的参数时当前尝试登录用户传过来的登录信息,它返回的是我们存储的的该用户的真实的信息,最终是拿两者比较来确定是否登录成功。
login时有这样一个调用链:
Subject # login -> ... -> AuthenticatingRealm # getAuthenticationInfo ->
AuthenticatingRealm # doGetAuthenticationInfo -> AuthenticatingRealm # assertCredentialsMatch
我重写了doGetAuthenticationInfo, 直接在里面完成验证,也可以重写assertCredentialsMatch去完成验证。
3.2 自定义permission
permission即用户权限,上面自定义realm中doGetAuthorizationInfo
通过userDAO从数据库中获取某个用户的所有权限,这里就需要我们自定义权限类,这里主要检查restful接口的一些操作权限如下:
- GET
- POST
- PUT
- DELETE
- ALL (拥有以上所有权限)
类如下:
//自定义的permission需要实现Permission接口
public class RestPermission implements org.apache.shiro.authz.Permission{
private RestType restType;
public RestPermission(RestType restType) {
this.restType = restType;
}
pulic RestPermission(String restType){
this(RestType.fromString(restType));
}
/**
只要实现这一个方法,判断当前拥有的权限(也就是this)是否包含p。
*/
@Override
public boolean implies(org.apache.shiro.authz.Permission p) {
if(p == null || !(p instanceof RestPermission)){
return false;
}
RestPermission rp = (RestPermission) p;
//当前拥有all权限或者当前权限和检查的权限一样返回true,验证通过
if(this.restType == RestType.ALL || this.restType == rp.restType){
return true;
}
return false;
}
enum RestType{
GET("GET"),
POST("POST"),
PUT("PUT"),
DELETE("DELETE"),
ALL("ALL"),
UNKOWN("UNKOWN");
private String type;
RestType(String type) {
this.type = type;
}
static RestType fromString(String type){
if(StringUtils.isBlank(type)){
return UNKOWN;
}
String t = type.trim().toUpperCase();
if(t.equals("GET")){
return GET;
}else if(t.equals("POST")){
return POST;
}else if(t.equals("PUT")){
return PUT;
}else if(t.equals("DELETE")){
return DELETE;
}else if(t.equals("ALL")){
return ALL;
}else{
return UNKOWN;
}
}
}
}
3.3 定义ShiroConfig
spring boot中不使用从xml文件加载bean,因此实现ShiroConfig,并使用@Configuration
注解来完成shiro中必要组件加载组装。
@Configuration
public class ShiroConfig {
public ShiroConfig() {
}
@Bean
public Realm shiroRealm() {
return new ShiroMysqlRealm();
}
@Bean(name = "lifecycleBeanPostProcessor")
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}
@Bean(name = "securityManager")
public DefaultWebSecurityManager securityManager() {
DefaultWebSecurityManager sm = new DefaultWebSecurityManager();
sm.setRealm(shiroRealm());
sm.setSessionManager(sessionManager());
return sm;
}
private SessionManager sessionManager() {
DefaultWebSessionManager sm = new DefaultWebSessionManager();
Cookie cookie = new SimpleCookie("XXXXXXX");
cookie.setHttpOnly(true);
cookie.setMaxAge(60 * 60); // expire in 1 hour
sm.setSessionIdCookie(cookie);
return sm;
}
@Bean
public MethodInvokingFactoryBean methodInvokingFactoryBean() {
MethodInvokingFactoryBean methodInvokingFactoryBean = new MethodInvokingFactoryBean();
methodInvokingFactoryBean.setStaticMethod("org.apache.shiro.SecurityUtils.setSecurityManager");
methodInvokingFactoryBean.setArguments(new Object[]{securityManager()});
return methodInvokingFactoryBean;
}
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultWebSecurityManager sm) {
AuthorizationAttributeSourceAdvisor sa = new AuthorizationAttributeSourceAdvisor();
sa.setSecurityManager(sm);
return sa;
}
/**
通过FactoryBean方式创建过滤器,‘ shiroFilter’自身也是一个Filter,它的作用有点类似‘ServleDispacther’, 它里面注册了很多url到filter的映射,
url的访问会先走向‘ shiroFilter’,然后‘ shiroFilter’根据url使用模式匹配去匹配到filterChain, 然后逐个激活filterChain里的filter。
*/
@Bean(name = "shiroFilter")
public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager sm) {
ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
// 设置登录跳转页面
shiroFilter.setLoginUrl("/login");
//登录成功后跳转页面
shiroFilter.setSuccessUrl("/index");
// 权限检查不过的跳转页面
shiroFilter.setUnauthorizedUrl("/forbidden");
//这是一个url pattern到过滤器名称的映射,一个url有多个filter时,过滤器名称用','隔开。
// 注意这里使用了LinkedHashMap,因为url添加的顺序很重要,当'shiroFilter'尝试根据当前url去匹配符合该url的模式时,只返回这个map里第一个匹配成功的url 模式
Map<String, String> filterChainDefinitionMapping = new LinkedHashMap<>();
//为url 模式 设置filter, anon是filter name
filterChainDefinitionMapping.put("/login", "anon");
filterChainDefinitionMapping.put("/", "authc");
filterChainDefinitionMapping.put("/api/v1/**", "authz-rest");
shiroFilter.setFilterChainDefinitionMap(filterChainDefinitionMapping);
shiroFilter.setSecurityManager(sm);
Map<String, Filter> filters = new HashMap<>();
/* 设置filter name到filter实例的映射。这里除了你通过现实的设置filtername
到filter的映射以外, 当前spring 容器里面所有实现了‘javax.servlet.Filter’的bean也会被默认添加进来,它的name就是bean name。
filters.put("anon", new AnonymousFilter());
filters.put("authc", new FormAuthenticationFilter());
filters.put("authz-rest", new RESTAuthzFilter());
shiroFilter.setFilters(filters);
return shiroFilter;
}
}
上面代码中我只自定义了一个filter,即RESTAuthzFilter
, 当访问的url符合"/api/v1/**"模式时就会调用这个filter。 前面我们已经定义了GET、POST、PUT等等的一些rest操作的权限,现在在这个filter里就需要检查一下当前登录用户拥有权限去尽心这个url的操作,实现如下:
public class RESTAuthzFilter extends FormAuthenticationFilter {
// servlet filter中最关键的是doFilter方法,这里实现isAccessAllowed最终会被foFilter调用。在这里检查访问权限。
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
//获得当前用户subject
Subject subject = this.getSubject(request, response);
//检查一下是否登录并通过身份认证
if (subject.isAuthenticated()) {
String uri = WebUtils.toHttp(request).getRequestURI().toString();
//这里getMethod拿到本次url请求的操作(PUT,GET等等),然后构造出permission表示本次操作需要的权限。
RestPermission permission = new RestPermission(WebUtils.toHttp(request).getMethod());
//检查用户是否具有权限,上面提到过这次调用会走到ShiroMysqlRealm#doGetAuthorizationInfo获取用户所拥有的权限,然后对返回的权限再调用RestPermission # implies检查。
return subject.isPermitted(permission)
} else {
return false;
}
}
}