1. 程式人生 > 實用技巧 >Springboot基於SpringSecurity圖片驗證碼登入

Springboot基於SpringSecurity圖片驗證碼登入

在前面的簡單登入驗證,我們簡單整合了SpringSecurity的登入,可以通過自定義設定或者從資料庫中讀取使用者許可權類。接下來我們實現一些簡單的驗證碼相關的登入驗證。

1、圖片驗證碼登入

其實這裡和最初的登入驗證沒啥區別,只是多了一個驗證碼的驗證過程。我們首先需要清楚認識到SpringSecurity的整個登入認證流程

  1. Spring Security使用UsernamePasswordAuthenticationFilter過濾器來攔截使用者名稱密碼認證請求
  2. 將使用者名稱和密碼封裝成一個UsernamePasswordToken物件交給AuthenticationManager處理。
  3. AuthenticationManager將挑出一個支援處理該型別Token的AuthenticationProvider(這裡預設為DaoAuthenticationProvider,AuthenticationProvider的其中一個實現類)來進行認證
  4. 認證過程中DaoAuthenticationProvider將呼叫UserDetailService的loadUserByUsername方法來處理認證(可以自定義UserDetailService的實現類)
  5. 如果認證通過(即UsernamePasswordToken中的使用者名稱和密碼相符)則返回一個UserDetails型別物件,並將認證資訊儲存到Session中,認證後我們便可以通過Authentication物件獲取到認證的資訊了。

那麼我們新增驗證碼驗證則有如下幾種思路:

1.1、登入表單提交前傳送 AJAX 驗證驗證碼

這種方式和SpringSecurity毫無關係,其實就是表單提交前先發個 HTTP 請求驗證驗證碼。

1.2、和使用者名稱、密碼一起傳送到後臺,在 Springsecurity中進行驗證

最開始我是採用的這種方式,這種方式也是和Spring security 結合的最緊密的方式。
首先需要清楚的是security預設只處理使用者名稱和密碼資訊。所以我們需要自定義實現WebAuthenticationDetails向其中加入驗證碼。

public class CustomWebAuthenticationDetails extends WebAuthenticationDetails {
    private static final long serialVersionUID = 6975601077710753878L;
    private final String verifyCode;

    public CustomWebAuthenticationDetails(HttpServletRequest request) {
        super(request);
        // verifyCode為頁面中驗證碼的name
        verifyCode = request.getParameter("verifyCode");
    }

    public String getVerifyCode() {
        return this.verifyCode;
    }
}

在這個方法中,我們將前臺 form 表單中的 verifyCode 獲取到,並通過 get 方法方便被呼叫。這樣我們就在驗證資訊類中添加了驗證碼的相關資訊。自定義了WebAuthenticationDetails,我i們還需要將其放入 AuthenticationDetailsSource 中來替換原本的 WebAuthenticationDetails ,因此還得實現自定義 AuthenticationDetailsSource :

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

該類內容將原本的 WebAuthenticationDetails 替換為了我們的 CustomWebAuthenticationDetails。
然後我們將 CustomAuthenticationDetailsSource 注入Spring Security中,替換掉預設的 AuthenticationDetailsSource。
修改 WebSecurityConfig,將其注入,然後在config()中使用 authenticationDetailsSource(authenticationDetailsSource)方法來指定它。

@Autowired
private AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> authenticationDetailsSource;
@Override
protected void configure(HttpSecurity http) throws Exception {                         
             http...
                  // 指定authenticationDetailsSource
                  .authenticationDetailsSource(authenticationDetailsSource)
                  ...
}

至此我們通過自定義WebAuthenticationDetails和AuthenticationDetailsSource將驗證碼和使用者名稱、密碼一起帶入了Spring Security中,下面我們需要將它取出來驗證。
這裡需要我們自定義AuthenticationProvider,需要注意的是,如果是我們自己實現AuthenticationProvider,那麼我們就需要自己做密碼校驗了。

