安全对于任何项目来说都是非常重要的,一般而言任何项目都会有较为严格的认证和授权操作,在Java开发领域常见的安全框架有Shiro和Spring Security。Shiro是一个轻量级的安全管理框架,提供了认证、授权、会话管理、密码管理、缓存管理等功能,而Spring Security则是一个更为复杂的安全管理框架,功能比Shiro更强大,权限控制细粒度更高,对于OAuth2的支持也更友好,再加上Spring Security源自Spring全家桶,因此与Spring框架可以做到无缝整合,尤其是SpringBoot中提供的自动化配置方案,可以让Spring Security使用变得更加便捷。本篇学习Spring Security在SpringBoot框架中的使用。

Spring Security基本配置

SpringBoot针对Spring Security提供了自动化的配置方案,因为SpringBoot整合Spring Security非常简单,这也是SpringBoot项目中使用Spring Security的优势所在。接下来介绍SpringBoot中如何整合Spring Security安全框架。

基本用法

第一步,新建一个项目,并添加依赖。使用spring Initializr构建工具构建一个SpringBoot的Web应用,名称为springsecurityspringboot,然后在pom.xml文件中添加如下依赖:

1
2
3
4
5
6
7
8
9
<!--添加springSecurity依赖-->
<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>

请注意只要开发者在项目中添加了spring-boot-starter-security依赖,那么项目中所有资源都会被保护起来。
第二步,新建测试Controller类。新建一个controller测试类HelloController,里面的代码为:

1
2
3
4
5
6
7
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello(){
return "hello,envy!";
}
}

第三步,启动项目进行测试。接下来启动项目,启动成功后访问http://localhost:8080/hello链接时,可以看到该链接会自动跳转到登录页面,这个登录页面是由SpringSecurity提供的,如下图所示:

请注意默认的用户名是user,默认的登录密码则在每次启动项目时随机生成,查看项目启动日志就能发现诸如如下图片所示的密码:

从项目启动的日志中获取的密码和user用户名就能进行登录,登录成功后用户就能访问http://localhost:8080/hello链接,并显示正确的信息。

配置用户名和密码

开发者如果需要修改默认的用户名和密码,可以在application.properties配置文件中添加如下配置来达到修改默认用户名、密码以及用户角色的目的:

1
2
3
spring.security.user.name=envy
spring.security.user.password=1234
spring.security.user.roles=admin

之后再重新启动项目,此时项目启动日志中就不会打印出随机生成的密码,用户可以使用自定义的用户名和密码进行登录,登录成功后,用户还具有另一个角色:admin,因为开发者在配置文件中给予该用于admin角色了。

基于内存的认证

当然开发者也可以自定义类,然后继承WebSecurityConfigurerAdapter进而实现对SpringSecurity更多的自定义配置。如基于内存的认证,则其对应的配置方式为:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class MyWebSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
PasswordEncoder passwordEncoder(){
return NoOpPasswordEncoder.getInstance();
}

protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("admin").password("1234").roles("ADMIN","USER")
.and().withUser("envy").password("1234").roles("USER");
}
}

简单解释一下上述代码的含义:

  • 首先自定义MyWebSecurityConfig类继承自WebSecurityConfigurerAdapter,且重写了configure(AuthenticationManagerBuilder auth)方法,在该方法中配置了两个用户,一个用户名是admin,密码是1234,具备两个角色ADMIN和USER;而另一个用户名是envy,密码是1234,仅仅具备一个角色USER。
  • 本例子仅仅只是用于demo演示,使用的SpringSecurity版本是5.1.15,在SpringSecurity5.x中引入了多种密码加密方式,开发者必须制定一种,请注意本例子使用的NoOpPasswordEncoder已经过时,所以这里也只是demo,实际工作中不能使用该NoOpPasswordEncoder方式,且这种是不对密码进行加密的方式,非常不安全。基于内存的用户配置角色时,不用在前面添加ROLE_前缀,这一点需要和后续介绍的基于数据库的认证有较大差别。

配置完成后,重启项目,接着就可以使用这两个配置用户进行登录了。

HttpSecurity

虽然现在可以实现认证功能,但是受保护的资源都是默认的,且不能根据实际情况进行角色管理,如果想要实现这些功能,就需要重写WebSecurityConfigurerAdapter中的另一个configure方法,相应的代码如下:

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
@Configuration
public class MyWebSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
PasswordEncoder passwordEncoder(){
return NoOpPasswordEncoder.getInstance();
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("root").password("1234").roles("ADMIN","DBA")
.and()
.withUser("admin").password("1234").roles("ADMIN","USER")
.and()
.withUser("envy").password("1234").roles("USER");
}

