Merikanto

一簫一劍平生意,負盡狂名十五年

Spring Boot - 04 Spring Security

This is the fourth post in a series of posts to cover different aspects of Spring Boot. Please note that the entire post isn’t necessarily only written in English.

In this post, I am going to cover the basics of Spring Security.



Debug

Do not litter security-unrelated code with security code!


Break Circular Dependency:

1
Requested bean is currently in creation: Is there an unresolvable circular reference?

Use @Lazy before any of the mentioned @Autowired or @Bean.


About Roles:

Use hasRole("User") instead of hasRole("ROLE_USER"), because ROLE_ is automatically added.



Basic Configuration

Enable Spring Security (the ONLY package needed for all security-related configs):

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

Spring Security starter provides the following features:

  • All HTTP request paths need authentication
  • No specific roles or authorities are required (Only one user, with the username user)
  • Login page made by Spring Security



Add User Store

What we need to configure:

  • Prompt a customized login page
  • Signup page, for multiple users
  • Apply different security rules for different request paths. e.g. The homepage and signup page shouldn’t require authentication at all

Configure a user store that handles multiple users:

  • In-memory
  • JDBC-based
  • LDAP-backed
  • Custom user details service (complete customization)

Barebones config class with @Override:

1
2
3
4
5
6
7
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {}
}

In-memory

Convenient for testing purposes / simple apps, but doesn’t allow easy editing of users.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {

// Must have the PasswordEncoder in Spring Security 5, otherwise give errors
PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();

// 直接在 Config 里面 define username & password
auth.inMemoryAuthentication()
.withUser("Merikanto")

// Need to add {noop}, to enable NoOpPasswordEncoder
.password("{noop}123456")
.authorities("ROLE_USER")
.and()
.withUser("KK")
.password("{noop}123456")
.authorities("ROLE_USER");
}

LDAP-backed

LDAP: Lightweight Directory Access Protocol


The default strategy for authenticating against LDAP is to perform a bind operation, authenticating the user directly to the LDAP server.

Another option is to perform a comparison operation. This involves sending the entered password to the LDAP directory and asking the server to compare the password against a user’s password attribute. Because the comparison is done within the LDAP server, the actual password remains secret.


Basic Config:

  • By default, users & groups base queries are empty: the search will be done from the root of LDAP hierarchy.
  • With specifying the Base, rather than search from root, search will be done where the organizational unit is people.
  • Add password comparison, for the comparison operation. Plus specify a different password attribute name.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {

auth.ldapAuthentication()

// Filters below: provide base LDAP queries (search for users & groups)
.userSearchFilter("(uid={0})")
.groupSearchFilter("member={0}")

// Specify base queries for finding users & groups
.userSearchBase("ou=people")
.groupSearchBase("ou=groups")

// Name for the password attribute is now: Passphrase
.passwordCompare()

// Need another encoder, to prevent MITM interception
.passwordEncoder(new BCryptPasswordEncoder())
.passwordAttribute("Passphrase");
}

Contact LDAP server:

The default LDAP server is on localhost:33389.


When the LDAP server starts, it will attempt to load data from any LDIF files that it can find in the classpath. LDIF (LDAP Data Interchange Format) is a standard way of representing LDAP data in a plain text file. Each record is composed of one or more lines, each containing a name:value pair. Records are separated from each other by blank lines.


1
2
3
4
5
6
7
// Add to the last line

.contextSource
.root("dc=tacocloud, dc=com")

// Ask the server to load content from the users.ldif file at classpath root
.ldif("classpath:users.ldif");

A sample LDIF file:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
dn: ou=groups,dc=tacocloud,dc=com
objectclass: top
objectclass: organizationalUnit
ou: groups
dn: ou=people,dc=tacocloud,dc=com
objectclass: top
objectclass: organizationalUnit
ou: people
dn: uid=buzz,ou=people,dc=tacocloud,dc=com
objectclass: top
objectclass: person
objectclass: organizationalPerson
objectclass: inetOrgPerson
cn: Buzz Lightyear
sn: Lightyear
uid: buzz
userPassword: password
dn: cn=tacocloud,ou=groups,dc=tacocloud,dc=com
objectclass: top
objectclass: groupOfNames
cn: tacocloud
member: uid=buzz,ou=people,dc=tacocloud,dc=com