@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {
    @Autowired
    private CustomUserDetailsService customUserDetailsService;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        // 獲取使用者輸入的使用者名稱和密碼
        String inputName = authentication.getName();
        String inputPassword = authentication.getCredentials().toString();

        CustomWebAuthenticationDetails details = (CustomWebAuthenticationDetails) authentication.getDetails();

        String verifyCode = details.getVerifyCode();
        if(!validateVerify(verifyCode)) {
            throw new DisabledException("驗證碼輸入錯誤");
        }

        // userDetails為資料庫中查詢到的使用者資訊
        UserDetails userDetails = customUserDetailsService.loadUserByUsername(inputName);

        // 如果是自定義AuthenticationProvider,需要手動密碼校驗
        if(!userDetails.getPassword().equals(inputPassword)) {
            throw new BadCredentialsException("密碼錯誤");
        }

        return new UsernamePasswordAuthenticationToken(inputName, inputPassword, userDetails.getAuthorities());
    }

    private boolean validateVerify(String inputVerify) {
        //獲取當前執行緒繫結的request物件
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        // 不分割槽大小寫
        // 這個validateCode是在servlet中存入session的名字
        String validateCode = ((String) request.getSession().getAttribute("validateCode")).toLowerCase();
        inputVerify = inputVerify.toLowerCase();

        System.out.println("驗證碼:" + validateCode + "使用者輸入:" + inputVerify);

        return validateCode.equals(inputVerify);
    }

    @Override
    public boolean supports(Class<?> authentication) {
        // 這裡不要忘記,和UsernamePasswordAuthenticationToken比較
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }
}

最後在 WebSecurityConfig 中將其注入,並在 config 方法中通過 auth.authenticationProvider() 指定使用。

@Autowired
private CustomAuthenticationProvider customAuthenticationProvider;

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.authenticationProvider(customAuthenticationProvider);
}

但是後面新增手機驗證簡訊驗證功能的時候就出問題了,主要是用CustomWebAuthenticationDetails替換了AuthenticationDetailsSource後,新增簡訊驗證鏈又不能使用該CustomWebAuthenticationDetails。所以後面我還是改成了自定義過濾器來驗證。

1.3、使用自定義過濾器(Filter),在 Spring security 校驗前驗證驗證碼合法性

使用過濾器的思路是:在 Spring Security 處理登入驗證請求前,驗證驗證碼,如果正確,放行;如果不正確,調到異常。其實這裡簡單新增一個過濾器就好了,我主要是想和簡訊驗證板塊向對照,也為了方便編寫不同驗證方式配置類。所以就自定義實現了整個驗證鏈。

  • 首先自定義實現一個只經過一次的過濾器
@Component
public class CustomValidateFilter extends OncePerRequestFilter {
      //自定義的公共登入失敗後的處理邏輯
    @Autowired
    private PublicAuthenticationFailureHandler publicAuthenticationFailureHandler;
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
            throws IOException, ServletException {
            //設定過濾請求url。(MyContants.CUST_FILTER_URL自定義的)
        if (StringUtils.equals(MyContants.CUST_FILTER_URL, request.getRequestURI())
                && StringUtils.equalsIgnoreCase(request.getMethod(), MyContants.REQUEST_MAPPING_POST)) {

            try {
                //驗證謎底與使用者輸入是否匹配
                validate(new ServletWebRequest(request));
            } catch (AuthenticationException e) {
                publicAuthenticationFailureHandler.onAuthenticationFailure(request, response, e);
                return;
            }

        }
        filterChain.doFilter(request, response);

    }