@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity.authorizeRequests()
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/user/**").access("hasAnyRole('ADMIN','USER')")
.antMatchers("/db/**").access("hasRole('ADMIN')and hasRole('DBA')")
.anyRequest()
.authenticated()
.and()
.formLogin()
.loginProcessingUrl("/login")
.permitAll()
.and()
.csrf()
.disable();
}

简单解释一下上述代码的含义:

  • 首先配置了三个用户,其中root用户具备ADMIN和DBA的角色,admin用户具备ADMIN和USER的角色,而envy用户仅仅是具备USER角色。
  • httpSecurity.authorizeRequests()方法开启HttpSecurity的配置,然后分别配置用户访问/admin/**模式的URL必须具备ADMIN的角色;访问/user/**模式的URL必须具备ADMIN或者USER的角色;访问/db/**模式的URL必须同时具备ADMIN和DBA的角色。
  • .anyRequest().authenticated()表示除了前面定义的URL模式外,用户访问其他的URL都必须认证后才能访问(即登录后才能访问)。
  • .and().formLogin().loginProcessingUrl("/login").permitAll()表示开启表单登录,也就是一开始看到的登录页面,同时配置了登录接口为/login,即可以直接调用/login接口,发起一个POST请求进行登录,登录参数中用户名必须命名为username,密码必须命名为password,配置loginProcessingUrl接口主要是方便Ajax或者移动端调用登录接口。最后还配置了permitAll表示和登录相关的接口都不需要认证即可访问。
  • .and().csrf().disable();表示关闭csrf验证。

配置完成后,接下来在HelloController类中添加如下接口开始进行测试:

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

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

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

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

根据前面在MyWebSecurityConfig类中的配置可知,/admin/hello接口只有root和admin用户具有访问权限;/user/hello接口只有admin和envy用户具有访问权限;/db/hello接口只有root用户具有访问权限;而/hello接口只要是登录用户都是可以访问的。接下来就可以运行项目,在浏览器中对上述分析结果进行验证即可,这里就不分析了。

登录表单详细配置

迄今为止开发者使用的登录表单都是SpringSecurity提供的默认页面,且登录成功后的页面跳转也是默认的,但是在实际开发过程中,尤其是在前后端分离的架构中,前后端数据是通过JSON来交互的,这时登录成功后就不是页面跳转,而是一段JSON提示。如果开发者想实现这些功能,只需要完善上文的配置即可。首先继续完善configure(HttpSecurity httpSecurity)方法中的代码:

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
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity.authorizeRequests()
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/user/**").access("hasAnyRole('ADMIN','USER')")
.antMatchers("/db/**").access("hasRole('ADMIN')and hasRole('DBA')")
.anyRequest()
.authenticated()
.and()
.formLogin()
.loginPage("/login_page")
.loginProcessingUrl("/login")
.usernameParameter("username")
.passwordParameter("password")
.successHandler(new AuthenticationSuccessHandler() {
@Override
public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
Object principal =authentication.getPrincipal();
httpServletResponse.setContentType("application/json;charset=utf-8");
PrintWriter out = httpServletResponse.getWriter();
httpServletResponse.setStatus(200);
Map<String,Object> map =new HashMap<>();
map.put("status",200);
map.put("msg",principal);
ObjectMapper objectMapper = new ObjectMapper();
out.write(objectMapper.writeValueAsString(map));
out.flush();
out.close();
}
})
.failureHandler(new AuthenticationFailureHandler() {
@Override
public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
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();
}
});
}

简单解释一下上述代码的含义:

  • 修改了configure(HttpSecurity httpSecurity)方法,首先配置了loginPage也就是登录页面,配置了loginPage后,如果用户未授权就访问一个需要授权才能访问的接口,那么系统就会自动跳转到login_page页面让用户登录,而这个login_page就是开发者自定义的登录页面,而不再是SpringSecurity提供的默认登录页面。
  • 然后配置了loginProcessingUrl表示登录请求处理接口,不论是自定义登录页面还是移动端登录,都需要使用该接口来调用处理登录的逻辑。
  • .usernameParameter.passwordParameter定义了认证所需的用户名和密码的参数名,默认用户名参数是username,密码参数是password,开发者在这里可以自定义。
  • .successHandler中则创建了一个匿名内部类来定义登录成功的处理逻辑。用户登录成功后可以跳转到某一个页面,也可以返回一段JSON,这个需要结合实际的情况来决定。这里假设返回一段JSON,那么用户登录成功后,返回一段成功的JSON。onAuthenticationSuccess方法的第三个参数authentication一般用来获取当前登录用户的信息,在登录成功后,可以获取到当前登录用户的信息一起返回给客户端。
  • 同理在.failureHandler中则创建了一个匿名内部类来定义登录失败的处理逻辑。用户登录失败后可以跳转到某一个页面,也可以返回一段JSON,这里依旧假设返回一段JSON,不同的是,登录失败的回调方法中有一个AuthenticationException参数,通过这个异常参数可以获取登录失败的原因,进而给用户一个较为明确的提示,之后用户可以根据该提示进行一些修改操作,进而完成登录操作。

配置完成后,接下来启动项目,开始进行登录测试,这是登录成功的图片:

而这是登录失败的示意图,可以看到之所以登录失败是因为密码或者账号输入错误:

注销登录配置

在前面如果开发者想要切换不同的用户进行登录,必须通过停止和重启项目才能完成,接下来介绍如何注销登录,其实这个也很简单,只需要提供简单的配置即可。继续修改configure(HttpSecurity httpSecurity)方法,只需要在该方法的后面添加如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
        .and()
.logout()
.logoutUrl("/logout")
.clearAuthentication(true)
.invalidateHttpSession(true)
.addLogoutHandler(new LogoutHandler() {
@Override
public void logout(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) {
}
}).logoutSuccessHandler(new LogoutSuccessHandler() {
@Override
public void onLogoutSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
httpServletResponse.sendRedirect("/login_page");
}
}).and();

同样也简单解释一下上述代码的含义:

  • .logout()表示开启注销登录的配置。.logoutUrl("/logout")表示配置注销登录请求URL为/logout,默认也是/logout.clearAuthentication(true)表示是否清除身份认证信息,默认为true,表示清除。.invalidateHttpSession(true)表示是否使Session失效,默认为true。.addLogoutHandler表示配置一个LogoutHandler,开发者可以在LogoutHandler中完成一些数据清除的工作,如Cookie的清除等,SpringSecurity提供了一些常见的实现,如下图所示:

接着配置了一个.logoutSuccessHandler拦截,开发者可以在这里配置当注销成功后应当执行的业务逻辑,如返回一段JSON提示或者是跳转到登录页面等,这些都是需要结合具体场景来的。

多个HttpSecurity

如果业务较为复杂,开发者也可以配置多个HttpSecurity,以实现对WebSecurityConfigurerAdapter的多次扩展。新建一个MultiHttpSecurityConfig类,其中的代码为:

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
@Configuration
public class MultiHttpSecurityConfig {
@Bean
PasswordEncoder passwordEncoder(){
return NoOpPasswordEncoder.getInstance();
}
@Autowired
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("admin").password("1234").roles("ADMIN","USER")
.and()
.withUser("envy").password("1234").roles("USER");
}

@Configuration
@Order(1)
public static class AdminSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity.antMatcher("/admin/**").authorizeRequests()
.anyRequest().hasRole("ADMIN");
}
}
@Configuration
public static class OtherSecurityConfig extends WebSecurityConfigurerAdapter{
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.loginProcessingUrl("/login")
.permitAll()
.and()
.csrf()
.disable();
}
}
}

在上述类中,配置多个HttpSecurity时,MultiHttpSecurityConfig不需要继承WebSecurityConfigurerAdapter,只需在MultiHttpSecurityConfig类中创建静态内部类继承WebSecurityConfigurerAdapter即可,静态内部类上也需要添加@Configuration注解,且由于存在两个HttpSecurity,因此需要使用@Order注解,@Order注解表示该配置的优先级,数字越小优先级越大,未加@Order注解的配置优先级最小。AdminSecurityConfig静态内部类主要用于处理/admin/**模式的URL,其他模式的URL则在OtherSecurityConfig静态内部类中配置的HttpSecurity中进行处理。

密码加密

(1)为什么需要密码加密?如果开发者直接在数据库中存入明文密码,那么当数据库被不法分子攻破后密码就毫无保密可言,因此对密码进行加密是对密码进行二次保护。
(2)加密方案。密码加密一般会用到散列函数,又称散列算法,哈希函数,这是一种从任何数据中创建数字“指纹”的方法。散列函数把消息或数据压缩成摘要,使得数据量变小,将数据的格式固定下来,然后将数据进行打乱混合,重新创建一个散列值。散列值通常用一个短的随机字母和数字组成的字符串来代表。好的散列函数在输入域中很少出现散列冲突。在散列表和数据处理中,不抑制冲突来区别数据会使得数据库记录更难找到。常用的散列函数有MD5消息摘要算法、安全散列算法(Secure Hash Algorithm)。
但是仅仅使用散列函数还不够,为了增加密码的安全性,一般在密码加密过程中还需要加盐,所谓的盐可以是一个随机数,也可以是用户名,加盐之后,即使密码明文相同的用户生成的密码,密文也是不相同的,这极大地提高了密码的安全性。但是传统的加盐方式需要在数据库中有专门的字段来记录盐值,这个字段可能使用用户名字段(因为用户名唯一),也可能是一个专门记录盐值的字段,这样的配置较为繁琐。SpringSecurity提供了多种密码加密方案,官方推荐使用BCryptPasswordEncoderBCryptPasswordEncoder使用BCrypt强哈希函数,开发者在使用时可以选择提供stength和SecureRandom实例。其中stength越大,密钥的迭代次数越多,密钥的迭代次数为2^strength。stength取值在4~31之间,默认为10,以上就是通常意义上的加密方案。
(3)实践。在SpringBoot中配置密码加密非常简单,只需要修改上文配置的PasswordEncoder这个Bean的实现即可,相应的代码为:

1
2
3
4
5
//使用BCryptPasswordEncoder哈希函数进行密码加密
@Bean
PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder(10);
}

创建BCryptPasswordEncoder时传入的参数10就是stength,即密钥的迭代次数(也可以不配置,默认stength的值就是10)。同时请注意配置的内存用户的密码不再是1234,而是经BCryptPasswordEncoder加密后的密码,相应的代码为:

1
2
3
4
5
6
7
8
9
10
//使用BCryptPasswordEncoder哈希函数进行密码加密后对密码进行保存操作
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("root").password("$2a$10$LE86rB.N5wYXAVRkLIEtGuLnPzvccwWN2/V5ZWa6g0Drk3WfvV5ou").roles("ADMIN","DBA")
.and()
.withUser("admin").password("$2a$10$/BOtEyJ62TbMea8BcnDgMOjQU58dlWm.l0QO5G7BEr5LhQ3F0GRf.").roles("ADMIN","USER")
.and()
.withUser("envy").password("$2a$10$5KYz93AK76T6uixVe/mPlempnSRwo2VxAfygj/q4v5A66AjWnc.kC").roles("USER");
}

这里的密码就是使用BCryptPasswordEncoder加密后的密码,虽然admin,root,envy加密后的密码不一样,但是明文都是1234。配置完成后,使用admin/1234、root/1234或者envy/1234就可以实现登录了。

此处的例子使用了配置在内存中的用户,一般情况下,用户信息都是存储在数据库中的,因此需要在用户注册时对密码进行加密,这里提供一段伪代码:

1
2
3
4
5
6
7
8
@Service
public class RegisterService {
public int register(String username,String password){
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder(10);
String encodePassword = bCryptPasswordEncoder.encode(password);
return saveToDb(username,encodePassword );
}
}

用户将密码从前端传过来之后,通过调用BCryptPasswordEncoder实例中的encode方法对密码进行加密处理,加密完成后将密文存入数据库即可。

方法安全

前面介绍的认证都是基于URL这种方式,其实开发者也可以通过注解来灵活配置方法安全,不过想要使用相关注解,首先需要通过@EnableGlobalMethodSecurity注解来开启基于注解的安全配置。新建一个MyAnnoWebSecurityConfig类,里面的代码为:

1
2
3
4
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true,securedEnabled = true)
public class MyAnnoWebSecurityConfig {
}

简单解释一下上述代码的含义:prePostEnabled会解锁@PreAuthorize@PostAuthorize这两个注解,顾名思义,@PreAuthorize注解会在方法执行前进行验证,而@PostAuthorize注解则在方法执行后进行验证。securedEnabled=true会解锁@Secured注解。

开启注解安全配置后,接下来创建一个AnnotationService进行测试,请注意事先需要注释掉前面基于URL模式的相关配置信息。相应的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Service
public class AnnotationService {
@Secured("ROLE_ADMIN")
public String admin(){
return "hello,admin";
}
@PreAuthorize("hasRole('ADMIN')and hasRole('DBA')")
public String dba(){
return "hello,dba";
}
@PreAuthorize("hasAnyRole('ADMIN','DBA','USER')")
public String user(){
return "hello,user";
}
}

接着新建AnnotationController类,其中的代码为:

1
2
3
4
5
6
7
8
9
10
@RestController
public class AnnotationController {
@Autowired
private AnnotationService annotationService;

@GetMapping("/admin")
public String admin(){
return annotationService.admin();
}
}

然后运行项目开始进行测试,这里就不过多赘述了。

基于数据库的认证

前面学习的认证都是在内存中完成的,在真实开发过程中,用户的基本信息及角色都是保存在数据库中,因此需要从数据库中进行认证。接下来就向大家介绍如何使用数据库中的数据进行认证与授权。
第一步,设计数据表。首先需要设计一个基本的用户角色表,如下图所示,一个三张表,分别是用户表,角色表,用户角色关联表:

考虑到性能问题,这里使用Mybatis,因此需要提前创建数据库和表,使用的SQL语句为:

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
drop database if exists sqlsecurity;
create database sqlsecurity;
use sqlsecurity;
drop table if exists user;
create table user(
id int(11) not null primary key auto_increment,
username varchar(32) not null comment '用户名',
password varchar(255) not null comment '密码',
enabled tinyint(1) default 1 comment '是否被禁用,1表示否,0表示是,默认1',
locked tinyint(1) default 0 comment '是否被锁住,1表示是,0表示否,默认0'
)engine=innodb,charset=utf8;

drop table if exists role;
create table role(
id int(11) not null primary key auto_increment,
name varchar(32) not null comment '角色',
name_note varchar(255) not null comment '角色说明'
)engine=innodb,charset=utf8;

drop table if exists user_role;
create table user_role(
id int(11) not null primary key auto_increment,
uid int(11)not null,
rid int(11) not null
)engine=innodb,charset=utf8;

alter table user_role add constraint fk_user_user_role foreign key(uid) references user(id);
alter table user_role add constraint fk_role_user_role foreign key(rid) references role(id);

为了后续测试,可以先往其中增加一些数据,具体的自行添加,这里贴一下笔者的数据示意图:

请注意角色名必须有一个默认的前缀ROLE_,这一点和前面内存的配置方式是不相同的。
第二步,创建项目。使用spring Initializr构建工具构建一个SpringBoot的Web应用,名称为sqlspringsecurity,然后添加如下依赖:

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
 <dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--添加security依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!--添加数据库驱动依赖-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!--添加数据库连接池依赖-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.0.25</version>
</dependency>
<!--添加mybatis依赖-->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>1.3.2</version>
</dependency>
<!--添加lombok依赖-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>

第三步,配置数据库。application.properties数据库配置文件中添加数据库连接配置信息:

1
2
3
4
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/sqlsecurity?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=1234

第四步,创建实体类。新建pojo包,并在其中根据数据库表来创建用户表和角色表所对应的实体类。其中Role.java类中的代码为:

1
2
3
4
5
6
@Data
public class Role {
private Integer id;
private String name;
private String nameNote;
}

User.java类中的代码为:

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
@Data
public class User implements UserDetails {
private Integer id;
private String username;
private String password;
@Getter(value = AccessLevel.NONE)
private Boolean enabled;
private Boolean locked;
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 true;
}

@Override
public boolean isAccountNonLocked() {
return !locked;
}

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

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

请注意这个User必须实现UserDetails接口,且实现其中的7个接口方法,如下表所示:

方法名 解释
getAuthorities 获取当前用户所具有的角色信息
getPassword 获取当前用户对象的密码
getUsername 获取当前用户对象的用户名
isAccountNonExpired 当前账户是否未过期
isAccountNonLocked 当前账户是否未锁定
isCredentialsNonExpired 当前账户密码是否未过期
isEnabled 当前账户是否可用

用户可以根据实际情况设置这7个方法的返回值,因为默认情况下不需要开发者自己进行密码角色等信息的对比,开发者只需要提供相关信息即可。如getPassword方法返回的密码和用户输入的密码不匹配,会自动抛出BadCredentialsException异常,isAccountNonExpired方法返回了false,会自动抛出AccountExpiredException异常,因此对于开发者而言,只需要按照数据库中的数据在这里返回相对应的配置即可。本例子中的数据库只设置了enabled和locked字段,故账户未过期和密码未过期这两个方法都是直接返回了true。getAuthorities方法用来获取当前用户所具有的角色信息,在本例子中用户所具有的角色存储在roles属性中,因此该方法可以直接遍历roles属性,然后构造SimpleGrantedAuthority集合并返回。

同时在enabled属性上必须添加@Getter(value = AccessLevel.NONE)注解,也就是不生成getter方法,因为此处的isEnabled方法实际上就是enabled属性的getter方法。

第五步,创建数据库访问层。新建mapper包,接着在里面新建一个UserMapper接口(注意名称必须是实体类大写+Mapper这种格式),里面的代码为:

1
2
3
4
5
@Mapper
public interface UserMapper {
User loadUserByUsername(String username);
List<Role> getUserRolesByUid(Integer uid);
}

第六步,创建UserMapper.xml文件。请注意必须在与UserMapper接口相同的包内新建UserMapper.xml文件,里面的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.envy.sqlspringsecurity.mapper.UserMapper">
<select id="loadUserByUsername" parameterType="String" resultType="com.envy.sqlspringsecurity.pojo.User">
select * from user where username = #{username}
</select>

<select id="getUserRolesByUid" resultType="com.envy.sqlspringsecurity.pojo.Role">
select * from role r, user_role ur where ur.uid= #{uid} and r.id = ur.rid
</select>
</mapper>

第七步,创建Service层。在service包内新建UserService类,里面的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Service
public class UserService implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userMapper.loadUserByUsername(username);
if(user==null){
throw new UsernameNotFoundException("账户不存在");
}
user.setRoles(userMapper.getUserRolesByUid(user.getId()));
return user;
}
}

请注意这里的UserService类也需要实现UserDetailsService接口,并实现其中的loadUserByUsername方法,该方法的参数就是用户登录时输入的用户名,通过用户名去数据库中查找用户,如果没有查到用户就抛出一个账户不存在的异常,如果查到了用户就继续查找用户所具有的角色信息,并将获取到的user对象返回,再由系统提供的DaoAuthenticationProvider类去比对密码是否正确。特别注意这个loadUserByUsername方法,它是在用户登录的时候自动调用的。

第八步,配置Spring Security。新建config包,并在其中新建WebSecurityConfig类,里面的代码为:

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
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

@Autowired
private UserService userService;

@Bean
PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder(10);
}

@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService);
}

@Override
public void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity.authorizeRequests()
.antMatchers("/admin/**").hasRole("admin")
.antMatchers("/db/**").hasRole("dba")
.antMatchers("/user/**").hasRole("user")
.anyRequest()
.authenticated()
.and().formLogin().loginProcessingUrl("/login").permitAll()
.and().csrf().disable();
}
}

这里大部分的配置和前面基于内存的验证方式的配置是一致的,不同的就是没有配置内存用户,而是将刚刚创建好的UserService配置到AuthenticationManagerBuilder中。

第九步,配置pom.xml文件,这一步很重要。在Maven工程中,XML配置文件建议写在resources目录下,但是很明显上述的BookMapper.xml文件写在了mapper包内,且不在resources目录下,此时运行项目肯定会抛出mapper文件找不到的异常,因为Maven运行时会忽略包内的xml文件,因此需要在pom.xml文件中重新指明资源文件的位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
 <build>
<!--使用mybatis时需要手动指明xml的位置-->
<resources>
<resource>
<directory>src/main/java</directory>
<includes>
<include>**/*.xml</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
</resource>
</resources>
</build>

