通過前面三篇文章,你應該大致了解了 Spring Security 的流程。你應該發現了,真正的 login 請求是由 Spring Security 幫我們處理的,那么我們如何實現自定義表單登錄呢,比如添加一個驗證碼…
源碼地址:https://github.com/jitwxs/blog_sample
文章目錄
一、添加驗證碼
1.1 驗證碼 Servlet
1.2 修改 login.html
1.3 添加匿名訪問 Url
二、AJAX 驗證
三、過濾器驗證
3.1 編寫驗證碼過濾器
3.2 注入過濾器
3.3 運行程序
四、Spring Security 驗證
4.1 WebAuthenticationDetails
4.2 AuthenticationDetailsSource
4.3 AuthenticationProvider
4.4 運行程序
首先在上一篇文章的基礎上,添加驗證碼功能。
一、添加驗證碼
驗證碼的 Servlet 代碼,大家無需關心其內部實現,我也是百度直接撈了一個,直接復制即可。
1.1 驗證碼 Servlet
/** * @author jitwxs * @date 2018/3/29 20:35 */ public class VerifyServlet extends HttpServlet { private static final long serialVersionUID = -5051097528828603895L; /** * 驗證碼圖片的寬度。 */ private int width = 100; /** * 驗證碼圖片的高度。 */ private int height = 30; /** * 驗證碼字符個數 */ private int codeCount = 4; /** * 字體高度 */ private int fontHeight; /** * 干擾線數量 */ private int interLine = 16; /** * 第一個字符的x軸值,因為后面的字符坐標依次遞增,所以它們的x軸值是codeX的倍數 */ private int codeX; /** * codeY ,驗證字符的y軸值,因為並行所以值一樣 */ private int codeY; /** * codeSequence 表示字符允許出現的序列值 */ char[] codeSequence = { 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' }; /** * 初始化驗證圖片屬性 */ @Override public void init() throws ServletException { // 從web.xml中獲取初始信息 // 寬度 String strWidth = this.getInitParameter("width"); // 高度 String strHeight = this.getInitParameter("height"); // 字符個數 String strCodeCount = this.getInitParameter("codeCount"); // 將配置的信息轉換成數值 try { if (strWidth != null && strWidth.length() != 0) { width = Integer.parseInt(strWidth); } if (strHeight != null && strHeight.length() != 0) { height = Integer.parseInt(strHeight); } if (strCodeCount != null && strCodeCount.length() != 0) { codeCount = Integer.parseInt(strCodeCount); } } catch (NumberFormatException e) { e.printStackTrace(); } //width-4 除去左右多余的位置,使驗證碼更加集中顯示,減得越多越集中。 //codeCount+1 //等比分配顯示的寬度,包括左右兩邊的空格 codeX = (width-4) / (codeCount+1); //height - 10 集中顯示驗證碼 fontHeight = height - 10; codeY = height - 7; } /** * @param request * @param response * @throws ServletException * @throws java.io.IOException */ @Override protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, java.io.IOException { // 定義圖像buffer BufferedImage buffImg = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); Graphics2D gd = buffImg.createGraphics(); // 創建一個隨機數生成器類 Random random = new Random(); // 將圖像填充為白色 gd.setColor(Color.LIGHT_GRAY); gd.fillRect(0, 0, width, height); // 創建字體,字體的大小應該根據圖片的高度來定。 Font font = new Font("Times New Roman", Font.PLAIN, fontHeight); // 設置字體。 gd.setFont(font); // 畫邊框。 gd.setColor(Color.BLACK); gd.drawRect(0, 0, width - 1, height - 1); // 隨機產生16條干擾線,使圖象中的認證碼不易被其它程序探測到。 gd.setColor(Color.gray); for (int i = 0; i < interLine; i++) { int x = random.nextInt(width); int y = random.nextInt(height); int xl = random.nextInt(12); int yl = random.nextInt(12); gd.drawLine(x, y, x + xl, y + yl); } // randomCode用於保存隨機產生的驗證碼,以便用戶登錄后進行驗證。 StringBuffer randomCode = new StringBuffer(); int red = 0, green = 0, blue = 0; // 隨機產生codeCount數字的驗證碼。 for (int i = 0; i < codeCount; i++) { // 得到隨機產生的驗證碼數字。 String strRand = String.valueOf(codeSequence[random.nextInt(36)]); // 產生隨機的顏色分量來構造顏色值,這樣輸出的每位數字的顏色值都將不同。 red = random.nextInt(255); green = random.nextInt(255); blue = random.nextInt(255); // 用隨機產生的顏色將驗證碼繪制到圖像中。 gd.setColor(new Color(red,green,blue)); gd.drawString(strRand, (i + 1) * codeX, codeY); // 將產生的四個隨機數組合在一起。 randomCode.append(strRand); } // 將四位數字的驗證碼保存到Session中。 HttpSession session = request.getSession(); session.setAttribute("validateCode", randomCode.toString()); // 禁止圖像緩存。 response.setHeader("Pragma", "no-cache"); response.setHeader("Cache-Control", "no-cache"); response.setDateHeader("Expires", 0); response.setContentType("image/jpeg"); // 將圖像輸出到Servlet輸出流中。 ServletOutputStream sos = response.getOutputStream(); ImageIO.write(buffImg, "jpeg", sos); sos.close(); } }
然后在 Application 中注入該 Servlet:
@SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } /** * 注入驗證碼servlet */ @Bean public ServletRegistrationBean indexServletRegistration() { ServletRegistrationBean registration = new ServletRegistrationBean(new VerifyServlet()); registration.addUrlMappings("/getVerifyCode"); return registration; } }
1.2 修改 login.html
在原本的 Login 頁面基礎上加上驗證碼字段:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>登陸</title> </head> <body> <h1>登陸</h1> <form method="post" action="/login"> <div> 用戶名:<input type="text" name="username"> </div> <div> 密碼:<input type="password" name="password"> </div> <div> <input type="text" class="form-control" name="verifyCode" required="required" placeholder="驗證碼"> <img src="getVerifyCode" title="看不清,請點我" onclick="refresh(this)" onmouseover="mouseover(this)" /> </div> <div> <label><input type="checkbox" name="remember-me"/>自動登錄</label> <button type="submit">立即登陸</button> </div> </form> <script> function refresh(obj) { obj.src = "getVerifyCode?" + Math.random(); } function mouseover(obj) { obj.style.cursor = "pointer"; } </script> </body> </html>
1.3 添加匿名訪問 Url
不要忘記在 WebSecurityConfig 中允許該 Url 的匿名訪問,不然沒有登錄是沒有辦法訪問的:
@Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() // 如果有允許匿名的url,填在下面 .antMatchers("/getVerifyCode").permitAll() .anyRequest().authenticated() .and() // 設置登陸頁 .formLogin().loginPage("/login") // 設置登陸成功頁 .defaultSuccessUrl("/").permitAll() // 登錄失敗Url .failureUrl("/login/error") // 自定義登陸用戶名和密碼參數,默認為username和password // .usernameParameter("username") // .passwordParameter("password") .and() .logout().permitAll() // 自動登錄 .and().rememberMe() .tokenRepository(persistentTokenRepository()) // 有效時間:單位s .tokenValiditySeconds(60) .userDetailsService(userDetailsService); // 關閉CSRF跨域 http.csrf().disable(); }
這樣驗證碼就加好了,運行下程序:
下面才算是這篇文章真正的部分。我們如何才能實現驗證碼驗證呢,思考一下,應該有以下幾種實現方式:
- 登錄表單提交前發送 AJAX 驗證驗證碼
- 使用自定義過濾器(Filter),在 Spring security 校驗前驗證驗證碼合法性
- 和用戶名、密碼一起發送到后台,在 Spring security 中進行驗證
二、AJAX 驗證
使用 AJAX 方式驗證和我們 Spring Security 框架就沒有任何關系了,其實就是表單提交前先發個 HTTP 請求驗證驗證碼,本篇不再贅述。
三、過濾器驗證
使用過濾器的思路是:在 Spring Security 處理登錄驗證請求前,驗證驗證碼,如果正確,放行;如果不正確,調到異常。
3.1 編寫驗證碼過濾器
自定義一個過濾器,實現 OncePerRequestFilter (該 Filter 保證每次請求一定會過濾),在 isProtectedUrl() 方法中攔截了 POST 方式的 /login 請求。
在邏輯處理中從 request 中取出驗證碼,並進行驗證,如果驗證成功,放行;驗證失敗,手動生成異常。
注:這里的異常設置在上一篇已經說過了:SpringBoot集成Spring Security(3)——異常處理
public class VerifyFilter extends OncePerRequestFilter { private static final PathMatcher pathMatcher = new AntPathMatcher(); @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { if(isProtectedUrl(request)) { String verifyCode = request.getParameter("verifyCode"); if(!validateVerify(verifyCode)) { //手動設置異常 request.getSession().setAttribute("SPRING_SECURITY_LAST_EXCEPTION",new DisabledException("驗證碼輸入錯誤")); // 轉發到錯誤Url request.getRequestDispatcher("/login/error").forward(request,response); } else { filterChain.doFilter(request,response); } } else { filterChain.doFilter(request,response); } } 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); } // 攔截 /login的POST請求 private boolean isProtectedUrl(HttpServletRequest request) { return "POST".equals(request.getMethod()) && pathMatcher.match("/login", request.getServletPath()); } }
3.2 注入過濾器
修改 WebSecurityConfig 的 configure 方法,添加一個 addFilterBefore() ,具有兩個參數,作用是在參數二之前執行參數一設置的過濾器。
Spring Security 對於用戶名/密碼登錄方式是通過 UsernamePasswordAuthenticationFilter 處理的,我們在它之前執行驗證碼過濾器即可。
@Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() // 如果有允許匿名的url,填在下面 .antMatchers("/getVerifyCode").permitAll() .anyRequest().authenticated() .and() // 設置登陸頁 .formLogin().loginPage("/login") // 設置登陸成功頁 .defaultSuccessUrl("/").permitAll() // 登錄失敗Url .failureUrl("/login/error") // 自定義登陸用戶名和密碼參數,默認為username和password // .usernameParameter("username") // .passwordParameter("password") .and() .addFilterBefore(new VerifyFilter(),UsernamePasswordAuthenticationFilter.class) .logout().permitAll() // 自動登錄 .and().rememberMe() .tokenRepository(persistentTokenRepository()) // 有效時間:單位s .tokenValiditySeconds(60) .userDetailsService(userDetailsService); // 關閉CSRF跨域 http.csrf().disable(); }
3.3 運行程序
現在來測試下,當驗證碼錯誤后:
四、Spring Security 驗證
使用過濾器就已經實現了驗證碼功能,但其實它和 AJAX 驗證差別不大。
- AJAX 是在提交前發一個請求,請求返回成功就提交,否則不提交;
- 過濾器是先驗證驗證碼,驗證成功就讓 Spring Security 驗證用戶名和密碼;驗證失敗,則產生異常·。
如果我們要做的需求是用戶登錄是需要多個驗證字段,不單單是用戶名和密碼,那么使用過濾器會讓邏輯變得復雜,這時候可以考慮自定義 Spring Security 的驗證邏輯了…
4.1 WebAuthenticationDetails
我們知道 Spring security 默認只會處理用戶名和密碼信息。這時候就要請出我們的主角——WebAuthenticationDetails
。
WebAuthenticationDetails: 該類提供了獲取用戶登錄時攜帶的額外信息的功能,默認提供了 remoteAddress 與 sessionId 信息。
我們需要實現自定義的 WebAuthenticationDetails
,並在其中加入我們的驗證碼:
import org.springframework.security.web.authentication.WebAuthenticationDetails; import javax.servlet.http.HttpServletRequest; /** * 獲取用戶登錄時攜帶的額外信息 * @author jitwxs * @since 2018/5/9 11:15 */ 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; } @Override public String toString() { StringBuilder sb = new StringBuilder(); sb.append(super.toString()).append("; VerifyCode: ").append(this.getVerifyCode()); return sb.toString(); } }
在這個方法中,我們將前台 form 表單中的 verifyCode
獲取到,並通過 get 方法方便被調用。
4.2 AuthenticationDetailsSource
自定義了WebAuthenticationDetails,我i們還需要將其放入 AuthenticationDetailsSource 中來替換原本的 WebAuthenticationDetails ,因此還得實現自定義 AuthenticationDetailsSource :
import org.springframework.security.authentication.AuthenticationDetailsSource; import org.springframework.security.web.authentication.WebAuthenticationDetails; import org.springframework.stereotype.Component; import javax.servlet.http.HttpServletRequest; /** * 該接口用於在Spring Security登錄過程中對用戶的登錄信息的詳細信息進行填充 * @author jitwxs * @since 2018/5/9 11:18 */ @Component public class CustomAuthenticationDetailsSource implements AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> { @Override public WebAuthenticationDetails buildDetails(HttpServletRequest request) { return new CustomWebAuthenticationDetails(request); } }
該類內容將原本的 WebAuthenticationDetails 替換為了我們的 CustomWebAuthenticationDetails。
然后我們將 CustomAuthenticationDetailsSource 注入Spring Security中,替換掉默認的 AuthenticationDetailsSource。
修改 WebSecurityConfig,首先注入 CustomAuthenticationDetailsSource ,然后在config()中使用 authenticationDetailsSource(authenticationDetailsSource)方法來指定它。
@Autowired private AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> authenticationDetailsSource; @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() // 如果有允許匿名的url,填在下面 .antMatchers("/getVerifyCode").permitAll() .anyRequest().authenticated() .and() // 設置登陸頁 .formLogin().loginPage("/login") // 設置登陸成功頁 .defaultSuccessUrl("/").permitAll() // 登錄失敗Url .failureUrl("/login/error") // 自定義登陸用戶名和密碼參數,默認為username和password // .usernameParameter("username") // .passwordParameter("password") // 指定authenticationDetailsSource .authenticationDetailsSource(authenticationDetailsSource) .and() .logout().permitAll() // 自動登錄 .and().rememberMe() .tokenRepository(persistentTokenRepository()) // 有效時間:單位s .tokenValiditySeconds(60) .userDetailsService(userDetailsService); // 關閉CSRF跨域 http.csrf().disable(); }
4.3 AuthenticationProvider
至此我們通過自定義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
注入進去:
@Autowired private CustomAuthenticationProvider customAuthenticationProvider; @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.authenticationProvider(customAuthenticationProvider); auth.userDetailsService(userDetailsService).passwordEncoder(new PasswordEncoder() { @Override public String encode(CharSequence charSequence) { return charSequence.toString(); } @Override public boolean matches(CharSequence charSequence, String s) { return s.equals(charSequence.toString()); } }); }
4.4 運行程序
是不是比較復雜,為了實現該需求自定義了 WebAuthenticationDetails
、AuthenticationDetailsSource
、AuthenticationProvider
,讓我們運行一下程序,當輸入錯誤驗證碼時:
五、SpringSecurity加密方法
源碼大致理解:
public class BCryptPasswordEncoder implements PasswordEncoder public interface PasswordEncoder { String encode(CharSequence var1); boolean matches(CharSequence var1, String var2); }
第一個方法是加密方法,可以將明文密碼加密成Bcrypt算法的密碼。每次加密都會根據不同的鹽生成不同的密文(同一明文,每次生成的密碼都不一樣),因而需要用第二種方法來比對密碼正確與否。
第二個方法用於比對密碼,第一個參數為明文密碼,第二個參數為加密后的密文,如果匹配則返回true,如果不匹配則返回false。
CustomAuthenticationProvider 修改如下:
@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);
//加密 BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); // 如果是自定義AuthenticationProvider,需要手動密碼校驗 if(!encoder.matches(inputPassword,userDetails.getPassword()) { 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); } }
---------------------
作者:Jitwxs
來源:CSDN
原文:https://blog.csdn.net/yuanlaijike/article/details/80253922