基于SpringSecurity的认证授权

130 阅读13分钟

基于SpringSecurity的认证授权

之前学习boot时,粗略的学习了一下ShiroSpringSecurity,只是会基本的使用,但说到原理,不是很了解,今天刚好给课设添加登录认证授权,系统的学习了一下SpringSecurity的底层原理和定制化配置,确实也是基本掌握了,在此记一笔,方便以后回顾.

说到SpringSeCurity,相信大家都不陌生,说的高大上,是一个跟Springboot良好集成的安全框架,说的易懂一点,其实就是一条过滤器链,向我们不用SpringSeCurity的时候,要实现登录认证授权,就需要自己手写过滤器和拦截器了,确实有点麻烦,那么使用SpringSecurity确实方便很多,下面来看看传统认证流程和SpringSecurity的认证流程有些啥区别:

image-20220514214713055

可以看到,加了框架,在到达Controller之前,需要经过一条过滤器链,这条过滤器链,就是咱们安全的保障,能够最后经过这条链路的,都是"勇士",传统的认证,咱们可以到达Controller,Controller调用Service层,去数据库通过用户名查询用户全部的信息,然后将用户的密码和请求体中的密码进行比较:

  • 若不一致,就返回"用户名或密码错误"的信息.
  • 若一致, 就将用户存入session返回sessionId,或者基于userId生成token令牌返回给前端,后序持久到redis中,代表用户登录了。

这么一听,确实,也可以实现,但为什么还要使用SpringSecurity呢对吧?

框架,就强在它的完整性,它提供了一整套完整的认证授权流程,我们只需要在这个流程基础上,添加上少许自定义配置就可以实现严格的认证授权功能。

说了这么多,下面正式进入正题了,咱们就是要用SpringSecurity实现认证授权,那么认证和授权到底是什么呢?

  • 认证: 就是确定到底你是哪个用户,也就是登录的作用,登录就是为了确定你所扮演的角色。
  • 授权:确定了你是哪个用户后,那么就需要不同角色授予不同的权限,比如:管理员(登录认证为是管理员)具有管理后台的权限,而普通用户就没用该权限。

做了那么多铺垫,下面就正式来揭开SpringSecurity的真面目:

工作流程:

就是在到达Controller之前的一条过滤器链,其中完成了用户认证和授权

image-20220514220329142

  • UsernamePasswordAuthenticationFilter :负责处理我们登录页填写了用户名密码后的登录请求。
  • ExceptionTranslationFilter:处理过滤器中抛出的任何AccessDeniedException(权限异常)和AuthenticationException(认证异常)。
  • FilterSecurityInterceptor:负责权限校验的过滤器。

其他的过滤器就不过多的说明了,主要我们使用需要接触到这三个过滤器。

FilterChainProxy

SpringSecurity添加了一个FilterChainProxy,这个代理过滤器会创建一套自定义的过滤器链,然后执行这一套过滤器链,也就是生成了上面图中的一整条过滤链路,我们来大致看看源码:

@Override
public void doFilter(ServletRequest request, ServletResponse response,
                     FilterChain chain) throws IOException, ServletException {
    ...省略其他代码
    
    // 获取Spring Security的一套过滤器
    List<Filter> filters = getFilters(request);
    // 将这一套过滤器组成Spring Security自己的过滤链,并开始执行
    VirtualFilterChain vfc = new VirtualFilterChain(fwRequest, chain, filters);
    vfc.doFilter(request, response);
    
    ...省略其他代码
}

Dubug看看到底SpringSecurity启动了多少条过滤器:

image-20220514221306105

看的让人头皮发麻,不要怕,让我们继续往下学!全部的精髓都在这一条的过滤器中,掌握了这条过滤器链和其中调用了的一些组件,等于你也就掌握了SpringSecurity。

依赖配置

只需要导入SpringSecurity的启动器就行。

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

