Spring Boot Part 7: Spring Security, Basic Authentication and Form Login, and Oauth2
Allen D. Ball
Posted on November 1, 2020
(This post has been updated at blog.hcf.dev with a later version of Spring Boot.)
This article explores integrating Spring Security into a Spring Boot application. Specifically, it will examine:
Managing users' credentials (IDs and passwords) and granted authorities
Creating a Spring MVC Controller with Spring Method Security and Thymeleaf (to provide features such as customized menus corresponding to a user's grants)
Creating a REST controller with Basic Authentication and Spring Method Security
The MVC application and REST controller will each have functions requiring various granted authorities. E.g., a "who-am-i" function may be executed by a "USER" but the "who" function will require 'ADMINISTRATOR" authority while "logout" and "change password" will simply require the user is authenticated. The MVC application will also use the Spring Security Thymeleaf Dialect to provide menus in the context of the authorities granted to the user.
After creating the baseline application, this article will then explore integrating OAuth authentication.
Source code for the series and for this part are available on Github.
Application
The following subsections outline creating and running the baseline application.
Prerequisites
@Configuration
@NoArgsConstructor @ToString @Log4j2
public class PasswordEncoderConfiguration {
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
The returned DelegatingPasswordEncoder
will decrypt most formats known to Spring and will encrypt using a BCryptPasswordEncoder
for storage.
@Repository
@Transactional(readOnly = true)
public interface CredentialRepository extends JpaRepository<Credential,String> {
}
public enum Authorities { USER, ADMINISTRATOR };
The generated tables are:
mysql> DESCRIBE credentials;
+----------+-------------+------+-----+---------+-------+
| Field | Type | Null | Key | Default | Extra |
+----------+-------------+------+-----+---------+-------+
| email | varchar(64) | NO | PRI | NULL | |
| password | longtext | NO | | NULL | |
+----------+-------------+------+-----+---------+-------+
2 rows in set (0.00 sec)
mysql> DESCRIBE authorities;
+--------+--------------+------+-----+---------+-------+
| Field | Type | Null | Key | Default | Extra |
+--------+--------------+------+-----+---------+-------+
| email | varchar(64) | NO | PRI | NULL | |
| grants | varchar(255) | NO | | NULL | |
+--------+--------------+------+-----+---------+-------+
2 rows in set (0.00 sec)
@Configuration
@NoArgsConstructor @ToString @Log4j2
public class UserServicesConfiguration {
@Autowired private CredentialRepository credentialRepository = null;
@Autowired private AuthorityRepository authorityRepository = null;
@Bean
public UserDetailsService userDetailsService() {
return new UserDetailsServiceImpl();
}
...
@NoArgsConstructor @ToString
private class UserDetailsServiceImpl implements UserDetailsService {
@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = null;
try {
Optional<Credential> credential = credentialRepository.findById(username);
Optional<Authority> authority = authorityRepository.findById(username);
user =
new User(username,
credential.get().getPassword(),
authority.map(t -> t.getGrants().asGrantedAuthorityList())
.orElse(AuthorityUtils.createAuthorityList()));
} catch (UsernameNotFoundException exception) {
throw exception;
} catch (Exception exception) {
throw new UsernameNotFoundException(username);
}
return user;
}
}
...
}
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@NoArgsConstructor(access = PRIVATE) @Log4j2
public abstract class WebSecurityConfigurerImpl extends WebSecurityConfigurerAdapter {
@Autowired private UserDetailsService userDetailsService = null;
@Autowired private PasswordEncoder passwordEncoder = null;
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder);
}
...
}
The WebSecurityConfigurer
for the REST controller (WebSecurityConfigurerImpl.API
) must be ordered before the configurer for the MVC controller (@Order(1)
) because otherwise its path-space, /api/**
, would be included in that of the MVC controller, /**
.
The configuration:
- Requires requests are authenticated
- Disables Cross-Site Request Forgery checks
- Configures Basic Authentication
public abstract class WebSecurityConfigurerImpl extends WebSecurityConfigurerAdapter {
...
@Configuration
@Order(1)
@NoArgsConstructor @ToString
public static class API extends WebSecurityConfigurerImpl {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.antMatcher("/api/**")
.authorizeRequests(t -> t.anyRequest().authenticated())
.csrf(t -> t.disable())
.httpBasic(t -> t.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.FORBIDDEN)));
}
}
...
}
The HttpStatusEntryPoint
is configured to prevent authentication failures from redirecting to the /error
page configured for the MVC controller.
The WebSecurityConfigurerImpl.UI
configuration:
- Ignores security checks on static assets
- Requires requests are authenticated
- Configures Form Login
- Configures a Logout Handler (alleviating the need to implement a corresponding MVC controller method)
public abstract class WebSecurityConfigurerImpl extends WebSecurityConfigurerAdapter {
...
@Configuration
@Order(2)
@NoArgsConstructor @ToString
public static class UI extends WebSecurityConfigurerImpl {
private static final String[] IGNORE = {
"/css/**", "/js/**", "/images/**", "/webjars/**", "/webjarsjs"
};
@Override
public void configure(WebSecurity web) {
web.ignoring().antMatchers(IGNORE);
}
...
@Override
protected void configure(HttpSecurity http) throws Exception {
http.antMatcher("/**")
.authorizeRequests(t -> t.anyRequest().authenticated())
.formLogin(t -> t.loginPage("/login").permitAll())
.logout(t -> t.logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
.logoutSuccessUrl("/").permitAll());
...
}
}
...
}
The outline of the MVC is shown below. (The individual methods will be described in detail in a subsequent chapter.) There are two things to note:
@Controller
@RequestMapping(value = { "/" })
@NoArgsConstructor @ToString @Log4j2
public class ControllerImpl implements ErrorController {
private static final String VIEW = ControllerImpl.class.getPackage().getName();
...
@Autowired private SpringResourceTemplateResolver resolver = null;
...
@PostConstruct
public void init() { resolver.setUseDecoupledLogic(true); }
@PreDestroy
public void destroy() { }
...
@RequestMapping(value = { "/" })
public String root() {
return VIEW;
}
...
@RequestMapping(value = "${server.error.path:${error.path:/error}}")
public String error() { return VIEW; }
@ExceptionHandler
@ResponseStatus(value = INTERNAL_SERVER_ERROR)
public String handle(Model model, Exception exception) {
model.addAttribute("exception", exception);
return VIEW;
}
...
}
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" th:xmlns="@{http://www.w3.org/1999/xhtml}">
<head>...</head>
<body>
<header>
<nav th:ref="navbar">
<th:block th:ref="container">
...
<div th:ref="navbar-menu">
...
<ul th:ref="navbar-end">
<li th:ref="navbar-item" sec:authorize="hasAuthority('ADMINISTRATOR')">
<button th:text="'Administrator'"/>
<ul th:ref="navbar-dropdown">...</ul>
</li>
<li th:ref="navbar-item" sec:authorize="hasAuthority('USER')">
<button th:text="'User'"/>
<ul th:ref="navbar-dropdown">...</ul>
</li>
<li th:ref="navbar-item" sec:authorize="isAuthenticated()">
<button sec:authentication="name"/>
<ul th:ref="navbar-dropdown">...</ul>
</li>
<li th:ref="navbar-item" sec:authorize="!isAuthenticated()">
<a th:text="'Login'" th:href="@{/login}"/>
</li>
</ul>
</div>
</th:block>
</nav>
</header>
<main th:unless="${#ctx.containsVariable('exception')}"
th:switch="${#request.servletPath}">
<section th:case="'/who'">...</section>
<section th:case="'/who-am-i'">...</section>
<section th:case="'/error'">...</section>
<section th:case="*">
<th:block th:if="${#ctx.containsVariable('form')}">
<th:block th:insert="~{${#execInfo.templateName + '/' + form.class.simpleName}}"/>
</th:block>
<p th:if="${#ctx.containsVariable('exception')}" th:text="${exception}"/>
</section>
</main>
<main th:if="${#ctx.containsVariable('exception')}">
<section>...</section>
</main>
<footer>
<nav th:ref="navbar">
<div th:ref="container">
...
<span th:ref="right">
<th:block sec:authorize="isAuthenticated()">
<span sec:authentication="authorities"/>
</th:block>
</span>
</div>
</nav>
</footer>
...
</body>
</html>
Bootstrap attributes are added through the "decoupled template logic" expressed in src/main/resources/templates/application.th.xml
. (The mechanics of decoupled template logic are not discussed further in this article.)
@RestController
@RequestMapping(value = { "/api/" }, produces = APPLICATION_JSON_VALUE)
@NoArgsConstructor @ToString @Log4j2
public class RestControllerImpl {
...
@ExceptionHandler({ AccessDeniedException.class, SecurityException.class })
public ResponseEntity<Object> handleFORBIDDEN() {
return new ResponseEntity<>(HttpStatus.FORBIDDEN);
}
}
Runtime Environment
spring.jpa.defer-datasource-initialization: true
spring.jpa.format-sql: true
spring.jpa.hibernate.ddl-auto: create
spring.jpa.open-in-view: true
spring.jpa.show-sql: false
spring.sql.init.enabled: true
spring.sql.init.data-locations: file:data.sql
...
While the above specifies the contents of data.sql
is to be loaded to the Spring data source, it does not configure the data source. Two additional profiles are provide to configure an hsqldb
or mysql
data source. The hsqldb
profile and application properties are shown below.
spring.datasource.driver-class-name: org.hsqldb.jdbc.JDBCDriver
spring.datasource.url: jdbc:hsqldb:mem:testdb;DB_CLOSE_DELAY=-1
spring.datasource.username: sa
spring.datasource.password:
The mysql
profile requires the embedded MySQL server described in Spring Embedded MySQL Server and packaged in the starter described in part 6 of this series.
spring.jpa.hibernate.naming.implicit-strategy: default
spring.jpa.hibernate.naming.physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
spring.datasource.driver-class-name: com.mysql.cj.jdbc.Driver
spring.datasource.url: jdbc:mysql://localhost:${mysqld.port}/application?serverTimezone=UTC&createDatabaseIfNotExist=true
spring.datasource.username: root
mysqld.home: target/mysql
mysqld.port: 3306
Depending on the desired database selection, run either mvn -Pspring-boot:run,hsqldb
or mvn -Pspring-boot:run,mysql
to start the application server.
INSERT INTO credentials (email, password)
VALUES ('admin@example.com', '{noop}abcdef'),
('user@example.com', '{noop}123456');
INSERT INTO authorities (email, grants)
VALUES ('admin@example.com', 'ADMINISTRATOR,USER'),
('user@example.com', 'USER');
The result of executing the above SQL is shown below.
mysql> SELECT * FROM credentials;
+-------------------+--------------+
| email | password |
+-------------------+--------------+
| admin@example.com | {noop}abcdef |
| user@example.com | {noop}123456 |
+-------------------+--------------+
2 rows in set (0.00 sec)
mysql> SELECT * FROM authorities;
+-------------------+--------------------+
| email | grants |
+-------------------+--------------------+
| admin@example.com | ADMINISTRATOR,USER |
| user@example.com | USER |
+-------------------+--------------------+
2 rows in set (0.00 sec)
For purposes of demonstration, the above passwords are exceedingly weak and unencrypted. Passwords may be encrypted outside the application with the htpasswd
command:
$ htpasswd -bnBC 10 "" 123456 | tr -d ':\n' | sed 's/$2y/$2a/'
$2a$10$PJO7Bxx9u9JHnZ0lhHJ2dO5WwWwGrDvBdy82mV/KHUw/b1Us1yZS6
Whose output may be used to set (UPDATE) user@example.com
's password to {BCRYPT}$2a$10$PJO7Bxx9u9JHnZ0lhHJ2dO5WwWwGrDvBdy82mV/KHUw/b1Us1yZS6
.
The next section discusses the MVC controller.
MVC Controller
Navigating to http://localhost:8080/login/ will present:
The portion of the Thymeleaf template that generates the navbar buttons and drop-down menus is shown below. The sec:authorize
expressions are evaluated to determine if Thymeleaf renders the corresponding HTML.
<ul th:ref="navbar-end">
<li th:ref="navbar-item" sec:authorize="hasAuthority('ADMINISTRATOR')">
<button th:text="'Administrator'"/>
<ul th:ref="navbar-dropdown">
<li><a th:text="'Who'" th:href="@{/who}"/></li>
</ul>
</li>
<li th:ref="navbar-item" sec:authorize="hasAuthority('USER')">
<button th:text="'User'"/>
<ul th:ref="navbar-dropdown">
<li><a th:text="'Who Am I?'" th:href="@{/who-am-i}"/></li>
</ul>
</li>
<li th:ref="navbar-item" sec:authorize="isAuthenticated()">
<button sec:authentication="name"/>
<ul th:ref="navbar-dropdown">
<li><a th:text="'Change Password'" th:href="@{/password}"/></li>
<li><a th:text="'Logout'" th:href="@{/logout}"/></li>
</ul>
</li>
<li th:ref="navbar-item" sec:authorize="!isAuthenticated()">
<a th:text="'Login'" th:href="@{/login}"/>
</li>
</ul>
In the case of an unauthenticated client, only the "Login" button is rendered (as shown in the image above). The resulting HTML (with the decoupled template logic applied) is shown below.
<ul class="navbar-nav text-white bg-dark">
<li class="navbar-item dropdown">
<a href="/login" class="btn navbar-link text-white bg-dark">Login</a>
</li>
</ul>
public class ControllerImpl implements ErrorController {
...
@Autowired private CredentialRepository credentialRepository = null;
@Autowired private PasswordEncoder encoder = null;
...
@RequestMapping(method = { GET }, value = { "login" })
public String login(Model model, HttpSession session) {
model.addAttribute("form", new LoginForm());
return VIEW;
}
@RequestMapping(method = { GET }, value = { "password" })
@PreAuthorize("isAuthenticated()")
public String password(Model model, Principal principal) {
Credential credential =
credentialRepository.findById(principal.getName())
.orElseThrow(() -> new AuthorizationServiceException("Unauthorized"));
model.addAttribute("form", new ChangePasswordForm());
return VIEW;
}
@RequestMapping(method = { POST }, value = { "password" })
@PreAuthorize("isAuthenticated()")
public String passwordPOST(Model model, Principal principal, @Valid ChangePasswordForm form, BindingResult result) {
Credential credential =
credentialRepository.findById(principal.getName())
.orElseThrow(() -> new AuthorizationServiceException("Unauthorized"));
try {
if (result.hasErrors()) {
throw new RuntimeException(String.valueOf(result.getAllErrors()));
}
if (! (Objects.equals(form.getUsername(), principal.getName())
&& encoder.matches(form.getPassword(), credential.getPassword()))) {
throw new AccessDeniedException("Invalid user name and password");
}
if (! (form.getNewPassword() != null
&& Objects.equals(form.getNewPassword(), form.getRepeatPassword()))) {
throw new RuntimeException("Repeated password does not match new password");
}
if (encoder.matches(form.getNewPassword(), credential.getPassword())) {
throw new RuntimeException("New password must be different than old");
}
credential.setPassword(encoder.encode(form.getNewPassword()));
credentialRepository.save(credential);
} catch (Exception exception) {
model.addAttribute("form", form);
model.addAttribute("errors", exception.getMessage());
}
return VIEW;
}
...
}
Once authenticated, the user management drop-down is rendered and the Login button is not. In addition, because user@example.com
has been granted "USER" authority, the User drop-down is rendered to HTML, also.
The change password form is straightforward.
And, as an aside, changed passwords are stored encrypted (as expected).
mysql> SELECT * FROM credentials;
+-------------------+----------------------------------------------------------------------+
| email | password |
+-------------------+----------------------------------------------------------------------+
| admin@example.com | {noop}abcdef |
| user@example.com | {bcrypt}$2a$10$UXRB6BbmcbHfXkWDTk755ewgWsENMgFZoJ.JcIoiIjuRyGhOpEaNS |
+-------------------+----------------------------------------------------------------------+
2 rows in set (0.00 sec)
The user dropdown expanded below:
public class ControllerImpl implements ErrorController {
...
@RequestMapping(value = { "who-am-i" })
@PreAuthorize("hasAuthority('USER')")
public String whoAmI(Model model, Principal principal) {
model.addAttribute("principal", principal);
return VIEW;
}
...
}
When selecting "User->Who Am I?" the application shows something similar to:
The application Thymeleaf template contains to display the method parameter Principal
:3
<section th:case="'/who-am-i'">
<p th:text="${principal}"/>
</section>
Clients that have been granted "ADMINISTRATOR" authority will be presented the Administrator drop-down menu.
public class ControllerImpl implements ErrorController {
...
@Autowired private SessionRegistry registry = null;
...
@RequestMapping(value = { "who" })
@PreAuthorize("hasAuthority('ADMINISTRATOR')")
public String who(Model model) {
model.addAttribute("principals", registry.getAllPrincipals());
return VIEW;
}
...
}
public abstract class WebSecurityConfigurerImpl extends WebSecurityConfigurerAdapter {
...
public static class UI extends WebSecurityConfigurerImpl {
...
@Override
protected void configure(HttpSecurity http) throws Exception {
...
http.sessionManagement(t -> t.maximumSessions(-1).sessionRegistry(sessionRegistry()));
}
...
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
...
}
...
}
A client with ADMINISTRATION authority may navigate to /who
:
While a client without (even if authenticated) will be denied:
The next section discusses the REST controller.
REST Controller
public class RestControllerImpl {
...
@RequestMapping(method = { GET }, value = { "who-am-i" })
@PreAuthorize("hasAuthority('USER')")
public ResponseEntity<Principal> whoAmI(Principal principal) throws Exception {
return new ResponseEntity<>(principal, HttpStatus.OK);
}
...
}
Invoking without authentication returns HTTP/1.1 403
.4
$ curl -is http://localhost:8080/api/who-am-i
HTTP/1.1 403
Set-Cookie: JSESSIONID=6EBD3FEED11F2499F6915E98E02D1C26; Path=/; HttpOnly
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Length: 0
Date: Sat, 17 Oct 2020 05:25:04 GMT
While supplying credentials for a user that is granted "USER" is successful.
$ curl -is --basic -u user@example.com:123456 http://localhost:8080/api/who-am-i
HTTP/1.1 200
Set-Cookie: JSESSIONID=C0F5D3A67AA59521A223B5F87B2915FC; Path=/; HttpOnly
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 17 Oct 2020 05:25:44 GMT
{
"authorities" : [ {
"authority" : "USER"
} ],
"details" : {
"remoteAddress" : "0:0:0:0:0:0:0:1",
"sessionId" : null
},
"authenticated" : true,
"principal" : {
"password" : null,
"username" : "user@example.com",
"authorities" : [ {
"authority" : "USER"
} ],
"accountNonExpired" : true,
"accountNonLocked" : true,
"credentialsNonExpired" : true,
"enabled" : true
},
"credentials" : null,
"name" : "user@example.com"
}
public class RestControllerImpl {
@Autowired private SessionRegistry registry = null;
...
@RequestMapping(method = { GET }, value = { "who" })
@PreAuthorize("hasAuthority('ADMINISTRATOR')")
public ResponseEntity<List<Object>> who() throws Exception {
return new ResponseEntity<>(registry.getAllPrincipals(), HttpStatus.OK);
}
...
}
Invoking with an authenticated client without "ADMINISTRATOR" authority granted returns HTTP/1.1 403
(as expected).5
$ curl -is --basic -u user@example.com:123456 http://localhost:8080/api/who
HTTP/1.1 403
...
While supplying credentials for a user that is granted "ADMINISTRATOR" is successful.
$ curl -is --basic -u admin@example.com:abcdef http://localhost:8080/api/who
HTTP/1.1 200
Set-Cookie: JSESSIONID=8E283763FD321382417C89B609DE9EDC; Path=/; HttpOnly
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 17 Oct 2020 05:27:39 GMT
[ {
"password" : null,
"username" : "user@example.com",
"authorities" : [ {
"authority" : "USER"
} ],
"accountNonExpired" : true,
"accountNonLocked" : true,
"credentialsNonExpired" : true,
"enabled" : true
}, {
"password" : null,
"username" : "admin@example.com",
"authorities" : [ {
"authority" : "ADMINISTRATOR"
}, {
"authority" : "USER"
} ],
"accountNonExpired" : true,
"accountNonLocked" : true,
"credentialsNonExpired" : true,
"enabled" : true
} ]
The next chapter will examine OAuth integration.
OAuth
The following subsections will:
Run an experiment by configuring the application as described in the first chapter for OAuth authentication
Change the application implementation to allow Form Login and OAuth authenitcation
Experiment
This section will examine the behavior Spring Security's default settings for authentication. An OAuth provider must be configured. This can easily be done on GitHub:
Navigate to GitHub and login
Select "Settings" from the right-most profile drop-down menu
-
Click "Register a new application." Fill in the form with the following values:
- Homepage URL: http://localhost:8080/
- Authorization callback URL: http://localhost:8080/login/oauth2/code/github
-
Note the Client ID and Secret as it will be required in the application configuration
The POM oauth
profile enables the Spring Boot oauth
profile. In addition, the required Spring Security dependencies for OAuth are added: An OAuth 2.0 client and support for Javascript Object Signing and Encryption (JOSE).
<profiles>
...
<profile>
<id>oauth</id>
<build>
...
</build>
</profile>
...
</profiles>
<dependencies verbose="true">
...
<dependency>
<groupId>com.okta.spring</groupId>
<artifactId>okta-spring-boot-starter</artifactId>
<version>2.0.1</version>
</dependency>
...
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
...
</dependencies>
Allowing the application to be run from Maven with either mvn -Pspring-boot:run,hsqldb,oauth
or mvn -Pspring-boot:run,mysql,oauth
.
public abstract class WebSecurityConfigurerImpl extends WebSecurityConfigurerAdapter {
...
public static class UI extends WebSecurityConfigurerImpl {
...
@Override
protected void configure(HttpSecurity http) throws Exception {
http.antMatcher("/**")
.authorizeRequests(t -> t.anyRequest().authenticated())
/* .formLogin(t -> t.loginPage("/login").permitAll()) */
.oauth2Login(Customizer.withDefaults())
.logout(t -> t.logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
.logoutSuccessUrl("/").permitAll());
...
}
...
}
...
}
Finally, the OAuth client prperties must be configured in the profile-specific application properties YAML file:6
spring:
security:
oauth2:
client:
registration:
github:
client-id: dad3306da38eb7be68a1
client-secret: 8a5394b2e29037b9bdf17e51af472020f85bfca6
Running the application now offers OAuth login:
Clicking GitHub will redirect for authorization:
If granted, the application will successfully login. However, the Principal
name will be unrecognizable as well as the granted authorities:
If invoked, the Change Password function fails with:
Because the authenticated Principal
has no corresponding Credential
database record.
A naive implementation to integrate Form Login and OAuth2 Login is configured by simply enabling both:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.antMatcher("/**")
.authorizeRequests(t -> t.anyRequest().authenticated())
.formLogin(t -> t.loginPage("/login").permitAll())
.oauth2Login(Customizer.withDefaults())
.logout(t -> t.logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
.logoutSuccessUrl("/").permitAll());
...
}
However, only the OAuth2 Login page is available (illustrated previously). A successful integration will need to create a custom OAuth2 Login page compatible with (and the same as) the Form Login Page.
The next subsection will adjust the implementation to:
Use user e'mail as
Principal
nameIntegrate Form Login and OAuth2 Login into a single custom login page
Manage granted authorities for OAuth2-authenticated users
Not offer the Change Password function to users logged in through OAuth2
Implementation
This section will adjust the implementation as outlined at the end of the previous section. The first step is to provide the OAuth 2.0 security client registrations and configured providers. The processes to configure Google and Okta Client IDs is very similar to the one for GitHub and must be configured on their respective sites. The authorization callback URI must be http://localhost:8080/login/oauth2/code/google and http://localhost:8080/login/oauth2/code/okta, respectively. A redacted example is shown below.
---
spring:
security:
oauth2:
client:
registration:
github:
client-id: XXXXXXXXXXXXXXXXXXXX
client-secret: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
scope: user:email
google:
client-id: XXXXXXXXXXXXXXXXXXXX
client-secret: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
okta:
client-id: XXXXXXXXXXXXXXXXXXXX
client-secret: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
client-name: Okta
provider:
github:
user-name-attribute: email
google:
user-name-attribute: email
okta:
issuer-uri: https://DOMAIN.okta.com/oauth2/default
user-name-attribute: email
Note that the providers are configured to use the user's e'mail address as the Principal
name (user-name-attribute: email
).
public abstract class WebSecurityConfigurerImpl extends WebSecurityConfigurerAdapter {
...
public static class UI extends WebSecurityConfigurerImpl {
...
@Autowired private OidcUserService oidcUserService = null;
...
@Override
protected void configure(HttpSecurity http) throws Exception {
...
try {
ClientRegistrationRepository repository =
getApplicationContext().getBean(ClientRegistrationRepository.class);
if (repository != null) {
http.oauth2Login(t -> t.clientRegistrationRepository(repository)
.userInfoEndpoint(u -> u.oidcUserService(oidcUserService))
.loginPage("/login").permitAll());
}
} catch (Exception exception) {
}
...
}
...
}
...
}
@Configuration
@NoArgsConstructor @ToString @Log4j2
public class UserServicesConfiguration {
...
@Bean
public OAuth2UserService<OAuth2UserRequest,OAuth2User> oAuth2UserService() {
return new OAuth2UserServiceImpl();
}
@Bean
public OidcUserService oidcUserService() {
return new OidcUserServiceImpl();
}
...
private static final List<GrantedAuthority> DEFAULT_AUTHORITIES =
AuthorityUtils.createAuthorityList(Authorities.USER.name());
@NoArgsConstructor @ToString
private class OAuth2UserServiceImpl extends DefaultOAuth2UserService {
private final DefaultOAuth2UserService delegate = new DefaultOAuth2UserService();
@Override
public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException {
String attribute =
request.getClientRegistration().getProviderDetails()
.getUserInfoEndpoint().getUserNameAttributeName();
OAuth2User user = delegate.loadUser(request);
try {
Optional<Authority> authority = authorityRepository.findById(user.getName());
user =
new DefaultOAuth2User(authority.map(t -> t.getGrants().asGrantedAuthorityList())
.orElse(DEFAULT_AUTHORITIES),
user.getAttributes(), attribute);
} catch (OAuth2AuthenticationException exception) {
throw exception;
} catch (Exception exception) {
log.warn("{}", request, exception);
}
return user;
}
}
@NoArgsConstructor @ToString
private class OidcUserServiceImpl extends OidcUserService {
{ setOauth2UserService(oAuth2UserService()); }
@Override
public OidcUser loadUser(OidcUserRequest request) throws OAuth2AuthenticationException {
String attribute =
request.getClientRegistration().getProviderDetails()
.getUserInfoEndpoint().getUserNameAttributeName();
OidcUser user = super.loadUser(request);
try {
Optional<Authority> authority = authorityRepository.findById(user.getName());
user =
new DefaultOidcUser(authority.map(t -> t.getGrants().asGrantedAuthorityList())
.orElse(DEFAULT_AUTHORITIES),
user.getIdToken(), user.getUserInfo(), attribute);
} catch (OAuth2AuthenticationException exception) {
throw exception;
} catch (Exception exception) {
log.warn("{}", request, exception);
}
return user;
}
}
}
Both the OAuth2UserService
and OidcUserService
map granted authorities for this application.
A @ControllerAdvice
is implemented to add two attributes to the Model
:
@ControllerAdvice
@NoArgsConstructor @ToString @Log4j2
public class ControllerAdviceImpl {
@Autowired private ApplicationContext context = null;
private List<ClientRegistration> oauth2 = null;
...
@ModelAttribute("oauth2")
public List<ClientRegistration> oauth2() {
if (oauth2 == null) {
oauth2 = new ArrayList<>();
try {
ClientRegistrationRepository repository =
context.getBean(ClientRegistrationRepository.class);
if (repository != null) {
ResolvableType type =
ResolvableType.forInstance(repository)
.as(Iterable.class);
if (type != ResolvableType.NONE
&& ClientRegistration.class.isAssignableFrom(type.resolveGenerics()[0])) {
((Iterable<?>) repository)
.forEach(t -> oauth2.add((ClientRegistration) t));
}
}
} catch (Exception exception) {
}
}
return oauth2;
}
@ModelAttribute("isPasswordAuthenticated")
public boolean isPasswordAuthenticated(Principal principal) {
return principal instanceof UsernamePasswordAuthenticationToken;
}
}
<div>
<div>
<div>
<form th:object="${form}">
<input type="email" th:name="username" th:placeholder="'E\'mail Address'"/>
<label th:text="'E\'mail Address'"/>
<input type="password" th:name="password" th:placeholder="'Password'"/>
<label th:text="'Password'"/>
<button type="submit" th:text="'Login'"/>
<th:block th:if="${! oauth2.isEmpty()}">
<hr/>
<a th:each="client : ${oauth2}" th:href="@{/oauth2/authorization/{id}(id=${client.registrationId})}" th:text="${client.clientName}"/>
</th:block>
</form>
</div>
</div>
<div>
<div>
<p th:if="${param.error}">Invalid username and password.</p>
<p th:if="${param.logout}">You have been logged out.</p>
</div>
</div>
</div>
...
<li th:ref="navbar-item" sec:authorize="isAuthenticated()">
<button sec:authentication="name"/>
<ul th:ref="navbar-dropdown">
<li th:if="${isPasswordAuthenticated}">
<a th:text="'Change Password'" th:href="@{/password}"/>
</li>
<li><a th:text="'Logout'" th:href="@{/logout}"/></li>
</ul>
</li>
...
The end result for the login page is shown below:
With a successful (Google) login:
[1] Implementing a PasswordEncoder
is discussed in detail in Spring PasswordEncoder Implementation. ↩
[2] This article has been updated for Spring Boot 2.5.x. spring.jpa.defer-datasource-initialization
has been added and spring.datasource.initialization-mode
and spring.datasource.data
were used instead of the spring.sql.init.*
properties. ↩
[3] It's important to note that the equivalent value for Principal
is available in the security dialect as ${#authentication}
which references an implementation of Authentication
. The use of ${#authentication}
will be explored further in the OAuth discussion in the next chapter. ↩
[4] Recall the discussion of setting HttpSecurity
basic authentication entry point. ↩
[5] Recall the discussion RestControllerImpl
's @ExceptionHandler
method. ↩
[6] YAML is not required but YAML lends itself to expressing the configuration compactly. ↩
Posted on November 1, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 1, 2020