2.1 案例初始化

还没有引入Spring Security的项目,包括登录、首页、系统管理等页面及其后台代码。
spring-security的初始化项目代码.zip
引入Spring Security的项目,完成认证功能。
spring-security-demo认证.zip

2.2 SpringSecurity认证基本原理和认证2种方式

在上述工程中导入Spring Security依赖

  1. <!--添加Spring Security 依赖 -->
  2. <dependency>
  3. <groupId>org.springframework.boot</groupId>
  4. <artifactId>spring-boot-starter-security</artifactId>
  5. </dependency>

2.2.1 过滤链介绍

在使用SpringSecurity框架,该框架会默认自动地替我们将系统中的资源进行保护,每次访问资源的 时候都必须经过一层身份的校验,如果通过了则重定向到我们输入的url中,否则访问是要被拒绝的。那 么SpringSecurity框架是如何实现的呢? Spring Security功能的实现主要是由一系列过滤器相互配合完成。也称之为过滤器链。
1651657428(1).jpg

过滤器是一种典型的AOP思想,Spring Security默认加载15个过滤器, 但是随着配置可以增加或者删除一些过滤器:

  1. org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter

    根据请求封装获取WebAsyncManager,从WebAsyncManager获取/注册的安全上下文可调用处理拦截器

  2. org.springframework.security.web.context.SecurityContextPersistenceFilter

    SecurityContextPersistenceFilter主要是使用SecurityContextRepository在session中保存 或更新一个SecurityContext,并将SecurityContext给以后的过滤器使用,来为后续filter 建立所需的上下文。SecurityContext中存储了当前用户的认证以及权限信息。

  3. org.springframework.security.web.header.HeaderWriterFilter

    向请求的Header中添加相应的信息,可在http标签内部使用security:headers来控制

  4. org.springframework.security.web.csrf.CsrfFilter

    csrf又称跨域请求伪造,SpringSecurity会对所有post请求验证是否包含系统生成的csrf的token信息,如果不包含,则报错。起到防止csrf攻击的效果。

  5. org.springframework.security.web.authentication.logout.LogoutFilter

    匹配URL为/logout的请求,实现用户退出,清除认证信息。

  6. org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter

    表单认证操作全靠这个过滤器,默认匹配URL为/login且必须为POST请求。

  7. org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter

    如果没有在配置文件中指定认证页面,则由该过滤器生成一个默认认证页面。

  8. org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter

    由此过滤器可以生产一个默认的退出登录页面

  9. org.springframework.security.web.authentication.www.BasicAuthenticationFilter

    此过滤器会自动解析HTTP请求中头部名字为Authentication,且以Basic开头的头信息。

  10. org.springframework.security.web.savedrequest.RequestCacheAwareFilter

    通过HttpSessionRequestCache内部维护了一个RequestCache用于缓存HttpServletRequest。

  11. org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter

    针对ServletRequest进行了一次包装,使得request具有更加丰富的API。

  12. org.springframework.security.web.authentication.AnonymousAuthenticationFilter

    当SecurityContextHolder中认证信息为空,则会创建一个匿名用户存入到SecurityContextHolder中。spring security为了兼容未登录的访问,也走了一套认证流程,只不过是一个匿名的身份。

  13. org.springframework.security.web.session.SessionManagementFilter

    securityContextRepository限制同一用户开启多个会话的数量

  14. org.springframework.security.web.access.ExceptionTranslationFilter

    异常转换过滤器位于整个springSecurityFilterChain的后方用来转换整个链路中出现的异常

  15. org.springframework.security.web.access.intercept.FilterSecurityInterceptor

    获取所配置资源访问的授权信息,根据SecurityContextHolder中存储的用户信息来决定其是否有权限。

2.2.2 认证方式

  1. HttpBasic认证

HttpBasic登录验证模式是Spring Security实现登录验证最简单的一种方式,也可以说是最简陋的一种方式。它的目的并不是保障登录验证的绝对安全,而是提供一种“防君子不防小人”的登录验证。
在使用的Spring Boot早期版本为1.X版本,依赖的Security 4.X版本,那么就无需任何配置,启动项目访问则会弹出默认的httpbasic认证。现在使用的是spring boot2.0以上版本(依赖Security 5.X版本),HttpBasic不再是默认的验证模式,在spring security 5.x默认的验证模式已经是表单模式。
HttpBasic模式要求传输的用户名密码使用Base64模式进行加密。如果用户名是 “admin” ,密码是“ admin”,则将字符串”admin:admin” 使用Base64编码算法加密。加密结果可能是:
YWtaW46YWRtaW4=。HttpBasic模式真的是非常简单又简陋的验证模式,Base64的加密算法是可逆的,想要破解并不难.

  1. formLogin登录认证模式

Spring Security的HttpBasic模式,该模式比较简单,只是进行了通过携带Http的Header进行
简单的登录验证,而且没有定制的登录页面,所以使用场景比较窄。对于一个完整的应用系统,与
登录验证相关的页面都是高度定制化的,非常美观而且提供多种登录方式。这就需要Spring
Security支持我们自己定制登录页面, spring boot2.0以上版本(依赖Security 5.X版本)默认会生
成一个登录页面.

2.3 表单认证

2.3.1 自定义表单登录

