Spring应用之RestTemplate with Authentication

介绍

REST对于资源型服务接口来说很合适,同时特别适合对于效率要求很高,但是对于安全要求不高的场景

具体做法是:

  1. 在每一个REST的call的头header(例如:@HeaderParam)都带user token 和 application ID
  2. 在服务器端对每一请求进行re-authentication(可以写一个拦截器来实现)
    • SpringSecurity 配置create-session="stateless"
    • 这样每个请求都是无状态的独立的,需要被再次认证re-authentication
  3. 注意:把token放在uri中是糟糕的做法
    • 首先是安全的原因
    • 其次是cache的原因,尽量放在head中
//RestTemplate默认使用的HttpClient未设置凭证,stateless请求
RestTemplate rt=new RestTemplate();

HttpClient

认证方案(authentication schemes)

Basic/Digest/NTLM 这些方案可用于服务器或代理对客户端的认证

  • Basic 基本认证: 以明码传送username和password(最不安全的一个方案),一般使用 Basic +TLS/SSL 加密方式。[使用UsernamePasswordCredentials实例]
  • Digest摘要式认证: 传送用password对Server端发送的随机数(nonce)加密后生成的字符串 (比Basic安全) [使用UsernamePasswordCredentials实例]
  • NTLM 最复杂,认证了一个连接而不是一请求 [使用NTCredentials实例]
  • 自定义

用户凭证(Credentials)

  • 任何用户身份验证的过程都需要一组可以用于建立用户身份的凭据
  • 用户凭证的最简单的形式可以仅仅是用户名/密码对
  • UsernamePasswordCredentials代表了一组包含安全规则和明文密码的凭据
      UsernamePasswordCredentials creds = new UsernamePasswordCredentials("user", "pwd");
      System.out.println(creds.getUserPrincipal().getName());
      System.out.println(creds.getPassword());
    
  • NTCredentials是微软Windows指定的实现,它包含了除了用户名/密码对外,一组额外的Windows指定的属性,比如用户域名的名字(相同的用户使用不同设置的认证可以属于不同的域)
      NTCredentials creds = new NTCredentials("user", "pwd", "workstation", "domain");
      System.out.println(creds.getUserPrincipal().getName());
      System.out.println(creds.getPassword());
    
  • 凭据提供器CredentialsProvider
    • 用来维护一组用户凭据,还有能够对特定认证范围AuthScope产生用户凭据Credential
    • 认证范围包括:主机名Hostname,端口号port,领域名称authScope,访问空间realm,认证方案authScheme
    • 当使用凭据提供器来注册凭据时,可以提供一个通配符(任意主机,任意端口,任意领域,任意模式)来替代确定的属性值
    • 如果直接匹配没有发现,CredentialsProvider期望被用来发现最匹配的特定范围
    • eg:
      CredentialsProvider credsProvider = new BasicCredentialsProvider();
      credsProvider.setCredentials(new AuthScope("somehost", AuthScope.ANY_PORT),new UsernamePasswordCredentials("u1", "p1"));
      credsProvider.setCredentials(new AuthScope("somehost", 8080),new UsernamePasswordCredentials("u2", "p2"));
      credsProvider.setCredentials(new AuthScope("otherhost", 8080, AuthScope.ANY_REALM, "ntlm"),new UsernamePasswordCredentials("u3", "p3"));
      

