Suppose, this is my app. It has no explicit config and only one controller with one endpoint. Very simple
package com.example.securitymre.controller;
import com.example.securitymre.data.Hello;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class HelloController {
@GetMapping("/hello")
public Hello getHello() {
return new Hello();
}
}
package com.example.securitymre.data;
import lombok.Getter;
import lombok.NoArgsConstructor;
@NoArgsConstructor
@Getter
public class Hello {
private String message = "Hello!";
public Hello(String message) {
this.message = message;
}
}
package com.example.securitymre;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SecurityMreApplication {
public static void main(String[] args) {
SpringApplication.run(SecurityMreApplication.class, args);
}
}
When I try to hit GET /hello
, I receive a 401 error which is weird since I haven't configured any security. However, I have Spring Security in my classpath
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.2</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>security-mre</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>security-mre</name>
<description>security-mre</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
Suppose, due to that dependency, there's some autoconfiguration that requires each request to be authenticated. But what are my requests authenticated against? I haven't configured any UserDetailsService
that queries a database or something
Is there any way I can interact with a Spring Boot Security application with no explicit configuration and actually visit my endpoints? Or is it in practical terms inaccessible unless I explicitly write some configuration?
Note. This answer is intended not only to answer the question but also to give a better understanding of Spring Security's internals. If you're only interested in the answer itself, skip to the bold part
Yes, there are a few ways you can access your endpoint
First, you need to understand what Spring Security is. Essentially, it's an extension to Spring Web that makes it easier to add security filters to your application. All those fluent methods of (Server)HttpSecurity
are in the end simply mapped to a bunch of filters that are, by default, applied on each request. When it comes to authentication, the central role is played by Spring's AuthenticationWebFilter
. Here's what it does
Authentication
from the exchange with the help of an AuthenticationConverter
. Its basic implementation, ServerHttpBasicAuthenticationConverter
, takes the request's Authorization
header, which it expects to be in the format of basic <Base64-encoded string>
, discards the "basic" prefix, decodes the encoded claims, and splits the resulting string by a colon (:
). What's on the left side of it is assumed to be a username, what's on the right side of it is assumed to be a password// AuthenticationWebFilter's converter is initialized to ServerHttpBasicAuthenticationConverter in-line. You can change it by calling a setter, but it never happens when there's no explicit config
public class AuthenticationWebFilter implements WebFilter {
private static final Log logger = LogFactory.getLog(AuthenticationWebFilter.class);
private final ReactiveAuthenticationManagerResolver<ServerWebExchange> authenticationManagerResolver;
private ServerAuthenticationSuccessHandler authenticationSuccessHandler = new WebFilterChainServerAuthenticationSuccessHandler();
private ServerAuthenticationConverter authenticationConverter = new ServerHttpBasicAuthenticationConverter();
// ServerHttpBasicAuthenticationConverter
public Mono<Authentication> apply(ServerWebExchange exchange) {
ServerHttpRequest request = exchange.getRequest();
String authorization = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (!StringUtils.startsWithIgnoreCase(authorization, "basic ")) {
return Mono.empty();
}
String credentials = (authorization.length() <= BASIC.length()) ? "" : authorization.substring(BASIC.length());
String decoded = new String(base64Decode(credentials), this.credentialsCharset);
String[] parts = decoded.split(":", 2);
if (parts.length != 2) {
return Mono.empty();
}
return Mono.just(UsernamePasswordAuthenticationToken.unauthenticated(parts[0], parts[1]));
}
private byte[] base64Decode(String value) {
try {
return Base64.getDecoder().decode(value);
}
catch (Exception ex) {
return new byte[0];
}
}
AuthenticationManager
. AuthenticationManager
is an interface with one method, authenticate()
, which takes an Authentication
and returns an Authentication
. It has to, at the very least, check the credentials (a password, typically) and then, if they are valid, return an authenticated Authentication
(one that returns true
on isAuthenticated()
calls). That Authentication
is then put in a SecurityContext
thus making the request itself authenticated// AuthenticationWebFilter
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
return this.requiresAuthenticationMatcher.matches(exchange)
.filter((matchResult) -> matchResult.isMatch())
// the filter tells its AuthenticationConverter to extract a token from the exchange...
.flatMap((matchResult) -> this.authenticationConverter.convert(exchange))
.switchIfEmpty(chain.filter(exchange).then(Mono.empty()))
// ...and then tells its AuthenticationConverter to check the claims
.flatMap((token) -> authenticate(exchange, chain, token))
.onErrorResume(AuthenticationException.class, (ex) -> this.authenticationFailureHandler
.onAuthenticationFailure(new WebFilterExchange(exchange, chain), ex));
}
private Mono<Void> authenticate(ServerWebExchange exchange, WebFilterChain chain, Authentication token) {
return this.authenticationManagerResolver.resolve(exchange)
// here's the key part
.flatMap((authenticationManager) -> authenticationManager.authenticate(token))
.switchIfEmpty(Mono
.defer(() -> Mono.error(new IllegalStateException("No provider found for " + token.getClass()))))
.flatMap(
(authentication) -> onAuthenticationSuccess(authentication, new WebFilterExchange(exchange, chain)))
.doOnError(AuthenticationException.class,
(ex) -> logger.debug(LogMessage.format("Authentication failed: %s", ex.getMessage())));
}
You didn't explicitly register an AuthenticationManager
, but Spring Security created one for you. Here it is. It's from ServerHttpSecurityConfiguration
(which also autoconfigured your overall ServerHttpSecurity
)
private ReactiveAuthenticationManager authenticationManager() {
if (this.authenticationManager != null) {
return this.authenticationManager;
}
if (this.reactiveUserDetailsService != null) {
UserDetailsRepositoryReactiveAuthenticationManager manager = new UserDetailsRepositoryReactiveAuthenticationManager(
this.reactiveUserDetailsService);
if (this.passwordEncoder != null) {
manager.setPasswordEncoder(this.passwordEncoder);
}
manager.setUserDetailsPasswordService(this.userDetailsPasswordService);
if (!this.observationRegistry.isNoop()) {
return new ObservationReactiveAuthenticationManager(this.observationRegistry, manager);
}
return manager;
}
return null;
}
As shown in the snippet above, it creates an AuthenticationManager
subtype called UserDetailsRepositoryReactiveAuthenticationManager
from an injected ReactiveUserDetailsService
instance. UserDetailsService
(reactive or not) is a simple interface that retrieves a user (or rather, a UserDetails
instance) by username. UserDetailsRepositoryReactiveAuthenticationManager
uses it to retrieve matching UserDetails
and then compares the presented password with the one of the UserDetails
object. For example, if my claims are "john_dow:12345", the AuthenticationManager
will call userDetailsService.findByUsername("john_dow")
, then the UserDetailsService
will, if it can, return some John Dow (a UserDetails
with username john_dow
), and finally the AuthenticationManager
will compare my "12345" with johnDoeUserDetails.getPassword()
. It will do it, again, indirectly by calling a boolean method passwordEncoder.matches("12345", johnDoeUserDetails.getPassword())
. In a simple scenario, such as the one we have, the PasswordEncoder
would do no encoding or decoding at all and basically just call equals()
between the two arguments. In a more realistic scenario, the second argument would be an encoded password, e.g. using the Bcrypt algorithm
Anyway, once the claims are verified, the AuthenticationManager
would create an authenticated Authentication
like so. UsernamePasswordAuthenticationToken
is a subtype of Authentication
. Notice it populates the authorities (roles) too
// AbstractUserDetailsReactiveAuthenticationManager
private UsernamePasswordAuthenticationToken createUsernamePasswordAuthenticationToken(UserDetails userDetails) {
return UsernamePasswordAuthenticationToken.authenticated(userDetails, userDetails.getPassword(),
userDetails.getAuthorities());
}
Now, you may be wondering, "Where did it find that UserDetailsService
to inject if I didn't write it too?" Spring Security, again, created one for you. It's declared in another configuration class, namely ReactiveUserDetailsServiceAutoConfiguration
. By the way, notice the @ConditionalOnClass
and @ConditionalOnMissingBean
annotations. This is the origin of all that. You had a ReactiveAuthenticationManager
class in your classpath (since you had a Spring Security dependency), but you didn't declare any of the listed beans explicitly, that's why this whole autoconfiguration kicked in in the first place!
@AutoConfiguration(before = ReactiveSecurityAutoConfiguration.class, after = RSocketMessagingAutoConfiguration.class)
@ConditionalOnClass({ ReactiveAuthenticationManager.class })
@ConditionalOnMissingBean(
value = { ReactiveAuthenticationManager.class, ReactiveUserDetailsService.class,
ReactiveAuthenticationManagerResolver.class },
type = { "org.springframework.security.oauth2.jwt.ReactiveJwtDecoder" })
@Conditional({ ReactiveUserDetailsServiceAutoConfiguration.RSocketEnabledOrReactiveWebApplication.class,
ReactiveUserDetailsServiceAutoConfiguration.MissingAlternativeOrUserPropertiesConfigured.class })
@EnableConfigurationProperties(SecurityProperties.class)
public class ReactiveUserDetailsServiceAutoConfiguration {
private static final String NOOP_PASSWORD_PREFIX = "{noop}";
private static final Pattern PASSWORD_ALGORITHM_PATTERN = Pattern.compile("^\\{.+}.*$");
private static final Log logger = LogFactory.getLog(ReactiveUserDetailsServiceAutoConfiguration.class);
@Bean
public MapReactiveUserDetailsService reactiveUserDetailsService(SecurityProperties properties,
ObjectProvider<PasswordEncoder> passwordEncoder) {
SecurityProperties.User user = properties.getUser();
UserDetails userDetails = getUserDetails(user, getOrDeducePassword(user, passwordEncoder.getIfAvailable()));
return new MapReactiveUserDetailsService(userDetails);
}
private UserDetails getUserDetails(SecurityProperties.User user, String password) {
List<String> roles = user.getRoles();
return User.withUsername(user.getName()).password(password).roles(StringUtils.toStringArray(roles)).build();
}
private String getOrDeducePassword(SecurityProperties.User user, PasswordEncoder encoder) {
String password = user.getPassword();
if (user.isPasswordGenerated()) {
logger.info(String.format("%n%nUsing generated security password: %s%n", user.getPassword()));
}
if (encoder != null || PASSWORD_ALGORITHM_PATTERN.matcher(password).matches()) {
return password;
}
return NOOP_PASSWORD_PREFIX + password;
}
Notice what the code above does. It injects a SecurityProperties
which has some nested user, then uses it as a template to create a brand new User
which is a subtype of UserDetails
. Let's check out the SecurityProperties
class
/**
* Configuration properties for Spring Security.
*
* @author Dave Syer
* @author Andy Wilkinson
* @author Madhura Bhave
* @since 1.0.0
*/
@ConfigurationProperties(prefix = "spring.security")
public class SecurityProperties {
/**
* Order applied to the {@code SecurityFilterChain} that is used to configure basic
* authentication for application endpoints. Create your own
* {@code SecurityFilterChain} if you want to add your own authentication for all or
* some of those endpoints.
*/
public static final int BASIC_AUTH_ORDER = Ordered.LOWEST_PRECEDENCE - 5;
/**
* Order applied to the {@code WebSecurityCustomizer} that ignores standard static
* resource paths.
*/
public static final int IGNORED_ORDER = Ordered.HIGHEST_PRECEDENCE;
/**
* Default order of Spring Security's Filter in the servlet container (i.e. amongst
* other filters registered with the container). There is no connection between this
* and the {@code @Order} on a {@code SecurityFilterChain}.
*/
public static final int DEFAULT_FILTER_ORDER = OrderedFilter.REQUEST_WRAPPER_FILTER_MAX_ORDER - 100;
private final Filter filter = new Filter();
private final User user = new User();
public User getUser() {
return this.user;
}
public Filter getFilter() {
return this.filter;
}
public static class Filter {
/**
* Security filter chain order for Servlet-based web applications.
*/
private int order = DEFAULT_FILTER_ORDER;
/**
* Security filter chain dispatcher types for Servlet-based web applications.
*/
private Set<DispatcherType> dispatcherTypes = EnumSet.allOf(DispatcherType.class);
public int getOrder() {
return this.order;
}
public void setOrder(int order) {
this.order = order;
}
public Set<DispatcherType> getDispatcherTypes() {
return this.dispatcherTypes;
}
public void setDispatcherTypes(Set<DispatcherType> dispatcherTypes) {
this.dispatcherTypes = dispatcherTypes;
}
}
public static class User {
/**
* Default user name.
*/
private String name = "user";
/**
* Password for the default user name.
*/
private String password = UUID.randomUUID().toString();
/**
* Granted roles for the default user name.
*/
private List<String> roles = new ArrayList<>();
private boolean passwordGenerated = true;
public String getName() {
return this.name;
}
public void setName(String name) {
this.name = name;
}
public String getPassword() {
return this.password;
}
public void setPassword(String password) {
if (!StringUtils.hasLength(password)) {
return;
}
this.passwordGenerated = false;
this.password = password;
}
public List<String> getRoles() {
return this.roles;
}
public void setRoles(List<String> roles) {
this.roles = new ArrayList<>(roles);
}
public boolean isPasswordGenerated() {
return this.passwordGenerated;
}
}
}
So what we see is that the default user has a username "user" and a password which is a randomly generated UUID. Unless it's reset (by calling setPassword()
), it keeps that value and also keeps a passwordGenerated
flag which is accessible via isPasswordGenerated()
. Let's get back to ReactiveUserDetailsServiceAutoConfiguration
if (user.isPasswordGenerated()) {
logger.info(String.format("%n%nUsing generated security password: %s%n", user.getPassword()));
}
It appears, if we keep our generated password, we should be able to see it in our cosole output! Let's examine it
INFO 9080 --- [ main] ctiveUserDetailsServiceAutoConfiguration :
Using generated security password: 82963d45-55ed-40ff-bc6c-bddef52c535a
Booyah! Now, we got all the information we need
basic
) and the algorithm (Base64)All we have to do is to generate a request with an Authorization
header that has this value: basic <base64endcoded(user:<our random UUID>)>
. There are free online Base64 tools that you can use. I liked this one. Note that you'll have a different UUID each time so you should check your console instead of pasting my UUID
(The screenshot is from IntelliJ's HTTP client. You may use Postman or the good-old curls. Any client that lets you specify a header)
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 20
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 0
Referrer-Policy: no-referrer
set-cookie: SESSION=; Max-Age=0; Expires=Sat, 17 Feb 2024 10:16:25 GMT; Path=/; HTTPOnly; SameSite=Lax
{
"message": "Hello!"
}
You may have noticed that SecurityProperties
is @ConfigurationProperties
. It means you can easily override those default values in your properties file. You won't see that log message anymore since you're going to have you own, non-generated password
# application.yml
spring:
security:
user:
name: mickey_m
password: pass
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 20
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 0
Referrer-Policy: no-referrer
{
"message": "Hello!"
}
You can also make a GET request to /login
, copy a CSRF token from the HTML, make a POST request with username, password, and the CSRF token in the request body to the same path
POST http://localhost:8080/login
Content-Type: application/x-www-form-urlencoded
username=mickey_m&password=pass&_csrf=MiyRbaso7IVlFnV8YstELfLORnHsvsg0JPW-qoGSqkri3WD7ChiiXZxO2rxILxFIW-ZwSJaoaxDVhv0ZRsLaz7WnnijX6VTP
receive a 404 (since you don't have a mapping for /
to which a request is redirected by default), then copy the session id from the response's cookie, and hit /hello
with that key-value pair in your Cookie
header. You'll get the same response
GET http://localhost:8080/hello
Cookie: SESSION=48d946ab-b9b9-4d93-99c5-eb484a21dbbe
But I won't describe that method in detail since the answer is already quite long (besides, it's rather twisted)