编写配置文件继承WebSecurityConfigurerAdapter类然后重写几个方法,修改SpringSecurity的默认配置

  1. @Configuration
  2. public class SecurityConfig extends WebSecurityConfigurerAdapter {
  3. @Override
  4. protected void configure(AuthenticationManagerBuilder auth) throws Exception {
  5. super.configure(auth);
  6. }
  7. @Override
  8. public void configure(WebSecurity web) throws Exception {
  9. // 解决静态资源被拦截问题
  10. web.ignoring().antMatchers("/css/**","/images/**","/js/**");
  11. }
  12. /**
  13. * http请求方法
  14. * @param http
  15. * @throws Exception
  16. */
  17. @Override
  18. protected void configure(HttpSecurity http) throws Exception {
  19. /* 开启http basic认证 所有请求都需要认证
  20. http.httpBasic().and().authorizeRequests().anyRequest().authenticated();*/
  21. // 开启表单认证
  22. http.formLogin().loginPage("/toLoginPage")
  23. .and().authorizeRequests()
  24. // 放行登录页面
  25. .antMatchers("/toLoginPage").permitAll()
  26. // 所有请求都需要认证
  27. .anyRequest().authenticated();
  28. }
  29. }

以上代码中,安全构建器HttpSecurity和webSecurity的区别:

  1. WebSecurity 不仅通过 HttpSecurity 定义某些请求的安全控制,也通过其他方式定义其他某些请求可以忽略安全控制
  2. HttpSecurity 仅用于定义需要安全控制的请求(当然 HttpSecurity 也可以指定某些请求不需要安全控制)
  3. 可以认为 HttpSecurity 是 WebSecurity 的一部分, WebSecurity 是包含 HttpSecurity 的更大的一个概念
  4. 构建目标不同

    • WebSecurity 构建目标是整个 Spring Security 安全过滤器 FilterChainProxy
    • HttpSecurity 的构建目标仅仅是 FilterChainProxy 中的一个 SecurityFilterChain

      2.3.2 表单登录

      自定义登录页面的账号密码值(提交账号密码表单的参数名),以及一些策略的配置

      1. @Configuration
      2. public class SecurityConfig extends WebSecurityConfigurerAdapter {
      3. @Override
      4. protected void configure(AuthenticationManagerBuilder auth) throws Exception {
      5. super.configure(auth);
      6. }
      7. @Override
      8. public void configure(WebSecurity web) throws Exception {
      9. // 解决静态资源被拦截问题
      10. web.ignoring().antMatchers("/css/**","/images/**","/js/**");
      11. }
      12. /**
      13. * http请求方法
      14. * @param http
      15. * @throws Exception
      16. */
      17. @Override
      18. protected void configure(HttpSecurity http) throws Exception {
      19. /* 开启http basic认证 所有请求都需要认证
      20. http.httpBasic().and().authorizeRequests().anyRequest().authenticated();*/
      21. // 开启表单认证
      22. http.formLogin().loginPage("/toLoginPage")
      23. // 表单提交的路径
      24. .loginProcessingUrl("/login")
      25. // 定义表单的账号密码
      26. .usernameParameter("username")
      27. .passwordParameter("password")
      28. // 定义登录成功后跳转的页面
      29. .successForwardUrl("/")
      30. .and().authorizeRequests()
      31. // permitAll表示放行所有请求,antMatchers表示请求的路径
      32. .antMatchers("/toLoginPage").permitAll()
      33. // 所有请求都需要认证
      34. .anyRequest().authenticated();
      35. // 关闭csrf防护
      36. http.csrf().disable();
      37. // 加载同源下的iframe页面
      38. http.headers().frameOptions().sameOrigin();
      39. }
      40. }

      2.3.3 基于数据库实现认证

      非密码加密(明文)认证
      之前我们所使用的用户名和密码是来源于框架自动生成的, 那么我们如何实现基于数据库中的用户名和
      密码功能呢? 要实现这个得需要实现security的一个UserDetailsService接口, 重写这个接口里面
      loadUserByUsername即可

  5. 编写MyUserDetailServiceImpl去实现Security的UserDetailsService类。

    1. @Service
    2. public class MyUserDetailServiceImpl implements UserDetailsService {
    3. @Autowired
    4. UserService userService;
    5. /**
    6. * 根据用户名查询用户
    7. * @param username 前端传的账号名
    8. * @return
    9. * @throws UsernameNotFoundException
    10. */
    11. @Override
    12. public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    13. User user = userService.findByUsername(username);
    14. if (user == null) {
    15. return null;
    16. }
    17. // 权限集合
    18. Collection<? extends GrantedAuthority> authorities = new ArrayList<>();
    19. // noop表示不进行密码加密(明文认证)
    20. return new org.springframework.security.core.userdetails.User(
    21. username,"{noop}" + user.getPassword(),
    22. // 用户是否启用
    23. true,
    24. // 用户是否过期
    25. true,
    26. // 用户凭证是否过期
    27. true,
    28. // 用户是否锁定
    29. true,
    30. authorities);
    31. }
    32. }
  6. 在前面编写的配置类中修改安全控制的方法

    1. @Configuration
    2. public class SecurityConfig extends WebSecurityConfigurerAdapter {
    3. @Autowired
    4. MyUserDetailServiceImpl myUserDetailService;
    5. /**
    6. * 身份安全管理器
    7. * @param auth
    8. * @throws Exception
    9. */
    10. @Override
    11. protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    12. super.configure(auth);
    13. auth.userDetailsService(myUserDetailService);
    14. }
    15. }

