SpringBoot 学习笔记_安全管理 —— Spring Security 配置

声明:

本次学习参考 《SpringBoot + Vue 开发实战》 · 王松(著) 一书。

本文的目的是记录我学习的过程和遇到的一些问题以及解决办法,其内容主要来源于原书。

如有侵权,请联系我删除


文章目录

  • SpringBoot 学习笔记_安全管理 —— Spring Security 配置
  • SpringBoot 安全管理 —— Spring Security 基本配置
  • 基本用法
  • 配置用户名和密码
  • 基于内存的认证
  • HttpSecurity
  • 登录表单详细配置
  • 注销登录配置
  • 多个 HttpSecurity
  • 密码加密
  • 方法安全


Java 中常见的安全框架有 Shiro 和 Spring Security。 Shiro 是一个轻量级安全管理框架,提供了认证、授权、会话管理、密码管理、缓存管理等功能;Spring Security 是一个相对复杂的安全管理框架,功能比 Shiro 更加强大,权限控制细粒度更高,对 OAuth 2 的支持也更友好,其源于 Spring 家族,和 Spring 框架可以无缝整合,且 SpringBoot 也提供了自动化配置方案。

SpringBoot 安全管理 —— Spring Security 基本配置

基本用法

  • 创建 SpringBoot 项目,添加依赖
<!--    添加 Spring Security 依赖    -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
  • 添加 Controller
public class HelloController {
    @GetMapping("/hello")
    public String hello(){
        return "Hello";
    }
}
  • 启动项目测试
    http://localhost:8080/login会自动跳转到 Spring Security 提供的登录页面。默认用户名 user, 默认密码随机生成在启动日志中(如: Using generated security password: 3d3cfaef-cde0-40c6-a023-0be1bb9a7fe4)。

配置用户名和密码

默认的用户名为 user, 默认密码为随机生成的。当然,也可以在 applicatioin.properties 中配置默认的用户名和密码及角色

spring.security.user.name=sang
spring.security.user.password=123456
spring.secutity.user.roles=admin

基于内存的认证

也可以自定义类继承 WebSecurityConfigurerAdapter,进而对 Spring Security 更多自定义配置。

@Configuration
// 自定义 MyWebSecurityConfig 继承 WebSecurityConfigurerAdapter
public class MyWebSecurityConfig extends WebSecurityConfigurerAdapter {
    // 引入密码的加密方式。必须指定一种。
    @Bean
    PasswordEncoder passwordEncoder() {
        // 这里使用 NoOpPasswordEncode,即不对密码进行加密。该方法因安全性不足够已被标记为过时
        return NoOpPasswordEncoder.getInstance();
    }

    // 重写 configure 方法
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                // 基于内存的用户配置角色时不需要加 "ROLE_" 前缀
                // 用户一:用户名:root, 密码 123456, 具备 ADMIN 和 DBA 角色
                .withUser("root").password("123456").roles("ADMIN", "DBA")
                .and()
                // 用户二:用户名:admin, 密码 123456, 具备 ADMIN 和 DBA 角色
                .withUser("admin").password("123456").roles("ADMIN", "USER")
                .and()
                // 用户三:用户名:sang, 密码 123456, 具备 USER 角色
                .withUser("sang").password("123").roles("USER");
    }
}

随着安全性的要求不断提高,之前默认的密码编码器 NoOpPasswordEncoder 已经不被推荐了,取代它的是 DelegatingPasswordEncoder,具体区别推荐访问

如果返回 Bad credentials 错误,一般是用户名和密码错误导致,如果密码有设定加密规则,也需要考虑。

HttpSecurity

如上配置已经可以实现认证功能,但是受保护的资源都是默认的,还不能根据实际情况进行角色管理。如果想实现特定的管理,需要重写 WebSecurityConfigurerAdapter 中的另一个方法

