Spring Security前后端分离配置以及自定义图片验证码和短信验证码登录功能

1,712 阅读8分钟

自定义图片验证和验证码验证

Spring Security原理

绿:检查请求中是否包含这些信息

蓝:处理异常

橙:决定该请求是否能访问到服务

自定义登录

原始的Spring Security采用的是登录方式在前后端分离的项目中是不适用的,所以需要我们自定义登录方式。

自定义验证成功处理器

@Component
public class CustomizeAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    @Resource
    private UserMapper userMapper;


    @SneakyThrows
    @Override
    public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
        
        //可以进行一些处理,比如登录成功之后可能需要返回给前台当前用户有哪些菜单权限,颁发令牌,更改数据库数据等等,
        //进而前台动态的控制菜单的显示等,具体根据自己的业务需求进行扩展
        //返回json数据
        CommonReturnType result = CommonReturnType.success("登录成功");
        httpServletResponse.setContentType("text/json;charset=utf-8");
        httpServletResponse.getWriter().write(JSON.toJSONString(result));
    }
}

自定义验证失败处理器

@Component
public class CustomizeAuthenticationFailureHandler implements AuthenticationFailureHandler {


    @Override
    public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
        //返回json数据
        CommonReturnType result = null;
        if (e instanceof AccountExpiredException) {
            //账号过期
            result = CommonReturnType.fail("账号过期");
        } else if (e instanceof InternalAuthenticationServiceException) {
            //密码错误
            result = CommonReturnType.fail("用户不存在");
        } else if(e instanceof BadCredentialsException) {
            //用户不存在
            result = CommonReturnType.fail(e.getMessage());
        } else if (e instanceof CredentialsExpiredException) {
            //密码过期
            result = CommonReturnType.fail("密码过期");
        } else if (e instanceof DisabledException) {
            //账号不可用
            result = CommonReturnType.fail("账号被禁用");
        } else if (e instanceof LockedException) {
            //账号锁定
            result = CommonReturnType.fail("账号锁定");
        } else if(e instanceof NonceExpiredException) {
            //异地登录
            result = CommonReturnType.fail("异地登录");
        } else if(e instanceof SessionAuthenticationException) {
            //session异常
            result = CommonReturnType.fail("session错误");
        } else if(e instanceof ValidateCodeException) {
            //验证码异常
            result = CommonReturnType.fail(e.getMessage());
        } else {
            //其他未知异常
            result = CommonReturnType.fail(e.getMessage());
        }
        httpServletResponse.setContentType("text/json;charset=utf-8");
        httpServletResponse.getWriter().write(JSON.toJSONString(result));
    }
}

匿名访问(未登录访问)处理器

@Component
public class CustomizeAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
        CommonReturnType result = CommonReturnType.fail("访问服务需要登录");
        httpServletResponse.setContentType("text/json;charset=utf-8");
        httpServletResponse.getWriter().write(JSON.toJSONString(result));
    }
}

访问权限拒绝处理器

@Component
public class CustomizeAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException {
        CommonReturnType result = CommonReturnType.fail("访问服务需要管理员身份");
        httpServletResponse.setContentType("text/json;charset=utf-8");
        httpServletResponse.getWriter().write(JSON.toJSONString(result));
    }
}

登出成功处理器

@Component
public class CustomizeLogoutSuccessHandler implements LogoutSuccessHandler {
    @Override
    public void onLogoutSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
        CommonReturnType result = CommonReturnType.success("登出成功");
        httpServletResponse.setContentType("text/json;charset=utf-8");
        httpServletResponse.getWriter().write(JSON.toJSONString(result));
    }
}