密码加密认证
在基于数据库完成用户登录的过程中,我们所是使用的密码是明文的,规则是通过对密码明文添加
{noop} 前缀。
Spring Security 中 PasswordEncoder 就是我们对密码进行编码的工具接口。该接口只有两个功能:
一个是匹配验证。另一个是密码编码。看PasswordEncoder 接口的实现类可以看出security是支持很多算法实现的,比如md5,BCryptPasswordEncoder等。我们要使用的就是BCryptPasswordEncoder加密算法。

  • BCrypt算法介绍
    • 任何应用考虑到安全,绝不能明文的方式保存密码。密码应该通过哈希算法进行加密。有很 多标准的算法比如SHA或者MD5,结合salt(盐)是一个不错的选择。 Spring Security 提供了 BCryptPasswordEncoder类,实现Spring的PasswordEncoder接口使用BCrypt强哈希方法来加密 密码。BCrypt强哈希方法 每次加密的结果都不一样,所以更加的安全。
    • bcrypt算法相对来说是运算比较慢的算法,在密码学界有句常话:越慢的算法越安全。黑客 破解成本越高.通过salt和const这两个值来减缓加密过程,它的加密时间(百ms级)远远超过 md5(大概1ms左右)。对于计算机来说,Bcrypt 的计算速度很慢,但是对于用户来说,这个过 程不算慢。bcrypt是单向的,而且经过salt和cost的处理,使其受攻击破解的概率大大降低,同时 破解的难度也提升不少,相对于MD5等加密方式更加安全,而且使用也比较简单
    • bcrypt加密后的字符串形如:$2a$10$wouq9P/HNgvYj2jKtUN8rOJJNRVCWvn1XoWy55N3sCkEHZPo3lyWq
    • 其中$是分割符,无意义;2a是bcrypt加密版本号;10是const的值;而后的前22位是salt值;再 然后的字符串就是密码的密文了;这里的const值即生成salt的迭代次数,默认值是10,推荐值 12。
  • 之前我们在项目中密码使用的是明文的是 noop , 代表不加密使用明文密码, 现在用BCrypt只需要 将 noop 换成 bcrypt 即可,通过PasswordEncoderFactories密码器工厂可以找到我们加密需要修改的字符串。数据库中的密码可以用BCryptPasswordEncoder类来加密。

    1. @Override
    2. public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    3. User user = userService.findByUsername(username);
    4. if (user == null) {
    5. return null;
    6. }
    7. // 权限集合
    8. Collection<? extends GrantedAuthority> authorities = new ArrayList<>();
    9. // noop表示不进行密码加密(明文认证)
    10. return new org.springframework.security.core.userdetails.User(
    11. username,"{bcrypt}" + user.getPassword(),
    12. // 用户是否启用
    13. true,
    14. // 用户是否过期
    15. true,
    16. // 用户凭证是否过期
    17. true,
    18. // 用户是否锁定
    19. true,
    20. authorities);
    21. }
    1. public final class PasswordEncoderFactories {
    2. private PasswordEncoderFactories() {
    3. }
    4. public static PasswordEncoder createDelegatingPasswordEncoder() {
    5. String encodingId = "bcrypt";
    6. Map<String, PasswordEncoder> encoders = new HashMap();
    7. encoders.put(encodingId, new BCryptPasswordEncoder());
    8. encoders.put("ldap", new LdapShaPasswordEncoder());
    9. encoders.put("MD4", new Md4PasswordEncoder());
    10. encoders.put("MD5", new MessageDigestPasswordEncoder("MD5"));
    11. encoders.put("noop", NoOpPasswordEncoder.getInstance());
    12. encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
    13. encoders.put("scrypt", new SCryptPasswordEncoder());
    14. encoders.put("SHA-1", new MessageDigestPasswordEncoder("SHA-1"));
    15. encoders.put("SHA-256", new MessageDigestPasswordEncoder("SHA-256"));
    16. encoders.put("sha256", new StandardPasswordEncoder());
    17. encoders.put("argon2", new Argon2PasswordEncoder());
    18. return new DelegatingPasswordEncoder(encodingId, encoders);
    19. }
    20. }

2.3.4 获取当前登录用户

在传统web系统中, 我们将登录成功的用户放入session中, 在需要的时候可以从session中获取用户,
那么Spring Security中我们如何获取当前已经登录的用户呢?

  • SecurityContextHolder
    • 保留系统当前的安全上下文SecurityContext,其中就包括当前使用系统的用户的信息
  • SecurityContext
    • 安全上下文,获取当前经过身份验证的主体或身份验证请求令牌

代码如下:

  1. /**
  2. * 获取当前登录对象
  3. * @return UserDetails
  4. */
  5. @GetMapping("/loginUser")
  6. @ResponseBody
  7. public UserDetails getCurrentUser() {
  8. UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
  9. return userDetails;
  10. }
  11. @GetMapping("/loginUser2")
  12. @ResponseBody
  13. public UserDetails getCurrentUser2(Authentication authentication) {
  14. UserDetails userDetails = (UserDetails) authentication.getPrincipal();
  15. return userDetails;
  16. }
  17. @GetMapping("/loginUser3")
  18. @ResponseBody
  19. public UserDetails getCurrentUser3(@AuthenticationPrincipal UserDetails userDetails) {
  20. return userDetails;
  21. }

返回值:{“password”:null,”username”:”admin”,”authorities”:[],”accountNonExpired”:true,”accountNonLocked”:true,”credentialsNonExpired”:true,”enabled”:true}

2.3.5 remember me记住我

在大多数网站中,都会实现RememberMe这个功能,方便用户在下一次登录时直接登录,避免再
次输入用户名以及密码去登录,Spring Security针对这个功能已经帮助我们实现

  1. 简单的Token生成方法