JDBC-based

Maintain user info in a relational DB with JDBC.


Spring Security’s default user queries:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- WITH auth.jdbcAuthentication().dataSource(dataSource):

-- Authentication: Get username & password
public static final String DEF_USERS_BY_USERNAME_QUERY =
"select username,password,enabled from users where username = ?";

-- Authorization: look up user's granted authorities
public static final String DEF_AUTHORITIES_BY_USERNAME_QUERY =
"select username,authority from authorities where username = ?";

-- Authorization: look up granted user group
public static final String DEF_GROUP_AUTHORITIES_BY_USERNAME_QUERY =
"select g.id, g.group_name, ga.authority " +
"from groups g, group_members gm, group_authorities ga " +
"where gm.username = ? and g.id = ga.group_id and g.id = gm.group_id";

Customized config:

Note: All the queries take username as the only parameter.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Set datasource, for DB access
@Autowired
DataSource dataSource;

// Only override the authentication & basic auth queries
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {

auth.jdbcAuthentication().dataSource(dataSource)
.usersByUsernameQuery(
"select username, password, enabled from Users " +
"where username=?")
.authoritiesByUsernameQuery(
"select username, authority from UserAuthorities " +
"where username=?");
}

Encoding passwords:

1
2
// Added to the last line
.passwordEncoder(new BCryptPasswordEncoder("merikanto"));

Other Cryptos:

Note: StandardPasswordEncoder using SHA256 is deprecated after Spring Security 5.1.5.

  • NoOpPasswordEncoder —Applies no encoding
  • BCryptPasswordEncoder —Applies bcrypt strong hashing encryption
  • Pbkdf2PasswordEncoder —Applies PBKDF2 encryption
  • SCryptPasswordEncoder —Applies scrypt hashing encryption

The Password encoder interface:

1
2
3
4
public interface PasswordEncoder {
String encode(CharSequence rawPassword);
boolean matches(CharSequence rawPassword, String encodedPassword);
}

Clarification:

The password in the DB is never decoded.

The plaintext password entered by the user is encoded using the same algorithm, and then compared with the encoded password in the DB (using the matches() method)


Custom User Detail

User Entity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@Data
@Entity
@NoArgsConstructor(access= AccessLevel.PRIVATE, force=true)
@RequiredArgsConstructor

// 注意是 implements UserDetails interface from Spring Security
public class User implements UserDetails {

private static final long serialVersionUID = 1L;

@Id
@GeneratedValue(strategy= GenerationType.AUTO)
private Long id;

private final String username;
private final String password;
private final String fullname;
private final String street;
private final String city;
private final String state;
private final String zip;
private final String phoneNumber;

// 下面的方法可以自动生成,点击 implements

@Override
// Return a collection of authorities granted to the user;
public Collection<? extends GrantedAuthority> getAuthorities() {

// In this case, returns a collection indicating that,
// All users are granted with the ROLE_USER authority
return Arrays.asList(new SimpleGrantedAuthority("ROLE_USER")); }

@Override
public boolean isAccountNonExpired() { return true; }

@Override
public boolean isAccountNonLocked() { return true; }

@Override
public boolean isCredentialsNonExpired() { return true; }

@Override
public boolean isEnabled() { return true; }
}

User Repository

1
2
3
4
5
public interface UserRepository extends CrudRepository<User, Long> {

// Look up a user by username
User findByUsername(String username);
}

User Details Service

1
2
3
4
5
public interface UserDetailsService {

// Either return a UserDetails object, or throw an exception
UserDetails loadByUsername(String username) throws UsernameNotFoundException;
}

User Details Service Implementation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

private final UserRepository userRepo;