@Configuration
// 自定义 MyWebSecurityConfig 继承 WebSecurityConfigurerAdapter
public class MyWebSecurityConfig extends WebSecurityConfigurerAdapter {
    // 引入密码的加密方式。必须指定一种。
    @Bean
    PasswordEncoder passwordEncoder() {
        // 这里使用 NoOpPasswordEncode,即不对密码进行加密。该方法因安全性不足够已被标记为过时
        return NoOpPasswordEncoder.getInstance();
        //return  new DelegatingPasswordEncoder(null, null);
    }

    /**
     * 重写 configure 方法,配置用户角色
     * @param auth
     * @throws Exception
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                // 基于内存的用户配置角色时不需要加 "ROLE_" 前缀
                // 用户一:用户名:root, 密码 123456, 具备 ADMIN 和 DBA 角色
                .withUser("root").password("123456").roles("ADMIN", "DBA")
                .and()
                // 用户二:用户名:admin, 密码 123456, 具备 ADMIN 和 DBA 角色
                .withUser("admin").password("123456").roles("ADMIN", "USER")
                .and()
                // 用户三:用户名:sang, 密码 123456, 具备 USER 角色
                .withUser("sang").password("123").roles("USER");
    }

    /**
     * 重写 configure 方法,根据实际情况配置角色与受保护资源关系
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 开启 HttpSecurity 的配置
        http.authorizeRequests()
                // 用户访问 /admin/** 模式的 URL 必须具备 ADMIN 角色
                .antMatchers("/admin/**")
                .hasRole("ADMIN")

                // 用户访问 /user/** 模式的 URL 必须具备 ADMIN 或者 USER 角色
                .antMatchers("/user/**")
                .access("hasAnyRole('ADMIN', 'USER')")

                // 用户访问 /db/** 模式的 URL 必须具备 ADMIN 和 DBA 角色
                .antMatchers("/db/**")
                .access("hasRole('ADMIN') and hasRole('DBA')")

                // 除了以上指定的 URL 模式之外,用户访问其他 URL 必须认证(登录)后
                .anyRequest()
                .authenticated()

                // 开启表单登录。即默认的登录页面
                .and()
                .formLogin()
                // 配置登录接口为 /login   即可以直接调用 /login 接口发起 POST 请求进行登录。用户名命名为 username,密码命令为 password
                .loginProcessingUrl("/login")
                // 表示和登录相关接口不需要认证即可访问
                .permitAll()

                // 关闭 CSRF
                .and()
                .csrf()
                .disable();
    }
}

接下来,在 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";
    }
}

登录表单详细配置

上面的配置,一直使用的是 Spring Security 的默认登录界面,登录成功后也是默认跳转。在前后端分离的模式中,登录成功后就不需要默认的跳转,而是需要返回 JSON 数据。要实现该功能,需要继续完善配置

@Configuration
// 自定义 MyWebSecurityConfig 继承 WebSecurityConfigurerAdapter
public class MyWebSecurityConfig extends WebSecurityConfigurerAdapter {
    // 引入密码的加密方式。必须指定一种。
    @Bean
    PasswordEncoder passwordEncoder() {
        // 这里使用 NoOpPasswordEncode,即不对密码进行加密。该方法因安全性不足够已被标记为过时
        return NoOpPasswordEncoder.getInstance();
        //return  new DelegatingPasswordEncoder(null, null);
    }

    /**
     * 重写 configure 方法,配置用户角色
     *
     * @param auth
     * @throws Exception
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                // 基于内存的用户配置角色时不需要加 "ROLE_" 前缀
                // 用户一:用户名:root, 密码 123456, 具备 ADMIN 和 DBA 角色
                .withUser("root").password("123456").roles("ADMIN", "DBA")
                .and()
                // 用户二:用户名:admin, 密码 123456, 具备 ADMIN 和 DBA 角色
                .withUser("admin").password("123456").roles("ADMIN", "USER")
                .and()
                // 用户三:用户名:sang, 密码 123456, 具备 USER 角色
                .withUser("sang").password("123456").roles("USER");
    }

    /**
     * 重写 configure 方法,根据实际情况配置角色与受保护资源关系
     *
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 开启 HttpSecurity 的配置
        http.authorizeRequests()
                // 用户访问 /admin/** 模式的 URL 必须具备 ADMIN 角色
                .antMatchers("/admin/**")
                .hasRole("ADMIN")

                // 用户访问 /user/** 模式的 URL 必须具备 ADMIN 或者 USER 角色
                .antMatchers("/user/**")
                .access("hasAnyRole('ADMIN', 'USER')")

                // 用户访问 /db/** 模式的 URL 必须具备 ADMIN 和 DBA 角色
                .antMatchers("/db/**")
                .access("hasRole('ADMIN') and hasRole('DBA')")

                // 除了以上指定的 URL 模式之外,用户访问其他 URL 必须认证(登录)后
                .anyRequest()
                .authenticated()

                /* *******  详细的配置  ******* */