1.jpg
Token=MD5(username+分隔符+expiryTime+分隔符+password)
注意: 这种方式不推荐使用, 有严重的安全问题. 就是密码信息在前端浏览器cookie中存放. 如果cookie
被盗取很容易破解.
实现:

  1. 前端页面需要增加remember-me复选框,值为true。
  2. 后端编写security的配置文件,增加了remember-me的配置

    1. /**
    2. * http请求方法
    3. * @param http
    4. * @throws Exception
    5. */
    6. @Override
    7. protected void configure(HttpSecurity http) throws Exception {
    8. /* 开启http basic认证 所有请求都需要认证
    9. http.httpBasic().and().authorizeRequests().anyRequest().authenticated();*/
    10. // 开启表单认证
    11. http.formLogin().loginPage("/toLoginPage")
    12. // 表单提交的路径
    13. .loginProcessingUrl("/login")
    14. // 定义表单的账号密码
    15. .usernameParameter("username")
    16. .passwordParameter("password")
    17. // 定义登录成功后跳转的页面
    18. .successForwardUrl("/")
    19. // 开启记住我功能
    20. .and().rememberMe()
    21. // token失效时间,默认2周
    22. .tokenValiditySeconds(3600*24*14)
    23. // 自定义表单的input值
    24. .rememberMeParameter("remember-me")
    25. .and().authorizeRequests()
    26. // permitAll表示放行所有请求,antMatchers表示请求的路径
    27. .antMatchers("/toLoginPage").permitAll()
    28. // 所有请求都需要认证
    29. .anyRequest().authenticated();
    30. // 关闭csrf防护
    31. http.csrf().disable();
    32. // 加载同源下的iframe页面
    33. http.headers().frameOptions().sameOrigin();
    34. }


  3. 持久化的token生成方法

2.jpg
存入数据库Token包含:

  • token: 随机生成策略,每次访问都会重新生成
  • series: 登录序列号,随机生成策略。用户输入用户名和密码登录时,该值重新生成。使用 remember-me功能,该值保持不变
  • expiryTime: token过期时间。

CookieValue=encode(series+token)
代码实现:在configure配置中加入.tokenRepository(),然后自定义一个PersistentTokenRepository

  1. @Override
  2. protected void configure(HttpSecurity http) throws Exception {
  3. /* 开启http basic认证 所有请求都需要认证
  4. http.httpBasic().and().authorizeRequests().anyRequest().authenticated();*/
  5. // 开启表单认证
  6. http.formLogin().loginPage("/toLoginPage")
  7. // 表单提交的路径
  8. .loginProcessingUrl("/login")
  9. // 定义表单的账号密码
  10. .usernameParameter("username")
  11. .passwordParameter("password")
  12. // 定义登录成功后跳转的页面
  13. .successForwardUrl("/")
  14. // 开启记住我功能
  15. .and().rememberMe()
  16. // token失效时间,默认2周
  17. .tokenValiditySeconds(3600*24*14)
  18. // 自定义表单的input值
  19. .rememberMeParameter("remember-me")
  20. // 设置remember-me的token生成
  21. .tokenRepository(getPersistentTokenRepository())
  22. .and().authorizeRequests()
  23. // permitAll表示放行所有请求,antMatchers表示请求的路径
  24. .antMatchers("/toLoginPage").permitAll()
  25. // 所有请求都需要认证
  26. .anyRequest().authenticated();
  27. // 关闭csrf防护
  28. http.csrf().disable();
  29. // 加载同源下的iframe页面
  30. http.headers().frameOptions().sameOrigin();
  31. }
  32. @Autowired
  33. DataSource dataSource;
  34. @Bean
  35. public PersistentTokenRepository getPersistentTokenRepository() {
  36. JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
  37. // 注入数据源
  38. jdbcTokenRepository.setDataSource(dataSource);
  39. // 项目启动时自动帮我们创建一张表,第一次启动设置为true,后续设置为false或者注释掉
  40. jdbcTokenRepository.setCreateTableOnStartup(true);
  41. return jdbcTokenRepository;
  42. }

项目启动成功后会自动创建一张表来存放token的值。

Cookie窃取伪造

  1. 先使用网页登录,记录remember-me的token值。
  2. 使用posment伪造cookie,把cookie的值改为跟网页一样。
  3. 这样请求需要登录接口的时候只要带上这个cookie就能访问需要登录才能访问的接口了。

解决:可以在重要接口加安全认证,代码如下:

  1. @GetMapping("/{id}")
  2. @ResponseBody
  3. public User getById(@PathVariable Integer id) {
  4. Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
  5. // 如果为true表明这个登录认证的信息来源于自动登录
  6. if (RememberMeAuthenticationToken.class.isAssignableFrom(authentication.getClass())) {
  7. throw new RememberMeAuthenticationException("认证来源于remember-me");
  8. }
  9. User user = userService.getById(id);
  10. return user;
  11. }

2.3.6 自定义登录成功处理和失败处理

在某些场景下,用户登录成功或失败的情况下用户需要执行一些后续操作,比如登录日志的搜集, 或者在现在目前前后端分离的情况下用户登录成功和失败后需要给前台页面返回对应的错误信息, 由前台主导登录成功或者失败的页面跳转. 这个时候需要要到用到AuthenticationSuccessHandler与 AnthenticationFailureHandler

  1. 自定义成功的处理:实现AuthenticationSuccessHandler接口并重写onAnthenticationSuccesss()方法
  2. 自定义失败的处理:实现AnthenticationFailureHandler接口并重写onAuthenticationFailure()方法

