SpringSecurity基本使用流程

发布时间 2023-08-24 21:33:22作者: Meer_zyh

本文介绍SpringBoot项目如何整合SpringSecurity,记录使用SpringSecurity完成项目的登录、退出、以及权限管理的相关流程。

1、导包:导入Security,前后端交互用户凭证用的是JWT,需要导入jwt,另外登录需要用到验证码,验证码的存储需要用到redis;

<!-- springboot security -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- jwt -->
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt</artifactId>
  <version>0.9.1</version>
</dependency>
<!-- 验证码 -->
<dependency>  
  <groupId>com.github.axet</groupId>
  <artifactId>kaptcha</artifactId> 
  <version>0.0.9</version>
</dependency>
<!-- redis -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

2、上一步安装好security后,启动项目会自动生成一个登录密码(可以在yml文件中配置账户/密码);客户端发起一个请求,会进入 Security 过滤器链,会跳到security默认的/login页面,输入用户名(user)/密码之后才能访问项目资源;

# 配置默认的账户/密码
spring:
  security:
    user:
      name: user
      password: 123

3、使用security实现用户认证:分为首次登录和访问资源认证

  • 首次登录认证:用户名、密码和验证码完成登录
  • 访问资源认证:请求头携带Jwt进行token认证
首次登录

security是通过UsernamePasswordAuthenticationFilter过滤器校验账户名/密码的,系统如果有验证码功能,则需要在UsernamePasswordAuthenticationFilter过滤器之前添加一个图片验证码过滤器CaptchaFilter

验证码相关代码
config/KaptchaConfig:定义验证码的样式
controller/AuthController:
// 获取验证码接口
@GetMapping("/captcha")
// 生成一个随机的key,将key 和验证码code存入redis,将验证码code转成Base64图片和key一起返回给前端;

使用postman访问/captcha会返回Unauthorized;security默认会阻止访问,需要在config/SecurityConfig中配置白名单;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private static final String[] URL_WHITELIST = {
            "/login",
            "/logout",
            "/captcha",
            "/favicon.ico",
    };

    protected void configure(HttpSecurity http) throws Exception {
        http
            // 登录配置
            .formLogin()	// 使用security默认的表单提交方式
          
            // 配置拦截规则
            .and()
            .authorizeRequests()
            .antMatchers(URL_WHITELIST).permitAll()	// 白名单放行
            .anyRequest().authenticated() // 其他接口拦截
        ;
    }

}
CaptchaFilter:验证码认证过滤器

此时不会校验验证码是否正确,需要在SecurityConfig中添加校验验证码的过滤器CaptchaFilter

// 配置自定义的过滤器
.and()
.addFilterBefore(captchaFilter, UsernamePasswordAuthenticationFilter.class) // 配置captchaFilter过滤器在用户名密码过滤器之前

security/CaptchaFilter.java 做验证码校验的过滤器

common/exception/CaptchaException 验证码出错的时候返回异常信息

添加完过滤器后,发送/login 的post接口,此时需要添加 http.cors().and().csrf().disable(),否则只会接受到get请求,如果验证码错误抛出异常后,需要交给认证失败处理器LoginFailureHandler,否则还会走一个名为/login的get请求;无法将验证码错误异常返给前端,需要在认证失败处理器中将请求错误封装成Result返给前端;

LoginFailureHandler: 认证失败处理器

此时校验验证码错误时可以返回自定义Result错误结果给前端 {"msg":"验证码错误","code":400};同时也给用户名密码错误添加认证失败处理器,用户名密码错误也能处理返回 {"msg":"用户名或密码错误","code":400}

// config/SecurityConfig
// 登录配置
.formLogin()   // 使用security默认的表单提交方式
.failureHandler(loginFailureHandler)
LoginSuccessHandler:认证(登录)成功处理器

此时用户名密码为.yml中配置的user/123,假设验证码是正确的,提交表单登录,会提醒跨域,添加config/CorsConfig并配置SecurityConfig解决跨域

// SecurityConfig
http.cors().and().csrf().disable()

解决完跨域后再次登录,虽然用户名密码正确,但还是会返回security默认的登录页,原因是:登录成功,security默认跳转到/链接,但是又会因为没有权限访问/,所有又会跳到security默认的登录页,所以我们必须取消原先默认的登录成功之后的操作,根据spring security的流程,登录成功之后会走AuthenticationSuccessHandler,因此在登录之前,我们先去自定义这个登录成功操作类LoginSuccessHandler,并在SecurityConfig中添加认证成功处理器;在LoginSuccessHandler中生成jwt(编写JwtUtils类,并在.yml中添加jwt配置信息),返回给前端,用于二次认证;此时就可以返回自定义的Result给前端了。

{
    "msg":"操作成功",
    "code":200,
    "data":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ1c2VyIiwiaWF0IjoxNjg2NjQ1MTA3LCJleHAiOjE2ODcyNDk5MDd9.i4qrA6wtCM8XsS9axppTTTOoA2bmg9vGgmqDWddwzbewNV72aoAgJqOTk9K8BJHk8su-hJHgeOhtnnCIBeTt9A"}
}

此时访问其他接口还是会跳到默认的登录页

身份认证

登录成功之后,前端拿到jwt的信息,也就是token,前端封装axios,在请求header中添加token;后端进行用户身份识别的时候,通过请求头中获取jwt,然后解析出用户名,这样就可以知道是谁在访问接口了,然后判断用户是否有权限操作;

JWTAuthenticationFilter:自定义过滤器用来识别jwt

访问接口时会进入该过滤器校验token

在JWTAuthenticationFilter中,获取到用户名之后封装成UsernamePasswordAuthenticationToken,之后交给SecurityContextHolder参数传递authentication对象,这样后续security就能获取到当前登录的用户信息了,也就完成了用户认证

