【SpringSecurity】认证授权框架——SpringSecurity使用方法

【SpringSecurity】认证授权框架——SpringSecurity使用方法

1. 概述

Spring Security是一个框架,提供 认证(authentication)、授权(authorization)和保护,以抵御常见的攻击。它对保护命令式和响应式应用程序有一流的支持,是保护基于Spring的应用程序的事实标准。它还提供了与其他库的集成,以简化其使用。

SpringSecurity的详细使用方法可见:Spring Security 中文文档 :: Spring Security Reference (springdoc.cn)

写这篇文章主要是为了复习SpringSecurity的相关知识,在这里也向大家推荐b站up主 三更草堂 ,他讲的SpringSecurity课程非常棒!


2. 准备工作

2.1 引依赖

创建springboot工程中引入这些依赖:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.5.0</version>
</parent>
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    <!--主要是这个依赖-->
     <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
     </dependency>
</dependencies>

2.2 测试

引入好依赖之后就可以启动应用了,我们访问应用的任何一个接口都会跳转到SpringSecurity的一个默认登录页面(如下所示):

image-20230330210017463

SpringSecurity提供了一个默认的登录接口 /login 和退出接口 /logout 。登录接口的默认用户名为 user ,而密码则打印在了应用的控制台:

image-20230330210243936

使用默认的用户名和控制台上的密码就能够登录系统访问接口了。在正常情况下我们需要禁用这个接口,使用我们自定义的接口。


3. 认证

SpringSecurity的认证机制有很多,比如:

  • Username and Password:使用用户名/密码进行认证
  • OAauth 2.0 Login:使用OpenID Connect 和非标准的OAuth 2.0登录(即GitHub)的OAuth 2.0登录。

我们主要了解第一种方式。SpringSecurity认证通过过滤器链实现:

image-20230330212422514

SpringSecurity的过滤器链包含15个过滤器,上图只展示了较为核心的过滤器。

  • SecurityContextPersistenceFilter:过滤器链的入口和出口,保存和清除 SecurityContextHolder 中的 SecurityContext
  • UsernamePasswordAuthenticationFilter:负责收集我们在登陆页面输入的用户名和密码并封装成Authentication对象。
  • ExceptionTranslationFilter:只处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException ,其他异常继续抛出。
  • FilterSecurityInterceptor:负责权限校验的过滤器。

3.1 认证流程

image-20230330212244925

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ailkTJHA-1680426554649)(null)]

注释:

  • Authentication:是一个接口,它的实现类表示当前访问系统的用户,封装了用户相关信息。
  • AuthenticationManager:也是一个接口,声明了认证Authentication的方法
  • UserDetailsService:还是一个接口,加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法。
  • UserDetails:接口,提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回。然后将这些信息封装到Authentication对象中。

3.2 登录校验问题

登录流程

  1. 自定义登录接口替代原有登录接口。

  2. 自定义实现UserDetailService接口的类A,替代原有实现类。

  3. 在类A中中查询数据库进行认证校验。

  4. 校验通过则使用jwt工具类生成token,token生成规则有两种:

    4.1 使用用户id生成token,其余用户信息存入redis。

    4.2 使用用户基本信息的json串(脱敏)生成token,服务端不必存储用户信息。

校验流程

  1. 自定义jwt认证过滤器。

  2. 解析token。

    2.1 解析获得用户id,根据用户id从redis中获得用户信息,存入SecurityContextHolder。

    2.2 解析获得用户信息json串,将其反序列化为用户对象,存入SecurityContextHolder。

在这里我们采用第一种方案,使用redis。


3.3 实现

3.3.1 实现UserDetailsService接口

创建UserDetailsService接口的实现类,在其中对用户进行认证和授权。

@Service
public class UserDetailServiceImpl implements UserDetailsService {
    
    

    @Autowired
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    
    
        //认证
        LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(StringUtils.isNotBlank(username), User::getUserName, username);
        User user = userMapper.selectOne(queryWrapper);
        if (Objects.isNull(user)) {
    
    
            throw new RuntimeException("用户名或密码错误");
        }

        //todo 查询对应的权限信息

        //把数据封装成UserDetails返回
        UserDetails userDetails = org.springframework.security.core.userdetails.User.withUsername(user.getUserName())
                .password(user.getPassword())
                .authorities(new String[]{
    
    })
                .build();
        return userDetails;
    }
}

除了使用security提供的生成UserDetails对象的方法外,我们还可以选择自定义。

我们新创建一个类 LoginUser 实现 UserDetails 接口。

@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoginUser implements UserDetails {
    
    

    private User user;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
    
    
        return null;
    }

    @Override
    public String getPassword() {
    
    
        return user.getPassword();
    }

    @Override
    public String getUsername() {
    
    
        return user.getUserName();
    }

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

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

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

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