认证Authentication

  • 服务器认证(Server Authentication):HttpClient处理服务器认证几乎是透明的,仅需要开发人员提供登录信息(login credentials)
    • 登录信息保存在HttpState类的实例中
    • 可以通过 setCredentials(String realm, Credentials cred)和getCredentials(String realm)来获取或设置
    • 注意:设定对非特定站点访问所需要的登录信息,将realm参数置为null
    • HttpClient内建的自动认证,可以通过HttpMethod类的setDoAuthentication(boolean doAuthentication)方法关闭(这次关闭只影响HttpMethod当前的实例)
    • 抢先认证(Preemptive Authentication)
      • 在这种模式时,HttpClient会主动将basic认证应答信息传给服务器
      • 即使在某种情况下服务器可能返回认证失败的应答
      • 这样做主要是为了减少连接的建立
      • 打开抢先认证: client.getState().setAuthenticationPreemptive(true);
      • 注意:
        • 当需要与不被信任的站点或web应用通信时,应该谨慎使用缺省的认证机制
        • 如果你提供的认证信息是敏感的,你应该指定认证域AuthScope,不推荐将认证域指定为AuthScope.ANY
  • 代理认证(Proxy Authentication): 除了登录信息需单独存放以外,代理认证与服务器认证几乎一致

认证状态(AuthState)

用来跟踪关于认证过程状态的详细信息

  • 在HTTP请求执行过程中,HttpClient创建2个AuthState的实例:
    • 一个对于目标主机认证
    • 另外一个对于代理认证
  • 如果目标服务器或代理需要用户认证,那么各自的AuthState实例将会被在认证处理过程中使用的AuthScopeAuthSchemeCrednetials来填充。
  • AuthState可以用来检查Request的认证类型,是否匹配AuthScheme实现,CrendentialsProvider是否对给定的AuthScope匹配用户凭据
  • 在HTTP请求执行的过程中,HttpClient将下列和认证相关的对象添加到HttpContext
    • http.authscheme-registryAuthSchemeRegistry实例代表真实的认证模式注册表
    • http.auth.credentials-providerCookieSpec实例代表了真实的凭据提供器
    • http.auth.target-scopeAuthState实例代表了真实的目标认证状态
    • http.auth.proxy-scopeAuthState实例代表了真实的代理认证状态

执行上下文(HttpContext)

  • 实际上是HttpClient用来在多次请求-响应的交互中,保持状态信息用的
  • 本地的HttpContext对象可以用于定制HTTP认证内容,并先于请求执行或在请求被执行之后检查它的状态
  • eg:

    HttpContext localContext = new BasicHttpContext();
    HttpResponse response = httpclient.execute(new HttpGet("http://localhost:8080/"), localContext);
    
    AuthState proxyAuthState = (AuthState) localContext.getAttribute(HttpClientContext.PROXY_AUTH_STATE);
    System.out.println("Proxy auth scope: " + proxyAuthState.getAuthScope());
    System.out.println("Proxy auth scheme: " + proxyAuthState.getAuthScheme());
    System.out.println("Proxy auth credentials: " + proxyAuthState.getCredentials());
    
    AuthState targetAuthState = (AuthState) localContext.getAttribute(HttpClientContext.TARGET_AUTH_STATE);
    System.out.println("Target auth scope: " + targetAuthState.getAuthScope());
    System.out.println("Target auth scheme: " + targetAuthState.getAuthScheme());
    System.out.println("Target auth credentials: " + targetAuthState.getCredentials());
    

Client端实现方案

构建BasicAuthentication

在HttpHeader中明文发送Authentication,建议组合SSL/TLS加密使用

Authorization: "Basic "+new String( Base64.encodeBase64((username:password).getBytes()) )

方案一:每次提交Request时,构建Authorization,放入HttpHeader中一并提交到Server

/*HttpClient*/
HttpClientBuilder builder=HttpClientBuilder.create();
SSLContext sslContext = SSLContexts.custom().loadTrustMaterial(null, new TrustSelfSignedStrategy()).useTLS().build();
SSLConnectionSocketFactory connectionFactory = new SSLConnectionSocketFactory(sslContext, new AllowAllHostnameVerifier());
HttpClient client=builder.setSSLSocketFactory(connectionFactory).build();

/*RestTemplate*/
RestTemplate restTemplate=new RestTemplate(new HttpComponentsClientHttpRequestFactory(httpClient));

