写在前面

在前一篇我们学习了如何基于数据库来实现授权操作,使用了JdbcUserDetailsManager类,同时发现它底层使用的是JdbcTemplate,用户操作非常不方便,且使用了JdbcUserDetailsManager自带的数据库,这些表和字段是无法满足实际的开发需要,因此通常做法是开发者自定义授权数据库和表。

出于操作简单的考虑,这里使用Spring Data JPA来代替JdbcTemplate进而完成对数据库的操作。

创建工程

使用IDEA创建一个名为security-jpa的SpringBoot工程:

当然也可以在创建项目的时候不添加任何依赖,而是在后续pom.xml依赖文件中添加如下依赖:

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>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</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>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>

之后开发者需要在MySQL数据库中新建一个名为securityjpa的数据库,这样就完成了创建工程和数据库的工作。

创建实体类

本篇主要介绍基于角色的权限控制,不涉及到具体的权限,因此只需角色Role和用户User实体类,但是在数据库中却需要用户角色表t_role、用户信息表t_user以及用户角色信息表t_user_role,请注意这个用户角色信息表t_user_role是可以自动生成的,因此用户只需创建角色Role和用户User这两个实体类。

新建entity包,并在其中创建用户角色类如下所示:

1
2
3
4
5
6
7
8
9
10
11
@Data
@Entity(name = "t_role")
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private String name;

private String description;
}

Role实体类用户描述用户角色信息,它有角色id、名称和描述等属性,其中@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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
@Data
@Entity(name = "t_user")
public class User implements UserDetails {

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

// @Getter(value = AccessLevel.NONE)
private String username;

// @Getter(value = AccessLevel.NONE)
private String password;

// @Getter(value = AccessLevel.NONE)
private boolean accountNonExpired;

// @Getter(value = AccessLevel.NONE)
private boolean accountNonLocked;

// @Getter(value = AccessLevel.NONE)
private boolean credentialsNonExpired;

// @Getter(value = AccessLevel.NONE)
private boolean enabled;

@ManyToMany(fetch = FetchType.EAGER,cascade = CascadeType.PERSIST)
private List<Role> roles;

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
for(Role role:roles){
authorities.add(new SimpleGrantedAuthority(role.getName()));
}
return authorities;
}

@Override
public String getPassword() {
return password;
}

@Override
public String getUsername() {
return username;
}

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

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

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

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

请注意这里的用户信息类User需要实现UserDetails接口,并实现其中的所有方法。原因在于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方法,它通过用户名来加载用户信息,里面传入的是一个UserDetails对象,因此我们要想自定义使用SpringSecurity中的认证方式,那么必须自定义一个UserDetails对象,这也就是我们自定义的User实体类需要实现UserDetails接口的原因。

回到User类中,仔细看一下这个类所定义的属性,username、password、accountNonExpired、accountNonLocked、credentialsNonExpired和enabled分别表示用户名、密码、账号是否过期、账号是否锁住、密码是否过期和账号是否可用。可以发现这些属性都是从实现UserDetails接口中的方法名称中反推出来的,目的就是为了满足后续使用。

roles属性表示用户所具有的角色,请注意User和Role是多对多关系,因此需要使用@ManyToMany注解来描述。还有就是实现UserDetails接口时重写的getAuthorities方法,这个方法用于返回用户的角色信息,这里我们选用了GrantedAuthority的一个名为SimpleGrantedAuthority的实现类,然后将用户角色的名称作为角色信息进行返回。

创建Repository

新建一个repository包,并在其中创建一个UserRepository接口,它需要继承JpaRepository接口,里面的代码如下所示:

1
2
3
public interface UserRepository extends JpaRepository<User,Long> {
User findUserByUsername(String username);
}

上面是Spring Data JPA最基本的操作。

自定义UserDetailService

前面说了这些不同来源的数据认证都被被封装为一个UserDetailsService接口,任何实现了该接口的对象都可以作为认证的数据源,而这里我们就将自定义的MyUserDetailService作为数据源,不再使用之前的基于inMemoryAuthenticationjdbcAuthentication的认证。

新建一个service包,并在里面新建一个MyUserDetailService类,这个类需要实现UserDetailsService接口,并重写其中的loadUserByUsername方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Service
public class MyUserDetailService implements UserDetailsService {
@Autowired
private UserRepository userRepository;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findUserByUsername(username);
if(user==null){
throw new UsernameNotFoundException("用户不存在");
}
return user;
}
}

在这个loadUserByUsername方法中,参数是用户登录时输入的用户名,之后系统会根据用户名去数据库中查询用户信息,如果存在则自动进行密码匹配,不存在则直接抛出异常。

创建Controller

新建一个controller包,并在其中创建一个HelloController类,其中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "hello,envy!";
}

@GetMapping("/admin/hello")
public String admin() {
return "hello,admin!";
}

@GetMapping("/user/hello")
public String user() {
return "hello,user!";
}
}

可以看到这个就是定义的需要测试的接口,和之前完全一样。

创建SecurityConfig