导入该依赖后,你再启动程序,会发现啥接口都访问不了了,因为被SpringSecurity保护了,没用自定义配置之前,啥接口都会被拦截掉,相反的会跳转到一个login页面,你需要提供账号(默认为user)和密码(启动项目时控制台会输出一串字符),登陆后才能访问到相应接口。

实际开发中,显然默认配置是不满足我们的需求的,我们就需要自定义一些配置。

自定义配置

很简单,只需要添加一个配置类.

@EnableWebSecurity
//标记该注解,会注入ioc容器,并指定该类为SpringSecurity配置类
public class SecurityConfig extends WebSecurityConfigurerAdapter {}
//该类需要继承WebSecurityConfigurerAdapter,重写其中的一些方法来自定义

登录认证

不管运用什么框架完成登录认证,思想是不变的。

如何判断当前是哪个用户正在使用咱们的系统就是登录认证的最终目的。如果不知道是哪个用户,A下了订单,结果要B来付钱,岂不是很尴尬。判断当前的用户在**SpringSecurity的实现就是Authentication**,它其实就是用户的一种封装,其中包含用户信息和权限信息。

在我们的程序中,需要通过**SecurityContext类(上下文对象)来获取到当前的Authentication**。

这种在一个线程中,横跨若干个方法,但每个方法中都需要使用和传递的对象,就称为上下文(context) ,上下文对象很有必要,要不你需要每个方法都加一个参数来提供这个对象。

而这个上下文对象交给**SecurityContextHolder**来管理,可以通过它来获得上下文对象,进而获得咱们的用户认证信息。

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

调用链路:SecurityContextHolder--->SecurityContext--->Authentication

核心组件

📝Authentication:存储了认证信息,代表当前登录用户

📝SeucirtyContext:上下文对象,用来获取Authentication

📝SecurityContextHolder:上下文管理对象,用来在程序任何地方获取SecurityContext(类似于ThreadLocal)

他们仨的关系是这样的:image-20220514223327577

认证逻辑

之前没用框架的逻辑是这样的:查询用户数据-->判断是否正确-->正确就将用户信息储存在上下文中表示用户登录了.

用了框架也一样,只不过是将用户(Authentication)放入上下文(SeucirtyContext)就能完成认证了:

Authentication authentication = new UsernamePasswordAuthenticationToken(用户名,用户密码,用户的权限集合);
SecurityContextHolder.getContext().setAuthentication(authentication);

这时认证完成后,后面的过滤器链都可以通过获得这个authentication来进行针对性的操作,比如授权等,所以认证过滤器通常是放在过滤器第一位的,要不后面过滤器连authentication也就是用户都不知道,拿屁操作。

那么,我们需要判断这个登录用户是否合法后才能放入上下文,就需要有一个判断流程,这个流程跟不加框架是一样的,都是可以操作调用Service层处理,就像这样:

@RestController
public class LoginController {
    @PostMapping("/login")
    public String login(@RequestBody LoginParam param) {
        if(userService.login(账号,密码)){
            return "账号或密码错误";
        }       
        // 生成一个包含账号密码的认证信息
        Authentication authentication = new UsernamePasswordAuthenticationToken(param.getUsername(), param.getPassword());
        // 将返回的Authentication存到上下文中
        SecurityContextHolder.getContext().setAuthentication(authentication);
        return "登录成功";
    }
}

等于说Service完成了所有判断,但SpringSecurity中也有相应的判断组件供我们使用,更加方便。

如果我们不做操作,会经过**UsernamePasswordAuthenticationFilter**来判断用户名和密码,但是如果是调用它的,就跟刚开始说的,是判断框架自带的账号密码,并不是我们数据库查出来的,没啥用,我们可以参考一下这个过滤器中的源码写法:

    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        if (this.postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        } else {
            String username = this.obtainUsername(request);
            username = username != null ? username : "";
            username = username.trim();
            String password = this.obtainPassword(request);
            password = password != null ? password : "";
            UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
            this.setDetails(request, authRequest);
            //这个是关键:调用了AuthenticationManager其中的authenticate方法完成判断
            return this.getAuthenticationManager().authenticate(authRequest);
        }
    }

