Sping Security (二): 认证流程

510 阅读7分钟

在上一篇入门原理中,我们介绍了Spring Security的主要流程是 认证授权。这一篇主要介绍其中的 认证流程 ,并完成一次认证流程。

说明: 本系列的文章使用的是 springBoot 2.7.11 (springboot 2x 最后一个稳定版本),对应的 Spring Security 是 5.7.8

完整认证流程

image.png

完整认证流程

security 配置

  • 继承 WebSecurityConfigurerAdapter

    在该类注释中有相关配置示例可以参考,但spring目前已经不推荐这种方式了!

//默认登录方式配置是在 SpringBootWebSecurityConfiguration  中
//@Configuration
public class DeprecatedSecurity extends WebSecurityConfigurerAdapter {

   @Autowired
   private SecurityProperties securityProperties;

   /**
    * 配置url 规则 ,登录方式等
    * @param http
    * @throws Exception
    */
   @Override
   protected void configure(HttpSecurity http) throws Exception {
       //访问 admin 需要鉴权且用户需要有Admin角色 ,
       // 所有url都需要鉴权
       http.authorizeRequests().antMatchers("/admin/**").hasAnyRole("Admin")
           .anyRequest().authenticated()
               .and()
               .formLogin().and()
               .httpBasic();
   }

   /**
    * 配置 用户角色
    * @param auth
    * @throws Exception
    */
   @Override
   protected void configure(AuthenticationManagerBuilder auth) throws Exception {
       auth.inMemoryAuthentication().withUser(securityProperties.getUser().getName()).password("{noop}".concat(securityProperties.getUser().getPassword())).roles("Admin");
//        super.configure(auth);
   }

   /**
    * 配置用户服务
    * 如何获取用户
    * @return
    */
   @Override
   protected UserDetailsService userDetailsService() {

       return super.userDetailsService();
   }
  • 手动实现 SecurityFilterChain

其中关于 httpSecurity的一些配置项也做了详细的注释


@Configuration
@EnableWebSecurity
@Slf4j
public class SecurityConfig {

    @Autowired
    private AuthenticationConfiguration configuration;
    
    @SneakyThrows
    @Bean
    public AuthenticationManager authenticationManagerBean() {
        AuthenticationManager authenticationManager = configuration.getAuthenticationManager();
        return authenticationManager;
    }
    /**
     * url拦截规则
     * 这个拦截链是可以定义多个bean的
     *
     * @param http
     * @return
     * @throws Exception
     */
    @Bean
    public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception {
        //使用由httpSecurityConfiguration 装配的 HttpSecurity 则会紧密的保持原有的过滤器
        http.csrf().disable()//前后端分离项目要关闭 csrf
                //登录接口只允许匿名访问(即未登录状态下才可以访问)
            .authorizeRequests().antMatchers("/user/login").anonymous()
                //登出接口需要鉴权才能访问
            .antMatchers("/user/logout").authenticated()
                //test 在任何情况下都可以访问
            .antMatchers("/user/test", "/error").permitAll()
                //denyAll 在任何情况下都不可以访问
            .antMatchers("/user/denyAll").denyAll()
                //其余接口都需要鉴权
            .anyRequest().authenticated();
                //formLogin 添加了 usernamePasswordAuthenticationFilter  登录页等信息
            //.formLogin();
        return http.build();
    }


    /**
     * url放行规则
     *
     * @return
     */
    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {

        return (web) -> web.ignoring().antMatchers("/html/**");
    }

    /**
     * 必须配置一种加密方式
     *
     * @return
     */
    @Bean
    public PasswordEncoder getPasswordEncoder() {

        return new BCryptPasswordEncoder();
    }


}

自定义加载用户信息

表单登录中需要验证账号密码,其中重要的过滤器就是 UsernamePasswordAuthenticationFilter ,下图就是该过滤器的执行过程。

image.png

表单登录的认证流程

其中UserDetailService 是一个接口,我们可以通过实现该接口的方式来重写加载用户信息的逻辑。

@Service
public class UserDetailServiceImpl implements UserDetailsService {

    @Resource
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 查询用户信息
        User user = userMapper.getUser(username);
        if (Objects.isNull(user)) {
            //抛出异常之后,后续拦截器会继续处理
            throw new BizException("用户名或密码错误");
        }
        //todo 查询对应的权限信息