security配置

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    //数据库查询用户服务
    @Autowired
    private UserNameDetailService userdetailservice;

    //未登录处理器(匿名访问无权限处理)
    @Autowired
    private CustomizeAuthenticationEntryPoint customizeAuthenticationEntryPoint;

    //会话过期策略处理器(异地登录)
    @Autowired
    private CustomizeSessionInformationExpiredStrategy customizeSessionInformationExpiredStrategy;

    //登录成功处理器
    @Autowired
    private CustomizeAuthenticationSuccessHandler customizeAuthenticationSuccessHandler;

    //登录失败处理器
    @Autowired
    private CustomizeAuthenticationFailureHandler customizeAuthenticationFailureHandler;

    //权限拒绝处理器
    @Autowired
    private CustomizeAccessDeniedHandler customizeAccessDeniedHandler;
	
    //登出成功处理器
    @Autowired
    private CustomizeLogoutSuccessHandler customizeLogoutSuccessHandler;

    //图片验证码过滤器
    @Autowired
    private ValidateImageCodeFilter validateImageCodeFilter;
    
	//短信验证码过滤器
    @Autowired
    private SmsFilter smsFilter;
    
	//短信验证码配置
    @Autowired
    private SmsAuthenticationConfig smsAuthenticationConfig;


    /**
     * 自定义数据库查寻认证
     * @param auth
     * @throws Exception
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userdetailservice).passwordEncoder(passwordEncoder());
    }

    /**
     * 设置加密方式
     * @return
     */
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    /**
     * 配置登录
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //开启跨域以及关闭防护
        http.csrf().disable().cors();
        //注册自定义图片验证码过滤器
        http.addFilterBefore(validateImageCodeFilter, UsernamePasswordAuthenticationFilter.class);
        //短信验证顾虑器
        http.addFilterBefore(smsFilter, ValidateImageCodeFilter.class);
        //将短信验证码认证配置到spring security中
        http.apply(smsAuthenticationConfig);
        //更改未登录或者登录过期默认跳转
        http.exceptionHandling().authenticationEntryPoint(customizeAuthenticationEntryPoint);
        //路径权限
        http.authorizeRequests()
            .antMatchers("/api/v1/user/login","/doc.html"
                    ,"/aip/v1/qrs/cc","/api/v1/user/mobile"
                    ,"/api/v1/user/sms","/api/v1/user/image")
            .permitAll()
            .antMatchers("/usr/add").hasAnyAuthority("admin")
            .anyRequest().authenticated();
        //退出登录
        http.logout()
            .logoutUrl("/logout").logoutSuccessUrl("/test/hello").deleteCookies("JSESSIONID")
            .logoutSuccessHandler(customizeLogoutSuccessHandler) //登出成功逻辑处理
        .and()
            .formLogin()
            .successHandler(customizeAuthenticationSuccessHandler) //登录成功逻辑处理
            .failureHandler(customizeAuthenticationFailureHandler) //登录失败逻辑处理
        .and()
            .exceptionHandling()
            .accessDeniedHandler(customizeAccessDeniedHandler) //权限拒绝逻辑处理
            .authenticationEntryPoint(customizeAuthenticationEntryPoint) //匿名访问无权限访问资源异常处理
        //会话管理
        .and()
            .sessionManagement()
            .maximumSessions(1) //同一个用户最大的登录数量
            .expiredSessionStrategy(customizeSessionInformationExpiredStrategy); //异地登录(会话失效)处理逻辑
    }

    public SmsAuthenticationConfig getSmsAuthenticationConfig() {
        return smsAuthenticationConfig;
    }
}

数据库查询用户服务

@Service("userdetailservice")
public class UserNameDetailService implements UserDetailsService {
    @Autowired
    private UserMapper userMapper;

    @Autowired
    private UserRoleRelationService userRoleRelationService;

    @Autowired
    private RolePermissionRelationService rolePermissionRelationService;

    @Autowired
    private SysPermissionService sysPermissionService;

    @SneakyThrows
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        if(null == username|| "".equals(username)) {
            throw new UsernameNotFoundException("用户名不能为空");
        }
        //查询用户
        //查找用户权限
        List<GrantedAuthority> auths = AuthorityUtils.commaSeparatedStringToAuthorityList(s1);
        return new User(user.getUsername(), new BCryptPasswordEncoder().encode(user.getPassword()), user.getEnabled(),user.getAccountNotExpired(),user.getCredentialsNotExpired(),user.getAccountNotLocked(),auths);
    }
}

自定义图片验证码

原理:

首先,我们通过一个接口获取图片验证码,同时将服务端将图片验证码存起来,然后我们在UsernamePasswordAuthenticationFilter前面添加过滤器来对验证码进行验证

图片验证码

public class ImageCode implements Serializable {
    //图片验证码
    private BufferedImage image;
    //验证码
    private String code;
    //过期时间
    private LocalDateTime expireTime;

    public ImageCode(BufferedImage image, String code, int expireIn) {
        this.image = image;
        this.code = code;
        this.expireTime = LocalDateTime.now().plusSeconds(expireIn);
    }

    public ImageCode(BufferedImage image, String code, LocalDateTime expireTime) {
        this.image = image;
        this.code = code;
        this.expireTime = expireTime;
    }

    public boolean isExpire() {
        return LocalDateTime.now().isAfter(expireTime);
    }

    public BufferedImage getImage() {
        return image;
    }

    public void setImage(BufferedImage image) {
        this.image = image;
    }

    public String getCode() {
        return code;
    }

    public void setCode(String code) {
        this.code = code;
    }

    public LocalDateTime getExpireTime() {
        return expireTime;
    }

    public void setExpireTime(LocalDateTime expireTime) {
        this.expireTime = expireTime;
    }
}

自定义验证异常

校验过程中需要抛出自定义的异常

public class ValidateCodeException extends AuthenticationException {
    private static final long serialVersionUID = 5022575393500654458L;
    public ValidateCodeException(String message) {
        super(message);
    }
}

随机生成验证码

public class ImageCodeUtil {
    /**
     * 创建图片验证码
     * @return
     */
    public static ImageCode createImageCode() {
        int width = 100; // 验证码图片宽度
        int height = 36; // 验证码图片长度
        int length = 4; // 验证码位数
        int expireIn = 120; // 验证码有效时间 120s

        BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
        Graphics g = image.getGraphics();
        Random random = new Random();
        g.setColor(getRandColor(200,250));
        g.fillRect(0,0,width,height);
        g.setFont(new Font("Times New Roman",Font.ITALIC, 35));
        g.setColor(getRandColor(160,200));
        for(int i = 0; i< 155; i++){
            int x = random.nextInt(width);
            int y = random.nextInt(height);
            int xl = random.nextInt(12);
            int yl = random.nextInt(12);
            g.drawLine(x, y, x + xl, y + yl);
        }

        StringBuilder sRand = new StringBuilder();
        String rand = null;
        for(int i = 0; i<length; i++){
            int anInt = random.nextInt(57);
            if(anInt  >= 10) {
                if(anInt + 65 >=91 && anInt + 65 <= 96) {
                    anInt += 6;
                }
                char ch = (char) (anInt + 65);
                rand = String.valueOf(ch);
            } else {
                rand =  String.valueOf(anInt);
            }
            sRand.append(rand);
            g.setColor(new Color(20 + random.nextInt(110), 20 + random.nextInt(110), 20 + random.nextInt(110)));
            g.drawString(rand, 15 * i + 15, 28);
        }
            g.dispose();
            return new ImageCode(image, sRand.toString(),expireIn);
    }

    private static Color getRandColor(int fc, int bc) {
        Random random = new Random();
        if(fc > 255) {
            fc = 255;
        }
        if (bc > 255) {
            bc = 255;
        }
        int r = fc + random.nextInt(bc - fc);
        int g = fc + random.nextInt(bc - fc);
        int b = fc + random.nextInt(bc - fc);
        return new Color(r, g, b);
    }

}