                .and()
                // 开启表单登录
                .formLogin()
                // 指定登录页面
                .loginPage("/login_page")
                // 指定登录请求处理接口
                .loginProcessingUrl("/login")
                // 自定义认证所需用户名参数名。默认 username
                .usernameParameter("username")
                // 自定义认证所需密码参数名。默认 password
                .passwordParameter("password")
                // 自定义登录成功处理逻辑
                .successHandler(new AuthenticationSuccessHandler() {
                    @Override
                    public void onAuthenticationSuccess(HttpServletRequest req,
                                                        HttpServletResponse res,
                                                        Authentication auth)
                            throws IOException {
                        Object principal = auth.getPrincipal();
                        res.setContentType("application/json;charset=utf-8");
                        PrintWriter out = res.getWriter();
                        res.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 req,
                                                        HttpServletResponse res,
                                                        AuthenticationException e)
                            throws IOException, ServletException {
                        res.setContentType("application/json;charset=utf-8");
                        PrintWriter out = res.getWriter();
                        res.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 if (e instanceof CredentialsExpiredException)
                            map.put("msg", "密码已过期,登录失败");
                        else
                            map.put("msg", "登录失败");
                        ObjectMapper objectMapper = new ObjectMapper();
                        out.write(objectMapper.writeValueAsString(map));
                        out.flush();
                        out.close();
                    }
                })
                //登录相关接口不需要认证即可访问
                .permitAll()
                // 关闭 CSRF
                .and()
                .csrf()
                .disable();

        /* *******  详细的配置  ******* */
    }
}

使用 postman 测试

spring boot actuator 安全校验 spring boot安全管理_自定义


spring boot actuator 安全校验 spring boot安全管理_java_02

注销登录配置

只需要继续在上面配置文件中追加配置

...
// 注销登录的相关配置
.and()
// 开启注销登录配置
.logout()
// 配置注销登录请求 URL,默认 /logout
.logoutUrl("/logout")
// 清除身份认证信息,默认 true
.clearAuthentication(true)
// 使 Session 失效,默认 true
.invalidateHttpSession(true)
// 自定义注销时的操作。如 清除 Cookies 等,Spring Security 默认有提供一些常见实现
.addLogoutHandler(new LogoutHandler() {
    @Override
    public void logout(HttpServletRequest req,
                       HttpServletResponse res,
                       Authentication auth) {

    }
})
// 自定义注销成功后的操作。如返回 JSON 或页面跳转。
.logoutSuccessHandler(new LogoutSuccessHandler() {
    @Override
    public void onLogoutSuccess(HttpServletRequest req,
                                HttpServletResponse res,
                                Authentication auth) throws IOException, ServletException {
        // 跳转到登录页面
        res.sendRedirect("/login_page");
    }
})
...

多个 HttpSecurity

