Java EE 7 集成Apache Shiro

Apache Shiro 是一个强大并且易用的Java安全框架,它的主要功能有认证(authentication),鉴权(authorization),加密和会话管理。在Shiro的易用的API帮助下,我们可以快速便捷地保护任何应用 - 从最小的移动端应用到大型的web企业级应用。

我们在用Java EE构建企业级web应用时,安全是必不可少的。 安全是一个宽泛的概念,在我们的web应用中来说主要有以下几方面:

  • 认证(authentication):确定“你”是不是“你”,与之相关动作的是登录;
  • 鉴权(authorization):确定“你”能不能进行某些操作;
  • 加密:一般来说是用户登录口令的加密存储;

Shiro提供的一系列功能正满足Java EE应用的安全需求,这里我就来介绍一下如何用Shiro来“保护”我们的Java EE web应用。我这里会用到Wildfly 10作为Java EE web应用的应用服务器,maven作为项目的构建工具。下面我们就来看一下具体步骤(源码可以在我的github找到):

添加依赖

<dependency>
  <groupId>javax.enterprise</groupId>
  <artifactId>cdi-api</artifactId>
  <version>1.2</version>
  <scope>provided</scope>
</dependency>
<dependency>
  <groupId>org.jboss.resteasy</groupId>
  <artifactId>jaxrs-api</artifactId>
  <version>3.0.10.Final</version>
</dependency>
<dependency>
  <groupId>org.apache.shiro</groupId>
  <artifactId>shiro-core</artifactId>
  <version>1.3.2</version>
</dependency>
<dependency>
  <groupId>org.apache.shiro</groupId>
  <artifactId>shiro-web</artifactId>
  <version>1.3.2</version>
</dependency>

这里我引入了 cdi-api ,因为后面我们会用到拦截器(Interceptor)来实现Shiro的 RequiresRoles 等注解的解析;我们会以REST接口的形式来演示如何保护应用的对外接口,所以这里我引入了 jaxrs-api ;接下来的是Shiro的相关依赖。

一个简单的Web应用

我们用 jaxrs-api 来实现一个简单的web应用,首先来配置jaxrs:

src/main/java/com/github/holyloop/rest/config/JaxRsActivator.java:

@ApplicationPath("/api")
public class JaxRsActivator extends Application {
    /* class body intentionally left blank */
}