/*Execute*/
//HttpHeader
String base64Creds = "Basic "+new String(Base64.encodeBase64((username+":"+password).getBytes()));
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "Basic " + base64Creds);
HttpEntity<Object> requestEntity=new HttpEntity<Object>(headers);

ResponseEntity<String> response=restTemplate.exchange(this.serverUrl+page,HttpMethod.GET, requestEntity, String.class,null);

方案二:HttpClientCredentialsProvider中维护UsernamePasswordCrendential,以后可直接提交Request

/*HttpClient*/
HttpClientBuilder builder=HttpClientBuilder.create();
//1.SSL
SSLContext sslContext = SSLContexts.custom().loadTrustMaterial(null, new TrustSelfSignedStrategy()).useTLS().build();
SSLConnectionSocketFactory connectionFactory = new SSLConnectionSocketFactory(sslContext, new AllowAllHostnameVerifier());
builder.setSSLSocketFactory(connectionFactory);
//2.UsernamePasswordCredentials
BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();
credentialsProvider.setCredentials(authScope,new UsernamePasswordCredentials(username, password));
builder.setDefaultCredentialsProvider(credentialsProvider);
//3.build HttpClient
HttpClient client=builder.build();

/*RestTemplate*/
RestTemplate restTemplate=new RestTemplate(new HttpComponentsClientHttpRequestFactory(httpClient));

/*Execute*/
ResponseEntity<String> response=restTemplate.getForEntity(this.serverUrl+page, String.class);

方案三:HttpContextCredentialsProvider中维护UsernamePasswordCrendential,以后可直接提交Request

/*HttpClient*/
HttpClientBuilder builder=HttpClientBuilder.create();
SSLContext sslContext = SSLContexts.custom().loadTrustMaterial(null, new TrustSelfSignedStrategy()).useTLS().build();
SSLConnectionSocketFactory connectionFactory = new SSLConnectionSocketFactory(sslContext, new AllowAllHostnameVerifier());
HttpClient client=builder.setSSLSocketFactory(connectionFactory).build();

/*扩展HttpComponentsClientHttpRequestFactory的createHttpContext方法*/
//以后每次提交Request,都会调用BasicAuthRequestFactory的createHttpContext方法生成HttpContext附加到Request中
    public class BasicAuthRequestFactory extends HttpComponentsClientHttpRequestFactory{
        @Override
        protected HttpContext createHttpContext(HttpMethod httpMethod, URI uri)
        {
            //1.set AuthScheme
            HttpHost targetHost = new HttpHost(uri.getHost(), uri.getPort(),uri.getScheme());
            AuthCache authCache = new BasicAuthCache();
            BasicScheme basicAuth = new BasicScheme();
            authCache.put(targetHost, basicAuth);
            HttpClientContext localContext = HttpClientContext.create();
            localContext.setAttribute(HttpClientContext.AUTH_CACHE, authCache);

            //2.set CredentialsProvider
            BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();
            credentialsProvider.setCredentials(new AuthScope(targetHost),new UsernamePasswordCredentials(username, password));
            localContext.setCredentialsProvider(credentialsProvider);
            return localContext;
        }
    }

/*RestTemplate*/
RestTemplate restTemplate=new RestTemplate(new BasicAuthRequestFactory(httpClient)); 

/*Execute*/
ResponseEntity<String> response=restTemplate.getForEntity(this.serverUrl+page, String.class);

构建DigestAuthentication

Authorization: Digest username="Mufasa",
                     realm="testrealm@host.com",
                     nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",
                     uri="/dir/index.html",
                     qop=auth,
                     nc=00000001,
                     cnonce="0a4f113b",
                     response="6629fae49393a05397450978507c4ef1",
                     opaque="5ccc069c403ebaf9f0171e9517f40e41"

