Add FactorGrantedAuthority

Closes gh-17996
This commit is contained in:
Rob Winch 2025-10-02 09:30:40 -05:00
parent 477a456d6c
commit ce36fc1e76
No known key found for this signature in database
10 changed files with 371 additions and 4 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(),

View File

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

View File

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

View File

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