新建一个config包,并在其中创建一个MySecurityConfig类这个类需要继承WebSecurityConfigurerAdapter类,其中的代码如下所示:

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
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {

@Autowired
private MyUserDetailService myUserDetailService;

@Bean
PasswordEncoder passwordEncoder(){
return NoOpPasswordEncoder.getInstance();
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(myUserDetailService);
}

@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/js/**", "/css/**");
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/admin/**").hasRole("admin")
.antMatchers("/user/**").hasRole("user")
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login.html")
.loginProcessingUrl("/goLogin")
.usernameParameter("name")
.passwordParameter("word")
.successHandler((httpServletRequest, httpServletResponse, authentication) ->{
Object principal = authentication.getPrincipal();
httpServletResponse.setContentType("application/json;charset=utf-8");
PrintWriter out = httpServletResponse.getWriter();
out.write(new ObjectMapper().writeValueAsString(principal));
out.flush();
out.close(); }
)
.failureHandler((httpServletRequest, httpServletResponse, e)-> {
httpServletResponse.setContentType("application/json;charset=utf-8");
PrintWriter out = httpServletResponse.getWriter();
httpServletResponse.setStatus(401);
Map<String, Object> map = new HashMap<>();
map.put("status", 401);
if (e instanceof LockedException) {
map.put("msg", "账户被锁定,登录失败!");
} else if (e instanceof BadCredentialsException) {
map.put("msg", "账户名或密码输入错误,登录失败!");
} else if (e instanceof DisabledException) {
map.put("msg", "账户被禁用,登录失败!");
} else if (e instanceof AccountExpiredException) {
map.put("msg", "账户已过期,登录失败!");
} else {
map.put("msg", "登录失败!");
}
ObjectMapper objectMapper = new ObjectMapper();
out.write(objectMapper.writeValueAsString(map));
out.flush();
out.close();
})
.permitAll()
.and()
.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint((httpServletRequest,httpServletResponse,authException)->{
httpServletResponse.setContentType("application/json;charset=utf-8");
PrintWriter out = httpServletResponse.getWriter();
out.write("您尚未登录,请先登录!");
out.flush();
out.close();
})
.and()
.logout()
.logoutUrl("/logout")
.logoutSuccessHandler((httpServletRequest,httpServletResponse,authentication)->{
httpServletResponse.setContentType("application/json;charset=utf-8");
PrintWriter out = httpServletResponse.getWriter();
out.write("注销登录成功!");
out.flush();
out.close();
}).and();
}

//角色继承
@Bean
RoleHierarchy roleHierarchy(){
RoleHierarchyImpl hierarchy= new RoleHierarchyImpl();
hierarchy.setHierarchy("ROLE_admin > ROLE_user");
return hierarchy;
}
}

可以看到这里几乎复用了前面的代码,但是注意以下新增的内容:

1
2
3
4
5
6
7
@Autowired
private MyUserDetailService myUserDetailService;

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(myUserDetailService);
}

可以看到这里依旧重写了configure(AuthenticationManagerBuilder auth)方法,所不同的是此处没有使用之前的inMemoryAuthenticationjdbcAuthentication,而是使用了自定义的MyUserDetailService作为数据源。

数据库和JPA配置

修改application.yml配置文件,在里面新增如下配置信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
username: root
password: root
url: jdbc:mysql:///securityjpa?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
jpa:
database: mysql
database-platform: mysql
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
dialect: org.hibernate.dialect.MySQL8Dialect

启动项目测试

首先启动security-jpa项目的入口类SecurityJpaApplication,可以发现此时securityjpa数据库中就已经出现了3个表:t_user、t_role和t_user_role。

接着在SecurityJpaApplicationTests测试类中新增如下代码,用于往数据库中新增2个用户及角色信息:

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
@SpringBootTest
class SecurityJpaApplicationTests {
@Autowired
private UserRepository userRepository;

@Test
void contextLoads() {
User user1 = new User();
user1.setUsername("envy");
user1.setPassword("1234");
user1.setAccountNonExpired(true);
user1.setAccountNonLocked(true);
user1.setCredentialsNonExpired(true);
user1.setEnabled(true);

List<Role> roles1 = new ArrayList<>();
Role r1 = new Role();
r1.setName("ROLE_admin");
r1.setDescription("管理员");

roles1.add(r1);
user1.setRoles(roles1);
userRepository.save(user1);


User user2 = new User();
user2.setUsername("test");
user2.setPassword("1234");
user2.setAccountNonExpired(true);
user2.setAccountNonLocked(true);
user2.setCredentialsNonExpired(true);
user2.setEnabled(true);

List<Role> roles2 = new ArrayList<>();
Role r2 = new Role();
r2.setName("ROLE_user");
r2.setDescription("普通用户");

roles2.add(r2);
user2.setRoles(roles2);
userRepository.save(user2);
}
}

之后查看一下数据库,可以看到数据库中已经存在3个表及对应数据:

接口测试

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

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

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

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

这样本篇关于Spring Security中使用Spring Data JPA进行数据管理的学习就到此为止,后续学习其他内容。