首頁 > 軟體

Spring Security 登入時新增圖形驗證碼實現範例

2022-11-15 14:00:48

前言

在前面的幾篇文章中,登入時都是使用使用者名稱 + 密碼進行登入的,但是在實際專案當中,登入時,還需要輸入圖形驗證碼。那如何在 Spring Security 現有的認證體系中,加入自己的認證邏輯呢?這就是本文的內容,本文會介紹兩種實現方案,一是基於過濾器實現;二是基於認證器實現。

驗證碼生成

既然需要輸入圖形驗證碼,那先來生成驗證碼吧。

加入驗證碼依賴

<!--驗證碼生成器-->
<dependency>
    <groupId>com.github.penggle</groupId>
    <artifactId>kaptcha</artifactId>
    <version>2.3.2</version>
</dependency>
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-web</artifactId>
</dependency>

Kaptcha 依賴是谷歌的驗證碼工具。

驗證碼設定

@Configuration
public class KaptchaConfig {
    @Bean
    public DefaultKaptcha captchaProducer() {
        Properties properties = new Properties();
        // 是否顯示邊框
        properties.setProperty("kaptcha.border","yes");
        // 邊框顏色
        properties.setProperty("kaptcha.border.color","105,179,90");
        // 字型顏色
        properties.setProperty("kaptcha.textproducer.font.color","blue");
        // 字型大小
        properties.setProperty("kaptcha.textproducer.font.size","35");
        // 圖片寬度
        properties.setProperty("kaptcha.image.width","300");
        // 圖片高度
        properties.setProperty("kaptcha.image.height","100");
        // 文字個數
        properties.setProperty("kaptcha.textproducer.char.length","4");
        //文字大小
        properties.setProperty("kaptcha.textproducer.font.size","100");
        //文字隨機字型
        properties.setProperty("kaptcha.textproducer.font.names", "宋體");
        //文字距離
        properties.setProperty("kaptcha.textproducer.char.space","16");
        //干擾線顏色
        properties.setProperty("kaptcha.noise.color","blue");
        // 文字內容 從設定字元中隨機抽取
        properties.setProperty("kaptcha.textproducer.char.string","0123456789");
        DefaultKaptcha kaptcha = new DefaultKaptcha();
        kaptcha.setConfig(new Config(properties));
        return kaptcha;
    }
}

驗證碼介面

/**
 * 生成驗證碼
 */
@GetMapping("/verify-code")
public void getVerifyCode(HttpServletResponse resp, HttpSession session) throws IOException {
    resp.setContentType("image/jpeg");
    // 生成圖形校驗碼內容
    String text = producer.createText();
    // 將驗證碼內容存入HttpSession
    session.setAttribute("verify_code", text);
    // 生成圖形校驗碼圖片
    BufferedImage image = producer.createImage(text);
    // 使用try-with-resources 方式,可以自動關閉流
    try(ServletOutputStream out = resp.getOutputStream()) {
        // 將校驗碼圖片資訊輸出到瀏覽器
        ImageIO.write(image, "jpeg", out);
    }
}

程式碼註釋寫的很清楚,就不過多的介紹。屬於固定的設定,既然設定完了,那就看看生成的效果吧!

接下來就看看如何整合到 Spring Security 中的認證邏輯吧!

加入依賴

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

基於過濾器

編寫自定義認證邏輯

這裡繼承的過濾器為 UsernamePasswordAuthenticationFilter,並重寫attemptAuthentication方法。使用者登入的使用者名稱/密碼是在 UsernamePasswordAuthenticationFilter 類中處理,那我們就繼承這個類,增加對驗證碼的處理。當然也可以實現其他型別的過濾器,比如:GenericFilterBeanOncePerRequestFilter,不過處理起來會比繼承UsernamePasswordAuthenticationFilter麻煩一點。

