2、前后端分离中使用

流程

image-20220519163824885

原生认证流程

image-20220520163029439

在前后端分离中,跳过了UsernamePasswordAuthenticationFilter,直接在接口中调用AuthenticationManagerauthenticate方法进行校验

需要解决的问题

1、Spring Security自带登录页面,而前后端分离项目不需要,我们只需要提供登录接口就可以了

2、登录成功应该响应token而不是Session+cookie

3、不再是传统ssm中的校验逻辑,而是需要我们自己写逻辑去校验

使用步骤

1、添加依赖

<!-- springboot依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

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

<!-- mybatis-plus依赖 -->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>${mybatis-plus.version}</version>
</dependency>

<!-- redis依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

2、建表,写crud

创建用户表(user)以及用户表的CRUD,使用MyBatisPlus,我这里user的用户名字段是email

3、配置用户名查询服务

实现UserDetail,对登录实体的方法进行实现

@Data
@AllArgsConstructor
public class LoginUser implements UserDetails {

    private User user;

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

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

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

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

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

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

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

实现UserDetailsService,编写查询逻辑

@Service
public class LoginUserDetailService implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
        // 此处就可以从数据库根据userName查询用户信息
        QueryWrapper<User>  queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("email",userName);
        User user = userMapper.selectOne(queryWrapper);
        System.out.println(user);
        if (Objects.isNull(user)){
            throw new UsernameNotFoundException("用户名不存在");
        }
        // 此处查询用户的权限

        // 将查询到的用户封装进LoginUser
        return new LoginUser(user);
    }
}

4、实现账号密码认证

在SecurityConfig中配置密码加密器和注入AuthenticationManager

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    /**
     * 注入bcrypt对密码进行加密
     * @return
     */
    @Bean
    public PasswordEncoder setPasswordEncoder(){
        return new BCryptPasswordEncoder(10);
    }
    
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

实现登录接口

@RestController
@RequestMapping("/user")
public class UserController {

    @Autowired
    private UserService userService;

    @PostMapping("/login")
    public ResultVO login(@RequestBody User user){
        try {
            String token =  userService.login(user);
            return ResultVO.success("登录成功",token);
        }catch (BadCredentialsException e){
            return ResultVO.failed("邮箱或密码错误,请重新输入!");
        }catch (LockedException e){
            return ResultVO.failed("账号被锁定,请联系管理员进行处理!");
        }catch (DisabledException e){
            return ResultVO.failed("用户已失效,请联系管理员进行处理!");
        }catch (Exception e){
            e.printStackTrace();
            return ResultVO.failed("登陆失败,服务器异常!");
        }
    }
}
@Service
public class UserService {

    @Autowired
    private AuthenticationManager authenticationManager;

    public String login(User user) {
        // AuthenticationManager进行用户认证
        // 创建一个认证对象,第一个参数是用户名,第二个参数是密码
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getEmail(),user.getPassword());
        //manager的认证方法返回值也是一个认证对象,如果认证失败会抛出异常
        Authentication authenticate = authenticationManager.authenticate(authenticationToken);
        //认证成功的话,可以从返回的认证对象中获取LoginUser
        LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
        //创建一个永久的jwt给用户,自己在redis中维护用户名对应jwtid,用于续签和登出
        String jwtId = UUID.randomUUID().toString();
        String token = JwtUtil.createToken(loginUser.getUsername(), -1, jwtId);
        //将token存入Redis
        RedisFactory.select(0).opsForValue().set(loginUser.getUsername(),jwtId);
        return token;
    }
}

在SecurityConfig中配置登录路径不拦截

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            	.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and() //这个非常重要,关闭使用session
                .csrf().disable() //关闭csrf
                .authorizeRequests() // 认证请求
                .antMatchers("/user/login").permitAll() // 定义此路径所有人都可以访问
                .anyRequest().authenticated(); // 任何请求都需要身份验证
    }
}

5、配置Token认证过滤器

编写Token认证过滤器,对已登录用户的token进行认证,注意:这里建议继承框架提供的OncePerRequestFilter,避免一个请求多次过滤

@Component
public class TokenAuthFilter extends OncePerRequestFilter {

    @Autowired
    private RoleMapper roleMapper;