这样我们的web应用的REST接口路径就是 $context-root/api/* 了。

添加几个简单的REST接口,首先添加一个任何人都可以访问的接口:

src/main/java/com/github/holyloop/rest/controller/AnonController.java:

@Path("/anon")
public class AnonController {
    @GET
    @Path("/greet")
    @Produces(MediaType.APPLICATION_JSON)
    public Response anyoneCanAccess() {
        return Response.status(Status.OK).entity("hello").build();
    }
}

再添加一个受保护的接口(当然,在没配置Shiro之前,它并不是受保护的):

src/main/java/com/github/holyloop/rest/controller/SecuredController.java:

@Path("/secured")
public class SecuredController {
    @GET
    @Path("/root")
    @Produces(MediaType.APPLICATION_JSON)
    public Response roleRootNeeded() {
        return Response.status(Status.OK).entity("hello, i'm secured").build();
    }
}

配置Shiro

这里方便起见,用户和角色的定义都放在配置文件里面,下一篇文章我会讲解如何配置基于数据库的用户/角色/权限管理。

src/main/webapp/WEB-INF/shiro.ini:

# =============================================================================
# Apache Shiro INI 配置
# =============================================================================

[main]
# authc 是 org.apache.shiro.web.filter.authc.FormAuthenticationFilter 的缩写
authc.loginUrl = /login

# -----------------------------------------------------------------------------
# 用户和他们的角色(可选的)
# username = password, role1, role2, ..., roleN
# -----------------------------------------------------------------------------
[users]
root = secret, admin
guest = guest, guest

# -----------------------------------------------------------------------------
# 角色和其被授予的权限
# roleName = perm1, perm2, ..., permN
# -----------------------------------------------------------------------------
[roles]
admin = *

# -----------------------------------------------------------------------------
# 资源和filter chains
# _URL_Ant_Path_Expression_ = _Path_Specific_Filter_Chain_
# -----------------------------------------------------------------------------
[urls]
/index.html = anon
/api/anon/** = anon
/login = authc
/api/secured/** = authc

首先 [main] 配置块里面配置了 authc 过滤器的 loginUrl 值为 /loginauthc 是Shiro为我们提供的几个默认过滤器之一,其他过滤器的定义可参考这里;然后我们分别在 [users][roles] 块里面配置了用户和角色;[urls] 中配置了资源路径和对应的过滤器。

需要注意的是URL是相对于应用根路径(context-root)的路径,假如我们的应用一开始为 www.somehost.com/myapp ,后来因为一些原因更改为 www.anotherhost.com 了,这里的url配置依然有效;还有比较重要的一点是URL定义的顺序很重要,一个准则是“谁先匹配谁胜出”(FIRST MATCH WINS),例如我们
定义了:

/account/** = ssl, authc
/account/signup = anon

那么就会出现问题:假如一个请求想访问 /account/signup/index.html (应允许任意用户访问),它将永远不会被处理,因为 /account/** 先匹配该路
径,它“短路”了剩余的匹配定义。

加载Shiro配置

我们通过 web.xml 来加载刚刚配置的Shiro:

src/main/webapp/WEB-INF/web.xml:

<?xml version="1.0" encoding="UTF-8"?>
<web-app id="WebApp_ID" version="3.0"
  xmlns="http://java.sun.com/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd">

  <session-config>
    <session-timeout>30</session-timeout>
  </session-config>

  <!-- listener -->
  <!-- EnvironmentLoaderListener 默认情况下使用 /WEB-INF/shiro.ini (或classpath:shiro.ini 
    作为配置文件 -->
  <listener>
    <listener-class>org.apache.shiro.web.env.EnvironmentLoaderListener</listener-class>
  </listener>

  <!-- filter -->
  <!-- ShiroFilter 使用 EnvironmentLoaderListener 初始化的 WebEnvironment 做必要的安全操作 -->
  <filter>
    <filter-name>ShiroFilter</filter-name>
    <filter-class>org.apache.shiro.web.servlet.ShiroFilter</filter-class>
  </filter>

  <filter-mapping>
    <filter-name>ShiroFilter</filter-name>
    <url-pattern>/*</url-pattern>
    <dispatcher>REQUEST</dispatcher>
    <dispatcher>FORWARD</dispatcher>
    <dispatcher>INCLUDE</dispatcher>
    <dispatcher>ERROR</dispatcher>
  </filter-mapping>

</web-app>

部署测试

接下来将应用部署到Wildfly(或Glassfish等适配Java EE的应用服务器),示例中将应用部署到了本机的8080端口;我这里用curl测试Shiro是否正常工作:

直接访问

首先我们在不登录的情况下分别请求之前定义的两个REST接口:

  • 任意用户可访问的 /anon/greet 接口:
curl -i localhost:8080/shiro-basic/api/anon/greet


shiro-basic 是应用上下文(context-root),可以看到响应为200,响应body为"hello"

  • 需要登录才能访问的 /secured/root 接口:
curl -i localhost:8080/shiro-basic/secured/root


可以看到一个重定向的响应,这就是我们配置的 /api/secured/** = authc 起作用了,未认证的用户请求了需要认证的接口,Shiro帮我们把请求重定向到了登录位置(见响应头中的 Set-Cookie 项),也就是我们配置的 authc.loginUrl

登录

我们在 shiro.ini 中配置了 authc.loginUrl = /login ,authc是表单认证过滤器,所以登录操作为:

curl -i -d "username=guest&password=guest" -c cookies "localhost:8080/shiro-basic/login"


响应头中 Set-Cookie 字段指定了此次会话的会话ID(JSESSIONID项),可以看一下curl命令中指定的cookie文件(比对响应头中的Set-Cookie字段值和cookies文件的内容 ):

登录完成之后,带上有效的cookie我们就是Shiro认证通过的用户了,可以正常请求需要认证的接口:

curl -i -b cookies localhost:8080/shiro-basic/api/secured/root

基于注解的鉴权

在实际项目开发中,我们常用注解来进行角色和权限的判断,比如:

@RequiresRoles("admin")
public void foo() {}

这表示 foo() 的执行需要请求者拥有“admin”角色,同时该类注解还支持逻辑操作,比如:

@RequiresRoles(value = {"admin","sa"}, logical = Logical.OR)

为了让 @RequiresRoles , @RequiresPermissions 等生效,我们需要借用拦截器(Interceptor)来帮我们完成解析工作。

定义ShiroSecured注解

首先我们自定义一个Shiro专用的拦截器绑定:

src/main/java/com/github/holyloop/interceptor/ShiroSecured.java:

@InterceptorBinding
@Retention(RUNTIME)
@Target({ TYPE, METHOD })
public @interface ShiroSecured {}

再实现拦截器:

src/main/java/com/github/holyloop/interceptor/ShiroSecuredInterceptor.java:

@Interceptor
@ShiroSecured
public class ShiroSecuredInterceptor {

    @AroundInvoke
    public Object interceptShiroSecurity(InvocationContext context) throws Exception {
        Class<?> c = context.getTarget().getClass();
        Method m = context.getMethod();
        Subject subject = SecurityUtils.getSubject();

        if (!subject.isAuthenticated() && hasAnnotation(c, m, RequiresAuthentication.class)) {
            throw new UnauthenticatedException("Authentication required");
        }

        if (subject.getPrincipal() != null && hasAnnotation(c, m, RequiresGuest.class)) {
            throw new UnauthenticatedException("Guest required");
        }

        if (subject.getPrincipal() == null && hasAnnotation(c, m, RequiresUser.class)) {
            throw new UnauthenticatedException("User required");
        }

        RequiresRoles roles = getAnnotation(c, m, RequiresRoles.class);

        if (roles != null) {
            // logic and
            if (Logical.AND.equals(roles.logical())) {
                subject.checkRoles(Arrays.asList(roles.value()));
            }
            // logic or
            if (Logical.OR.equals(roles.logical())) {
                boolean hasAtLeastOneRole = false;
                for (String role : roles.value()) {
                    if (subject.hasRole(role)) {
                        hasAtLeastOneRole = true;
                        break;
                    }
                }
                if (!hasAtLeastOneRole) {
                    throw new AuthorizationException();
                }
            }
        }

        RequiresPermissions permissions = getAnnotation(c, m, RequiresPermissions.class);

        if (permissions != null) {
            // logic and
            if (Logical.AND.equals(permissions.logical())) {
                subject.checkPermissions(permissions.value());
            }
            // logic or
            if (Logical.OR.equals(permissions.logical())) {
                boolean hasAtLeastOnePermission = false;
                for (String permission : permissions.value()) {
                    if (subject.isPermitted(permission)) {
                        hasAtLeastOnePermission = true;
                        break;
                    }
                }
                if (!hasAtLeastOnePermission) {
                    throw new AuthorizationException();
                }
            }
        }

        return context.proceed();
    }

    /**
     * 被调用方法是否有指定的注解
     * 
     * @param c 被调用方法所属的类
     * @param m 被调用方法
     * @param a 目标注解
     * @return
     */
    private static boolean hasAnnotation(Class<?> c, Method m, Class<? extends Annotation> a) {
        return m.isAnnotationPresent(a) || c.isAnnotationPresent(a) || c.getSuperclass().isAnnotationPresent(a);
    }

    /**
     * 获取被调用方法指定的注解详情
     * 
     * @param c 被调用方法所属的类
     * @param m 被调用方法
     * @param a 目标注解
     * @return
     */
    private static <A extends Annotation> A getAnnotation(Class<?> c, Method m, Class<A> a) {
        return m.isAnnotationPresent(a) ? m.getAnnotation(a)
                : c.isAnnotationPresent(a) ? c.getAnnotation(a) : c.getSuperclass().getAnnotation(a);
    }
}