在一些复杂业务中,可以配置多个 HttpSecurity 实现对 WebSecurityConfigurerAdapter 的多次扩展

  • 配置多个 HttpSecurity 时,配置类不需要继承 WebSecurityConfigurerAdapter,只需在配置类内部创建静态内部类继承 WebSecurityConfigurerAdapter,然后加上 @Configuration 注解和 @Order 注解。@Order 注解表示优先级,值越小,优先级越高
@Configuration
public class MultiHttpSecurityConfig {
	@Bean
	PasswordEncoder passwordEncoder() {
		return NoOpPasswordEncoder.getInstance();
	}

	@Autowired
	public void configure (AuthenticationManagerBuilder auth) throws Excpetion {
		auth.inMemoryAuthentication()
			.withUser("admin").password("123456").roles("ADMIN")
			.and()
			.withUser("sang").password("123456").roles("USER");
	}

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

	/**
	 * 其他模式 URL 处理
	 */
	@Configuration
	@Order(2)
	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();
		}
	}
}

密码加密

前面的所有例子都是使用了 NoOpPasswordEncoder.getInstance() 密码编码器,也提到过该方法是不进行加密的,因为安全性低,已经被标记为过时。

想要对密码进行加密,可以在 Config 文件中配置密码加密规则,如 MD5 加密、BCrypt 强哈希加密等等,如:

@Bean
PasswordEncoder passwordEncoder() {
	return new BCryptPasswordEncoder(10); // 这里 10 表示密钥迭代次数,默认为 10
}

同时,配置内存中的用户密码也不能再是明文的 123456 了,而要改为密文。

但是,实际项目中,大多情况下用户都是通过手动注册,然后将密码存储到数据库中,这种场景,最方便的就是创建一个 Service 用来对用户信息加密,并存储到 DB

@Service
public class RegService {
    public int reg(String username, String password){
        BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(10);
        String encodePassword = encoder.encode(password);
        // 这里将加密后的用户名和密文保存到数据库。返回保存是否成功标识。
        return 1;
    }
}

方法安全

上述的认证和授权都是基于 URL 的,有些需求可能会要求认证精确到某个方法,所以就需要使用 @EnableGlobalMethodSecurity 注解开启基于注解的安全配置。

@Configuration

// 开启基于注解的安全配置
// prePostEnable = true 解锁 @PreAuthorize 和 @PostAuthorize 注解。分别是在方法执行前和方法执行后进行验证。
// securedEnabled = true 解锁 @Secured 注解
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)

public class MyWebSecurityConfig extends WebSecurityConfigurerAdapter {
	...
}

开启注解安全配置后,创建一个 MethodService 进行测试

@Service
public class MethodService {

    // 访问该方法需要 ADMIN 角色。这里注意角色前需要前缀 ROLE_
    @Secured("ROLE_ADMIN")
    public String admin(){
        return "Hello Admin";
    }

    // 访问该方法需要 ADMIN 和 DBA 双角色。
    @PreAuthorize("hasRole('ADMIN') and hasRole('DBA')")
    public String dba(){
        return "Hello DBA";
    }

    // 访问该方法需要 ADMIN 或者 DBA 或者 USER 角色
    @PreAuthorize("hasAnyRole('ADMIN', 'DBA', 'USER')")
    public String user(){
        return "Hello USER";
    }
}

创建 Controller 测试

@RestController
public class MethodController {

    @Autowired
    MethodService service;

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

    @GetMapping("/method/dba")
    public String dba(){
        return service.dba();
    }

    @GetMapping("/method/user")
    public String user(){
        return service.user();
    }
}

使用 sang 用户登录(角色为 USER),访问 /method/admin/method/dba 返回 403 错误,访问 /method/user 返回 Hello 字样,说明配置成功。

  • 如果遇到项目启动后无法找到页面或重定向次数过多错误:
  • 先检查是否配置了自定义的登录页面: .loginPage("/login_page") 却没有创建该 html 文件,可以自定义创建登录页面的 html 文件,或者暂时注释掉该行,使用 Spring Security 提供的默认登录页面