import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.util.StringUtils;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
public class VerifyCodeFilter extends UsernamePasswordAuthenticationFilter {
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        // 需要是 POST 請求
        if (!request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException(
                    "Authentication method not supported: " + request.getMethod());
        }
        // 獲得請求驗證碼值
        String code = request.getParameter("code");
        HttpSession session = request.getSession();
        // 獲得 session 中的 驗證碼值
        String sessionVerifyCode = (String) session.getAttribute("verify_code");
        if (StringUtils.isEmpty(code)){
            throw new AuthenticationServiceException("驗證碼不能為空!");
        }
        if(StringUtils.isEmpty(sessionVerifyCode)){
            throw new AuthenticationServiceException("請重新申請驗證碼!");
        }
        if (!sessionVerifyCode.equalsIgnoreCase(code)) {
            throw new AuthenticationServiceException("驗證碼錯誤!");
        }
        // 驗證碼驗證成功,清除 session 中的驗證碼
        session.removeAttribute("verify_code");
        // 驗證碼驗證成功,走原本父類別認證邏輯
        return super.attemptAuthentication(request, response);
    }
}

程式碼邏輯很簡單,驗證驗證碼是否正確,正確則走父類別原本邏輯,去驗證使用者名稱密碼是否正確。 過濾器定義完成後,接下來就是用我們自定義的過濾器代替預設的 UsernamePasswordAuthenticationFilter

  • SecurityConfig
import cn.cxyxj.study04.Authentication.config.MyAuthenticationFailureHandler;
import cn.cxyxj.study04.Authentication.config.MyAuthenticationSuccessHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }
    @Bean
    @Override
    protected UserDetailsService userDetailsService() {
        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
        manager.createUser(User.withUsername("cxyxj").password("123").roles("admin").build());
        manager.createUser(User.withUsername("security").password("security").roles("user").build());
        return manager;
    }
    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean()
            throws Exception {
        return super.authenticationManagerBean();
    }
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 用自定義的 VerifyCodeFilter 範例代替 UsernamePasswordAuthenticationFilter
        http.addFilterBefore(new VerifyCodeFilter(), UsernamePasswordAuthenticationFilter.class);
        http.authorizeRequests()  //開啟設定
                // 驗證碼、登入介面放行
                .antMatchers("/verify-code","/auth/login").permitAll()
                .anyRequest() //其他請求
                .authenticated().and()//驗證   表示其他請求需要登入才能存取
                .csrf().disable();  // 禁用 csrf 保護
    }
    @Bean
    VerifyCodeFilter loginFilter() throws Exception {
        VerifyCodeFilter verifyCodeFilter = new VerifyCodeFilter();
        verifyCodeFilter.setFilterProcessesUrl("/auth/login");
        verifyCodeFilter.setUsernameParameter("account");
        verifyCodeFilter.setPasswordParameter("pwd");
        verifyCodeFilter.setAuthenticationManager(authenticationManagerBean());
        verifyCodeFilter.setAuthenticationSuccessHandler(new MyAuthenticationSuccessHandler());
        verifyCodeFilter.setAuthenticationFailureHandler(new MyAuthenticationFailureHandler());
        return verifyCodeFilter;
    }
}

當我們替換了 UsernamePasswordAuthenticationFilter 之後,原本在 SecurityConfig#configure 方法中關於 form 表單的設定就會失效,那些失效的屬性,都可以在設定 VerifyCodeFilter 範例的時候設定;還需要記得設定AuthenticationManager,否則啟動時會報錯。

  • MyAuthenticationFailureHandler
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
/**
 * 登入失敗回撥
 */
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
        response.setContentType("application/json;charset=utf-8");
        PrintWriter out = response.getWriter();
        String msg = "";
        if (e instanceof LockedException) {
            msg = "賬戶被鎖定,請聯絡管理員!";
        }
       else if (e instanceof BadCredentialsException) {
            msg = "使用者名稱或者密碼輸入錯誤,請重新輸入!";
        }
        out.write(e.getMessage());
        out.flush();
        out.close();
    }
}
  • MyAuthenticationSuccessHandler
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
/**
 * 登入成功回撥
 */
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        Object principal = authentication.getPrincipal();
        response.setContentType("application/json;charset=utf-8");
        PrintWriter out = response.getWriter();
        out.write(new ObjectMapper().writeValueAsString(principal));
        out.flush();
        out.close();
    }
}

測試

  • 不傳入驗證碼發起請求。

  • 請求獲取驗證碼介面

  • 輸入錯誤的驗證碼

  • 輸入正確的驗證碼