注意这里我抛出的几个认证/鉴权失败的异常,稍后我们会处理它们。

接下来,我们需要显式地启用该拦截器:

src/main/webapp/WEB-INF/beans.xml:

<beans xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="
        http://xmlns.jcp.org/xml/ns/javaee
        http://xmlns.jcp.org/xml/ns/javaee/beans_1_1.xsd"
  bean-discovery-mode="all">

    <interceptors>
        <class>com.github.holyloop.interceptor.ShiroSecuredInterceptor</class>
    </interceptors>
    
</beans>

处理鉴权异常

在我们的拦截器中,视情况而定会抛出 AuthorizationExceptionUnauthenticatedException ,分别表示未授权和未认证,通常在HTTP响应码中分别对应403和401。这里我们需要处理这两个异常,将冗长的异常信息封装为调用者可理解的消息。

src/main/java/com/github/holyloop/rest/config/RestUnauthorizedExceptionMapper.java:

@Provider
public class RestUnauthorizedExceptionMapper implements ExceptionMapper<AuthorizationException> {

    @Override
    public Response toResponse(AuthorizationException arg0) {
        return Response.status(Status.FORBIDDEN)
                .entity(new ExceptionMessageWrapper(403, "forbidden"))
                .build();
    }

}

src/main/java/com/github/holyloop/rest/config/RestUnauthenticatedExceptionMapper.java:

