Spring Security 如何配置TestRestTemplate以使用Keystore?

6psbrbz9  于 2023-04-12  发布在  Spring
关注(0)|答案(4)|浏览(173)

我的项目有一系列使用TestRestTemplateMockMvc的集成测试。这些测试都成功通过了。
我现在已经在我的项目中添加了Spring Boot Starter SecuritySpring Security OAuth2 Autoconfigure依赖项。我已经添加了一个扩展WebSecurityConfigurerAdapter的自定义类,以允许对我的应用程序进行开放访问(目前)。

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
            .authorizeRequests()
            .anyRequest()
            .permitAll();
    }

    @Override
    public void configure(WebSecurity webSecurity) {
        webSecurity
            .ignoring()
            .antMatchers(HttpMethod.OPTIONS, "/**");
    }
}

该应用程序还需要充当OAuth2 Resource Server,因此我还使用@EnableResourceServer注解了我的主类。
应用程序工作正常,但现在所有的集成测试都失败了。

Could not fetch user details: class org.springframework.web.client.ResourceAccessException, I/O error on GET request for <the path to my userinfo URL>: 
PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: 
unable to find valid certification path to requested target; nested exception is javax.net.ssl.SSLHandshakeException: 
PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target

似乎我在测试中使用的TestRestTemplate需要被指示使用与应用程序相同的密钥库。是否可以做到这一点?它将如何为MockMvc工作?

voase2hg

voase2hg1#

Sping Boot 2解决方案