图片验证码过滤器

这里继承OncePerRequestFilter和继承BasicAuthenticationFilter是一样的,因为BasicAuthenticationFilter也是继承了OncePerRequestFilter。

@Slf4j
@Component
public class ValidateImageCodeFilter extends OncePerRequestFilter {

    @Autowired
    //自定义验证失败处理器
    private CustomizeAuthenticationFailureHandler customizeAuthenticationFailureHandler;

    //这里我选择将验证码存放在HttpSessionSessionStrategy中(可以使用redis等进行存储)
    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //请求路径中是否包含login这个关键词 && 发送的请求必须是post
        if (StringUtils.contains(request.getRequestURI(), "login")
                && StringUtils.equalsIgnoreCase(request.getMethod(), "post")) {
            try {
                //开始验证
                validateCode(new ServletWebRequest(request));
            } catch (ValidateCodeException e) {
                //如果验证失败,就使用自定义验证处理器
                customizeAuthenticationFailureHandler.onAuthenticationFailure(request, response, e);
                return;
            }
        }
        filterChain.doFilter(request, response);
    }
    //验证实现
    private void validateCode(ServletWebRequest servletWebRequest) throws ServletRequestBindingException, ValidateCodeException {
        //从SessionStrategy中拿出验证码
        ImageCode codeInSession = (ImageCode) sessionStrategy.getAttribute(servletWebRequest,"SESSION_KEY_IMAGE_CODE");
        //从请求路径中拿出验证码
        String codeInRequest = ServletRequestUtils.getStringParameter(servletWebRequest.getRequest(), "imageCode");
        //验证码判空
        if (StringUtils.isBlank(codeInRequest)) {
            throw new ValidateCodeException("验证码不能为空 ");
        }
        //验证码颁发方验证
        if (codeInSession == null) {
            throw new ValidateCodeException("验证码不存在!");
        }
        //验证码是否过期
        if (codeInSession.isExpire()) {
            sessionStrategy.removeAttribute(servletWebRequest,"SESSION_KEY_IMAGE_CODE");
            throw new ValidateCodeException("验证码已过期!");
        }
        //验证码正确性
        if (!StringUtils.equalsIgnoreCase(codeInSession.getCode(), codeInRequest)) {
            throw new ValidateCodeException("验证码不正确!");
        }
        //移除服务端的验证码存储
        sessionStrategy.removeAttribute(servletWebRequest,"SESSION_KEY_IMAGE_CODE");
    }

}