使用我们自定义的类去创建UserDetails对象。

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    
    
    //认证
    LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(StringUtils.isNotBlank(username), User::getUserName, username);
    User user = userMapper.selectOne(queryWrapper);
    if (Objects.isNull(user)) {
    
    
        throw new RuntimeException("用户名或密码错误");
    }

    //todo 查询对应的权限信息

    //把数据封装成UserDetails返回
    return new LoginUser(user);
}

3.3.2 密码存储和校验

我们不会在数据库中使用明文的方式存储密码,我们需要存储的是加密之后的密码。

SpringSecurity默认使用的PasswordEncoder要求数据库中的密码格式为:{id}password 。它会根据id去判断密码的加密方式。这种方式不太行,所以我们需要使用BCryptPasswordEncoder去替代原来的PasswordEncoder。

我们只需要定义一个SpringSecurity的配置类,并且让这个类继承 WebSecurityConfigurerAdapter

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
    

    //创建 BCryptPasswordEncoder 对象并注入容器
    @Bean
    public PasswordEncoder passwordEncoder() {
    
    
        return new BCryptPasswordEncoder();
    }
}

3.3.3 自定义登录接口

创建一个控制器 LoginController ,在其中定义一个登录接口:

@RestController
public class LoginController {
    
    

    @Autowired
    private UserService userService;

    @PostMapping("/user/login")
    public ResponseResult login(@RequestBody User user) {
    
    
        return userService.login(user);
    }
}
3.3.3.1 配置

我们需要让SpringSecurity对自定义的接口放行,让用户不用登陆也可以访问。然后在 login(user) 中调用AuthenticationManager的authenticate方法来进行用户认证,所以需要在SpringSecurity配置类中进行以下两点配置:

  1. 对自定义登录接口放行
  2. AuthenticationManager 对象注入容器
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
    

    //创建 BCryptPasswordEncoder 对象并注入容器
    @Bean
    public PasswordEncoder passwordEncoder() {
    
    
        return new BCryptPasswordEncoder();
    }

    //注入AuthenticationManager对象
    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
    
    
        return super.authenticationManagerBean();
    }

    //对登录接口放行
    @Override
    protected void configure(HttpSecurity http) throws Exception {
    
    
        http
                //关闭csrf
                .csrf().disable()
                //不通过Session获取SecurityContext
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                // 对于登录接口 允许匿名访问(未登录时可以访问,登陆后不能访问)
                .antMatchers("/user/login").anonymous()
                // 除上面外的所有请求全部需要鉴权认证
                .anyRequest().authenticated();
    }
}

3.3.3.2 定义登录方法

在用户服务接口的实现类中定义 login(user) 方法:

@Service("userService")
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
    
    


    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    private AuthenticationManager authenticationManager;
    
    @Override
    public ResponseResult login(User user) {
    
    
        //调用 AuthenticationManager 对象的 authenticate() 方法进行用户认证
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(), user.getPassword());
        Authentication authenticate = null;
        try {
    
    
            authenticate = authenticationManager.authenticate(authenticationToken);
        } catch (Exception e) {
    
    
            throw new RuntimeException(e.getMessage());
        }
        if (authenticate == null) {
    
    
            throw new RuntimeException("登陆失败");
        }
        //如果认证未通过,则给出对应提示
        //通过,则使用userid生成一个jwt,jwt存入ResponseResult
        LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
        String userId = loginUser.getUser().getId().toString();
        String jwt = JwtUtil.createJWT(userId);
        Map<String, String> map = new HashMap<>();
        map.put("token", jwt);
        //把用户信息存入redis
        stringRedisTemplate.opsForValue().set("login:" + userId, JSON.toJSONString(loginUser));
        return new ResponseResult(200, "登录成功", map);
    }
}

3.3.4 自定义认证过滤器

我们需要一个认证过滤器,作用是取出每次请求的请求头,看它是否携带 token 。再对 token 进行校验,判断用户的登录状态。

@Component
public class JwtAuthenticactionTokenFilter extends OncePerRequestFilter {
    
    
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    
    
        //获取token
        String token = request.getHeader("token");
        if (StringUtils.isBlank(token)) {
    
    
            //没有token
            //放行,让后面的认证过滤器去拒绝
            filterChain.doFilter(request, response);
            return;
        }
        //解析token
        String userId;
        try {
    
    
            Claims claims = JwtUtil.parseJWT(token);
            userId = claims.getSubject();
        } catch (Exception e) {
    
    
            e.printStackTrace();
            throw new RuntimeException("token非法");
        }
        //拼接redis的key
        String key = "login:" + userId;
        String userJson = stringRedisTemplate.opsForValue().get(key);
        if (StringUtils.isBlank(userJson)) {
    
    
            throw new RuntimeException("用户未登录");
        }
        LoginUser loginUser = JSON.parseObject(userJson, LoginUser.class);
        //存入SecurityContextHolder
        //todo 还未授权
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginUser, null, null);
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        //放行
        filterChain.doFilter(request, response);

    }
}