下面的答案是针对民间开发对Sping Boot 2和使用自签名证书的开发(适当的证书推荐用于生产-见https://letsencrypt.org/)。
您可以使用keytool命令创建包含自签名证书的密钥库文件:-

keytool -genkey -storetype PKCS12 \
    -alias selfsigned_localhost_sslserver \
    -keyalg RSA -keysize 2048 -validity 3650 \
    -dname "CN=localhost, OU=Engineering, O=Acme Corp, L=New York, S=New York, C=US" \
    -noprompt -keypass changeit -storepass changeit \
    -keystore keystore-self-signed.p12

keystore-self-signed.p12文件将包含一个自签名证书,该文件可以移动到src/main/resources文件夹(或src/test/resources,如果您喜欢)。
将以下内容添加到您的application.yaml Spring配置中以使用SSL并指向keystore:-

server:
  port: 443
  ssl:
    enabled: true
    key-store: classpath:keystore-self-signed.p12
    key-store-type: PKCS12
    protocol: TLS
    enabled-protocols: TLSv1.2   # Best practice - see https://github.com/ssllabs/research/wiki/SSL-and-TLS-Deployment-Best-Practices
    key-password: changeit
    key-store-password: changeit

让我们创建一个超级简单的Sping Boot 控制器端点来测试:-

@RestController
public class PingController {

    @GetMapping("/ping")
    public ResponseEntity<String> ping() {
        return new ResponseEntity<>("pong", HttpStatus.OK);
    }

}

我们现在可以使用curl命令(或Postman)来访问此端点,即

$ curl https://localhost/ping --insecure --silent
pong

注意:如果我们不包含--insecure,那么curl将返回curl: (60) SSL certificate problem: self signed certificate
要使用TestRestTemplate测试到他的端点的正确Sping Boot 集成测试,我们可以执行以下操作:-

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public class PingControllerTest {

    @Value("${server.ssl.key-store}")
    private Resource keyStore;   // inject keystore specified in config

    @Value("${server.ssl.key-store-password}")
    private String keyStorePassword;  // inject password from config

    @LocalServerPort
    protected int port;   // server port picked randomly at runtime

    private TestRestTemplate restTemplate;

    @Before
    public void setup() throws Exception {
        SSLContext sslContext = new SSLContextBuilder()
            .loadTrustMaterial(
                keyStore.getURL(),
                keyStorePassword.toCharArray()
            ).build();
        SSLConnectionSocketFactory socketFactory = new SSLConnectionSocketFactory(sslContext);
        HttpClient httpClient = HttpClients.custom().setSSLSocketFactory(socketFactory).build();
        HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(
            httpClient);
        RestTemplateBuilder rtb = new RestTemplateBuilder()
            .requestFactory(() -> factory)
            .rootUri("https://localhost:" + port);
        this.restTemplate = new TestRestTemplate(rtb, null, null, HttpClientOption.SSL);
    }

    @Test
    public void shouldPing() {
        ResponseEntity<String> result = restTemplate.getForEntity("/ping", String.class);
        assertEquals(HttpStatus.OK, result.getStatusCode());
        assertEquals("pong", result.getBody());
    }

}

如您所见,setup方法创建了SSLContext对象的示例,该示例加载(并“信任”)keystore-self-signed.p12文件中的自签名证书(通过Spring Resource对象注入)。
SSLContext类被注入到一个SSLConnectionSocketFactory对象中,然后再注入到一个HttpClient对象中,然后再注入到一个HttpComponentsClientHttpRequestFactory对象中。
这个工厂对象最终被注入到TestRestTemplate示例中,用于shouldPing集成测试。

注意-我最初在以下代码中浪费了时间:

...
this.restTemplate = new TestRestTemplate(rgb);

……但是这个回来了……

org.springframework.web.client.ResourceAccessException: I/O error on GET request for "https://localhost:56976/ping": 
    sun.security.validator.ValidatorException: PKIX path building failed: 
    sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target; nested exception is 
    javax.net.ssl.SSLHandshakeException: sun.security.validator.ValidatorException: PKIX path building failed: 
    sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target

通过TestRestTemplate调试后,我意识到必须使用TestRestTemplate的4参数构造器和HttpClientOption.SSL,即

this.restTemplate = new TestRestTemplate(rtb, null, null, HttpClientOption.SSL);

但是,如果你使用的是普通的RestTemplate(例如,在Spring测试之外),那么下面的代码可以工作:-

...
RestTemplate restTemplate = new RestTemplate(rgb);

注意,为了改进-创建一个@Bean方法,它返回一个TestRestTemplate示例。

swvgeqrz

swvgeqrz2#

我认为在运行测试时,您可能还需要传递-Djavax.net.ssl.trustStore= -Djavax.net.ssl.trustStorePassword=参数。对于在配置和maven中运行单个测试传递参数,您也可以传递这些参数。
下面两个链接可能会有帮助
Specifying trust store information in spring boot application.properties
http://codeboarding.com/tag/testresttemplate/

cbwuti44

cbwuti443#

谢谢你,你发布的第一个链接非常有用。这是我的RestTemplate工作代码,它接受任何证书,如果其他人发现它有用的话。它仍然依赖于提供的有效令牌,但这是另一个故事。

private RestTemplate buildRestTemplate() throws Exception {
    SSLContext sslContext = new SSLContextBuilder()
        .loadTrustMaterial(
            new TrustSelfSignedStrategy()
        ).build();
    SSLConnectionSocketFactory socketFactory =
        new SSLConnectionSocketFactory(sslContext);
    HttpClient httpClient = HttpClients.custom()
        .setSSLSocketFactory(socketFactory).build();
    HttpComponentsClientHttpRequestFactory factory =
        new HttpComponentsClientHttpRequestFactory(httpClient);
    return new RestTemplate(factory);
}
1cosmwyk

1cosmwyk4#

使用JUnit 4测试,spring-boot-starter-parent=2.3.12.RELEASE

我在使用TestRestTemplate测试启用SSL的Sping Boot 后端时遇到了同样的问题。
当Spring服务器不使用SSL时,我基于TestRestTemplate的JUnit测试工作得很好。但是一旦我通过设置其属性将其配置为使用具有自签名证书的SSL:

server.ssl.enabled=true

我开始收到与OP相同的例外。
经过多次尝试,我只设法获得了一个RestTemplate来连接到启用SSL的服务器,但这个类不像TestRestTemplate那样处理4xx5xx服务器异常,它捕获并解包它们,允许您使用调试器进行Assert或检查它们,因此如果我希望触发服务器异常的测试通过,我不得不重写它们。
如果我能让TestRestTemplate与支持SSL的服务器一起工作,我就可以以最少的重写重用所有的JUnit测试。
经过一些挖掘和调试,我发现注入到测试类中的TestRestTemplate有一个嵌入式RestTemplate

errorHandler=TestRestTemplate$NoOpResponseErrorHandler

但是它的requestFactory在默认情况下不支持SSL连接。
长话短说,我没有创建一个新的RestTemplate,而是重用了框架在赋予它SSL连接功能后注入的那个。
基本上你想:
1.将其requestFactory设置为启用SSL的;
1.捕获其rootURI,如果服务器支持SSL,则将http:替换为https:,如果服务器不支持SSL,则将http:替换为https:;
1.始终使用绝对URI与服务器建立连接。
下面是基本测试类的代码:

[...]

import java.net.URI;
import java.net.URISyntaxException;
import java.security.KeyManagementException;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;

import org.apache.http.conn.ssl.NoopHostnameVerifier;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.apache.http.conn.ssl.TrustSelfSignedStrategy;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.ssl.SSLContexts;
import org.junit.Before;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpHeaders;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.TestExecutionListeners;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.context.support.DependencyInjectionTestExecutionListener;
import org.springframework.test.context.support.DirtiesContextTestExecutionListener;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestTemplate;

SpringBootTest.WebEnvironment.RANDOM_PORT)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@RunWith(SpringRunner.class)
@TestExecutionListeners({ DependencyInjectionTestExecutionListener.class, DirtiesContextTestExecutionListener.class })
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
@Slf4j
public abstract class BaseTest {
    protected HttpHeaders mHttpHeaders = new HttpHeaders();
    // use this to get its RestTemplate with 4xx and 5xx exception handling and rootUri
    @Autowired
    protected TestRestTemplate mAutowiredTestRestTemplate;
    // the RestTemplate one actually used by derived test classes
    protected RestTemplate mRestTemplate = null;
    // the injected rootURI
    protected String mRootUri;
    // inject flag from config
    @Value("${server.ssl.enabled}")
    private boolean mIsServerSslEnabled;