    private void validate(ServletWebRequest request) throws SessionAuthenticationException {
        Logger logger = LoggerFactory.getLogger(getClass());
        HttpSession session = request.getRequest().getSession();
        String sessionValidateCode = (String) session.getAttribute("validateCode");
        String parameterVerifyCode = request.getParameter("verifyCode");
        logger.info("驗證碼",sessionValidateCode,parameterVerifyCode);

        if (StringUtils.isEmpty(parameterVerifyCode)) {
            throw new SessionAuthenticationException("驗證碼不能為空");
        }

        if (!StringUtils.equalsAnyIgnoreCase(sessionValidateCode,parameterVerifyCode)) {
            throw new SessionAuthenticationException("驗證碼不正確");
        }
        session.removeAttribute("validateCode");
    }
}
  • 然後就是自定義AbstractAuthenticationProcessingFilter進行請求驗證.(模仿UsernamePasswordAuthenticationFilter 實現)
public class CustomAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
      //這裡還是使用者名稱加密碼
    private String usernameParameter = MyContants.CUST_FORM_USERNAME_KEY;
    private String passwordParameter = MyContants.CUST_FORM_PASSWORD_KEY;
    private boolean postOnly = true;

    public CustomAuthenticationFilter() {
        super(new AntPathRequestMatcher(MyContants.CUST_FILTER_URL, MyContants.REQUEST_MAPPING_POST));
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        if (this.postOnly && !MyContants.REQUEST_MAPPING_POST.equals(request.getMethod())) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        } else {
            String username = this.obtainUsername(request);
            String password = this.obtainPassword(request);
            if (username == null) {
                username = "";
            }

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

            username = username.trim();
            CustomAuthenticationToken authRequest = new CustomAuthenticationToken(username, password);
            setDetails(request, authRequest);
            return this.getAuthenticationManager().authenticate(authRequest);
        }
    }

    @Nullable
    protected String obtainPassword(HttpServletRequest request) {
        return request.getParameter(this.passwordParameter);
    }

    @Nullable
    protected String obtainUsername(HttpServletRequest request) {
        return request.getParameter(this.usernameParameter);
    }

    protected void setDetails(HttpServletRequest request, CustomAuthenticationToken authRequest) {
        authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
    }

    public void setUsernameParameter(String usernameParameter) {
        Assert.hasText(usernameParameter, "Username parameter must not be empty or null");
        this.usernameParameter = usernameParameter;
    }

    public void setPasswordParameter(String passwordParameter) {
        Assert.hasText(passwordParameter, "Password parameter must not be empty or null");
        this.passwordParameter = passwordParameter;
    }

    public void setPostOnly(boolean postOnly) {
        this.postOnly = postOnly;
    }

    public final String getUsernameParameter() {
        return this.usernameParameter;
    }

    public final String getPasswordParameter() {
        return this.passwordParameter;
    }
}

  • 自定義AbstractAuthenticationToken (模仿 UsernamePasswordAuthenticationToken 實現)
public class CustomAuthenticationToken extends AbstractAuthenticationToken {
    private static final long serialVersionUID = 520L;
    private final Object principal;
    private Object credentials;

    public CustomAuthenticationToken(Object principal, Object credentials) {
        super(null);
        this.principal = principal;
        this.credentials = credentials;
        this.setAuthenticated(false);
    }

    public CustomAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        this.credentials = credentials;
        super.setAuthenticated(true);
    }

    @Override
    public Object getCredentials() {
        return this.credentials;
    }

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

    @Override
    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
        if (isAuthenticated) {
            throw new IllegalArgumentException("Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
        } else {
            super.setAuthenticated(false);
        }
    }

    @Override
    public void eraseCredentials() {
        super.eraseCredentials();
        this.credentials = null;
    }
}
  • 自定義AuthenticationProvider進行登入驗證
public class CustomAuthenticationProvider implements AuthenticationProvider {
    private UserDetailsService userDetailsService;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        CustomAuthenticationToken customAuthenticationToken = (CustomAuthenticationToken) authentication;

        // userDetails為資料庫中查詢到的使用者資訊
        UserDetails userDetails = userDetailsService.loadUserByUsername((String) customAuthenticationToken.getPrincipal());