3.3.4.1 确定过滤器顺序

定义了认证过滤器后,我们还需要确定过滤器的执行顺序,我们必须保证这个过滤器的执行在Security的认证过滤器之前,所以我们把他放在 UsernamePasswordAuthenticationFilter 之前比较合适。

找到之前的配置类,从容器中取出自定义的 JwtAuthenticactionTokenFilter 过滤器并在 configure() 方法中加入最后一行代码。

@Autowired
private JwtAuthenticactionTokenFilter jwtAuthenticactionTokenFilter;

protected void configure(HttpSecurity http) throws Exception {
    
    
    http
            //关闭csrf
            .csrf().disable()
            //不通过Session获取SecurityContext
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests()
            // 对于登录接口 允许匿名访问(未登录时可以访问,登陆后不能访问)
            .antMatchers("/user/login").anonymous()
            // 除上面外的所有请求全部需要鉴权认证
            .anyRequest().authenticated();

    //把自定义认证过滤器放在 UsernamePasswordAuthenticationFilter 过滤波器前面
    http.addFilterBefore(jwtAuthenticactionTokenFilter, UsernamePasswordAuthenticationFilter.class);
}

3.3.5 自定义退出登录接口

退出流程:

  1. 通过 SecurityContextHolder 获得用户id
  2. 利用用户id凭借redis的key值
  3. 通过key值把redis中的用户信息删除

首先定义一个退出接口:

@RequestMapping("/user/logout")
public ResponseResult logout() {
    
    
    return userService.logout();
}

在实现类中定义 logout() 方法:

@Override
public ResponseResult logout() {
    
    
    //获取SecurityContextHolder中的用户id
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    LoginUser loginUser = (LoginUser) authentication.getPrincipal();
    Long id = loginUser.getUser().getId();
    //删除redis中的值
    stringRedisTemplate.delete("login:" + id);
    return new ResponseResult(200, "退出成功");
}

4. 授权

4.1 授权流程

在SpringSecurity中,会使用默认的FilterSecurityInterceptor来进行权限校验。在FilterSecurityInterceptor中会从SecurityContextHolder获取其中的Authentication,然后获取其中的权限信息。当前用户是否拥有访问当前资源所需的权限。


4.2 实现

4.2.1 开启全局权限功能

@EnableGlobalMethodSecurity(prePostEnabled = true)

开启功能之后,我们就可以在接口方法上面加上 @PreAuthorize 注解了。

@RequestMapping("/hello")
@PreAuthorize("hasAuthority('test')")
public String hello() {
    
    
    return "hello";
}

4.2.2 封装权限信息

权限的封装是在登录流程时发生的,所以我们在登录方法中把权限信息封装进入 UserDetails 的实现类中:

@Data
@NoArgsConstructor
public class LoginUser implements UserDetails {
    
    

    private User user;

    //权限字符串集合
    private List<String> permissions;

    //权限集合,不用序列化到redis
    @JSONField(serialize = false)
    private Set<SimpleGrantedAuthority> authorities;

    public LoginUser(User user, List<String> permissions) {
    
    
        this.user = user;
        this.permissions = permissions;
    }

    //将权限字符串集合封装成权限集合
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
    
    
        if (authorities == null) {
    
    
            authorities = permissions.stream()
                    .map(item -> new SimpleGrantedAuthority(item))
                    .collect(Collectors.toSet());
        }
        return authorities;
    }

    @Override
    public String getPassword() {
    
    
        return user.getPassword();
    }

    @Override
    public String getUsername() {
    
    
        return user.getUserName();
    }

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

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

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

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

修改完毕,我们先在登录校验方法 loadUserByUsername(String username) 中查询数据库得到权限字符串集合:

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    
    
    //认证
    LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(StringUtils.isNotBlank(username), User::getUserName, username);
    User user = userMapper.selectOne(queryWrapper);
    if (Objects.isNull(user)) {
    
    
        throw new RuntimeException("用户名或密码错误!");
    }

    //查询对应的权限信息
    List<String> list = menuMapper.selectPermsByUserId(user.getId());
    return new LoginUser(user, list);
}

然后在自定义过滤器中补充权限设置:

UsernamePasswordAuthenticationToken authenticationToken =
        new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());

4.3 自定义权限校验方法

Security提供的权限校验方法比较单一,且不灵活。所以我们可以自定义权限校验方法并且在注解中使用我们的方法。

1)自定义权限校验方法

创建一个类并注入容器中,在其中编写我们的校验方法:

@Component(value = "ex")
public class ExpressionRoot {
    
    

    public boolean hasAuthority(String authority) {
    
    
        //获取当前用户的权限
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        List<String> permissions = loginUser.getPermissions();
        //判断用户是否拥有访问权限
        return permissions.contains(authority);
    }
}

2)在注解中使用我们的方法

@RequestMapping("/hello")
//@PreAuthorize("hasAuthority('system:dept:list')")
@PreAuthorize("@ex.hasAuthority('system:dept:list')")
public String hello() {
    
    
    return "hello";
}

4.4 基于配置的权限控制

我们上面的示例都是基于注解的权限控制,我们也可以通过配置来进行权限的控制,如下所示:

 http.authorizeRequests()
     // 对于登录接口 允许匿名访问
     .antMatchers("/user/login").anonymous()
     //基于配置的权限控制
     .antMatchers("/testCors").hasAuthority("system:dept:list222")
     // 除上面外的所有请求全部需要鉴权认证
     .anyRequest().authenticated();

5. 自定义失败处理

我们还希望在认证失败或者是授权失败的情况下也能和我们的接口一样返回相同结构的json,这样可以让前端能对响应进行统一的处理。要实现这个功能我们需要知道SpringSecurity的异常处理机制。

​ 在SpringSecurity中,如果我们在认证或者授权的过程中出现了异常会被ExceptionTranslationFilter捕获到。在ExceptionTranslationFilter中会去判断是认证失败还是授权失败出现的异常。

​ 如果是认证过程中出现的异常会被封装成AuthenticationException然后调用AuthenticationEntryPoint对象的方法去进行异常处理。

​ 如果是授权过程中出现的异常会被封装成AccessDeniedException然后调用AccessDeniedHandler对象的方法去进行异常处理。

​ 所以如果我们需要自定义异常处理,我们只需要自定义AuthenticationEntryPoint和AccessDeniedHandler然后配置给SpringSecurity即可。


5.1 认证失败处理器

自定义认证失败处理器:

@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
    
    
    @Override
    public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
    
    
        ResponseResult result = new ResponseResult(HttpStatus.UNAUTHORIZED.value(), "用户认证失败请重新登录");
        String json = JSON.toJSONString(result);
        //处理异常
        WebUtils.renderString(httpServletResponse, json);
    }
}

WebUtils 工具类中对响应进行封装,具体方法如下:

public static String renderString(HttpServletResponse response, String string) {
    
    
    try
    {
    
    
        response.setStatus(200);
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");
        response.getWriter().print(string);
    }
    catch (IOException e)
    {
    
    
        e.printStackTrace();
    }
    return null;
}

5.2 权限不足处理器

自定义权限不足处理器:

@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
    
    
    @Override
    public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException {
    
    
        ResponseResult result = new ResponseResult(HttpStatus.FORBIDDEN.value(), "权限不足");
        String json = JSON.toJSONString(result);
        //处理异常
        WebUtils.renderString(httpServletResponse, json);
    }
}

5.3 配置

我们将这两个处理器注入容器,再将其配置到Security中:

@Autowired
private AuthenticationEntryPoint authenticationEntryPoint;

@Autowired
private AccessDeniedHandler accessDeniedHandler;

//对登录接口放行
@Override
protected void configure(HttpSecurity http) throws Exception {
    
    
    //省略其他配置......
    
    //配置异常处理器
    http.exceptionHandling().authenticationEntryPoint(authenticationEntryPoint)//认证失败处理器
            .accessDeniedHandler(accessDeniedHandler);//授权失败处理器
}

6. 跨域问题

只有浏览器和服务端的之间的请求有跨域问题,服务端和服务端之间是不存在跨域问题的。

我们首先在我们的springboot项目中进行跨域配置:

@Configuration
public class CorsConfig implements WebMvcConfigurer {
    
    

    @Override
    public void addCorsMappings(CorsRegistry registry) {
    
    
        // 设置允许跨域的路径
        registry.addMapping("/**")
                // 设置允许跨域请求的域名
                .allowedOriginPatterns("*")
                // 是否允许cookie
                .allowCredentials(true)
                // 设置允许的请求方式
                .allowedMethods("GET", "POST", "DELETE", "PUT")
                // 设置允许的header属性
                .allowedHeaders("*")
                // 跨域允许时间
                .maxAge(3600);
    }
}

由于我们的资源都会收到SpringSecurity的保护,所以想要跨域访问还要让SpringSecurity运行跨域访问。

然后在security的配置类中添加跨域配置:

@Override
protected void configure(HttpSecurity http) throws Exception {
    
    
    //省略其他配置......
    
    //允许跨域
    http.cors();
}

猜你喜欢

转载自blog.csdn.net/Decade_Faiz/article/details/129913597