所以,咱们自己写,也可以来通过AuthenticationManager其中的authenticate方法来完成判断。

AuthenticationManager

要使用AuthenticationManager,我们先在ioc中添加AuthenticationManager组件.

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    /**
     * 认证配置
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.antMatchers("/login").permitAll() //表示所有人可以访问
    }
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}
​

注意:我们需要对登录接口"/login"放行才行,要不就会经过"残酷"的过滤器链,包括UsernamePasswordAuthenticationFilter,那么自定义登录认证也就失去意义了哦!!

接口定义:

@RestController
public class LoginController {
    @Autowired
    private AuthenticationManager authenticationManager;
    @PostMapping("/login")
    public String login(@RequestBody LoginParam param) {
        // 生成一个包含账号密码的认证信息
        Authentication authentication = new UsernamePasswordAuthenticationToken(param.getUsername(), param.getPassword());
        // AuthenticationManager校验这个认证信息,返回一个已认证的Authentication
        Authentication authentication = authenticationManager.authenticate(authentication);
        // 将返回的Authentication存到上下文中
        SecurityContextHolder.getContext().setAuthentication(authentication);
        return "登录成功";
    }
}

是不是思路清晰了很多,**AuthenticationManager完成了之前userService**的业务操作。

原理:

根据用户名先查询出用户对象(没有查到则抛出异常)👉将用户对象的密码和传递过来的密码进行校验,密码不匹配则抛出异常

这个逻辑没啥好说的,再简单不过了。重点是这里每一个步骤Spring Security都提供了组件

📝是谁执行 根据用户名查询出用户对象 逻辑的呢?用户对象数据可以存在内存中、文件中、数据库中,你得确定好怎么查才行。这一部分就是交由💡UserDetialsService 处理,该接口只有一个方法loadUserByUsername(String username),通过用户名查询用户对象,默认实现是在内存中查询。

📝那查询出来的 用户对象 又是什么呢?每个系统中的用户对象数据都不尽相同,咱们需要确认我们的用户数据是啥样的才行。Spring Security中的用户数据则是由💡UserDetails 来体现,该接口中提供了账号、密码等通用属性。

📝对密码进行校验大家可能会觉得比较简单,if、else搞定,就没必要用什么组件了吧?但框架毕竟是框架考虑的比较周全,除了if、else外还解决了密码加密的问题,这个组件就是💡PasswordEncoder,负责密码加密与校验。

我们可以看下AuthenticationManager校验逻辑的大概源码:

public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    ...省略其他代码
    
    // 传递过来的用户名
    String username = authentication.getName();
    // 调用UserDetailService的方法,通过用户名查询出用户对象UserDetail(查询不出来UserDetailService则会抛出异常)
    UserDetails userDetails = this.getUserDetailsService().loadUserByUsername(username);
    String presentedPassword = authentication.getCredentials().toString();
    
    // 传递过来的密码
    String password = authentication.getCredentials().toString();
    // 使用密码解析器PasswordEncoder传递过来的密码是否和真实的用户密码匹配
    if (!passwordEncoder.matches(password, userDetails.getPassword())) {
        // 密码错误则抛出异常
        throw new BadCredentialsException("错误信息...");
    }
    
    // 注意哦,这里返回的已认证Authentication,是将整个UserDetails放进去充当Principal
    UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(userDetails,
                authentication.getCredentials(), userDetails.getAuthorities());
    return result;
    
    ...省略其他代码
}

流程:UserDetialsService-->UserDetails--->PasswordEncoder。

UserDetials
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserInfo {
    int id;
    private String username;
    private String password;
    private String nickname;
    //权限等级
    private int level;
}
​
@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoginUser implements UserDetails {
    //用户信息
    private UserInfo userInfo;
    //权限集合
    private List<String> permission;
​
    public LoginUser(UserInfo userInfo,List<String> permission) {
        this.userInfo = userInfo;
        this.permission = permission;
    }
​
    private List<GrantedAuthority> authorities;
​
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        authorities = new ArrayList<>();
        for (String s : this.permission) {
            authorities.add(new SimpleGrantedAuthority(s));
        }
        return  authorities;
    }
​
    @Override
    public String getPassword() {
        return this.userInfo.getPassword();
    }
​
    @Override
    public String getUsername() {
        return this.userInfo.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;
    }
}
UserDetialsService

业务实现这个接口,重写其中的loadUserByUsername方法即可。这个方法就执行根据用户名到数据库中查找相应用户,并封装成UserDetails类返回的操作.

@Service
@Transactional
public class UserServiceImpl implements UserService, UserDetailsService {
​
    @Autowired
    UserMapper userMapper;
​
    @Autowired
    RedisUtil redisUtil;
​
    /**
     * 通过用户名 查找用户
     * @param username
     * @param password
     * @return
     */
    @Override
    public UserInfo findUserByUsername(String username) {
        if(StringUtils.isEmpty(username)) {
            throw new RuntimeException("请输入用户名!!");
        }
        LambdaQueryWrapper<UserInfo> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(UserInfo::getUsername, username);
        wrapper.last("limit 1");
​
        UserInfo userInfo = userMapper.selectOne(wrapper);
        if(Objects.isNull(userInfo)) {
            throw new RuntimeException("用户们不存在!!");
        }
        return userInfo;
    }
