Pārlūkot izejas kodu

Add FactorGrantedAuthority

Closes gh-17996
Rob Winch 1 nedēļu atpakaļ
vecāks
revīzija
ce36fc1e76

+ 3 - 0
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);

BIN
config/src/test/resources/serialized/7.0.x/org.springframework.security.core.authority.FactorGrantedAuthority.serialized


+ 1 - 0
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"

+ 2 - 2
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;
 	}
 

+ 2 - 2
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<GrantedAuthority> 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;

+ 173 - 0
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}.
+		 * <p>
+		 * 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);
+		}
+
+	}
+
+}

+ 2 - 0
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(),

+ 56 - 0
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}.
+ *
+ * <pre>
+ *     ObjectMapper mapper = new ObjectMapper();
+ *     mapper.registerModule(new CoreJackson2Module());
+ * </pre>
+ *
+ * @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) {
+	}
+
+}

+ 70 - 0
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");
+	}
+
+}

+ 62 - 0
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);
+	}
+
+}