三、Spring Security表單驗證碼

表單驗證碼登錄

表單登錄驗證碼驗證,一般在用戶名、密碼提交登錄前,添加過濾器,先驗證驗證碼的有效性(開發(fā)中一般用的這種),然后再提交用戶名、密碼。文章下面還會使用另一種方法:驗證碼和用戶名、密碼一起同時提交登錄。

Spring Security中,兩種實現(xiàn)方式為:

  • 使用自定義過濾器(Filter),在提交用戶名、密碼前,先驗證驗證碼的有效性
  • 驗證碼和用戶名、密碼一起在Spring Security中進(jìn)行驗證

一、驗證碼生成

新建一個包validateCode放置所有驗證碼相關(guān)的類。

1.1、驗證碼實體對象

@Data
public class ValidateCode {
    private BufferedImage image;
    private String code;
    private LocalDateTime expireTime;

    /**
     * @param expirtSecond 設(shè)置過期時間,單位秒
     */
    public ValidateCode(BufferedImage image, String code, int expirtSecond){
        this.image = image;
        this.code = code;
        // expireSecond秒后的時間
        this.expireTime = LocalDateTime.now().plusSeconds(expirtSecond);
    }
    /**
     * 驗證碼是否過期
     */
    public boolean isExpired(){
        return LocalDateTime.now().isAfter(expireTime);
    }
}

1.2、生成驗證碼:

@Service
public class ValidateCodeCreateService {
    public ValidateCode createImageCode() {
        // 寬度
        // 從請求參數(shù)中獲取數(shù)據(jù),否則,讀取配置文件配置值
        int width = 80;
        // 高度
        int height = 30;
        // 認(rèn)證碼長度
        int charLength = 4;
        // 過期時間(秒)
        int expireTime = 60;
        BufferedImage image = new BufferedImage(width, height,
                BufferedImage.TYPE_INT_RGB);
        // 獲取圖形上下文
        Graphics g = image.getGraphics();
        // 生成隨機類
        Random random = new Random();
        // 設(shè)定背景色
        g.setColor(getRandColor(200, 250));
        g.fillRect(0, 0, width, height);
        // 設(shè)定字體
        g.setFont(new Font("Times New Roman", Font.PLAIN, 18));
        // 隨機產(chǎn)生155條干擾線,使圖象中的認(rèn)證碼不易被其它程序探測到
        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);
        }
        // 取隨機產(chǎn)生的認(rèn)證碼
        String sRand = "";
        for (int i = 0; i < charLength; i++) {
            String rand = String.valueOf(random.nextInt(10));
            sRand += rand;
            // 將認(rèn)證碼顯示到圖象中
            g.setColor(new Color(20 + random.nextInt(110), 20 + random
                    .nextInt(110), 20 + random.nextInt(110)));
            // 調(diào)用函數(shù)出來的顏色相同,可能是因為種子太接近,所以只能直接生成
            g.drawString(rand, 13 * i + 6, 16);
        }
        // 圖象生效
        g.dispose();
        return new ValidateCode(image, sRand, expireTime);
    }

    /**
     * 給定范圍獲得隨機顏色
     */
    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);
    }
}

驗證碼圖片生成接口

@RestController
public class ValidateCodeController {
    @Autowired
    private ValidateCodeCreateService validateCodeCreateService;

    @GetMapping("/get-validate-code")
    public void getImageCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
        // 創(chuàng)建驗證碼
        ValidateCode validateCode = validateCodeCreateService.createImageCode();
        // 將驗證碼放到session中(也可放在Redis中,可設(shè)置過期時間)
        request.getSession().setAttribute("validate-code", validateCode);
        // 返回驗證碼給前端
        ImageIO.write(validateCode.getImage(), "JPEG", response.getOutputStream());
    }
}

二、登錄頁面配置

修改resources/templates下登錄頁面,添加驗證碼選項:

<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
    <meta charset="UTF-8">
    <title>登錄頁面</title>
</head>
<body>

<form th:action="@{/my-login}" method="post">
    <div><label> 用戶名 : <input type="text" name="username"/> </label></div>
    <div><label> 密碼: <input type="password" name="password"/> </label></div>
    <div>驗證碼:
        <input type="text" class="form-control" name="validateCode" required="required" placeholder="驗證碼">
        <img src="get-validate-code" title="看不清,請點我" onclick="refresh(this)" />
    </div>
    <button type="submit" class="btn">登錄</button>
</form>
<script>
    function refresh(obj) { obj.src = "get-validate-code"; }
</script>
</body>
</html>

WebSecurityConfig配置:

@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    // ...
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                // 獲取驗證碼允許匿名訪問
                .antMatchers("/get-validate-code").permitAll()
                .anyRequest().authenticated()
            .and()
                .formLogin()
                .loginPage("/user-login").permitAll()
                .loginProcessingUrl("/my-login")
    // ...
    }
}

正常項目已經(jīng)配置好,啟動項目,訪問localhost:8080/hello跳轉(zhuǎn)到自定義的登錄頁面:

圖片

隨便輸入內(nèi)容提交,登錄失敗,返回:

圖片

輸入正確的用戶名、密碼,驗證碼隨意輸入登錄,登錄成功,返回:

圖片

可以看到,這里Spring Security默認(rèn)只驗證用戶名、密碼,沒有驗證驗證碼是否正確。所以下面開始實現(xiàn)登錄驗證碼驗證,有以下兩種種實現(xiàn)方式:

  1. 使用自定義過濾器(Filter),在校驗用戶名、密碼前判斷驗證碼合法性,驗證通過后,通過用戶名和密碼登錄
  2. 驗證碼和用戶名、密碼一起提交到后臺登錄

三、過濾器驗證

原理:在 Spring Security 處理登錄請求前,先驗證驗證碼,如果正確,放行去登錄;如果不正確,返回失敗處理。

2.1、驗證碼過濾器

自定義一個過濾器,OncePerRequestFilter(該Filter保證每次請求只過濾一次):

public class ValidateCodeFilter extends OncePerRequestFilter {
    // URL正則匹配
    private static final PathMatcher pathMatcher = new AntPathMatcher();

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 只有登錄請求‘/authentication/form’,并且為'post'請求時,才校驗
        if ("POST".equals(request.getMethod())
                && pathMatcher.match("/anthentication/form", request.getServletPath())) {
            try {
                codeValidate(request);
            } catch (ValidateCodeException e) {
               // 驗證碼不通過,跳到錯誤處理器處理
                response.setContentType("application/json;charset=UTF-8");
                response.getWriter().append(
                    new ObjectMapper().createObjectNode()
                        .put("status", "500")
                        .put("msg", e.getMessage())
                        .toString());
                // 異常后,不執(zhí)行后面
                return;
            }
        }
        doFilter(request, response, filterChain);
    }

    private void codeValidate(HttpServletRequest request) throws JsonProcessingException {
        // 獲取到傳入的驗證碼
        String codeInRequest = request.getParameter("validateCode");
        ValidateCode codeInSession = (ValidateCode) request.getSession(false).getAttribute("validate-code");

        // 校驗驗證碼是否正確
        if (StringUtils.isEmpty(codeInRequest)) {
            throw new ValidateCodeException("驗證碼的值不能為空");
        }
        if (codeInSession == null) {
            throw new ValidateCodeException("驗證碼不存在");
        }
        if (codeInSession.isExpired()) {
            throw new ValidateCodeException("驗證碼已過期");
        }
        if (!StringUtils.equals(codeInSession.getCode(), codeInRequest)) {
            throw new ValidateCodeException("驗證碼不匹配");
        }

        // 校驗正確后,移除session中驗證碼
        request.getSession(false).removeAttribute("validate-code");
    }
}

class ValidateCodeException extends AuthenticationException {
    public ValidateCodeException(String message) {
        super(message);
    }
}

2.2、配置過濾器

Spring Security 對于用戶名/密碼登錄驗證是通過 UsernamePasswordAuthenticationFilter 處理的,只要在它之前執(zhí)行驗證碼過濾器即可:

@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    ...
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                // 驗證碼過濾器在用戶名、密碼校驗前
                .addFilterBefore(new ValidateCodeFilter(), UsernamePasswordAuthenticationFilter.class)
                .authorizeRequests()
                .antMatchers("/get-validate-code").permitAll()
                .anyRequest().authenticated()
            .and()
                .formLogin()
                .loginPage("/user-login").permitAll()
                .loginProcessingUrl("/my-login")
    }
}

2.4、運行程序

啟動項目,訪問localhost:8080/login到登錄頁,隨機輸入內(nèi)容登錄:

圖片

點擊登錄后,后臺驗證驗證碼錯誤,顯示如下:

圖片

輸入正確的驗證碼,而用戶名、密碼錯誤:

圖片

全部正確時,返回用戶信息:

圖片

四、和用戶名、密碼同時驗證

上面使用過濾器實現(xiàn)了驗證碼功能,該過濾器是先驗證驗證碼,驗證成功就讓 Spring Security 驗證用戶名和密碼。

如果用戶登錄是需要多個登錄字段,不單單是用戶名和密碼,這時候可以考慮自定義 Spring Security 的驗證邏輯。

