Spring Security1 - 用户信息UserDetails相关入门

1.前言

本篇将通过Spring Boot 2.x来讲解Spring Security中的用户主题UserDetails

2.Spring Boot集成Spring Security

集成Spring Security只需要引入其对应的Starter组件。Spring Security不仅仅能保Servlet Web应用,也可以保护Reactive Web应用,本文我们讲前者。我们只需要在Spring Security项目引入以下依赖即可:

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
<dependencies>
<!-- actuator 指标监控 非必须 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- spring security starter 必须 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- spring mvc servlet web 必须 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- lombok 插件 非必须 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

3.UserDetailsServiceAutoConfiguration

启动项目,访问Actuator端点http://localhost:8080/actuator会跳转到一个登录页面,如下:

登录页面

要求你输入用户名Username(默认值为user)和密码Password。密码在springboot控制台会打印出类似Using generated security password: e1f163be-ad18-4be1-977c-88a6bcee0d37的字样,后面的长串就是密码,当然这不是生产可用的。如果你足够细心会从控制台打印日志发现该随机密码是由UserDetailsServiceAutoConfiguration配置类生成的,我们就从它开始顺藤摸瓜来一探究竟。

3.1 UserDetailsService

UserDetailsService接口只提供了一个方法

1
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;

该方法很容易理解:通过用户名来加载用户。这个方法主要用于从系统数据中查询并加载具体的用户到Spring Security中。

3.2 UserDetails

从上面UserDetailsService可以知道最终交给Spring Security的是UserDetails。该接口是提供用户信息的核心接口。该接口实现仅仅存储用户信息。后续会将该接口提供的用户信息封装到认证对象Authentication中取。UserDetails默认提供了:

. 用户的权限集,默认需要添加ROLE_前缀
. 用户的加密后的密码,不加密会使用{noop}前缀
. 应用内唯一的用户名
. 账户是否过期
. 账户是否锁定
. 凭证是否过期
. 用户是否可用

如果以上的信息满足不了你使用,你可以自行实现扩展以存储更多的用户信息。比如用户的邮箱、手机号等等。通常我们使用其实现类:

1
org.springframework.security.core.userdetails.User

该类内置一个建造起UserBuilder会很方便地帮助我们构建UserDetails对象,后面我们会用到它

3.3 UserDetailsServiceAutoConfiguration

UserDetailsServiceAutoconfiguration全限定名为:

1
org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration

源码如下:

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
@Configuration
@ConditionalOnClass(AuthenticationManager.class)
@ConditionalOnBean(ObjectPostProcessor.class)
@ConditionalOnMissingBean({ AuthenticationManager.class, AuthenticationProvider.class, UserDetailsService.class })
public class UserDetailsServiceAutoConfiguration {

private static final String NOOP_PASSWORD_PREFIX = "{noop}";

private static final Pattern PASSWORD_ALGORITHM_PATTERN = Pattern.compile("^\\{.+}.*$");

private static final Log logger = LogFactory.getLog(UserDetailsServiceAutoConfiguration.class);

@Bean
@ConditionalOnMissingBean(
type = "org.springframework.security.oauth2.client.registration.ClientRegistrationRepository")
@Lazy
public InMemoryUserDetailsManager inMemoryUserDetailsManager(SecurityProperties properties,
ObjectProvider<PasswordEncoder> passwordEncoder) {
SecurityProperties.User user = properties.getUser();
List<String> roles = user.getRoles();
return new InMemoryUserDetailsManager(
User.withUsername(user.getName()).password(getOrDeducePassword(user, passwordEncoder.getIfAvailable()))
.roles(StringUtils.toStringArray(roles)).build());
}

private String getOrDeducePassword(SecurityProperties.User user, PasswordEncoder encoder) {
String password = user.getPassword();
if (user.isPasswordGenerated()) {
logger.info(String.format("%n%nUsing generated security password: %s%n", user.getPassword()));
}
if (encoder != null || PASSWORD_ALGORITHM_PATTERN.matcher(password).matches()) {
return password;
}
return NOOP_PASSWORD_PREFIX + password;
}

}

我们来简单解读一下该类,从@Conditional系列注解我们知道该类在类路径下存在AuthenticationManager、在Spring容器中存在BeanObjectPostProcessor并且不存在BeanAuthenticationManagerAuthenticationProviderUserDetailsService的情况下生效。千万不要纠结这些类干嘛用的该类只初始化了一个UserDetailsManager类型的Bean。UserDetailsManager类负责对安全用户实体抽象UserDetails的增删改查操作。同时还继承了UserDetailsService接口。

