spring security 实现登录验证码及记住我

spring security 验证码登录:

  在现在主流的网站登录页上,我们经常可以看到登陆的时候是通过账号密码登录,那么时常会看到需要我们输入一个图片验证码里面的值。或者通过手机验证码进行短信登陆,进行获取验证码进行登录。而这两种登陆方式都用到了验证码,前者是图片验证码,后者是短信验证码。在spring security 中使用验证码来验证登录,其核心还是拦截器链,当请求获取验证码的时候,将该验证码放入request中,当用户收到后进行输入。然后取出request里面的值进行比对。或者将这个东西存在别的比较安全的地方都是可以的。然后添加自己的校验拦截器。进行响应的逻辑处理,即可达到验证码登录的效果。

  为了让读者能更好的理解这一套处理逻辑,下图展示了这个功能的类图:

  在这里我们需要导入以下依赖,commons没用到的可以删掉:

<dependency>
            <groupId>org.springframework.social</groupId>
            <artifactId>spring-social-config</artifactId>
            <version>1.1.4.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.social</groupId>
            <artifactId>spring-social-core</artifactId>
            <version>1.1.4.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.social</groupId>
            <artifactId>spring-social-security</artifactId>
            <version>1.1.4.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.social</groupId>
            <artifactId>spring-social-web</artifactId>
            <version>1.1.4.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>commons-lang</groupId>
            <artifactId>commons-lang</artifactId>
            <version>2.6</version>
        </dependency>
        <dependency>
            <groupId>commons-beanutils</groupId>
            <artifactId>commons-beanutils</artifactId>
            <version>1.8.3</version>
        </dependency>
        <dependency>
            <groupId>commons-collections</groupId>
            <artifactId>commons-collections</artifactId>
            <version>3.2.1</version>
        </dependency>

1.验证码生成器 ValidateCodeGenerator,主要逻辑是用于生成验证码:

public interface ValidateCodeGenerator {
    /**
     * 生成验证码
     */
    ValidateCode generate(ServletWebRequest request);
}

  这里需要定义我们验证码的值及过期时间:

public class ValidateCode {

    private String code;

    private LocalDateTime expireTime;

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

    public ValidateCode(String code, LocalDateTime expireTime){
        this.code = code;
        this.expireTime = expireTime;
    }
   // 判断验证码是否超时
    public boolean isExpried() {
        return LocalDateTime.now().isAfter(expireTime);
    }
   // 省略get/set
}

  上述两种类型的验证码中,图片验证码需要向浏览器发送图片流,所以这里还需要定义一个图片验证码类:

public class ImageCode extends ValidateCode {

    private BufferedImage image;

    public ImageCode(BufferedImage image, String code, int expireIn) {
        super(code, expireIn);
        this.image = image;
    }

    public ImageCode(BufferedImage image, String code, LocalDateTime expireTime) {
        super(code, expireTime);
        this.image = image;
    }
    // 省略get/set
}

  图片验证码生成,样式可以自己调整,不行的话就百度:

public class ImageCodeGenerator implements ValidateCodeGenerator {

    @Override
    public ImageCode generate(ServletWebRequest request) {
        int width = ServletRequestUtils.getIntParameter(request.getRequest(), "width",60);
        int height = ServletRequestUtils.getIntParameter(request.getRequest(), "height",20);
        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, 20));
        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);
        }
        String sRand = "";
        for (int i = 0; i < 4; i++) {
            String rand = String.valueOf(random.nextInt(10));
            sRand += rand;
            g.setColor(new Color(20 + random.nextInt(110), 20 + random.nextInt(110), 20 + random.nextInt(110)));
            g.drawString(rand, 13 * i + 6, 16);
        }
        g.dispose();
        return new ImageCode(image, sRand, 60);
    }

    /**
     * 生成随机背景条纹
     */
    private 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);
    }
}

  短信验证码,但是测试环境我们就随机生成一个六位数的密码:

@Component("smsValidateCodeGenerator")
public class SmsCodeGenerator implements ValidateCodeGenerator {


    @Override
    public ValidateCode generate(ServletWebRequest request) {
        String code = RandomStringUtils.randomNumeric(6);
        return new ValidateCode(code, 60);
    }
}

2.验证码处理器 ValidateCodeProcessor ,这里主要是创建验证码及校验验证码功能,还有一个是需要往 request 里面存放我们的验证码,所以这里定义一个前缀。

public interface ValidateCodeProcessor {
    /**
     * 验证码放入session时的前缀
     */
    String SESSION_KEY_PREFIX = "SESSION_KEY_FOR_CODE_";

    /**
     * 创建校验码*/
    void create(ServletWebRequest request) throws Exception;

    /**
     * 校验验证码*/
    void validate(ServletWebRequest servletWebRequest);
}

  抽象的验证码处理器:

public abstract class AbstractValidateCodeProcessor<C extends ValidateCode> implements ValidateCodeProcessor {

    /**
     * 操作session的工具类
     */
    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
    /**
     * 收集系统中所有的 {@link ValidateCodeGenerator} 接口的实现。
     */
    @Autowired
    private Map<String, ValidateCodeGenerator> validateCodeGenerators;

    /*
     * (non-Javadoc)
     */
    @Override
    public void create(ServletWebRequest request) throws Exception {
        C validateCode = generate(request);
        save(request, validateCode);
        send(request, validateCode);
    }

    /**
     * 生成校验码
     *
     */
    @SuppressWarnings("unchecked")
    private C generate(ServletWebRequest request) {
        String type = getValidateCodeType(request).toString().toLowerCase();
        String generatorName = type + ValidateCodeGenerator.class.getSimpleName();
        ValidateCodeGenerator validateCodeGenerator = validateCodeGenerators.get(generatorName);
        if (validateCodeGenerator == null) {
            throw new ValidateCodeException("验证码生成器" + generatorName + "不存在");
        }
        return (C) validateCodeGenerator.generate(request);
    }

    /**
     * 保存校验码
     *
     * @param request
     * @param validateCode
     */
    private void save(ServletWebRequest request, C validateCode) {
        sessionStrategy.setAttribute(request, getSessionKey(request), validateCode);
    }

    /**
     * 构建验证码放入session时的key
     *
     * @param request
     * @return
     */
    private String getSessionKey(ServletWebRequest request) {
        return SESSION_KEY_PREFIX + getValidateCodeType(request).toString().toUpperCase();
    }

    /**
     * 发送校验码,由子类实现
     *
     * @param request
     * @param validateCode
     * @throws Exception
     */
    protected abstract void send(ServletWebRequest request, C validateCode) throws Exception;

    /**
     * 根据请求的url获取校验码的类型
     *
     * @param request
     * @return
     */
    private ValidateCodeType getValidateCodeType(ServletWebRequest request) {
        String type = StringUtils.substringBefore(getClass().getSimpleName(), "CodeProcessor");
        return ValidateCodeType.valueOf(type.toUpperCase());
    }

    @SuppressWarnings("unchecked")
    @Override
    public void validate(ServletWebRequest request) {

        ValidateCodeType processorType = getValidateCodeType(request);
        String sessionKey = getSessionKey(request);

        C codeInSession = (C) sessionStrategy.getAttribute(request, sessionKey);

        String codeInRequest;
        try {
            codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(),
                    processorType.getParamNameOnValidate());
        } catch (ServletRequestBindingException e) {
            throw new ValidateCodeException("获取验证码的值失败");
        }

        if (StringUtils.isBlank(codeInRequest)) {
            throw new ValidateCodeException(processorType + "验证码的值不能为空");
        }

        if (codeInSession == null) {
            throw new ValidateCodeException(processorType + "验证码不存在");
        }

        if (codeInSession.isExpried()) {
            sessionStrategy.removeAttribute(request, sessionKey);
            throw new ValidateCodeException(processorType + "验证码已过期");
        }