@Provider
public class RestUnauthenticatedExceptionMapper implements ExceptionMapper<UnauthenticatedException> {

    @Override
    public Response toResponse(UnauthenticatedException arg0) {
        return Response.status(Status.UNAUTHORIZED)
                .entity(new ExceptionMessageWrapper(401, "unauthorized"))
                .build();
    }

}

其中 ExceptionMessageWrapper 是我自定义的一个异常消息包装,可有可无(不需要它的情况下让响应的entity()为空即可),它的结构很简单:

private int code;
private String message;

public ExceptionMessageWrapper(int code, String message) {
  super();
  this.code = code;
  this.message = message;
}

应用鉴权注解

接下来我们简单地应用一下鉴权注解,修改 src/main/java/com/github/holyloop/rest/controller/SecuredController.java 为:

@ShiroSecured
@Path("/secured")
public class SecuredController {

    @RequiresRoles("admin")
    @GET
    @Path("/root")
    @Produces(MediaType.APPLICATION_JSON)
    public Response roleRootNeeded() {
        return Response.status(Status.OK).entity("hello, i'm secured").build();
    }

}

我们在类声明上添加了 @ShiroSecured ,这样该类的所有方法在调用时都会被 ShiroSecuredInterceptor 处理;roleRootNeeded 方法上添加了 @RequiresRoles("admin") ,表明该方法的调用者需要拥有admin角色。

部署测试

先用一个没有admin角色的用户请求该接口:

curl -i -d "username=guest&password=guest" -c cookies "localhost:8080/shiro-basic/login"
curl -i -b cookies localhost:8080/shiro-basic/api/secured/root

结果如下:

可以看到响应为403;接下来切换为拥有admin角色的用户测试:

curl -i -d "username=root&password=secret" -c cookies "localhost:8080/shiro-basic/login"
curl -i -b cookies localhost:8080/shiro-basic/api/secured/root

结果如下:

那么Shiro的鉴权注解就生效了。

总结

以上就是在Java EE环境中集成Shiro的基本方法,我们在 shiro.ini 中配置了基于表单的认证过滤器 authc ;添加了几个硬编码的用户和角色;添加了Shiro鉴权注解的拦截器,使得鉴权变得更灵活方便。下一篇文章我会介绍基于数据库的Shiro认证/鉴权配置方法。

posted @ 2018-04-22 20:43  holyloop  阅读(399)  评论(0编辑  收藏  举报