获取验图片证码接口

@RequestMapping("/image")
public void login(HttpServletRequest request, HttpServletResponse response) throws IOException {
    ImageCode imageCode = ImageCodeUtil.createImageCode();
    ImageCode codeInRedis = new ImageCode(null,imageCode.getCode(),imageCode.getExpireTime());
    new HttpSessionSessionStrategy().setAttribute(new ServletWebRequest(request), "SESSION_KEY_IMAGE_CODE", codeInRedis);
    response.setContentType("image/jpeg;charset=utf-8");
    response.setStatus(HttpStatus.OK.value());
    ImageIO.write(imageCode.getImage(), "jpeg", response.getOutputStream());
}

自定义短信验证码

和图片验证码不同,短信验证码是一种登录方式,而图片验证码是账户密码登录的一个参数。

这里,我们需要定义一个新的登录验证的方式。我们借鉴账户密码的验证方式来写。

短信验证码过滤器

拦截短信验证码登录请求,组成一个验证token,然后进行验证。最后将这一整套流程注册进spring security

public class SmsAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    public static final String MOBILE_KEY = "mobile";

    private String mobileParameter = MOBILE_KEY;

    private boolean postOnly = true;


    public SmsAuthenticationFilter() {
        super(new AntPathRequestMatcher("/api/v1/user/mobile", "POST"));
    }


    @Override
    public Authentication attemptAuthentication(HttpServletRequest request,
                                                HttpServletResponse response) throws AuthenticationException {
        if (postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException(
                    "Authentication method not supported: " + request.getMethod());
        }

        String mobile = obtainMobile(request);

        if (mobile == null) {
            mobile = "";
        }
        mobile = mobile.trim();
        //生成一个验证token,但是没有经过验证
        SmsAuthenticationToken authRequest = new SmsAuthenticationToken(mobile);
        setDetails(request, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }

    protected String obtainMobile(HttpServletRequest request) {
        return request.getParameter(mobileParameter);
    }

    protected void setDetails(HttpServletRequest request,
                              SmsAuthenticationToken authRequest) {
        authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
    }

    public void setMobileParameter(String mobileParameter) {
        Assert.hasText(mobileParameter, "mobile parameter must not be empty or null");
        this.mobileParameter = mobileParameter;
    }

    public void setPostOnly(boolean postOnly) {
        this.postOnly = postOnly;
    }

    public final String getMobileParameter() {
        return mobileParameter;
    }
}