        if (!StringUtils.equals(codeInSession.getCode(), codeInRequest)) {
            throw new ValidateCodeException(processorType + "验证码不匹配");
        }

        sessionStrategy.removeAttribute(request, sessionKey);
    }
}

  子类实现,图片验证码处理器:

@Component("imageValidateCodeProcessor")
public class ImageCodeProcessor extends AbstractValidateCodeProcessor<ImageCode> {

    /**
     * 发送图形验证码,将其写到响应中
     */
    @Override
    protected void send(ServletWebRequest request, ImageCode imageCode) throws Exception {
        ImageIO.write(imageCode.getImage(), "JPEG", request.getResponse().getOutputStream());
    }

}

  短信验证码处理器,这里主要需要调用发送短信的api进行短信发送,这里就直接通过打印一行日志来表示:

@Component("smsValidateCodeProcessor")
public class SmsCodeProcessor extends AbstractValidateCodeProcessor<ValidateCode> {
    /**
     * 短信验证码发送器
     */
    @Autowired
    private SmsCodeSender smsCodeSender;

    @Override
    protected void send(ServletWebRequest request, ValidateCode validateCode) throws Exception {
        String paramName = "mobile";
        String mobile = ServletRequestUtils.getRequiredStringParameter(request.getRequest(), paramName);
        smsCodeSender.send(mobile, validateCode.getCode());
    }
}

public interface SmsCodeSender {
   void send(String mobile, String code);
}
public class DefaultSmsCodeSender implements SmsCodeSender {

   @Override
   public void send(String mobile, String code) {
      System.out.println("向手机"+mobile+"发送短信验证码"+code);
   }
}

  那么到现在为止,验证码的生成及处理已经完成。

3.向spring容器注入相关类,当然也可以直接标在类上

@Configuration
public class ValidateCodeBeanConfig {

    @Bean
    @ConditionalOnMissingBean(name = "imageValidateCodeGenerator")
    public ValidateCodeGenerator imageValidateCodeGenerator() {
        ImageCodeGenerator codeGenerator = new ImageCodeGenerator();
        return codeGenerator;
    }

    @Bean
    @ConditionalOnMissingBean(SmsCodeSender.class)
    public SmsCodeSender smsCodeSender() {
        return new DefaultSmsCodeSender();
    }
}

4.验证码处理器控制类 ValidateCodeProcessorHolder

@Component
public class ValidateCodeProcessorHolder {

    @Autowired
    private Map<String, ValidateCodeProcessor> validateCodeProcessors;

    public ValidateCodeProcessor findValidateCodeProcessor(ValidateCodeType type) {
        return findValidateCodeProcessor(type.toString().toLowerCase());
    }

    public ValidateCodeProcessor findValidateCodeProcessor(String type) {
        String name = type.toLowerCase() + ValidateCodeProcessor.class.getSimpleName();
        ValidateCodeProcessor processor = validateCodeProcessors.get(name);
        if (processor == null) {
            throw new ValidateCodeException("验证码处理器" + name + "不存在");
        }
        return processor;
    }
}

5.接下去就是需要我们的拦截器登场了

@Component("validateCodeFilter")
public class ValidateCodeFilter extends OncePerRequestFilter implements InitializingBean {

    /**
     * 验证码校验失败处理器
     */
    @Autowired
    private AuthenticationFailureHandler authenticationFailureHandler;
    /**
     * 系统中的校验码处理器
     */
    @Autowired
    private ValidateCodeProcessorHolder validateCodeProcessorHolder;
    /**
     * 存放所有需要校验验证码的url
     */
    private Map<String, ValidateCodeType> urlMap = new HashMap<>();
    /**
     * 验证请求url与配置的url是否匹配的工具类
     */
    private AntPathMatcher pathMatcher = new AntPathMatcher();

