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.
Note that this post’s details and the example source code has been updated for Spring Boot version 2.5.3 so some output may show older Spring Boot versions.
Application
The following subsections outline creating and running the baseline application.
Prerequisites
A PasswordEncoder
must be configured. This is
straightforward:1
@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.
Users’ credentials and granted authorities are stored in a database
(configured at runtime) and accessed through JPA. The Credential
@Entity
and JpaRepository
are shown below.
@Entity
@Table(catalog = "application", name = "credentials")
@Data @NoArgsConstructor
public class Credential {
@Id @Column(length = 64, nullable = false, unique = true)
@NotBlank @Email
private String email = null;
@Lob @Column(nullable = false)
@NotBlank
private String password = null;
}
@Repository
@Transactional(readOnly = true)
public interface CredentialRepository extends JpaRepository<Credential,String> {
}
The implementations of Authority
and AuthorityRepository
are nearly
identical with the password
property/column replaced with grants
, a
AuthoritiesSet
(Set<Authorities>
) with a
@Converter
-annotated
AttributeConverter
to convert to and from a
comma-separated string of Authorities
(Enum
) names for storing
in the database.
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)
The CredentialRepository
and AuthorityRepository
are injected into a
UserDetailsService
implementation to provide
UserDetails
.
@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;
}
}
...
}
Separate WebSecurityConfigurer
instances will be
configured for the RestControllerImpl
(/api/**
) and ControllerImpl
(/**
) but each will share the same super-class where the
PasswordEncoder
and
UserDetailsService
configured above will be injected
and configured.
@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:
-
The template resolver is configured to use “decoupled template logic”
-
All methods (including a custom
/error
mapping) return the same view (Thymeleaf template)
@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;
}
...
}
The common Thymeleaf template is outlined below. <li/>
elements provide
drop-down menus which are activated by security dialect sec:authorize
attributes. A th:switch
attribute provides a <section/>
“case” element
for each supported path. A form is displayed if the “form” attribute is set
in the Model
. And, if the user is authenticated, their granted
authorities are displayed in the right of the footer with the
sec:authentication
attribute.
<!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.)
The outline of the REST controller is shown below. The common exception
handler returns an HTTP 403 code for security-related exceptions. This
combined with the Basic Authentication entry point setting in the
WebSecurityConfigurer
prevents the REST controller from redirecting in the
event of an Exception.
@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
The POM (pom.xml
) has a similar spring-boot:run
profile to that
described in part 1 of this
series. The relevant parts of the
application.properties
file are shown below.2
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.mode: ALWAYS
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.
<profile>
<id>hsqldb</id>
<dependencies>
<dependency>
<groupId>org.hsqldb</groupId>
<artifactId>hsqldb</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<profiles combine.children="append">
<profile>hsqldb</profile>
</profiles>
</configuration>
</plugin>
</plugins>
</build>
</profile>
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.
<profile>
<id>mysql</id>
<repositories>...</repositories>
<dependencies>
<dependency>
<groupId>ball</groupId>
<artifactId>ball-spring-mysqld-starter</artifactId>
<version>2.1.2.20210415</version>
</dependency>
</dependencies>
<build>
...
</build>
</profile>
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.
data.sql
defines two users: user@example.com
who is granted “USER” authority and
admin@example.com
who is granted “USER” and “ADMINISTRATOR” authorities.
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>
The controller methods to present the Login form, present the Change
Password form, and handle the change password POST method are shown below.
The default Spring Security login POST method is used and does not have to
be implemented here. The @PreAuthorize
annotations on the
change password methods enforce that the client must be authenticated to use
those functions. No logout method needs to be implemented because a logout
handler was configured in the
WebSecurityConfigurer
.
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:
The /who-am-i
method adds the client’s Principal
(injected
as a parameter by Spring) to the Model
so it may be presented in the
Thymeleaf template (as long as the client has the “USER” authority).
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.
The /who
method will list the Principal
s of currently registered
sessions and is only available to clients granted “ADMINISTRATOR” authority.
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;
}
...
}
The method implementation is straightforward with the injected
SessionRegistry
. The SessionRegistry
implementation bean must be configured as part of the
WebSecurityConfigurer
.
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
Similar to the corresponding @Controller
method described in
the previous section, the /api/who-am-i
method returns the client’s
Principal
(injected as a parameter by Spring) if the client
has the “USER” authority.
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"
}
The /api/who
method returns the list of all Principal
s
logged in (defined as having active sessions in the UI) if the client has
“ADMINISTRATOR” authority.
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
.
The WebSecurityConfigurer
is changed to use OAuth
Login instead of Form Login (with default configuration):
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
name -
Integrate 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
).
The next step is to integrate the OAuth Login page with the custom Form
Login page. Simply calling
HttpSecurity.oauth2Login(Customizer.withDefaults())
will attempt to configure a
ClientRegistrationRepository
bean but that will fail if no spring.security.oauth2.client.registration.*
properties are configured. The implementation tests if the bean is
configured before attempting the HttpSecurity
method call.
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) {
}
...
}
...
}
...
}
Both the OAuth2UserService
and
OidcUserService
beans are configured by configuring the
Open ID Connect (OIDC) OidcUserService
– OIDC is built on top of OAuth
2.0 to provide identity services. The OidcUserService
delegates to the
OAuth2UserService
for retrieving Oauth 2.0-specific information.
@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
:
-
oauth2
, aList
of configuredClientRegistration
s -
isPasswordAuthenticated
, indicating if thePrincipal
was authenticated with a password
@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;
}
}
The LoginForm
is modified to include configured OAuth 2.0 authentication
options (if configured):
<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>
And the Change Password menu option is only offered if the client was
authenticated with a password (by testing the isPasswordAuthenticated
Model
attribute:
...
<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. ↩