代码实现

  1. 创建MyAuthenticationServiceImpl类来实现AuthenticationSuccessHandler接口和AnthenticationFailureHandler接口。

    1. @Service
    2. public class MyAuthenticationServiceImpl implements AuthenticationSuccessHandler, AuthenticationFailureHandler {
    3. RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
    4. /**
    5. * 登录成功的处理逻辑
    6. * @param request
    7. * @param response
    8. * @param authentication
    9. */
    10. @Override
    11. public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
    12. System.out.println("登录成功处理....");
    13. // 重定向到首页
    14. redirectStrategy.sendRedirect(request,response,"/");
    15. }
    16. /**
    17. * 登录失败的处理逻辑
    18. * @param request
    19. * @param response
    20. * @param exception
    21. */
    22. @Override
    23. public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {
    24. System.out.println("登录失败处理....");
    25. redirectStrategy.sendRedirect(request,response,"/toLoginPage");
    26. }
    27. }
  2. 在security配置文件中加入successHandler和failureHandler的配置 ```java @Autowired MyAuthenticationServiceImpl myAuthenticationService;

@Override protected void configure(HttpSecurity http) throws Exception { / 开启http basic认证 所有请求都需要认证 http.httpBasic().and().authorizeRequests().anyRequest().authenticated();/ // 开启表单认证 http.formLogin().loginPage(“/toLoginPage”) // 表单提交的路径 .loginProcessingUrl(“/login”) // 定义表单的账号密码 .usernameParameter(“username”) .passwordParameter(“password”) // 定义登录成功后跳转的页面 .successForwardUrl(“/“) // 自定义登录成功或者失败处理 .successHandler(myAuthenticationService) .failureHandler(myAuthenticationService) // 开启记住我功能 .and().rememberMe() // token失效时间,默认2周 .tokenValiditySeconds(36002414) // 自定义表单的input值 .rememberMeParameter(“remember-me”) // 设置remember-me的token生成 .tokenRepository(getPersistentTokenRepository()) .and().authorizeRequests() // permitAll表示放行所有请求,antMatchers表示请求的路径 .antMatchers(“/toLoginPage”).permitAll() // 所有请求都需要认证 .anyRequest().authenticated(); // 关闭csrf防护 http.csrf().disable(); // 加载同源下的iframe页面 http.headers().frameOptions().sameOrigin(); }

  1. <a name="Xahld"></a>
  2. ## 2.3.7 异步登录(前后端分离)
  3. 改造前面的代码,实现异步登录,模拟前后端分离,前端请求/login接口,后端返回成功或者失败的信息,前端来判断是否成功来跳转页面。
  4. 1. 前端代码
  5. ```javascript
  6. function login() {
  7. $.ajax({
  8. type: "POST",
  9. dataType: "json",
  10. url: "/login",
  11. data: $("#formLogin").serialize(),
  12. success: function (data) {
  13. if (data.code == 200) {
  14. window.location.href="/";
  15. } else {
  16. alert(data.message);
  17. }
  18. }
  19. })
  20. }
  1. 后端代码

    1. @Service
    2. public class MyAuthenticationServiceImpl implements AuthenticationSuccessHandler, AuthenticationFailureHandler {
    3. @Autowired
    4. ObjectMapper objectMapper;
    5. /**
    6. * 登录成功的处理逻辑
    7. * @param request
    8. * @param response
    9. * @param authentication
    10. */
    11. @Override
    12. public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
    13. System.out.println("登录成功处理....");
    14. // 封装成功信息
    15. Map<String, Object> result = new HashMap<>(8);
    16. result.put("code", HttpStatus.OK.value());
    17. result.put("message","登录成功");
    18. response.setContentType("application/json;charset=UTF-8");
    19. response.getWriter().write(objectMapper.writeValueAsString(result));
    20. }
    21. /**
    22. * 登录失败的处理逻辑
    23. * @param request
    24. * @param response
    25. * @param exception
    26. */
    27. @Override
    28. public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {
    29. System.out.println("登录失败处理....");
    30. Map<String, Object> result = new HashMap<>(8);
    31. result.put("code", HttpStatus.UNAUTHORIZED.value());
    32. result.put("message","登录失败");
    33. response.setContentType("application/json;charset=UTF-8");
    34. response.getWriter().write(objectMapper.writeValueAsString(result));
    35. }
    36. }

    2.3.8 退出登录

  2. security的退出登录默认请求url是”/logout”,处理逻辑是转发到登录页面。

  3. 自定义退出逻辑与请求url(前端只需要请求该url即可)

    1. @Service
    2. public class MyAuthenticationServiceImpl implements AuthenticationSuccessHandler, AuthenticationFailureHandler,
    3. LogoutSuccessHandler {
    4. // 实现LogoutSuccessHandler接口并实现该方法
    5. @Override
    6. public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
    7. System.out.println("退出登录成功...");
    8. redirectStrategy.sendRedirect(request,response,"/toLoginPage");
    9. }
    10. }

    配置logout的请求url与成功处理器

    1. @Override
    2. protected void configure(HttpSecurity http) throws Exception {
    3. /* 开启http basic认证 所有请求都需要认证
    4. http.httpBasic().and().authorizeRequests().anyRequest().authenticated();*/
    5. // 开启表单认证
    6. http.formLogin().loginPage("/toLoginPage")
    7. // 表单提交的路径
    8. .loginProcessingUrl("/login")
    9. // 定义表单的账号密码
    10. .usernameParameter("username")
    11. .passwordParameter("password")
    12. // 定义登录成功后跳转的页面
    13. .successForwardUrl("/")
    14. // 自定义登录成功或者失败处理
    15. .successHandler(myAuthenticationService)
    16. .failureHandler(myAuthenticationService)
    17. // 自定义退出登录成功的处理
    18. .and().logout().logoutUrl("/logout")
    19. .logoutSuccessHandler(myAuthenticationService)
    20. // 开启记住我功能
    21. .and().rememberMe()
    22. // token失效时间,默认2周
    23. .tokenValiditySeconds(3600*24*14)
    24. // 自定义表单的input值
    25. .rememberMeParameter("remember-me")
    26. // 设置remember-me的token生成
    27. .tokenRepository(getPersistentTokenRepository())
    28. .and().authorizeRequests()
    29. // permitAll表示放行所有请求,antMatchers表示请求的路径
    30. .antMatchers("/toLoginPage").permitAll()
    31. // 所有请求都需要认证
    32. .anyRequest().authenticated();
    33. // 关闭csrf防护
    34. http.csrf().disable();
    35. // 加载同源下的iframe页面
    36. http.headers().frameOptions().sameOrigin();
    37. }

