简介

  • 在一些环境中,可能需要把web应用做成无状态的,即服务端无状态,也就是说服务端不会存储像会话这种东西,而是每次请求时带上相应的用户名进行登录。
  • 如不使用OAuth2协议来实现,则可以使用REST+HMAC认证。
  • REST-HMAC:Hash-based Messager Authentication Code,基于散列的消息认证码。使用一个密钥(只有客户端和服务端知道)和一个消息作为输入,生成它们的消息摘要。访问时使用该消息摘要进行传播,服务端对该消息摘要进行验证。
  • 如果以用户名+密码生成消息摘要,一旦被别人而重复使用该摘要进行验证,解决办法如下:
  1. 客户端每次申请一个Token,使用该Token进行加密,而该Token是一次性的;
  2. 客户端每次生成一个唯一的token,使用该Token进行加密,服务器端记录下这些token,如果之前用过就认为是非法请求。
  • 本例直接用请求的参数生成消息摘要,即无法篡改数据,虽然可能被窃取而多次使用,不过解决方法如上所示。

服务器端

  • 控制用户登录时不建立会话,而是每次请求带上用户身份进行认证。

禁止建立会话

  • StatelessDefaultSubjectFactory:自定义Subject工厂,禁止建立会话。
public class StatelessDefaultSubjectFactory extends DefaultWebSubjectFactory {
    @Override
    public Subject createSubject(SubjectContext context){
        //不创建session,之后调用Subject.getSession()将抛出DisabledSessionException
        context.setSessionCreationEnabled(false);

        return super.createSubject(context);
    }
}
  • spring-config-shiro.xml: 注册并使用该Subject工厂。

    <!--Subject工厂: 继承DefaultWebSubjectFactory,禁止建立会话-->
    <bean id="subjectFactory" class="StatelessDefaultSubjectFactory"/>
    
    <!-- 安全管理器 -->
    <bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
        <property name="realm" ref="statelessRealm"/>
        <property name="subjectDAO.sessionStorageEvaluator.sessionStorageEnabled" value="false"/>
        <property name="subjectFactory" ref="subjectFactory"/>
        <property name="sessionManager" ref="sessionManager"/>
    </bean>
    

认证

  • StatelessAuthcFilter:拦截每次请求进行认证。主要是获取请求中包含的用户名、参数和消息摘要,封装成token,然后传给realm进行认证。
public class StatelessAuthcFilter extends AccessControlFilter {
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
        return false;
    }

    /**
     * @Author haien
     * @Description 获取客户端传入的用户名、参数和消息摘要,生成StatelessToken,
                    然后交给realm登录(实际是认证)。
     * @Date 2019/4/8
     * @Param [request, response]
     * @return boolean
     **/
    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response)
            throws Exception {
        //1. 获取客户端生成的消息摘要
        String clientDigest=request.getParameter(Constants.PARAM_DIGEST);
        //2. 获取客户端传入的用户身份
        String username=request.getParameter(Constants.PARAM_USERNAME);
        //3. 获取客户端请求的参数列表
        Map<String,String[]> params=new HashMap<>(request.getParameterMap());
        params.remove(Constants.PARAM_DIGEST);
        //4. 封装成无状态Token
        StatelessToken token=new StatelessToken(username,params,clientDigest);
        //5. 委托给realm进行登录,准确说是认证
        try {
            getSubject(request, response).login(token);
        }catch (Exception e){
            e.printStackTrace();
            //6. 认证失败处理
            onLoginFail(response);
            return false;
        }

        //认证成功返回true,执行其他过滤链
        return true;
    }

    private void onLoginFail(ServletResponse response) throws IOException {
        HttpServletResponse httpResponse=(HttpServletResponse)response;
        httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        httpResponse.getWriter().write("login error");
    }
}
  • 其中用到的token类为StatelessToken:
/**
 * @Author haien
 * @Description 将客户端传来的用户信息、请求参数和消息摘要封装成无状态Token
 * @Date 2019/4/8
 **/
public class StatelessToken implements AuthenticationToken {
    private String username;
    private Map<String,?> params;
    private String clientDigest;

    //全参构造器

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

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

    //getter、setter
}
  • StatelessRealm:自定义realm实现认证,主要是服务端自己生成消息摘要,再配合用户名封装成AuthenticationInfo,跟token比对。其实和之前用户名+密码生成的Info、token相比,也只是把密码换成了消息摘要而已。
public class StatelessRealm extends AuthorizingRealm {

    //supports、doGetAuthorizationIfo方法