第十步,创建Controller层。在controller包内新建UserController类,里面的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@RestController
public class UserController {
@GetMapping("/admin/hello")
public String admin(){
return "hello,admin!";
}

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

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

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

第十一步,测试。运行项目 ,在浏览器中输入http://localhost:8080/admin/hello链接时,会里面跳转到http://localhost:8080/login链接,且只有admin和root用户才能访问到该页面,其余的链接和这个非常相似这里就不过多赘述。

高级配置

角色继承

在前面定义了三种角色:ROLE_dba、ROLE_admin和ROLE_user,但是三种角色之间不具备任何关系,一般来说角色之间是有关系的,如ROLE_admin一般既有admin的权限,又具有user的权限。那么如何配置这种继承关系呢?在Spring Security中只需要开发者提供一个RoleHierarchy即可。以前面数据库认证的例子为例,假设ROLE_dba具有所有的权限,ROLE_admin也具有ROLE_user的权限,而ROLE_user则是一个公共的角色,即ROLE_admin继承ROLE_user、ROLE_dba继承ROLE_admin,要想描述这种继承关系,只需要开发者在SpringSecurity的配置类(此处是WebSecurityConfig类)中提供一个RoleHierarchy即可,相应的代码为:

1
2
3
4
5
6
7
@Bean
RoleHierarchy roleHierarchy(){
RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
String hierarchy = "ROLE_dba> ROLE_admin ROLE_admin>ROLE_user";
roleHierarchy.setHierarchy(hierarchy);
return roleHierarchy;
}

配置完RoleHierarchy之后,具有ROLE_dba角色的用户就可以访问到所有资源,而ROLE_admin角色的用户也可以访问具有ROLE_user角色才能访问的资源。开发者可以自行测试一下上述配置是否成功生效,此处笔者就不再赘述了。

动态配置权限

使用HttpSecurity配置的认证授权规则还不够灵活,无法实现资源和角色之间的动态调整,要实现动态配置URL权限,就需要开发者自定义权限配置。此处依然基于前面数据库的认证案例进行权限的动态配置。

第一步,数据库设计。此处的数据库依旧是前面的sqlsecurity,现在在该基础上新增两张表:资源表和资源角色关联表,其中资源表定义了用户能够访问的URL模式,资源角色表则定义了访问该模式的URL应当需要怎样的角色,使用的SQL语句如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use sqlsecurity;
drop table if exists menu;
create table menu(
id int(11) not null primary key auto_increment,
pattern varchar(32) not null comment 'URL模式'
)engine=innodb,charset=utf8;

drop table if exists menu_role;
create table menu_role(
id int(11) not null primary key auto_increment,
mid int(11)not null,
rid int(11) not null
)engine=innodb,charset=utf8;

alter table menu_role add constraint fk_menu_menu_role foreign key(mid) references menu(id);
alter table menu_role add constraint fk_role_menu_role foreign key(rid) references role(id);

为了后续测试,可以先往其中增加一些数据,具体的自行添加,这里贴一下笔者的数据示意图:

第二步创,创建实体类。在pojo包中根据数据库表来创建资源表所对应的实体类。其中Menu.java类中的代码为:

1
2
3
4
5
6
@Data
public class Menu {
private Integer id;
private String pattern;
private List<Role> roles;
}

第三步,创建数据库访问层。新建一个MenuMapper接口(注意名称必须是实体类大写+Mapper这种格式),里面的代码为:

1
2
3
4
@Mapper
public interface MenuMapper {
List<Menu> getAllMenus();
}

第四步,创建MenuMapper.xml文件。请注意必须在与MenuMapper接口相同的包内新建MenuMapper.xml文件,里面的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.envy.sqlspringsecurity.mapper.MenuMapper">
<resultMap id="BaseResultMap" type="com.envy.sqlspringsecurity.pojo.Menu">
<id property="id" column="id"/>
<result property="pattern" column="pattern"/>
<collection property="roles" ofType="com.envy.sqlspringsecurity.pojo.Role">
<id property="id" column="rid"/>
<result property="name" column="rname"/>
<result property="nameNote" column="rnameNote"/>
</collection>
</resultMap>

<select id="getAllMenus" resultMap="BaseResultMap">
select m.*,r.id as rid,r.name as rname,r.name_note as rnameNote from menu m
left join menu_role mr on m.id=mr.mid
left join role r on mr.rid=r.id
</select>
</mapper>

**第五步,自定义FilterInvocationSecurityMetadataSource**。要实现动态配置权限。首先要自定义FilterInvocationSecurityMetadataSource,Spring Security中通过FilterInvocationSecurityMetadataSource接口中的getAttributes方法来确定一个请求需要哪些角色,FilterInvocationSecurityMetadataSource接口的默认实现类是DefaultFilterInvocationSecurityMetadataSource,开发者可以参考该实现类中的方法实现来自定义实现自己CustomFilterInvocationSecurityMetadataSource类的方法。新建component包,并在其中创建CustomFilterInvocationSecurityMetadataSource类文件,里面的代码为:

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
@Component
public class CustomFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {

AntPathMatcher antPathMatcher = new AntPathMatcher();
@Autowired
MenuMapper menuMapper;

@Override
public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
String requestUrl = ((FilterInvocation)object).getRequestUrl();
List<Menu> allMenus = menuMapper.getAllMenus();
for(Menu menu:allMenus){
if(antPathMatcher.match(menu.getPattern(),requestUrl)){
List<Role> roles = menu.getRoles();
String [] roleArr = new String[roles.size()];
for(int i =0;i<roleArr.length;i++){
roleArr[i] = roles.get(i).getName();
}
return SecurityConfig.createList(roleArr);
}
}
return SecurityConfig.createList("ROLE_LOGIN");
}

@Override
public Collection<ConfigAttribute> getAllConfigAttributes() {
return null;
}

@Override
public boolean supports(Class<?> aClass) {
return FilterInvocation.class.isAssignableFrom(aClass);
}
}