    // @Before is ok because is run when the class is already instantiated
    // but notice that it's run for every test in the class
    @Before
    public void initTest() {
        if (mRestTemplate == null) {
            initRestTemplateAndRootUri();
        }
    }

    /**
     * Init the mRestTemplate using the injected one with added SSL capabilities
     */
    private void initRestTemplateAndRootUri() {
        final String tplRootUri = mAutowiredTestRestTemplate.getRootUri();
        // fix the rootURI schema according to the SSL enabled state
        mRootUri = mIsServerSslEnabled ? tplRootUri.replace("http:", "https:") : tplRootUri.replace("https:", "http:");
        try {
            mRestTemplate = buildSslRestTemplate();
        } catch (Exception e) {
            // unrecoverable
            throw new RuntimeException(e);
        }
    }

    /**
     * Return the injected RestTemplate modified with a SSL context accepting self-signed certificates
     * 
     * @throws KeyStoreException
     * @throws NoSuchAlgorithmException
     * @throws KeyManagementException
     */
    private RestTemplate buildSslRestTemplate()
            throws KeyManagementException, NoSuchAlgorithmException, KeyStoreException {
        SSLConnectionSocketFactory scsf = new SSLConnectionSocketFactory(
                SSLContexts.custom().loadTrustMaterial(null, new TrustSelfSignedStrategy()).build(),
                NoopHostnameVerifier.INSTANCE);
        CloseableHttpClient httpClient = HttpClients.custom().setSSLSocketFactory(scsf).build();
        HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(httpClient);
        // instead of creating a new RestTemplate, reuse the one embedded in the
        // injected TestRestTemplate, which keeps its 4xx and 5xx exceptions handling
        // capabilities, just change its request factory to a SSL-enabled one
        RestTemplate result = mAutowiredTestRestTemplate.getRestTemplate();
        result.setRequestFactory(requestFactory);
        return result;
    }

    /**
     * Helper methods to make an absolute URI from a relative
     */
    protected String makeAbsUri(String relUri) {
        return mRootUri + relUri;
    }

    protected URI makeAbsUri(URI relUri) {
        try {
            return new URI(mRootUri + relUri.toString());
        } catch (URISyntaxException e) {
            // unrecoverable
            throw new RuntimeException(e);
        }
    }

}

而派生的测试类应该以这种方式调用修改后的mRestTemplate

public class UserTest extends BaseTest {
    private static final String RELATIVE_URL = "/api/v1/user/";

[...]

    @Test
    public void readOneById_idNotExists_ko_notFound() {
        mHttpHeaders.clear();
        mHttpHeaders.set(MY_AUTH_HEADER_KEY, myJwtAuthHeaderValue);
        HttpEntity<String> entity = new HttpEntity<>(null, mHttpHeaders);
        Long userId = 999L;
        // this request should trigger a 4xx server exception
        // always use the absolute URI returned by the base class helper method
        ResponseEntity<MyCustomResponse<Object>> response = mRestTemplate.exchange(makeAbsUri(RELATIVE_URL + userId), HttpMethod.GET,
                entity, new ParameterizedTypeReference<MyCustomResponse<Object>>() {
                });
        // notice that this custom RestTemplate has caught the exception, just like an ordinary TestRestTemplate
        // and so the following code is executed:
        // check response
        assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
        assertNotNull(response.getBody());
        // - payload
        Object payload = response.getBody().getPayload();
        assertNull(payload);
        // - error status
        assertEquals(Status.NOT_FOUND, response.getBody().getStatus());
        // - error message
        Object errorsObj = response.getBody().getErrors();
        assertNotNull(errorsObj);
        assertTrue(errorsObj instanceof HashMap);
        HashMap<?, ?> errorMap = (HashMap<?, ?>) errorsObj;
        String msg = (String) errorMap.get("details");
        assertNotNull(msg);
        assertEquals(mMessageSource.getMessage("user.not.found", new Object[] { "#" + userId }, Locale.getDefault()), msg);
    }

总之,这个解决方案给了我两全其美的效果:具有SSL连接功能的RestTemplate,以及与TestRestTemplate类相同的4xx5xx异常处理语义。

相关问题