Do not revoke batch tokens.

We now track the token type and no longer attempt revoking batch tokens.
Also, refactor code duplicates to use LoginTokenUtil.

Closes gh-764
This commit is contained in:
Mark Paluch
2023-03-07 10:11:54 +01:00
parent 2d9463eb6f
commit af510372d4
10 changed files with 352 additions and 50 deletions

View File

@@ -44,15 +44,15 @@ import org.springframework.web.client.RestOperations;
* <p>
* This {@link SessionManager} also implements {@link DisposableBean} to revoke the
* {@link LoginToken} once it's not required anymore. Token revocation will stop regular
* token refresh. Tokens are only revoked only if the associated
* {@link ClientAuthentication} returns a {@link LoginToken}.
* token refresh. Tokens are only revoked if the associated {@link ClientAuthentication}
* returns a {@link LoginToken#isServiceToken() service token}.
* <p>
* If Token renewal runs into a client-side error, it assumes the token was
* revoked/expired. It discards the token state so the next attempt will lead to another
* login attempt.
* <p>
* By default, {@link VaultToken} are looked up in Vault to determine renewability and the
* remaining TTL, see {@link #setTokenSelfLookupEnabled(boolean)}.
* By default, {@link VaultToken} are looked up in Vault to determine renewability,
* remaining TTL, accessor and type, see {@link #setTokenSelfLookupEnabled(boolean)}.
* <p>
* The session manager dispatches authentication events to {@link AuthenticationListener}
* and {@link AuthenticationErrorListener}. Event notifications are dispatched either on
@@ -390,7 +390,12 @@ public class LifecycleAwareSessionManager extends LifecycleAwareSessionManagerSu
}
public boolean isRevocable() {
return this.revocable;
if (token instanceof LoginToken login && login.isServiceToken()) {
return this.revocable;
}
return false;
}
}

View File

@@ -16,7 +16,9 @@
package org.springframework.vault.authentication;
import java.time.Duration;
import java.util.Arrays;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.vault.support.VaultToken;
@@ -34,12 +36,29 @@ public class LoginToken extends VaultToken {
*/
private final Duration leaseDuration;
private LoginToken(char[] token, Duration duration, boolean renewable) {
@Nullable
private final String accessor;
@Nullable
private final String type;
private LoginToken(char[] token, Duration duration, boolean renewable, @Nullable String accessor,
@Nullable String type) {
super(token);
this.leaseDuration = duration;
this.renewable = renewable;
this.accessor = accessor;
this.type = type;
}
/**
* @return a new {@link LoginTokenBuilder}.
* @since 3.0.2
*/
public static LoginTokenBuilder builder() {
return new LoginTokenBuilder();
}
/**
@@ -79,7 +98,7 @@ public class LoginToken extends VaultToken {
Assert.notNull(leaseDuration, "Lease duration must not be null");
Assert.isTrue(!leaseDuration.isNegative(), "Lease duration must not be negative");
return new LoginToken(token, leaseDuration, false);
return new LoginToken(token, leaseDuration, false, null, null);
}
/**
@@ -110,7 +129,7 @@ public class LoginToken extends VaultToken {
Assert.notNull(leaseDuration, "Lease duration must not be null");
Assert.isTrue(!leaseDuration.isNegative(), "Lease duration must not be negative");
return new LoginToken(token, leaseDuration, true);
return new LoginToken(token, leaseDuration, true, null, null);
}
/**
@@ -127,14 +146,169 @@ public class LoginToken extends VaultToken {
return this.renewable;
}
/**
* @return the token accessor.
* @since 3.0.2
*/
@Nullable
public String getAccessor() {
return accessor;
}
/**
* @return the token type.
* @since 3.0.2
* @see #isBatchToken()
* @see #isServiceToken())
*/
@Nullable
public String getType() {
return type;
}
/**
* @return {@literal true} if the token is a batch token.
* @since 3.0.2
*/
public boolean isBatchToken() {
return "batch".equals(this.type);
}
/**
* @return {@literal true} if the token is a service token.
* @since 3.0.2
*/
public boolean isServiceToken() {
return this.type == null || "service".equals(this.type);
}
@Override
public String toString() {
StringBuffer sb = new StringBuffer();
sb.append(getClass().getSimpleName());
sb.append(" [renewable=").append(this.renewable);
sb.append(", leaseDuration=").append(this.leaseDuration);
sb.append(", type=").append(this.type);
sb.append(']');
return sb.toString();
}
/**
* Builder for {@link LoginToken}.
*
* @since 3.0.2
*/
public static class LoginTokenBuilder {
@Nullable
private char[] token;
private boolean renewable;
/**
* Duration in seconds.
*/
private Duration leaseDuration = Duration.ZERO;
@Nullable
private String accessor;
@Nullable
private String type;
private LoginTokenBuilder() {
}
/**
* Configure the token value. This is a required builder property. Without this
* property, you cannot {@link #build()} a {@link LoginToken}.
* @param token must not be empty or {@literal null}.
* @return {@code this} {@link LoginTokenBuilder}.
*/
public LoginTokenBuilder token(String token) {
Assert.hasText(token, "Token must not be empty");
return token(token.toCharArray());
}
/**
* Configure the token value. This is a required builder property. Without this
* property, you cannot {@link #build()} a {@link LoginToken}.
* @param token must not be empty or {@literal null}.
* @return {@code this} {@link LoginTokenBuilder}.
*/
public LoginTokenBuilder token(char[] token) {
Assert.notNull(token, "Token must not be null");
Assert.isTrue(token.length > 0, "Token must not be empty");
this.token = token;
return this;
}
/**
* Configure whether the token is renewable.
* @param renewable
* @return {@code this} {@link LoginTokenBuilder}.
*/
public LoginTokenBuilder renewable(boolean renewable) {
this.renewable = renewable;
return this;
}
/**
* Configure the lease duration.
* @param leaseDuration must not be {@literal null}.
* @return {@code this} {@link LoginTokenBuilder}.
*/
public LoginTokenBuilder leaseDuration(Duration leaseDuration) {
Assert.notNull(leaseDuration, "Lease duration must not be empty");
this.leaseDuration = leaseDuration;
return this;
}
/**
* Configure the token accessor.
* @param accessor must not be empty or {@literal null}.
* @return {@code this} {@link LoginTokenBuilder}.
*/
public LoginTokenBuilder accessor(String accessor) {
Assert.hasText(accessor, "Token accessor must not be empty");
this.accessor = accessor;
return this;
}
/**
* Configure the token type.
* @param type must not be empty or {@literal null}.
* @return {@code this} {@link LoginTokenBuilder}.
*/
public LoginTokenBuilder type(String type) {
Assert.hasText(type, "Token type must not be empty");
this.type = type;
return this;
}
/**
* Build a new {@link LoginToken} instance. {@link #token} must be configured.
* @return a new {@link LoginToken} instance.
*/
public LoginToken build() {
Assert.notNull(token, "Token must not be null");
return new LoginToken(Arrays.copyOf(this.token, this.token.length), this.leaseDuration, this.renewable,
this.accessor, this.type);
}
}
}

View File

@@ -15,13 +15,11 @@
*/
package org.springframework.vault.authentication;
import java.time.Duration;
import java.util.Map;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.vault.VaultException;
import org.springframework.vault.client.VaultHttpHeaders;
@@ -35,7 +33,7 @@ import org.springframework.web.client.RestOperations;
/**
* Adapts tokens created by a {@link ClientAuthentication} to a {@link LoginToken}. Allows
* decoration of a {@link ClientAuthentication} object to perform a self-lookup after
* token retrieval to obtain the remaining TTL and renewability.
* token retrieval to obtain the remaining TTL, renewability, accessor and token type.
* <p>
* Using this adapter decrements the usage counter for the created token.
*
@@ -77,14 +75,7 @@ public class LoginTokenAdapter implements ClientAuthentication {
Map<String, Object> data = lookupSelf(restOperations, token);
Boolean renewable = (Boolean) data.get("renewable");
Number ttl = (Number) data.get("ttl");
if (renewable != null && renewable) {
return LoginToken.renewable(token.toCharArray(), getLeaseDuration(ttl));
}
return LoginToken.of(token.toCharArray(), getLeaseDuration(ttl));
return LoginTokenUtil.from(token.toCharArray(), data);
}
private static Map<String, Object> lookupSelf(RestOperations restOperations, VaultToken token) {
@@ -106,8 +97,4 @@ public class LoginTokenAdapter implements ClientAuthentication {
}
}
static Duration getLeaseDuration(@Nullable Number ttl) {
return ttl == null ? Duration.ZERO : Duration.ofSeconds(ttl.longValue());
}
}

View File

@@ -19,6 +19,7 @@ import java.time.Duration;
import java.util.Map;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* Utility class for {@link LoginToken}.
@@ -51,26 +52,43 @@ final class LoginTokenUtil {
* @return the {@link LoginToken}
* @since 2.0
*/
static LoginToken from(char[] token, Map<String, Object> auth) {
static LoginToken from(char[] token, Map<String, ?> auth) {
Assert.notNull(auth, "Authentication must not be null");
Boolean renewable = (Boolean) auth.get("renewable");
Number leaseDuration = (Number) auth.get("lease_duration");
String accessor = (String) auth.get("accessor");
String type = (String) auth.get("type");
if (leaseDuration == null) {
leaseDuration = (Number) auth.get("ttl");
}
if (renewable != null && renewable) {
return LoginToken.renewable(token, Duration.ofSeconds(leaseDuration.longValue()));
if (type == null) {
type = (String) auth.get("token_type");
}
LoginToken.LoginTokenBuilder builder = LoginToken.builder();
builder.token(token);
if (StringUtils.hasText(accessor)) {
builder.accessor(accessor);
}
if (leaseDuration != null) {
return LoginToken.of(token, Duration.ofSeconds(leaseDuration.longValue()));
builder.leaseDuration(Duration.ofSeconds(leaseDuration.longValue()));
}
return LoginToken.of(token);
if (renewable != null) {
builder.renewable(renewable);
}
if (StringUtils.hasText(type)) {
builder.type(type);
}
return builder.build();
}
}

View File

@@ -47,15 +47,15 @@ import org.springframework.web.reactive.function.client.WebClientResponseExcepti
* <p>
* This {@link ReactiveSessionManager} also implements {@link DisposableBean} to revoke
* the {@link LoginToken} once it's not required anymore. Token revocation will stop
* regular token refresh. Tokens are only revoked only if the associated
* {@link VaultTokenSupplier} returns a {@link LoginToken}.
* regular token refresh. Tokens are only revoked if the associated
* {@link VaultTokenSupplier} returns a {@link LoginToken#isServiceToken() service token}.
* <p>
* If Token renewal runs into a client-side error, it assumes the token was
* revoked/expired. It discards the token state so the next attempt will lead to another
* login attempt.
* <p>
* By default, {@link VaultToken} are looked up in Vault to determine renewability and the
* remaining TTL, see {@link #setTokenSelfLookupEnabled(boolean)}.
* By default, {@link VaultToken} are looked up in Vault to determine renewability,
* remaining TTL, accessor and type, see {@link #setTokenSelfLookupEnabled(boolean)}.
* <p>
* The session manager dispatches authentication events to {@link AuthenticationListener}
* and {@link AuthenticationErrorListener}.
@@ -371,17 +371,7 @@ public class ReactiveLifecycleAwareSessionManager extends LifecycleAwareSessionM
Mono<Map<String, Object>> data = lookupSelf(webClient, token);
return data.map(it -> {
Boolean renewable = (Boolean) it.get("renewable");
Number ttl = (Number) it.get("ttl");
if (renewable != null && renewable) {
return LoginToken.renewable(token.toCharArray(), LoginTokenAdapter.getLeaseDuration(ttl));
}
return LoginToken.of(token.toCharArray(), LoginTokenAdapter.getLeaseDuration(ttl));
});
return data.map(it -> LoginTokenUtil.from(token.toCharArray(), it));
}
private static Mono<Map<String, Object>> lookupSelf(WebClient webClient, VaultToken token) {
@@ -431,7 +421,12 @@ public class ReactiveLifecycleAwareSessionManager extends LifecycleAwareSessionM
}
public boolean isRevocable() {
return this.revocable;
if (token instanceof LoginToken login && login.isServiceToken()) {
return this.revocable;
}
return false;
}
}

View File

@@ -71,7 +71,7 @@ public class VaultToken {
* @since 1.1
*/
public char[] toCharArray() {
return this.token;
return Arrays.copyOf(this.token, this.token.length);
}
@Override
@@ -89,6 +89,7 @@ public class VaultToken {
return Arrays.hashCode(this.token);
}
@Override
public String toString() {
return getClass().getSimpleName();
}

View File

@@ -197,6 +197,22 @@ class LifecycleAwareSessionManagerUnitTests {
verifyNoMoreInteractions(this.listener);
}
@Test
void shouldNotRevokeBatchToken() {
LoginToken batchToken = LoginToken.builder().token("login").type("batch").build();
when(this.clientAuthentication.login()).thenReturn(batchToken);
this.sessionManager.setTokenSelfLookupEnabled(false);
this.sessionManager.renewToken();
this.sessionManager.destroy();
verifyNoInteractions(this.restOperations);
verify(this.listener).onAuthenticationEvent(any(AfterLoginEvent.class));
verifyNoMoreInteractions(this.listener);
}
@Test
@SuppressWarnings("unchecked")
void shouldNotThrowExceptionsOnRevokeErrors() {

View File

@@ -39,11 +39,41 @@ class LoginTokenUnitTests {
@Test
void toStringShouldPrintFields() {
assertThat(LoginToken.of("token").toString()).isEqualTo("LoginToken [renewable=false, leaseDuration=PT0S]");
assertThat(LoginToken.of("token".toCharArray(), Duration.ofSeconds(1)).toString())
.isEqualTo("LoginToken [renewable=false, leaseDuration=PT1S]");
assertThat(LoginToken.renewable("token".toCharArray(), Duration.ofSeconds(1)).toString())
.isEqualTo("LoginToken [renewable=true, leaseDuration=PT1S]");
assertThat(LoginToken.of("token")).hasToString("LoginToken [renewable=false, leaseDuration=PT0S, type=null]");
assertThat(LoginToken.of("token".toCharArray(), Duration.ofSeconds(1)))
.hasToString("LoginToken [renewable=false, leaseDuration=PT1S, type=null]");
assertThat(LoginToken.renewable("token".toCharArray(), Duration.ofSeconds(1)))
.hasToString("LoginToken [renewable=true, leaseDuration=PT1S, type=null]");
assertThat(LoginToken.builder().token("foo").type("service").build())
.hasToString("LoginToken [renewable=false, leaseDuration=PT0S, type=service]");
}
@Test
void shouldConstructTokenWithAccessor() {
assertThat(LoginToken.of("token").getAccessor()).isNull();
LoginToken loginToken = LoginToken.builder().token("token").accessor("acc").build();
assertThat(loginToken.getToken()).isEqualTo("token");
assertThat(loginToken.getAccessor()).isEqualTo("acc");
}
@Test
void shouldConstructServiceToken() {
assertThat(LoginToken.of("token").isServiceToken()).isTrue();
LoginToken loginToken = LoginToken.builder().token("token").type("service").build();
assertThat(loginToken.isServiceToken()).isTrue();
}
@Test
void shouldConstructBatchToken() {
assertThat(LoginToken.of("token").isBatchToken()).isFalse();
LoginToken loginToken = LoginToken.builder().token("token").type("batch").build();
assertThat(loginToken.isBatchToken()).isTrue();
}
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2023 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.io.Serializable;
import java.util.Map;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
/**
* Unit tests for {@link LoginTokenUtil}.
*
* @author Mark Paluch
*/
class LoginTokenUtilUnitTests {
@Test
void shouldCreateServiceToken() {
Map<String, ? extends Serializable> response = Map.of("type", "service", "ttl", 100, "accessor",
"B6oixijqmeR4bsLOJH88Ska9");
LoginToken loginToken = LoginTokenUtil.from("foo".toCharArray(), response);
assertThat(loginToken.isServiceToken()).isTrue();
assertThat(loginToken.getAccessor()).isEqualTo("B6oixijqmeR4bsLOJH88Ska9");
}
@Test
void shouldCreateBatchToken() {
Map<String, ? extends Serializable> response = Map.of("type", "batch", "ttl", 100, "accessor",
"B6oixijqmeR4bsLOJH88Ska9");
LoginToken loginToken = LoginTokenUtil.from("foo".toCharArray(), response);
assertThat(loginToken.isBatchToken()).isTrue();
assertThat(loginToken.getAccessor()).isEqualTo("B6oixijqmeR4bsLOJH88Ska9");
}
}

View File

@@ -258,6 +258,26 @@ class ReactiveLifecycleAwareSessionManagerUnitTests {
verifyNoMoreInteractions(this.listener);
}
@Test
void shouldNotRevokeBatchTokenOnDestroy() {
LoginToken batchToken = LoginToken.builder().token("login").type("batch").build();
mockToken(batchToken);
this.sessionManager.setTokenSelfLookupEnabled(false);
this.sessionManager.renewToken() //
.as(StepVerifier::create) //
.expectNextCount(1) //
.verifyComplete();
this.sessionManager.destroy();
verify(this.webClient, never()).post();
verify(this.webClient.post(), never()).uri("auth/token/revoke-self");
verify(this.listener).onAuthenticationEvent(any(AfterLoginEvent.class));
verifyNoMoreInteractions(this.listener);
}
@Test
void shouldNotThrowExceptionsOnRevokeErrors() {