2.4 随机验证图形

图形验证码一般是防止恶意请求,人眼看起来都费劲,何况是机器。不少网站为了防止用户利用机器人自动注册、登录、灌水,都采用了验证码技术。所谓验证码,就是将一串随机产生的数字或符号,生成
一幅图片, 图片里加上一些干扰, 也有目前需要手动滑动的图形验证码. 这种可以有专门去做的第三方平台. 比如极验(https://www.geetest.com/)。
spring security添加验证码大致可以分为三个步骤:

  1. 根据随机数生成验证码图片
  2. 将验证码图片显示到登录页面
  3. 认证流程中加入验证码校验

生成验证码图片的代码:

  1. @RestController
  2. @RequestMapping("/code")
  3. public class ValidateCodeController {
  4. public final static String REDIS_KEY_IMAGE_CODE = "REDIS_KEY_IMAGE_CODE";
  5. public final static int expireIn = 60; // 验证码有效时间 60s
  6. //使用sessionStrategy将生成的验证码对象存储到Session中,并通过IO流将生成的图片输出到登录页面上。
  7. @Autowired
  8. public StringRedisTemplate stringRedisTemplate;
  9. @RequestMapping("/image")
  10. public void createCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
  11. //获取访问IP
  12. String remoteAddr = request.getRemoteAddr();
  13. //生成验证码对象
  14. ImageCode imageCode = createImageCode();
  15. //生成的验证码对象存储到redis中 KEY为REDIS_KEY_IMAGE_CODE+IP地址
  16. stringRedisTemplate.boundValueOps(REDIS_KEY_IMAGE_CODE + "-" + remoteAddr)
  17. .set(imageCode.getCode(), expireIn, TimeUnit.SECONDS);
  18. //通过IO流将生成的图片输出到登录页面上
  19. ImageIO.write(imageCode.getImage(), "jpeg", response.getOutputStream());
  20. }
  21. /**
  22. * 用于生成验证码对象
  23. *
  24. * @return
  25. */
  26. private ImageCode createImageCode() {
  27. int width = 100; // 验证码图片宽度
  28. int height = 36; // 验证码图片长度
  29. int length = 4; // 验证码位数
  30. //创建一个带缓冲区图像对象
  31. BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
  32. //获得在图像上绘图的Graphics对象
  33. Graphics g = image.getGraphics();
  34. Random random = new Random();
  35. //设置颜色、并随机绘制直线
  36. g.setColor(getRandColor(200, 250));
  37. g.fillRect(0, 0, width, height);
  38. g.setFont(new Font("Times New Roman", Font.ITALIC, 20));
  39. g.setColor(getRandColor(160, 200));
  40. for (int i = 0; i < 155; i++) {
  41. int x = random.nextInt(width);
  42. int y = random.nextInt(height);
  43. int xl = random.nextInt(12);
  44. int yl = random.nextInt(12);
  45. g.drawLine(x, y, x + xl, y + yl);
  46. }
  47. //生成随机数 并绘制
  48. StringBuilder sRand = new StringBuilder();
  49. for (int i = 0; i < length; i++) {
  50. String rand = String.valueOf(random.nextInt(10));
  51. sRand.append(rand);
  52. g.setColor(new Color(20 + random.nextInt(110), 20 + random.nextInt(110), 20 + random.nextInt(110)));
  53. g.drawString(rand, 13 * i + 6, 16);
  54. }
  55. g.dispose();
  56. return new ImageCode(image, sRand.toString());
  57. }
  58. /**
  59. * 获取随机演示
  60. *
  61. * @param fc
  62. * @param bc
  63. * @return
  64. */
  65. private Color getRandColor(int fc, int bc) {
  66. Random random = new Random();
  67. if (fc > 255) {
  68. fc = 255;
  69. }
  70. if (bc > 255) {
  71. bc = 255;
  72. }
  73. int r = fc + random.nextInt(bc - fc);
  74. int g = fc + random.nextInt(bc - fc);
  75. int b = fc + random.nextInt(bc - fc);
  76. return new Color(r, g, b);
  77. }
  78. }

Spring Security的认证校验是由UsernamePasswordAuthenticationFilter过滤器完成的,所以我们
的验证码校验逻辑应该在这个过滤器之前。验证码通过后才能到后续的操作

