写在前面

前面学习的都是基于内存的授权操作,但是在实际开发过程中都是将数据存储在数据库中,因此本篇就来学习如何基于数据库来实现授权操作。

UserDetailService接口

通过前面的学习,我们知道SpringSecurity存在多种认证方式,查看一下这个AuthenticationManagerBuilder类,可以发现它存在inMemoryAuthentication(内存)、jdbcAuthentication(数据库)和ldapAuthentication(LDAP)等三种认证方式:

1
2
3
4
5
6
7
8
9
10
11
public InMemoryUserDetailsManagerConfigurer<AuthenticationManagerBuilder> inMemoryAuthentication() throws Exception {
return (InMemoryUserDetailsManagerConfigurer)this.apply(new InMemoryUserDetailsManagerConfigurer());
}

public JdbcUserDetailsManagerConfigurer<AuthenticationManagerBuilder> jdbcAuthentication() throws Exception {
return (JdbcUserDetailsManagerConfigurer)this.apply(new JdbcUserDetailsManagerConfigurer());
}

public LdapAuthenticationProviderConfigurer<AuthenticationManagerBuilder> ldapAuthentication() throws Exception {
return (LdapAuthenticationProviderConfigurer)this.apply(new LdapAuthenticationProviderConfigurer());
}

而且这些不同来源的数据认证都被被封装为一个UserDetailsService接口,任何实现了该接口的对象都可以作为认证的数据源,如下所示:

1
2
3
public interface UserDetailsService {
UserDetails loadUserByUsername(String var1) throws UsernameNotFoundException;
}

这个接口中只有一个loadUserByUsername方法,它通过用户名来加载用户信息。前面说过SpringSecurity中与Web安全相关的配置都在WebSecurityConfigurerAdapter类中,而这类中有一个userDetailsService方法:

1
2
3
4
protected UserDetailsService userDetailsService() {
AuthenticationManagerBuilder globalAuthBuilder = (AuthenticationManagerBuilder)this.context.getBean(AuthenticationManagerBuilder.class);
return new WebSecurityConfigurerAdapter.UserDetailsServiceDelegator(Arrays.asList(this.localConfigureAuthenticationBldr, globalAuthBuilder));
}

因此开发者可以通过重写这个userDetailsService方法,进而提供一个UserDetailsService实例来配置用户信息,也可以自定义一个类并实现UserDetailService接口,还可以使用系统默认提供的UserDetailService实例,上一篇中我们就使用了基于内存的默认类InMemoryUserDetailsManager

查看一下这个UserDetailService接口的实现类,这个图怎么来的可以点击 这里

从实现类图中可以看出,处理之前使用的InMemoryUserDetailsManager,还有一个JdbcUserDetailsManager,而这个JdbcUserDetailsManager就是本篇要学习的基于数据库授权的核心。

JdbcUserDetailsManager类

查看一下这个JdbcUserDetailsManager类的源码:

1
public class JdbcUserDetailsManager extends JdbcDaoImpl implements UserDetailsManager, GroupManager {}

可以发现它继承了JdbcDaoImpl类,并实现了UserDetailsManager接口,这个UserDetailsManager接口非常熟悉,查看一下它的源码:

1
2
3
4
5
6
7
8
9
10
11
public interface UserDetailsManager extends UserDetailsService {
void createUser(UserDetails var1);

void updateUser(UserDetails var1);

void deleteUser(String var1);

void changePassword(String var1, String var2);

boolean userExists(String var1);
}

它继承了UserDetailsService接口,看到这里比较明白了,之前的InMemoryUserDetailsManager也是实现了UserDetailsManager接口。因此无论是InMemoryUserDetailsManager还是JdbcUserDetailsManager类,它们都有一些公共的方法,当然这些都是UserDetailsManager接口所提供的。

回到JdbcUserDetailsManager类中,可以看到里面定义了大量的SQL语句:

这些语句有什么用呢?作用非常大,因为JdbcUserDetailsManager自己提供了一个数据库,这个数据库脚本信息存在于org/springframework/security/core/userdetails/jdbc/users.ddl路径下:

脚本中的内容如下所示:

1
2
3
create table users(username varchar_ignorecase(50) not null primary key,password varchar_ignorecase(500) not null,enabled boolean not null);
create table authorities (username varchar_ignorecase(50) not null,authority varchar_ignorecase(50) not null,constraint fk_authorities_users foreign key(username) references users(username));
create unique index ix_auth_username on authorities (username,authority);

可以看到这里面有两个表和一个索引,且其中定义了varchar_ignorecase类型,这种类型是HSQLDB数据库才支持的,但是MySQL并不支持,如果需要创建对应的表,就需要将varchar_ignorecase类型修改为varchar类型。新建envysecurity数据库,之后执行如下语句:

1
2
3
4
5
6
7
8
9
10
11
12
use envysecurity;

create table users(
username varchar(50) not null primary key,
password varchar(500) not null,
enabled boolean not null);

create table authorities (
username varchar(50) not null,
authority varchar(50) not null,
constraint fk_authorities_users foreign key(username) references users(username));
create unique index ix_auth_username on authorities (username,authority);

可以看到users表中保存了用户的基本信息,包括用户名、密码以及账户是否可用。authorities表中保存了用户的角色信息,包括用户名和角色名称。users表和authorities表通过用户名username进行关联。

基于数据库授权

接下来我们将前面基于内存(InMemoryUserDetailsManager)授权的相关代码注释掉,之后重新定义一个userDetailsService方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Autowired
private DataSource dataSource;

@Override
@Bean
protected UserDetailsService userDetailsService(){
JdbcUserDetailsManager manager = new JdbcUserDetailsManager(dataSource);
if(!manager.userExists("envy")){
manager.createUser(User.withUsername("envy").password("1234").roles("admin").build());
}
if(!manager.userExists("test")){
manager.createUser(User.withUsername("test").password("1234").roles("user").build());
}
return manager;
}

解释一下上述代码的含义:首先创建一个JdbcUserDetailsManager实例对象,注意里面传入DataSource参数。之后调用manager.userExists()方法来判断用户是否存在,如果不存在则创建一个新用户,原因在于这段代码会在项目每次启动的时候都会执行,因此这里添加一个判断可以避免重复创建用户。最后调用manager.createUser()方法,参数是UserDetails对象,请注意这个manager.createUser()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void createUser(final UserDetails user) {
this.validateUserDetails(user);
this.getJdbcTemplate().update(this.createUserSql, new PreparedStatementSetter() {
public void setValues(PreparedStatement ps) throws SQLException {
ps.setString(1, user.getUsername());
ps.setString(2, user.getPassword());
ps.setBoolean(3, user.isEnabled());
}
});
if (this.getEnableAuthorities()) {
this.insertUserAuthorities(user);
}

}

可以看到这个createUser底层使用的是JdbcTemplate,之后调用了update方法,里面传入预定义的SQL语句和对应的参数。createUserSql语句如下所示:

1
private String createUserSql = "insert into users (username, password, enabled) values (?,?,?)";

之后传入一个预编译对象,里面的值从UserDetails对象中获取:

1
2
3
4
5
6
new PreparedStatementSetter() {
public void setValues(PreparedStatement ps) throws SQLException {
ps.setString(1, user.getUsername());
ps.setString(2, user.getPassword());
ps.setBoolean(3, user.isEnabled());
}

再来查看一下这个manager.userExists()方法,如下所示:

1
2
3
4
5
6
7
8
9
10
private String userExistsSql = "select username from users where username = ?";

public boolean userExists(String username) {
List<String> users = this.getJdbcTemplate().queryForList(this.userExistsSql, new String[]{username}, String.class);
if (users.size() > 1) {
throw new IncorrectResultSizeDataAccessException("More than one user found with name '" + username + "'", 1);
} else {
return users.size() == 1;
}
}

这段代码的含义是通过用户名去数据库中查询是否已经存在相同的用户名,如果存在则抛出异常。

数据库依赖

既然使用到了数据库,那么就需要在pom.xml依赖文件中新增数据库依赖信息:

1
2
3
4
5
6
7
8
9
10
<!--添加MySQL依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>

接着在application.yml配置文件中添加数据库配置信息:

1
2
3
4
5
6
spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
username: root
password: envy123
url: jdbc:mysql:///envysecurity?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai

上述配置信息完成后,接下来就可以启动项目,之后查看envysecurity数据库,可以看到之前配置的两个用户都自动添加进来了,且都设置了角色,如下所示:

users表中的数据如下所示:

authorities表中的数据如下所示:

当然了,用户也可以不通过重写userDetailsService方法来提供用户信息,可以直接在数据库中新增用户信息,如笔者在数据库中新增了一个和test用户除了用户名不同,其余均相同的book用户。

项目测试

接下来就可以启动项目进行测试了,首先以envy用户身份登录,之后依次访问/hello/admin/hello/user/hello这三个接口,可以发现这三个接口都是可以正常访问的。

之后以book或者test用户身份登录,可以发现它们无法访问/admin/hello接口,其他两个都是可以的。

需要说明的是,如果开发者将数据库中用户enabled字段由1(正常)修改为0(禁用),那么该用户就无法登陆系统,即登录失败:

enabled默认值为true,但是在数据库中则是以数字1进行存储,这是比较通用的做法。

这样本篇关于Spring Security中基于数据库授权操作的学习就到此为止,后续学习其他内容。