        LoginUser loginUser = new LoginUser(user);
        return loginUser;
    }

}
@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();
    }

    /**
     * 账号是否未过期
     * @return
     */
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    /**
     * 账号是否未被锁定
     * @return
     */
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    /**
     * 账号是否凭证未过期
     * @return
     */
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    /**
     * 账号是否可用
     * @return
     */
    @Override
    public boolean isEnabled() {
        return true;
    }
}

至此,我们基本上就实现了自己加载用户信息的方式。

自定义token 认证

现在很多应用都实行了前后端分离,使用 token的方式进行鉴权访问。下面我们讲解如何使用自定义token实现security验证。

设计方案

  • 提供一个登录接口,来获取token
  • Token工具类,生成和解析token (userId)
  • 自定义Token过滤器,如果token校验通过,则封装一个已认证对象给其他过滤器继续处理
  • 如果想降低数据库压力,引入redis保存/查询用户信息
  • 一个登出接口

POM 文件

<dependency>
   <groupId>io.jsonwebtoken</groupId>
   <artifactId>jjwt</artifactId>
   <version>0.9.0</version>
</dependency>

需要额外引入 jwt来生成和解析token

登录接口

  • 既然自定义登录接口,则废弃security原有的登录页接口 /login
  • 借助 security 的过滤器进行账户验证
    • 也可以自己查数据库验证
  • 验证通过,则生成 jwt token
  • 用户信息存入 redis(可选)
  • 返回token
@RestController
@RequestMapping("/user")
public class LoginController {

    @Autowired
    private LoginService loginService;

    /**
     * 1.自定义登录接口:
     *      借助 AuthenticationManager 调用 ProviderManager 的方法进行认证,如果认证通过生成 jwt
     *      把用户信息存入redis
     *
     */
    @RequestMapping("/login")
    public Result<Token> login(@RequestBody User user) {

        return loginService.login(user);
    }
}
@Service
public class LoginService {

    @Autowired
    private AuthenticationManager authenticationManager;

    public Result<Token> login(User user) {
        /** 构建 用户密码待验证token */
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
                user.getUserName(),user.getPassword());
        /** 直接将账号密码交给 认证器进行验证 */
        Authentication authenticate = authenticationManager.authenticate(authenticationToken);
        if (Objects.isNull(authenticate)) {
            throw new BizException("用户名或密码错误");
        }
        //认证通过 从认证结果中拿出 完整的User信息 使用userId 生成 jwt
        LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
        Token token = JwtUtils.getJwtToken(loginUser.getUser());
        //todo 将user 存入redis

        return Result.success(token);
    }

}

这里面有几个要注意的点:

  • 在登录服务中,如果借助security进行账户校验,需要获取一个bean AuthenticationManager ,这个bean 需要通过以下方式获取
@Autowired
private AuthenticationConfiguration configuration;

@SneakyThrows
@Bean
public AuthenticationManager authenticationManagerBean() {
    AuthenticationManager authenticationManager = configuration.getAuthenticationManager();
    return authenticationManager;
}
  • LoginService 中,我们需要根据 UserId 生成 token (为什么不用userName呢?其实也可以,但userName容易暴露,userId相对而言更安全一些)时,要使用鉴权过后的 Authentication里去取 LoginUser里的User,而不是将前端参数绑定出来的User对象。
  • 生成UsernamePasswordAuthenticationToken时不要用错构造函数,要用只有两个参数的构造函数,这样返回的认证对象是未认证
  • 最后需要在 security 配置中放行 登录接口的url

token工具类

package com.example.domain.util;

import com.example.domain.entity.Token;
import com.example.domain.entity.User;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.util.StringUtils;

import java.util.Date;

public class JwtUtils {

    private static final long expire = 1000 * 60 * 60  * 24;

    private static final String SECRET = "ukc8BDbRigUDaY6pZFfWus2jZWLP26";
    // private static final String SECRET = "ukc8BDbRigUDaY6pZFfWus2jZWLPHO";

    private static final String TOKEN_ID = "id";

    /**
     * 生成Token
     * @param user
     * @return
     */
    public static Token getJwtToken(User user) {

        Date expireDate = new Date(System.currentTimeMillis() + expire);
        String jwtToken = Jwts.builder()
                .setHeaderParam("type", "jwt")
                .setHeaderParam("alg", "HS2256")
                .setSubject("lin-user")
                .setIssuedAt(new Date())
                .setExpiration(expireDate)
                .claim(TOKEN_ID, user.getId())
                .claim("userName", user.getUserName())
                .signWith(SignatureAlgorithm.HS256, SECRET)
                .compact();

        return Token.builder()
                .token(jwtToken)
                .expireTime(expireDate)
                .build();
    }