明白了上面这些让我们把目光再回到UserDetailsServiceAutoConfiguration上来。该类初始化了一个名为InMemoryUserDetailsManager的内存用户管理器。该管理器通过配置注入了一个默认的UserDetails存在内存中,就是我们上面用的那个user,每次启动user都是动态生成的。那么问题来了如果我们定义自己的UserDetailsManagerBean是不是就可以实现我们需要的用户管理逻辑呢?

3.4 自定义UserDetailsManager

我们来自定义一个UserDetailsManager来看看能不能达到自定义用户管理的效果。首先我们针对UserDetailsManager的所有方法进行一个代理实现,我们依然将用户存在内存中,区别就是这是我们自定义的:

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
package cn.felord.spring.security;

import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;

import java.util.HashMap;
import java.util.Map;

/**
* 代理 {@link org.springframework.security.provisioning.UserDetailsManager} 所有功能
*
* @author Felordcn
*/
public class UserDetailsRepository {

private Map<String, UserDetails> users = new HashMap<>();


public void createUser(UserDetails user) {
users.putIfAbsent(user.getUsername(), user);
}


public void updateUser(UserDetails user) {
users.put(user.getUsername(), user);
}


public void deleteUser(String username) {
users.remove(username);
}


public void changePassword(String oldPassword, String newPassword) {
Authentication currentUser = SecurityContextHolder.getContext()
.getAuthentication();

if (currentUser == null) {
// This would indicate bad coding somewhere
throw new AccessDeniedException(
"Can't change password as no Authentication object found in context "
+ "for current user.");
}

String username = currentUser.getName();

UserDetails user = users.get(username);


if (user == null) {
throw new IllegalStateException("Current user doesn't exist in database.");
}

// todo copy InMemoryUserDetailsManager 自行实现具体的更新密码逻辑
}


public boolean userExists(String username) {

return users.containsKey(username);
}


public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return users.get(username);
}
}

该类负责具体对UserDetails的增删改查操作。我们将其注入Spring容器:

1
2
3
4
5
6
7
8
9
@Bean
public UserDetailsRepository userDetailsRepository() {
UserDetailsRepository userDetailsRepository = new UserDetailsRepository();

// 为了让我们的登录能够运行 这里我们初始化一个用户Felordcn 密码采用明文 当你在密码12345上使用了前缀{noop} 意味着你的密码不使用加密,authorities 一定不能为空 这代表用户的角色权限集合
UserDetails felordcn = User.withUsername("Felordcn").password("{noop}12345").authorities(AuthorityUtils.NO_AUTHORITIES).build();
userDetailsRepository.createUser(felordcn);
return userDetailsRepository;
}

为了方便测试,我们也内置一个名称为Felordcn密码为12345UserDetails用户,密码采用明文。当你在密码12345上使用了前缀{noop}意味着你的密码不使用加密,这里我们并没有指定密码加密方式你可以使用PasswordEncoder来指定一种加密方式。通常推荐使用Bcrypt作为加密方式。默认Spring Security使用的也是此方式。authorities一定不能为null这代表用户的角色权限集合。接下来我们实现一个UserDetailsManager并注入Spring 容器:

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
@Bean
public UserDetailsManager userDetailsManager(UserDetailsRepository userDetailsRepository) {
return new UserDetailsManager() {
@Override
public void createUser(UserDetails user) {
userDetailsRepository.createUser(user);
}

@Override
public void updateUser(UserDetails user) {
userDetailsRepository.updateUser(user);
}

@Override
public void deleteUser(String username) {
userDetailsRepository.deleteUser(username);
}

@Override
public void changePassword(String oldPassword, String newPassword) {
userDetailsRepository.changePassword(oldPassword, newPassword);
}

@Override
public boolean userExists(String username) {
return userDetailsRepository.userExists(username);
}

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userDetailsRepository.loadUserByUsername(username);
}
};
}

这样实际执行委托给了UserDetailsRepository来做。我们重复章节3.的动作进入登录页面分别输入Felordcn12345成功进入。

3.5 数据库管理用户

经过以上的配置,相信聪明的你已经知道如何使用数据库来管理用户了。只需要将UserDetailsRepository中的users属性替代为抽象的Dao接口就行了,无论你使用JPA还是Mybatis来实现。

4. 总结

今天我们对Spring Security 中的用户信息 UserDetails 相关进行的一些解读。并自定义了用户信息处理服务。相信你已经对在Spring Security中如何加载用户信息,如何扩展用户信息有所掌握了源码地址,使用day01分支

转载自@felord.cn