解释一下上述代码的含义:(1)开发者自定义一个CustomFilterInvocationSecurityMetadataSource类且实现FilterInvocationSecurityMetadataSource接口,同时实现了其接口中的3个方法,不过主要是对getAttributes方法进行了实现,该方法的参数是一个FilterInvocation对象,开发者可以从FilterInvocation对象的getRequestUrl方法中提取出当前请求的URL,getAttributes方法的返回值是一个Collection<ConfigAttribute>表示当前请求URL所需的角色。(2)使用AntPathMatcher antPathMatcher = new AntPathMatcher();创建了一个AntPathMatcher对象,主要用来实现ant风格的URL匹配。(3)调用((FilterInvocation)object).getRequestUrl()方法来从参数中提取出当前请求的URL。(4)List<Menu> allMenus = menuMapper.getAllMenus();表示从数据库中获取所有的资源信息,在此例子中是指menu表以及menu所对应的role。在真实项目环境中,开发者可以将资源信息缓存在Redis或者其他缓存数据库中。(5)接下来的for循环用于遍历资源信息,遍历过程中获取当前请求的URL所需要的角色信息并返回。如果当前请求的URL在资源表中不存在相应的模式,就假设该请求登录后即可访问,即直接返回ROLE_LOGIN。(6)getAllConfigAttributes方法用来返回定义好的权限资源,SpringSecurity在启动时会校验相关配置是否正确,如果不需要校验,那么该方法直接返回null即可。(7)supports方法返回类对象是否支持校验。

