Spring Security 技术栈开发企业级认证授权(2)

1,750 阅读45分钟

个人博客:www.zhenganwen.top,文末有惊喜! 本文是《Spring Security 技术栈开发企业级认证授权》一文的后续

使用Spring Security开发基于表单的认证

实现图形验证码功能

功能实现

由于图形验证码是通用功能,所以我们将相关逻辑写在security-code

首先,将图形、图形中的验证码、验证码过期时间封装在一起

package top.zhenganwen.security.core.verifycode.dto;

import lombok.Data;
import java.awt.image.BufferedImage;
import java.time.LocalDateTime;

/**
 * @author zhenganwen
 * @date 2019/8/24
 * @desc ImageCode
 */
@Data
public class ImageCode {
    private String code;
    private BufferedImage image;
    // 验证码过期时间
    private LocalDateTime expireTime;

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

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

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

然后提供一个生成验证码的接口

package top.zhenganwen.security.core.verifycode;

import org.springframework.social.connect.web.HttpSessionSessionStrategy;
import org.springframework.social.connect.web.SessionStrategy;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.ServletWebRequest;
import top.zhenganwen.security.core.verifycode.dto.ImageCode;

import javax.imageio.ImageIO;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.Random;

/**
 * @author zhenganwen
 * @date 2019/8/24
 * @desc VerifyCodeController
 */
@RestController
@RequestMapping("/verifyCode")
public class VerifyCodeController {

    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

    public static final String SESSION_KEY = "SESSION_KEY_IMAGE_CODE";

    /**
     * 1.生成图形验证码
     * 2.将验证码存到Session中
     * 3.将图形响应给前端
     */
    @GetMapping("/image")
    public void imageCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
        ImageCode imageCode = generateImageCode(67, 23, 4);
        // Session读写工具类, 第一个参数写法固定
        sessionStrategy.setAttribute(new ServletWebRequest(request), SESSION_KEY, imageCode.getCode());
        ImageIO.write(imageCode.getImage(), "JPEG", response.getOutputStream());
    }

    /**
     * @param width     图形宽度
     * @param height    图形高度
     * @param strLength 验证码字符数
     * @return
     */
    private ImageCode generateImageCode(int width, int height, int strLength) {

        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 < strLength; 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(sRand, image, 60);
    }

    /**
     * 生成随机背景条纹
     *
     * @param fc
     * @param bc
     * @return
     */
    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);
    }
}

security-browser的配置类中将生成验证码的接口权限放开:

protected void configure(HttpSecurity http) throws Exception {
        http
                .formLogin()
                    .loginPage("/auth/require")
                    .loginProcessingUrl("/auth/login")
                    .successHandler(customAuthenticationSuccessHandler)
                    .failureHandler(customAuthenticationFailureHandler)
                .and()
                .authorizeRequests()
                    .antMatchers(
                            "/auth/require",
                            securityProperties.getBrowser().getLoginPage(),
                            "/verifyCode/image").permitAll()
                    .anyRequest().authenticated()
                .and()
                .csrf().disable();
    }

security-demo中测试验证码的生成,在login.html中添加验证码输入框:

<form action="/auth/login" method="post">
    用户名: <input type="text" name="username">
    密码: <input type="password" name="password">
    验证码:<input type="text" name="verifyCode"><img src="/verifyCode/image" alt="">
    <button type="submit">提交</button>
</form>

访问/login.html,验证码生成如下:

image.png

接下来我们编写验证码校验逻辑,由于security并未提供验证码校验对应的过滤器,因此我们需要自定义一个并将其插入到UsernamePasswordFilter之前:

package top.zhenganwen.security.core.verifycode;


import org.springframework.security.core.AuthenticationException;

/**
 * @author zhenganwen
 * @date 2019/8/24
 * @desc VerifyCodeException
 */
public class VerifyCodeException extends AuthenticationException {
    public VerifyCodeException(String explanation) {
        super(explanation);
    }
}
package top.zhenganwen.security.core.verifycode;

import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.social.connect.web.HttpSessionSessionStrategy;
import org.springframework.social.connect.web.SessionStrategy;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.filter.OncePerRequestFilter;
import top.zhenganwen.security.core.verifycode.dto.ImageCode;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Objects;

/**
 * @author zhenganwen
 * @date 2019/8/24
 * @desc VerifyCodeAuthenticationFilter
 */
@Component
// 继承OncePerRequestFilter的过滤器在一次请求中只会被执行一次
public class VerifyCodeAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private AuthenticationFailureHandler customAuthenticationFailureHandler;

    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException,
    IOException {
        // 如果是登录请求
        if (Objects.equals(request.getRequestURI(), "/auth/login") && StringUtils.endsWithIgnoreCase(request.getMethod(), "POST")) {
            try {
                this.validateVerifyCode(new ServletWebRequest(request));
            } catch (VerifyCodeException e) {
                // 若抛出异常则使用自定义认证失败处理器处理一下,否则没人捕获(因为该过滤器配在了UsernamePasswordAuthenticationFilter的前面)
                customAuthenticationFailureHandler.onAuthenticationFailure(request, response, e);
            }
        }
        filterChain.doFilter(request, response);
    }

    // 从Session中读取验证码和用户提交的验证码进行比对
    private void validateVerifyCode(ServletWebRequest request) {
        String verifyCode = (String) request.getParameter("verifyCode");
        if (StringUtils.isBlank(verifyCode)) {
            throw new VerifyCodeException("验证码不能为空");
        }
        ImageCode imageCode = (ImageCode) sessionStrategy.getAttribute(request, VerifyCodeController.SESSION_KEY);
        if (imageCode == null) {
            throw new VerifyCodeException("验证码不存在");
        }
        if (imageCode.isExpired()) {
            throw new VerifyCodeException("验证码已过期,请刷新页面");
        }
        if (StringUtils.equals(verifyCode,imageCode.getCode()) == false) {
            throw new VerifyCodeException("验证码错误");
        }
        // 登录成功,移除Session中保存的验证码
        sessionStrategy.removeAttribute(request, VerifyCodeController.SESSION_KEY);
    }
}

security-browser

@Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .addFilterBefore(verifyCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                .formLogin()
                    .loginPage("/auth/require")
                    .loginProcessingUrl("/auth/login")
                    .successHandler(customAuthenticationSuccessHandler)
                    .failureHandler(customAuthenticationFailureHandler)
                .and()
                .authorizeRequests()
                    .antMatchers(
                            "/auth/require",
                            securityProperties.getBrowser().getLoginPage(),
                            "/verifyCode/image").permitAll()
                    .anyRequest().authenticated()
                .and()
                .csrf().disable();
    }

访问/login.html什么都不填直接登录,返回的JSON如下

{"cause":null,"stackTrace":[...],"localizedMessage":"验证码不能为空","message":"验证码不能为空","suppressed":[]}{"cause":null,"stackTrace":[...],"localizedMessage":"坏的凭证","message":"坏的凭证","suppressed":[]}

发现连着返回了两个exception的JSON串,且是一前以后返回的(两个JSON串是连着的,中间没有任何符号),这是因为我们在VerifyCodeAuthenticationFilter中调用customAuthenticationFailureHandler进行认证失败处理之后,接着执行了doFilter,而后的UsernamePasswordAuthenticationFilter也会拦截登录请求/auth/login,在校验的过程中捕获到BadCredentialsException,又调用customAuthenticationFailureHandler返回了一个exceptionJSON串

这里有两点需要优化

  • 返回的异常信息不应该包含堆栈

    CustomAuthenticationFailureHandler中返回从exception中提取的异常信息,而不要直接返回exception

    //        response.getWriter().write(objectMapper.writeValueAsString(exception));
    response.getWriter().write(objectMapper.writeValueAsString(new SimpleResponseResult(exception.getMessage())));
    