    /**
     * 初始化要拦截的url配置信息
     */
    @Override
    public void afterPropertiesSet() throws ServletException {
        super.afterPropertiesSet();

        urlMap.put("/authentication/form", ValidateCodeType.IMAGE);
        addUrlToMap("/user/*", ValidateCodeType.IMAGE);

        urlMap.put("/authentication/mobile", ValidateCodeType.SMS);
        addUrlToMap("/user/*", ValidateCodeType.SMS);
    }

    /**
     * 系统中配置的需要校验验证码的URL根据校验的类型放入map*/
    protected void addUrlToMap(String urlString, ValidateCodeType type) {
        if (StringUtils.isNotBlank(urlString)) {
            String[] urls = StringUtils.splitByWholeSeparatorPreserveAllTokens(urlString, ",");
            for (String url : urls) {
                urlMap.put(url, type);
            }
        }
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {

        ValidateCodeType type = getValidateCodeType(request);
        if (type != null) {
            logger.info("校验请求(" + request.getRequestURI() + ")中的验证码,验证码类型" + type);
            try {
                validateCodeProcessorHolder.findValidateCodeProcessor(type)
                        .validate(new ServletWebRequest(request, response));
                logger.info("验证码校验通过");
            } catch (ValidateCodeException exception) {
                authenticationFailureHandler.onAuthenticationFailure(request, response, exception);
                return;
            }
        }

        chain.doFilter(request, response);

    }

    /**
     * 获取校验码的类型,如果当前请求不需要校验,则返回null
     */
    private ValidateCodeType getValidateCodeType(HttpServletRequest request) {
        ValidateCodeType result = null;
        if (!StringUtils.equalsIgnoreCase(request.getMethod(), "get")) {
            Set<String> urls = urlMap.keySet();
            for (String url : urls) {
                if (pathMatcher.match(url, request.getRequestURI())) {
                    result = urlMap.get(url);
                }
            }
        }
        return result;
    }
}

  这里我们通过枚举来定义处理器类型,同时需要实现我们的异常类:

public enum ValidateCodeType {

    /**
     * 短信验证码
     */
    SMS {
        @Override
        public String getParamNameOnValidate() {
            return "smsCode";
        }
    },
    /**
     * 图片验证码
     */
    IMAGE {
        @Override
        public String getParamNameOnValidate() {
            return "imageCode";
        }
    };

    /**
     * 校验时从请求中获取的参数的名字
     * @return
     */
    public abstract String getParamNameOnValidate();

}
public class ValidateCodeException extends AuthenticationException {

    private static final long serialVersionUID = -7285211528095468156L;

    public ValidateCodeException(String msg) {
        super(msg);
    }
}

6.接下去就是需要把这个拦截器加入到 spring security的拦截器链中,就是在配置类中将我们的拦截器注入,然后通过调用http.addFilterBefore添加到某个拦截器前面,就可以了:

http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)

7.提供一个接口进行验证码获取:

@RestController
public class ValidateCodeController {

    @Autowired
    private ValidateCodeProcessorHolder validateCodeProcessorHolder;

    /**
     * 创建验证码,根据验证码类型不同,调用不同的*/
    @GetMapping("/code/{type}")
    public void createCode(HttpServletRequest request, HttpServletResponse response, @PathVariable String type)
            throws Exception {
        validateCodeProcessorHolder.findValidateCodeProcessor(type).create(new ServletWebRequest(request, response));
    }
}

  经过以上的步骤我们就能获取到验证码了,可以看到接口上有个 type属性。这个属性用于区分时图片验证码还是短信验证码,是因为这两个处理器在注入容器的时候命名来决定的,所以type可以为 image 、sms 类型。小伙伴本也可以进行代码重构,将其中的一些配置通过配置类的方式注入。

spring security 记住我:

  在Security 中要实现记住我功能很简单,先来看代码:

