SpringBoot安全框架Spring Security

Spring Boot针对Spring Security提供了自动化配置方案,因此可以使Spring Security非常容易地整合进Spring Boot项目中,这也是在Spring Boot项目中使用Spring Security的优势。

Spring Security的基本配置

1. 基本用法

基本整合步骤如下。

1.1 创建项目,添加依赖

创建一个 Spring Boot Web项目,然后添加spring-boot-starter-security依赖即可,代码如下:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

只要开发者在项目中添加了spring-boot-starter-security依赖,项目中所有资源都会被保护起来。

1.2 添加hello接口

接下来在项目中添加一个简单的/hello接口,内容如下:

@RestController
public class MyController {
    @GetMapping("/hello")
    public String hello(){
        return "Hello";
    }
}

1.3 启动项目测试

接下来启动项目,启动成功后,访问/hello接口会自动跳转到登录页面,这个登录页面是由Spring Security提供的,如图所示。

在这里插入图片描述

默认的用户名是user,默认的登录密码则在每次启动项目时随机生成,查看项目启动日志,如图所示。

在这里插入图片描述

从项目启动日志中可以看到默认的登录密码,登录成功后,用户就可以访问“/hello”接口了。

2. 配置用户名和密码

如果开发者对默认的用户名和密码不满意,可以在application.properties中配置默认的用户名、密码以及用户角色,配置方式如下:

spring.security.user.name=suo
spring.security.user.password=123
spring.security.user.roles=admin

当开发者在 application.properties 中配置了默认的用户名和密码后,再次启动项目,项目启动日志就不会打印出随机生成的密码了,用户可直接使用配置好的用户名和密码登录,登录成功后,用户还具有一个角色——admin。

3. 基于内存的认证

当然,开发者也可以自定义类继承自WebSecurityConfigurerAdapter,进而实现对Spring Security更多的自定义配置,例如基于内存的认证,配置方式如下:

@Configuration
public class MyWebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }

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

代码解释:

  • 自定义MyWebSecurityConfig继承自WebSecurityConfigurerAdapter ,并重写configure(AuthenticationManagerBuilder auth)方法,在该方法中配置两个用户,一个用户名是admin,密码123,具备两个角色ADMIN和USER;另一个用户名是sang,密码是123,具备一个角色USER。
  • 本案例使用的Spring Security版本是5.6.2,在Spring Security 5.x中引入了多种密码加密方式,开发者必须指定一种,本案例使用NoOpPasswordEncoder,即不对密码进行加密,单不推荐这种方式加密。

注意:基于内存的用户配置在配置角色时不需要添加“ROLE_”前缀,这点和基于数据库的认证有差别。

4. HttpSecurity

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

@Configuration
public class MyWebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.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的角色,sang用户具备USER的角色。

  • 第18行调用authorizeRequests()方法开启HttpSecurity 的配置,第19~24行配置分别表示用户访问/admin/模式的URL必须具备ADMIN的角色;用户访问/user/ 模式的URL必须具备ADMIN或USER的角色;用户访问db/模式的URL必须具备ADMIN和DBA的角色。

  • 第25、26行表示除了前面定义的URL模式之外,用户访问其他的URL都必须认证后访问(登录后访问)。

  • 第27~30行表示开启表单登录,即读者一开始看到的登录页面,同时配置了登录接口为“/login”,即可以直接调用“/login”接口,发起一个POST请求进行登录,登录参数中用户名必须命名为username,密码必须命名为password,配置loginProcessingUrl接口主要是方便Ajax或者移动端调用登录接口。最后还配置了permitAll,表示和登录相关的接口都不需要认证即可访问。

  • 第32、33行表示关闭csrf。

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

@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!";
    }
}

根据上文的配置,“/admin/hello”接口root和 admin用户具有访问权限;“luser/hello”接口admin和 sang用户具有访问权限;“ldb/hello”路径则只有root用户具有访问权限。浏览器中的测试比较容易,这里不再赘述。

5. 登录表单详细配置

迄今为止,登录表单一直使用Spring Security提供的页面,登录成功后也是默认的页面跳转,但是,前后端分离正在成为企业级应用开发的主流,在前后端分离的开发方式中,前后端的数据交互通过JSON进行,这时,登录成功后就不是页面跳转了,而是一段JSON提示。要实现这些功能,只需要继续完善上文的配置,代码如下:

@Configuration
public class MyWebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/admin/** ").hasRole("ADMIN")
                .antMatchers("/user/**")
                .access("hasAnyRole('ADMIN','USER') ").antMatchers(" / db/** ")
                .access("hasRole ('ADMIN') and hasRole ('DBA') ").anyRequest()
                .authenticated()
                .and()
                .formLogin()
                .loginPage("/loginPage")
                .loginProcessingUrl("/login")
                .usernameParameter("name")
                .passwordParameter("passwd")
                .successHandler(new AuthenticationSuccessHandler() {
                    @Override
                    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
                        Object principal = authentication.getPrincipal();
                        response.setContentType("application/json; charset=utf-8");
                        PrintWriter out = response.getWriter();
                        response.setStatus(200);
                        Map<String, Object> map = new HashMap<>();
                        map.put("status", 200);
                        map.put("msg", principal);
                        ObjectMapper om = new ObjectMapper();
                        out.write(om.writeValueAsString(map));
                        out.flush();
                        out.close();
                    }
                })
                .failureHandler(new AuthenticationFailureHandler() {
                    @Override
                    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
                        response.setContentType("application/json; charset=utf-8");
                        PrintWriter out = response.getWriter();
                        response.setStatus(401);
                        Map<String, Object> map = new HashMap<>();
                        map.put("status", 401);
                        if (exception instanceof LockedException) {
                            map.put("msg", "账户被锁定,登录失败! ");
                        } else if (exception instanceof BadCredentialsException) {
                            map.put("msg", "账户名或密码输入错误,登录失败! ");
                        } else if (exception instanceof DisabledException) {
                            map.put("msg", "账户被禁用,登录失败! ");
                        } else if (exception instanceof AccountExpiredException) {
                            map.put("msg", "账户已过期,登录失败!");
                        } else if (exception instanceof CredentialsExpiredException) {
                            map.put("msg", "密码已过期,登录失败!");
                        } else {
                            map.put("msg", "登录失败! ");
                        }
                        ObjectMapper om = new ObjectMapper();
                        out.write(om.writeValueAsString(map));
                        out.flush();
                        out.close();
                    }
                }).permitAll()
                .and()
                .csrf()
                .disable();
    }
}

代码解释:

  • 第25行配置了loginPage,即登录页面,配置了loginPage后,如果用户未获授权就访问一个
    需要授权才能访问的接口,就会自动跳转到login_page页面让用户登录,这个login_page就是开发者自定义的登录页面,而不再是Spring Security提供的默认登录页。

  • 第26行配置了loginProcessingUrl,表示登录请求处理接口,无论是自定义登录页面还是移动端登录,都需要使用该接口。

  • 第27、28行定义了认证所需的用户名和密码的参数名,默认用户名参数是username,密码参数是password,可以在这里自定义。

  • 第29~44行定义了登录成功的处理逻辑。用户登录成功后可以跳转到某一个页面,也可以返回一段JSON,这个要看具体业务逻辑,本案例假设是第二种,用户登录成功后,返回一段登录成功的JSON。onAuthenticationSuccess方法的第三个参数一般用来获取当前登录用户的信息,在登录成功后,可以获取当前登录用户的信息一起返回给客户端。

  • 第45~70行定义了登录失败的处理逻辑,和登录成功类似,不同的是,登录失败的回调方法里有一个AuthenticationException参数,通过这个异常参数可以获取登录失败的原因,进而给用户一个明确的提示。

配置完成后,使用Postman进行登录测试,如图所示。

在这里插入图片描述

登录请求参数用户名是name,密码是passwd,登录成功后返回用户的基本信息,密码已经过滤掉了。如果登录失败,也会有相应的提示,如图所示。

在这里插入图片描述

6. 注销登录配置

如果想要注销登录,也只需要提供简单的配置即可,代码如下:

@Configuration
public class MyWebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/admin/** ").hasRole("ADMIN")
                .antMatchers("/user/**")
                .access("hasAnyRole('ADMIN','USER') ").antMatchers(" / db/** ")
                .access("hasRole ('ADMIN') and hasRole ('DBA') ").anyRequest()
                .authenticated()
                .and()
                .formLogin()
                .loginPage("/loginPage")
                .loginProcessingUrl("/login")
                .usernameParameter("name")
                .passwordParameter("passwd")
                .successHandler(new AuthenticationSuccessHandler() {
                    @Override
                    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
                        Object principal = authentication.getPrincipal();
                        response.setContentType("application/json; charset=utf-8");
                        PrintWriter out = response.getWriter();
                        response.setStatus(200);
                        Map<String, Object> map = new HashMap<>();
                        map.put("status", 200);
                        map.put("msg", principal);
                        ObjectMapper om = new ObjectMapper();
                        out.write(om.writeValueAsString(map));
                        out.flush();
                        out.close();
                    }
                })
                .failureHandler(new AuthenticationFailureHandler() {
                    @Override
                    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
                        response.setContentType("application/json; charset=utf-8");
                        PrintWriter out = response.getWriter();
                        response.setStatus(401);
                        Map<String, Object> map = new HashMap<>();
                        map.put("status", 401);
                        if (exception instanceof LockedException) {
                            map.put("msg", "账户被锁定,登录失败! ");
                        } else if (exception instanceof BadCredentialsException) {
                            map.put("msg", "账户名或密码输入错误,登录失败! ");
                        } else if (exception instanceof DisabledException) {
                            map.put("msg", "账户被禁用,登录失败! ");
                        } else if (exception instanceof AccountExpiredException) {
                            map.put("msg", "账户已过期,登录失败!");
                        } else if (exception instanceof CredentialsExpiredException) {
                            map.put("msg", "密码已过期,登录失败!");
                        } else {
                            map.put("msg", "登录失败! ");
                        }
                        ObjectMapper om = new ObjectMapper();
                        out.write(om.writeValueAsString(map));
                        out.flush();
                        out.close();
                    }
                }).permitAll()
                .and()
                .logout()
                .logoutUrl("/logout")
                .clearAuthentication(true)
            	.invalidateHttpSession(true)
                .addLogoutHandler(new LogoutHandler() {
                    @Override
                    public void logout(HttpServletRequest req,
                                       HttpServletResponse resp, Authentication auth) {
                    }
                })
                .logoutSuccessHandler(new LogoutSuccessHandler() {
                    @Override

                    public void onLogoutSuccess(HttpServletRequest req,
                                                HttpServletResponse resp, Authentication auth)
                            throws IOException {
                        resp.sendRedirect("/login_page");
                    }
                })
                .and()
                .csrf()
                .disable();
    }
}

代码解释:

  • 第73行表示开启注销登录的配置。
  • 第74行表示配置注销登录请求URL为“/logout”,默认也是“/logout”。
  • 第75行表示是否清除身份认证信息,默认为 true,表示清除。
  • 第76行表示是否使Session失效,默认为true。
  • 第77行配置一个LogoutHandler,开发者可以在LogoutHandler中完成一些数据清除工作,例如Cookie 的清除。Spring Security提供了一些常见的实现,如图所示。

在这里插入图片描述

  • 第83行配置一个LogoutSuccessHandler,开发者可以在这里处理注销成功后的业务逻辑,例如返回一段JSON提示或者跳转到登录页面等。

7. 多个HttpSecurity

如果业务比较复杂,开发者也可以配置多个HttpSecurity,实现对WebSecurityConfigurerAdapter的多次扩展,代码如下:

@Configuration
public class MultiHttpSecurityConfig {
    @Bean
    PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }

    @Autowired
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                .withUser("admin").password("123").roles("ADMIN", "USER").and()
                .withUser("sang").password("123").roles("USER");
    }

    @Configuration
    @Order(1)
    public static class AdminSecurityConfig extends WebSecurityConfigurerAdapter {
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http.antMatcher(" / admin/**").authorizeRequests()
                    .anyRequest().hasRole("ADMIN");
        }

        @Configuration
        public static class otherSecurityConfig extends WebSecurityConfigurerAdapter {
            @Override
            protected void configure(HttpSecurity http) throws Exception {
                http.authorizeRequests()
                        .anyRequest().authenticated().and()
                        .formLogin()
                        .loginProcessingUrl("/login").permitAll()
                        .and()
                        .csrf().disable();
            }
        }
    }
}

代码解释:

  • 配置多个HttpSecurity时,MultiHttpSecurityConfig 不需要继承WebSecurityConfigurerAdapter,在MultiHttpSecurityConfig 中创建静态内部类继承WebSecurityConfigurerAdapter 即可,静态内部类上添加@Configuration注解和@Order 注解,@Order注解表示该配置的优先级,数字越小优先级越大,未加@Order注解的配置优先级最小。
  • 第15到22行配置表示该类主要用来处理“admin/**”模式的URL,其他的URL将在第24到35行配置的HttpSecurity中进行处理。

8. 密码加密

8.1 为什么要加密

2011年12月21日,有人在网络上公开了一个包含600万个 CSDN用户资料的数据库,数据全部为明文存储,包含用户名、密码以及注册邮箱。事件发生后,CSDN在微博、官方网站等渠道发出了声明,解释说此数据库是2009年备份所用的,因不明原因泄露,已经向警方报案,后又在官网发出了公开道歉信。在接下来的十多天里,金山、网易、京东、当当、新浪等多家公司被卷入这次事件中。整个事件中最触目惊心的莫过于CSDN把用户密码明文存储,由于很多用户是多个网站共用一个密码,因此一个网站密码泄露就会造成很大的安全隐患。有了这么多前车之鉴,我们现在做系统时,密码都要加密处理。

8.2 加密方案

密码加密一般会用到散列函数,又称散列算法、哈希函数,这是一种从任何数据中创建数字“指纹”的方法。散列函数把消息或数据压缩成摘要,使得数据量变小,将数据的格式固定下来,然后将数据打乱混合,重新创建一个散列值。散列值通常用一个短的随机字母和数字组成的字符串来代表。好的散列函数在输入域中很少出现散列冲突。在散列表和数据处理中,不抑制冲突来区别数据会使得数据库记录更难找到。我们常用的散列函数有MD5消息摘要算法、安全散列算法( Secure Hash Algorithm)。

但是仅仅使用散列函数还不够,为了增加密码的安全性,一般在密码加密过程中还需要加盐,所谓的盐可以是一个随机数,也可以是用户名,加盐之后,即使密码明文相同的用户生成的密码,密文也不相同,这可以极大地提高密码的安全性。但是传统的加盐方式需要在数据库中有专门的字段来记录盐值,这个字段可能是用户名字段(因为用户名唯一),也可能是一个专门记录盐值的字段,这样的配置比较烦琐。Spring Security提供了多种密码加密方案,官方推荐使用BCryptPasswordEncoder,BCryptPasswordEncoder使用BCrypt 强哈希函数,开发者在使用时可以选择提供 strength和 SecureRandom实例。strength 越大,密钥的迭代次数越多,密钥迭代次数为2^strength。strength取值在4~31之间,默认为10。

8.3 实践

在Spring Boot中配置密码加密非常容易,只需要修改上文配置的 PasswordEncoder这个Bean的实现即可,代码如下:

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

创建BCryptPasswordEncoder时传入的参数10就是strength,即密钥的迭代次数(也可以不配置,默认为10)。同时,配置的内存用户的密码也不再是123了,代码如下:

 @Override
 protected void configure(AuthenticationManagerBuilder auth) throws Exception {
     auth.inMemoryAuthentication()
.withUser("admin").password("$2a$10$t2V6WlZjKt4Lv/ByT7Eu3.7vEwoNQiuawVrUzjdlrY2j1QbmKWSS6").roles("ADMIN", "USER")
.and()
.withUser("suo").password("$2a$10$f6O0vzvn1qjbZucVQP7rVOHNP9Pz//zcruBC2Z5kMZdTTkxZSCa5K").roles("USER");

这里的密码就是使用BCryptPasswordEncoder 加密后的密码,虽然admin和suo加密后的密码不一样,但是明文都是123。配置完成后,使用admin/123或者suo/123就可以实现登录。本案例使用了配置在内存中的用户,一般情况下,用户信息是存储在数据库中的,因此需要在用户注册时对密码进行加密处理,代码如下:

@Service
public class RegService {
    public int reg(String username, String password) {
        BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(10);
        String encodePasswod = encoder.encode(password);
        return saveToDB(username, encodePasswod);//saveToDb是保存到数据库的方法
    }
}

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

9. 方法安全

上文介绍的认证与授权都是基于URL 的,开发者也可以通过注解来灵活地配置方法安全,要使用相关注解,首先要通过@EnableGlobalMethodSecurity注解开启基于注解的安全配置:

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true,securedEnabled = true)
public class webSecurityConfig {
}

代码解释:

  • prePostEnabled=true会解锁@PreAuthorize和@PostAuthorize两个注解,顾名思义,@PreAuthorize注解会在方法执行前进行验证,而@PostAuthorize注解在方法执行后进行验证。
  • securedEnabled=true会解锁@Secured注解。

开启注解安全配置后,接下来创建一个MethodService进行测试,代码如下:

@Service
public class MethodService {
    @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 "user";
    }
}

代码解释:

  • @Secured(“ROLE_ADMIN”)注解表示访问该方法需要ADMIN角色,注意这里需要在角色前加一个前缀“ROLE_”。
  • @PreAuthorize(“hasRole(‘ADMIN’) and hasRole(‘DBA’)”)注解表示访问该方法既需要ADMIN角色又需要DBA角色。
  • @PreAuthorize(“hasAnyRole('ADMIN, ‘DBA,USER’)”)表示访问该方法需要ADMIN、DBA或USER角色。
  • @PreAuthorize和@PostAuthorize中都可以使用基于表达式的语法。

最后,在Controller中注入Service并调用Service中的方法进行测试,这里比较简单,可以自行测试。

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