Spring Security 技術棧開發企業級認證受權(2)

我的博客:www.zhenganwen.top,文末有驚喜! 本文是《Spring Security 技術棧開發企業級認證受權》一文的後續html

使用Spring Security開發基於表單的認證

實現圖形驗證碼功能

功能實現

因爲圖形驗證碼是通用功能,因此咱們將相關邏輯寫在security-code前端

首先,將圖形、圖形中的驗證碼、驗證碼過時時間封裝在一塊兒java

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);
    }
}
複製代碼

而後提供一個生成驗證碼的接口web

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的配置類中將生成驗證碼的接口權限放開:redis

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中添加驗證碼輸入框:spring

<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,驗證碼生成以下:sql

image.png

接下來咱們編寫驗證碼校驗邏輯,因爲security並未提供驗證碼校驗對應的過濾器,所以咱們須要自定義一個並將其插入到UsernamePasswordFilter以前:shell

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以下apache

{"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

相關文章
相關標籤/搜索