    /**
     * @Author haien
     * @Description 服务端利用用户名和请求参数生成消息摘要,与客户端消息摘要匹配,
                    以此作为身份验证; 缺点是一旦别人截获请求,则可以重复请求。
     * @Date 2019/4/8
     * @Param [token]
     * @return org.apache.shiro.authc.AuthenticationInfo
     **/
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token)
            throws AuthenticationException {
        StatelessToken statelessToken=(StatelessToken)token;
        String username=statelessToken.getUsername();

        //根据用户名获取密钥(和客户端一样)
        String key=getKey(username);
        //在服务器端使用对客户端参数生成消息摘要
        String serverDigest=HmacSHA256Utils.digest(key,statelessToken.getParams());

        //然后进行客户端消息摘要和服务端消息摘要的匹配
        return new SimpleAuthenticationInfo(username,serverDigest,getName());
    }

    /**
     * @Author haien
     * @Description 获取服务端密钥,此处硬编码一个
                    (原本应该是有一套和客户端一致的算法的)
     * @Date 2019/4/8
     * @Param [username]
     * @return java.lang.String
     **/
    private String getKey(String username){
        if("admin".equals(username))
            return "dadadswdewq2ewdwqdwadsadasd";

        return null;
    }
}
  • spring-config-shiro.xml: 配置以上过滤器和realm。

    <!-- Realm实现 -->
    <bean id="statelessRealm" class="com.haien.chapter20.realm.StatelessRealm">
        <!--默认应该是true-->
        <property name="cachingEnabled" value="false"/>
    </bean>
    <!-- 安全管理器 -->
    <bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
        <property name="realm" ref="statelessRealm"/>
        <property name="subjectDAO.sessionStorageEvaluator.sessionStorageEnabled" value="false"/>
        <property name="subjectFactory" ref="subjectFactory"/>
        <property name="sessionManager" ref="sessionManager"/>
    </bean>
    
    <!--拦截每次请求做身份认证,因为要截获的参数不止表单参数,
    所以不能直接用FormAuthenticationFilter-->
    <bean id="statelessAuthcFilter"
          class="com.haien.chapter20.filter.StatelessAuthcFilter"/>
    <!-- Shiro的Web过滤器 -->
    <bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">
        <property name="securityManager" ref="securityManager"/>
        <property name="filters">
            <util:map>
                <entry key="statelessAuthc" value-ref="statelessAuthcFilter"/>
            </util:map>
        </property>
        <property name="filterChainDefinitions">
            <value>
                /** = statelessAuthc
            </value>
        </property>
    </bean>
    

测试

  • 依赖:

    <!--jetty服务器,可代替tomcat-->
    <dependency>
      <groupId>org.eclipse.jetty</groupId>
      <artifactId>jetty-server</artifactId>
      <version>8.1.8.v20121106</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.eclipse.jetty</groupId>
      <artifactId>jetty-webapp</artifactId>
      <version>8.1.8.v20121106</version>
      <scope>test</scope>
    </dependency>
    
  • ClientTest:准备服务器,模拟客户端发起请求。

public class ClientTest {
    private static Server server;
    private RestTemplate restTemplate=new RestTemplate();

    /**
     * @Author haien
     * @Description 在整个测试开始之前启动服务器
     * @Date 2019/4/9
     * @Param
     * @return
     **/
    @BeforeClass //在整个测试开始之前执行
    public static void beforeClass() throws Exception {
        //创建一个Server
        server=new Server(8080);

        WebAppContext context=new WebAppContext();
        String webappPath="./src/main/webapp";
        context.setDescriptor(webappPath+"/WEB/INF/web.xml"); //指定web.xml配置文件
        context.setResourceBase(webappPath); //指定webapp目录
        context.setContextPath("/");
        context.setParentLoaderPriority(true);

        server.setHandler(context);
        server.start();
    }

    /**
     * @Author haien
     * @Description 测试结束后停止服务器
     * @Date 2019/4/9
     * @Param []
     * @return void
     **/
    @AfterClass
    public static void afterClass() throws Exception {
        server.stop();
    }

    /**
     * @Author haien
     * @Description 测试成功情况
     * @Date 2019/4/9
     * @Param []
     * @return void
     **/
    @Test
    public void testServiceHelloSuccess(){
        String username="admin";
        String param11="param11";
        String param12="param12";
        String param2="param2";
        String key="dadadswdewq2ewdwqdwadsadasd"; //和服务端使用的key一致

        //参数必须按照如下顺序,否则客户端会接收不到某个参数
        MultiValueMap<String,String> params=new LinkedMultiValueMap<>();
        params.add("param1",param11);
        params.add("param1",param12);
        params.add(Constants.PARAM_USERNAME,username);
        params.add("param2",param2);
        params.add(Constants.PARAM_DIGEST,HmacSHA256Utils.digest(key,params)); //和服务端生成摘要方式一致

        //构造url;UriComponentsBuilder需要spring-web依赖
        String url=UriComponentsBuilder.fromHttpUrl("http://localhost:8080/hello")
                .queryParams(params).build().toUriString();
        //发起请求并获取响应,指定响应体映射为string对象
        ResponseEntity responseEntity=restTemplate.getForEntity(url,String.class);

        Assert.assertEquals("hello"+param11+param2,responseEntity.getBody());
    }

    /**
     * @Author haien
     * @Description 测试失败情况
     * @Date 2019/4/9
     * @Param []
     * @return void
     **/
    @Test
    public void testServiceHelloFail(){
        String username="admin";
        String param11="param11";
        String param12="param12";
        String param2="param2";
        String key="dadadswdewq2ewdwqdwadsadasd"; //和服务端使用的key一致

        MultiValueMap<String,String> params=new LinkedMultiValueMap<>();
        params.add("param1",param11);
        params.add("param1",param12);
        params.add(Constants.PARAM_USERNAME,username);
        params.add("param2",param2);
        params.add(Constants.PARAM_DIGEST,HmacSHA256Utils.digest(key,params)); //和服务端生成摘要方式一致
        //篡改请求参数
        params.set("param2",param2+"1");

        //构造url;UriComponentsBuilder需要spring-web依赖
        String url=UriComponentsBuilder.fromHttpUrl("http://localhost:8080/hello")
                .queryParams(params).build().toUriString();
        //发起请求并获取响应,指定响应体映射为string对象
        try {
            ResponseEntity responseEntity = restTemplate.getForEntity(url, String.class);
        } catch (HttpClientErrorException e){
            Assert.assertEquals(HttpStatus.UNAUTHORIZED,e.getStatusCode());
            Assert.assertEquals("login error",e.getResponseBodyAsString());
        }
    }
}