PS: 其中nonce是digest认证的中心=base64(expirationTime + ":" + md5Hex(expirationTime + ":" + key))

  • expirationTime是nonce的过期时间
  • key是放置nonce修改的私钥
  • 如果服务器生成的nonce已经过期(但是摘要还是有效)
    • DigestProcessingFilterEntryPoint会发送一个"stale=true"头信息
    • 这告诉用户代理,这里不再需要打扰用户(像是密码和用户其他都是正确的),只是简单尝试使用一个新nonce
  • digest认证不使用session,所以无法与rememberMe同用
@Override
protected HttpContext createHttpContext(HttpMethod httpMethod, URI uri)
{
    HttpHost targetHost = new HttpHost(uri.getHost(), uri.getPort(),uri.getScheme());
    AuthCache authCache = new BasicAuthCache();
    DigestScheme digestAuth = new DigestScheme();
    digestAuth.overrideParamter("realm", realm);
    digestAuth.overrideParamter("nonce", Long.toString(new Random().nextLong(), 36));
    authCache.put(targetHost, digestAuth);

    HttpClientContext localContext = HttpClientContext.create();
    localContext.setAttribute(HttpClientContext.AUTH_CACHE, authCache);

    if(this.username!=null){
        BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();
        credentialsProvider.setCredentials(new AuthScope(targetHost),new UsernamePasswordCredentials(username, password));
        localContext.setCredentialsProvider(credentialsProvider);
    }
    return localContext;
}

Server端进行认证

这里使用SpringSecurity

注意:

  • 一定要设置create-session="stateless"
    • 表示Spring Security对登录成功的用户不会创建Session了,你的application也不会允许新建session,
    • 且Spring Security会跳过所有的 filterChain:HttpSessionSecurityContextRepository, SessionManagementFilter, RequestCacheFilter.
  • 多种authentication-manager,需设置id,而不是alias
  • 此认证浏览器Browser会记住Authentication,故主要用于Restful API无状态请求的认证中

配置BasicAuthentication

<sec:http pattern="/vendor/**" realm="shuttle vendors" create-session="stateless" authentication-manager-ref="vendorAuthenticationManager">
    <sec:intercept-url pattern="/vendor/**" access="ROLE_SUPPLIER" />
    <sec:http-basic/>
</sec:http>
<sec:authentication-manager id="vendorAuthenticationManager">
    <sec:authentication-provider>
        <sec:user-service id="vendorUserService">
            <sec:user name="S001" password="abc" authorities="ROLE_SUPPLIER" />
            <sec:user name="S002" password="abc" authorities="ROLE_SUPPLIER" />
        </sec:user-service>
    </sec:authentication-provider>
</sec:authentication-manager>

配置DigestAuthentication

<sec:http pattern="/vendor/**" create-session="stateless" entry-point-ref="digestEntryPoint" authentication-manager-ref="vendorAuthenticationManager">
    <sec:intercept-url pattern="/vendor/**" access="ROLE_SUPPLIER" />
    <sec:custom-filter ref="digestFilter" position="DIGEST_AUTH_FILTER" />
</sec:http>
<bean id="digestFilter" class="org.springframework.security.web.authentication.www.DigestAuthenticationFilter">
    <property name="userDetailsService" ref="vendorUserService" />
    <property name="authenticationEntryPoint" ref="digestEntryPoint" />
</bean>
<bean id="digestEntryPoint" class="org.springframework.security.web.authentication.www.DigestAuthenticationEntryPoint">
    <property name="realmName" value="shuttle vendors" />
    <property name="key" value="zero" />
</bean>
<sec:authentication-manager id="vendorAuthenticationManager">
    <sec:authentication-provider>
        <sec:user-service id="vendorUserService">
            <sec:user name="S001" password="abc" authorities="ROLE_SUPPLIER" />
            <sec:user name="S002" password="abc" authorities="ROLE_SUPPLIER" />
        </sec:user-service>
    </sec:authentication-provider>
</sec:authentication-manager>