(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
A PasswordEncoder
must be configured. This is straightforward:1
PasswordEncoderConfiguration
@Configuration
@NoArgsConstructor @ToString @Log4j2
public class PasswordEncoderConfiguration {
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
Enter fullscreen mode Exit fullscreen mode
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.
Credential
@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;
}
Enter fullscreen mode Exit fullscreen mode CredentialRepository
@Repository
@Transactional(readOnly = true)
public interface CredentialRepository extends JpaRepository<Credential,String> {
}
Enter fullscreen mode Exit fullscreen mode
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.
Authorities
public enum Authorities { USER, ADMINISTRATOR };
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode
The CredentialRepository
and AuthorityRepository
are injected into a UserDetailsService
implementation to provide UserDetails
.
UserServicesConfiguration – UserDetailsService
@bean
@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;
}
}
...
}
Enter fullscreen mode Exit fullscreen mode
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.
WebSecurityConfigurerImpl
@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);
}
...
}
Enter fullscreen mode Exit fullscreen mode
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
WebSecurityConfigurerImpl.API
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)));
}
}
...
}
Enter fullscreen mode Exit fullscreen mode
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)
WebSecurityConfigurerImpl.UI
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());
...
}
}
...
}
Enter fullscreen mode Exit fullscreen mode
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)
ControllerImpl
@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;
}
...
}
Enter fullscreen mode Exit fullscreen mode
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.
application.html
<!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>
Enter fullscreen mode Exit fullscreen mode
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.
RestControllerImpl
@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);
}
}
Enter fullscreen mode Exit fullscreen mode
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
application.properties
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
...
Enter fullscreen mode Exit fullscreen mode
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>
Enter fullscreen mode Exit fullscreen mode application-hsqldb.properties
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:
Enter fullscreen mode Exit fullscreen mode
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>
Enter fullscreen mode Exit fullscreen mode application-mysql.properties
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
Enter fullscreen mode Exit fullscreen mode
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.
data.sql
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');
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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>
Enter fullscreen mode Exit fullscreen mode
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>
Enter fullscreen mode Exit fullscreen mode
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
.
ControllerImpl – Login and Change Password Methods
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;
}
...
}
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode
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).
ControllerImpl – /who-am-i
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;
}
...
}
Enter fullscreen mode Exit fullscreen mode
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>
Enter fullscreen mode Exit fullscreen mode
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.
ControllerImpl – /who
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;
}
...
}
Enter fullscreen mode Exit fullscreen mode
The method implementation is straightforward with the injected SessionRegistry
. The SessionRegistry
implementation bean must be configured as part of the WebSecurityConfigurer
.
WebSecurityConfigurerImpl.UI – SessionRegistry
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();
}
...
}
...
}
Enter fullscreen mode Exit fullscreen mode
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.
RestControllerImpl – /who-am-i
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);
}
...
}
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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"
}
Enter fullscreen mode Exit fullscreen mode
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.
RestControllerImpl – /who
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);
}
...
}
Enter fullscreen mode Exit fullscreen mode
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
...
Enter fullscreen mode Exit fullscreen mode
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
} ]
Enter fullscreen mode Exit fullscreen mode
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>
Enter fullscreen mode Exit fullscreen mode
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):
WebSecurityConfigurerImpl.UI (Default OAuth2 Customizer)
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());
...
}
...
}
...
}
Enter fullscreen mode Exit fullscreen mode
Finally, the OAuth client prperties must be configured in the profile-specific application properties YAML file:6
application-oauth.yml
spring:
security:
oauth2:
client:
registration:
github:
client-id: dad3306da38eb7be68a1
client-secret: 8a5394b2e29037b9bdf17e51af472020f85bfca6
Enter fullscreen mode Exit fullscreen mode
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());
...
}
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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.
WebSecurityConfigurerImpl.UI (Custom Login Page)
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) {
}
...
}
...
}
...
}
Enter fullscreen mode Exit fullscreen mode
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.
UserServicesConfiguration – OAuth2UserService and OidcUserService Beans
@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;
}
}
}
Enter fullscreen mode Exit fullscreen mode
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
ControllerAdviceImpl
@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;
}
}
Enter fullscreen mode Exit fullscreen mode
The LoginForm
is modified to include configured OAuth 2.0 authentication options (if configured):
LoginForm.html
<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>
Enter fullscreen mode Exit fullscreen mode
And the Change Password menu option is only offered if the client was authenticated with a password (by testing the isPasswordAuthenticated
Model
attribute:
application.html – Change Password
...
<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>
...
Enter fullscreen mode Exit fullscreen mode
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. ↩
原文链接:Spring Boot Part 7: Spring Security, Basic Authentication and Form Login, and Oauth2
暂无评论内容