diff --git a/config/src/test/java/org/springframework/security/SerializationSamples.java b/config/src/test/java/org/springframework/security/SerializationSamples.java index 41418ecafa..4bf96f0ccd 100644 --- a/config/src/test/java/org/springframework/security/SerializationSamples.java +++ b/config/src/test/java/org/springframework/security/SerializationSamples.java @@ -94,6 +94,7 @@ import org.springframework.security.config.annotation.AlreadyBuiltException; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.authority.FactorGrantedAuthority; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextImpl; import org.springframework.security.core.context.TransientSecurityContext; @@ -584,6 +585,8 @@ final class SerializationSamples { token.setDetails(details); return token; }); + generatorByClassName.put(FactorGrantedAuthority.class, + (r) -> FactorGrantedAuthority.withAuthority("profile:read").issuedAt(Instant.now()).build()); generatorByClassName.put(UsernamePasswordAuthenticationToken.class, (r) -> { var token = UsernamePasswordAuthenticationToken.unauthenticated(user, "creds"); token.setDetails(details); diff --git a/config/src/test/resources/serialized/7.0.x/org.springframework.security.core.authority.FactorGrantedAuthority.serialized b/config/src/test/resources/serialized/7.0.x/org.springframework.security.core.authority.FactorGrantedAuthority.serialized new file mode 100644 index 0000000000..3529f2a8fa Binary files /dev/null and b/config/src/test/resources/serialized/7.0.x/org.springframework.security.core.authority.FactorGrantedAuthority.serialized differ diff --git a/core/spring-security-core.gradle b/core/spring-security-core.gradle index 2292388d9f..bc28ffe604 100644 --- a/core/spring-security-core.gradle +++ b/core/spring-security-core.gradle @@ -27,6 +27,7 @@ dependencies { optional 'org.jetbrains.kotlinx:kotlinx-coroutines-reactor' testImplementation 'commons-collections:commons-collections' + testImplementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' testImplementation 'io.projectreactor:reactor-test' testImplementation "org.assertj:assertj-core" testImplementation "org.junit.jupiter:junit-jupiter-api" diff --git a/core/src/main/java/org/springframework/security/authentication/jaas/AbstractJaasAuthenticationProvider.java b/core/src/main/java/org/springframework/security/authentication/jaas/AbstractJaasAuthenticationProvider.java index 215792865a..f7dbefd51a 100644 --- a/core/src/main/java/org/springframework/security/authentication/jaas/AbstractJaasAuthenticationProvider.java +++ b/core/src/main/java/org/springframework/security/authentication/jaas/AbstractJaasAuthenticationProvider.java @@ -46,7 +46,7 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.GrantedAuthorities; import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.authority.FactorGrantedAuthority; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.session.SessionDestroyedEvent; import org.springframework.util.Assert; @@ -214,7 +214,7 @@ public abstract class AbstractJaasAuthenticationProvider implements Authenticati } } } - authorities.add(new SimpleGrantedAuthority(AUTHORITY)); + authorities.add(FactorGrantedAuthority.fromAuthority(AUTHORITY)); return authorities; } diff --git a/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenAuthenticationProvider.java b/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenAuthenticationProvider.java index 38917e0c9c..6307a32622 100644 --- a/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenAuthenticationProvider.java +++ b/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenAuthenticationProvider.java @@ -25,7 +25,7 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.GrantedAuthorities; import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.authority.FactorGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; @@ -65,7 +65,7 @@ public final class OneTimeTokenAuthenticationProvider implements AuthenticationP try { UserDetails user = this.userDetailsService.loadUserByUsername(consumed.getUsername()); Collection authorities = new HashSet<>(user.getAuthorities()); - authorities.add(new SimpleGrantedAuthority(AUTHORITY)); + authorities.add(FactorGrantedAuthority.fromAuthority(AUTHORITY)); OneTimeTokenAuthentication authenticated = new OneTimeTokenAuthentication(user, authorities); authenticated.setDetails(otpAuthenticationToken.getDetails()); return authenticated; diff --git a/core/src/main/java/org/springframework/security/core/authority/FactorGrantedAuthority.java b/core/src/main/java/org/springframework/security/core/authority/FactorGrantedAuthority.java new file mode 100644 index 0000000000..b9a3f6b4a4 --- /dev/null +++ b/core/src/main/java/org/springframework/security/core/authority/FactorGrantedAuthority.java @@ -0,0 +1,173 @@ +/* + * Copyright 2004-present 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.security.core.authority; + +import java.time.Instant; +import java.util.Objects; + +import org.jspecify.annotations.Nullable; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.util.Assert; + +/** + * A {@link GrantedAuthority} specifically used for indicating the factor used at time of + * authentication. + * + * @author Yoobin Yoon + * @author Rob Winch + * @since 7.0 + */ +public final class FactorGrantedAuthority implements GrantedAuthority { + + private static final long serialVersionUID = 1998010439847123984L; + + private final String authority; + + private final Instant issuedAt; + + @SuppressWarnings("NullAway") + private FactorGrantedAuthority(String authority, Instant issuedAt) { + Assert.notNull(authority, "authority cannot be null"); + Assert.notNull(issuedAt, "issuedAt cannot be null"); + this.authority = authority; + this.issuedAt = issuedAt; + } + + /** + * Creates a new {@link Builder} with the specified authority. + * @param authority the authority value (must not be null or empty) + * @return a new {@link Builder} + */ + public static Builder withAuthority(String authority) { + return new Builder(authority); + } + + /** + * Creates a new {@link Builder} with the specified factor which is automatically + * prefixed with "FACTOR_". + * @param factor the factor value which is automatically prefixed with "FACTOR_" (must + * not be null or empty) + * @return a new {@link Builder} + */ + public static Builder withFactor(String factor) { + Assert.hasText(factor, "factor cannot be empty"); + Assert.isTrue(!factor.startsWith("FACTOR_"), () -> "factor cannot start with 'FACTOR_' got '" + factor + "'"); + return withAuthority("FACTOR_" + factor); + } + + /** + * Shortcut for {@code withAuthority(authority).build()}. + * @param authority the authority value (must not be null or empty) + * @return a new {@link FactorGrantedAuthority} + */ + public static FactorGrantedAuthority fromAuthority(String authority) { + return withAuthority(authority).build(); + } + + /** + * Shortcut for {@code withFactor(factor).build()}. + * @param factor the factor value which is automatically prefixed with "FACTOR_" (must + * not be null or empty) + * @return a new {@link FactorGrantedAuthority} + */ + public static FactorGrantedAuthority fromFactor(String factor) { + return withFactor(factor).build(); + } + + @Override + public String getAuthority() { + return this.authority; + } + + /** + * Returns the instant when this authority was issued. + * @return the issued-at instant + */ + public Instant getIssuedAt() { + return this.issuedAt; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj instanceof FactorGrantedAuthority fga) { + return this.authority.equals(fga.authority) && this.issuedAt.equals(fga.issuedAt); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hash(this.authority, this.issuedAt); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("FactorGrantedAuthority ["); + sb.append("authority=").append(this.authority); + sb.append(", issuedAt=").append(this.issuedAt); + sb.append("]"); + return sb.toString(); + } + + /** + * Builder for {@link FactorGrantedAuthority}. + */ + public static final class Builder { + + private final String authority; + + private @Nullable Instant issuedAt; + + private Builder(String authority) { + Assert.hasText(authority, "A granted authority textual representation is required"); + this.authority = authority; + } + + /** + * Sets the instant when this authority was issued. + * @param issuedAt the issued-at instant + * @return this builder + */ + public Builder issuedAt(Instant issuedAt) { + Assert.notNull(issuedAt, "issuedAt cannot be null"); + this.issuedAt = issuedAt; + return this; + } + + /** + * Builds a new {@link FactorGrantedAuthority}. + *

+ * If {@code issuedAt} is not set, it defaults to {@link Instant#now()}. + * @return a new {@link FactorGrantedAuthority} + * @throws IllegalArgumentException if temporal constraints are invalid + */ + public FactorGrantedAuthority build() { + if (this.issuedAt == null) { + this.issuedAt = Instant.now(); + } + + return new FactorGrantedAuthority(this.authority, this.issuedAt); + } + + } + +} diff --git a/core/src/main/java/org/springframework/security/jackson2/CoreJackson2Module.java b/core/src/main/java/org/springframework/security/jackson2/CoreJackson2Module.java index 844c8ecdd0..e131e96853 100644 --- a/core/src/main/java/org/springframework/security/jackson2/CoreJackson2Module.java +++ b/core/src/main/java/org/springframework/security/jackson2/CoreJackson2Module.java @@ -25,6 +25,7 @@ import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.RememberMeAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.FactorGrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.User; @@ -60,6 +61,7 @@ public class CoreJackson2Module extends SimpleModule { context.setMixInAnnotations(AnonymousAuthenticationToken.class, AnonymousAuthenticationTokenMixin.class); context.setMixInAnnotations(RememberMeAuthenticationToken.class, RememberMeAuthenticationTokenMixin.class); context.setMixInAnnotations(SimpleGrantedAuthority.class, SimpleGrantedAuthorityMixin.class); + context.setMixInAnnotations(FactorGrantedAuthority.class, FactorGrantedAuthorityMixin.class); context.setMixInAnnotations(Collections.unmodifiableSet(Collections.emptySet()).getClass(), UnmodifiableSetMixin.class); context.setMixInAnnotations(Collections.unmodifiableList(Collections.emptyList()).getClass(), diff --git a/core/src/main/java/org/springframework/security/jackson2/FactorGrantedAuthorityMixin.java b/core/src/main/java/org/springframework/security/jackson2/FactorGrantedAuthorityMixin.java new file mode 100644 index 0000000000..c5268327c2 --- /dev/null +++ b/core/src/main/java/org/springframework/security/jackson2/FactorGrantedAuthorityMixin.java @@ -0,0 +1,56 @@ +/* + * Copyright 2004-present 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.security.jackson2; + +import java.time.Instant; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +/** + * Jackson Mixin class helps in serialize/deserialize + * {@link org.springframework.security.core.authority.SimpleGrantedAuthority}. + * + *

+ *     ObjectMapper mapper = new ObjectMapper();
+ *     mapper.registerModule(new CoreJackson2Module());
+ * 
+ * + * @author Rob Winch + * @since 7.0 + * @see CoreJackson2Module + * @see SecurityJackson2Modules + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY) +@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.NONE, + getterVisibility = JsonAutoDetect.Visibility.PUBLIC_ONLY, isGetterVisibility = JsonAutoDetect.Visibility.NONE) +@JsonIgnoreProperties(ignoreUnknown = true) +abstract class FactorGrantedAuthorityMixin { + + /** + * Mixin Constructor. + * @param authority the authority + */ + @JsonCreator + FactorGrantedAuthorityMixin(@JsonProperty("authority") String authority, + @JsonProperty("issuedAt") Instant issuedAt) { + } + +} diff --git a/core/src/test/java/org/springframework/security/core/authority/FactorGrantedAuthorityTests.java b/core/src/test/java/org/springframework/security/core/authority/FactorGrantedAuthorityTests.java new file mode 100644 index 0000000000..9e3b5b1eea --- /dev/null +++ b/core/src/test/java/org/springframework/security/core/authority/FactorGrantedAuthorityTests.java @@ -0,0 +1,70 @@ +/* + * Copyright 2004-present 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.security.core.authority; + +import java.time.Instant; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests {@link FactorGrantedAuthority}. + * + * @author Yoobin Yoon + * @author Rob Winch + */ +public class FactorGrantedAuthorityTests { + + @Test + public void buildWhenOnlyAuthorityThenDefaultsIssuedAtToNow() { + Instant before = Instant.now(); + + FactorGrantedAuthority authority = FactorGrantedAuthority.withAuthority("profile:read").build(); + + Instant after = Instant.now(); + + assertThat(authority.getAuthority()).isEqualTo("profile:read"); + assertThat(authority.getIssuedAt()).isBetween(before, after); + } + + @Test + public void buildWhenAllFieldsSetThenCreatesCorrectly() { + Instant issuedAt = Instant.now(); + + FactorGrantedAuthority authority = FactorGrantedAuthority.withAuthority("admin:write") + .issuedAt(issuedAt) + .build(); + + assertThat(authority.getAuthority()).isEqualTo("admin:write"); + assertThat(authority.getIssuedAt()).isEqualTo(issuedAt); + } + + @Test + public void buildWhenNullAuthorityThenThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> FactorGrantedAuthority.withAuthority(null)) + .withMessage("A granted authority textual representation is required"); + } + + @Test + public void buildWhenEmptyAuthorityThenThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> FactorGrantedAuthority.withAuthority("")) + .withMessage("A granted authority textual representation is required"); + } + +} diff --git a/core/src/test/java/org/springframework/security/jackson2/FactorGrantedAuthorityMixinTests.java b/core/src/test/java/org/springframework/security/jackson2/FactorGrantedAuthorityMixinTests.java new file mode 100644 index 0000000000..08afa4ce4e --- /dev/null +++ b/core/src/test/java/org/springframework/security/jackson2/FactorGrantedAuthorityMixinTests.java @@ -0,0 +1,62 @@ +/* + * Copyright 2004-present 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.security.jackson2; + +import java.io.IOException; +import java.time.Instant; + +import com.fasterxml.jackson.core.JsonProcessingException; +import org.json.JSONException; +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.FactorGrantedAuthority; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Rob Winch + * @since 7.0 + */ +class FactorGrantedAuthorityMixinTests extends AbstractMixinTests { + + // @formatter:off + public static final String AUTHORITY_JSON = "{\"@class\": \"org.springframework.security.core.authority.FactorGrantedAuthority\", \"authority\": \"FACTOR_PASSWORD\", \"issuedAt\": 1759177143.043000000 }"; + + private Instant issuedAt = Instant.ofEpochMilli(1759177143043L); + + // @formatter:on + + @Test + void serializeSimpleGrantedAuthorityTest() throws JsonProcessingException, JSONException { + GrantedAuthority authority = FactorGrantedAuthority.withAuthority("FACTOR_PASSWORD") + .issuedAt(this.issuedAt) + .build(); + String serializeJson = this.mapper.writeValueAsString(authority); + JSONAssert.assertEquals(AUTHORITY_JSON, serializeJson, true); + } + + @Test + void deserializeGrantedAuthorityTest() throws IOException { + FactorGrantedAuthority authority = (FactorGrantedAuthority) this.mapper.readValue(AUTHORITY_JSON, Object.class); + assertThat(authority).isNotNull(); + assertThat(authority.getAuthority()).isEqualTo("FACTOR_PASSWORD"); + assertThat(authority.getIssuedAt()).isEqualTo(this.issuedAt); + } + +}