解决方法:自定义一个ValidateFilter类继承OncePerRequestFilter(一次请求只会经过一次过滤器),重写该类方法来编写验证码验证的逻辑,然后在security的配置类中编写http.addFilterBefore()设置自定义的过滤器在账号密码过滤器UsernamePasswordAuthenticationFilter的前面执行。

  1. @Component
  2. public class ValidateFilter extends OncePerRequestFilter {
  3. @Autowired
  4. StringRedisTemplate stringRedisTemplate;
  5. @Autowired
  6. MyAuthenticationServiceImpl myAuthenticationService;
  7. @Override
  8. protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
  9. // 判断是否是登录请求,如果不是则直接放行
  10. if ("/login".equals(request.getRequestURI())
  11. && request.getMethod().equalsIgnoreCase("POST")) {
  12. String imageCode = request.getParameter("imageCode");
  13. try {
  14. validate(request,imageCode);
  15. } catch (AuthenticationException e) {
  16. myAuthenticationService.onAuthenticationFailure(request,response,e);
  17. return;
  18. }
  19. }
  20. filterChain.doFilter(request,response);
  21. }
  22. private void validate(HttpServletRequest request, String imageCode) throws AuthenticationException {
  23. String redisKey = "REDIS_KEY_IMAGE_CODE" + "-" + request.getRemoteAddr();
  24. String code = stringRedisTemplate.boundValueOps(redisKey).get();
  25. if (!StringUtils.hasText(imageCode)) {
  26. throw new ValidateException("验证码不能为空");
  27. }
  28. if (!StringUtils.hasText(code)) {
  29. throw new ValidateException("验证码已过期");
  30. }
  31. if (!imageCode.equals(code)) {
  32. throw new ValidateException("验证码输入错误");
  33. }
  34. // 从redis中删除验证码
  35. stringRedisTemplate.delete(redisKey);
  36. }
  37. }
  1. @Override
  2. protected void configure(HttpSecurity http) throws Exception {
  3. // 把我们自定义的validateFilter加在UsernamePasswordAuthenticationFilter前面
  4. http.addFilterBefore(validateFilter, UsernamePasswordAuthenticationFilter.class);
  5. }

2.5 Session管理

2.5.1 会话超时

  1. 配置session会话超时时间,默认为30分钟,但是Spring Boot中的会话超时时间至少为60秒

    1. #session设置 #配置session超时时间
    2. server.servlet.session.timeout=60
  2. 自定义设置session超时后地址

    1. http.sessionManagement()//设置session管理
    2. .invalidSessionUrl("/toLoginPage")// session无效后跳转的路径, 默认是登录页面

    2.5.2 并发控制

    并发控制即同一个账号同时在线个数,同一个账号同时在线个数如果设置为1表示,该账号在同一时
    间内只能有一个有效的登录,如果同一个账号又在其它地方登录,那么就将上次登录的会话过期,即后
    面的登录会踢掉前面的登录

  3. 修改超时时间

    1. #session设置 #配置session超时时间
    2. server.servlet.session.timeout=600
  4. 设置最大会话数量

    1. http.sessionManagement().//设置session管理
    2. invalidSessionUrl("/toLoginPage") // session无效后跳转的路径, 默认是登录页面
    3. .maximumSessions(1) //设置session最大会话数量 ,1同一时间只能有一个 用户登录
    4. .expiredUrl("/toLoginPage");//设置session过期后跳转路径
  5. 阻止用户第二次登录: sessionManagement也可以配置 maxSessionsPreventsLogin:boolean值,当达到 maximumSessions设置的最大会话个数时阻止登录。

    1. http.sessionManagement() // 设置session管理
    2. .invalidSessionUrl("/toLoginPage") // session无效后跳转的路径, 默 认是登录页面
    3. .maximumSessions(1) // 设置session最大会话数量 ,1同一时间只能有一个 用户登录
    4. .maxSessionsPreventsLogin(true) // 当达到最大会话个数时阻止登录。
    5. .expiredUrl("/toLoginPage");// 设置session过期后跳转路径

    2.5.3 集群session

    实际场景中一个服务会至少有两台服务器在提供服务,在服务器前面会有一个nginx做负载均衡,
    用户访问nginx,nginx再决定去访问哪一台服务器。当一台服务宕机了之后,另一台服务器也可以继续
    提供服务,保证服务不中断。如果我们将session保存在Web容器(比如tomcat)中,如果一个用户第一
    次访问被分配到服务器1上面需要登录,当某些访问突然被分配到服务器二上,因为服务器二上没有用
    户在服务器一上登录的会话session信息,服务器二还会再次让用户登录,用户已经登录了还让登录就
    感觉不正常了。
    1651809059(1).jpg
    解决这个问题的思路是用户登录的会话信息不能再保存到Web服务器中,而是保存到一个单独的库
    (redis、mongodb、jdbc等)中,所有服务器都访问同一个库,都从同一个库来获取用户的session信
    息,如用户在服务器一上登录,将会话信息保存到库中,用户的下次请求被分配到服务器二,服务器二
    从库中检查session是否已经存在,如果存在就不用再登录了,可以直接访问服务了。
    1651809092(1).jpg

  6. 引入依赖

    1. <!-- 基于redis实现session共享 -->
    2. <dependency>
    3. <groupId>org.springframework.session</groupId>
    4. <artifactId>spring-session-data-redis</artifactId>
    5. </dependency
  7. 设置session存储类型

    1. #使用redis共享session
    2. spring.session.store-type=redis

2.6 csrf防护机制

