Add GitHub authentication.

Closes gh-821
Original pull request: gh-853
This commit is contained in:
Nanne Baars
2024-01-18 16:02:37 +01:00
committed by Mark Paluch
parent af060ac44f
commit fdcab61e3a
5 changed files with 385 additions and 1 deletions

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
};
}
});
}
}

View File

@@ -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