// ServiceImpl is injected with an instance of UserRepo via the constructor
@Autowired
public UserDetailsServiceImpl(UserRepository userRepo) {
this.userRepo = userRepo;
}


@Override
// loadByUsername: Never return null!
public UserDetails loadByUsername(String username) throws UsernameNotFoundException {
User user = userRepo.findByUsername(username);
if (user != null)
return user;
throw new UsernameNotFoundException("User [ " + username + " ] is not found");
}
}

Modify SecurityConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import org.springframework.security.core.userdetails.UserDetailsService;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

// 注意 autowired 的是 import 的东西,security core 里面的,而不是自己写的 service!
@Autowired
private UserDetailsService userDetailsService;

// Still need a password encoder
@Bean
public PasswordEncoder encoder() {
return new Pbkdf2PasswordEncoder("Merikanto");
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {

auth.userDetailsService(userDetailsService)
.passwordEncoder(encoder());
}
}

Line 21:.passwordEncoder(encoder());

It appears that we call the encoder() method and pass its returned value to passwordEncoder() .

However, since the encoder() method is annotated with @Bean , it will be used to declare a PasswordEncoder bean in the Spring application context. Any calls to encoder() will then be intercepted to return the bean instance from the application context.


User Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Controller
@RequestMapping("/register")
public class RegistrationController {

private final UserRepository userRepo;
private final PasswordEncoder passwordEncoder;

public RegistrationController(UserRepository userRepo, PasswordEncoder passwordEncoder) {
this.userRepo = userRepo;
this.passwordEncoder = passwordEncoder;
}

@GetMapping
public String registerForm() {
return "registration";
}

@PostMapping
public String processRegistration(RegistrationForm form) {

// Pass to toUser (code is below)
userRepo.save(form.toUser(passwordEncoder));
return "redirect:/login";
}
}

A Separate Registration Form

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Data
public class RegistrationForm {

private String username;
private String password;
private String fullname;
private String street;
private String city;
private String state;
private String zip;
private String phone;

// Note here, toUser uses the above properties to create a new User object
public User toUser(PasswordEncoder passwordEncoder) {
return new User(

// First encode the password, then controller saves to the DB
username, passwordEncoder.encode(password),
fullname, street, city, state, zip, phone);
}


Securing Web Requests


Secure Requests

Configs with HttpSecurity:

  • Require that certain security conditions must be met before serving a request
  • Configure a custom login page, and enable logout
  • Configure cross-site request forgery protection (CSRF)

Specify 2 security rules:

  • Requests for /design and /orders should be for users with a granted authority of ROLE_USER
  • All requests should be permitted to all users
1
2
3
4
5
6
7
8
9
// HttpSecurity object

@Override
protected void configure(HttpSecurity http) throws Exception {

http.authorizeRequests()
.antMatchers("/deign", "/orders").hasRole("USER")
.antMatchers("/", "/**").permitAll();
}

Important:

The order of the rules are important. Security rules declared first take precedence over those declared lower down.

If we reverse the order, all requests would have permitAll() applied to them, and the rule for /design and /orders requests would have no effect.


Spring Security’s config methods for securing paths:

SpEL: Spring Expression Language


Method What It Does
access(String) Allows access if the given SpEL expression evaluates to true
rememberMe() Allows access for users who are authenticated via remember-me
permitAll() Allows access unconditionally
denyAll() Denies access unconditionally
.
authenticated() Allows access to authenticated users
fullyAuthenticated() Allows access if the user is fully authenticated (not remembered)
hasAnyAuthority(String...) Allows access if the user has any of the given authorities
hasAnyRole(String...) Allows access if the user has any of the given roles
hasAuthority(String) Allows access if the user has the given authority
hasIpAddress(String) Allows access if the request comes from the given IP address
hasRole(String) Allows access if the user has the given role
.
not() Negates the effect of any of the other access methods
anonymous() Allows access to anonymous users


Spring Security Extension to SpEL

Security Expression What It Evaluates
authentication The user’s authentication object
isRememberMe() true if the user was authenticated via remember-me
denyAll Always evaluates to false
permitAll Always evaluates to true
.
isAuthenticated() true if the user is authenticated
isFullyAuthenticated() true if the user is fully authenticated (not with remember-me)
hasAnyRole(list of roles) true if the user has any of the given roles
hasRole(role) true if the user has the given role
hasIpAddress(IP address) true if the request comes from the given IP address
.
isAnonymous() true if the user is anonymous
principal The user’s principal object

Example:

Only allow authenticated user to place order on Tuesday:

1
2
3
4
5
6
7
8
9
10
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/design", "/orders")
.access("hasRole('USER') && T(java.util.Calendar).getInstance().get("+
"T(java.util.Calendar).DAY_OF_WEEK) == " +
"T(java.util.Calendar).TUESDAY")

.antMatchers(“/”, "/**").access("permitAll");
}

