Add GitHub authentication.
Closes gh-821 Original pull request: gh-853
This commit is contained in:
@@ -321,6 +321,13 @@
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.squareup.okhttp3</groupId>
|
||||
<artifactId>mockwebserver</artifactId>
|
||||
<version>${okhttp3.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Logging -->
|
||||
|
||||
<dependency>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright 2017-2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.vault.authentication;
|
||||
|
||||
import java.util.Map;
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.vault.VaultException;
|
||||
import org.springframework.vault.support.VaultResponse;
|
||||
import org.springframework.vault.support.VaultToken;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
import org.springframework.web.client.RestOperations;
|
||||
|
||||
/**
|
||||
* GitHub's authentication method can be used to authenticate with Vault using a GitHub
|
||||
* personal access token.
|
||||
*
|
||||
* @author Nanne Baars
|
||||
* @since 3.2
|
||||
* @see GitHubAuthentication
|
||||
* @see RestOperations
|
||||
* @see <a href="https://www.vaultproject.io/api-docs/auth/github">GitHub Auth Backend</a>
|
||||
*/
|
||||
public class GitHubAuthentication implements ClientAuthentication, AuthenticationStepsFactory {
|
||||
|
||||
private static final Log logger = LogFactory.getLog(GitHubAuthentication.class);
|
||||
|
||||
private final GitHubAuthenticationOptions options;
|
||||
|
||||
private final RestOperations restOperations;
|
||||
|
||||
/**
|
||||
* Create a {@link GitHubAuthentication} using {@link GitHubAuthenticationOptions} and
|
||||
* {@link RestOperations}.
|
||||
* @param options must not be {@literal null}.
|
||||
* @param restOperations must not be {@literal null}.
|
||||
*/
|
||||
public GitHubAuthentication(GitHubAuthenticationOptions options, RestOperations restOperations) {
|
||||
|
||||
Assert.notNull(options, "GithubAuthenticationOptions must not be null");
|
||||
Assert.notNull(restOperations, "RestOperations must not be null");
|
||||
|
||||
this.options = options;
|
||||
this.restOperations = restOperations;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthenticationSteps getAuthenticationSteps() {
|
||||
return AuthenticationSteps.fromSupplier(options.getTokenSupplier())
|
||||
.map(token -> getGitHubLogin(token))
|
||||
.login(AuthenticationUtil.getLoginPath(this.options.getPath()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public VaultToken login() throws VaultException {
|
||||
|
||||
Map<String, String> login = getGitHubLogin(this.options.getTokenSupplier().get());
|
||||
|
||||
try {
|
||||
|
||||
VaultResponse response = this.restOperations
|
||||
.postForObject(AuthenticationUtil.getLoginPath(this.options.getPath()), login, VaultResponse.class);
|
||||
Assert.state(response != null && response.getAuth() != null, "Auth field must not be null");
|
||||
|
||||
logger.debug("Login successful using GitHub authentication");
|
||||
|
||||
return LoginTokenUtil.from(response.getAuth());
|
||||
}
|
||||
catch (RestClientException e) {
|
||||
throw VaultLoginException.create("GitHub", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, String> getGitHubLogin(String token) {
|
||||
return Map.of("token", token);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Copyright 2017-2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.vault.authentication;
|
||||
|
||||
import java.util.function.Supplier;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* Authentication options for {@link GitHubAuthentication}.
|
||||
* <p>
|
||||
* Authentication options provide the role and the token.
|
||||
* {@link GitHubAuthenticationOptions} can be constructed using {@link #builder()}.
|
||||
* Instances of this class are immutable once constructed.
|
||||
*
|
||||
* @author Nanne Baars
|
||||
* @author Mark Paluch
|
||||
* @since 3.2
|
||||
* @see GitHubAuthentication
|
||||
* @see #builder()
|
||||
*/
|
||||
public class GitHubAuthenticationOptions {
|
||||
|
||||
public static final String DEFAULT_GITHUB_AUTHENTICATION_PATH = "github";
|
||||
|
||||
/**
|
||||
* Path of the GitHub authentication backend mount. Optional and defaults to
|
||||
* {@literal github}.
|
||||
*/
|
||||
private final String path;
|
||||
|
||||
/**
|
||||
* Supplier instance to obtain the GitHub personal access token.
|
||||
*/
|
||||
private final Supplier<String> tokenSupplier;
|
||||
|
||||
private GitHubAuthenticationOptions(Supplier<String> tokenSupplier, String path) {
|
||||
|
||||
this.tokenSupplier = tokenSupplier;
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return a new {@link GitHubAuthenticationOptions}.
|
||||
*/
|
||||
public static GithubAuthenticationOptionsBuilder builder() {
|
||||
return new GithubAuthenticationOptionsBuilder();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return access token to use.
|
||||
*/
|
||||
public Supplier<String> getTokenSupplier() {
|
||||
return this.tokenSupplier;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the path of the GitHub authentication backend mount.
|
||||
*/
|
||||
public String getPath() {
|
||||
return this.path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder for {@link GitHubAuthenticationOptions}.
|
||||
*/
|
||||
public static class GithubAuthenticationOptionsBuilder {
|
||||
|
||||
private String path = DEFAULT_GITHUB_AUTHENTICATION_PATH;
|
||||
|
||||
@Nullable
|
||||
private Supplier<String> tokenSupplier;
|
||||
|
||||
/**
|
||||
* Configure the mount path.
|
||||
* @param path must not be {@literal null} or empty.
|
||||
* @return {@code this} {@link GithubAuthenticationOptionsBuilder}.
|
||||
*/
|
||||
public GithubAuthenticationOptionsBuilder path(String path) {
|
||||
|
||||
Assert.hasText(path, "Path must not be empty");
|
||||
|
||||
this.path = path;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the GitHub token. Vault authentication will use this token as
|
||||
* singleton. If you want to provide a dynamic token that can change over time,
|
||||
* see {@link #tokenSupplier(Supplier)}.
|
||||
* @param token must not be {@literal null}.
|
||||
* @return {@code this} {@link GithubAuthenticationOptionsBuilder}.
|
||||
*/
|
||||
public GithubAuthenticationOptionsBuilder token(String token) {
|
||||
|
||||
Assert.hasText(token, "Token must not be empty");
|
||||
|
||||
return tokenSupplier(() -> token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the {@link Supplier} to obtain a token.
|
||||
* @param tokenSupplier must not be {@literal null}.
|
||||
* @return {@code this} {@link GithubAuthenticationOptionsBuilder}.
|
||||
*/
|
||||
public GithubAuthenticationOptionsBuilder tokenSupplier(Supplier<String> tokenSupplier) {
|
||||
|
||||
Assert.notNull(tokenSupplier, "Token supplier must not be null");
|
||||
|
||||
this.tokenSupplier = tokenSupplier;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a new {@link GitHubAuthenticationOptions} instance.
|
||||
* @return a new {@link GitHubAuthenticationOptions}.
|
||||
*/
|
||||
public GitHubAuthenticationOptions build() {
|
||||
|
||||
Assert.notNull(this.tokenSupplier, "Token must not be null");
|
||||
|
||||
return new GitHubAuthenticationOptions(this.tokenSupplier, this.path);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* Copyright 2017-2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.vault.authentication;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import okhttp3.mockwebserver.Dispatcher;
|
||||
import okhttp3.mockwebserver.MockResponse;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
import okhttp3.mockwebserver.RecordedRequest;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.vault.support.VaultToken;
|
||||
import org.springframework.vault.util.IntegrationTestSupport;
|
||||
import org.springframework.vault.util.Settings;
|
||||
import org.springframework.vault.util.TestRestTemplateFactory;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
/**
|
||||
* Integration tests for {@link GitHubAuthentication} using
|
||||
* {@link AuthenticationStepsExecutor}.
|
||||
*
|
||||
* @author Nanne Baars
|
||||
* @author Mark Paluch
|
||||
*/
|
||||
class GitHubAuthenticationIntegrationTest extends IntegrationTestSupport {
|
||||
|
||||
private static final int organizationId = 1;
|
||||
|
||||
private final MockWebServer gitHubMockServer = new MockWebServer();
|
||||
|
||||
@BeforeEach
|
||||
void before() throws Exception {
|
||||
|
||||
if (!prepare().hasAuth("github")) {
|
||||
prepare().mountAuth("github");
|
||||
}
|
||||
|
||||
prepare().getVaultOperations()
|
||||
.doWithSession(
|
||||
restOperations -> restOperations.postForEntity("auth/github/config", Map.of("organization_id", 1,
|
||||
"base_url", "http://localhost:%d".formatted(gitHubMockServer.getPort())), Map.class));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void after() throws IOException {
|
||||
gitHubMockServer.shutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldLoginSuccessfully() {
|
||||
RestTemplate restTemplate = TestRestTemplateFactory.create(Settings.createSslConfiguration());
|
||||
setupGithubMockServer(gitHubUserResponse(), gitHubOrganizationResponse(organizationId),
|
||||
gitHubTeamResponse(organizationId));
|
||||
|
||||
GitHubAuthentication authentication = new GitHubAuthentication(
|
||||
GitHubAuthenticationOptions.builder().tokenSupplier(() -> "TOKEN").build(), restTemplate);
|
||||
VaultToken loginToken = authentication.login();
|
||||
|
||||
assertThat(loginToken.getToken()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailIfOrganizationIsNotTheSame() {
|
||||
RestTemplate restTemplate = TestRestTemplateFactory.create(Settings.createSslConfiguration());
|
||||
var wrongOrganizationId = organizationId + 1;
|
||||
setupGithubMockServer(gitHubUserResponse(), gitHubOrganizationResponse(wrongOrganizationId),
|
||||
gitHubTeamResponse(wrongOrganizationId));
|
||||
|
||||
GitHubAuthentication authentication = new GitHubAuthentication(
|
||||
GitHubAuthenticationOptions.builder().tokenSupplier(() -> "TOKEN2").build(), restTemplate);
|
||||
|
||||
assertThatThrownBy(authentication::login).isInstanceOf(VaultLoginException.class)
|
||||
.hasMessageContaining("Cannot login using GitHub: user is not part of required org");
|
||||
}
|
||||
|
||||
private String gitHubUserResponse() {
|
||||
return """
|
||||
{
|
||||
"login": "octocat",
|
||||
"id": 100
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
private String gitHubOrganizationResponse(int organizationId) {
|
||||
return """
|
||||
[
|
||||
{
|
||||
"login": "Foo bar organization",
|
||||
"id": %d
|
||||
}
|
||||
]
|
||||
""".formatted(organizationId);
|
||||
}
|
||||
|
||||
private String gitHubTeamResponse(int organizationId) {
|
||||
return """
|
||||
[
|
||||
{
|
||||
"id": 45,
|
||||
"name": "Justice League",
|
||||
"slug": "justice-league",
|
||||
"organization": {
|
||||
"id": %d
|
||||
}
|
||||
}
|
||||
]
|
||||
""".formatted(organizationId);
|
||||
}
|
||||
|
||||
private void setupGithubMockServer(String userJson, String orgJson, String teamJson) {
|
||||
gitHubMockServer.setDispatcher(new Dispatcher() {
|
||||
|
||||
@Override
|
||||
public MockResponse dispatch(RecordedRequest request) {
|
||||
|
||||
return switch (request.getPath()) {
|
||||
case "/user" -> new MockResponse().setResponseCode(200).setBody(userJson);
|
||||
case "/user/orgs?per_page=100" -> new MockResponse().setResponseCode(200).setBody(orgJson);
|
||||
case "/user/teams?per_page=100" -> new MockResponse().setResponseCode(200).setBody(teamJson);
|
||||
default -> new MockResponse().setResponseCode(404);
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -43,7 +43,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.springframework.vault.authentication.JwtAuthentication.DEFAULT_JWT_AUTHENTICATION_PATH;
|
||||
|
||||
/**
|
||||
* Integration tests for {@link KubernetesAuthentication} using
|
||||
* Integration tests for {@link JwtAuthentication} using
|
||||
* {@link AuthenticationStepsExecutor}.
|
||||
*
|
||||
* @author Nanne Baars
|
||||
|
||||
Reference in New Issue
Block a user