JwtAuthenticationEntryPoint:认证失败处理器

把认证过滤器和认证失败入口配置到SecurityConfig中

// 异常处理器
.and()
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint) // 认证失败入口配置
  
// 配置自定义的过滤器
.and()
.addFilter(jwtAuthenticationFilter())	// 添加认证过滤器

此时,登录成功后,访问其他的接口需要带上token,在JwtAuthenticationFilter中校验token,如果token为null,往过滤器下一步走,跳到jwtAuthenticationEntryPoint(jwt认证失败处理器),返回错误信息给前端;此外token异常或过期,抛出JwtException;token正确时,则认证成功,返回接口数据;如果有些接口加上了权限认证,则在登录封装token的时候查询用户的权限列表,将用户权限封装到token中,访问接口时会校验token是否有权限,有才能访问,后面会实现权限认证

实现用户名密码查库登录

此时用户名和密码是默认在配置在.yml文件中,需要实现查询数据库登录

将配置文件中默认用户密码删除;使用Security内置的BCryptPasswordEncoder生成一个加密的密码;

// config/SecurityConfig 配置
@Bean
BCryptPasswordEncoder bCryptPasswordEncoder() {   
  return new BCryptPasswordEncoder();
}

在TestController中添加一个生成加密密码的接口,把/test/**添加白名单,访问接口生成加密的密码,将用户名和加密密码添加到数据库中;目前数据库中用户名密码为admin/123

要实现查询数据库登录,需要重新定义这个查用户数据的过程,需要重写UserDetailsService接口;因为security在认证用户身份的时候会调用UserDetailsService.loadUserByUsername()方法,因此我们重写了之后security就可以根据我们的流程去查库获取用户了。然后我们把UserDetailsServiceImpl配置到SecurityConfig中

@Autowired
UserDetailServiceImpl userDetailService;

/*
	* 配置userDetailService注入到security中;委托security实现查询数据库账号密码登录
	* */
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
  auth.userDetailsService(userDetailService);
}

security/UserDetailsServiceImpl

在UserDetailsServiceImpl类中,继承了UserDetailsService,复写了loadUserByUsername方法,首先根据用户名查询是否存在用户,没有则抛出异常;方法返回UserDetails,为了后面我们可能会调整用户的一些数据,需要自定义AccountUser去重写UserDetails,在AccountUser中可以自定义用户字段,AccountUser中封装了用户的权限信息,通过getUserAuthority(userId)方法传入用户id查询用户的权限信息,封装成List后封装到AccountUser中,该过程会自动校验密码(todo);正确则登录成功获取到token,否则返回报错信息。

return new AccountUser(sysUser.getId(), sysUser.getUsername(), sysUser.getPassword(), getUserAuthority(sysUser.getId()));

public List<GrantedAuthority> getUserAuthority(Long userId){
    // 角色(ROLE_admin)、菜单操作权限 sys:user:list
    String authority = sysUserService.getUserAuthorityInfo(userId);  // ROLE_admin,ROLE_normal,sys:user:list,....
    return AuthorityUtils.commaSeparatedStringToAuthorityList(authority);
}

// getUserAuthorityInfo方法中获取用户的角色和菜单权限,返回一个以逗号拼接的字符串
接口权限控制

用户认证成功之后,我们就知道谁在访问系统接口,这时又有一个问题,就是这个用户有没有权限来访问我们这个接口,需要在两个地方赋予用户权限

1、用户登录,调用UserDetailsService.loadUserByUsername()方法时候可以返回用户的权限信息

2、接口调用进行身份认证过滤器时候JWTAuthenticationFilter,需要返回用户权限信息

使用注解实现接口的权限控制,Security内置的权限注解:

@PreAuthorize:方法执行前进行权限检查

@PostAuthorize:方法执行后进行权限检查

@Secured:类似于 @PreAuthorize

// 需要Admin角色权限
@PreAuthorize("hasRole('admin')")
// 添加用户的操作权限
@PreAuthorize("hasAuthority('sys:user:save')")

验证权限的流程:

1、用户登录或者调用接口时候识别到用户,并获取到用户的权限信息

2、注解标识Controller中的方法需要的权限或角色

3、Security通过FilterSecurityInterceptor匹配URI和权限是否匹配

4、有权限则可以访问接口,当无权限的时候返回异常交给AccessDeniedHandler操作类处理

此时访问没有权限的接口,发现还是可以访问,原因是SecurityConfig需要添加开启注解权限校验,否则无法识别@PreAuthorize注解

@EnableGlobalMethodSecurity(prePostEnabled = true)	// 开启注解权限校验

添加后重启,访问无权限的接口,返回{"code":400,"msg":"不允许访问","data":null},最后添加权限不足异常处理器AccessDeniedHandler

JwtAccessDeniedHandler:无权限处理器

config/SecurityConfig添加权限不足异常处理器

// 异常处理器
.and()
.exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint) // 认证失败入口配置            
.accessDeniedHandler(jwtAccessDeniedHandler) // 权限不足

添加后访问无权限的接口,发现并没有走到jwtAccessDeniedHandler过滤器 --- todo

用户退出

添加logoutSuccessHandler

config/SecurityConfig

// 退出配置
.and()
.logout()
.logoutSuccessHandler(jwtLogoutSuccessHandler)

编写退出逻辑jwtLogoutSuccessHandler,返回退出成功信息

完结撒花!

最近看了一篇知乎上大佬讲解SpringSecurity的文章,讲解的比较清楚,贴上链接一起学习:
https://zhuanlan.zhihu.com/p/342755411?utm_medium=social&utm_oi=1343915562263547904