    @Override
    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
        // 从请求头中获取jwt
        String authToken = httpServletRequest.getHeader("auth-token");
        // 如果请求头没有token,直接放行,交给下面的过滤器处理
        // 如果当前请求的接口需要认证或权限,下面的过滤器会拦截
        if (Objects.isNull(authToken) || !StringUtils.hasText(authToken)){
            filterChain.doFilter(httpServletRequest,httpServletResponse);
            return;
        }
        // 验证token,以及redis中是否存在该token
        if (JwtUtil.verifyToken(authToken)){
            Map<String, String> payload = JwtUtil.getPayload(authToken);
            String id = payload.get("id");
            String sub = payload.get("sub");
            String s = RedisFactory.select(0).opsForValue().get(sub);
            // 验证jwt中的id和redis中的id是否一致
            if (!Objects.isNull(s) && s.equals(id)) {
                //完成续签,刷新token的有效时间
                RedisFactory.select(0).expire(sub,UserService.JWTID_LIVE, TimeUnit.SECONDS);
                //获取用户权限信息
                List<String> list = roleMapper.selectUserRolesByEmail(sub);
                List<SimpleGrantedAuthority> authorities = new ArrayList<>();
                for (String authority : list){
                    authorities.add(new SimpleGrantedAuthority(authority));
                }
                // 创建一个认证对象,第一个参数为用户信息,第三个参数为用户权限
                UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(sub,null,authorities);
                // 传入认证对象到SecurityContextHolder中,证明该用户已经认证
                SecurityContextHolder.getContext().setAuthentication(token);
            }
        }
        // 放行
        filterChain.doFilter(httpServletRequest,httpServletResponse);
    }
}

在SecurityConfig中配置Token过滤器

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private TokenAuthFilter tokenAuthFilter;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http	
            	.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and() //这个非常重要,关闭使用session
                .csrf().disable() //关闭csrf
                .authorizeRequests() // 认证请求
                .antMatchers("/user/login").permitAll() // 定义此路径所有人都可以访问
                .anyRequest().authenticated(); // 任何请求都需要身份验证
        // 配置在内置用户名密码认证过滤器之前
        http.addFilterBefore(tokenAuthFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

6、退出登录

@RestController
@RequestMapping("/user")
public class UserController {

    @Autowired
    private UserService userService;

    /**
     * 退出登录
     * @return
     */
    @PostMapping("/logout")
    public ResultVO logout(){
        try {
            userService.logout();
            return ResultVO.success("退出登录成功");
        }catch (Exception e){
            return ResultVO.failed("退出登录失败,服务器异常");
        }
    }
}
@Service
public class UserService {

    /**
     * 退出登录
     */
    public void logout() {
        // 在token认证过滤器里面已经将用户信息存入了SecurityContextHolder
        // 从SecurityContextHolder取出用户信息
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        // 我们在过滤器中存入的是邮箱,所以获取也是邮箱
        String email = (String) authentication.getPrincipal();
        RedisFactory.select(0).delete(email);
    }
}

7、授权

思路就是,开启注解,然后在Token认证过滤器中,将用户权限查出来,放到UsernamePasswordAuthenticationToken的第三个参数里就可以了

配置其他登录方式

这里以邮件验证码为例

1、配置过滤器

可以仿照原生的UsernamePasswordAuthenticationFilter来写

注意:如果是前后端分离项目,由于验证是自己在service中手动调用、跳过了过滤器,也就不需要这个过滤器

public class EmailCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter{


    /**
     * 表示拦截/user/loginByEmail地址的post请求
     */
    public EmailCodeAuthenticationFilter() {
        super(new AntPathRequestMatcher("/user/loginByEmail","POST"));
    }

    @Override
    public void setAuthenticationManager(AuthenticationManager authenticationManager) {
        super.setAuthenticationManager(authenticationManager);
    }

    /**
     * 从请求中获取参数,封装为EmailCodeAuthenticationToken然后调用authenticate方法进行认证
     * @param request
     * @param response
     * @return
     * @throws AuthenticationException
     * @throws IOException
     * @throws ServletException
     */
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        //如果不是post请求,直接异常
        if (!"POST".equals(request.getMethod())) {
            throw new AuthenticationServiceException("请求方式有误: " + request.getMethod());
        }
        //如果请求的参数格式不是json,直接异常
        if (!request.getContentType().equals(MediaType.APPLICATION_JSON_VALUE)){
            throw new AuthenticationServiceException("参数不是json:" + request.getMethod());
        }
        //从请求体中获取参数
        String email = "";
        String code = "";
        try {
            Map<String,String> map = JSONObject.parseObject(String.valueOf(request.getInputStream()),Map.class);
            email = map.get("email");
            code = map.get("code");
        } catch (IOException e) {
            throw new AuthenticationServiceException("参数不对:" + request.getMethod());
        }
        email = email.trim();
        //封装为EmailCodeAuthenticationToken
        EmailCodeAuthenticationToken token = new EmailCodeAuthenticationToken(email,code);
        //进行验证
        return this.getAuthenticationManager().authenticate(token);
    }
}