        if(userDetails == null){
            throw new InternalAuthenticationServiceException("無法根據名字獲取使用者資訊");
        }
        // 如果是自定義AuthenticationProvider,需要手動密碼校驗
        if(!userDetails.getPassword().equals(customAuthenticationToken.getCredentials())) {
            throw new BadCredentialsException("密碼錯誤");
        }
        // 此時鑑權成功後,應當重新 new 一個擁有鑑權的 authenticationResult 返回

        return new CustomAuthenticationToken(userDetails.getUsername(), userDetails.getPassword(), userDetails.getAuthorities());

    }


    @Override
    public boolean supports(Class<?> authentication) {
        return CustomAuthenticationToken.class.isAssignableFrom(authentication);
    }
    public UserDetailsService getUserDetailService() {
        return userDetailsService;
    }

    public void setUserDetailService(UserDetailsService userDetailService) {
        this.userDetailsService = userDetailService;
    }
}

  • 自定義UserDetailsService返回userdetails
@Service("customUserDetailsService")
public class CustomUserDetailsService implements UserDetailsService {
    @Autowired
    private UserService userService;
    @Autowired
    private RoleService roleService;
    @Autowired
    private UserRoleService userRoleService;
    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        // 從資料庫中取出使用者資訊
        User user = userService.queryByUsername(s);
        // 判斷使用者是否存在
        if(user == null) {
            throw new UsernameNotFoundException("使用者名稱不存在");
        }

        // 新增許可權
        List<UserRole> userRoles = userRoleService.listByUserId(user.getId());
        for (UserRole userRole : userRoles) {
            Role role = roleService.selectById(userRole.getRoleId());
            authorities.add(new SimpleGrantedAuthority(role.getName()));
        }
        // 返回UserDetails實現類 這裡因為我自己也存在一個user類。所以前面加了全類名。(以後幹啥都不要圖簡單自定義user這種萬金油名字)
        return new org.springframework.security.core.userdetails.User(user.getUsername(),user.getPassword(), authorities);
    }
}

  • 然後實現配置類CustomAuthenticationSecurityConfig
@Component("customAuthenticationSecurityConfig")
public class CustomAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    @Autowired
    private AuthenticationSuccessHandler authenticationSuccessHandler;
    @Autowired
    private AuthenticationFailureHandler authenticationFailureHandler;
    @Autowired
    @Qualifier("customUserDetailsService")
    private CustomUserDetailsService customUserDetailsService;
    @Override
    public void configure(HttpSecurity http) {
        CustomAuthenticationFilter customAuthenticationFilter = new CustomAuthenticationFilter();
        customAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
        customAuthenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
        customAuthenticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler);

        CustomAuthenticationProvider customAuthenticationProvider = new CustomAuthenticationProvider();
        customAuthenticationProvider.setUserDetailService(customUserDetailsService);
        http.authenticationProvider(customAuthenticationProvider)
                .addFilterAfter(customAuthenticationFilter,UsernamePasswordAuthenticationFilter.class);
    }
}
  • 新增到認證鏈
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private CustomValidateFilter customValidateFilter;

    @Autowired
    private SmsCodeAuthenticationSecurityConfig smsCodeAuthenticationSecurityConfig;

    @Override
    protected void configure(HttpSecurity http) throws Exception {                
        http...
            // 新增驗證碼校驗過濾器
            .addFilterBefore(customValidateFilter,UsernamePasswordAuthenticationFilter.class)
            ...                
            .and()
            // 新增驗證碼校驗過濾器
            .apply(customAuthenticationSecurityConfig);
    }  
}

再次宣告,我這裡主要是想習慣一種類似於模板的程式碼格式才採用自定義整個驗證鏈來進行驗證,實際上單單是一個驗證碼驗證的話後面自定義驗證鏈完全不需要,就只是新增一個過濾器就行了。相關內容手機簡訊驗證的程式碼可能更加清楚。