​
    /**
     * 重写UserDetailService的方法来自定义
     * @param s
     * @return
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        UserInfo user = this.findUserByUsername(s);
        if(user.getLevel() == 1) {
            //1:管理员,具有"user", "admin"权限。
            return new LoginUser(user, new ArrayList<>(Arrays.asList("user", "admin")));
        }else {
            //2:用户
            return new LoginUser(user, new ArrayList<>(Arrays.asList("user")));
        }
    }
}
PasswordEncoder

可以看到**authenticate**方法中使用了密码加密校验,但是框架自带的是{noop}明文校验,明文校验并不安全,所以我们可以配置一下自己的校验器。在开始的配置文件中添加一个加密器到容器中即可。

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    public PasswordEncoder passwordEncoding() {
        return new BCryptPasswordEncoder();
    }
}

授权

说完了认证,授权其实就简单了,只是在**Authentication**类中多添加一个用户的权限集合就行了.也可以通过注解实现,但我并不推荐,注解在后序的维护中及其困难。

image-20220514234823604

最主要的是,这些权限有啥用,肯定是不同权限能够访问不同的资源撒,那么我们需要在配置类中配置对应的权限能够访问哪些接口,接口就是对应的资源了。

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
​
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()    //关闭csrf防止跨域攻击
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)  //不通过Session获取SecurityContext
                .and()
                .authorizeRequests()
                .antMatchers("/login","/register", "/error","/auth", "/comment/getAll", "/foodCategory/**", "/foodMaterial/**", "/introduce/**", "/mainPage/**").permitAll() //表示所有人都可以访问
                .antMatchers("/end/**").hasAuthority("admin")  //表示只有具有"admin"权限才能访问
                .antMatchers("/comment/", "/foodCollect", "/logout", "/user/**", "/update").authenticated() //表示需要登录认证才能访问
                .and()
                .cors();   //允许跨域
    }
}
​

异常自定义处理

如果你自己实战过,可以发现,添加了框架后,认证授权会报很多你之前没有见过的异常,这些是SpringSecurity自定义的异常,由其中的一个过滤器**ExceptionTranslationFilter**处理。

如果我们不自定义异常处理,那么返回给用户的消息就是这样的:

image-20220514233519173

这违背了我们统一消息体返回的原则,那么我们需要自定义配置异常处理:

异常分为认证异常,授权异常,有相应的处理器:

AuthenticationEntryPoint

认证异常处理器,是一个接口,咱们自定义认证异常处理需要实现这个接口。

@Component
public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
        httpServletResponse.setCharacterEncoding("UTF-8");
        httpServletResponse.setContentType("application/json");
        httpServletResponse.getWriter().print(JSONUtil.toJsonStr(Result.fail(ResultCode.NOT_ACCESS.getCode(), ResultCode.NOT_ACCESS.getMsg())));
    }
}

AccessDeniedHandler

授权认证处理器,也是一个接口,自定义授权异常处理需要实现这个接口。

@Component
public class MyAuthorizationHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException {
        httpServletResponse.setCharacterEncoding("UTF-8");
        httpServletResponse.setContentType("application/json");
        httpServletResponse.getWriter().print(JSONUtil.toJsonStr(Result.fail(ResultCode.NOT_AUTH.getCode(), ResultCode.NOT_AUTH.getMsg())));
    }
}

注意:ResultCode是自定义的返回类哈,需要自己写emmm

编写了自己的配置,还需要添加到SpringSecurity的配置中去:

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
​
    @Autowired
    MyAuthenticationEntryPoint myAuthenticationEntryPoint;
    @Autowired
    MyAuthorizationHandler myAuthorizationHandler;
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authenticationEntryPoint(myAuthenticationEntryPoint)
             .accessDeniedHandler(myAuthorizationHandler);
    }
}

那么之后出现了认证或者授权异常,都会进入我们的异常处理类中进行相应处理.

自定义过滤器

前后端分离,通常使用JWT来认证,那么用户登录后,返回的token,之后的请求都需要在请求头中添加token,以便于后端验证。

那么问题是,之前咱们的过滤器都是针对登录认证的,没有应对登录后用户请求的过滤器,无法处理请求中的token,若不处理,进入到UsernamePasswordAuthenticationFilter,又会使用框架自带的账号密码来过滤,就很尴尬,那么怎么办呢?

自定义一个过滤器不就行了嘛,用来专门验证请求的token,如果token合法,就生成**Authentication存入上下文对象中,这样,后面的过滤器调用上下文中的Authentication**,就会知道该用户已经认证过了。

@Component
@Slf4j
public class JWTAuthenticationTokenFilter extends OncePerRequestFilter {
​
    @Autowired
    UserService userService;
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = request.getHeader("Authorization");
​
        if(StringUtils.isEmpty(token)) {
            //放行,后面会报错
            filterChain.doFilter(request,response);
            return;
        }
        
        UserInfo userInfo = userService.findUserByToken(token);
        
        if(userInfo == null) {
            //放行,后面会报错
            filterChain.doFilter(request,response);
            return;
        }
        
        //配置到上下文中
        //授权
        LoginUser loginUser;
        Authentication authentication;
        if(userInfo.getLevel() == 1) {
            loginUser = new LoginUser(userInfo,new LinkedList<>(Arrays.asList("user","admin")));
            authentication = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
            System.out.println(loginUser.getAuthorities());
        }else {
            loginUser = new LoginUser(userInfo,new LinkedList<>(Arrays.asList("user")));
            authentication = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
​
        }
        SecurityContextHolder.getContext().setAuthentication(authentication);
​
        //放行
        filterChain.doFilter(request,response);
    }
}

将这个过滤器配置到过滤器链中去:

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
​
    @Autowired
    JWTAuthenticationTokenFilter jwtAuthenticationTokenFilter;
​
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
    }
}
​

放在**UsernamePasswordAuthenticationFilter过滤之前,替代了默认的认证过滤器。登陆后的用户访问其他接口时,就会带上token**进入到我们自定义的认证过滤器中验证了。

总结

image-20220515002354734