Summary
When using a Form Login, a single OAuth2 provider and the auto-generated login page, the auto-configured AuthenticationEntryPoint will redirect to the provider immediately, bypassing the login page and effectively preventing form login.
Actual Behavior
When trying to access a protected resource, spring security will immediately redirect to the OAuth2 provider's authentication page instead of the local login page.
Expected Behavior
When Form Login is configured, the login page should never be skipped.
Configuration
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().anyRequest().authenticated()
.and()
.oauth2Login()
.and()
.oauth2Client()
.and()
.formLogin().permitAll();
}
spring.security.oauth2.client.registration.facebook.client-id=some-id
spring.security.oauth2.client.registration.facebook.client-secret=some-secret
Version
5.1.4-RELEASE, not sure as of which version this happens.
Sample
I don't have a sample, but I found the exact location of the bug:
https://github.com/spring-projects/spring-security/blob/2c136f7b6c5b9ea75430bc660489bb8b22c1e466/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java#L444-L453
The condition should check whether Form Login is enabled and don't apply the shortcut if it is.
Comment From: jgrandja
Thanks for the report @netmikey. Would you be interested in submitting a PR for this fix?
Comment From: rhamedy
I could work on this if @netmikey didn't want to π
Comment From: jgrandja
Thanks @rhamedy for the offer. Let's provide @netmikey a chance to respond. If it's not picked up by end of next week than feel free to take it on. Thanks!
Comment From: netmikey
Thanks, @jgrandja. Since I'm pretty busy those days, please go ahead @rhamedy π
Comment From: rhamedy
Thanks both :slightly_smiling_face:
@jgrandja anything I should know or keep in mind when working towards a fix in addition to the pointers that @netmikey included in the PR description?
Comment From: jgrandja
@rhamedy Nothing that comes to mind at the moment. But feel free to ask any questions along the way. Thanks for taking this on.
Comment From: rhamedy
Hi @jgrandja
I wanted to run these changes and questions by you before creating a pull request. For the actual issue pointed out in the description of the ticket, I did the following
if (loginUrlToClientName.size() == 1)
to following and verified it with a test π
if (loginUrlToClientName.size() == 1
&& http.getConfigurer(FormLoginConfigurer.class) == null)
I could not find an alternative method to check if formLogin is configured π€
Secondly, I noticed that the same issue is there in the reactive side as well. These might be the related issues 5339 & 5347.
Assuming that the Reactive side need fixing as well, I updated the
if (urlToText.size() == 1) {
to
if (urlToText.size() == 1 && formLogin == null) {
however I am not sure if formLogin == null is the right approach? π€ I added a test but, the test fails and complains that authenticationManager cannot be null when I have clearly set it as shown below
@Test
public void defaultLoginPageWhenLoginFormEnabledAndSingleClientRegistrationThenShowDefault() {
this.spring.register(OAuth2LoginWithSingleClientRegistrations.class,
OAuth2LoginMockAuthenticationManagerConfigWithFormLoginEnabled.class).autowire();
WebTestClient webTestClient = WebTestClientBuilder
.bindToWebFilters(new GitHubWebFilter(), this.springSecurity)
.build();
WebDriver driver = WebTestClientHtmlUnitDriverBuilder
.webTestClientSetup(webTestClient)
.build();
driver.get("http://localhost/");
assertThat(driver.getCurrentUrl()).startsWith("http://localhost/login");
}
@Configuration
static class OAuth2LoginMockAuthenticationManagerConfigWithFormLoginEnabled {
ReactiveAuthenticationManager manager = mock(ReactiveAuthenticationManager.class);
@Bean
public SecurityWebFilterChain springSecurityFilter(ServerHttpSecurity http) {
http
.authorizeExchange()
.anyExchange().authenticated()
.and()
.oauth2Login()
.and()
.formLogin()
.authenticationManager(manager); // is still null in FormLoginSpec.configure line 2378
return http.build();
}
}
The error snippet is as follow
Caused by: java.lang.IllegalArgumentException: authenticationManager cannot be null
at org.springframework.util.Assert.notNull(Assert.java:198)
at org.springframework.security.web.server.authentication.AuthenticationWebFilter.<init>(AuthenticationWebFilter.java:82)
I am new to Reactive so I feel like I am not configuring the mock correctly. Anyway, the test issue is not relevant unless we to update the Reactive side. If we have to update the Reactive side then could you please help with root cause of test failure?
Comment From: jgrandja
@rhamedy I would like to avoid the http.getConfigurer(FormLoginConfigurer.class) conditional check, as I don't feel OAuth2LoginConfigurer should be aware of (or have reference) to FormLoginConfigurer. I'll investigate on my end and you continue as well and we'll figure out an alternative approach.
I would leave the reactive changes out for now. Let's figure out the apprach for the fix on servlet side first and go from there.
Comment From: rhamedy
Sounds good @jgrandja, I will look into other options π
Comment From: jgrandja
@rhamedy If you look at the logic in OAuth2LoginConfigurer.initDefaultLoginFilter(), it obtains a reference to DefaultLoginPageGeneratingFilter which FormLoginConfigurer does the same. Maybe you can check DefaultLoginPageGeneratingFilter.isEnabled() which will return true if formLogin() is enabled. I think this should work. But I'm also wondering if there might be an ordering issue between FormLoginConfigurer and OAuth2LoginConfigurer when they get init(). Anyway, give this a try and see how it goes.
Comment From: jgrandja
@netmikey As a temporary workaround, you can configure httpSecurity.oauth2Login().loginPage("/custom-login") to specify your custom login page and you won't have the same issue with the auto-redirect.
The auto-redirect only happens when there is one client configured and no custom loginPage() configured. Just as an FYI, the default login page is provided as a convenience for development or samples - it's not meant to be used in production applications since you can't customize.
Comment From: netmikey
I know, and I'm working on a custom login already.
The default form is very convenient for starting development though, and I found its behavior confusing, especially for someone starting on that subject.
Comment From: rhamedy
hi @jgrandja last week I spent some quite some time debugging alternative options and a majority of my attempts resulted it either in no success or failing existing test. I added the following breaking test
@Test
public void oauth2LoginWithFormLoginPageThenRedirectDefaultLoginPage() throws Exception {
loadConfig(OAuth2LoginConfigFormLoginPage.class);
String requestUri = "/";
this.request = new MockHttpServletRequest("GET", requestUri);
this.request.setServletPath(requestUri);
this.springSecurityFilterChain.doFilter(this.request, this.response, this.filterChain);
assertThat(this.response.getRedirectedUrl()).matches("http://localhost/login");
}
@EnableWebSecurity
static class OAuth2LoginConfigFormLoginPage extends CommonWebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.oauth2Login()
.clientRegistrationRepository(
new InMemoryClientRegistrationRepository(GOOGLE_CLIENT_REGISTRATION))
.and()
.formLogin();
super.configure(http);
}
}
That above test breaks with no fix π and then I proceeded with adding alternative fixes to http.getConfigurer(FormLoginConfigurer.class) which you rightly stated that is not the right approach. One of the alternatives is the following
Map<String, String> loginUrlToClientName = this.getLoginLinks();
DefaultLoginPageGeneratingFilter loginPageGeneratingFilter = http.getSharedObject(DefaultLoginPageGeneratingFilter.class);
if (loginUrlToClientName.size() == 1 && loginPageGeneratingFilter == null) {
...
}
With the above fix the newly added test above passes β
however, an existing test fails the assertions and the question to ask is it correct for DefaultLoginPageGeneratingFilter to be not null in case of this test when the test clearly does not set the form login and only declares oauth2 as shown in the config.
In your suggestions you mentioned making use of DefaultLoginPageGeneratingFilter.isEnabled() and the method is as follow (the following might explain why DefaultLoginPageGeneratingFilter is not null with oauth2Login and might not be null in case of openId too π).
public boolean isEnabled() {
return formLoginEnabled || openIdEnabled || oauth2LoginEnabled;
}
Not sure if that is going to help since it's not just formLoginEnabled but, others too. Regardless, in the tests that I added above the value of isEnabled in OAuth2LoginConfigurer.initDefaultLoginFilter() is false. I tried adding a new public method just for isFormLoginEnabled and that returns false too.
I wanted to specifically check if the filter chain includes UsernamePasswordAuthenticationFilter which is added by FormLoginConfigurer and could not find a reference, not sure why.
This AbstractConfiguredSecurityBuilder.java class has a init method that initializes each configurer's init method and the init method for FormLoginConfigurer.java is different than OAuth2LoginConfigurer.java when it comes to what is being set and not. I added some debug lines in the AbstractConfiguredSecurityBuilder.init() to see whether the FormLoginConfigurer is one of the configurers for the test and it is there.
I might have to do a line by line debug to see where we could potentially add a fix and I am starting to feel like when inside OAuth2LoginConfigurer the only thing that we can do is check if FormLoginConfigurer is in http which is not good practice, any other fix we might have to apply it outside OAuth2LoginConfiguer either before or after (in after case, the oauth2 login configuer might overwrite the formlogin stuff).
Sorry for the detailed messages, it does not seem to be a straight curve and I am starting to feel that my limited knowledge of spring/security is catching up with me π
Comment From: jgrandja
@rhamedy
the question to ask is it correct for
DefaultLoginPageGeneratingFilterto benot null
Take a look at WebSecurityConfigurerAdapter.getHttp() and you will notice that HttpSecurity.apply(new DefaultLoginPageConfigurer<>()) is called when defaults are enabled. So in common configurations, DefaultLoginPageConfigurer will be available so it can be configured by FormLoginConfigurer and/or OAuth2LoginConfigurer.
My original suggestion on checking DefaultLoginPageGeneratingFilter.isEnabled() will not work. The ordering of the configurers is dependent - formLogin() would need to be called before oauth2Login() in WebSecurityConfigurerAdapter.configure(), which obviously we cannot rely on. So this option is out.
This is a tricky one for sure. At the moment, I don't have another suggestion but will give this some further thought.
Comment From: rhamedy
Make sense. I will give it some more thoughts in the coming days and will update here.
Comment From: dcoraboeuf
Hi,
Has there been any evolution on this?
I'm using Spring Security 5.3.1 (through Spring Boot 2.3.0.M4) and still face the same issue.
My application needs to support both "classic" form logins (for service accounts) and several OAuth2 providers. When I enable OAuth2 login, I cannot use the form login any longer.
My configuration looks like:
oauth2Client { }
oauth2Login { }
formLogin {
loginPage = "/login" // There is a controller
permitAll()
}
I can login without any issue using OAuth2. When I remove OAuth2, the form login works.
But when I enable both, I cannot access the form login any longer. Weird enough, going to /login renders the default login page, with a link to my OAuth2 provider π€ and bypasses entirely the controller.
I've tried to enable a custom login page for the oauth2Login doing like this:
oauth2Login {
loginPage = "/oauth2-login" // Using a controller
authorizationEndpoint {
baseUri = "/oauth2-login/code"
}
}
I've adapted the OAuth2 provider to use /oauth2-login/code/okta instead of /login/oauth2/code/okta. But then, the redirection seems broken (browser complaining about cookies).
@netmikey , did you finally make a custom form working ?
Thanks, Damien
Comment From: netmikey
@dcoraboeuf : actually, I didn't follow that path. I first switched to implementing a full OAuth Authentication Server which in turn contained the custom login form, based on Spring Security OAuth Server. Technically that worked.
I never finished it though, because when Spring Security announced they were dropping the support for oauth authentication servers last year (which I know they revised recently), I moved to Keycloak as an IAM provider. They have Spring Security and Spring Boot adapters that make its integration as IAM provider into client applications pretty easy.
Comment From: dcoraboeuf
@netmikey , thanks for your fast answer.
In my case, I need to support two identification modes: a basic form login, using built-in accounts in the application, and several configurable OAuth2 providers.
I can make one or the other work very simply ; it's the combination of the both which does not work. More precisely, the OAuth2 authentication works, but the classic login form is no longer accessible.
I'll try to find a way and document this if (when?) I find a solution.
Thanks all the same !
Comment From: jgrandja
@dcoraboeuf
Has there been any evolution on this?
No there hasn't. It's a bit of a tricky issue to solve (see previous comments).
It's also on the lower priority list since the default login page is really meant for development. A production application would typically setup a custom login page.
Comment From: dcoraboeuf
I have a custom login page for the username/password authentication, but it is not taken into account the moment I declare also a OAuth2 login.
Independently both work well.
I wonder if there are examples of applications mixing form based authentication and OAuth2 authentication.
Comment From: dcoraboeuf
By using a custom login (the same actually) in both OAuth2 config & form login config, I could make this work. The blocking point for me what that launching the application from Okta kept redirecting to the login page, which was of course not satisfying.
Very important for me what: the "Initiate Login URI" in the Okta application has to be set explicitly to http://localhost:8080/oauth2/authorization/okta, while "Login redirect URI" remains to be set to http://localhost:8080/login/oauth2/code/okta.
My config looks now like:
// OAuth setup
oauth2Login {
loginPage = "/login"
permitAll()
}
// Using a form login
formLogin {
loginPage = "/login"
permitAll()
}
The controller looks like:
@Controller
class LoginController(
private val clientRegistrationRepository: OntrackClientRegistrationRepository
) {
@GetMapping("/login")
fun login(model: Model): String {
val registrations = clientRegistrationRepository.toList()
model.addAttribute("registrations", registrations)
return "login"
}
}
and I'm using Thymeleaf to render the login page, using /oauth2/authorization/{registrationId} to render the registered OIDC links.
This way, I can have:
- a username / password form using accounts in a database
- an OIDC login initiated from the app
- an app login initiated from the OIDC provider (Okta in my case)
So, in the end, all's good and the setup is still very simple.
Best regards, Damien
Comment From: jgrandja
@dcoraboeuf
Very important for me what: the "Initiate Login URI" in the Okta application has to be set explicitly to
http://localhost:8080/oauth2/authorization/okta, while "Login redirect URI" remains to be set tohttp://localhost:8080/login/oauth2/code/okta.
http://localhost:8080/oauth2/authorization/okta can be customized via http.oauth2Login().authorizationEndpoint.baseUri() - see OAuth 2.0 Login Page in the reference for further details.
As well, http://localhost:8080/login/oauth2/code/okta can be customized via http.oauth2Login().redirectionEndpoint.baseUri() - see Redirection Endpoint in the reference for further details.
Comment From: jarpz
Is there some way to customize loginPage when we use oauth2Login?
In the servlet configuration it is allowed by using this:
..extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.cors().disable()
.csrf().disable()
.authorizeRequests()
.antMatchers("/oauth2/**",
"/login/**", "/actuator/**", "/", "/login.html").permitAll()
.and()
.authorizeRequests()
.anyRequest().authenticated()
.and()
.oauth2Login()
.loginPage("/login"); // This method does not exist on when we use: ServerHttpSecurity
}
...
@Bean
RouterFunction<ServerResponse> loginPage() {
return RouterFunctions.route(GET("/login"), req -> {
return ServerResponse.ok().render("login.html");
});
}
But using ServerHttpSecurity loginPage it is not present. Any suggestion on how to fix it with web flux?
Regards
Comment From: sjohnr
Hey everyone. I've read through the comments to catch up on this issue. Before looking to a fix, I wanted to see if I understood the issue correctly. So far, with a hello-world style app, I seem to be able to get the desired behavior to work, so I'm wondering if I'm missing anything. Here's what I have:
First, I ran auth-server from @jgrandja 's oauth2-protocol-patterns to stand in for okta, facebook, google, etc. Note: I've added an entry to /etc/hosts for auth-server:9000 to serve up from localhost:9000.
Second, I stood up a spring boot app with the following:
application.yml:
server:
port: 8080
spring:
security:
oauth2:
client:
registration:
login-client:
provider: spring
client-id: login-client
client-secret: secret
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
scope: openid
provider:
spring:
issuer-uri: http://auth-server:9000
user:
password: "{noop}password"
SecurityConfiguration.java:
import org.springframework.boot.autoconfigure.security.SecurityProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.anyRequest().authenticated()
.and()
.oauth2Login()
.loginPage("/login")
.and()
.oauth2Client()
.and()
.formLogin()
.permitAll()
.and();
}
@Bean
public UserDetailsService userDetailsService(SecurityProperties securityProperties) {
SecurityProperties.User user = securityProperties.getUser();
UserDetails userDetails = User.withUsername(user.getName())
.password(user.getPassword())
.roles(user.getRoles().toArray(new String[0]))
.build();
return new InMemoryUserDetailsManager(userDetails);
}
}
HomeController.java:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
public class HomeController {
@GetMapping("/")
public Map<String, String> hello() {
return Map.of("greeting", "Hello, World");
}
}
You'll notice I had to work through an issue with the auto-configured UserDetailsService because the configurations do complete with each other a little bit. But it seems to work.
If I visit localhost:8080 it redirects to /login with the generated login form. It does not contain a link to the OAuth2 provider because I disabled it via .oauth2Login().loginPage("/login"). This seems to be the magic to disable OAuth2 redirects from any page, while retaining the form login out of the box.
If I visit http://localhost:8080/oauth2/authorization/login-client (for example, if a link on my fictitious web app took me there), I am redirected to http://auth-server:9000/login, where I log in successfully and am redirected back to http://localhost:8080/.
I have not tried anything with the oauth client yet.
If you all (@netmikey, @rhamedy, or @dcoraboeuf) have any thoughts on what I'm missing to reproduce your particular issue, let me know.
@jarpz, would you mind opening a separate issue (if one doesn't already exist) on that?
Update:
One additional note:
If I configure .oauth2Login() with a login page that is the same as the redirect-uri for the oauth2 client registration, then I can get default redirect behavior for my oauth provider, but retain ability to use form login.
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.anyRequest().authenticated()
.and()
.oauth2Login()
.loginPage("/oauth2/authorization/login-client")
.and()
.oauth2Client()
.and()
.formLogin()
.permitAll()
.and();
}
Does this achieve the desired behavior in the opening comment?
Comment From: jgrandja
@sjohnr To reproduce the issue, change your SecurityConfiguration:
From:
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.anyRequest().authenticated()
.and()
.oauth2Login()
.loginPage("/login")
.and()
.oauth2Client()
.and()
.formLogin()
.permitAll()
.and();
}
To:
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.anyRequest().authenticated()
.and()
.oauth2Login()
.and()
.oauth2Client()
.and()
.formLogin()
.permitAll()
.and();
}
Removing .loginPage("/login") will enable the default login page to be rendered. However, because there is only 1 ClientRegistration registered it will auto-redirect to the provider instead. It should display the default login page because formLogin() is also configured.
Makes sense?
Comment From: jarpz
Hey everyone. I've read through the comments to catch up on this issue. Before looking to a fix, I wanted to see if I understood the issue correctly. So far, with a hello-world style app, I seem to be able to get the desired behavior to work, so I'm wondering if I'm missing anything. Here's what I have:
The problem I reported is when we use WebFlux Security configuration, In that case, we are not allowed to "change" the default generated webpage for oauth2Client, like when you use "mvc" security adapter does. It seems both forms of build security expression are not equivalent. they don't have the same methods.
Regards
Comment From: jgrandja
@jarpz
Is there some way to customize loginPage when we use oauth2Login?
This is a question that would be better suited to Stack Overflow. We prefer to use GitHub issues only for bugs and enhancements.
But using
ServerHttpSecurityloginPage it is not present. Any suggestion on how to fix it with web flux?
If a feature is missing in ServerHttpSecurity that exists in HttpSecurity then please log a new issue.