@Configuration
@EnableWebSecurity// 开启Security
@EnableGlobalMethodSecurity(prePostEnabled = true)//开启Spring方法级安全
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    // Secutiry 处理链
//    SecurityContextPersistenceFilter
//    --> UsernamePasswordAuthenticationFilter
//    --> BasicAuthenticationFilter
//    --> ExceptionTranslationFilter
//    --> FilterSecurityInterceptor
    @Autowired
    private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;

    @Autowired
    private MyAuthenticationProvider myAuthenticationProvider;

    @Autowired
    private MyAuthenctiationFailureHandler myAuthenctiationFailureHandler;

    @Autowired
    private MyUserDetailService myUserDetailService;

    @Autowired
    private ValidateCodeFilter validateCodeFilter;

    @Autowired
    private DataSource dataSource;

    //密码加密
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    // 自定义认证配置
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(myAuthenticationProvider);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //关闭Security功能
//        http.csrf().disable()
//                .authorizeRequests()
//                .anyRequest().permitAll()
//                .and().logout().permitAll();

        http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
                    .authorizeRequests()
                    .antMatchers("/wuzz/test4","/code/*").permitAll() //不需要保护的资源,可以多个
                    .antMatchers("/wuzz/**").authenticated()// 需要认证得资源,可以多个
                    .and()
                .formLogin().loginPage("http://localhost:8080/#/login")//自定义登陆地址
                    .loginProcessingUrl("/authentication/form") //登录处理地址
                    .successHandler(myAuthenticationSuccessHandler) // 登陆成功处理器
                    .failureHandler(myAuthenctiationFailureHandler) // 登陆失败处理器
                    .permitAll()
                    .and()
                    .userDetailsService(myUserDetailService)//设置userDetailsService,处理用户信息
                .rememberMe()//实现记住我功能 RememberMeAuthenticationFilter
                .tokenRepository(persistentTokenRepository())
                .tokenValiditySeconds(3600)

        ;
        http.headers().cacheControl(); //禁用缓存
        http.csrf().disable(); //禁用csrf校验
    }
    //忽略的uri
//    @Override
//    public void configure(WebSecurity web) throws Exception {
//        web.ignoring()
//                .antMatchers( "/api/**", "/resources/**", "/static/**", "/public/**", "/webui/**", "/h2-console/**"
//                        , "/configuration/**",  "/swagger-resources/**", "/api-docs", "/api-docs/**", "/v2/api-docs/**"
//                        ,  "/**/*.css", "/**/*.js","/**/*.ftl", "/**/*.png ", "/**/*.jpg", "/**/*.gif ", "/**/*.svg", "/**/*.ico", "/**/*.ttf", "/**/*.woff");
//    }

    @Bean
    public PersistentTokenRepository persistentTokenRepository() {
        JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
        tokenRepository.setDataSource(dataSource);
        //启动的时候是否创建该表,这个表格是保存用户登录信息的
//        tokenRepository.setCreateTableOnStartup(true);
        return tokenRepository;
    }
}

  上述代码中新增注入两个类DataSource、PersistentTokenRepository(通过jdbcTemplate实现),因为记住我功能是将token信息存入到数据库,实现类由两种,一种基于内存,另一种基于数据库,后者比较有保障。所以这里使用JDBC,当然需要在application.properties 加入以下配置:

spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://192.168.1.101:3306/study?useUnicode=true&characterEncoding=utf-8
spring.datasource.username=root
spring.datasource.password=123456

  然后配置启用记住我功能:

.rememberMe()//实现记住我功能 RememberMeAuthenticationFilter
.tokenRepository(persistentTokenRepository())
 .tokenValiditySeconds(3600) // 过期时间

  就这样就配置好了记住我功能,然后需要我们在前端请求登录的时候加上参数 remember-me,这个参数是固定的。在 AbstractRememberMeServices 类中声明:

public static final String SPRING_SECURITY_REMEMBER_ME_COOKIE_KEY = "remember-me";

  当我们登陆的时候这个值传的是 true,那么就会进行进驻我操作。

   记住我操作的源码可以阅读 RememberMeAuthenticationFilter,流程很简单。多过几遍就清晰了。

posted @ 2020-06-23 16:39  吴振照  阅读(370)  评论(0编辑  收藏