在 spring security 中,認證和授權其實都是使用過濾器鏈進行的。比如登錄這個操作就是在
UsernamePasswordAuthenticationFilter
這個過濾器中進行的。一般在登錄時為了防止暴力破解密碼,我們一般都會進行人機驗證,以此來區分是機器人還是人工操作的。這個情況下,我們就可是定義一個驗證碼過濾器,在登錄之前進行人機校驗。
這里我們使用比較原始的校驗方法,這也是比較簡單的方式,就是在登錄時向指定郵箱發送校驗碼,然后登錄時驗證用戶輸入的驗證碼是否和系統發送的驗證碼一致。
一、郵件發送
在 spring boot 中如果需要發送郵件,我們只需要引入一下相關依賴:
implementation 'org.springframework.boot:spring-boot-starter-mail'
然后在配置文件中配置相關參數,我這里配置的是QQ郵箱,注意 password 要替換成你自己郵箱的授權碼:
server:
servlet:
session:
timeout: 30s
spring:
# 郵件怕配置
mail:
host: smtp.qq.com
port: 587
username: ynkm.lxw@qq.com
password: [授權碼]
protocol: smtp
properties:
mail:
smtp:
auth: true
然后我們測試一下,看看是否能正常發送:
import cn.hutool.core.util.RandomUtil;
import cn.hutool.core.util.RuntimeUtil;
import cn.hutool.core.util.StrUtil;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import javax.annotation.Resource;
import javax.mail.MessagingException;
import javax.mail.internet.MimeMessage;
@SpringBootTest
public class JavaMailSenderTest {
@Resource
JavaMailSender mailSender;
@Test
public void send() throws MessagingException {
// 驗證碼
String code = RandomUtil.randomStringUpper(6).toUpperCase();
// 發送郵件
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true);
helper.setFrom("ynkm.lxw@qq.com");
helper.setTo("lixingwu@aliyun.com");
helper.setSubject("主題:校驗碼");
helper.setText(StrUtil.format("校驗碼 <h3>{}</h3>", code), true);
mailSender.send(message);
}
}
結果:
現在我們把這個它寫成接口【在前面我們已經把這個接口進行了忽略,不用任何權限都可訪問】,每次發送我們就把生成的校驗碼保存在Session中,然后在校驗時取出來進行和用戶輸入的檢驗碼進行對比。
@PostMapping("/code")
public Dict getCode(
@RequestParam(name = "email") String email
) throws MessagingException {
String code = RandomUtil.randomStringUpper(6);
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true);
helper.setFrom("ynkm.lxw@qq.com");
helper.setTo(email);
helper.setSubject("主題:校驗碼");
helper.setText(StrUtil.format("校驗碼 <h3>{}</h3>", code), true);
mailSender.send(message);
request.getSession().setAttribute("code", code);
Console.log("校驗碼發送成功[{}]", code);
return Dict.create()
.set("code", 0)
.set("msg", "成功")
.set("data", "校驗碼發送成功,請注意查收!");
}
二、驗證碼異常
如果在驗證碼輸入錯誤時,我們為了便於處理,我們先定義一個驗證碼異常類,專門處理它。
import org.springframework.security.core.AuthenticationException;
/**
* 驗證碼輸入異常
*
* @author lixin
*/
public class ValidateCodeException extends AuthenticationException {
public ValidateCodeException(String msg, Throwable t) {
super(msg, t);
}
public ValidateCodeException(String msg) {
super(msg);
}
public ValidateCodeException() {
super("validate code check fail!");
}
}
然后在登錄失敗的處理器 JsonFailureHandler 處判斷是不是 ValidateCodeException ,是的話我們返回 “驗證碼輸入有誤”信息,具體配置看代碼:
import cn.hutool.core.lang.Console;
import cn.hutool.core.lang.Dict;
import cn.hutool.core.util.CharsetUtil;
import cn.hutool.extra.servlet.ServletUtil;
import cn.hutool.http.ContentType;
import cn.hutool.json.JSONUtil;
import com.miaopasi.securitydemo.config.security.exception.ValidateCodeException;
import org.springframework.security.authentication.*;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 登錄失敗
*
* @author lixin
*/
@Component
public class JsonFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(
HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception
) throws IOException, ServletException {
Console.log("登錄失敗,{}", exception);
Dict res = Dict.create().set("code", 1000).set("msg", "登錄失敗");
if (exception instanceof UsernameNotFoundException) {
res.set("data", "用戶名不存在");
} else if (exception instanceof LockedException) {
res.set("data", "賬號被鎖定");
} else if (exception instanceof DisabledException) {
res.set("data", "賬號被禁用");
} else if (exception instanceof CredentialsExpiredException) {
res.set("data", "密碼過期");
} else if (exception instanceof AccountExpiredException) {
res.set("data", "賬號過期");
} else if (exception instanceof BadCredentialsException) {
res.set("data", "賬號密碼輸入有誤");
} else if (exception instanceof ValidateCodeException) {
res.set("data", "驗證碼輸入有誤");
} else {
res.set("data", exception.getMessage());
}
String contentType = ContentType.JSON.toString(CharsetUtil.CHARSET_UTF_8);
ServletUtil.write(response, JSONUtil.toJsonStr(res), contentType);
}
}
三、驗證碼過濾器
在用戶信息登錄時,我們需要先校驗用戶輸入的校驗碼是否正確,正確后才進行賬號密碼的驗證。這時候我們就需要在 UsernamePasswordAuthenticationFilter
這個過濾器前面再加上一個 驗證碼的過濾器
,這樣就實現我們的驗證碼功能了。
(1)基於以上分析,我們先要創建一個驗證碼的過濾器 ValidateCodeFilter
:
import cn.hutool.core.lang.Console;
import cn.hutool.core.util.StrUtil;
import com.miaopasi.securitydemo.config.security.exception.ValidateCodeException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.ServletRequestBindingException;
import org.springframework.web.bind.ServletRequestUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.Filter;
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 lixin
*/
@Component("validateCodeFilter")
public class ValidateCodeFilter extends OncePerRequestFilter implements Filter {
private final AuthenticationFailureHandler authenticationFailureHandler;
@Autowired
public ValidateCodeFilter(AuthenticationFailureHandler authenticationFailureHandler) {
this.authenticationFailureHandler = authenticationFailureHandler;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 必須是登錄的post請求才能進行驗證,其他的直接放行
if (StrUtil.equals("/doLogin", request.getRequestURI()) && StrUtil.equalsIgnoreCase(request.getMethod(), "POST")) {
Console.log("進入[自定義驗證碼過濾器]");
try {
// 1. 進行驗證碼的校驗
validate(request);
} catch (AuthenticationException e) {
// 2. 捕獲步驟1中校驗出現異常,交給失敗處理類進行進行處理
authenticationFailureHandler.onAuthenticationFailure(request, response, e);
return;
}
}
// 校驗通過,就放行
filterChain.doFilter(request, response);
}
/**
* 驗證輸入的驗證碼是否正確
*
* @param request 請求對象
*/
private void validate(HttpServletRequest request) throws ServletRequestBindingException {
String code1 = ServletRequestUtils.getStringParameter(request, "code");
Object code2 = request.getSession().getAttribute("code");
Console.log("輸入的驗證碼為:{},Session中的code為:{}", code1, code2);
if (Objects.isNull(code1) || Objects.isNull(code2) || !Objects.equals(code1, code2)) {
throw new ValidateCodeException();
}
// 移除保存的驗證碼,防止重復使用驗證碼進行登錄
request.getSession().removeAttribute("code");
}
}
這是一個一般的過濾器,在校驗不通過時,會拋出異常 ValidateCodeException
,然后傳遞給 AuthenticationFailureHandler
這個對象,這樣 JsonFailureHandler
就會收到這個異常,然后就執行處理的邏輯,最后向請求的對象拋出 “驗證碼輸入有誤” 的信息。
(2)然后我們就需要把我們創建的 ValidateCodeFilter
配置到過濾器鏈上,我們可以在 SecurityConfig
中進行配置,具體配置看代碼:
import com.miaopasi.securitydemo.config.security.filter.ValidateCodeFilter;
import com.miaopasi.securitydemo.config.security.handler.*;
import com.miaopasi.securitydemo.config.security.impl.UrlAccessDecisionManager;
import com.miaopasi.securitydemo.config.security.impl.UrlFilterInvocationSecurityMetadataSource;
import com.miaopasi.securitydemo.config.security.impl.UserDetailsServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.ObjectPostProcessor;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.access.intercept.FilterSecurityInterceptor;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
/**
* Security配置類,會覆蓋yml配置文件的內容
*
* @author lixin
*/
@EnableWebSecurity
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final JsonSuccessHandler successHandler;
private final JsonFailureHandler failureHandler;
private final JsonAccessDeniedHandler accessDeniedHandler;
private final JsonAuthenticationEntryPoint authenticationEntryPoint;
private final JsonLogoutSuccessHandler logoutSuccessHandler;
private final UserDetailsServiceImpl userDetailsService;
private final UrlFilterInvocationSecurityMetadataSource filterInvocationSecurityMetadataSource;
private final UrlAccessDecisionManager accessDecisionManager;
private final ValidateCodeFilter validateCodeFilter;
@Autowired
public SecurityConfig(JsonSuccessHandler successHandler, JsonFailureHandler failureHandler, JsonAccessDeniedHandler accessDeniedHandler, JsonAuthenticationEntryPoint authenticationEntryPoint, JsonLogoutSuccessHandler logoutSuccessHandler, UserDetailsServiceImpl userDetailsService, UrlFilterInvocationSecurityMetadataSource filterInvocationSecurityMetadataSource, UrlAccessDecisionManager accessDecisionManager, ValidateCodeFilter validateCodeFilter) {
this.successHandler = successHandler;
this.failureHandler = failureHandler;
this.accessDeniedHandler = accessDeniedHandler;
this.authenticationEntryPoint = authenticationEntryPoint;
this.logoutSuccessHandler = logoutSuccessHandler;
this.userDetailsService = userDetailsService;
this.filterInvocationSecurityMetadataSource = filterInvocationSecurityMetadataSource;
this.accessDecisionManager = accessDecisionManager;
this.validateCodeFilter = validateCodeFilter;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// 在校驗密碼前設置一層【驗證碼過濾器】用於校驗登錄時輸入驗證碼是否正確
.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeRequests()
.withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
@Override
public <O extends FilterSecurityInterceptor> O postProcess(O object) {
object.setSecurityMetadataSource(filterInvocationSecurityMetadataSource);
object.setAccessDecisionManager(accessDecisionManager);
return object;
}
})
.anyRequest().authenticated()
.and().formLogin()
.usernameParameter("username")
.passwordParameter("password")
.loginProcessingUrl("/doLogin")
.successHandler(successHandler)
.failureHandler(failureHandler)
.and().logout().logoutUrl("/doLogout")
.logoutSuccessHandler(logoutSuccessHandler)
.and().exceptionHandling()
.accessDeniedHandler(accessDeniedHandler)
.authenticationEntryPoint(authenticationEntryPoint)
.and().cors()
.and().csrf().disable();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
}
四、測試
(1)獲取校驗碼,調用接口 /code
,返回如下JSON字符串:
{
"code": 0,
"msg": "成功",
"data": "校驗碼發送成功,請注意查收!"
}
然后在郵箱中發現郵件
(2)輸入賬號密碼 不輸入校驗碼
進行登錄,返回JSON字符串:
{
"msg": "登錄失敗",
"code": 1000,
"data": "驗證碼輸入有誤"
}
(3)輸入賬號密碼和 正確校驗碼
進行登錄,返回JSON字符串:
{
"msg": "登錄成功",
"code": 0,
"data": {
"authenticated": true,
"authorities": [
{}
],
"principal": {
"isDelete": false,
"sort": 0,
"gmtCreate": 1594918566938,
"operator": "管理員",
"authorities": [
{}
],
"id": 1,
"remarks": "測試用戶1",
"username": "user1",
"status": 0
},
"details": {
"sessionId": "947F45CAFC0DE62BC317BE0A99005803",
"remoteAddress": "127.0.0.1"
}
}
}
(4)輸入賬號密碼和 錯誤校驗碼
進行登錄,返回JSON字符串:
{
"msg": "登錄失敗",
"code": 1000,
"data": "驗證碼輸入有誤"
}
五、說明一下
其實這篇文章主要是想說明我們如何在默認的過濾器鏈中插入自己的過濾器,實現在即想要的功能。我在這里使用了郵箱驗證碼這個功能來進行說明,實例中比較依賴session,實際情況可以根據項目情況進行處理。
spring security系列文章請 點擊這里 查看。
這是代碼 碼雲地址 。
注意注意!!!項目是使用分支的方式來提交每次測試的代碼的,請根據章節來我切換分支。