# security-launch **Repository Path**: yanglf_admin/security-launch ## Basic Information - **Project Name**: security-launch - **Description**: No description available - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2021-10-02 - **Last Updated**: 2022-02-11 ## Categories & Tags **Categories**: Uncategorized **Tags**: SpringBoot ## README # 核心功能 - Authentication: 身份认证,用户登录的验证 - Authorization: 访问授权,授权资源的访问权限 - 安全防护,防止跨站请求,session 攻击等 # 环境准备 https://gitee.com/hanxt/boot-security-starter ## 添加依赖 ```xml org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-security com.baomidou mybatis-plus-boot-starter 3.3.2 mysql mysql-connector-java org.springframework.boot spring-boot-starter-freemarker org.projectlombok lombok org.springframework.boot spring-boot-devtools org.springframework.boot spring-boot-starter-test test org.junit.vintage junit-vintage-engine ``` ## 修改配置文件 ```yaml spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 datasource: url: jdbc:mysql://localhost:3306/bootdb?useSSL=false&useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: admin123 driver-class-name: com.mysql.cj.jdbc.Driver thymeleaf: cache: false suffix: .ftlh prefix: classpath:/templats/ security: user: name: admin password: admin123 mybatis-plus: mapper-locations: classpath:mapper/*Mapper.xml logging: level: com.yanglf.demo6: debug ``` # Http Basic ## 流程 1. 客户端请求服务器资源 2. Basic Authentication Filter 过滤器 拦截 需要输入用户名、密码 3. 用户输入用户名,密码(base64加密) 再请求服务器 4. 登录成功后,客户端每次 header 都要携带 Authorization (Basic ),这个字符串也就是 对 `userName:password` 的 base64加密,不安全 ## 集成步骤 > 新增 security 配置文件 ```java @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { // 开启 http basic http.httpBasic() .and() // 所有请求需要登录才可以访问 .authorizeRequests() .anyRequest() .authenticated(); } } ``` # 密码加密校验 ## Hash算法 - 单项算法 - hash值(密码)不可逆 ## PasswordEncoder - `encoder(rawPassword)` 把参数按照特定的解析规则进行解析。 - `matches(rawPassword,encodedPassword)` 验证从存储中获取的编码密码与编码后提交的原始密码是否匹配。如果密码匹配,则返回 true;如果不匹配,则返回 false。第一个参数表示需要被解析的密码。第二个参数表示存储的密码 - `upgradeEncoding(encodedPassword)` 如果解析的密码能够再次进行解析且达到更安全的结果则返回 true,否则返回 false。默认返回 false。 > 密码组成部分 $2a$10$iYDIDVSGnr06FjmbNRoyUuvbxlS2eL9hF9eOCGUoRmUXzUfS9NyLO - `$2a` 表示 Bcrypt 算法版本 - `$10` 表示算法强度 - `$iYDIDVSGnr06FjmbNRoyUu` 随机生成的盐值 - `vbxlS2eL9hF9eOCGUoRmUXzUfS9NyLO` hash值 # FormLogin 认证模式 - 不需要写 controller - 默认集成 `UsernamePasswordAuthencationFilter`,只需要针对它进行配置 > 修改配置文件 ```java @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { // 权限配置 从上往下配置 anyRequest().authenticated() 只能配置到最后 @Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() // formLogin .formLogin() // 登录页面配置 .loginPage("/login.html") // 处理登录业务逻辑的接口url .loginProcessingUrl("/login") // 登录 用户名 请求参数名 .usernameParameter("userName") // 登录 密码 请求参数名 .passwordParameter("password") // 登录成功跳转的url地址 .defaultSuccessUrl("/") .and() .authorizeRequests() // 不需要登录就可以访问的资源 .antMatchers("/login.html", "/login").permitAll() // user 和 admin 角色可以访问的资源 .antMatchers("/", "/biz1", "biz2") // hasAnyAuthority("ROLE_user") 等价与 hasAnyRole("user") .hasAnyAuthority("ROLE_user", "ROLE_admin") // admin 可以访问的资源 .antMatchers("/syslog", "/sysuser") .hasAnyRole("admin") // 其他请求都需要认证 .anyRequest() .authenticated(); } // 配置用户方式一 @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.inMemoryAuthentication() .withUser("user") .password(passwordEncoder().encode("123456")) .roles("user") .and() .withUser("admin") .password(passwordEncoder().encode("admin123")) .roles("admin") .and() .passwordEncoder(passwordEncoder()); } // 配置用户方式二 /* @Override @Bean protected UserDetailsService userDetailsService() { InMemoryUserDetailsManager manager=new InMemoryUserDetailsManager(); manager.createUser(User.withUsername("javaboy") .password(passwordEncoder().encode("admin123")) .build()); manager.createUser(User.withUsername("yanglf") .password(passwordEncoder().encode("123456")) .build()); return manager; }*/ // 角色继承, admin 继承 user 的权限 ,user 可以访问的权限,admin 都可以访问 // 一般 用作 上级 继承 下级的 权限 @Bean RoleHierarchy roleHierarchy(){ RoleHierarchyImpl hierarchy=new RoleHierarchyImpl(); hierarchy.setHierarchy("ROLE_admin > ROLE_user"); return hierarchy; } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Override public void configure(WebSecurity web) throws Exception { // 将静态资源放出来 HttpSecurity 配置的规则,都要走过滤器 这里配置不需要 web.ignoring().antMatchers("/css/**","/fonts","/img/**","/js/**"); } } ``` > classpath: static 下边创建 login.html ```html 系统登录
用户名
密码
``` # 登录认证流程 **登录认证主体: Authentication** 1. SecurityContent 2. PersistenceFilter 3. BasicAuthenticationFilter 4. UsernamePasswordAuthenticationFilter 5. RememberMeAuthenticationFilter 6. SocialAuthenticationFilter 7. Oauth2AuthenticationProcessingFilter 8. Oauth2ClientAuthenticationProcessingFilter 9. ExceptionTranslationFilter 10. FilterSecurityInterceptor 11. Controller API # 自定义登录 ## 自定义登录验证处理结果 ### 场景 - 不同的人登录后,看到不同的首页 - 前后端分离,期望返回JSON,而不是 HTML 页面 ### 登录成功处理逻辑 ```java @Component public class MyAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { @Value("${spring.security.loginType}") private String loginType; @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException { if (loginType.equalsIgnoreCase("json")) { response.setContentType("application/json;charset=UTF-8"); AjaxResponse ajaxResponse = AjaxResponse.success("登录成功"); response.getWriter().write(new ObjectMapper().writeValueAsString(ajaxResponse)); } else { // 会自动跳转到登录之前请求的页面 super.onAuthenticationSuccess(request, response, authentication); } } } ``` ### 登录失败逻辑 ```java @Component public class MyAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { @Value("${spring.security.loginType}") private String loginType; @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { if (loginType.equalsIgnoreCase("JSON")) { response.setContentType("application/json;charset=UTF-8"); AjaxResponse ajaxResponse = AjaxResponse.error(CustomExceptionType.USER_INPUT_ERROR, "用户名或密码错误,请检查后重新登录"); response.getWriter().write(new ObjectMapper().writeValueAsString(ajaxResponse)); } else { response.setContentType("application/html;charset=UTF-8"); super.onAuthenticationFailure(request, response, exception); } } } ``` ### 权限缺失 ```java @Component public class MyAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException { response.setContentType("application/json;charset=UTF-8"); AjaxResponse ajaxResponse = AjaxResponse.error(CustomExceptionType.USER_INPUT_ERROR,"访问权限缺失"); response.getWriter().write(new ObjectMapper().writeValueAsString(ajaxResponse)); } } ``` ### 未登录 ```java @Component public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint, Serializable { private static final long serialVersionUID = -8970718410437077606L; @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException { int code = HttpStatus.UNAUTHORIZED; String msg = StringUtils.format("请求访问:{},尚未登录,请登录", request.getRequestURI()); ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(code, msg))); } } ``` ### 修改配置文件 ```java // 登录成功 @Autowired private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler; // 登录失败 @Autowired private MyAuthenticationFailureHandler myAuthenticationFailureHandler; //认证失败处理类 @Autowired private MyAuthenticationEntryPoint unauthorizedHandler; // 403 无权限访问处理 @Autowired private MyAccessDeniedHandler myAccessDeniedHandler; @Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() .exceptionHandling() .accessDeniedHandler(myAccessDeniedHandler) .authenticationEntryPoint(unauthorizedHandler) // formLogin .formLogin() // 登录页面配置 .loginPage("/login.html") // 处理登录业务逻辑的接口url .loginProcessingUrl("/login") // 登录 用户名 请求参数名 .usernameParameter("username") // 登录 密码 请求参数名 .passwordParameter("password") .successHandler(myAuthenticationSuccessHandler) .failureHandler(myAuthenticationFailureHandler) // 登录成功跳转的url地址 //.defaultSuccessUrl("/") // 登录失败跳转的url地址 //.failureUrl("/login.html") .and() .authorizeRequests() // 不需要登录就可以访问的资源 .antMatchers("/login.html", "/login").permitAll() // user 和 admin 角色可以访问的资源 .antMatchers("/", "/biz1", "biz2") // hasAnyAuthority("role_user") 等价与 hasAnyRole("user") .hasAnyAuthority("ROLE_user", "ROLE_admin") // admin 可以访问的资源 .antMatchers("/syslog", "/sysuser") .hasAnyRole("admin") .anyRequest() .authenticated(); } ``` ### 修改前端页面 ```html 系统登录
用户名
密码
``` ## 图形验证码登录 ### 验证码生成 #### 新增依赖 ```xml com.github.penggle kaptcha 2.3.2 javax.servlet-api javax.servlet org.apache.commons commons-lang3 3.9 ``` #### 配置文件 需要修改 setting - file encoding 的编码 为 UTF-8 ```properties kaptcha.border=no kaptcha.border.color=105,179,90 kaptcha.image.width=100 kaptcha.image.height=45 kaptcha.session.key=code kaptcha.textproducer.font.color=blue kaptcha.textproducer.fint.size=35 kaptcha.textproducer.char.length=4 kaptcha.textproducer.font.names=宋体,楷体,微软雅黑 ``` #### 配置类 ```java @Configuration @PropertySource("classpath:kaptcha.properties") public class KaptchaConfig { @Value("${kaptcha.border}") private String border; @Value("${kaptcha.border.color}") private String borderColor; @Value("${kaptcha.textproducer.font.color}") private String fontColor; @Value("${kaptcha.textproducer.fint.size}") private String fontSize; @Value("${kaptcha.image.width}") private String imageWidth; @Value("${kaptcha.image.height}") private String imageHeight; @Value("${kaptcha.session.key}") private String sessionKey; @Value("${kaptcha.textproducer.char.length}") private String charLength; @Value("${kaptcha.textproducer.font.names}") private String fontNames; @Bean(name = "capachaProducer") public DefaultKaptcha getKaptcha() { DefaultKaptcha defaultKaptcha = new DefaultKaptcha(); Properties properties = new Properties(); properties.setProperty("kaptcha.border", border); properties.setProperty("kaptcha.border.color", borderColor); properties.setProperty("kaptcha.textproducer.font.color", fontColor); properties.setProperty("kaptcha.textproducer.fint.size", fontSize); properties.setProperty("kaptcha.image.width", imageWidth); properties.setProperty("kaptcha.image.height", imageHeight); properties.setProperty("kaptcha.session.key", sessionKey); properties.setProperty("kaptcha.textproducer.char.length", charLength); properties.setProperty("kaptcha.textproducer.font.names", fontNames); defaultKaptcha.setConfig(new Config(properties)); return defaultKaptcha; } } ``` #### 验证码对象 ```java @Data public class CaptchaCode { private String code; private LocalDateTime expireTime; public CaptchaCode(String code, int expireAfterSecond) { this.code = code; this.expireTime = LocalDateTime.now().plusSeconds(expireAfterSecond); } public boolean isExpired() { return LocalDateTime.now().isAfter(expireTime); } } ``` #### 生成验证码接口 ```java @RestController public class CaptchaContoller { @Autowired private DefaultKaptcha captchaProducer; @GetMapping("/kaptcha") public void kaptcha(HttpSession session, HttpServletResponse response) throws IOException { response.setHeader("Expires", "0"); response.setHeader("Cache-Control", "no-store,no-cache,must-revalidate"); response.setHeader("Cache-Control", "post-check=0,pre-check=0"); response.setHeader("Pragma", "no-cache"); response.setContentType("image/jpeg"); String capText = captchaProducer.createText(); session.setAttribute("captcha_key", new CaptchaCode(capText, 2 * 60)); try (ServletOutputStream out = response.getOutputStream()) { BufferedImage bufferedImage = captchaProducer.createImage(capText); ImageIO.write(bufferedImage, "jpg", out); out.flush(); } } } ``` #### 配置类放行获取验证码接口 ```java //SecurityConfig ... .antMatchers("/login.html", "/login", "/kaptcha").permitAll() ``` #### 修改前端接口 ```html
用户名
密码
验证码
``` ### 图形验证码过滤器 - 编写自定义图形验证码过滤器 `CaptchaCodeFilter` ,过滤器中拦截登录请求 - 过滤器中从session获取验证码文字和用户输入的对比,对比通过执行其他过滤链 - 对比不通过,抛出 `SessionAuthenticationException` 异常,交给 AuthenticationFailureHandler 处理 - 最后将 CaptchaCodeFilter 放在 UsernamePasswordAuthenticationFilter 过滤器前执行 > 校验验证码过滤器 ```java @Component public class CaptchaCodeFilter extends OncePerRequestFilter { @Autowired private MyAuthenticationFailureHandler myAuthenticationFailureHandler; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String requestURI = request.getRequestURI(); String method = request.getMethod(); if (StringUtils.equals(requestURI,"/login") && StringUtils.equalsIgnoreCase(method,"post")) { // 验证码是否一致 try { validate(new ServletWebRequest(request)); }catch (AuthenticationException e){ myAuthenticationFailureHandler.onAuthenticationFailure(request,response,e); return; } } filterChain.doFilter(request, response); } private void validate(ServletWebRequest request) throws ServletRequestBindingException { HttpSession session = request.getRequest().getSession(); String codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(), "captchaCode"); if (StringUtils.isEmpty(codeInRequest)) { throw new SessionAuthenticationException("验证码不能为空"); } CaptchaCode codeInSession = (CaptchaCode) session.getAttribute("captcha_key"); if (Objects.isNull(codeInSession)) { throw new SessionAuthenticationException("验证码不存在"); } if(codeInSession.isExpired()){ session.removeAttribute("captcha_key"); throw new SessionAuthenticationException("验证码已过期"); } if(!StringUtils.equals(codeInSession.getCode(),codeInRequest)){ throw new SessionAuthenticationException("验证码不匹配"); } } } ``` > 修改校验失败逻辑 ```java @Component public class MyAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { @Value("${spring.security.loginType}") private String loginType; @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { String errorMsg = "用户名或密码错误,请检查后重新登录"; if (exception instanceof SessionAuthenticationException) { errorMsg = exception.getMessage(); } if (loginType.equalsIgnoreCase("JSON")) { response.setContentType("application/json;charset=UTF-8"); AjaxResponse ajaxResponse = AjaxResponse.error(CustomExceptionType.USER_INPUT_ERROR, errorMsg); response.getWriter().write(new ObjectMapper().writeValueAsString(ajaxResponse)); } else { response.setContentType("application/html;charset=UTF-8"); super.onAuthenticationFailure(request, response, exception); } } } ``` > 将校验过滤器配置到 `UsernamePasswordAuthenticationFilter` 之前 ```java @Autowired private CaptchaCodeFilter captchaCodeFilter; @Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() .addFilterBefore(captchaCodeFilter, UsernamePasswordAuthenticationFilter.class) // formLogin .formLogin() // 登录页面配置 .loginPage("/login.html") // 处理登录业务逻辑的接口url .loginProcessingUrl("/login") // 登录 用户名 请求参数名 .usernameParameter("username") // 登录 密码 请求参数名 .passwordParameter("password") .successHandler(myAuthenticationSuccessHandler) .failureHandler(myAuthenticationFailureHandler) // 登录成功跳转的url地址 //.defaultSuccessUrl("/") // 登录失败跳转的url地址 //.failureUrl("/login.html") .and() .authorizeRequests() // 不需要登录就可以访问的资源 .antMatchers("/login.html", "/login", "/kaptcha").permitAll() // user 和 admin 角色可以访问的资源 //.antMatchers("/", "/biz1", "biz2") /* .antMatchers("/").hasAnyAuthority("/") .antMatchers("/biz1").hasAnyAuthority("/biz1") .antMatchers("/biz2").hasAnyAuthority("/biz2") // hasAnyAuthority("role_user") 等价与 hasAnyRole("user") // admin 可以访问的资源 .antMatchers("/sysuser").hasAnyAuthority("/sysuser") .antMatchers("/syslog").hasAnyAuthority("/syslog")*/ .anyRequest().access("@rbaService.hasPermission(request,authentication)") .and().rememberMe() .rememberMeParameter("remember-me-new") .rememberMeCookieName("remember-me-cookie") .tokenValiditySeconds(2 * 24 * 60 * 60) .tokenRepository(persistentTokenRepository()) // .antMatchers("/syslog", "/sysuser") //.anyRequest() //.authenticated() .and() .logout() .logoutUrl("/logout") //.logoutSuccessUrl("/login.html") .logoutSuccessHandler(myLogoutSuccessHandler) .deleteCookies("JESSION") .and().sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) .sessionFixation().migrateSession() .maximumSessions(1) // true 表示已经登录,就不允许再登陆 // false 表示允许再次登录,但是之前登录的账号会被踢下线 .maxSessionsPreventsLogin(false) .expiredSessionStrategy(myExpiredSessionStrategy); } ``` ## 短信验证码登录 - `SmsController` 获取短信验证码 - `SmsCodeValidateFilter` 校验 session 中验证码是否和用户输入验证码一致 - `SmsCodeAuthenticationFilter` 校验验证码成功,进行登录授权,调用 `UserDetailsService` ### 获取短信验证码 > 获取验证码接口 ```java @Slf4j @RestController public class SmsController { @Autowired private MyUserDetailsServiceMapper myUserDetailsServiceMapper; @GetMapping("/smscode") public AjaxResponse sms(@RequestParam String mobile, HttpSession httpSession) { MyUserDetails myUserDetails = myUserDetailsServiceMapper.findByUserName(mobile); if (Objects.isNull(myUserDetails)) { return AjaxResponse.error( CustomExceptionType.USER_INPUT_ERROR, "您输入的手机号未曾注册"); } SmsCode smsCode = new SmsCode( RandomStringUtils.randomNumeric(4), 60, mobile); // TODO 调用短信接口发送短信 log.info(smsCode.getCode() + "+>" + mobile); httpSession.setAttribute("sms_key", smsCode); return AjaxResponse.success("短信验证码发送成功:【" + smsCode.getCode() + "】"); } } ``` > 验证码对象 ```java @Data public class SmsCode { private String code; private String mobile; private LocalDateTime expireTime; public SmsCode(String code, int expireAfterSecond, String mobile) { this.code = code; this.mobile = mobile; this.expireTime = LocalDateTime.now().plusSeconds(expireAfterSecond); } public boolean isExpired() { return LocalDateTime.now().isAfter(expireTime); } } ``` > 放行获取验证码接口 ```java //SecurityConfig ... .antMatchers("/login.html", "/login", "/kaptcha","/smscode").permitAll() ``` > 修改前端页面 ```html

短信登录

手机号
短信验证码
``` ### 验证短信验证码 #### 验证逻辑 ```java @Component public class SmsCodeValidateFilter extends OncePerRequestFilter { @Autowired private MyUserDetailsServiceMapper myUserDetailsServiceMapper; @Autowired private MyAuthenticationFailureHandler myAuthenticationFailureHandler; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String requestURI = request.getRequestURI(); String method = request.getMethod(); if (requestURI.equals("/smslogin") && method.equalsIgnoreCase("post")) { // 验证码是否一致 try { validate(new ServletWebRequest(request)); } catch (AuthenticationException e) { myAuthenticationFailureHandler.onAuthenticationFailure(request, response, e); return; } } filterChain.doFilter(request, response); } private void validate(ServletWebRequest request) throws ServletRequestBindingException { HttpSession session = request.getRequest().getSession(); String mobileInRequest = ServletRequestUtils.getStringParameter(request.getRequest(), "mobile"); String codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(), "smsCode"); SmsCode codeInSession = (SmsCode) session.getAttribute("sms_key"); if (StringUtils.isEmpty(mobileInRequest)) { throw new SessionAuthenticationException("手机号不能为空"); } if (StringUtils.isEmpty(codeInRequest)) { throw new SessionAuthenticationException("短信验证码不能为空"); } if (Objects.isNull(codeInSession)) { throw new SessionAuthenticationException("短信验证码不存在"); } if (codeInSession.isExpired()) { session.removeAttribute("sms_key"); throw new SessionAuthenticationException("短信验证码已过期"); } if (!codeInSession.getCode().equals(codeInRequest)) { throw new SessionAuthenticationException("验证码不匹配"); } if (!codeInSession.getMobile().equals(mobileInRequest)) { throw new SessionAuthenticationException("短信发送目标与您的手机号不一致"); } MyUserDetails myUserDetails = myUserDetailsServiceMapper.findByUserName(mobileInRequest); if(Objects.isNull(myUserDetails)){ throw new SessionAuthenticationException("手机号未曾注册"); } } } ``` #### Security配置类放行登录url ```java .antMatchers("/login.html", "/login", "/kaptcha","/smscode","/smslogin").permitAll() ``` #### 修改前端页面 ```html

短信登录

手机号
短信验证码
``` ### 登录认证过滤器 **用户密码登录** - UsernamePasswordAuthenticationFilter - AuthenticationManager - DaoAuthenticationProvider - UserDetailsService **短信验证码登录** - SmsCodeAuthenticationFilter - AuthenticationManager - SmsCodeAuthenticationProvider - UserDetailsService >`SmsCodeAuthenticationFilter` ```java public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter { public static final String SPRING_SECURITY_FORM_MOBILE_KEY = "mobile"; private String mobileParameter = SPRING_SECURITY_FORM_MOBILE_KEY; private boolean postOnly = true; public SmsCodeAuthenticationFilter() { super(new AntPathRequestMatcher("/smslogin", "POST")); } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { if (this.postOnly && !request.getMethod().equals("POST")) { throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod()); } else { String mobile = this.obtainMobile(request); if (mobile == null) { mobile = ""; } mobile = mobile.trim(); SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(mobile); this.setDetails(request, authRequest); return this.getAuthenticationManager().authenticate(authRequest); } } @Nullable protected String obtainMobile(HttpServletRequest request) { return request.getParameter(this.mobileParameter); } protected void setDetails(HttpServletRequest request, SmsCodeAuthenticationToken authRequest) { authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request)); } public void setMobileParameter(String mobileParameter) { Assert.hasText(mobileParameter, "Mobile parameter must not be empty or null"); this.mobileParameter = mobileParameter; } public void setPostOnly(boolean postOnly) { this.postOnly = postOnly; } public final String getMobileParameter() { return this.mobileParameter; }} ``` >`SmsCodeAuthenticationToken` ```java public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken { private static final long serialVersionUID = 530L; // 存放认证信息,认证之前放的手机号,认证之后存放 UserDetails private final Object principal; public SmsCodeAuthenticationToken(Object principal) { super((Collection) null); this.principal = principal; this.setAuthenticated(false); } public SmsCodeAuthenticationToken(Object principal, Collection authorities) { super(authorities); this.principal = principal; super.setAuthenticated(true); } @Override public Object getCredentials() { return null; } @Override public Object getPrincipal() { return this.principal; } @Override public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException { if (isAuthenticated) { throw new IllegalArgumentException("Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead"); } else { super.setAuthenticated(false); } } @Override public void eraseCredentials() { super.eraseCredentials(); }} ``` > `SmsCodeAuthenticationProvider` ```java public class SmsCodeAuthenticationProvider implements AuthenticationProvider { private UserDetailsService userDetailsService; public void setUserDetailsService(UserDetailsService userDetailsService) { this.userDetailsService = userDetailsService; } protected UserDetailsService getUserDetailsService() { return this.userDetailsService; } @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { SmsCodeAuthenticationToken authenticationToken = (SmsCodeAuthenticationToken) authentication; UserDetails userDetails = userDetailsService.loadUserByUsername((String) authenticationToken.getPrincipal()); if (userDetails == null) { throw new InternalAuthenticationServiceException("无法根据手机号获取用户信息"); } SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(userDetails, userDetails.getAuthorities()); authenticationResult.setDetails(authenticationToken.getDetails()); return authenticationResult; } @Override public boolean supports(Class authentication) { return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication); }} ``` ### 配置过滤器 > `SmsCodeSecurityConfig` ```java @Componentpublic class SmsCodeSecurityConfig extends SecurityConfigurerAdapter { @Autowired private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler; @Autowired private MyAuthenticationFailureHandler myAuthenticationFailureHandler; @Autowired private MyExpiredSessionStrategy myExpiredSessionStrategy; @Autowired private MyUserDetailsService myUserDetailsService; @Autowired private SmsCodeValidateFilter smsCodeValidateFilter; @Override public void configure(HttpSecurity http) throws Exception { SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter(); smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class)); smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(myAuthenticationSuccessHandler); smsCodeAuthenticationFilter.setAuthenticationFailureHandler(myAuthenticationFailureHandler); SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider(); smsCodeAuthenticationProvider.setUserDetailsService(myUserDetailsService); http.addFilterBefore(smsCodeValidateFilter, UsernamePasswordAuthenticationFilter.class); http.authenticationProvider(smsCodeAuthenticationProvider) .addFilterAfter(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); }} ``` > 子配置类添加到父配置类 ```java @Configuration@EnableGlobalMethodSecurity(prePostEnabled = true)public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler; @Autowired private MyAuthenticationFailureHandler myAuthenticationFailureHandler; @Autowired private MyExpiredSessionStrategy myExpiredSessionStrategy; @Autowired private MyUserDetailsService myUserDetailsService; @Autowired private MyLogoutSuccessHandler myLogoutSuccessHandler; @Autowired private CaptchaCodeFilter captchaCodeFilter; @Autowired private SmsCodeSecurityConfig smsCodeSecurityConfig; @Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() .addFilterBefore(captchaCodeFilter, UsernamePasswordAuthenticationFilter.class) // formLogin .formLogin() // 登录页面配置 .loginPage("/login.html") // 处理登录业务逻辑的接口url .loginProcessingUrl("/login") // 登录 用户名 请求参数名 .usernameParameter("username") // 登录 密码 请求参数名 .passwordParameter("password") .successHandler(myAuthenticationSuccessHandler) .failureHandler(myAuthenticationFailureHandler) .and() .apply(smsCodeSecurityConfig) // 登录成功跳转的url地址 //.defaultSuccessUrl("/") // 登录失败跳转的url地址 //.failureUrl("/login.html") .and() .authorizeRequests() // 不需要登录就可以访问的资源 .antMatchers("/login.html", "/login", "/kaptcha", "/smscode", "/smslogin").permitAll() // user 和 admin 角色可以访问的资源 //.antMatchers("/", "/biz1", "biz2") /* .antMatchers("/").hasAnyAuthority("/") .antMatchers("/biz1").hasAnyAuthority("/biz1") .antMatchers("/biz2").hasAnyAuthority("/biz2") // hasAnyAuthority("role_user") 等价与 hasAnyRole("user") // admin 可以访问的资源 .antMatchers("/sysuser").hasAnyAuthority("/sysuser") .antMatchers("/syslog").hasAnyAuthority("/syslog")*/ .anyRequest().access("@rbaService.hasPermission(request,authentication)") .and().rememberMe() .rememberMeParameter("remember-me-new") .rememberMeCookieName("remember-me-cookie") .tokenValiditySeconds(2 * 24 * 60 * 60) .tokenRepository(persistentTokenRepository()) // .antMatchers("/syslog", "/sysuser") //.anyRequest() //.authenticated() .and() .logout() .logoutUrl("/logout") //.logoutSuccessUrl("/login.html") .logoutSuccessHandler(myLogoutSuccessHandler) .deleteCookies("JESSION") .and().sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) .sessionFixation().migrateSession() .maximumSessions(1) // true 表示已经登录,就不允许再登陆 // false 表示允许再次登录,但是之前登录的账号会被踢下线 .maxSessionsPreventsLogin(false) .expiredSessionStrategy(myExpiredSessionStrategy); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(myUserDetailsService) // 验证密码时候匹配 BCryptPasswordEncoder 加密 .passwordEncoder(passwordEncoder()); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Override public void configure(WebSecurity web) throws Exception { // 将静态资源放出来 HttpSecurity 配置的规则,都要走过滤器 这里配置不需要 web.ignoring().antMatchers("/css/**", "/fonts", "/img/**", "/js/**"); } @Autowired private DataSource dataSource; @Bean public PersistentTokenRepository persistentTokenRepository() { JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl(); tokenRepository.setDataSource(dataSource); return tokenRepository; }} ``` ### 修改查询条件 > UserDetailsService ```java @Componentpublic class MyUserDetailsService implements UserDetailsService { @Autowired private MyUserDetailsServiceMapper myUserDetailsServiceMapper; @Override public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException { // 用户基础数据加载 MyUserDetails myUserDetails = myUserDetailsServiceMapper.findByUserName(userName); if (Objects.isNull(myUserDetails)) { throw new UsernameNotFoundException("用户名不存在"); } // 用户角色加载 List roleCodes = myUserDetailsServiceMapper.findRoleByUserName(userName); // 根据角色列表加载当前用户所有权限 List authorities = myUserDetailsServiceMapper.findAuthorityByRoleCodes(roleCodes); roleCodes = roleCodes.stream() .map(rc -> "ROLE_" + rc) .collect(Collectors.toList()); authorities.addAll(roleCodes); myUserDetails.setAuthorities(AuthorityUtils.commaSeparatedStringToAuthorityList( String.join(",", authorities) )); return myUserDetails; }} ``` >MyUserDetailsServiceMapper ```java public interface MyUserDetailsServiceMapper extends BaseMapper { // 根据用户id查询用户信息 @Select("SELECT username,password,enabled \n" + "FROM sys_user u \n" + "WHERE u.username = #{userId} OR u.phone = #{userId}") MyUserDetails findByUserName(@Param(value = "userId") String userId); // 根据用户id查询用户角色 @Select("SELECT role_code \n" + "FROM sys_role r \n" + "LEFT JOIN sys_user_role ur ON ur.role_id = r.id \n" + "LEFT JOIN sys_user u ON u.id = ur.user_id \n" + "WHERE u.username = #{userId} OR u.phone = #{userId}") List findRoleByUserName(@Param(value = "userId") String userId); // 根据用户角色查询用户权限 @Select({ "" }) List findAuthorityByRoleCodes(@Param(value = "roleCodes") List roleCodes); @Select("SELECT url \n" + "FROM sys_menu m \n" + "LEFT JOIN sys_role_menu rm ON m.id = rm.menu_id \n" + "LEFT JOIN sys_role r ON r.id = rm.role_id \n" + "LEFT JOIN sys_user_role ur ON ur.role_id = r.id \n" + "LEFT JOIN sys_user u ON u.id=ur.user_id \n" + "WHERE u.user_name = #{userId} OR u.phone = #{userId}") List findUrlsByUserName(@Param(value = "userId") String userId); } ``` # Session 会话管理 ## Spring Security session 创建策略 - `always` 如果当前请求没有对应的session存在,创建一个session - `ifRequired` 默认,在需要使用到session时创建session - `never` Spring Security 将永远不会主动创建session,但如果session在当前应用中已经存在,它将使用该session - `stateless` Spring Security 不会创建或使用任何session,适合于接口型的无状态应用(前后端分离),该方式节省内存资源 ```java @Override protected void configure(HttpSecurity http) throws Exception { http.sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED); } ``` ## 会话超时管理 - `server.servlet.session.timeout=15m` - `spring.session.timeout=15m` ```java @Override protected void configure(HttpSecurity http) throws Exception { http.sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) // 非法 session .invalidSessionUrl("/sessionExpired.html"); } ``` ## session 保护 - `migrationSession` 默认保护方式,即对于同一个session用户,每次登录验证将创建一个新的HTTP session,旧的HTTP session将无效,将旧的session属性值复制到新session上面 - `none` 原始会话不会失效 - `newSession` 将创建一个干净的会话,而不会复制旧会话中的任何实行 ```java @Override protected void configure(HttpSecurity http) throws Exception { http.sessionManagement() .sessionFixation() .migrateSession(); } ``` ## cookie 的安全 - `httpOnly` 如果为true,则浏览器脚本将无法访问cookie - `secure` 如果为true,则仅通过 HTTPS 连接发送 cookie,HTTP 无法携带 cookie ```properties server.servlet.session.cookie.http-only=falseserver.servlet.session.cookie.secrue=true ``` ## 同账号多端登录踢下线 ### 限制最大登录用户数量 > 配置 最大session 数量 ```java @Autowired private CustomExpiredSessionStrategy customExpiredSessionStrategy; @Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() // formLogin .formLogin() // 登录页面配置 .loginPage("/login.html") // 处理登录业务逻辑的接口url .loginProcessingUrl("/login") // 登录 用户名 请求参数名 .usernameParameter("username") // 登录 密码 请求参数名 .passwordParameter("password") .successHandler(myAuthenticationSuccessHandler) .failureHandler(myAuthenticationFailureHandler) // 登录成功跳转的url地址 //.defaultSuccessUrl("/") // 登录失败跳转的url地址 //.failureUrl("/login.html") .and() .authorizeRequests() // 不需要登录就可以访问的资源 .antMatchers("/login.html", "/login").permitAll() // user 和 admin 角色可以访问的资源 .antMatchers("/", "/biz1", "biz2") // hasAnyAuthority("role_user") 等价与 hasAnyRole("user") .hasAnyAuthority("ROLE_user", "ROLE_admin") // admin 可以访问的资源 .antMatchers("/syslog", "/sysuser") .hasAnyRole("admin") .anyRequest() .authenticated() .and().sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) .sessionFixation().migrateSession() .maximumSessions(1) // true 表示已经登录,就不允许再登陆 // false 表示允许再次登录,但是之前登录的账号会被踢下线 .maxSessionsPreventsLogin(false) .expiredSessionStrategy(customExpiredSessionStrategy); } ``` > 踢下线逻辑 ```java @Component public class CustomExpiredSessionStrategy implements SessionInformationExpiredStrategy { @Value("${spring.security.loginType}") private String loginType; // 页面跳转处理逻辑 private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); @Override public void onExpiredSessionDetected(SessionInformationExpiredEvent sessionInformationExpiredEvent) throws IOException, ServletException { HttpServletRequest request = sessionInformationExpiredEvent.getRequest(); HttpServletResponse response = sessionInformationExpiredEvent.getResponse(); if (loginType.equalsIgnoreCase("JSON")) { Date lastRequest = sessionInformationExpiredEvent.getSessionInformation().getLastRequest(); String message = "您的账号登录已经超时或者已经在另一台机器登录,您被迫下线。" + lastRequest; AjaxResponse ajaxResponse = AjaxResponse.error(CustomExceptionType.USER_INPUT_ERROR, message); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(new ObjectMapper().writeValueAsString(ajaxResponse)); } else { redirectStrategy.sendRedirect(request, response, "/login.html"); } } } ``` # 集成数据库 ## 默认JdbcUserDetailsManager ### 创建表 security 定义 `user.ddl` ```sql create table users(username varchar(50) not null primary key,password varchar(500) not null,enabled boolean not null); create table authorities (username varchar(50) not null,authority varchar(50) not null,constraint fk_authorities_users foreign key(username) references users(username)); create unique index ix_auth_username on authorities (username,authority); ``` ### 添加有依赖 ```xml mysql mysql-connector-java org.springframework.boot spring-boot-starter-jdbc ``` ### 配置数据源 ```yaml spring: datasource: url: jdbc:mysql://localhost:3306/bootdb?useSSL=false&useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: admin123 driver-class-name: com.mysql.cj.jdbc.Driver ``` ### 配置用户 ```java @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService()); } @Override protected UserDetailsService userDetailsService() { JdbcUserDetailsManager manager = new JdbcUserDetailsManager(); if(!manager.userExists("javajob")){ manager.createUser(User.withUsername("javaboy") .password(passwordEncoder().encode("admin123")) .build()); } if(!manager.userExists("yanglf")){ manager.createUser(User.withUsername("yanglf") .password(passwordEncoder().encode("admin123")) .build()); } return manager; } ``` ## JPA 动态加载用户 ### 新增依赖 ```xml org.springframework.boot spring-boot-starter-data-jpa mysql mysql-connector-java ``` ### 新增配置 ```yaml spring: jpa: # 使用 innodb 数据库 支持事务 database-platform: org.hibernate.dialect.MySQL5InnoDBDialect hibernate: # create 每次都会删除上一次的表,根据定义的 model 重新创建 # create-drop 每次 session 关闭 删除表,创建 session 创建表 # update 数据库没有创建,数据库存在,更新表结构,不会删除以前的数据 # validate 每次加载时候,验证数据库结构和定义的model是否一致,不一致报错,比较安全 ddl-auto: update database: mysql show-sql: true ``` ### 新建model > Role ```java @Data @Entity(name="t_role") public class Role{ @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private String nameZh; } ``` > User ```java @Data @Entity(name="t_user") public class User implements UserDetails { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; // 密码 private String password; // 用户名 private String username; // 角色 // 多对多关系 @ManyToMany(fetch=FetchType.EAGER,cascade=CascadeType.PERSIST) private List roles; // 账号是否没过期 private boolean accountNoneExpired; // 账号是否没锁定 private boolean accountNoneLocked; // 密码是否没过期 private boolean credentialsNoneExpired; // 账号是否可用 private boolean enabled; @Override public Collection getAuthorities() { List authorities=new ArrayList<>(); for (Role role : getRoles){ authorities.add(new SimpleGrantedAuthority(role.getName())); } return authorities; } @Override public String getPassword() { return password; } @Override public String getUsername() { return username; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return enabled; } } ``` ### 新建 mapper 和 service > mapper ```java public interface UserDao extends JpaRepository{ User findUserByUsername(String username); } ``` > service ```java @Component public class UserService implements UserDetailsService { @Autowired private UserDao userDao; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // 用户基础数据加载 User user = userDao.findUserByUsername(username); if (Objects.isNull(user)) { throw new UsernameNotFoundException("用户名不存在"); } return user; } } ``` ### 修改配置类 ```java @Autowired private UserService userService; @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userService) // 验证密码时候匹配 BCryptPasswordEncoder 加密 .passwordEncoder(passwordEncoder()); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } ``` ## RBAC 角色权限管理控制模型 ### Role-Base Access Control - 用户: 系统接口及功能访问的操作者 - 权限: 能够访问某接口或者做某操作的授权资格 - 角色: 具有一类相同操作权限的用户的总称 > 一个用户多个角色 (菜单权限和接口权限) - 用户表 sys_user | 字段 | 类型 | 描述 | | ----------- | -------------- | -------------------------------- | | id (PK) | bigint(20) | id | | username | varchar(64) | 用户名 | | password | varchar(64) | 密码 | | org_id | bigint(20) | 组织id | | enabled | tinyint(1) | 是否有效(0-无效用户,1-有效用户) | | phone | varchar(16) | 手机号 | | email | varchar(32) | 邮箱 | | create_time | datetime | 创建时间 | | ~~role_id~~ | ~~bigint(20)~~ | ~~角色id(一个用户对一个角色)~~ | - 角色表 sys_role | 字段 | 类型 | 说明 | | --------- | ------------ | ------------------------ | | id (PK) | bigint(20) | id | | role_name | varchar(32) | 角色名称(中文) | | role_desc | varchar(128) | 角色描述 | | role_code | varchar(32) | 角色编码( ADMIN、COMMON) | | sort | int(11) | 排序 | | status | tinyint(1) | 是否禁用(0-否,1-是) | - 用户角色表 sys_user_role | 字段 | 类型 | 说明 | | ------- | ---------- | ------ | | user_id | bigint(20) | 用户id | | role_id | bigint(20) | 角色id | - 菜单表 sys_menu | 字段 | 类型 | 说明 | | --------- | ------------ | ----------------------- | | id (PK) | bigint(20) | id | | menu_pid | bigint(20) | 父菜单id | | menu_pids | varchar(128) | 当前菜单所有父菜单 | | is_leaf | tinyint(1) | 是否叶子节点(0-否,1-是) | | menu_name | varchar(64) | 菜单名称 | | url | varchar(64) | 菜单跳转url | | icon | varchar(45) | 菜单图标 | | sort | int(11) | 排序 | | level | int(11) | 菜单层级 | | status | tinyint(1) | 是否禁用(0-否,1-是) | - 角色菜单 sys_role_menu | 字段 | 类型 | 说明 | | ------- | ---------- | ------ | | role_id | bigint(20) | 角色id | | menu_id | bigint(20) | 菜单id | - 接口表 sys_api | 字段 | 类型 | 说明 | | -------- | ----------- | -------- | | id (PK) | bigint(20) | id | | api_name | varchar(64) | api 名称 | | url | varchar(64) | 接口url | - 角色接口表 sys_role_api | 字段 | 类型 | 说明 | | ------- | ---------- | ------ | | role_id | bigint(20) | 角色id | | api_id | bigint(20) | 接口id | - 组织机构表 sys_org | 字段 | 类型 | 说明 | | -------- | ------------ | ------------------------------------------------------- | | id (PK) | bigint(20) | id | | org_pid | bigint(20) | 上级组织id | | org_pids | varchar(128) | 所有父节点id `[1],[2],[21]` 逗号隔开 `FIND_IN_SET` | | is_leaf | tinyint(1) | 是否叶子节点(0-否,1-是) | | org_name | varchar(32) | 组织名称 | | address | varchar(64) | 地址 | | phone | varchar(16) | 电话 | | email | varchar(32) | 邮箱 | | sort | int(11) | 排序 | | level | int(11) | 组织层级 | | status | tinyint(1) | 是否禁用(0-否,1-是) | > 建表sql ```sql SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0; -- ---------------------------- -- Table structure for sys_api -- ---------------------------- DROP TABLE IF EXISTS `sys_api`; CREATE TABLE `sys_api` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id', `api_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT 'api 名称', `url` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '接口url', PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic; -- ---------------------------- -- Records of sys_api -- ---------------------------- -- ---------------------------- -- Table structure for sys_menu -- ---------------------------- DROP TABLE IF EXISTS `sys_menu`; CREATE TABLE `sys_menu` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id', `menu_pid` bigint(20) NULL DEFAULT NULL COMMENT '父菜单id', `menu_pids` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '当前菜单所有父菜单', `is_leaf` tinyint(1) NULL DEFAULT NULL COMMENT '是否叶子节点(0-否,1-是)', `menu_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '菜单名称', `url` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '菜单跳转url', `icon` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '菜单图标', `sort` int(11) NULL DEFAULT NULL COMMENT '排序', `level` int(11) NULL DEFAULT NULL COMMENT '菜单层级', `status` tinyint(1) NULL DEFAULT NULL COMMENT '是否禁用(0-否,1-是)', PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic; -- ---------------------------- -- Records of sys_menu -- ---------------------------- INSERT INTO `sys_menu` VALUES (1, 0, '[0]', 0, '首页', '/', NULL, 1, 1, 0); INSERT INTO `sys_menu` VALUES (2, 1, '[0],[1]', 1, '用户管理', '/sysuser', NULL, 1, 2, 0); INSERT INTO `sys_menu` VALUES (3, 1, '[0],[1]', 1, '日志管理', '/syslog', NULL, 2, 2, 0); INSERT INTO `sys_menu` VALUES (4, 1, '[0],[1]', 1, '业务一', '/biz1', NULL, 3, 2, 0); INSERT INTO `sys_menu` VALUES (5, 1, '[0],[1]', 1, '业务二', '/biz2', NULL, 4, 2, 0); -- ---------------------------- -- Table structure for sys_org -- ---------------------------- DROP TABLE IF EXISTS `sys_org`; CREATE TABLE `sys_org` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id', `org_pid` bigint(20) NULL DEFAULT NULL COMMENT '上级组织id', `org_pids` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '所有父节点id ', `is_leaf` tinyint(1) NULL DEFAULT NULL COMMENT '是否叶子节点(0-否,1-是)', `org_name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '组织名称', `address` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '地址', `phone` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '电话', `email` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '邮箱', `sort` int(11) NULL DEFAULT NULL COMMENT '排序', `level` int(11) NULL DEFAULT NULL COMMENT '组织层级', `status` tinyint(1) NULL DEFAULT NULL COMMENT '是否禁用(0-否,1-是)', PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic; -- ---------------------------- -- Records of sys_org -- ---------------------------- -- ---------------------------- -- Table structure for sys_role -- ---------------------------- DROP TABLE IF EXISTS `sys_role`; CREATE TABLE `sys_role` ( `id` bigint(22) NOT NULL AUTO_INCREMENT COMMENT 'id', `role_name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '角色名称', `role_desc` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '角色描述', `role_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '角色编码( ADMIN、COMMON)', `sort` int(11) NULL DEFAULT NULL COMMENT '排序', `status` tinyint(1) NULL DEFAULT NULL COMMENT '是否禁用(0-否,1-是)', PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic; -- ---------------------------- -- Records of sys_role -- ---------------------------- INSERT INTO `sys_role` VALUES (1, '管理员', '系统管理员,查看所有', 'admin', 1, 0); INSERT INTO `sys_role` VALUES (2, '普通用户', '普通用户,首页,业务一,业务二', 'common', 2, 0); -- ---------------------------- -- Table structure for sys_role_api -- ---------------------------- DROP TABLE IF EXISTS `sys_role_api`; CREATE TABLE `sys_role_api` ( `role_id` bigint(20) NOT NULL COMMENT '角色id', `api_id` bigint(20) NOT NULL COMMENT '接口id ' ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic; -- ---------------------------- -- Records of sys_role_api -- ---------------------------- -- ---------------------------- -- Table structure for sys_role_menu -- ---------------------------- DROP TABLE IF EXISTS `sys_role_menu`; CREATE TABLE `sys_role_menu` ( `role_id` bigint(20) NOT NULL COMMENT '角色id', `menu_id` bigint(20) NOT NULL COMMENT '菜单id' ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic; -- ---------------------------- -- Records of sys_role_menu -- ---------------------------- INSERT INTO `sys_role_menu` VALUES (1, 1); INSERT INTO `sys_role_menu` VALUES (1, 2); INSERT INTO `sys_role_menu` VALUES (1, 3); INSERT INTO `sys_role_menu` VALUES (1, 4); INSERT INTO `sys_role_menu` VALUES (1, 5); INSERT INTO `sys_role_menu` VALUES (2, 1); INSERT INTO `sys_role_menu` VALUES (2, 4); INSERT INTO `sys_role_menu` VALUES (2, 5); -- ---------------------------- -- Table structure for sys_user -- ---------------------------- DROP TABLE IF EXISTS `sys_user`; CREATE TABLE `sys_user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id', `username` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '用户名', `password` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '密码', `org_id` bigint(20) NULL DEFAULT NULL COMMENT '组织id', `enabled` tinyint(1) NULL DEFAULT NULL COMMENT '是否有效(0-无效用户,1-有效用户)', `phone` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '手机号', `email` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '邮箱', `create_time` datetime NULL DEFAULT NULL COMMENT '创建时间', PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic; -- ---------------------------- -- Records of sys_user -- ---------------------------- INSERT INTO `sys_user` VALUES (1, 'admin', '$2a$10$pcGlvILrp6x4VMAKgt1Cb.gdxTeTLqg5VFd9qIobWVGxs51ccpxfm', NULL, 1, '1569236410', '185625412@qq.com', '2021-09-29 18:45:55'); INSERT INTO `sys_user` VALUES (2, 'user', '$2a$10$YphX7rrDUOEYxSJoOzWkCu4tidQ9N1PYjxMkyXcqJ2iQEg9lrZn7W', NULL, 1, '1352654214', '2365412@163.com', '2021-10-02 18:45:59'); -- ---------------------------- -- Table structure for sys_user_role -- ---------------------------- DROP TABLE IF EXISTS `sys_user_role`; CREATE TABLE `sys_user_role` ( `user_id` bigint(20) NOT NULL COMMENT '用户id', `role_id` bigint(20) NOT NULL COMMENT '角色id' ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic; -- ---------------------------- -- Records of sys_user_role -- ---------------------------- SET FOREIGN_KEY_CHECKS = 1; ``` ## 动态加载用户角色权限 ### 定义用户类 ```java @Data public class MyUserDetails implements UserDetails { // 密码 private String password; // 用户名 private String username; // 账号是否没过期 private boolean accountNoneExpired; // 账号是否没锁定 private boolean accountNoneLocked; // 密码是否没过期 private boolean credentialsNoneExpired; // 账号是否可用 private boolean enabled; // 用户权限集合 private Collection authorities; @Override public Collection getAuthorities() { return authorities; } @Override public String getPassword() { return password; } @Override public String getUsername() { return username; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return enabled; } } ``` ### 用户信息查询 Mapper ```java public interface MyUserDetailsServiceMapper extends BaseMapper { // 根据用户id查询用户信息 @Select("SELECT username,password,enabled \n" + "FROM sys_user u \n" + "WHERE u.username = #{userId}") MyUserDetails findByUserName(@Param(value = "userId") String userId); // 根据用户id查询用户角色 @Select("SELECT role_code \n" + "FROM sys_role r \n" + "LEFT JOIN sys_user_role ur ON ur.role_id = r.id \n" + "LEFT JOIN sys_user u ON u.id = ur.user_id \n" + "WHERE u.username = #{userId}") List findRoleByUserName(@Param(value = "userId") String userId); // 根据用户角色查询用户权限 @Select({ "" }) List findAuthorityByRoleCodes(@Param(value = "roleCodes") List roleCodes); } ``` ### 登录业务逻辑 ```java @Component public class MyUserDetailsService implements UserDetailsService { @Autowired private MyUserDetailsServiceMapper myUserDetailsServiceMapper; @Override public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException { // 用户基础数据加载 MyUserDetails myUserDetails = myUserDetailsServiceMapper.findByUserName(userName); if (Objects.isNull(myUserDetails)) { throw new UsernameNotFoundException("用户名不存在"); } // 用户角色加载 List roleCodes = myUserDetailsServiceMapper.findRoleByUserName(userName); // 根据角色列表加载当前用户所有权限 List authorities = myUserDetailsServiceMapper.findAuthorityByRoleCodes(roleCodes); roleCodes = roleCodes.stream() .map(rc -> "ROLE_" + rc) .collect(Collectors.toList()); authorities.addAll(roleCodes); myUserDetails.setAuthorities(AuthorityUtils.commaSeparatedStringToAuthorityList( String.join(",", authorities) )); return myUserDetails; } } ``` ### 修改配置文件 ```java @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler; @Autowired private MyAuthenticationFailureHandler myAuthenticationFailureHandler; @Autowired private CustomExpiredSessionStrategy customExpiredSessionStrategy; @Autowired private MyUserDetailsService myUserDetailsService; @Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() // formLogin .formLogin() // 登录页面配置 .loginPage("/login.html") // 处理登录业务逻辑的接口url .loginProcessingUrl("/login") // 登录 用户名 请求参数名 .usernameParameter("username") // 登录 密码 请求参数名 .passwordParameter("password") .successHandler(myAuthenticationSuccessHandler) .failureHandler(myAuthenticationFailureHandler) // 登录成功跳转的url地址 //.defaultSuccessUrl("/") // 登录失败跳转的url地址 //.failureUrl("/login.html") .and() .authorizeRequests() // 不需要登录就可以访问的资源 .antMatchers("/login.html", "/login").permitAll() // user 和 admin 角色可以访问的资源 //.antMatchers("/", "/biz1", "biz2") .antMatchers("/").hasAnyAuthority("/") .antMatchers("/biz1").hasAnyAuthority("/biz1") .antMatchers("/biz2").hasAnyAuthority("/biz2") // hasAnyAuthority("role_user") 等价与 hasAnyRole("user") // admin 可以访问的资源 .antMatchers("/sysuser").hasAnyAuthority("/sysuser") .antMatchers("/syslog").hasAnyAuthority("/syslog") // .antMatchers("/syslog", "/sysuser") .anyRequest() .authenticated() .and().sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) .sessionFixation().migrateSession() .maximumSessions(1) // true 表示已经登录,就不允许再登陆 // false 表示允许再次登录,但是之前登录的账号会被踢下线 .maxSessionsPreventsLogin(false) .expiredSessionStrategy(customExpiredSessionStrategy); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(myUserDetailsService) // 验证密码时候匹配 BCryptPasswordEncoder 加密 .passwordEncoder(passwordEncoder()); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Override public void configure(WebSecurity web) throws Exception { // 将静态资源放出来 HttpSecurity 配置的规则,都要走过滤器 这里配置不需要 web.ignoring().antMatchers("/css/**", "/fonts", "/img/**", "/js/**"); } } ``` ## 动态加载资源鉴权规则 ```java @Component("rbaService") public class MyRBAService { public boolean hasPermission(HttpServletRequest request, Authentication authentication) { Object principal = authentication.getPrincipal(); if (principal instanceof UserDetails) { UserDetails userDetails = (UserDetails) principal; Collection grantedAuthorities = userDetails.getAuthorities(); // "/syslog" SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(request.getRequestURI()); return grantedAuthorities.contains(simpleGrantedAuthority); } return false; } public boolean checkUserId(Authentication authentication, Long userId) { return true; } } ``` > 修改配置文件 ```java @Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() // formLogin .formLogin() // 登录页面配置 .loginPage("/login.html") // 处理登录业务逻辑的接口url .loginProcessingUrl("/login") // 登录 用户名 请求参数名 .usernameParameter("username") // 登录 密码 请求参数名 .passwordParameter("password") .successHandler(myAuthenticationSuccessHandler) .failureHandler(myAuthenticationFailureHandler) // 登录成功跳转的url地址 //.defaultSuccessUrl("/") // 登录失败跳转的url地址 //.failureUrl("/login.html") .and() .authorizeRequests() // 不需要登录就可以访问的资源 .antMatchers("/login.html", "/login").permitAll() // user 和 admin 角色可以访问的资源 //.antMatchers("/", "/biz1", "biz2") /* .antMatchers("/").hasAnyAuthority("/") .antMatchers("/biz1").hasAnyAuthority("/biz1") .antMatchers("/biz2").hasAnyAuthority("/biz2") // hasAnyAuthority("role_user") 等价与 hasAnyRole("user") // admin 可以访问的资源 .antMatchers("/sysuser").hasAnyAuthority("/sysuser") .antMatchers("/syslog").hasAnyAuthority("/syslog")*/ .anyRequest().access("@rbaService.hasPermission(request,authentication)") .antMatchers("/person/{id}").access("@rbaService.checkUserId(authentication,#id)") // .antMatchers("/syslog", "/sysuser") //.anyRequest() //.authenticated() .and().sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) .sessionFixation().migrateSession() .maximumSessions(1) // true 表示已经登录,就不允许再登陆 // false 表示允许再次登录,但是之前登录的账号会被踢下线 .maxSessionsPreventsLogin(false) .expiredSessionStrategy(customExpiredSessionStrategy); } ``` ## 权限表达式使用方法 > 权限表达式全局使用 ```java .and() .authorizeRequests() // 不需要登录就可以访问的资源 .antMatchers("/login.html", "/login").permitAll() .anyRequest().access("@rbaService.hasPermission(request,authentication)") ``` > 表达或者的关系 ```java .and() .authorizeRequests() // 不需要登录就可以访问的资源 .antMatchers("/login.html", "/login").permitAll() .antMatchers("/system/**").access("hasAnyRole('admin') or hasAuthority('ROLE_admin')") .anyRequest().authenticated() ``` > 常用表达式函数及对象 | 表达式函数 | 描述 | | ------------------------------------------ | ------------------------------------------------------------ | | hasRole([role]) | 用户拥有指定角色时返回true (Spring Security 默认带有 `ROLE_` 前缀) | | hasAnyRole([role1],[role2]) | 用户拥有任何指定的角色返回true | | hasAuthority([authority]) | 用有某资源的访问权限时返回true | | hasAnyAuthority([authority1],[authority2]) | 拥有某些资源其中部分资源的访问权限返回true | | permitAll | 永远返回true | | denyAll | 永远返回false | | anonymous | 当前用户匿名时返回true | | rememberMe | 当前用户勾选记住密码,返回true | | authentication | 当前用户的 `authentication` 对象 | | fullAuthenticated | 当前用户既不是 `anonymous` 也不是 `rememberMe` 返回 true | | hasIpAddress('192.168.1.0/24') | 请求发送的IP匹配时返回true | > 方法级别的安全控制 - `@PreAuthorize("SPEL")` - `@PreFilter("SPEL")` - `@PostAuthrize("SPEL")` - `@PostFilter("SPEL")` **测试service** Spring Security 配置类必须添加 `@EnableGlobalMethodSecurity(prePostEnabled = true)` 注解,才可以生效 ```java @Slf4j@Servicepublic class MethodELService { // 只有 admin 角色用户可以访问 @PreAuthorize("hasAnyRole('admin')") public List findAll() { List list = new ArrayList<>(); list.add(new Person("张三")); list.add(new Person("李四")); list.add(new Person("tom")); return list; } // 只返回 和当前登录用户的 用户名 一致的数据 @PostAuthorize("returnObject.name == authentication.name") public Person findOne() { String name = SecurityContextHolder.getContext().getAuthentication().getName(); log.info("name:{}", name); return new Person("admin"); } // 请求参数 过滤 成 偶数 @PreFilter(filterTarget = "ids", value = "filterObject%2==0") public void delete(List ids, List usernames) { log.info("ids:{}",ids); } // 只返回 和当前登录用户的 用户名 一致的数据 @PostFilter("filterObject.name == authentication.name") public List findAllPerson() { List list = new ArrayList<>(); list.add(new Person("admin")); list.add(new Person("jack")); return list; }} ``` **测试 controller** ```java @Slf4j@Controllerpublic class IndexController { @Autowired private MethodELService methodELService; @PostMapping("/login") public String index(String userName, String password) { return "index"; } @GetMapping("/syslog") public String syslog() { return "syslog"; } @GetMapping("/sysuser") public String sysuser() { return "sysuser"; } @GetMapping("/biz1") public String biz1() { List all = methodELService.findAll(); log.info("findAll:{}", all); Person person = methodELService.findOne(); log.info("findOne:{}", person); List ids = new ArrayList<>(); ids.add(1); ids.add(2); methodELService.delete(ids, null); List allPerson = methodELService.findAllPerson(); log.info("findAllPerson:{}", allPerson); return "biz1"; } @GetMapping("/biz2") public String biz2() { return "biz2"; }} ``` # RememberMe > 后端 ```java @Override protected void configure(HttpSecurity http) throws Exception { http.rememberMe() // 设置 form 表单 参数名 .rememberMeParameter("remember-me-new") // 设置保存到浏览器的cookie 名称 .rememberMeCookieName("remember-me-cookie") // 设置 token 有效期 默认 2 周 .tokenValiditySeconds(2 * 24 * 60 * 60) // 持久化到数据库 .tokenRepository(persistentTokenRepository()); } @Autowired private DataSource dataSource; // 持久化 登录 token 到数据库 @Bean public PersistentTokenRepository persistentTokenRepository() { JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl(); tokenRepository.setDataSource(dataSource); return tokenRepository; } ``` > 前端 ```html
用户名
密码
``` > 创建持久化数据库表 ```sql create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, token varchar(64) not null, last_used timestamp not null) ``` > RememberMeToken 组成部分 - RememberMeToken = Base64(username + expiryTime + signatureValue) - signatureValue = username 、 expiryTime 和 username 时一个预定的key,并将他们经过MD5进行签名。 # 退出登录 > logout 的默认行为 - 当前 session 失效,即 logout的核心需求,session失效就是访问权限的回收 - 删除当前用户的 remember-me 功能信息 - clear 清除当前的 SecurityContent - 重定向到登录页面,loginPage 配置的指定页面 > 配置 ```java @Autowired private MyLogoutSuccessHandler myLogoutSuccessHandler; @Override protected void configure(HttpSecurity http) throws Exception { http.logout() // 指定退出请求的 url,html 中退出按钮请求url 也需要修改 .logoutUrl("/logout") // 退出成功跳转页面 .logoutSuccessUrl("/login.html") // 退出登录的业务逻辑 不能和 logoutSuccessUrl 一块使用 .logoutSuccessHandler(myLogoutSuccessHandler) // 退出时 删除 指定 cookie .deleteCookies("JESSION") } @Autowired private DataSource dataSource; // 持久化 登录 token 到数据库 @Bean public PersistentTokenRepository persistentTokenRepository() { JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl(); tokenRepository.setDataSource(dataSource); return tokenRepository; } ``` > 退出登录逻辑 ```java @Component public class MyLogoutSuccessHandler implements LogoutSuccessHandler { @Override public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { response.sendRedirect("/login.html"); } } ``` > 前端页面修改 ```html 业务系统管理

业务系统管理


退出
日志管理
用户管理
业务一
业务二 ``` # JWT 集成 ## 认证流程 - `JwtAuthenticationTokenFilter` 检查 是否携带token,并解析用户信息 - `UserDetailsService` 根据用户名加载用户信息 - `JwtTokenUtils` 根据用户信息检查token有效性 - `ApiController` ## 集成 ### 添加依赖 ```xml io.jsonwebtoken jjwt 0.9.1 ``` ### 封装工具类 ```java @Data@Component@ConfigurationProperties(prefix = "jwt")public class JwtTokenUtils { private String secret; private Long expiration; private String header; /** * 生成token * * @param userDetails 用户 * @return 令牌 token */ public String generateToken(UserDetails userDetails) { Map claims = new HashMap<>(); claims.put("sub", userDetails.getUsername()); claims.put("created", new Date()); return generateToken(claims); } public String refreshToken(String token) { String refreshedToken; try { Claims claims = getClaimsFromToken(token); claims.put("created", new Date()); refreshedToken = generateToken(claims); } catch (Exception e) { refreshedToken = null; } return refreshedToken; } /** * 从令牌中获取用户名 * * @param token 令牌 * @return 用户名 */ public String getUsernameFromToken(String token) { String username; try { Claims claims = getClaimsFromToken(token); username = claims.getSubject(); } catch (Exception e) { username = null; } return username; } /** * 从 claims 生成令牌 * * @param claims * @return */ private String generateToken(Map claims) { Date expirationDate = new Date(System.currentTimeMillis() + expiration); return Jwts.builder().setClaims(claims) .setExpiration(expirationDate) .signWith(SignatureAlgorithm.ES512, secret) .compact(); } /** * 验证令牌 * * @param token 令牌 * @param userDetails 用户信息 * @return 是否有效 */ public Boolean validateToken(String token, UserDetails userDetails) { MyUserDetails myUserDetails = (MyUserDetails) userDetails; String username = getUsernameFromToken(token); return (username.equals(myUserDetails.getUsername()) && !isTokenExpired(token)); } /** * 判断令牌是否过期 * * @param token 令牌 * @return 是否过期 */ public boolean isTokenExpired(String token) { try { Claims claims = getClaimsFromToken(token); Date expiration = claims.getExpiration(); return expiration.before(new Date()); } catch (Exception e) { return false; } } /** * 从令牌中获取加密的数据 * * @param token * @return */ private Claims getClaimsFromToken(String token) { Claims claims; try { claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); } catch (ExpiredJwtException e) { claims = e.getClaims(); } catch (Exception e) { claims = null; } return claims; }} ``` #### 配置文件 ```yaml jwt: secret: abashajkqewqw213 expiration: 3600000 header: JWTHeaderName ``` #### service ```java @Servicepublic class JWTAuthService { @Autowired private AuthenticationManager authenticationManager; @Autowired private UserDetailsService userDetailsService; @Autowired private JwtTokenUtils jwtTokenUtils; public String login(String username, String password) throws CustomException { try { UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(username, password); Authentication authenticate = authenticationManager.authenticate(usernamePasswordAuthenticationToken); SecurityContextHolder.getContext().setAuthentication(authenticate); } catch (AuthenticationException e) { throw new CustomException(CustomExceptionType.USER_INPUT_ERROR, "用户名密码错误"); } UserDetails userDetails = userDetailsService.loadUserByUsername(username); return jwtTokenUtils.generateToken(userDetails); } public String refreshToken(String oldToken) { if (jwtTokenUtils.isTokenExpired(oldToken)) { return jwtTokenUtils.refreshToken(oldToken); } return null; }} ``` #### Controller ```java @RestControllerpublic class JWTAuthController { @Autowired private JWTAuthService jwtAuthService; @GetMapping("/authentication") public AjaxResponse login(@RequestBody Map map) { String username = map.get("username"); String password = map.get("password"); if (StringUtils.isEmpty(username) || StringUtils.isEmpty(password)) { return AjaxResponse.error(CustomExceptionType.USER_INPUT_ERROR, "用户名密码不能为空"); } try { String token = jwtAuthService.login(username, password); return AjaxResponse.success(token); } catch (CustomException e) { return AjaxResponse.error(e); } } @GetMapping("/refreshToken") public AjaxResponse refreshToken(@RequestHeader("${jwt.header}") String token) { return AjaxResponse.success(jwtAuthService.refreshToken(token)); }} ``` #### 配置类 ```java @Configuration@EnableGlobalMethodSecurity(prePostEnabled = true)public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler; @Autowired private MyAuthenticationFailureHandler myAuthenticationFailureHandler; @Autowired private MyExpiredSessionStrategy myExpiredSessionStrategy; @Autowired private MyUserDetailsService myUserDetailsService; @Autowired private MyLogoutSuccessHandler myLogoutSuccessHandler; @Autowired private CaptchaCodeFilter captchaCodeFilter; @Autowired private SmsCodeSecurityConfig smsCodeSecurityConfig; @Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() .addFilterBefore(captchaCodeFilter, UsernamePasswordAuthenticationFilter.class) .apply(smsCodeSecurityConfig) .and() .authorizeRequests() // 不需要登录就可以访问的资源 .antMatchers("/authentication","/refreshToken").permitAll() .anyRequest().access("@rbaService.hasPermission(request,authentication)") .and().rememberMe() .rememberMeParameter("remember-me-new") .rememberMeCookieName("remember-me-cookie") .tokenValiditySeconds(2 * 24 * 60 * 60) .tokenRepository(persistentTokenRepository()) .and() .logout() .logoutUrl("/logout") .logoutSuccessHandler(myLogoutSuccessHandler) .deleteCookies("JESSION") .and().sessionManagement() // 基于token,所以不需要session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) .sessionFixation().migrateSession() .maximumSessions(1) // true 表示已经登录,就不允许再登陆 // false 表示允许再次登录,但是之前登录的账号会被踢下线 .maxSessionsPreventsLogin(false) .expiredSessionStrategy(myExpiredSessionStrategy); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(myUserDetailsService) // 验证密码时候匹配 BCryptPasswordEncoder 加密 .passwordEncoder(passwordEncoder()); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Override public void configure(WebSecurity web) throws Exception { // 将静态资源放出来 HttpSecurity 配置的规则,都要走过滤器 这里配置不需要 web.ignoring().antMatchers("/css/**", "/fonts", "/img/**", "/js/**"); } @Autowired private DataSource dataSource; @Bean public PersistentTokenRepository persistentTokenRepository() { JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl(); tokenRepository.setDataSource(dataSource); return tokenRepository; } @Override @Bean(name = BeanIds.AUTHENTICATION_MANAGER) public AuthenticationManager authenticationManager() throws Exception { return super.authenticationManager(); }} ``` ### 集成 Security #### 编写过滤器 ```java @Componentpublic class JwtAuthenticationTokenFilter extends OncePerRequestFilter { @Autowired private JwtTokenUtils jwtTokenUtils; @Autowired private MyUserDetailsService myUserDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String jwtToken = request.getHeader(jwtTokenUtils.getHeader()); if (StringUtils.isNotEmpty(jwtToken)) { String username = jwtTokenUtils.getUsernameFromToken(jwtToken); if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = myUserDetailsService.loadUserByUsername(username); if (jwtTokenUtils.validateToken(jwtToken, userDetails)) { // 给当前 JWT 令牌 用户 授权 UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authenticationToken); } } } filterChain.doFilter(request, response); }} ``` #### 配置过滤器 ```java @Autowired private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter; @Override protected void configure(HttpSecurity http) throws Exception { // CSRF禁用,因为不使用session http.csrf().disable() // 认证失败处理类 .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and() .addFilterBefore(captchaCodeFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class) .apply(smsCodeSecurityConfig) .and() // 过滤请求 .authorizeRequests() // 只允许 不登录(匿名) 访问 .antMatchers("/login", "/captchaImage").anonymous() // 登录 不登录 都允许 访问 .antMatchers("/authentication", "/refreshToken").permitAll() .anyRequest().access("@rbaService.hasPermission(request,authentication)") // 除上面外的所有请求全部需要鉴权认证 .anyRequest().authenticated() .and().rememberMe() .rememberMeParameter("remember-me-new") .rememberMeCookieName("remember-me-cookie") .tokenValiditySeconds(2 * 24 * 60 * 60) .tokenRepository(persistentTokenRepository()) .and() .logout() .logoutUrl("/logout") .logoutSuccessHandler(myLogoutSuccessHandler) .deleteCookies("JESSION") .and().sessionManagement() // 基于token,所以不需要session .sessionCreationPolicy(SessionCreationPolicy.STATELESS); }} ``` #### 测试 controller ```java @RestControllerpublic class HelloControllerController { @GetMapping("/hello") public String hello(String name) { return name; }} ``` 登录成功后,携带 token 访问 这个接口,记得在数据库给当前对应角色,分配权限 ```sql INSERT INTO `bootdb`.`sys_menu` (`id`, `menu_pid`, `menu_pids`, `is_leaf`, `menu_name`, `url`, `icon`, `sort`, `level`, `status`) VALUES (6, 1, '1', 1, 'hello', '/hello', NULL, 5, 2, 0);INSERT INTO `bootdb`.`sys_role_menu` (`role_id`, `menu_id`) VALUES (1, 6); ``` # 跨域 ## 前端解决方案 - html 的 script 标签 - html 的 link 标签 - html 的 img 标签 - html 的 iframe 标签: 对于使用 jsp、freemarker开发的项目,这是实现跨域访问最常见的方式 ## 使用代理解决 ## CORS - `Access-Control-Origin` 允许哪些IP或者域名可以跨域访问 - `Access-Control-Max-Age` 表示在多少秒内不需要重复校验该请求的跨域访问权限 - `Access-Control-Allow-Methods` 表示允许跨域访问请求的HTTP方法,如:GET、POST、PUT、DELETE - `Access-Control-Allow-Headers` 表示访问资源允许携带哪些Header信息,如: Accept、Accept-Lanauage、Content-Length、Content-Lanaguage、Content-Type ## 测试跨域 js ```js window.onload = function (ev) { var headers = {"JWTHeaderName": ''}; $.ajax({ url: 'http://localhost:8888/hello', type: 'POST', headers: headers, success: function (data) { alert('跨域访问成功'); }, error: function (e) { alert('跨域访问失败'); } }) let kaptchaImage = document.getElementById("kaptcha"); kaptchaImage.onclick = function (ev1) { kaptchaImage.src = "/kaptcha?" + Math.floor(Math.random() * 100); } } ``` ## 开启跨域 ```java @Override protected void configure(HttpSecurity http) throws Exception { // 开启跨域 http.cors(); } // 配置跨域 @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration corsConfiguration = new CorsConfiguration(); corsConfiguration.setAllowedOrigins(Arrays.asList("http://localhost:8080")); corsConfiguration.setAllowedMethods(Arrays.asList("POST","GET")); corsConfiguration.applyPermitDefaultValues(); UrlBasedCorsConfigurationSource configurationSource = new UrlBasedCorsConfigurationSource(); configurationSource.registerCorsConfiguration("/**", corsConfiguration); return configurationSource; } ``` ## CSRF 跨站攻击防御 - `CORS` 跨站资源共享,,局部打破同源策略限制,使在一定规则下HTTP请求可以突破浏览器限制,实现跨站访问 - `CSRF` 是一种网络攻击方式,也可以说是一种安全漏洞,这个安全漏洞在web开发中广泛存在,我们需要堵住这个漏洞 > 配置 ```java @Override protected void configure(HttpSecurity http) throws Exception { http.csrf() // 使用 cookie 保存 .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) // csrf 忽略登录url,只针对所有POST 请求防御 .ignoringAntMatchers("/authentication") } ``` > 测试 - 登录时,cookie 会返回 `XSRF-TOKEN` - 所有 `POST` 请求 访问接口的时候,请求 `Header` 中除了要携带 `JWTHeaderName` ,还需要携带 `X-XSRF-TOKEN` > 前端携带参数方式 - Header 中携带 CSRF token ```js var headers={};headers['X-XSRF-TOKEN'] = "${_csrf.token}";$ajax({ headers: headers}) ``` - 直接作为参数提交 ```js $ajax({ data:{ '_csrf':"${_csrf.token}" }}) ``` - form 表单的隐藏字段 ```html ```