CSRF(Cross-site request forgery),中文名称:跨站请求伪造
可以这么理解CSRF攻击:攻击者盗用了你的身份,以你的名义发送恶意请求。CSRF能够做的
事情包括:以你名义发送邮件,发消息,盗取你的账号,甚至于购买商品,虚拟货币转账……造成的问
题包括:个人隐私泄露以及财产安全。
CSRF这种攻击方式在2000年已经被国外的安全人员提出,但在国内,直到06年才开始被关注,08
年,国内外的多个大型社区和交互网站分别爆出CSRF漏洞,如:NYTimes.com(纽约时报)、
Metafifilter(一个大型的BLOG网站),YouTube和百度HI……而现在,互联网上的许多站点仍对此毫无
防备,以至于安全业界称CSRF为“沉睡的巨人”。
csrf原理.jpg
从上图可以看出,要完成一次CSRF攻击,受害者必须依次完成三个步骤:

  1. 登录受信任的网站A,并在本地生成cookie
  2. 在不登出A的情况下,登录危险网站B
  3. 触发网站B的一些元素


    Csrf的防御策略
    在业界目前防御 CSRF 攻击主要有三种策略:

  • 验证 HTTP Referer 字段
  • 在请求地址中添加 token 并验证
  • 在 HTTP 头中自定义属性并验证
  1. 验证 HTTP Referer 字段

根据 HTTP 协议,在 HTTP 头中有一个字段叫 Referer,它记录了该 HTTP 请求的来源地址。
在通常情况下,访问一个安全受限页面的请求来自于同一个网站,在后台请求验证其 Referer 值,
如果是以自身安全网站开头的域名,则说明该请求是是合法的。如果 Referer 是其他网站的话,则
有可能是黑客的 CSRF 攻击,拒绝该请求。

  1. 在请求地址中添加 token 并验证

    CSRF 攻击之所以能够成功,是因为黑客可以完全伪造用户的请求,该请求中所有的用户验证
    信息都是存在于 cookie 中,因此黑客可以在不知道这些验证信息的情况下直接利用用户自己的
    cookie 来通过安全验证。要抵御 CSRF,关键在于在请求中放入黑客所不能伪造的信息,并且该
    信息不存在于 cookie 之中。可以在 HTTP 请求中以参数的形式加入一个随机产生的 token,并在
    服务器端建立一个拦截器来验证这个 token,如果请求中没有 token 或者 token 内容不正确,则
    认为可能是 CSRF 攻击而拒绝该请求。

  2. 在 HTTP 头中自定义属性并验证

这种方法也是使用 token 并进行验证,和上一种方法不同的是,这里并不是把 token 以参数
的形式置于 HTTP 请求之中,而是把它放到 HTTP 头中自定义的属性里。

security中的csrf防御机制
org.springframework.security.web.csrf.CsrfFilter

csrf又称跨站请求伪造,SpringSecurity会对所有post请求验证是否包含系统生成的csrf的token 信息,如果不包含,则报错。起到防止csrf攻击的效果。(1. 生成token 2.验证token)

  1. 开启csrf防护

    1. //开启csrf防护, 可以设置哪些不需要防护
    2. http.csrf().ignoringAntMatchers("/user/save");
  2. 页面需要添加token值

    1. <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>

    2.7 跨域与CORS

    跨域
    跨域,实质上是浏览器的一种保护处理。如果产生了跨域,服务器在返回结果时就会被浏览器拦截
    (注意:此时请求是可以正常发起的,只是浏览器对其进行了拦截),导致响应的内容不可用. 产生跨域的
    几种情况有一下:

当前页面url 被请求页面url 是否跨域 原因
http://www.bcd.com http://www.bcd.com/index 同源(协议, 域名, 端口号
相同)
http://www.bcd.com https://www.bcd.com 协议不同(http,https)
http://www.bcd.com http://www.efg.com 主域名不同
http://www.bcd,com http://mmm.bcd.com 子域名不同

解决跨域

  1. JSONP:浏览器允许一些带src属性的标签跨域,也就是在某些标签的src属性上写url地址是不会产生跨域问题
  2. CORS解决跨域:CORS是一个W3C标准,全称是”跨域资源共享”(Cross-origin resource sharing)。CORS需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,IE浏览器不能低于IE10。浏览器在发 起真正的请求之前,会发起一个OPTIONS类型的预检请求,用于请求服务器是否允许跨域,在得到许可的情况下才会发起请求

基于Spring Security的CORS跨域支持
先声明跨域配置源再开启跨域支持

  1. public CorsConfigurationSource corsConfigurationSource() {
  2. CorsConfiguration corsConfiguration = new CorsConfiguration();
  3. // 允许跨域的站点
  4. corsConfiguration.addAllowedOrigin("*");
  5. // 允许跨域的方法
  6. corsConfiguration.addAllowedMethod("*");
  7. // 允许跨域的请求头
  8. corsConfiguration.addAllowedHeader("*");
  9. // 允许带凭证
  10. corsConfiguration.setAllowCredentials(true);
  11. UrlBasedCorsConfigurationSource urlBasedCorsConfigurationSource = new UrlBasedCorsConfigurationSource();
  12. // 对所有请求生效
  13. urlBasedCorsConfigurationSource.registerCorsConfiguration("/**",corsConfiguration);
  14. return urlBasedCorsConfigurationSource;
  15. }
  16. @Override
  17. protected void configure(HttpSecurity http) throws Exception {
  18. // 开启跨域支持
  19. http.cors().configurationSource(corsConfigurationSource());
  20. }