一,圖形驗證碼的用途?
1,什么是圖形驗證碼?
驗證碼(CAPTCHA)是“Completely Automated Public Turing test to tell Computers and Humans Apart”(全自動區分計算機和人類的圖靈測試)的縮寫,
它是用來區分用戶是人類還是計算機的公共全自動程序
它可以防止對url的惡意刷量/頻繁攻擊/破解密碼等
2,如果有短信驗證碼,還需要圖形驗證碼嗎?
當然需要,很多發送短信驗證碼的url就是因為沒有圖形驗證碼才遭受到攻擊
3,我們在這里使用了kaptcha這個圖形驗證碼庫,
官方代碼站:
https://github.com/penggle/kaptcha
說明:劉宏締的架構森林是一個專注架構的博客,地址:https://www.cnblogs.com/architectforest
對應的源碼可以訪問這里獲取: https://github.com/liuhongdi/
說明:作者:劉宏締 郵箱: 371125307@qq.com
二,演示項目的相關信息
1,項目地址
https://github.com/liuhongdi/securityloginadv
2,項目功能說明:
基於數據庫實現登錄和權限管理,
記住登錄(自動登錄)
用kaptcha實現圖形驗證碼
3,項目結構:如圖:
三,配置文件說明
1,pom.xml
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!--kaptcha begin--> <dependency> <groupId>com.github.penggle</groupId> <artifactId>kaptcha</artifactId> <version>2.3.2</version> </dependency> <!--security begin--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <!--thymeleaf begin--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <!--validation begin--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!--mysql mybatis begin--> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.1.3</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <!-- JSON解析fastjson begin--> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.72</version> </dependency>
2,application.properties
#thymeleaf spring.thymeleaf.cache=false spring.thymeleaf.encoding=UTF-8 spring.thymeleaf.mode=HTML spring.thymeleaf.prefix=classpath:/templates/ spring.thymeleaf.suffix=.html #mysql spring.datasource.url=jdbc:mysql://localhost:3306/security?characterEncoding=utf8&useSSL=false spring.datasource.username=root spring.datasource.password=lhddemo spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver #mybatis mybatis.mapper-locations=classpath:/mapper/*Mapper.xml mybatis.type-aliases-package=com.example.demo.mapper #error server.error.include-stacktrace=always #log logging.level.org.springframework.web=trace #session server.servlet.session.timeout=120
3,數據庫
表結構:
CREATE TABLE `sys_user` (
`userId` int(11) NOT NULL AUTO_INCREMENT COMMENT 'id', `userName` varchar(100) NOT NULL DEFAULT '' COMMENT '用戶名', `password` varchar(100) NOT NULL DEFAULT '' COMMENT '密碼', `nickName` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '昵稱', PRIMARY KEY (`userId`), UNIQUE KEY `userName` (`userName`) ) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用戶表'
添加數據 :
INSERT INTO `sys_user` (`userId`, `userName`, `password`, `nickName`) VALUES
(1, 'lhd', '$2a$10$yGcOz3ekNI6Ya67tqQueS.raxyTOedGsv5jh2BwtRrI5/K9QEIPGq', '老劉'), (2, 'admin', '$2a$10$yGcOz3ekNI6Ya67tqQueS.raxyTOedGsv5jh2BwtRrI5/K9QEIPGq', '管理員'), (3, 'merchant', '$2a$10$yGcOz3ekNI6Ya67tqQueS.raxyTOedGsv5jh2BwtRrI5/K9QEIPGq', '商戶老張');
說明:3個密碼都是111111,僅供演示使用,大家在生產環境中一定不要這樣設置
CREATE TABLE `sys_user_role` (
`urId` int(11) NOT NULL AUTO_INCREMENT COMMENT 'id', `userId` int(11) NOT NULL DEFAULT '0' COMMENT '用戶id', `roleName` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '角色id', PRIMARY KEY (`urId`), UNIQUE KEY `userId` (`userId`,`roleName`) ) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用戶角色關聯表'
插入數據:
INSERT INTO `sys_user_role` (`urId`, `userId`, `roleName`) VALUES
(1, 2, 'ADMIN'), (2, 3, 'MERCHANT');
用來保存記住登錄信息的persistent_logins數據表:
CREATE TABLE `persistent_logins` ( `username` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, `series` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, `token` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, `last_used` timestamp NOT NULL, PRIMARY KEY (`series`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
四,java代碼說明:
1,WebSecurityConfig.java
@Configuration @EnableWebSecurity public class WebSecurityConfig extends WebSecurityConfigurerAdapter { private final static BCryptPasswordEncoder ENCODER = new BCryptPasswordEncoder(); private static final String SECRET = "lhd@2020"; @Resource private UserLoginFailureHandler userLoginFailureHandler;//驗證失敗的處理類 @Resource private UserLoginSuccessHandler userLoginSuccessHandler;//驗證成功的處理類 @Resource private UserLogoutSuccessHandler userLogoutSuccessHandler; @Resource private UserAccessDeniedHandler userAccessDeniedHandler; @Resource private SecUserDetailService secUserDetailService; //rememberme @Resource private DataSource dataSource; //rememberme repository @Bean public PersistentTokenRepository persistentTokenRepository(){ JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl(); // 設置數據源 tokenRepository.setDataSource(dataSource); return tokenRepository; } //指定加密的方式,避免出現:There is no PasswordEncoder mapped for the id "null" @Bean public PasswordEncoder passwordEncoder(){//密碼加密類 return new BCryptPasswordEncoder(); } //配置規則 @Override protected void configure(HttpSecurity http) throws Exception { //static http.authorizeRequests() .antMatchers("/css/**","/js/**","/img/**")//靜態資源等不需要驗證 .permitAll(); //permitall http.authorizeRequests() .antMatchers("/home/**","/image/defaultkaptcha**")//permitall .permitAll(); //login http.formLogin() .loginPage("/login/login") .loginProcessingUrl("/login/logined")//發送Ajax請求的路徑 .usernameParameter("username")//請求驗證參數 .passwordParameter("password")//請求驗證參數 .failureHandler(userLoginFailureHandler)//驗證失敗處理 .successHandler(userLoginSuccessHandler)//驗證成功處理 .permitAll(); //登錄頁面用戶任意訪問 //logout http.logout() .logoutUrl("/login/logout") .logoutSuccessUrl("/login/logout") .logoutSuccessHandler(userLogoutSuccessHandler)//登出處理 .deleteCookies("JSESSIONID") .clearAuthentication(true) .invalidateHttpSession(true) .permitAll(); //有角色的用戶才能訪問 http.authorizeRequests() .antMatchers("/admin/**").hasRole("ADMIN") .antMatchers("/merchant/**").hasAnyRole("MERCHANT","ADMIN"); //其他任何請求,登錄后可以訪問 http.authorizeRequests().anyRequest().authenticated(); //rememberme http.rememberMe() .rememberMeCookieName("remember-me") .tokenRepository(persistentTokenRepository()) .tokenValiditySeconds(300) //Token過期時間為1minutes,一個小時 .userDetailsService(secUserDetailService); //圖形驗證碼 http.addFilterBefore(new KaptchaFilter("/login/logined", "/login?error"), UsernamePasswordAuthenticationFilter.class); //logout時有可能session已過期 http.csrf().ignoringAntMatchers("/login/logout"); //accessdenied http.exceptionHandling().accessDeniedHandler(userAccessDeniedHandler);//無權限時的處理 } @Resource public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(secUserDetailService).passwordEncoder(new PasswordEncoder() { @Override public String encode(CharSequence charSequence) { return ENCODER.encode(charSequence); } //密碼匹配,看輸入的密碼經過加密與數據庫中存放的是否一樣 @Override public boolean matches(CharSequence charSequence, String s) { return ENCODER.matches(charSequence,s); } }); } }
訪問規則和remeberme的配置
2,SecUser.java
public class SecUser extends User { //用戶id private int userid; //昵稱 private String nickname; public SecUser(String username, String password, Collection<? extends GrantedAuthority> authorities) { super(username, password, authorities); } public SecUser(String username, String password, boolean enabled, boolean accountNonExpired, boolean credentialsNonExpired, boolean accountNonLocked, Collection<? extends GrantedAuthority> authorities) { super(username, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorities); } public String getNickname() { return nickname; } public void setNickname(String nickname) { this.nickname = nickname; } public int getUserid() { return userid; } public void setUserid(int userid) { this.userid = userid; } }
繼承自spring security中的User類,增加了用戶id和昵稱
3,SecUserDetailService.java
@Component("SecUserDetailService") public class SecUserDetailService implements UserDetailsService{ @Resource private SysUserService sysUserService; @Override public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException { //得到用戶信息 SysUser oneUser = sysUserService.getOneUserByUsername(s);//數據庫查詢 看用戶是否存在 String encodedPassword = oneUser.getPassword(); Collection<GrantedAuthority> collection = new ArrayList<>();//權限集合 //用戶角色role前面要添加ROLE_ List<String> roles = oneUser.getRoles(); System.out.println(roles); for (String roleone : roles) { GrantedAuthority grantedAuthority = new SimpleGrantedAuthority("ROLE_"+roleone); collection.add(grantedAuthority); } //給用戶增加用戶id和昵稱 SecUser user = new SecUser(s,encodedPassword,collection); user.setUserid(oneUser.getUserId()); user.setNickname(oneUser.getNickName()); return user; } }
從數據庫查詢用戶信息
4,UserAccessDeniedHandler.java
@Component("UserAccessDeniedHandler") public class UserAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException { boolean isAjax = ServletUtil.isAjax();if (isAjax == true) { ServletUtil.printRestResult(RestResult.error(ResponseCode.ACCESS_DENIED)); } else { ServletUtil.printString(ResponseCode.ACCESS_DENIED.getMsg()); } } }
處理訪問被拒絕
5,UserLoginFailureHandler.java
@Component("UserLoginFailureHandler") public class UserLoginFailureHandler extends SimpleUrlAuthenticationFailureHandler { @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { //System.out.println("UserLoginFailureHandler"); ServletUtil.printRestResult(RestResult.error(ResponseCode.LOGIN_FAIL)); } }
處理登錄失敗
6,UserLoginSuccessHandler.java
@Component("UserLoginSuccessHandler") public class UserLoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { ServletUtil.printRestResult(RestResult.success(0,"登錄成功")); } }
處理登錄成功
7,UserLogoutSuccessHandler.java
@Component("UserLogoutSuccessHandler") public class UserLogoutSuccessHandler implements LogoutSuccessHandler{ @Override public void onLogoutSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException { httpServletRequest.getSession().invalidate(); ServletUtil.printRestResult(RestResult.success(0,"退出成功")); } }
處理退出成功
8,KaptchaFilter.java
public class KaptchaFilter extends AbstractAuthenticationProcessingFilter { // parameter name private static final String VRIFYCODE ="vrifyCode"; // 攔截請求地址 private String servletPath; public KaptchaFilter(String servletPath, String failureUrl) { super(servletPath); this.servletPath = servletPath; setAuthenticationFailureHandler(new SimpleUrlAuthenticationFailureHandler(failureUrl)); } @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse res = (HttpServletResponse) response; if ("POST".equalsIgnoreCase(req.getMethod()) && servletPath.equals(req.getServletPath())) { String expect = (String) req.getSession().getAttribute(VRIFYCODE); if (expect != null && !expect.equalsIgnoreCase(req.getParameter(VRIFYCODE))) { System.out.println("kaptchafilter: vrifycode is not right"); ServletUtil.printRestResult(RestResult.error(ResponseCode.AUTHCODE_INVALID)); return; } else { System.out.println("kaptchafilter: vrifycode is right"); } } else { System.out.println("kaptchafilter:not post"); } chain.doFilter(req, res); } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException { return null; } }
過濾器,檢查圖形驗證碼是否正確
9,KaptchaSingle.java
public class KaptchaSingle { private static KaptchaSingle instance; private KaptchaSingle() { }; public static KaptchaSingle getInstance() { if (instance == null) { instance = new KaptchaSingle(); } return instance; } /** * 生成DefaultKaptcha 默認配置 * @return */ public DefaultKaptcha produce() { Properties properties = new Properties(); properties.put("kaptcha.border", "no"); properties.put("kaptcha.border.color", "105,179,90"); properties.put("kaptcha.textproducer.font.color", "blue"); properties.put("kaptcha.image.width", "199"); properties.put("kaptcha.image.height", "50"); properties.put("kaptcha.textproducer.font.size", "37"); properties.put("kaptcha.session.key", "code"); properties.put("kaptcha.textproducer.char.length", "4"); properties.put("kaptcha.textproducer.font.names", "宋體,楷體,微軟雅黑"); properties.put("kaptcha.textproducer.char.string", "0123456789ABCEFGHIJKLMNOPQRSTUVWXYZ"); properties.put("kaptcha.obscurificator.impl", "com.google.code.kaptcha.impl.WaterRipple"); properties.put("kaptcha.noise.color", "black"); properties.put("kaptcha.noise.impl", "com.google.code.kaptcha.impl.DefaultNoise"); properties.put("kaptcha.background.clear.from", "185,56,213"); properties.put("kaptcha.background.clear.to", "white"); properties.put("kaptcha.textproducer.char.space", "3"); Config config = new Config(properties); DefaultKaptcha defaultKaptcha = new DefaultKaptcha(); defaultKaptcha.setConfig(config); return defaultKaptcha; } }
配置Kaptcha
10,ImageController.java
@Controller @RequestMapping("/image") public class ImageController { //生成圖形驗證碼 @RequestMapping("/defaultkaptcha") public void defaultKaptcha(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) throws Exception { byte[] captchaChallengeAsJpeg = null; ByteArrayOutputStream jpegOutputStream = new ByteArrayOutputStream(); try { // 代碼方式創建:DefaultKaptcha KaptchaSingle single = KaptchaSingle.getInstance(); DefaultKaptcha defaultKaptcha = single.produce(); // 生產驗證碼字符串並保存到session中 String createText = defaultKaptcha.createText(); httpServletRequest.getSession().setAttribute("vrifyCode", createText); // 使用生產的驗證碼字符串返回一個BufferedImage對象並轉為byte寫入到byte數組中 BufferedImage challenge = defaultKaptcha.createImage(createText); ImageIO.write(challenge, "jpg", jpegOutputStream); } catch (IllegalArgumentException e) { httpServletResponse.sendError(HttpServletResponse.SC_NOT_FOUND); return; } // 定義response輸出類型為image/jpeg類型,使用response輸出流輸出圖片的byte數組 captchaChallengeAsJpeg = jpegOutputStream.toByteArray(); httpServletResponse.setHeader("Cache-Control", "no-store"); httpServletResponse.setHeader("Pragma", "no-cache"); httpServletResponse.setDateHeader("Expires", 0); httpServletResponse.setContentType("image/jpeg"); ServletOutputStream responseOutputStream = httpServletResponse.getOutputStream(); responseOutputStream.write(captchaChallengeAsJpeg); responseOutputStream.flush(); responseOutputStream.close(); } }
生成圖形驗證碼
11,login.html
<!DOCTYPE html> <html> <head> <meta content="text/html;charset=UTF-8"/> <title>登錄頁面</title> <script type="text/javascript" language="JavaScript" src="/js/jquery-1.6.2.min.js"></script> <style type="text/css"> body { padding-top: 50px; } .starter-template { padding: 40px 15px; text-align: center; } </style> <!-- CSRF --> <meta name="_csrf" th:content="${_csrf.token}"/> <!-- default header name is X-CSRF-TOKEN --> <meta name="_csrf_header" th:content="${_csrf.headerName}"/> </head> <body> <nav class="navbar navbar-inverse navbar-fixed-top"> <div class="container"> <div id="navbar" class="collapse navbar-collapse"> <ul class="nav navbar-nav"> <li><a href="/home/home"> 首頁 </a></li> </ul> </div><!--/.nav-collapse --> </div> </nav> <div class="container"> <div class="starter-template"> <h2>使用賬號密碼登錄</h2> <div class="form-group"> <label for="username">賬號</label> <input type="text" class="form-control" id="username" name="username" value="" placeholder="賬號" /> </div> <div class="form-group"> <label for="password">密碼</label> <input type="password" class="form-control" id="password" name="password" placeholder="密碼" /> </div> <div class="form-group"> <label for="password">記住登錄</label> <input type="checkbox" name="is_remember_me" id="is_remember_me" value="true" /> </div> <div class="form-group"> <label for="password">驗證碼</label> <img id="kaptcha" alt="驗證碼" onclick = "refresh_kaptcha()" src="/image/defaultkaptcha" /><br/> <input type="text" id="vrifyCode" name="vrifyCode" placeholder="驗證碼" /> </div> <button name="formsubmit" value="登錄" onclick="go_login()" >登錄</button> </div> </div> <script> //刷新圖形驗證碼 function refresh_kaptcha() { document.getElementById("kaptcha").src='/image/defaultkaptcha?d='+new Date(); } //登錄 function go_login(){ if ($("#username").val() == "") { alert('用戶名不可為空'); $("#username").focus(); return false; } if ($("#password").val() == "") { alert('密碼不可為空'); $("#password").focus(); return false; } if ($("#vrifyCode").val() == "") { alert('驗證碼不可為空'); $("#vrifyCode").focus(); return false; } var rememberme_val = false; if (document.getElementById('is_remember_me').checked == true) { rememberme_val = true; } var postdata = { username:$("#username").val(), password:$("#password").val(), vrifyCode:$("#vrifyCode").val(), 'remember-me':rememberme_val } var csrfToken = $("meta[name='_csrf']").attr("content"); var csrfHeader = $("meta[name='_csrf_header']").attr("content"); $.ajax({ type:"POST", //type:"GET", url:"/login/logined", data:postdata, //返回數據的格式 datatype: "json",//"xml", "html", "script", "json", "jsonp", "text". beforeSend: function(request) { request.setRequestHeader(csrfHeader, csrfToken); // 添加 CSRF Token }, success:function(data){ if (data.code == 0) { // alert('login success:'+data.msg); window.location.href="/home/home"; } else { alert("failed:"+data.msg); //window.location.href="/login/login"; } }, //調用執行后調用的函數 complete: function(XMLHttpRequest, textStatus){ }, //調用出錯執行的函數 error: function(){ //請求出錯處理 alert('error'); } }); } </script> </body> </html>
12,其他相關代碼,可以訪問github
五,測試效果
1,訪問登錄頁面:
http://127.0.0.1:8080/login/login
如果輸入錯誤的圖形驗證碼時,會報錯:
2,登錄時選中記住登錄:
查看cookie:
可以看到cookie中增加了remember-me這個cookie
查看數據庫:
persistent_logins數據表中也生成了記住登錄信息的記錄
3,登錄后記住當前的session id的值:
因為我們配置了session的時長是120秒,
所以在120秒后再回來刷新頁面 ,因為rememberme的cookie的時長是5分鍾(300秒)
則刷新頁面后應該會生成一個新的session id:
可以見到雖然仍然處於登錄狀態,但原session已過期,
remember-me功能為當前會話生成了新的session
六,查看spring boot版本:
. ____ _ __ _ _ /\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \ ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \ \\/ ___)| |_)| | | | | || (_| | ) ) ) ) ' |____| .__|_| |_|_| |_\__, | / / / / =========|_|==============|___/=/_/_/_/ :: Spring Boot :: (v2.3.1.RELEASE)