SmsAuthenticationToken

在上一步的拦截器中,我们拦截了短信验证码登录请求,我们需要组装一个AuthenticationToken

public class SmsAuthenticationToken extends AbstractAuthenticationToken {
    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

    private final Object principal;

    public SmsAuthenticationToken(String mobile) {
        super(null);
        this.principal = mobile;
        setAuthenticated(false);
    }

    public SmsAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        super.setAuthenticated(true); // must use super, as we override
    }

    @Override
    public Object getCredentials() {
        return null;
    }

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

    @Override
    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
        if (isAuthenticated) {
            throw new IllegalArgumentException(
                    "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
        }

        super.setAuthenticated(false);
    }

    @Override
    public void eraseCredentials() {
        super.eraseCredentials();
    }
}

SmsAuthenticationProvider

对上面组装的验证token进行验证。

public class SmsAuthenticationProvider implements AuthenticationProvider {
    @Autowired
    private MobileDetailService mobileDetailService;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        SmsAuthenticationToken authenticationToken = (SmsAuthenticationToken) authentication;
        UserDetails userDetails = mobileDetailService.loadUserByUsername((String) authenticationToken.getPrincipal());

        if (null == userDetails) {
            throw new InternalAuthenticationServiceException("未找到与该手机号对应的用户");
        }
        //标记这个验证结果为已验证
        SmsAuthenticationToken authenticationResult = new SmsAuthenticationToken(userDetails, userDetails.getAuthorities());

        authenticationResult.setDetails(authenticationToken.getDetails());

        return authenticationResult;
    }

    @Override
    public boolean supports(Class<?> aClass) {
        return SmsAuthenticationToken.class.isAssignableFrom(aClass);
    }

    public UserDetailsService getUserDetailService() {
        return mobileDetailService;
    }

    public void setUserDetailService(MobileDetailService mobileDetailService) {
        this.mobileDetailService =  mobileDetailService;
    }
}

配置短信验证码流程到spring security

@Component
public class SmsAuthenticationConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    @Autowired
    private AuthenticationFailureHandler authenticationFailureHandler;

    @Autowired
    private AuthenticationSuccessHandler authenticationSuccessHandler;

    @Autowired
    private MobileDetailService mobileDetailService;

    @Override
    public void configure(HttpSecurity http) {
        //一个验证拦截器
        SmsAuthenticationFilter smsAuthenticationFilter = new SmsAuthenticationFilter();
        //给这个验证拦截器设置一个管理器
        smsAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
        //设置验证成功的处理器
        smsAuthenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
        //设置验证失败的处理器
        smsAuthenticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler);
        //一个验证provider实现验证功能(将权限等信息加进去)
        SmsAuthenticationProvider smsAuthenticationProvider = new SmsAuthenticationProvider();
        //给这个provider设置我的登录账户信息获取service
        smsAuthenticationProvider.setUserDetailService(mobileDetailService);
        //将这个验证器加到用户名登录的后面
        http.authenticationProvider(smsAuthenticationProvider)
                .addFilterAfter(smsAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

获取验证码接口

通过接口直接返回代替了短信服务,这里仍然采用了sessionstrategy存放,根据需要可以采用redis等第三方数据库存取。

@RequestMapping("/sms")
public void createSms(HttpServletRequest request,HttpServletResponse response,String mobile) throws IOException {
    SmsCode smsCode = RandomSmsUtil.createSMSCode();
    new HttpSessionSessionStrategy().setAttribute(new ServletWebRequest(request),"SESSION_KEY_SMS_CODE" + mobile,smsCode);
    response.getWriter().write(smsCode.getCode());
    System.out.println("您的验证码信息为:" + smsCode.getCode() + "有效时间为:" + smsCode.getExpireTime());
}

最后别忘记,自定义的这两种方式都需要在配置类中注册。我已经在最前面配置自动登录的时候配置好了提前配置了。

至此,整个自定义短信验证码登录,以及图片验证码,就已经完成了!在大多数的登录场景就已经够用了。如有错误,敬请指正!!