第六步,自定义AccessDecisionManager当一个请求走完FilterInvocationSecurityMetadataSource中的getAttributes方法后,接下来就会来到AccessDecisionManager类中进行角色信息的比对。在component包中创建CustomAccessDecisionManager类文件,里面的代码为:

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
@Component
public class CustomAccessDecisionManager implements AccessDecisionManager {
@Override
public void decide(Authentication authentication, Object o, Collection<ConfigAttribute> collection) throws AccessDeniedException, InsufficientAuthenticationException {
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
for(ConfigAttribute configAttribute:collection){
if("ROLE_LOGIN".equals(configAttribute.getAttribute()) && authentication instanceof UsernamePasswordAuthenticationToken){
return;
}
for(GrantedAuthority authority:authorities){
if(configAttribute.getAttribute().equals(authority.getAuthority())){
return;
}
}
}
throw new AccessDeniedException("权限不足");
}

@Override
public boolean supports(ConfigAttribute configAttribute) {
return true;
}

@Override
public boolean supports(Class<?> aClass) {
return true;
}
}

解释一下上述代码的含义:(1)开发者自定义一个CustomAccessDecisionManager类且实现AccessDecisionManager接口中的三个方法,最主要的还是decide方法,在该方法中判断当前登录用户是否具备当前请求URL所需要的的角色信息,如果不具备,就抛出AccessDeniedException异常,否则不做任何事情即可。(2)decide方法有三个参数,第一个参数包含当前登录用户的信息;第二个参数则是一个FilterInvocation对象,可以获取当前请求信息等;第三个参数就是CustomFilterInvocationSecurityMetadataSource类中getAttributes方法的返回值,即当前请求URL所需要的角色。(3)decide方法用于进行角色信息对比,如果需要的角色是ROLE_LOGIN,说明当前请求的URL用户只需登录即可访问,如果authenticationUsernamePasswordAuthenticationToken的实例,那么说明当前用户已经登录,该方法到此结束,否则进入正常的判断流程,如果当前用户具备当前请求需要的角色,那么方法也结束。