2、编写认证对象

可以仿照原生的UsernamePasswordAuthenticationToken来写,这个类主要封装了验证链过程中需要的主体、是否验证通过等信息

public class EmailCodeAuthenticationToken extends AbstractAuthenticationToken {

    /**
     * email地址
     */
    private final Object email;
    /**
     * 验证码
     */
    private final Object code;

    public EmailCodeAuthenticationToken(Object email, Object code) {
        super(null);
        this.email = email;
        this.code = code;
        super.setAuthenticated(false);
    }
    public EmailCodeAuthenticationToken(Object email, Object code,Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.email = email;
        this.code = code;
        super.setAuthenticated(true);
    }

    @Override
    public Object getCredentials() {
        return this.code;
    }

    @Override
    public Object getPrincipal() {
        return this.email;
    }
}

3、配置验证逻辑

可以仿照原生的DaoAuthenticationProvider来写

这个类有两个方法

authenticate这个方法接收一个Authentication的参数,用来对传入的主体信息进行验证

supports这个方法,主要用于框架分析不同的Authentication实现类,使用哪个Provider进行验证

public class EmailCodeAuthenticationProvider implements AuthenticationProvider {

    private UserDetailsService userDetailsService;

    public EmailCodeAuthenticationProvider(UserDetailsService service){
        this.userDetailsService = service;
    }

    /**
     * 验证码认证逻辑
     * @param authentication
     * @return
     * @throws AuthenticationException
     */
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        EmailCodeAuthenticationToken token = (EmailCodeAuthenticationToken) authentication;
        //从token中获取email和验证码code
        String email = (String) token.getPrincipal();
        String code = (String) token.getCredentials();
        //从redis中查询该用户的验证码
        String s = RedisFactory.select(1).opsForValue().get(email);
        if (Objects.isNull(s) || !code.equals(s)){
            throw new BadCredentialsException("验证码错误");
        }
        // 验证成功从redis删除验证码保证安全
        RedisFactory.select(1).delete(email);
        // 验证成功就调用我们自己写的LoginUserDetailService来查询用户的相关信息
        LoginUser loginUser = (LoginUser) userDetailsService.loadUserByUsername(email);
        // 将用户信息封装到EmailCodeAuthenticationToken中返回
        EmailCodeAuthenticationToken result = new EmailCodeAuthenticationToken(loginUser,null, loginUser.getAuthorities());
        return result;
    }

    /**
     * 判断是上面 authenticate 方法的 authentication 参数,是哪种类型
     * 如果是EmailCodeAuthenticationToken就使用该provider进行验证
     * @param aClass
     * @return
     */
    @Override
    public boolean supports(Class<?> aClass) {
        return (EmailCodeAuthenticationToken.class.isAssignableFrom(aClass));
    }
}

4、配置到框架

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private LoginUserDetailService loginUserDetailService;

    /**
     * 配置自定义验证逻辑
     * @param auth
     * @throws Exception
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(loginUserDetailService).passwordEncoder(setPasswordEncoder());
        auth.authenticationProvider(emailCodeAuthenticationProvider());
    }
    /**
     * 注入bcrypt对密码进行加密
     * @return
     */
    @Bean
    public PasswordEncoder setPasswordEncoder(){
        return new BCryptPasswordEncoder(10);
    }
    /**
     * 注入邮件验证码登录认证provider
     * @return
     */
    @Bean
    public AuthenticationProvider emailCodeAuthenticationProvider(){
        EmailCodeAuthenticationProvider provider = new EmailCodeAuthenticationProvider(loginUserDetailService);
        return provider;
    }
}

5、使用

使用的时候和上面账号密码验证的login方法一样,只不过传入authenticationManager.authenticate()方法的认证对象应该使用自定义的EmailCodeAuthenticationToken类型