輸入已經使用過的驗證碼

各位讀者是不是會覺得既然繼承了 Filter,那是不是每個介面都會進入到我們的自定義方法中呀!如果是繼承了 GenericFilterBean、OncePerRequestFilter 那是肯定會的,需要手動處理。 但我們繼承的是 UsernamePasswordAuthenticationFilter,security 已經幫忙處理了。處理邏輯在其父類別 AbstractAuthenticationProcessingFilter#doFilter 中。

基於認證器

編寫自定義認證邏輯

驗證碼邏輯編寫完成,那接下來就自定義一個 VerifyCodeAuthenticationProvider 繼承自 DaoAuthenticationProvider,並重寫 authenticate 方法。

import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
/**
 * 驗證碼驗證器
 */
public class VerifyCodeAuthenticationProvider extends DaoAuthenticationProvider {
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        HttpServletRequest req = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        // 獲得請求驗證碼值
        String code = req.getParameter("code");
        // 獲得 session 中的 驗證碼值
        HttpSession session = req.getSession();
        String sessionVerifyCode = (String) session.getAttribute("verify_code");
        if (StringUtils.isEmpty(code)){
            throw new AuthenticationServiceException("驗證碼不能為空!");
        }
        if(StringUtils.isEmpty(sessionVerifyCode)){
            throw new AuthenticationServiceException("請重新申請驗證碼!");
        }
        if (!code.toLowerCase().equals(sessionVerifyCode.toLowerCase())) {
            throw new AuthenticationServiceException("驗證碼錯誤!");
        }
        // 驗證碼驗證成功,清除 session 中的驗證碼
        session.removeAttribute("verify_code");
        // 驗證碼驗證成功,走原本父類別認證邏輯
        return super.authenticate(authentication);
    }
}

自定義的認證邏輯完成了,剩下的問題就是如何讓 security 走我們的認證邏輯了。

在 security 中,所有的 AuthenticationProvider 都是放在 ProviderManager 中統一管理的,所以接下來我們就要自己提供 ProviderManager,然後注入自定義的 VerifyCodeAuthenticationProvider。

  • SecurityConfig
import cn.cxyxj.study02.config.MyAuthenticationFailureHandler;
import cn.cxyxj.study02.config.MyAuthenticationSuccessHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }
    @Bean
    @Override
    protected UserDetailsService userDetailsService() {
        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
        manager.createUser(User.withUsername("cxyxj").password("123").roles("admin").build());
        manager.createUser(User.withUsername("security").password("security").roles("user").build());
        return manager;
    }
    @Bean
    VerifyCodeAuthenticationProvider verifyCodeAuthenticationProvider() {
        VerifyCodeAuthenticationProvider provider = new VerifyCodeAuthenticationProvider();
        provider.setPasswordEncoder(passwordEncoder());
        provider.setUserDetailsService(userDetailsService());
        return provider;
    }
    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        ProviderManager manager = new ProviderManager(verifyCodeAuthenticationProvider());
        return manager;
    }
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()  //開啟設定
                // 驗證碼介面放行
                .antMatchers("/verify-code").permitAll()
                .anyRequest() //其他請求
                .authenticated()//驗證   表示其他請求需要登入才能存取
                .and()
                .formLogin()
                .loginPage("/login.html") //登入頁面
                .loginProcessingUrl("/auth/login") //登入介面,此地址可以不真實存在
                .usernameParameter("account") //使用者名稱欄位
                .passwordParameter("pwd") //密碼欄位
                .successHandler(new MyAuthenticationSuccessHandler())
                .failureHandler(new MyAuthenticationFailureHandler())
                .permitAll() // 上述 login.html 頁面、/auth/login介面放行
                .and()
                .csrf().disable();  // 禁用 csrf 保護
        ;
    }
}

測試

不傳入驗證碼發起請求。

  • 請求獲取驗證碼介面

  • 輸入錯誤的驗證碼

  • 輸入正確的驗證碼

  • 輸入已經使用過的驗證碼

以上就是Spring Security 登入時新增圖形驗證碼實現範例的詳細內容,更多關於Spring Security 登入圖形驗證碼的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com