前后端分离下的SpringSecurity
项目创建
-
使用
SpringBoot
初始化器创建SpringBoot
项目 -
修改项目依赖
project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> modelVersion>4.0.0modelVersion> parent> groupId>org.springframework.bootgroupId> artifactId>spring-boot-starter-parentartifactId> version>2.7.9version> relativePath/> parent> groupId>com.examplegroupId> artifactId>baizhi-securityartifactId> version>0.0.1-SNAPSHOTversion> properties> java.version>1.8java.version> properties> dependencies> dependency> groupId>org.springframework.bootgroupId> artifactId>spring-boot-starter-securityartifactId> dependency> dependency> groupId>org.springframework.bootgroupId> artifactId>spring-boot-starter-webartifactId> dependency> dependency> groupId>org.mybatis.spring.bootgroupId> artifactId>mybatis-spring-boot-starterartifactId> version>2.3.0version> dependency> dependency> groupId>com.mysqlgroupId> artifactId>mysql-connector-jartifactId> scope>runtimescope> dependency> dependency> groupId>org.projectlombokgroupId> artifactId>lombokartifactId> optional>trueoptional> dependency> dependency> groupId>org.springframework.bootgroupId> artifactId>spring-boot-starter-testartifactId> scope>testscope> dependency> dependency> groupId>org.springframework.securitygroupId> artifactId>spring-security-testartifactId> scope>testscope> dependency> dependency> groupId>com.alibabagroupId> artifactId>druid-spring-boot-starterartifactId> version>1.2.15version> dependency> dependency> groupId>com.github.pengglegroupId> artifactId>kaptchaartifactId> version>2.3.2version> dependency> dependency> groupId>org.springframework.bootgroupId> artifactId>spring-boot-starter-data-redisartifactId> dependency> dependencies> build> plugins> plugin> groupId>org.springframework.bootgroupId> artifactId>spring-boot-maven-pluginartifactId> configuration> excludes> exclude> groupId>org.projectlombokgroupId> artifactId>lombokartifactId> exclude> excludes> configuration> plugin> plugins> build> project>
-
Java
环境JDK 1.8
-
YAML
配置spring: datasource: druid: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/security?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC username: root password: root redis: host: 192.168.47.128 # 虚拟机 ip port: 6379 # (配置过主从复制)必须使用 master 机器 的端口号 database: 0 # 选择的数据库实例 connect-timeout: 10000 # 超时时间 mybatis: type-aliases-package: com.example.baizhisecurity.entity mapper-locations: com/example/baizhisecurity/mapper/*Mapper.xml logging: level: com.example.baizhisecurity: debug # 查看 SQL # 修改服务器的过期时间为 1 分钟 server: servlet: session: timeout: 1 error: # 自定义错误页面相关的配置 whitelabel: enabled: false # 关闭默认的显示 path: /error # 定义错误的路径 resources: # 资源映射 add-mappings: true
数据库表
-
user
user
-- {noop} 是 SpringSecurity 密码无加密的 id INSERT INTO `user` VALUES (1, 'root', '{bcrypt}$2a$10$f1Y3k626cs1ict.wKKWNDuFwk46.YkcdIx/Ib/wHEsnoW7Uo/1Nb6', 1, 1, 1, 1); INSERT INTO `user` VALUES (2, 'admin', '{noop}123', 1, 1, 1, 1); INSERT INTO `user` VALUES (3, 'coder-itl', '{noop}123', 1, 1, 1, 1);
-
role
role
INSERT INTO `security`.`role` (`id`, `name`, `name_zh`) VALUES (1, 'ROLE_product', '商品管理员'); INSERT INTO `security`.`role` (`id`, `name`, `name_zh`) VALUES (2, 'ROLE_admin', '系统管理员'); INSERT INTO `security`.`role` (`id`, `name`, `name_zh`) VALUES (3, 'ROLE_user', '用户管理员');
-
user_role
INSERT INTO `security`.`user_role` (`id`, `uid`, `rid`) VALUES (1, 1, 1); INSERT INTO `security`.`user_role` (`id`, `uid`, `rid`) VALUES (2, 1, 2); INSERT INTO `security`.`user_role` (`id`, `uid`, `rid`) VALUES (3, 2, 2); INSERT INTO `security`.`user_role` (`id`, `uid`, `rid`) VALUES (4, 3, 3);
实体类
-
用户实体
package com.example.baizhisecurity.entity; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import java.util.*; @Data @NoArgsConstructor @AllArgsConstructor public class User implements UserDetails { private Integer id; private String username; private String password; private Boolean enabled; private Boolean accountNonExpired; private Boolean accountNonLocked; private Boolean credentialsNonExpired; private ListRole> roles = new ArrayList>(); // 权限集合 @Override public Collection? extends GrantedAuthority> getAuthorities() { SetSimpleGrantedAuthority> authorities = new HashSet>(); roles.forEach(role -> { SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(role.getName()); authorities.add(simpleGrantedAuthority); }); return authorities; } @Override public String getPassword() { return password; } @Override public String getUsername() { return username; } @Override public boolean isAccountNonExpired() { return accountNonExpired; } @Override public boolean isAccountNonLocked() { return accountNonLocked; } @Override public boolean isCredentialsNonExpired() { return credentialsNonExpired; } @Override public boolean isEnabled() { return enabled; } }
-
角色实体
package com.example.baizhisecurity.entity; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @Data @NoArgsConstructor @AllArgsConstructor public class Role { private Integer id; private String name; private String nameZh; }
控制器
-
测试控制器类
@RestController public class HelloController { @GetMapping("/hello") public ResultModel hello() { return ResultModel.success(HttpStatus.OK.value(), "访问成功", "Hello developer,You successfully retrieved the data!"); } }
JSON 响应和统一数据返回
-
响应
public class ResponseUtil { public static void out(HttpServletResponse response,ResultModel resultModel){ ObjectMapper objectMapper = new ObjectMapper(); // 设置响应的状态为 200 response.setStatus(HttpStatus.OK.value()); // 设置响应的格式为 JSON 格式 response.setContentType(MediaType.APPLICATION_JSON_VALUE); try { // 使用jackson,把json格式的resultModel写入到response的输出流中 objectMapper.writeValue(response.getOutputStream(),resultModel); } catch (IOException e) { e.printStackTrace(); } } }
-
统一数据返回模型
package com.example.baizhisecurity.common; import lombok.Data; import java.io.Serializable; @Data public class ResultModelT> implements Serializable { // 状态码 private int code; // 1000表示成功 401 表示认证失败 // 消息 private String message; // 数据 private T data; private static ResultModel resultModel = new ResultModel(); public static ResultModel success(String message) { resultModel.setCode(1000); resultModel.setMessage(message); resultModel.setData(null); return resultModel; } public static ResultModel success(Object data) { resultModel.setCode(1000); resultModel.setMessage("success"); resultModel.setData(data); return resultModel; } public static ResultModel success(String message, Object data) { resultModel.setCode(1000); resultModel.setMessage(message); resultModel.setData(data); return resultModel; } public static ResultModel success(Integer code, String message) { resultModel.setCode(1000); resultModel.setMessage(message); return resultModel; } public static ResultModel success(Integer code, String message, Object data) { resultModel.setCode(code); resultModel.setMessage(message); resultModel.setData(data); return resultModel; } public static ResultModel error() { resultModel.setCode(500); resultModel.setMessage("error"); return resultModel; } public static ResultModel error(int code, String message) { resultModel.setCode(code); resultModel.setMessage(message); return resultModel; } }
SpringSecurity 的配置
配置类
-
配置类的实现
package com.example.baizhisecurity.config; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @Slf4j @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true) public class SecurityConfig extends WebSecurityConfigurerAdapter { // redis private final StringRedisTemplate redisTemplate; // 登录成功处理 private final MyLogoutSuccessHandler myLogoutSuccessHandler; // 自定义认证成功处理 private final MyAuthenticationSuccessHandler myAuthenticationSuccessHandler; // 自定义认证失败处理 private final MyAuthenticationFailureHandler myAuthenticationFailureHandler; // 自定义认证异常处理 private final MyAuthenticationEntryPoint myAuthenticationEntryPoint; // RememberMe 需要的数据源 private final DataSource dataSource; // 数据库数据源认证 private final MyUserDetalService myUserDetalService; // 自定义授权异常处理 private final MyAccessDeniedHandler myAccessDeniedHandler; @Autowired public SecurityConfig( DataSource dataSource, StringRedisTemplate redisTemplate, MyUserDetalService myUserDetalService, MyAccessDeniedHandler myAccessDeniedHandler, MyLogoutSuccessHandler myLogoutSuccessHandler, MyAuthenticationEntryPoint myAuthenticationEntryPoint, MyAuthenticationFailureHandler myAuthenticationFailureHandler, MyAuthenticationSuccessHandler myAuthenticationSuccessHandler ) { this.redisTemplate = redisTemplate; this.myLogoutSuccessHandler = myLogoutSuccessHandler; this.myAuthenticationSuccessHandler = myAuthenticationSuccessHandler; this.myAuthenticationFailureHandler = myAuthenticationFailureHandler; this.myAuthenticationEntryPoint = myAuthenticationEntryPoint; this.dataSource = dataSource; this.myUserDetalService = myUserDetalService; this.myAccessDeniedHandler = myAccessDeniedHandler; } // 放行资源白名单 private static final String[] WHITE = { "/login", "/css/**", "/img/**", "/captcha/**" }; /** * TODO: 自定义前后端分离 Form 表单 => JSON 格式 * 自定义 Filter 交给工厂管理 */ @Bean public LoginFilter loginFilter() throws Exception { LoginFilter loginFilter = new LoginFilter(redisTemplate); // 设置认证路径 loginFilter.setFilterProcessesUrl("/login"); // 指定接受 json 用户名的 key loginFilter.setUsernameParameter("username"); // 指定接受 json 密码的 key loginFilter.setPasswordParameter("password"); // 指定接受 json 验证码的 key loginFilter.setKaptchaParameter("kaptcha"); // 指定接受 json 记住我的 key loginFilter.setRememberMeParameter("remember-me"); // TODO 什么作用 loginFilter.setAuthenticationManager(authenticationManagerBean()); // 认账成功处理 loginFilter.setAuthenticationSuccessHandler(myAuthenticationSuccessHandler); //认证失败处理 loginFilter.setAuthenticationFailureHandler(myAuthenticationFailureHandler); // TODO 设置认证成功时使用自定义 rememberMeService loginFilter.setRememberMeServices(rememberMeServices()); return loginFilter; } /** * authenticationManagerBean 是一个方法名,用于获取一个 Spring Security 的认证管理器实例, * 该方法将认证管理器实例化并将其注入到 Spring 上下文中以供其他 Bean 使用。 * Spring Security 默认会为您提供一个认证管理器实例,但如果您需要在自己的代码中使用它, * 可以使用这个方法将其注入到您的代码中。 * 在这个方法中,super.authenticationManagerBean() 调用了父类的同名方法, * 返回了一个 AuthenticationManager 实例。这个实例将被 Spring 管理并注入到上下文中。 * Regenerate response * * @return * @throws Exception */ @Override @Bean public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } /** * 自定义 AuthenticationManager 推荐 * 它的作用是管理用户认证的过程。 * 具体来说,它接收用户的登录请求并从Spring Security进行用户认证。在进行用户认证的过程中,AuthenticationManager 首先根据用户名获取用户信息, * 然后将给定的用户名和密码与用户信息进行比较,如果验证通过,则认为用户已经被认证。如果验证失败,则会抛出异常,表示用户认证失败。 * * @param auth * @throws Exception */ @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(myUserDetalService); } /** * 前后端分离的配置实现 * * @param http * @throws Exception */ @Override protected void configure(HttpSecurity http) throws Exception { http // 前后端分离配置开启 csrf .csrf() // 将令牌保存到 cookie 中,允许 cookie 前端获取 .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) .and() // 放行资源 .authorizeRequests().mvcMatchers(WHITE).permitAll() // 认证资源 .anyRequest().authenticated() // 开启表单认证 .and() .formLogin() .and() // 注销 .logout() // 前后端分离的处理方式,页面不跳转,响应 json 格式 .logoutSuccessHandler(myLogoutSuccessHandler) // 清除会话、清楚认证标记、注销成功后的默认跳转到登录页等为默认配置,可以不声明出现 // 退出的请求方式指定 GET、POST .logoutRequestMatcher(new OrRequestMatcher( new AntPathRequestMatcher("/logout", "GET"), // 可以指定多种同时指定请求方式 new AntPathRequestMatcher("/myLogout", "POST") )) .and() // 认证异常的处理 .exceptionHandling() .authenticationEntryPoint(myAuthenticationEntryPoint) // 授权异常处理 .accessDeniedHandler(myAccessDeniedHandler) // 记住我 .and() .rememberMe() // 前后端分离的实现: 设置自动登录使用那个 rememberMe .rememberMeServices(rememberMeServices()) // 跨域配置,当加入 SpringSecurity 后,原来SpringBoot的跨域解决失效 .and() .cors() ; // at: 用来某个 filter 替换过滤器链中那个 filter // before: 放在过滤器链中那个 filter 之前 // after: 放在过滤器链中那个 filter 之后 http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class); } // 指定 RememberMe 数据持久化处理 @Bean public PersistentTokenRepository persistentTokenRepository() { JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl(); // 指定数据源 tokenRepository.setDataSource(dataSource); // TODO 第一次使用需要设置为 true tokenRepository.setCreateTableOnStartup(false); return tokenRepository; } /** * 前后端分离记住我的实现 * * @return MyRememberServices(String key, UserDetailsService userDetailsService, PersistentTokenRepository tokenRepository) */ @Bean public RememberMeServices rememberMeServices() { return new MyRememberServices(UUID.randomUUID().toString(), userDetailsService(), persistentTokenRepository()); } }
前后端分离相关自定义实现
-
自定义授权异常处理
@Component public class MyAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { ResponseUtil.out(response, ResultModel.error(HttpStatus.FORBIDDEN.value(), "请获取授权后在访问....")); } }
-
自定义认证异常处理
@Component public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { ResponseUtil.out(response, ResultModel.error(HttpStatus.UNAUTHORIZED.value(), "请认证之后再去处理....")); } }
-
自定义认证失败处理
@Component public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler { @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { ResponseUtil.out(response, ResultModel.error(HttpStatus.UNAUTHORIZED.value(), "认证失败")); } }
-
自定义认证成功后的处理
@Component public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { ResponseUtil.out(response, ResultModel.success(HttpStatus.OK.value(), "认证成功", authentication)); } }
-
自定义注销成功的处理
@Component public class MyLogoutSuccessHandler implements LogoutSuccessHandler { @Override public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { ResponseUtil.out(response, ResultModel.success(HttpStatus.OK.value(), "注销成功")); } }
-
自定义前后端分离认证
Filter
package com.example.baizhisecurity.filter; import com.example.baizhisecurity.exception.KaptchaNotMatchException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.http.MediaType; import org.springframework.security.authentication.AuthenticationServiceException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.util.ObjectUtils; import javax.servlet.ServletRequest; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.Map; /** * 自定义前后端分离认证 Filter */ @Slf4j public class LoginFilter extends UsernamePasswordAuthenticationFilter { private StringRedisTemplate redisTemplate; // 设置默认的表单验证码 name = kaptcha private static final String FORM_KAPTCHA_KEY = "kaptcha"; private static final String FORM_REMEMBER_ME_KEY = "remember-me"; private String kaptchaParameter = FORM_KAPTCHA_KEY; private String rememberMeParameter = FORM_REMEMBER_ME_KEY; public LoginFilter(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } // 提供自定义的验证码名称 public String getKaptchaParameter() { return this.kaptchaParameter; } public void setKaptchaParameter(final String kaptchaParameter) { this.kaptchaParameter = kaptchaParameter; } public String getRememberMeParameter() { return rememberMeParameter; } public void setRememberMeParameter(String rememberMeParameter) { this.rememberMeParameter = rememberMeParameter; } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) { // 1. 判断请求方式是否是 POST if (!request.getMethod().equals("POST")) { throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod()); } // 2. 判断 数据是否是 JSON 格式 ServletRequest re = (ServletRequest) request; if (request.getContentType().equalsIgnoreCase(MediaType.APPLICATION_JSON_VALUE)) { try { // 将请求体中的数据进行反序列化 MapString, String> userInfo = new ObjectMapper().readValue(request.getInputStream(), Map.class); // 获取 json 用户名 String username = userInfo.get(getUsernameParameter()); // 获取 json 密码 String password = userInfo.get(getPasswordParameter()); // 获取 json 验证码 String kaptcha = userInfo.get(getKaptchaParameter()); // 获取 session 中的验证码 String redisCode = redisTemplate.opsForValue().get("kaptcha"); log.info("redisCode: {}", redisCode); // 获取 json 中的记住我 String rememberMe = userInfo.get(getRememberMeParameter()); if (!ObjectUtils.isEmpty(rememberMe)) { // 将这个 remember-me 设置到作用域中 request.setAttribute(getRememberMeParameter(), rememberMe); } // 用户输入的验证码和 session 作用域中的都不能为空 if (!ObjectUtils.isEmpty(kaptcha) && !ObjectUtils.isEmpty(redisCode) && kaptcha.equalsIgnoreCase(redisCode)) { log.info("用户名: {} 密码: {},是否记住我: {}", userInfo, password, rememberMe); // 获取用户名和密码认证 UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password); setDetails(request, authRequest); return this.getAuthenticationManager().authenticate(authRequest); } } catch (IOException e) { throw new RuntimeException(e); } // 没有通过则执行自定义异常 throw new KaptchaNotMatchException("验证码不匹配!"); } // 如果不是 JSON 格式数据,则调用传统方式进行认证 return super.attemptAuthentication(request, response); } }
记住我
-
实现
package com.example.baizhisecurity.config.rememberme; import org.springframework.core.log.LogMessage; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.web.authentication.rememberme.PersistentTokenBasedRememberMeServices; import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; import org.springframework.util.ObjectUtils; import javax.servlet.http.HttpServletRequest; /** * TODO 这个类不能被 Spring 容器管理 * 自定义记住我 service 的实现,这个类必须实现它的构造方法 */ public class MyRememberServices extends PersistentTokenBasedRememberMeServices { public MyRememberServices(String key, UserDetailsService userDetailsService, PersistentTokenRepository tokenRepository) { super(key, userDetailsService, tokenRepository); } /** * 自定义前后端分离获取 remember-me 的方式 * * @param request * @param parameter * @return */ @Override protected boolean rememberMeRequested(HttpServletRequest request, String parameter) { // 获取作用域中存储的 String rememberMe = Object parameterRememberMe = request.getAttribute(parameter); if (!ObjectUtils.isEmpty(parameterRememberMe)) { String rememberMe = parameterRememberMe.toString(); if (rememberMe == null || !rememberMe.equalsIgnoreCase("true") && !rememberMe.equalsIgnoreCase("on") && !rememberMe.equalsIgnoreCase("yes") && !rememberMe.equals("1")) { this.logger.debug(LogMessage.format("Did not send remember-me cookie (principal did not set parameter '%s')", parameter)); return false; } else { return true; } } // 进行传统表单认证 return super.rememberMeRequested(request, parameter); } }
跨域配置
-
这个地方很特殊,在看到的教学过程中会在当前类下创建一个配置类,设置为数据源,但在这个项目学习的过程中出现了意外的错误
CORS error
,在这个过程中,预检
请求发送成功,但是到了最真实的请求时,就出现错误,经过不断地修改跨域配置,前期在Vue
项目中添加了devServer
配置,对于跨域同样是失效的。// http 此种配置可能未生效在前后端分离中,但是之前使用的时候是生效的,这个点暂时属于疑问,希望多多评论 http.cors().configurationSource(configurationSource()) // SpringSecurity 配置后未能生效的跨域配置 CorsConfigurationSource configurationSource() { CorsConfiguration corsConfiguration = new CorsConfiguration(); corsConfiguration.setAllowedHeaders(Arrays.asList("*")); corsConfiguration.setAllowedMethods(Arrays.asList("*")); corsConfiguration.setAllowedOrigins(Arrays.asList("*")); corsConfiguration.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", corsConfiguration); return source; }
-
真实有效的解决方案
package com.example.baizhisecurity.config; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; /** * 1. 先对 SpringBoot 配置,运行跨域请求 */ @Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { // 设置允许跨域的路径 registry.addMapping("/**") // 设置允许跨域请求的域名 .allowedOriginPatterns("*") // 是否允许 Cookie .allowCredentials(true) // 设置允许的请求方式 .allowedMethods("GET", "POST", "DELETE", "PUT") // 设置允许的 header 属性 .allowedHeaders("*") // 设置允许时间 .maxAge(3600L); } }
// 2. 最后只需要在 SpringSecurity 的 hppt 配置跨域 http.cors();
在经过上面两步后,成功解决
CORS
引起的问题并成功的获取到了数据。
验证码
-
配置验证码
@Configuration public class KaptchaConfig { @Bean public Producer kaptcha() { Properties properties = new Properties(); properties.setProperty("kaptcha.image.width", "120"); properties.setProperty("kaptcha.image.height", "40"); properties.setProperty("kaptcha.textproducer.char.string", "0123456789qwertyuiopasdfghjklzxcvbnmQWERTYUIOASDFGHJKLZXCVBNM"); properties.setProperty("kaptcha.textproducer.char.length", "4"); Config config = new Config(properties); DefaultKaptcha defaultKaptcha = new DefaultKaptcha(); defaultKaptcha.setConfig(config); return defaultKaptcha; } }
-
验证码的控制器类
@Slf4j @CrossOrigin @RestController public class CaptchaController { @Autowired private Producer producer; @Autowired private StringRedisTemplate redisTemplate; @GetMapping("/captcha") public ResultModel getVerifyCode(HttpServletRequest request, HttpServletResponse response, HttpSession session) throws IOException { // 1. 生成验证码 String text = producer.createText(); log.info("code text: {}", text); // 2. TODO 放入 session/redis redisTemplate.opsForValue().set("kaptcha", text); // 3. 生成图片 BufferedImage image = producer.createImage(text); FastByteArrayOutputStream fos = new FastByteArrayOutputStream(); ImageIO.write(image, "jpg", fos); String base64Img = Base64.encodeBase64String(fos.toByteArray()); return ResultModel.success(HttpStatus.OK.value(), "验证码获取成功!", base64Img); } }
自定义全局异常
-
验证码异常
public class KaptchaNotMatchException extends AuthenticationException { public KaptchaNotMatchException(String msg, Throwable cause) { super(msg, cause); } public KaptchaNotMatchException(String msg) { super(msg); } }
-
全局异常处理
@ControllerAdvice public class GlobalExceptionHandle { @ResponseBody @ExceptionHandler(Exception.class) public ResultModel error(Exception e) { e.printStackTrace(); return ResultModel.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "执行了全局异常处理!"); } }
Mapper 定义
-
Mapper
定义@Repository public interface UserMapper { User findUserByUserName(String username); ListRole> getRoleByUid(Integer uid); Integer updatePassword(String username, @Param("password") String newPassword); }
-
Mapper
映射实现DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> mapper namespace="com.example.baizhisecurity.mapper.UserMapper"> select id="findUserByUserName" resultType="user"> select * from user where username = #{username} select> select id="getRoleByUid" resultType="role"> select r.id, r.name, r.name_zh from role r, user_role ur where r.id = ur.uid and ur.uid = #{uid} select> update id="updatePassword"> update `user` set password = #{password} where username = #{username} update> mapper>
业务类实现
-
UserDetailsService
package com.example.baizhisecurity.service; import com.example.baizhisecurity.entity.Role; import com.example.baizhisecurity.entity.User; import com.example.baizhisecurity.mapper.UserMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsPasswordService; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import org.springframework.util.ObjectUtils; import java.util.List; @Service public class MyUserDetalService implements UserDetailsService, UserDetailsPasswordService { @Autowired private UserMapper userMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // 1. 查询用户 User user = userMapper.findUserByUserName(username); if (ObjectUtils.isEmpty(user)) { throw new UsernameNotFoundException("用户不存在"); } // 2. 查询用户的权限信息 // 查询权限信息 ListRole> roles = userMapper.getRoleByUid(user.getId()); user.setRoles(roles); return user; } /** * 自动密码升级解决方案 {推荐: 随着 SpringSecurity 版本的升级,密码的底层加密会实现自动升级} * * @param user * @param newPassword * @return */ // 实现密码更新 @Override public UserDetails updatePassword(UserDetails user, String newPassword) { Integer updatePassword = userMapper.updatePassword(user.getUsername(), newPassword); if (updatePassword == 1) { ((User) user).setPassword(newPassword); } return user; } }
前端部分
-
ElemenUI
选择了全局安装
-
登录表单
template> div class="login" v-cloak> div class="left"> video autoplay="autoplay" loop="loop" muted oncanplay="true" src="@/assets/video/passport.mp4">video> div> div class="right"> div class="box"> p> strong> 登录 strong> span>没有账户? router-link to="/register">免费注册router-link> span> p> el-form :model="ruleForm" :rules="rules" ref="ruleForm" label-width="100px" class="demo-ruleForm"> el-form-item label="" prop="username"> el-input placeholder="请输入账号" v-model="ruleForm.username" type="text"> i slot="suffix" class="el-input__icon icon-jurassic_user">i> el-input> el-form-item> el-form-item label="" prop="password"> el-input v-model="ruleForm.password" ref="pwdRef" placeholder="请输入密码" :type="inputType"> i slot="suffix" class="el-input__icon icon-mima" @click="showPasswd">i> el-input> el-form-item> el-form-item label="" prop="kaptcha" class="code"> el-input placeholder="请输入验证码" v-model="ruleForm.kaptcha" type="text" style="width: 170px;"> i slot="suffix" class="el-input__icon icon-yanzhengma">i> el-input> img :src="kaptchaCode" ref="captchaImg" alt="" title="点击刷新" @click="refreshCaptcha"> el-form-item> el-button @click="loginHandle">登录el-button> el-form> div> div> div> template> script> import { loginNetwork, refNewCode } from "@/network/user/user"; export default { data() { return { ruleForm: { username: '', // 用户名 password: '', // 密码 kaptcha: '' // 验证码 }, kaptchaCode: "", showPassword: false, // 默认不显示密码 rules: { username: [ { required: true, message: '请输入用户名', trigger: 'blur' }, { min: 3, max: 15, message: '长度在 3 到 15 个字符', trigger: 'blur', }, ], password: [ { required: true, message: '请输入密码', trigger: 'blur' }, { min: 3, max: 15, message: '长度在 3 到 15 个字符', trigger: 'blur', }, ], kaptcha: [ { required: true, message: '请输入验证码', trigger: 'blur' }, { min: 3, max: 5, message: '长度在 4 个字符', trigger: 'blur', }, ], }, } }, computed: { // 修改密码显示 inputType() { return this.showPassword ? 'text' : 'password'; }, }, created() { this.refreshCaptcha() }, methods: { // 点击刷新验证码 refreshCaptcha() { refNewCode().then(res => { if (res.code === 200) { // 解析 base64 图片资源 data:image/png;base64, this.kaptchaCode = "data:image/png;base64," + res.data this.$message.success(res.message || "刷新成功!") } else { this.$message.error(res.message || "验证码获取失败!") } }) }, // 点击显示验证码明文字符 showPasswd() { this.showPassword = !this.showPassword; }, // 点击登录事件 loginHandle() { // 表单校验 this.$refs.ruleForm.validate((valid) => { if (valid) { console.log(valid) loginNetwork(this.ruleForm).then(res => { console.log("loginNetwork: ", res) // 判断 code if (res.code === 200) { this.$message.success(res.message) // TODO 页面跳转 this.$router.push("/admin") } else { this.$message.error(res.message) } }) } }) } }, } script> style lang="less" scoped> [v-cloak] { display: none; } .code { display: flex; justify-content: space-between; align-items: center; img { height: 40px; line-height: 40px; margin-left: 10px; vertical-align: middle; } } .icon-yanjing_xianshi { position: absolute; font-size: 14px; z-index: 1; right: 10px; color: #606266; font-family: iconfont; } .el-button:hover { background: #ffa459; } .icon-mima, .icon-yanzhengma, .icon-jurassic_user { font-family: iconfont; } .box p { position: relative; left: 80px; padding: 20px; strong { font-size: 32px; font-weight: 600; line-height: 40px; color: #121315; } span { display: block; margin-top: 8px; font-size: 14px; font-weight: 400; line-height: 22px; color: #767e89; } a { color: #fb9337; cursor: pointer; transition: color 0.3s; } } .right { position: relative; width: 50%; margin-left: 140px; box-sizing: border-box; .box { position: absolute; top: 300px; } .el-form { width: 100%; .el-input { width: 300px; } } } .el-button { position: relative; left: 100px; width: 300px; color: #fff; background-color: #fb9337; } .login { display: flex; justify-content: space-between; width: 100%; height: 100%; .left video { display: inline-block; width: 100%; height: 100vh; object-fit: cover; } } style>
-
渲染效果
登录页面
-
-
发送请求认证测试
表单测试
代码下载
-
源代码下载
https://gitee.com/coderitl/split-springsecurity.git
特殊点说明
- 项目整体采用的是前后端分离开发
- 前后端分离后的特点是所有响应以
JSON
格式显示 - 在登录页面上,需要特别的注意自定义登录页面是针对传统的
WEB
开发,而前后端分离是将登陆表单以JSON
格式显示的
项目测试
-
测试获取验证码
http://localhost:8080/captcha
data
是图片数据的Base64
显示,前端是需要拼接的POSTMAN
测试 -
测试直接访问控制器数据
未登陆时访问数据 -
细节
-
这里需要注意,使用的时候需要在
header
中添加CSRF
需要的键值第一步获取 cookie
中关于CSRF
相关的键值 -
将上图中
红色框
中的值复制下来,添加到本次请求的header
中CSRF
配置 -
在添加好后,再次访问请求
成功获得认证 -
下次访问时,需要删除
header
中CSRF
的值,之后再次添加 -
疑问点
难道每一次都需要访问一次失败的再添加cookie后才能访问成功吗?
在前端使用的时候,是通过添加相关的配置获取的是
cookie
的内容,所以访问时就已经实现了添加,所以不会出现访问失败一次的现象。 -
Vue
中CSRF
的配置-
下载插件
# 下载 cookie 使用的插件 npm install vue-cookie --save
-
使用
// config.js import axios from "axios"; import VueCookie from "vue-cookie"; axios.defaults.xsrfHeaderName = "X-CSRF-TOKEN"; axios.defaults.xsrfCookieName = "CSRF-TOKEN"; axios.defaults.withCredentials = "true"; export function request(config) { // 1.创建axios的实例 const instance = axios.create({ baseURL: "http://localhost:8080", timeout: 5000, }); // 2.axios的拦截器 // 2.1.请求拦截的作用 instance.interceptors.request.use( (config) => { // 在发送请求之前做些什么 // 获取 CSRF Token const csrfToken = VueCookie.get("XSRF-TOKEN"); console.log("csrfToken: " + csrfToken); if (csrfToken) { // 在请求头中添加 CSRF Token config.headers["X-XSRF-TOKEN"] = csrfToken; } return config; }, (err) => { // 对请求错误做些什么 return Promise.reject(err); } ); // 2.2.响应拦截 instance.interceptors.response.use( (res) => { return res.data; }, (err) => { console.log(err); } ); // 3.发送真正的网络请求 return instance(config); }
-
-
-
服务器托管,北京服务器托管,服务器租用 http://www.fwqtg.net