- 在`VerifyCodeAuthenticationFilter`发现认证失败异常并调用认证失败处理器处理后,应该`return`一下,没有必要再走后续的过滤器了

  ```java
  if (Objects.equals(request.getRequestURI(), "/auth/login") && StringUtils.endsWithIgnoreCase(request.getMethod(), "POST")) {
              try {
                  this.validateVerifyCode(new ServletWebRequest(request));
              } catch (VerifyCodeException e) {
                  // 若抛出异常则使用自定义认证失败处理器处理一下,否则没人捕获(因为该过滤器配在了UsernamePasswordAuthenticationFilter的前面)
                  customAuthenticationFailureHandler.onAuthenticationFailure(request, response, e);
              	return;
              }
          }
          filterChain.doFilter(request, response);

重新测试

{
    content: "验证码不能为空"
}

接着测试验证码,填入admin,123456和图形验证码后登陆,登陆成功,认证成功处理器返回Authentication

{
    authorities: [
        {
            authority: "admin"
        },
        {
            authority: "user"
        }
    ],
    details: {
        remoteAddress: "0:0:0:0:0:0:0:1",
        sessionId: "452F44596C9D9FF55DBA91A1F24E05B0"
    },
    authenticated: true,
    principal: {
        password: null,
        username: "admin",
        authorities: [
            {
                authority: "admin"
            },
            {
                authority: "user"
            }
        ],
        accountNonExpired: true,
        accountNonLocked: true,
        credentialsNonExpired: true,
        enabled: true
    },
    credentials: null,
    name: "admin"
}

重构图形验证码功能

至此,图形验证码的功能我们已经基本实现完了,但是作为高级工程师我们不应该满足于此,在实现功能之余还应该想想如何重构代码使该功能可重用,当别人需要不同尺寸、不同数量验证字符、不同验证逻辑时,也能够复用我们的代码

图形验证码基本参数可配置

如图形的长宽像素、验证码字符数、验证码有效期持续时间

一般系统的配置生效机制如下,我们作为被依赖的模块需要提供一个常用的默认配置,依赖我们的应用可以自己添加配置项来覆盖这个默认配置,最后在应用运行时还可以通过在请求中附带参数来动态切换配置

image.png

security-core添加配置类

package top.zhenganwen.security.core.properties;

import lombok.Data;

/**
 * @author zhenganwen
 * @date 2019/8/25
 * @desc ImageCodeProperties
 */
@Data
public class ImageCodeProperties {
    private int width=67;
    private int height=23;
    private int strLength=4;
    private int durationSeconds = 60;
}
package top.zhenganwen.security.core.properties;

import lombok.Data;

/**
 * @author zhenganwen
 * @date 2019/8/25
 * @desc VerifyCodeProperties 封装图形验证码和短信验证码
 */
@Data
public class VerifyCodeProperties {
    private ImageCodeProperties image = new ImageCodeProperties();
}
package top.zhenganwen.security.core.properties;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;

/**
 * @author zhenganwen
 * @date 2019/8/23
 * @desc SecurityProperties 封装整个项目各模块的配置项
 */
@Data
@ConfigurationProperties(prefix = "demo.security")
public class SecurityProperties {
    private BrowserProperties browser = new BrowserProperties();
    private VerifyCodeProperties code = new VerifyCodeProperties();
}

在生成验证接口中,将对应参数改为动态读取

package top.zhenganwen.security.core.verifycode;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.social.connect.web.HttpSessionSessionStrategy;
import org.springframework.social.connect.web.SessionStrategy;
import org.springframework.web.bind.ServletRequestUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.ServletWebRequest;
import top.zhenganwen.security.core.properties.SecurityProperties;
import top.zhenganwen.security.core.verifycode.dto.ImageCode;

import javax.imageio.ImageIO;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.Random;

/**
 * @author zhenganwen
 * @date 2019/8/24
 * @desc VerifyCodeController
 */
@RestController
@RequestMapping("/verifyCode")
public class VerifyCodeController {

    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

    public static final String SESSION_KEY = "SESSION_KEY_IMAGE_CODE";

    @Autowired
    private SecurityProperties securityProperties;

    /**
     * 1.生成图形验证码
     * 2.将验证码存到session中
     * 3.将图形响应给前端
     */
    @GetMapping("/image")
    public void imageCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
        // 首先读取URL参数中的width/height,如果没有则使用配置文件中的
        int width = ServletRequestUtils.getIntParameter(request, "width", securityProperties.getCode().getImage().getWidth());
        int height = ServletRequestUtils.getIntParameter(request, "height", securityProperties.getCode().getImage().getHeight());

        ImageCode imageCode = generateImageCode(width, height, securityProperties.getCode().getImage().getStrLength());
        // Session读写工具类, 第一个参数写法固定
        sessionStrategy.setAttribute(new ServletWebRequest(request), SESSION_KEY, imageCode);
        ImageIO.write(imageCode.getImage(), "JPEG", response.getOutputStream());
    }

    /**
     * @param width     图形宽度
     * @param height    图形高度
     * @param strLength 验证码字符数
     * @return
     */
    private ImageCode generateImageCode(int width, int height, int strLength) {

        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 < strLength; 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(sRand, image, securityProperties.getCode().getImage().getDurationSeconds());
    }

}

测试应用级配置验证码字符数覆盖默认的,在security-demoapplication.properties中添加配置项

demo.security.code.image.strLength=6

测试请求参数级配置覆盖应用级配置

demo.security.code.image.width=100
验证码:<input type="text" name="verifyCode"><img src="/verifyCode/image?width=200" alt="">

访问/login.html,发现图形宽度200,验证码字符数为6,测试成功

验证码认证过滤器拦截的接口可配

现在我们的VerifyCodeFilter仅拦截登录请求并进行验证码校验,可能别的接口也需要验证码才能调用(也许是为了非法重复请求),那么这时我们需要支持应用能够动态地配置需要进行验证码校验的接口,例如

demo.security.code.image.url=/user,/user/*

表示请求/user/user/*之前都需要进行验证码校验

于是我们新增一个可配置拦截URI的属性

@Data
public class ImageCodeProperties {
    private int width=67;
    private int height=23;
    private int strLength=4;
    private int durationSeconds = 60;
    // 需要拦截的URI列表,多个URI以逗号分隔
    private String uriPatterns;
}

然后在VerifyCodeAuthenticationFilter读取配置文件中的demo.security.code.image.uriPatterns并初始化一个uriPatternSet集合,在拦截逻辑里遍历集合并将拦截的URI与集合元素进行模式匹配,如果有一个匹配上则说明该URI需要检验验证码,验证失败则抛出异常留给认证失败处理器处理,校验成功则跳出遍历循环直接放行

@Component
public class VerifyCodeAuthenticationFilter extends OncePerRequestFilter implements InitializingBean {

    @Autowired
    private AuthenticationFailureHandler customAuthenticationFailureHandler;

    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

    @Autowired
    private SecurityProperties securityProperties;

    private Set<String> uriPatternSet = new HashSet<>();

    // uri匹配工具类,帮我们做类似/user/1到/user/*的匹配
    private AntPathMatcher antPathMatcher = new AntPathMatcher();

    @Override
    public void afterPropertiesSet() throws ServletException {
        super.afterPropertiesSet();
        String uriPatterns = securityProperties.getCode().getImage().getUriPatterns();
        if (StringUtils.isNotBlank(uriPatterns)) {
            String[] strings = StringUtils.splitByWholeSeparatorPreserveAllTokens(uriPatterns, ",");
            uriPatternSet.addAll(Arrays.asList(strings));
        }
        uriPatternSet.add("/auth/login");
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException,
            IOException {
        for (String uriPattern : uriPatternSet) {
            if (antPathMatcher.match(uriPattern, request.getRequestURI())) {
                try {
                    this.validateVerifyCode(new ServletWebRequest(request));
                } catch (VerifyCodeException e) {
                    // 若抛出异常则使用自定义认证失败处理器处理一下,否则没人捕获(因为该过滤器配在了UsernamePasswordAuthenticationFilter的前面)就抛给前端了
                    customAuthenticationFailureHandler.onAuthenticationFailure(request, response, e);
                    return;
                }
                break;
            }
        }
        filterChain.doFilter(request, response);
    }

    private void validateVerifyCode(ServletWebRequest request) {...}
}

我们将uriPatternSet的初始化逻辑写在了InitializingBean接口的afterPropertiesSet方法中,这相当于在传统的spring.xml中配置了一个init-method标签,该方法会在VerifyCodeAuthenticationFilter的所有autowire属性被赋值后由spring执行

访问/user/user/1均被提示验证码不能为空,修改配置项为uriPattern=/user/*重启后登录/login.html再访问/user没被拦截,而访问/user/1提示验证码不能为空,测试成功

图形验证码生成逻辑可配——以增量的方式适应变化

现在我们的图形验证码的样式是固定的,只能生成数字验证码,别人要想换一个样式或生成字母、汉子验证码似乎无能为力。他在想,如果他能够像使用Spring一样实现一个接口返回自定义的ImageCode来使用自己的验证码生成逻辑那该多好

Spring提供的这种你实现一个接口就能替代Spring原有实现的思想一种很常用设计模式,在需要扩展功能的时候无需更改原有代码,而只需添加一个实现类,以增量的方式适应变化

首先我们将生成图形验证码的逻辑抽象成接口

package top.zhenganwen.security.core.verifycode;

import top.zhenganwen.security.core.verifycode.dto.ImageCode;

/**
 * @author zhenganwen
 * @date 2019/8/25
 * @desc ImageCodeGenerator 图形验证码生成器接口
 */
public interface ImageCodeGenerator {

    ImageCode generateImageCode(int width, int height, int strLength, int durationSeconds);
}

然后将之前写在Controller中的生成图形验证码的方法作为该接口的默认实现

package top.zhenganwen.security.core.verifycode;

import top.zhenganwen.security.core.verifycode.dto.ImageCode;

import java.awt.*;
import java.awt.image.BufferedImage;
import java.util.Random;

/**
 * @author zhenganwen
 * @date 2019/8/25
 * @desc DefaultImageCodeGenerator
 */
public class DefaultImageCodeGenerator implements ImageCodeGenerator {

    @Override
    public ImageCode generateImageCode(int width, int height, int strLength, int durationSeconds) {
        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 < strLength; 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(sRand, image, durationSeconds);
    }

    /**
     * 生成随机背景条纹
     *
     * @param fc
     * @param bc
     * @return
     */
    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);
    }
}

然后将该默认实现注入到容器中,注意@ConditionOnMissingBean是实现该模式的重点注解,标注了该注解的bean会在所有未标注@ConditionOnMissingBeanbean都被实例化注入到容器中后,判断容器中是否存在id为imageCodeGeneratorbean,如果不存在才会进行实例化并作为id为imageCodeGeneratorbean被使用

package top.zhenganwen.security.core;

import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import top.zhenganwen.security.core.properties.SecurityProperties;
import top.zhenganwen.security.core.verifycode.DefaultImageCodeGenerator;
import top.zhenganwen.security.core.verifycode.ImageCodeGenerator;

/**
 * @author zhenganwen
 * @date 2019/8/23
 * @desc SecurityCoreConfig
 */
@Configuration
@EnableConfigurationProperties(SecurityProperties.class)
public class SecurityCoreConfig {

    @Bean
    @ConditionalOnMissingBean(name = "imageCodeGenerator")
    public ImageCodeGenerator imageCodeGenerator() {
        ImageCodeGenerator imageCodeGenerator = new DefaultImageCodeGenerator();
        return imageCodeGenerator;
    }
}

验证码生成接口改为依赖验证码生成器接口来生成验证码(面向抽象编程以适应变化):

@RestController
@RequestMapping("/verifyCode")
public class VerifyCodeController {

    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

    public static final String SESSION_KEY = "SESSION_KEY_IMAGE_CODE";

    @Autowired
    private SecurityProperties securityProperties;

    @Autowired
    private ImageCodeGenerator imageCodeGenerator;

    /**
     * 1.生成图形验证码
     * 2.将验证码存到session中
     * 3.将图形响应给前端
     */
    @GetMapping("/image")
    public void imageCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
        // 首先读取URL参数中的width/height,如果没有则使用配置文件中的
        int width = ServletRequestUtils.getIntParameter(request, "width", securityProperties.getCode().getImage().getWidth());
        int height = ServletRequestUtils.getIntParameter(request, "height", securityProperties.getCode().getImage().getHeight());

        ImageCode imageCode = imageCodeGenerator.generateImageCode(width, height,
                securityProperties.getCode().getImage().getStrLength(),
                securityProperties.getCode().getImage().getDurationSeconds());
        // Session读写工具类, 第一个参数写法固定
        sessionStrategy.setAttribute(new ServletWebRequest(request), SESSION_KEY, imageCode);
        ImageIO.write(imageCode.getImage(), "JPEG", response.getOutputStream());
    }

}

重启服务并登录以确保重构后并未改变代码的功能性

最后,我们在security-demo中新增一个自定义的图形验证码生成器来替换默认的:

package top.zhenganwen.securitydemo.security;

import top.zhenganwen.security.core.verifycode.ImageCodeGenerator;
import top.zhenganwen.security.core.verifycode.dto.ImageCode;

/**
 * @author zhenganwen
 * @date 2019/8/25
 * @desc CustomImageCodeGenerator
 */
@Component("imageCodeGenerator")
public class CustomImageCodeGenerator implements ImageCodeGenerator {
    @Override
    public ImageCode generateImageCode(int width, int height, int strLength, int durationSeconds) {
        System.out.println("调用自定义的代码生成器");
        return null;
    }
}

这里我们简单的打印一下日志返回一个null,这样login.html调用图形验证码生成器接口生成图形验证码时如果走的是我们这个自定义的图形验证码生成器就会抛出异常。注意@Componentvalue属性要和@ConditionOnMissingBeanname属性一致才能实现替换

实现记住我功能

需求

有时用户希望在填写登录表单时勾选一个“记住我”选框,在登陆后的一段时间内可以无需登录即可访问受保护的URL

securityrememberMe.gif

实现

本节,我们就来实现以下该功能:

  1. 首先页面需要一个“记住我”选框,选框的name属性需为remember-me(可自定义配置),value属性为true

    <form action="/auth/login" method="post">
        用户名: <input type="text" name="username">
        密码: <input type="password" name="password">
        验证码:<input type="text" name="verifyCode"><img src="/verifyCode/image?width=200" alt="">
        <input type="checkbox" name="remember-me" value="true">记住我
        <button type="submit">提交</button>
    </form>
    
  2. 在数据源对应的数据库中创建一张表persistent_logins,表创建语句在JdbcTokenRepositoryImpl的变量CREATE_TABLE_SQL

    create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, "
    			+ "token varchar(64) not null, last_used timestamp not null)
    
  3. seurity配置类中增加“记住我”的相关配置,这里因为Cookie受限于浏览器,所有我们配在security-browser模块中,如下rememberMe()部分

    @Autowired
        private DataSource dataSource;
    
        @Autowired
        private UserDetailsService userDetailsService;
    
        @Bean
        public PersistentTokenRepository persistentTokenRepository() {
            JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
            jdbcTokenRepository.setDataSource(dataSource);
            return jdbcTokenRepository;
        }
    
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http
                    .addFilterBefore(verifyCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                    .formLogin()
                        .loginPage("/auth/require")
                        .loginProcessingUrl("/auth/login")
                        .successHandler(customAuthenticationSuccessHandler)
                        .failureHandler(customAuthenticationFailureHandler)
                        .and()
                    .rememberMe()
                        .tokenRepository(persistentTokenRepository())
                        .tokenValiditySeconds(3600)
                        .userDetailsService(userDetailsService)
    //                    可配置页面选框的name属性
    //                    .rememberMeParameter()            
                        .and()
                    .authorizeRequests()
                        .antMatchers(
                                "/auth/require",
                                securityProperties.getBrowser().getLoginPage(),
                                "/verifyCode/image").permitAll()
                        .anyRequest().authenticated()
                    .and()
                    .csrf().disable();
        }
    
  4. 测试

    未登录访问/user提示需要登录,登录/login.html后访问/user可访问成功,查看数据库表persistent_logins,发现新增了一条记录。关闭服务模拟Session关闭(因为Session是保存服务端的,关闭服务端比关闭浏览器更能保证Session关闭)。重启服务,未登录访问受保护的/user,发现可以直接访问

源码分析

首次登陆序列图

上图是开启了“记住我”功能后,用户首次登录的序列图,在AbstractAuthenticationProcessingFilter中校验用户名密码成功之后在方法的末尾会调用successfulAuthentication,查看其源码(部分省略):

protected void successfulAuthentication(HttpServletRequest request,
                                        HttpServletResponse response, FilterChain chain, Authentication authResult)
    throws IOException, ServletException {

    SecurityContextHolder.getContext().setAuthentication(authResult);

    rememberMeServices.loginSuccess(request, response, authResult);

    successHandler.onAuthenticationSuccess(request, response, authResult);
}

发现在successHandler.onAuthenticationSuccess()调用认证成功处理器之前,还执行了rememberMeServices.loginSuccess,这个方法就是用来向数据库插入一条username-token记录并将token写入Cookie的,具体逻辑在PersistentTokenBasedRememberMeServices#onLoginSuccess()

protected void onLoginSuccess(HttpServletRequest request,
                              HttpServletResponse response, Authentication successfulAuthentication) {
    String username = successfulAuthentication.getName();

    PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(
        username, generateSeriesData(), generateTokenData(), new Date());
    try {
        tokenRepository.createNewToken(persistentToken);
        addCookie(persistentToken, request, response);
    }catch (Exception e) {
        logger.error("Failed to save persistent token ", e);
    }
}

在我们设置的tokenValiditySeconds期间,若用户未登录但从同一浏览器访问受保护服务,RememberMeAuthenticationFilter会拦截到请求:

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
    throws IOException, ServletException {

    if (SecurityContextHolder.getContext().getAuthentication() == null) {
        Authentication rememberMeAuth = rememberMeServices.autoLogin(request,
                                                                     response);
        ...
    }

会调用autoLogin()尝试从Cookie中读取token并从持久层查询username-token,如果查到了再根据username调用UserDetailsService查找用户,查找到了生成新的认证成功的Authentication保存到当前线程保险箱中:

AbstractRememberMeServices#autoLogin

public final Authentication autoLogin(HttpServletRequest request,
                                      HttpServletResponse response) {
    String rememberMeCookie = extractRememberMeCookie(request);

    if (rememberMeCookie == null) {
        return null;
    }

    if (rememberMeCookie.length() == 0) {
        logger.debug("Cookie was empty");
        cancelCookie(request, response);
        return null;
    }

    UserDetails user = null;

    try {
        String[] cookieTokens = decodeCookie(rememberMeCookie);
        user = processAutoLoginCookie(cookieTokens, request, response);
        userDetailsChecker.check(user);

        return createSuccessfulAuthentication(request, user);
    }
    ...
}

PersistentTokenBasedRememberMeServices

protected UserDetails processAutoLoginCookie(String[] cookieTokens,
                                             HttpServletRequest request, HttpServletResponse response) {

    final String presentedSeries = cookieTokens[0];
    final String presentedToken = cookieTokens[1];

    PersistentRememberMeToken token = tokenRepository
        .getTokenForSeries(presentedSeries);

    return getUserDetailsService().loadUserByUsername(token.getUsername());
}

短信验证码登录

之前我们使用的都是传统的用户名密码的登录方式,随着短信验证码登录、第三方应用如QQ登录的流行,传统的登录方式已无法满足我们的需求了

用户名密码认证流程是已经固化在security框架中了,我们只能编写一些实现接口扩展部分细节,而对于大体的流程是无法改变的。因此要想实现短信验证码登录,我们需要自定义一套登录流程

短信验证码发送接口

要想实现短信验证码功能首先我们需要提供此接口,前端可以通过调用此接口传入手机号进行短信验证码的发送。如下,在浏览器的登录页通过点击事件发送验证码,本来应该通过AJAX异步调用发送接口,这里为了方便演示使用超链接进行同步调用,也是为了方便演示这里将手机号写死了而没有通过js动态获取用户输入的手机号

<form action="/auth/login" method="post">
    用户名: <input type="text" name="username">
    密码: <input type="password" name="password">
    验证码:<input type="text" name="verifyCode"><img src="/verifyCode/image?width=200" alt="">
    <input type="checkbox" name="remember-me" value="true">记住我
    <button type="submit">提交</button>
</form>
<hr/>
<form action="/auth/sms" method="post">
    手机号: <input type="text" name="phoneNumber" value="12345678912">
    验证码: <input type="text"><a href="/verifyCode/sms?phoneNumber=12345678912">点击发送</a>
    <input type="checkbox" name="remember-me" value="true">记住我
    <button type="submit">提交</button>
</form>

重构PO

后端security-core首先要新建一个类封装短信验证码的相关属性:

package top.zhenganwen.security.core.verifycode.dto;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class SmsCode {
    protected String code;
    protected LocalDateTime expireTime;
    public boolean isExpired() {
        return LocalDateTime.now().isAfter(expireTime);
    }
}

这里由于之前的ImageCode也有这两个属性,因此将SmsCode重命名为VerifyCodeImageCode继承以复用代码

@Data
@AllArgsConstructor
@NoArgsConstructor
public class VerifyCode {
    protected String code;
    protected LocalDateTime expireTime;
    public boolean isExpired() {
        return LocalDateTime.now().isAfter(expireTime);
    }
}
@Data
public class ImageCode extends VerifyCode{
    private BufferedImage image;
    public ImageCode(String code, BufferedImage image, LocalDateTime expireTime) {
        super(code,expireTime);
        this.image = image;
    }
    public ImageCode(String code, BufferedImage image, int durationSeconds) {
        this(code, image, LocalDateTime.now().plusSeconds(durationSeconds));
    }
}

重构验证码生成器

接下来我们需要一个短信验证码生成器,不像图形验证码生成器那样复杂。前者的生成逻辑就是生成一串随机的纯数字串,不像后者那样有图形长宽、颜色、背景、边框等,因此前者可以直接标注为@Component而无需考虑ConditionOnMissingBean,重构验证码生成器类结构:

image.png

package top.zhenganwen.security.core.verifycode.generator;

import top.zhenganwen.security.core.verifycode.dto.VerifyCode;

public interface VerifyCodeGenerator<T extends VerifyCode> {

    /**
     * 生成验证码
     * @return
     */
    T generateVerifyCode();
}

package top.zhenganwen.security.core.verifycode.generator;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.ServletRequestUtils;
import top.zhenganwen.security.core.properties.SecurityProperties;
import top.zhenganwen.security.core.verifycode.dto.ImageCode;

import javax.servlet.http.HttpServletRequest;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.util.Random;

public class DefaultImageCodeGenerator implements VerifyCodeGenerator<ImageCode> {

    @Autowired
    private SecurityProperties securityProperties;

    @Autowired
    HttpServletRequest request;

    @Override
    public ImageCode generateVerifyCode() {

        int width = ServletRequestUtils.getIntParameter(request, "width", securityProperties.getCode().getImage().getWidth());
        int height = ServletRequestUtils.getIntParameter(request, "height", securityProperties.getCode().getImage().getHeight());
        int strLength = securityProperties.getCode().getImage().getStrLength();
        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 < strLength; 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(sRand, image, securityProperties.getCode().getImage().getDurationSeconds());
    }

  ...
}
package top.zhenganwen.securitydemo.security;

import top.zhenganwen.security.core.verifycode.generator.VerifyCodeGenerator;
import top.zhenganwen.security.core.verifycode.dto.ImageCode;

//@Component
public class CustomImageCodeGenerator implements VerifyCodeGenerator<ImageCode> {
    @Override
    public ImageCode generateVerifyCode() {
        System.out.println("调用自定义的代码生成器");
        return null;
    }
}
package top.zhenganwen.security.core.verifycode.generator;

import org.apache.commons.lang.RandomStringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import top.zhenganwen.security.core.properties.SecurityProperties;
import top.zhenganwen.security.core.verifycode.dto.VerifyCode;

import java.time.LocalDateTime;


@Component("smsCodeGenerator")
public class SmsCodeGenerator implements VerifyCodeGenerator<VerifyCode> {

    @Autowired
    private SecurityProperties securityProperties;

    @Override
    public VerifyCode generateVerifyCode() {
        // 随机生成一串纯数字字符串,数字个数为 strLength
        String randomCode = RandomStringUtils.randomNumeric(securityProperties.getCode().getSms().getStrLength());
        return new VerifyCode(randomCode, LocalDateTime.now().plusSeconds(securityProperties.getCode().getSms().getDurationSeconds()));
    }

}

短信验证码发送器

生成短信验证码之后我们需要将其保存在Session中并调用短信服务提供商的接口将短信发送出去,由于将来依赖我们的应用可能会配置不同的短信服务提供商接口,为了保证代码的可扩展性我们需要将短信发送这一行为抽象成接口并提供一个默认可被覆盖的实现,这样依赖我们的应用就可以通过注入一个新的实现来启用它们的短信发送逻辑

package top.zhenganwen.security.core.verifycode;

public interface SmsCodeSender {
    /**
     * 根据手机号发送短信验证码
     * @param smsCode
     * @param phoneNumber
     */
    void send(String smsCode, String phoneNumber);
}
package top.zhenganwen.security.core.verifycode;

public class DefaultSmsCodeSender implements SmsCodeSender {
    @Override
    public void send(String smsCode, String phoneNumber) {
        // 这里只是简单的打印一下,实际应该调用短信服务提供商向手机号发送短信验证码
        System.out.printf("向手机号%s发送短信验证码%s", phoneNumber, smsCode);
    }
}
package top.zhenganwen.security.core;

import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import top.zhenganwen.security.core.properties.SecurityProperties;
import top.zhenganwen.security.core.verifycode.DefaultImageCodeGenerator;
import top.zhenganwen.security.core.verifycode.DefaultSmsCodeSender;
import top.zhenganwen.security.core.verifycode.ImageCodeGenerator;
import top.zhenganwen.security.core.verifycode.SmsCodeSender;

@Configuration
@EnableConfigurationProperties(SecurityProperties.class)
public class SecurityCoreConfig {

    @Bean
    @ConditionalOnMissingBean(name = "imageCodeGenerator")
    public ImageCodeGenerator imageCodeGenerator() {
        ImageCodeGenerator imageCodeGenerator = new DefaultImageCodeGenerator();
        return imageCodeGenerator;
    }

    @Bean
    @ConditionalOnMissingBean(name = "smsCodeSender")
    public SmsCodeSender smsCodeSender() {
        return new DefaultSmsCodeSender();
    }
}

重构配置类

package top.zhenganwen.security.core.properties;

import lombok.Data;

@Data
public class SmsCodeProperties {
    // 短信验证码数字个数,默认4个数字
    private int strLength = 4;
    // 有效时间,默认60秒
    private int durationSeconds = 60;
}

package top.zhenganwen.security.core.properties;

import lombok.Data;

@Data
public class ImageCodeProperties extends SmsCodeProperties{
    private int width=67;
    private int height=23;
    private String uriPatterns;

    public ImageCodeProperties() {
        // 图形验证码默认显示6个字符
        this.setStrLength(6);
        // 图形验证码过期时间默认为3分钟
        this.setDurationSeconds(180);
    }
}
package top.zhenganwen.security.core.properties;

import lombok.Data;

@Data
public class VerifyCodeProperties {
    private ImageCodeProperties image = new ImageCodeProperties();
    private SmsCodeProperties sms = new SmsCodeProperties();
}

发送短信验证码接口

@RestController
@RequestMapping("/verifyCode")
public class VerifyCodeController {

    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

    public static final String IMAGE_CODE_SESSION_KEY = "SESSION_KEY_IMAGE_CODE";

    public static final String SMS_CODE_SESSION_KEY = "SESSION_KEY_SMS_CODE";

    @Autowired
    private VerifyCodeGenerator<ImageCode> imageCodeGenerator;

    @Autowired
    private VerifyCodeGenerator<VerifyCode> smsCodeGenerator;

    @Autowired
    private SmsCodeSender smsCodeSender;

    /**
     * 1.生成图形验证码
     * 2.将验证码存到session中
     * 3.将图形响应给前端
     */
    @GetMapping("/image")
    public void imageCode(HttpServletRequest request, HttpServletResponse response) throws IOException {

        ImageCode imageCode = imageCodeGenerator.generateVerifyCode();
        sessionStrategy.setAttribute(new ServletWebRequest(request), IMAGE_CODE_SESSION_KEY, imageCode);
        ImageIO.write(imageCode.getImage(), "JPEG", response.getOutputStream());
    }

    /**
     * 1.生成短信验证码
     * 2.将验证码存到session中
     * 3.调用短信验证码发送器发送短信
     */
    @GetMapping("/sms")
    public void smsCode(HttpServletRequest request, HttpServletResponse response) throws ServletRequestBindingException {
        long phoneNumber = ServletRequestUtils.getRequiredLongParameter(request, "phoneNumber");
        VerifyCode verifyCode = smsCodeGenerator.generateVerifyCode();
        sessionStrategy.setAttribute(new ServletWebRequest(request), SMS_CODE_SESSION_KEY, verifyCode);
        smsCodeSender.send(verifyCode.getCode(), String.valueOf(phoneNumber));
    }

}

测试

security-browser中,我们将新增的接口/verifyCode/sms的访问权限放开:

	.authorizeRequests()
                    .antMatchers(
                            "/auth/require",
                            securityProperties.getBrowser().getLoginPage(),
                            "/verifyCode/**").permitAll()
                    .anyRequest().authenticated()

访问/login.html,点击点击发送超链接,后台输出如下:

向手机号12345678912发送短信验证码1220

重构——模板方法 & 依赖查找

现在我们的VerifyCodeController中的两个方法imageCodesmsCode的主干流程是一致的:

  1. 生成验证码
  2. 保存验证码,如保存到Session中、redis中等等
  3. 发送验证码给用户

这种情况下,我们可以应用模板方法设计模式(可看考我的另一篇文章《图解设计模式》),重构后的类图如下所示:

image.png

image.png

常量类

public class VerifyCodeConstant {
    public static final String IMAGE_CODE_SESSION_KEY = "SESSION_KEY_IMAGE_CODE";

    public static final String SMS_CODE_SESSION_KEY = "SESSION_KEY_SMS_CODE";

    public static final String VERIFY_CODE_PROCESSOR_IMPL_SUFFIX = "CodeProcessorImpl";

    public static final String VERIFY_CODE_Generator_IMPL_SUFFIX = "CodeGenerator";

    public static final String PHONE_NUMBER_PARAMETER_NAME = "phoneNumber";
}
public enum VerifyCodeTypeEnum {

    IMAGE("image"),SMS("sms");

    private String type;

    public String getType() {
        return type;
    }

    VerifyCodeTypeEnum(String type) {
        this.type = type;
    }
}

验证码发送处理器——模板方法 & 接口隔离 & 依赖查找

public interface VerifyCodeProcessor {
    /**
     * 发送验证码逻辑
     * 1.   生成验证码
     * 2.   保存验证码
     * 3.   发送验证码
     * @param request       封装request和response的工具类,用它我们就不用每次传{@link javax.servlet.http.HttpServletRequest}和{@link javax.servlet.http.HttpServletResponse}了
     */
    void sendVerifyCode(ServletWebRequest request);
}
public abstract class AbstractVerifyCodeProcessor<T extends VerifyCode> implements VerifyCodeProcessor {

    @Override
    public void sendVerifyCode(ServletWebRequest request) {
        T verifyCode = generateVerifyCode(request);
        save(request, verifyCode);
        send(request, verifyCode);
    }

    /**
     * 生成验证码
     *
     * @param request
     * @return
     */
    public abstract T generateVerifyCode(ServletWebRequest request);

    /**
     * 保存验证码
     *
     * @param request
     * @param verifyCode
     */
    public abstract void save(ServletWebRequest request, T verifyCode);

    /**
     * 发送验证码
     *
     * @param request
     * @param verifyCode
     */
    public abstract void send(ServletWebRequest request, T verifyCode);
}
@Component
public class ImageCodeProcessorImpl extends AbstractVerifyCodeProcessor<ImageCode> {

    private Logger logger = LoggerFactory.getLogger(getClass());

    /**
     * Spring高级特性
     * Spring会查找容器中所有{@link VerifyCodeGenerator}的实例并以 key=beanId,value=bean的形式注入到该map中
     */
    @Autowired
    private Map<String, VerifyCodeGenerator> verifyCodeGeneratorMap = new HashMap<>();

    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

    @Override
    public ImageCode generateVerifyCode(ServletWebRequest request) {
        VerifyCodeGenerator<ImageCode> verifyCodeGenerator = verifyCodeGeneratorMap.get(IMAGE.getType() + VERIFY_CODE_Generator_IMPL_SUFFIX);
        return verifyCodeGenerator.generateVerifyCode();
    }

    @Override
    public void save(ServletWebRequest request, ImageCode imageCode) {
        sessionStrategy.setAttribute(request,IMAGE_CODE_SESSION_KEY, imageCode);
    }

    @Override
    public void send(ServletWebRequest request, ImageCode imageCode) {
        HttpServletResponse response = request.getResponse();
        try {
            ImageIO.write(imageCode.getImage(), "JPEG", response.getOutputStream());
        } catch (IOException e) {
            logger.error("输出图形验证码:{}", e.getMessage());
        }
    }
}
@Component
public class SmsCodeProcessorImpl extends AbstractVerifyCodeProcessor<VerifyCode> {

    private Logger logger = LoggerFactory.getLogger(getClass());

    @Autowired
    private Map<String, VerifyCodeGenerator> verifyCodeGeneratorMap = new HashMap<>();

    @Autowired
    private SmsCodeSender smsCodeSender;

    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

    @Override
    public VerifyCode generateVerifyCode(ServletWebRequest request) {
        VerifyCodeGenerator verifyCodeGenerator = verifyCodeGeneratorMap.get(SMS.getType() + VERIFY_CODE_Generator_IMPL_SUFFIX);
        return verifyCodeGenerator.generateVerifyCode();
    }

    @Override
    public void save(ServletWebRequest request, VerifyCode verifyCode) {
        sessionStrategy.setAttribute(request, SMS_CODE_SESSION_KEY, verifyCode);
    }

    @Override
    public void send(ServletWebRequest request, VerifyCode verifyCode) {
        try {
            long phoneNumber = ServletRequestUtils.getRequiredLongParameter(request.getRequest(),PHONE_NUMBER_PARAMETER_NAME);
            smsCodeSender.send(verifyCode.getCode(),String.valueOf(phoneNumber));
        } catch (ServletRequestBindingException e) {
            throw new RuntimeException("手机号码不能为空");
        }
    }
}

验证码生成器

public interface VerifyCodeGenerator<T extends VerifyCode> {

    /**
     * 生成验证码
     * @return
     */
    T generateVerifyCode();
}
public class DefaultImageCodeGenerator implements VerifyCodeGenerator<ImageCode> {

    @Autowired
    private SecurityProperties securityProperties;

    @Autowired
    HttpServletRequest request;

    @Override
    public ImageCode generateVerifyCode() {

        int width = ServletRequestUtils.getIntParameter(request, "width", securityProperties.getCode().getImage().getWidth());
        int height = ServletRequestUtils.getIntParameter(request, "height", securityProperties.getCode().getImage().getHeight());
        int strLength = securityProperties.getCode().getImage().getStrLength();
        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 < strLength; 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(sRand, image, securityProperties.getCode().getImage().getDurationSeconds());
    }

    /**
     * 生成随机背景条纹
     *
     * @param fc
     * @param bc
     * @return
     */
    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("smsCodeGenerator")
public class SmsCodeGenerator implements VerifyCodeGenerator<VerifyCode> {

    @Autowired
    private SecurityProperties securityProperties;

    @Override
    public VerifyCode generateVerifyCode() {
        // 随机生成一串纯数字字符串,数字个数为 strLength
        String randomCode = RandomStringUtils.randomNumeric(securityProperties.getCode().getSms().getStrLength());
        return new VerifyCode(randomCode, LocalDateTime.now().plusSeconds(securityProperties.getCode().getSms().getDurationSeconds()));
    }

}

验证码发送器

public interface SmsCodeSender {
    /**
     * 根据手机号发送短信验证码
     * @param smsCode
     * @param phoneNumber
     */
    void send(String smsCode, String phoneNumber);
}
public class DefaultSmsCodeSender implements SmsCodeSender {
    @Override
    public void send(String smsCode, String phoneNumber) {
        System.out.printf("向手机号%s发送短信验证码%s", phoneNumber, smsCode);
    }
}

验证码发送接口

@RestController
@RequestMapping("/verifyCode")
public class VerifyCodeController {

/*    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

    @Autowired
    private VerifyCodeGenerator<ImageCode> imageCodeGenerator;

    @Autowired
    private VerifyCodeGenerator<VerifyCode> smsCodeGenerator;

    @Autowired
    private SmsCodeSender smsCodeSender;

    *//**
     * 1.生成图形验证码
     * 2.将验证码存到session中
     * 3.将图形响应给前端
     *//*
    @GetMapping("/image")
    public void imageCode(HttpServletRequest request, HttpServletResponse response) throws IOException {

        ImageCode imageCode = imageCodeGenerator.generateVerifyCode();
        sessionStrategy.setAttribute(new ServletWebRequest(request), IMAGE_CODE_SESSION_KEY, imageCode);
        ImageIO.write(imageCode.getImage(), "JPEG", response.getOutputStream());
    }

    *//**
     * 1.生成短信验证码
     * 2.将验证码存到session中
     * 3.调用短信验证码发送器发送短信
     *//*
    @GetMapping("/sms")
    public void smsCode(HttpServletRequest request, HttpServletResponse response) throws ServletRequestBindingException {
        long phoneNumber = ServletRequestUtils.getRequiredLongParameter(request, "phoneNumber");
        VerifyCode verifyCode = smsCodeGenerator.generateVerifyCode();
        sessionStrategy.setAttribute(new ServletWebRequest(request), SMS_CODE_SESSION_KEY, verifyCode);
        smsCodeSender.send(verifyCode.getCode(), String.valueOf(phoneNumber));
    }*/

    @Autowired
    private Map<String, VerifyCodeProcessor> verifyCodeProcessorMap = new HashMap<>();

    @GetMapping("/{type}")
    public void sendVerifyCode(@PathVariable String type, HttpServletRequest request, HttpServletResponse response) {
        if (Objects.equals(type, IMAGE.getType()) == false && Objects.equals(type, SMS.getType()) == false) {
            throw new IllegalArgumentException("不支持的验证码类型");
        }
        VerifyCodeProcessor verifyCodeProcessor = verifyCodeProcessorMap.get(type + VERIFY_CODE_PROCESSOR_IMPL_SUFFIX);
        verifyCodeProcessor.sendVerifyCode(new ServletWebRequest(request, response));
    }
}

配置类

package top.zhenganwen.security.core;

import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import top.zhenganwen.security.core.properties.SecurityProperties;
import top.zhenganwen.security.core.verifycode.generator.DefaultImageCodeGenerator;
import top.zhenganwen.security.core.verifycode.sender.DefaultSmsCodeSender;
import top.zhenganwen.security.core.verifycode.generator.VerifyCodeGenerator;
import top.zhenganwen.security.core.verifycode.sender.SmsCodeSender;

/**
 * @author zhenganwen
 * @date 2019/8/23
 * @desc SecurityCoreConfig
 */
@Configuration
@EnableConfigurationProperties(SecurityProperties.class)
public class SecurityCoreConfig {

    @Bean
    @ConditionalOnMissingBean(name = "imageCodeGenerator")
    public VerifyCodeGenerator imageCodeGenerator() {
        VerifyCodeGenerator imageCodeGenerator = new DefaultImageCodeGenerator();
        return imageCodeGenerator;
    }

    @Bean
    @ConditionalOnMissingBean(name = "smsCodeSender")
    public SmsCodeSender smsCodeSender() {
        return new DefaultSmsCodeSender();
    }
}

测试

要知道重构只是提高代码质量和增加代码可读性,因此每次小步重构之后一定要记得测试原有功能是否收到影响

  • 访问/login.html进行用户名密码登录,登陆后访问受保护服务/user

  • 访问/login.html点击点击发送,查看控制台是否打印发送日志

  • 修改/login.html,将图形验证码宽度设置为600

     验证码:<input type="text" name="verifyCode"><img src="/verifyCode/image?width=600" alt="">
    

测试通过,重构成功!

短信验证码登录

要想实现短信验证码登录流程,我们可以借鉴已有的用户名密码登录流程,分析有哪些组件是需要我们自己来实现的:

image.png

首先我们需要一个SmsAuthenticationFilter拦截短信登录请求进行认证,期间它会将登录信息封装成一个Authentication请求AuthenticationManager进行认证

AuthenticationManager会遍历所有的AuthenticationProvider找到其中支持认证该Authentication并调用authenticate进行实际的认证,因此我们需要实现自己的Authentication(SmsAuthenticationToken)和认证该AuthenticationAuthenticationProviderSmsAuthenticationProvider),并将SmsAuthenticationProvider添加到SpringSecurtyAuthenticationProvider集合中,以使AuthenticationManager遍历该集合时能找到我们自定义的SmsAuthenticationProvider

SmsAuthenticationProvider在进行认证时,需要调用UserDetailsService根据手机号查询存储的用户信息(loadUserByUsername),因此我们还需要自定义的SmsUserDetailsService

下面我们来一一实现下(其实就是依葫芦画瓢,把对应用户名密码登录流程对应组件的代码COPY过来改一改)

SmsAuthenticationToken

package top.zhenganwen.security.core.verifycode.sms;

import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.SpringSecurityCoreVersion;

import java.util.Collection;

/**
 * @author zhenganwen
 * @date 2019/8/30
 * @desc SmsAuthenticationToken
 */
public class SmsAuthenticationToken extends AbstractAuthenticationToken {

    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

    // ~ Instance fields
    // ================================================================================================
    // 认证前保存的是用户输入的手机号,认证成功后保存的是后端存储的用户详情
    private final Object principal;

    // ~ Constructors
    // ===================================================================================================

    /**
     * 认证前时调用该方法封装请求参数成一个未认证的token => authRequest
     *
     * @param phoneNumber 手机号
     */
    public SmsAuthenticationToken(Object phoneNumber) {
        super(null);
        this.principal = phoneNumber;
        setAuthenticated(false);
    }

    /**
     * 认证成功后需要调用该方法封装用户信息成一个已认证的token => successToken
     *
     * @param principal   用户详情
     * @param authorities 权限信息
     */
    public SmsAuthenticationToken(Object principal, Object credentials,
                                  Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        super.setAuthenticated(true); // must use super, as we override
    }

    // ~ Methods
    // ========================================================================================================

    // 用户名密码登录的凭证是密码,验证码登录不传密码
    @Override
    public Object getCredentials() {
        return null;
    }

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

}

SmsAuthenticationFilter

package top.zhenganwen.security.core.verifycode.sms;

import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.util.Assert;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * @author zhenganwen
 * @date 2019/8/30
 * @desc SmsAuthenticationFilter
 */
public class SmsAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    // ~ Static fields/initializers
    // =====================================================================================

    public static final String SPRING_SECURITY_FORM_PHONE_NUMBER_KEY = "phoneNumber";

    private String phoneNumberParameter = SPRING_SECURITY_FORM_PHONE_NUMBER_KEY;
    private boolean postOnly = true;

    // ~ Constructors
    // ===================================================================================================

    public SmsAuthenticationFilter() {
        super(new AntPathRequestMatcher("/auth/sms", "POST"));
    }

    // ~ Methods
    // ========================================================================================================

    @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 phoneNumber = obtainPhoneNumber(request);

        if (phoneNumber == null) {
            phoneNumber = "";
        }

        phoneNumber = phoneNumber.trim();

        SmsAuthenticationToken authRequest = new SmsAuthenticationToken(phoneNumber);

        return this.getAuthenticationManager().authenticate(authRequest);
    }

    /**
     * Enables subclasses to override the composition of the phoneNumber, such as by
     * including additional values and a separator.
     *
     * @param request so that request attributes can be retrieved
     *
     * @return the phoneNumber that will be presented in the <code>Authentication</code>
     * request token to the <code>AuthenticationManager</code>
     */
    protected String obtainPhoneNumber(HttpServletRequest request) {
        return request.getParameter(phoneNumberParameter);
    }

    /**
     * Sets the parameter name which will be used to obtain the phoneNumber from the login
     * request.
     *
     * @param phoneNumberParameter the parameter name. Defaults to "phoneNumber".
     */
    public void setPhoneNumberParameter(String phoneNumberParameter) {
        Assert.hasText(phoneNumberParameter, "phoneNumber parameter must not be empty or null");
        this.phoneNumberParameter = phoneNumberParameter;
    }

    /**
     * Defines whether only HTTP POST requests will be allowed by this filter. If set to
     * true, and an authentication request is received which is not a POST request, an
     * exception will be raised immediately and authentication will not be attempted. The
     * <tt>unsuccessfulAuthentication()</tt> method will be called as if handling a failed
     * authentication.
     * <p>
     * Defaults to <tt>true</tt> but may be overridden by subclasses.
     */
    public void setPostOnly(boolean postOnly) {
        this.postOnly = postOnly;
    }

    public final String getPhoneNumberParameter() {
        return phoneNumberParameter;
    }

}

SmsAuthenticationProvider

package top.zhenganwen.security.core.verifycode.sms;

import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;

/**
 * @author zhenganwen
 * @date 2019/8/30
 * @desc SmsAuthenticationProvider
 */
public class SmsAuthenticationProvider implements AuthenticationProvider {

    private UserDetailsService userDetailsService;

    public SmsAuthenticationProvider() {

    }

    /**
     * 该方法会被 AuthenticationManager调用,对authentication进行验证,并返回一个认证通过的{@link Authentication}
     * @param authentication
     * @return
     */
    @Override
    public Authentication authenticate(Authentication authentication){
        // 用户名密码登录方式需要在这里校验前端传入的密码和后端存储的密码是否一致
        // 但如果将短信验证码的校验放在这里的话就无法复用了,例如用户登录后访问“我的钱包”服务可能也需要发送短信验证码并进行验证
        // 因此短信验证码的校验逻辑单独抽取到一个过滤器里(留到后面实现), 这里直接返回一个认证成功的authentication
        if (authentication instanceof SmsAuthenticationToken == false) {
            throw new IllegalArgumentException("仅支持对SmsAuthenticationToken的认证");
        }

        SmsAuthenticationToken authRequest = (SmsAuthenticationToken) authentication;
        UserDetails userDetails = getUserDetailsService().loadUserByUsername((String) authentication.getPrincipal());
        SmsAuthenticationToken successfulAuthentication = new SmsAuthenticationToken(userDetails, userDetails.getPassword(), userDetails.getAuthorities());
        return successfulAuthentication;
    }

    /**
     * Authentication的authenticate方法在遍历所有AuthenticationProvider时会调用该方法判断当前AuthenticationProvider是否对
     * 某个具体Authentication的校验
     *
     * 重写此方法以支持对 {@link SmsAuthenticationToken} 的认证校验
     * @param clazz 支持的token类型
     * @return
     */
    @Override
    public boolean supports(Class<?> clazz) {
        // 如果传入的类是否是SmsAuthenticationToken或其子类
        return SmsAuthenticationToken.class.isAssignableFrom(clazz);
    }

    public UserDetailsService getUserDetailsService() {
        return userDetailsService;
    }

    /**
     * 提供对UserDetailsService的动态注入
     * @return
     */
    public void setUserDetailsService(UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }
}

SmsDetailsService

package top.zhenganwen.security.core.verifycode.sms;

import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Service;

import java.util.Objects;

/**
 * @author zhenganwen
 * @date 2019/8/30
 * @desc SmsUserDetailsService
 */
@Service
public class SmsUserDetailsService implements UserDetailsService {

    /**
     * 根据登录名查询用户,这里登录名是手机号
     *
     * @param phoneNumber
     * @return
     * @throws PhoneNumberNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String phoneNumber) throws PhoneNumberNotFoundException {
        // 实际上应该调用DAO根据手机号查询用户
        if (Objects.equals(phoneNumber, "12345678912") == false) {
            // 未查到
            throw new PhoneNumberNotFoundException();
        }
        // 查到了
        // 使用security提供的UserDetails的实现模拟查出来的用户,在你的项目中可以使用User实体类实现UserDetails接口,这样就可以直接返回查出的User实体对象
        return new User("anwen","123456", AuthorityUtils.createAuthorityList("admin","super_admin"));
    }
}

这里要注意一下,添加了该类后,容器中就有两个UserDetails组建了,之前@Autowire userDetails的地方要换成@Autowire customDetailsService,否则会报错

SmsLoginConfig

各个环节的组件我们都实现了,现在我们需要写一个配置类将这些组件串起来,告诉security这些自定义组件的存在。由于短信登录方式在PC端和移动端都用得上,因此我们将其定义在security-core

package top.zhenganwen.security.core.verifycode.sms;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.stereotype.Component;

/**
     * @author zhenganwen
     * @date 2019/8/30
     * @desc SmsSecurityConfig
     */
@Component
public class SmsLoginConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    @Autowired
    AuthenticationSuccessHandler customAuthenticationSuccessHandler;

    @Autowired
    AuthenticationFailureHandler customAuthenticationFailureHandler;

    @Autowired
    UserDetailsService smsUserDetailsService;

    @Override
    public void configure(HttpSecurity http) throws Exception {
        SmsAuthenticationFilter smsAuthenticationFilter = new SmsAuthenticationFilter();
        // 认证过滤器会请求AuthenticationManager认证authRequest,因此我们需要为其注入AuthenticatonManager,但是该实例是由Security管理的,我们需要通过getSharedObject来获取
        smsAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
        // 认证成功/失败处理器还是使用之前的
        smsAuthenticationFilter.setAuthenticationSuccessHandler(customAuthenticationSuccessHandler);
        smsAuthenticationFilter.setAuthenticationFailureHandler(customAuthenticationFailureHandler);

        SmsAuthenticationProvider smsAuthenticationProvider = new SmsAuthenticationProvider();
        // 将SmsUserDetailsService注入到SmsAuthenticationProvider中
        smsAuthenticationProvider.setUserDetailsService(smsUserDetailsService);

        // 将SmsAuthenticationProvider加入到Security管理的AuthenticationProvider集合中
        http.authenticationProvider(smsAuthenticationProvider)
            // 注意要添加到UsernamePasswordAuthenticationFilter之后,自定义的认证过滤器都应该添加到其之后,自定义的验证码等过滤器都应该添加到其之前
            .addFilterAfter(smsAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

测试

访问/login.html,点击点击发送,查看控制台输出的短信验证码,再访问/login.html进行登录,登录成功!

但是,进行用户名密码登录却失败了!提示Bad Credentials,说密码错误,于是我在校验密码的地方进行断点调试:

DaoAuthenticationProvider#additionalAuthenticationChecks

protected void additionalAuthenticationChecks(UserDetails userDetails,
                                              UsernamePasswordAuthenticationToken authentication)
    throws AuthenticationException {
    Object salt = null;

    if (this.saltSource != null) {
        salt = this.saltSource.getSalt(userDetails);
    }

    if (authentication.getCredentials() == null) {
        logger.debug("Authentication failed: no credentials provided");

        throw new BadCredentialsException(messages.getMessage(
            "AbstractUserDetailsAuthenticationProvider.badCredentials",
            "Bad credentials"));
    }

    String presentedPassword = authentication.getCredentials().toString();

    if (!passwordEncoder.isPasswordValid(userDetails.getPassword(),
                                         presentedPassword, salt)) {
        logger.debug("Authentication failed: password does not match stored value");

        throw new BadCredentialsException(messages.getMessage(
            "AbstractUserDetailsAuthenticationProvider.badCredentials",
            "Bad credentials"));
    }
}

发现passwordEncoder居然是PlaintextPasswordEncoder而不是我们注入的BCryptPasswordEncoder,这是为什么呢?

我们需要追本溯源查看该passwordEncoder是什么时候被赋值的,Alt + F7在该文件中查看该类的setPasswordEncoder(Object passwordEncoder)方法的调用时机,发现在构造方法中就会被初始化为PlaintextPasswordEncoder;但这并不是我们想要的,我们想看为什么在添加短信验证码登录功能之前注入的加密器BCryptPasswordEncoder就能生效,于是Ctrl + Alt + F7在整个项目和类库中查找setPasswordEncoder(Object passwordEncoder)的调用时机,发现如下线索:

InitializeUserDetailsManagerConfigurer

@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
    if (auth.isConfigured()) {
        return;
    }
    UserDetailsService userDetailsService = getBeanOrNull(
        UserDetailsService.class);
    if (userDetailsService == null) {
        return;
    }

    PasswordEncoder passwordEncoder = getBeanOrNull(PasswordEncoder.class);

    DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
    provider.setUserDetailsService(userDetailsService);
    if (passwordEncoder != null) {
        provider.setPasswordEncoder(passwordEncoder);
    }

    auth.authenticationProvider(provider);
}

/**
 * @return
 */
private <T> T getBeanOrNull(Class<T> type) {
    String[] userDetailsBeanNames = InitializeUserDetailsBeanManagerConfigurer.this.context
        .getBeanNamesForType(type);
    if (userDetailsBeanNames.length != 1) {
        return null;
    }

    return InitializeUserDetailsBeanManagerConfigurer.this.context
        .getBean(userDetailsBeanNames[0], type);
}

原来,在查找我们是否注入其它PasswordEncoder实例并试图向DaoAuthenticationProvider注入我们配置的BCryptPasswordEncoder之前,会从容器中获取UserDetails实例,如果容器中没有或者实例个数大于1,那么就返回了。

原来,是我们在实现短信验证码登录功能时,在SmsUserDetailsService标注的@Component导致容器中存在了smsUserDetailsService和之前的customUserDetailsService两个UserDetailsService实例,以至于上述代码12之后的代码都未执行,也就是说我们的CustomUserDetailsServiceBCryptPasswordEncoder都没有注入到DaoAuthenticationProvider中去。

至于为什么校验密码之前,DaoAuthenticationProvider中的this.getUserDetailsService().loadUserByUsername(username)仍能调用CustomUserDetailsService以及为什么是CustomUserDetailsService被注入到了DaoAuthenticationProvider中而不是SmsUserDetialsService,还有待分析

既然找到了问题所在(容器中存在两个UserDetailsService实例),简单的解决办法就是去掉SmsUserDetailsService@Component,在配置短信登录串联组件时自己new一个就好了

//@Component
public class SmsUserDetailsService implements UserDetailsService {
@Component
public class SmsLoginConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    @Autowired
    AuthenticationSuccessHandler customAuthenticationSuccessHandler;

    @Autowired
    AuthenticationFailureHandler customAuthenticationFailureHandler;

    //	@Autowired
    //	SmsUserDetailsService smsUserDetailsService;

    @Override
    public void configure(HttpSecurity http) throws Exception {

        SmsAuthenticationFilter smsAuthenticationFilter = new SmsAuthenticationFilter();
        smsAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
        smsAuthenticationFilter.setAuthenticationSuccessHandler(customAuthenticationSuccessHandler);
        smsAuthenticationFilter.setAuthenticationFailureHandler(customAuthenticationFailureHandler);

        SmsAuthenticationProvider smsAuthenticationProvider = new SmsAuthenticationProvider();
        // 自己new一下    
        SmsUserDetailsService smsUserDetailsService = new SmsUserDetailsService();
        smsAuthenticationProvider.setUserDetailsService(smsUserDetailsService);

        http.authenticationProvider(smsAuthenticationProvider)
            .addFilterAfter(smsAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

重新测试两种登录方式,均能通过!

短信验证码过滤器

上节说道,为了复用,我们应该将短信验证码的验证逻辑单独放到一个过滤器中,这里我们可以参考之前写的图形验证码过滤器,复制一份改一改

package top.zhenganwen.security.core.verifycode.filter;

import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.social.connect.web.HttpSessionSessionStrategy;
import org.springframework.social.connect.web.SessionStrategy;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.filter.OncePerRequestFilter;
import top.zhenganwen.security.core.properties.SecurityProperties;
import top.zhenganwen.security.core.verifycode.exception.VerifyCodeException;
import top.zhenganwen.security.core.verifycode.po.VerifyCode;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;

import static top.zhenganwen.security.core.verifycode.constont.VerifyCodeConstant.SMS_CODE_SESSION_KEY;

/**
 * @author zhenganwen
 * @date 2019/8/24
 * @desc VerifyCodeAuthenticationFilter
 */
@Component
public class SmsCodeAuthenticationFilter extends OncePerRequestFilter implements InitializingBean {

    @Autowired
    private AuthenticationFailureHandler customAuthenticationFailureHandler;

    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

    @Autowired
    private SecurityProperties securityProperties;

    private Set<String> uriPatternSet = new HashSet<>();

    // uri匹配工具类,帮我们做类似/user/1到/user/*的匹配
    private AntPathMatcher antPathMatcher = new AntPathMatcher();

    @Override
    public void afterPropertiesSet() throws ServletException {
        super.afterPropertiesSet();
        String uriPatterns = securityProperties.getCode().getSms().getUriPatterns();
        if (StringUtils.isNotBlank(uriPatterns)) {
            String[] strings = StringUtils.splitByWholeSeparatorPreserveAllTokens(uriPatterns, ",");
            uriPatternSet.addAll(Arrays.asList(strings));
        }
        uriPatternSet.add("/auth/sms");
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException,
    IOException {
        for (String uriPattern : uriPatternSet) {
            // 有一个匹配就需要拦截 校验验证码
            if (antPathMatcher.match(uriPattern, request.getRequestURI())) {
                try {
                    this.validateVerifyCode(new ServletWebRequest(request));
                } catch (VerifyCodeException e) {
                    customAuthenticationFailureHandler.onAuthenticationFailure(request, response, e);
                    return;
                }
                break;
            }
        }
        filterChain.doFilter(request, response);
    }

    // 拦截用户登录的请求,从Session中读取保存的短信验证码和用户提交的验证码进行比对
    private void validateVerifyCode(ServletWebRequest request){
        String smsCode = (String) request.getParameter("smsCode");
        if (StringUtils.isBlank(smsCode)) {
            throw new VerifyCodeException("验证码不能为空");
        }
        VerifyCode verifyCode = (VerifyCode) sessionStrategy.getAttribute(request, SMS_CODE_SESSION_KEY);
        if (verifyCode == null) {
            throw new VerifyCodeException("验证码不存在");
        }
        if (verifyCode.isExpired()) {
            throw new VerifyCodeException("验证码已过期,请刷新页面");
        }
        if (StringUtils.equals(smsCode,verifyCode.getCode()) == false) {
            throw new VerifyCodeException("验证码错误");
        }
        sessionStrategy.removeAttribute(request, SMS_CODE_SESSION_KEY);
    }
}

然后记得将其添加到security的过滤器链中,并且只能添加到所有认证过滤器之前:

SecurityBrowserConfig

@Override
protected void configure(HttpSecurity http) throws Exception {

    http
        .addFilterBefore(smsCodeAuthenticationFilter,UsernamePasswordAuthenticationFilter.class)
        .addFilterBefore(verifyCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
        .formLogin()
        .loginPage("/auth/require")
        .loginProcessingUrl("/auth/login")
        .successHandler(customAuthenticationSuccessHandler)
        .failureHandler(customAuthenticationFailureHandler)
        .and()
        .rememberMe()
        .tokenRepository(persistentTokenRepository())
        .tokenValiditySeconds(3600)
        .userDetailsService(customUserDetailsService)
        //                    可配置页面选框的name属性
        //                    .rememberMeParameter()
        .and()
        .authorizeRequests()
        .antMatchers(
        "/auth/require",
        securityProperties.getBrowser().getLoginPage(),
        "/verifyCode/**").permitAll()
        .anyRequest().authenticated()
        .and()
        .csrf().disable()
        .apply(smsLoginConfig);
}

最后在login.html中修改登录URL/auth/sms以及短信验证码参数名smsCode

<form action="/auth/login" method="post">
    用户名: <input type="text" name="username" value="admin">
    密码: <input type="password" name="password" value="123">
    验证码:<input type="text" name="verifyCode"><img src="/verifyCode/image?width=600" alt="">
    <input type="checkbox" name="remember-me" value="true">记住我
    <button type="submit">提交</button>
</form>
<hr/>
<form action="/auth/sms" method="post">
    手机号: <input type="text" name="phoneNumber" value="12345678912">
    验证码: <input type="text" name="smsCode"><a href="/verifyCode/sms?phoneNumber=12345678912">点击发送</a>
    <input type="checkbox" name="remember-me" value="true">记住我
    <button type="submit">提交</button>
</form>

重构——消除重复代码

之前我们将图形验证码过滤器的代码COPY一份改了改就成了短信验证码过滤器,这两个类的主流程是相同的,只是具体实现稍有不同(从Session中读写不同的key对应的验证码对象),这可以使用模板方法进行抽取

我们代码中还存在很多字面量魔法值,我们也应该尽量消除他们,将它们提取成常量或配置属性,在需要用到的地方统一进行引用,这样就不会导致后续需要更改时忘记了某处的魔法值而导致异常。例如,如果仅仅将.loginPage("/auth/require")改为.loginPage("/authentication/require"),而没有通过更改BrowserSecurityController中的@RequestMapping("/auth/require"),就会导致程序功能出现问题

我们可以将系统配置相关的代码分模块封装成对应的配置类放在security-core中,security-browsersecurity-app中只留自身特有的配置(例如将token写到cookie中的remember-me方式应该放在security-browser中,而security-app中对应放移动端remember-me的配置方式),最后security-browsersecurity-app都可以通过http.apply的方式引用security-core中的通用配置,以实现代码的复用

只要你的项目中出现了两处以上相同的代码,你敏锐的嗅觉就应该发现这些最不起眼但也是最需要注意的代码坏味道,应该想办法及时重构而不要等到系统庞大后想动却牵一发而动全身

魔法值重构

package top.zhenganwen.security.core.verifycode.filter;

public enum VerifyCodeType {

    SMS{
        @Override
        public String getVerifyCodeParameterName() {
            return SecurityConstants.DEFAULT_SMS_CODE_PARAMETER_NAME;
        }
    },

    IMAGE{
        @Override
        public String getVerifyCodeParameterName() {
            return SecurityConstants.DEFAULT_IMAGE_CODE_PARAMETER_NAME;
        }
    };

    public abstract String getVerifyCodeParameterName();
}
package top.zhenganwen.security.core;

public interface SecurityConstants {

    /**
     * 表单密码登录URL
     */
    String DEFAULT_FORM_LOGIN_URL = "/auth/login";

    /**
     * 短信登录URL
     */
    String DEFAULT_SMS_LOGIN_URL = "/auth/sms";

    /**
     * 前端图形验证码参数名
     */
    String DEFAULT_IMAGE_CODE_PARAMETER_NAME = "imageCode";

    /**
     * 前端短信验证码参数名
     */
    String DEFAULT_SMS_CODE_PARAMETER_NAME = "smsCode";

    /**
     * 图形验证码缓存在Session中的key
     */
    String IMAGE_CODE_SESSION_KEY = "IMAGE_CODE_SESSION_KEY";

    /**
     * 短信验证码缓存在Session中的key
     */
    String SMS_CODE_SESSION_KEY = "SMS_CODE_SESSION_KEY";

    /**
     * 验证码校验器bean名称的后缀
     */
    String VERIFY_CODE_VALIDATOR_NAME_SUFFIX = "CodeValidator";

    /**
     * 未登录访问受保护URL则跳转路径到 此
     */
    String FORWARD_TO_LOGIN_PAGE_URL = "/auth/require";

    /**
     * 用户点击发送验证码调用的服务
     */
    String VERIFY_CODE_SEND_URL = "/verifyCode/**";
}

验证码过滤器重构

image.png

  • VerifyCodeValidatorFilter,责任是拦截需要进行验证码校验的请求
  • VerifyCodeValidator,使用模板方法,抽象验证码的校验逻辑
  • VerifyCodeValidatorHolder,利用Spring的依赖查找,聚集容器中所有的VerifyCodeValidator实现类(各种验证码的具体验证逻辑),对外提供根据验证码类型获取对应验证码校验bean的方法

login.html,将其中图形验证码参数改成了imageCode

<form action="/auth/login" method="post">
    用户名: <input type="text" name="username" value="admin">
    密码: <input type="password" name="password" value="123">
    验证码:<input type="text" name="imageCode"><img src="/verifyCode/image?width=600" alt="">
    <input type="checkbox" name="remember-me" value="true">记住我
    <button type="submit">提交</button>
</form>
<hr/>
<form action="/auth/sms" method="post">
    手机号: <input type="text" name="phoneNumber" value="12345678912">
    验证码: <input type="text" name="smsCode"><a href="/verifyCode/sms?phoneNumber=12345678912">点击发送</a>
    <input type="checkbox" name="remember-me" value="true">记住我
    <button type="submit">提交</button>
</form>

VerifyCodeValidateFilter

package top.zhenganwen.security.core.verifycode.filter;

import static top.zhenganwen.security.core.SecurityConstants.DEFAULT_SMS_LOGIN_URL;

@Component
public class VerifyCodeValidateFilter extends OncePerRequestFilter implements InitializingBean {

    // 认证失败处理器
    @Autowired
    private AuthenticationFailureHandler authenticationFailureHandler;

    // session读写工具
    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

    // 映射 需要校验验证码的 uri 和 校验码类型,如 /auth/login -> 图形验证码  /auth/sms -> 短信验证码
    private Map<String, VerifyCodeType> uriMap = new HashMap<>();

    @Autowired
    private SecurityProperties securityProperties;

    private AntPathMatcher antPathMatcher = new AntPathMatcher();

    @Autowired
    private VerifyCodeValidatorHolder verifyCodeValidatorHolder;

    @Override
    public void afterPropertiesSet() throws ServletException {
        super.afterPropertiesSet();

        uriMap.put(SecurityConstants.DEFAULT_FORM_LOGIN_URL, VerifyCodeType.IMAGE);
        putUriPatterns(uriMap, securityProperties.getCode().getImage().getUriPatterns(), VerifyCodeType.IMAGE);

        uriMap.put(SecurityConstants.DEFAULT_SMS_LOGIN_URL, VerifyCodeType.SMS);
        putUriPatterns(uriMap, securityProperties.getCode().getSms().getUriPatterns(), VerifyCodeType.SMS);
    }

    private void putUriPatterns(Map<String, VerifyCodeType> urlMap, String uriPatterns, VerifyCodeType verifyCodeType) {
        if (StringUtils.isNotBlank(uriPatterns)) {
            String[] strings = StringUtils.splitByWholeSeparatorPreserveAllTokens(uriPatterns, ",");
            for (String string : strings) {
                urlMap.put(string, verifyCodeType);
            }
        }
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException
            , IOException {
        try {
            checkVerifyCodeIfNeed(request, uriMap);
        } catch (VerifyCodeException e) {
            authenticationFailureHandler.onAuthenticationFailure(request, response, e);
            return;
        }
        filterChain.doFilter(request, response);
    }

    private void checkVerifyCodeIfNeed(HttpServletRequest request, Map<String, VerifyCodeType> uriMap) {
        String requestUri = request.getRequestURI();
        Set<String> uriPatterns = uriMap.keySet();
        for (String uriPattern : uriPatterns) {
            if (antPathMatcher.match(uriPattern, requestUri)) {
                VerifyCodeType verifyCodeType = uriMap.get(uriPattern);
                verifyCodeValidatorHolder.getVerifyCodeValidator(verifyCodeType).validateVerifyCode(new ServletWebRequest(request), verifyCodeType);
                break;
            }
        }
    }

}

VerifyCodeValidator

package top.zhenganwen.security.core.verifycode.filter;

import java.util.Objects;

public abstract class VerifyCodeValidator {

    protected SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

    @Autowired
    private VerifyCodeValidatorHolder verifyCodeValidatorHolder;

    /**
     * 校验验证码
     * 1.从请求中获取传入的验证码
     * 2.从服务端获取存储的验证码
     * 3.校验验证码
     * 4.校验成功移除服务端验证码,校验失败抛出异常信息
     *
     * @param request
     * @param verifyCodeType
     * @throws VerifyCodeException
     */
    public void validateVerifyCode(ServletWebRequest request, VerifyCodeType verifyCodeType) throws VerifyCodeException {
        String requestCode = getVerifyCodeFromRequest(request, verifyCodeType);

        VerifyCodeValidator codeValidator = verifyCodeValidatorHolder.getVerifyCodeValidator(verifyCodeType);
        if (Objects.isNull(codeValidator)) {
            throw new VerifyCodeException("不支持的验证码校验类型: " + verifyCodeType);
        }
        VerifyCode storedVerifyCode = codeValidator.getStoredVerifyCode(request);

        codeValidator.validate(requestCode, storedVerifyCode);

        codeValidator.removeStoredVerifyCode(request);
    }

    /**
     * 校验验证码是否过期,默认进行简单的文本比对,子类可重写以校验传入的明文验证码和后端存储的密文验证码
     *
     * @param requestCode
     * @param storedVerifyCode
     */
    private void validate(String requestCode, VerifyCode storedVerifyCode) {
        if (Objects.isNull(storedVerifyCode) || storedVerifyCode.isExpired()) {
            throw new VerifyCodeException("验证码已失效,请重新生成");
        }
        if (StringUtils.isBlank(requestCode)) {
            throw new VerifyCodeException("验证码不能为空");
        }
        if (StringUtils.equalsIgnoreCase(requestCode, storedVerifyCode.getCode()) == false) {
            throw new VerifyCodeException("验证码错误");
        }
    }

    /**
     * 是从Session中还是从其他缓存方式移除验证码由子类自己决定
     *
     * @param request
     */
    protected abstract void removeStoredVerifyCode(ServletWebRequest request);

    /**
     * 是从Session中还是从其他缓存方式读取验证码由子类自己决定
     *
     * @param request
     * @return
     */
    protected abstract VerifyCode getStoredVerifyCode(ServletWebRequest request);


    /**
     * 默认从请求中获取验证码参数,可被子类重写
     *
     * @param request
     * @param verifyCodeType
     * @return
     */
    private String getVerifyCodeFromRequest(ServletWebRequest request, VerifyCodeType verifyCodeType) {
        try {
            return ServletRequestUtils.getStringParameter(request.getRequest(), verifyCodeType.getVerifyCodeParameterName());
        } catch (ServletRequestBindingException e) {
            throw new VerifyCodeException("非法请求,请附带验证码参数");
        }
    }

}

ImageCodeValidator

package top.zhenganwen.security.core.verifycode.filter;

@Component
public class ImageCodeValidator extends VerifyCodeValidator {

    @Override
    protected void removeStoredVerifyCode(ServletWebRequest request) {
        sessionStrategy.removeAttribute(request, SecurityConstants.IMAGE_CODE_SESSION_KEY);
    }

    @Override
    protected VerifyCode getStoredVerifyCode(ServletWebRequest request) {
        return (VerifyCode) sessionStrategy.getAttribute(request, SecurityConstants.IMAGE_CODE_SESSION_KEY);
    }
}

SmsCodeValidator

package top.zhenganwen.security.core.verifycode.filter;

@Component
public class SmsCodeValidator extends VerifyCodeValidator {

    @Override
    protected void removeStoredVerifyCode(ServletWebRequest request) {
        sessionStrategy.removeAttribute(request, SecurityConstants.SMS_CODE_SESSION_KEY);
    }

    @Override
    protected VerifyCode getStoredVerifyCode(ServletWebRequest request) {
        return (VerifyCode) sessionStrategy.getAttribute(request,SecurityConstants.SMS_CODE_SESSION_KEY);
    }
}

VerifyCodeValidatorHolder

package top.zhenganwen.security.core.verifycode.filter;

@Component
public class VerifyCodeValidatorHolder {

    @Autowired
    private Map<String, VerifyCodeValidator> verifyCodeValidatorMap = new HashMap<>();

    public VerifyCodeValidator getVerifyCodeValidator(VerifyCodeType verifyCodeType) {
        VerifyCodeValidator verifyCodeValidator =
                verifyCodeValidatorMap.get(verifyCodeType.toString().toLowerCase() + SecurityConstants.VERIFY_CODE_VALIDATOR_NAME_SUFFIX);
        if (Objects.isNull(verifyCodeType)) {
            throw new VerifyCodeException("不支持的验证码类型:" + verifyCodeType);
        }
        return verifyCodeValidator;
    }

}

SecurityBrowserConfig

@Autowire
VerifyCodeValidatorFilter verifyCodeValidatorFilter;

http
//                .addFilterBefore(smsCodeAuthenticationFilter,UsernamePasswordAuthenticationFilter.class)
//                .addFilterBefore(verifyCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(verifyCodeValidateFilter, UsernamePasswordAuthenticationFilter.class)
                .formLogin()
                    .loginPage(SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL)
                    .loginProcessingUrl(SecurityConstants.DEFAULT_FORM_LOGIN_URL)
                    .successHandler(customAuthenticationSuccessHandler)
                    .failureHandler(customAuthenticationFailureHandler)
                    .and()
                .rememberMe()
                    .tokenRepository(persistentTokenRepository())
                    .tokenValiditySeconds(3600)
                    .userDetailsService(customUserDetailsService)
                    .and()
                .authorizeRequests()
                    .antMatchers(
                            SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL,
                            securityProperties.getBrowser().getLoginPage(),
                            SecurityConstants.VERIFY_CODE_SEND_URL).permitAll()
                    .anyRequest().authenticated()
                .and()
                .csrf().disable()
                .apply(smsLoginConfig);

系统配置重构

image.png

security-core

package top.zhenganwen.security.core.config;

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

    @Autowired
    AuthenticationSuccessHandler customAuthenticationSuccessHandler;

    @Autowired
    AuthenticationFailureHandler customAuthenticationFailureHandler;

    @Override
    public void configure(HttpSecurity http) throws Exception {

        SmsAuthenticationFilter smsAuthenticationFilter = new SmsAuthenticationFilter();
        smsAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
        smsAuthenticationFilter.setAuthenticationSuccessHandler(customAuthenticationSuccessHandler);
        smsAuthenticationFilter.setAuthenticationFailureHandler(customAuthenticationFailureHandler);

        SmsAuthenticationProvider smsAuthenticationProvider = new SmsAuthenticationProvider();
        SmsUserDetailsService smsUserDetailsService = new SmsUserDetailsService();
        smsAuthenticationProvider.setUserDetailsService(smsUserDetailsService);

        http.authenticationProvider(smsAuthenticationProvider)
            .addFilterAfter(smsAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    }
}
package top.zhenganwen.security.core.config;

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

    @Autowired
    private VerifyCodeValidateFilter verifyCodeValidateFilter;

    @Override
    public void configure(HttpSecurity builder) throws Exception {
        builder.addFilterBefore(verifyCodeValidateFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

security-browser

package top.zhenganwen.securitydemo.browser;

@Configuration
public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Autowired
    private SecurityProperties securityProperties;

    @Autowired
    private AuthenticationSuccessHandler customAuthenticationSuccessHandler;

    @Autowired
    private AuthenticationFailureHandler customAuthenticationFailureHandler;

    @Autowired
    private DataSource dataSource;

    @Autowired
    private UserDetailsService customUserDetailsService;

    @Bean
    public PersistentTokenRepository persistentTokenRepository() {
        JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
        jdbcTokenRepository.setDataSource(dataSource);
        return jdbcTokenRepository;
    }

    @Autowired
    SmsLoginConfig smsLoginConfig;

    @Autowired
    private VerifyCodeValidatorConfig verifyCodeValidatorConfig;

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        // 启用验证码校验过滤器
        http.apply(verifyCodeValidatorConfig);
        // 启用短信登录过滤器
        http.apply(smsLoginConfig);
        
        http
                // 启用表单密码登录过滤器
                .formLogin()
                    .loginPage(SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL)
                    .loginProcessingUrl(SecurityConstants.DEFAULT_FORM_LOGIN_URL)
                    .successHandler(customAuthenticationSuccessHandler)
                    .failureHandler(customAuthenticationFailureHandler)
                    .and()
                // 浏览器应用特有的配置,将登录后生成的token保存在cookie中
                .rememberMe()
                    .tokenRepository(persistentTokenRepository())
                    .tokenValiditySeconds(3600)
                    .userDetailsService(customUserDetailsService)
                    .and()
                // 浏览器应用特有的配置
                .authorizeRequests()
                    .antMatchers(
                            SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL,
                            securityProperties.getBrowser().getLoginPage(),
                            SecurityConstants.VERIFY_CODE_SEND_URL).permitAll()
                    .anyRequest().authenticated().and()
                .csrf().disable();
    }
}

使用Spring Social开发第三方登录

OAuth协议简介

产生背景

有时应用与应用之间会进行合作,已达到共赢的目的。例如时下较火的微信公众号、微信小程序。一方面,公众号、小程序开发者能够以丰富的内容吸引微信用户为微信提高用户留存率;另一方面,公众号、小程序能够借助微信强大的用户基础为自己的服务引流

这时问题来了,如果使用最传统的方式,小程序要想取得用户信息而向用户申请索取账号密码(例如美颜小程序需要读取用户的微信相册进行美化),且不说用户给不给,就算用户给了,那么还是会存在以下几个问题(以美颜小程序为例)

  • 访问权限

    无法控制小程序的访问权限,说是只读取微信相册,谁知道他拿了账号密码后会不会查看微信好友、使用微信钱包呢

  • 授权时效

    一旦小程序获取到用户的账号密码,用户便无法控制此次授权后,该小程序日后还不会使用该账号密码进行非法登录,用户只有每次授权后更改密码才行

  • 可靠性

    如果用户采用此种方式对多个小程序进行授权,一旦小程序泄露用户密码,那么用户面临被盗号的危险

OAuth解决方案

用户同意授权给第三方应用(如微信小程序相对于微信用户)时,只会给第三方应用一个token令牌(第三方应用可以通过这个token访问用户的特定数据资源),这个令牌就是为了解决上述问题而生:

  • 令牌是有时限的,只在规定的时间内有效,解决了 授权时效 的问题
  • 令牌只能访问用户授予访问的特定资源,解决了 访问权限 的问题
  • 令牌是一串短期有效,过期则没有任何意义的随机字符串 ,解决了 可靠性 问题

OAuth协议运行流程

首先介绍一下涉及到的几个角色及其职责:

  • Provider,服务提供商,如微信、QQ,拥有大量的用户数据
    • Authorization Server,认证服务器,用户同意授权后,由认证服务器来生成token传给第三方应用
    • Resource Server,存储了第三方应用所需的资源,确认token无误则开放相应资源给第三方应用
  • Resource Owner,资源所有者,如微信用户就是微信相册的资源所有者,相片是微信用户拍的,只不过存储在了微信服务器上
  • Client,第三方应用,需要依赖具有强大用户基础的服务提供商进行引流的应用

image.png

上述第二步还涉及到几种授权模式:

  • 授权码模式(authorization code)
  • 密码模式(resource owner password credentials)
  • 客户卡模式(client credentials)
  • 简化模式(implicit)

本章和下一章(app)将分别详细介绍前两种模式,现在互联网上几乎大部分社交平台如QQ、微博、淘宝等服务提供商都是采用的授权码模式

授权码模式授权流程

以我们平常访问某社交网站时不想注册该网站用户而直接使用QQ登录这一场景为例,如图是该社交网站作为第三方应用使用OAuth协议开发QQ联合登录的大致时序图

image.png

授权码模式之所以被广泛使用,其原因有如下两点:

  • 用户同意授权这一行为是在认证服务器上进行确认的,相比较其他3种模式在第三方应用客户端上确认(客户端可伪造用户同意授权)而言,更加透明
  • 认证服务器不是直接返回token,而是先返回授权码。像有的静态网站可能会使用implicit模式让认证服务器直接返回token从而再在页面上使用AJAX调用资源服务器接口。前者是认证服务器对接第三方应用服务器(认证服务器返回token是通过回调与第三方应用事先约定好的第三方应用接口并传入token,因此所有token都是存放在服务端的);而后者是认证服务器对接浏览器等第三方应用的客户端,token直接传给客户端存在安全风险

这也是为什么现在主流的服务提供商都采用授权码模式,因为其授权流程更完备、更安全。

Spring Social基本原理

Spring Social其实就是将上述时序图所描述的授权流程封装到了特定的类和接口中了。OAuth协议有两个版本,国外很早就用了所以流行OAuth1,而国内用得比较晚因此基本都是OAuth2,本章也是基于OAuth2来集成QQ、微信登录功能。

image.png

如图是Spring Social的主要组件,各功能如下:

  • OAuth2Operations,封装从请求用户授权到认证服务向我们返回token的整个流程。OAuth2Template是为我们提供的默认实现,这个流程基本上是固定的,无需我们介入
  • Api,封装拿到token后我们调用资源服务器接口获取用户信息的过程,这个需要我们自己定义,毕竟框架也不知道我们要接入哪个开放平台,但它也为我们提供了一个抽象AbstractOAuth2ApiBinding
  • AbstractOAuth2ServiceProvider,集成OAuth2OperationApi,串起获取token和拿token访问用户资源两个过程
  • Connection,统一用户视图,由于各服务提供商返回的用户信息数据结构是不一致的,我们需要通过适配器ApiAdapter将其统一适配到Connection这个数据结构上,可以看做用户在服务提供商中的实体
  • OAuth2ConnectionFactory,集成AbstractOAuth2ServiceProviderApiAdapter,完成整个用户授权以及获取用户信息实体的流程
  • UsersConnectionRepository,我们的系统中一般都有自己的用户表,如何将接入系统的用户实体Connection和我们自己的用户实体User进行对应就靠它来完成,用来完成我们userIdConnection的映射

开发QQ登录功能

未完待续……

参考资料

视频教程 链接: pan.baidu.com/s/1wQWD4wE0… 提取码: z6zi