spring boot:spring security给用户登录增加自动登录及图形验证码功能(spring boot 2.3.1)
一,图形验证码的用途?
1,什么是图形验证码?
验证码(CAPTCHA)是“Completely Automated Public Turing test to tell Computers and Humans Apart”(全自动区分计算机和人类的图灵测试)的缩写,
它是用来区分用户是人类还是计算机的公共全自动程序
它可以防止对url的恶意刷量/频繁攻击/破解密码等
2,如果有短信验证码,还需要图形验证码吗?
当然需要,很多发送短信验证码的url就是因为没有图形验证码才遭受到攻击
3,我们在这里使用了kaptcha这个图形验证码库,
官方代码站:
https://github.com/penggle/kaptcha
说明:刘宏缔的架构森林是一个专注架构的博客,
网站:https://blog.imgtouch.com
本文: https://blog.imgtouch.com/index.php/2023/05/25/springbootspringsecurity-gei-yong-hu-deng-lu-zeng-jia-zi-dong-deng-lu-ji-tu-xing-yan-zheng-ma-gong/
对应的源码可以访问这里获取: 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)