第七步,配置自定义类在前面定义的WebSecurityConfig类中配置上述自定义的两个类。请注意主要是对configure(HttpSecurity httpSecurity)方法进行修改和新增上述两个类的配置信息,相应的代码为:

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
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

@Autowired
private UserService userService;

@Bean
PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder(10);
}

@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService);
}

@Bean
RoleHierarchy roleHierarchy(){
RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
String hierarchy = "ROLE_dba> ROLE_admin ROLE_admin>ROLE_user";
roleHierarchy.setHierarchy(hierarchy);
return roleHierarchy;
}

//在数据库中定义URL模式
@Override
public void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity.authorizeRequests()
.withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
@Override
public <O extends FilterSecurityInterceptor> O postProcess(O o) {
o.setSecurityMetadataSource(cfisms());
o.setAccessDecisionManager(cadm());
return o;
}
})
.and()
.formLogin()
.loginProcessingUrl("/login").permitAll()
.and()
.csrf().disable();
}

@Bean
CustomFilterInvocationSecurityMetadataSource cfisms(){
return new CustomFilterInvocationSecurityMetadataSource();
}

@Bean
CustomAccessDecisionManager cadm(){
return new CustomAccessDecisionManager();
}
}

此处仅仅是对前述WebSecurityConfig定义的补充,特别是对configure(HttpSecurity httpSecurity)方法的实现进行了修改,且添加了两个Bean。在定义FilterSecurityInterceptor时,将前述定义的两个实例作为属性设置进去。

通过上面的配置就实现了动态配置权限,权限和资源的关系可以在muen_role表中动态来调整。运行项目,接下来就是测试环节,考虑到这里的测试和前面的测试相差不大,因此就忽略测试环节。