3.1、WebAuthenticationDetails

Spring security 默認(rèn)只會處理用戶名和密碼信息,如果我們需要增加驗證碼字段驗證,則需要拿到驗證碼。而WebAuthenticationDetails類提供了獲取用戶登錄時攜帶的額外信息的功能,可以通過該類拿到驗證碼。所以我們需要自定義類繼承該類拿到驗證碼:

public class CustomWebAuthenticationDetails extends WebAuthenticationDetails {
    @Getter // 設(shè)置getter方法,以便拿到驗證碼
    private final String validateCode;
    public CustomWebAuthenticationDetails(HttpServletRequest request) {
        super(request);
        // 拿頁面?zhèn)鱽淼尿炞C碼
        validateCode = request.getParameter("validateCode");
    }
}

3.2、AuthenticationDetailSource

把自定義CustomWebAuthenticationDetails,放入 AuthenticationDetailsSource 中來替換原本的 WebAuthenticationDetails ,因此還得實現(xiàn)自定義 CustomAuthenticationDetailsSource ,設(shè)置為我們自定義的 CustomWebAuthenticationDetails

@Component("authenticationDetailsSource")
public class CustomAuthenticationDetailsSource implements AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> {
    @Override
    public WebAuthenticationDetails buildDetails(HttpServletRequest httpRequest) {
        return new CustomWebAuthenticationDetails(httpRequest);
    }
}

3.3、Spring Security配置

CustomAuthenticationDetailsSource 注入Spring Security中,替換掉默認(rèn)的 AuthenticationDetailsSource。

修改 WebSecurityConfig,將其注入,然后在config()中使用 authenticationDetailsSource(authenticationDetailsSource)方法來指定它。

@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter { 
    
    // 省略其他
    
    @Autowired
    private AuthenticationDetailsSource authenticationDetailsSource;
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers("/get-validate-code").permitAll()
                .anyRequest().authenticated()
              .and()
                .formLogin()
                .loginPage("/user-login").permitAll()
                .loginProcessingUrl("/my-login")
                .authenticationDetailsSource(authenticationDetailsSource);
        http.csrf().disable();
    }
}

3.4、AuthenticationProvider

通過自定義CustomWebAuthenticationDetailsCustomAuthenticationDetailsSource將驗證碼和用戶名、密碼一起加入了Spring Security中,但默認(rèn)的認(rèn)證中還不會對驗證碼進(jìn)行校驗,需要重寫UserDetailsAuthenticationProvider進(jìn)行校驗。

@Component
public class CustomAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
    @Autowired
    private CustomUserDetailsService userDetailsService;
    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
        // 獲取登錄提交的用戶名和密碼
        String inputPassword = (String) authentication.getCredentials();

        // 獲取登錄提交的驗證碼
        CustomWebAuthenticationDetails details = (CustomWebAuthenticationDetails) authentication.getDetails();
        String validateCode = details.getValidateCode();

        // 驗證碼校驗
        checkValidateCode(validateCode);

        // 驗證用戶名
        if (!passwordEncoder.matches(inputPassword, userDetails.getPassword())) {
            throw new BadCredentialsException("密碼錯誤");
        }
    }
    
    @Override
    protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken) throws AuthenticationException {
        return userDetailsService.loadUserByUsername(username);
    }
    
    private void checkValidateCode(String validateCode) {
        ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = requestAttributes.getRequest();
        ValidateCode codeInSession = (ValidateCode) request.getSession(false).getAttribute("validate-code");
        if (StringUtils.isEmpty(validateCode)) {
            throw new ValidateCodeException("驗證碼的值不能為空");
        }
        if (codeInSession == null) {
            throw new ValidateCodeException("驗證碼不存在");
        }
        if (codeInSession.isExpired()) {
            // 移除session中驗證碼
            request.getSession(false).removeAttribute("validate-code");
            throw new ValidateCodeException("驗證碼已過期");
        }
        if (!StringUtils.equals(codeInSession.getCode(), validateCode)) {
            throw new ValidateCodeException("驗證碼不匹配");
        }
        // 移除session中驗證碼
        request.getSession(false).removeAttribute("validate-code");
    }
}
class ValidateCodeException extends AuthenticationException {
    ValidateCodeException(String message) {
        super(message);
    }
}

WebSecurityConfig 中將其注入,并在 configure(AuthenticationManagerBuilder auth) 方法中通過 auth.authenticationProvider() 指定使用

@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter { 
    @Autowired
    private CustomAuthenticationProvider authenticationProvider;
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // auth.userDetailsService(userDetailsService);
        auth.authenticationProvider(authenticationProvider);
    }
}

啟動程序測試即可。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容