    /**
     * 判断token是否存在与有效
     * @Param jwtToken
     */
    public static boolean checkToken(String jwtToken){
        if (StringUtils.hasText(jwtToken)){
            return false;
        }
        try{
            //验证token
            Jwts.parser().setSigningKey(SECRET).parseClaimsJws(jwtToken);
        }catch (Exception e){
            e.printStackTrace();
            return false;
        }
        return true;
    }

    /**
     * 根据token获取会员id
     * @Param request
     */
    public static Integer getUserId(String token){
        Jws<Claims> claimsJws = Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token);
        Claims body = claimsJws.getBody();
        return (Integer) body.get(TOKEN_ID);
    }

}

自定义token过滤器

token 过滤器是用来校验token,然后转换成用户信息,进而封装出一个已认证的认证对象丢给其他过滤器。
很明显这个过滤器应该在 UsernamePasswordAuthenticationFilter 这个过滤器的前面。

流程:

  1. 获取token
  2. 解析 token 中的 userId
  3. 从redis中获取用户信息
  4. 存入 SecurityContextHolder

SecurityContextHolder 里面保存了一个绑定当前 request线程的 SecurityContext 对象,我们往这里面放Authentication对象可以让后续过滤器也可以取出来用。实际上security也就是这样操作的

现在,我们如何写这个过滤器呢? 自定义Filter 有很多种方式,比如

  • implements Filter
  • extends GenericFilter 但这些都会有个问题就是 Filter可能会重跑2次 (加入securityFilterChain 会执行一次,被 代理类引入servlet容器之后也会执行一次),如果这块不清楚可以翻看下前一张中关于容器部分。

推荐 继承 spring的 OncePerRequestFilter 可以保证过滤器只会跑一次

@Component
public class TokenFilter extends OncePerRequestFilter {

    @Resource
    private UserMapper userMapper;

    /**
     * 1.获取token
     * 2.解析 token 中的 userId
     * 3.从redis中获取用户信息
     * 4.存入 SecurityContextHolder
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        String token = request.getHeader("token");
        if (!StringUtils.hasText(token)) {
            //没token 当前过滤器直接放行 (后续由security其他过滤器进行处理)
            filterChain.doFilter(request, response);
            return;
        }
        Integer userId = JwtUtils.getUserId(token);
        //根据userId 获取用户信息
        User user = userMapper.getUserById(Long.valueOf(userId));
        if (Objects.isNull(user)) {
            throw new BizException("用户未登录");
        }
        // 存入 securityContextHolder
        // 这个构造参数会执行 super.setAuthenticated(true) 表示已认证
        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                user.getUserName(), user.getPassword(), null);
        SecurityContextHolder.getContext().setAuthentication(authentication);
        //todo 从redis中获取
        //继续执行后续过滤器
        filterChain.doFilter(request, response);
    }
}

需要注意的事:

  • token 这个字段应该是在http header 里,而不是在接口请求参数里。
  • 构建的 authentication 要正确使用构造函数,返回一个已认证的对象
  • 过滤器执行完token后,需要放行,让过滤器链继续跑

登出接口

用户使用token 进行登出操作。首先要通过security的验证流程,然后删除redis中的用户信息即可。

public Result<String> logout() {
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    //登出接口也需要鉴权 authentication不为null
    LoginUser loginUser = (LoginUser) authentication.getPrincipal();
    Long userId = loginUser.getUser().getId();
    //todo 从redis 中删除对应的用户信息
    return Result.success("登出成功");
}

security 配置

既然使用token登录,则需要在SecurityConfig中进行以下配置

  • 关闭 csrf
http.csrf().disable()
  • 取消 session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
  • 登录接口应该是支持匿名访问(未登录可以访问,已登录不可以访问)
.authorizeRequests().antMatchers("/user/login").anonymous()
  • 登出接口需要鉴权访问
.antMatchers("/user/logout").authenticated()
  • 将自定义的token过滤器加到UsernamePasswordAuthenticationFilter前面
http.addFilterBefore(tokenFilter,UsernamePasswordAuthenticationFilter.class);