在前面的簡單登錄驗證,我們簡單整合了SpringSecurity的登錄,可以通過自定義設置或者從數據庫中讀取用戶權限類。接下來我們實現一些簡單的驗證碼相關的登錄驗證。
1、圖片驗證碼登錄
其實這里和最初的登錄驗證沒啥區別,只是多了一個驗證碼的驗證過程。我們首先需要清楚認識到SpringSecurity的整個登錄認證流程
- Spring Security使用UsernamePasswordAuthenticationFilter過濾器來攔截用戶名密碼認證請求
- 將用戶名和密碼封裝成一個UsernamePasswordToken對象交給AuthenticationManager處理。
- AuthenticationManager將挑出一個支持處理該類型Token的AuthenticationProvider(這里默認為DaoAuthenticationProvider,AuthenticationProvider的其中一個實現類)來進行認證
- 認證過程中DaoAuthenticationProvider將調用UserDetailService的loadUserByUsername方法來處理認證(可以自定義UserDetailService的實現類)
- 如果認證通過(即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);
}
}
再次聲明,我這里主要是想習慣一種類似於模板的代碼格式才采用自定義整個驗證鏈來進行驗證,實際上單單是一個驗證碼驗證的話后面自定義驗證鏈完全不需要,就只是添加一個過濾器就行了。相關內容手機短信驗證的代碼可能更加清楚。