Custom Login & Logout

Add a ViewController Path in WebConfig:

1
2
3
4
5
6
7
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/").setViewName("home");

// 加上这一行
registry.addViewController("/login");
}

Add to SecurityConfig:

By default, Spring Security listens for login requests at /login, and expects that the username and password fields be named username and password .

But we can change it through the config below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
http.authorizeRequests()
.antMatchers("/deign", "/orders").access("hasRole('USER')")
.antMatchers("/", "/**").permitAll()

// Login Form 指向 /login
.and().formLogin().loginPage("/login")

// 登陆成功后,将被强行带到 /design 页面(force = true),无论之前在哪个页面
.defaultSuccessUrl("/design", true)

// Customize login, 现在登陆入口变成了 /hello, 用户名和密码的field name也更改了
.loginProcessingUrl("/hello")
.usernameParameter("uname")
.passwordParameter("pword");

Logging Out

1
2
// 末尾加上这行
.and().logout().logoutSuccessUrl("/");

CSRF

Spring Security has built-in CSRF protection, and it’s enabled by default.

Cross-site request forgery (CSRF) is a common security attack. It involves subjecting a user to visit a maliciously designed web page that automatically & secretly submits a form to another application on behalf of a user. For instance, a user may be presented with a form on an attacker’s website that automatically posts to a URL on the user’s banking website to transfer money.

To protect against such attacks, applications can generate a CSRF token upon displaying a form, place that token in a hidden field, and then save it for later use on the server. When the form is submitted, the token is sent back to the server along with the rest of the form data. The request is then intercepted by the server and compared with the token that was originally generated. If the token matches, the request is allowed to proceed.



Know Your User

Link User with Order:

Information about the authenticated user can be obtained via the SecurityContext object, or injected into controllers using @AuthenticationPrincipal .


Add this to the Order entity:

1
2
@ManyToOne
private User user;

Determine which user is associated with which order(s):

  • Inject a Principal object into the controller method
  • Inject an Authentication object into the controller method
  • Use SecurityContextHolder to get at the security context
  • Use an @AuthenticationPrincipal annotated method

1
2
3
4
5
6
7
8
9
10
@PostMapping
public String processOrder(@Valid Order order, Errors errors, SessionStatus sessionStatus,
Principal principal) {

// 加上这些
User user = userRepository.findByUsername(
principal.getName());
order.setUser(user);

}

上面的方法是没问题的,但这个是 Bad Practice!

Do not litter security-unrelated code with security code!


方法一(Cleanest Solution):

1
2
3
4
5
6
7
8
9
10
// @AuthPrincipal limits the security-specific code to the annotation itself

@PostMapping
public String processOrder(@Valid Order order, Errors errors, SessionStatus sessionStatus,
@AuthenticationPrincipal User user) {

// 换成这一行
order.setUser(user);

}

方法二

1
2
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
User user = (User) authentication.getPrincipal();

此方法的好处是:

It can be used anywhere in the application, not only in a controller’s handler methods. This makes it suitable for use in lower levels of the code.