当前位置: 首页 > article >正文

Spring Security使用

文章目录

      • Spring Security的起点
      • FilterChain重写
      • 重写登录验证逻辑
      • 增加CSRF Token

Spring Security的起点

  1. 在AbstractApplicationContext.refresh()方法时,子类ServletWebServerApplicationContext会创建一个ServletContextInitializerBeans这个Bean对象
  2. ServletContextInitializerBeans在执行addServletContextInitializerBeans()时会使用BeanFactory去查找ServletContextInitializer类型的Bean,这时会找到DelegatingFilterProxyRegistrationBean这个bean对象
  3. DelegatingFilterProxyRegistrationBean会往ServletContext注册DelegatingFilterProxy对象,对象包含了filter的默认名称springSecurityFilterChain,可以看成这个DelegatingFilterProxy对象就是一个Filter,它又包含了filterChain,相当于又做了一层包装
  4. DelegatingFilterProxy使用filter的名字(springSecurityFilterChain)在BeanFactory中查找该filter对象
  5. 后续请求来时,会先进入DelegatingFilterProxy这个外层的Tomcat Filter,然后它再把请求传给springSecurityFilterChain这个内部的Filter Chain进行处理
  6. 默认的springSecurityFilterChain对象定义在org.springframework.boot.autoconfigure.security.servlet.SpringBootWebSecurityConfiguration.SecurityFilterChainConfiguration#defaultSecurityFilterChain
  7. 我们一般要重写这个filterChain的定义

过滤链的流程大致如下:

DelegatingFilterProxy
FilterChain
SecurityFilter1
SecurityFilter0
SecurityFilter2
...
SecurityFilterN
TomcatFilter0
TomcatFilter1
TomcatFilter2
Servlet

FilterChain重写

一般来说,filterChain是我们需要重写的,我们的应用是无法直接使用默认filter的配置

  1. Configuration类需要添加@EnableWebSecurity,才会有HttpSecurity对象。
  2. 重写主要是对HttpSecurity对象进行设置,FilterChain里的Filter配置,来自于HttpSecurity中的各个Configurer,例如CsrfConfigurer用于创建CsrfFilter,FormLoginConfigurer用于创建UsernamePasswordAuthenticationFilter,一般都是在Configurer.configure()方法中创建并添加各种filter的。
  3. HttpSecurity中的formLogin(Customizer)或者是csrf(Customizer)方法,Customizer是一个函数式表达式,提供让我们对这些Configurer进行自定义,就是HttpSecurity会自己创建好各种Configurer,但是还提供了方法让我们去修改这些Configurer的配置,如果不需要进行修改,就传入Customizer.withDefaults()就可以了,它默认直接返回configurer,不做修改。
  4. 如下图,formLogin(formLoginConfigurer->{})可以对formLoginConfigurer的配置进行修改,里面我就修改了loginProcessingUrl,这样在请求/user/login时,它创建的UsernamePasswordAuthenticationFilter会判断,如果当前请求的路径跟该路径匹配,就会走验证用户名密码的逻辑,它的默认路径是/login,我的应用程序的登录接口是/user/login,两者不匹配是不会走用户名密码验证逻辑的。
  5. 同时formLogin修改了usernameParameter跟passwordParameter,UsernamePasswordAuthenticationFilter会从请求中使用这两个名称从request中获取用户名跟密码去进行校验,默认值是"username"跟"password",如果请求参数跟这两个值不匹配,那么获取到的用户名密码就是null,后面的验证逻辑就走不下去了,这些默认值跟程序不匹配的地方都需要修改
  6. 还有csrf,这里为了方便测试接口,不让CsrfFilter因为我没有token拦截我的请求,我修改让它对所有请求都忽略。
  7. HttpSecurity中有很多配置Configurer类的方法,可以根据需要自行修改,这里只是举例修改了其中两个,还有Configurer可以注册一个到多个Filter,Configurer跟Filter不仅仅是一对一的关系,各个Filter的功能都不一样,修改的配置也不一样,需要自己测试修改适配自己的程序,没有什么好的捷径
@Configuration
@EnableWebSecurity
public class SecurityConfiguration {

    @Bean
    @Order(SecurityProperties.BASIC_AUTH_ORDER)
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests((requests) -> requests.anyRequest().authenticated())
                .formLogin(c->{
                    c.loginProcessingUrl("/user/login");
                    c.usernameParameter("userName");
                    c.passwordParameter("password");
                })
                .httpBasic(withDefaults())
                .csrf((t) -> {
                    t.ignoringRequestMatchers("/**");
                });
        return http.build();
    }
}

重写登录验证逻辑

请求中的用户名/密码
数据库查询到的用户对应的密码
校验结果
UsernamePasswordAuthenticationFilter
UserDetailsService
PasswordEncoder
End

以上是登录校验逻辑走的流程

  1. 由于我的应用程序是Resuful接口,传的是json,所以在filter中无法通过request.getParameter()获取参数,只能通过request.getInputStream(),再转为Map获取其中的json参数,所以需要创建一个新类,继承UsernamePasswordAuthenticationFilter ,重写其obtainPassword()跟obtainUsername(),如果不是Restful接口,传的是form数据,可以通过request.getParameter()获取数据,则不需要重写
    class MyUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

        private ObjectMapper objectMapper = new ObjectMapper();

        @Override
        protected String obtainPassword(HttpServletRequest request) {
            try {
                Map requestMap = objectMapper.readValue(request.getInputStream(), Map.class);
                Object o = requestMap.get(getPasswordParameter());
                if (o != null) {
                    return String.valueOf(o);
                } else {
                    return "";
                }
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }

        @Override
        protected String obtainUsername(HttpServletRequest request) {
            try {
                Map requestMap = objectMapper.readValue(request.getInputStream(), Map.class);
                Object o = requestMap.get(getUsernameParameter());
                if (o != null) {
                    return String.valueOf(o);
                } else {
                    return "";
                }
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
    }
  1. 上面这样做,又会引出一个问题,获取到username后,inputStream就关闭了,无法再通过getInputStream()获取到password,甚至后续流程request无法通过InputStream获取到数据,导致Controller层无法转换@RequestBody,所以还需要对request进行改造,做一层封装,报request中的数据保存早一个byte[]中,后续可以重复读取
    class RequestBodyCopyServletRequestWrapper extends HttpServletRequestWrapper {

        private byte[] copyBody = null;
        
        public RequestBodyCopyServletRequestWrapper(HttpServletRequest request) {
            super(request);
            try {
                copyBody = StreamUtils.copyToByteArray(request.getInputStream());
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }

        @Override
        public ServletInputStream getInputStream() throws IOException {
            ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(copyBody);
            return new ServletInputStream() {

                @Override
                public int read() throws IOException {
                    return byteArrayInputStream.read();
                }

                @Override
                public boolean isFinished() {
                    return false;
                }

                @Override
                public boolean isReady() {
                    return false;
                }

                @Override
                public void setReadListener(ReadListener listener) {

                }
            };
        }

        @Override
        public BufferedReader getReader() throws IOException {
            return new BufferedReader(new InputStreamReader(getInputStream()));
        }
    }
  1. MyUsernamePasswordAuthenticationFilter 使用上面创建的RequestBodyCopyServletRequestWrapper 包装一下request对象
    class MyUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

        private ObjectMapper objectMapper = new ObjectMapper();

        @Override
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
            ServletRequestWrapper requestWrapper = null;
            if (request instanceof HttpServletRequest) {
                requestWrapper = new RequestBodyCopyServletRequestWrapper((HttpServletRequest) request);
                super.doFilter(requestWrapper, response, chain);
            } else {
                super.doFilter(request, response, chain);
            }
        }
    }
  1. HttpSecurity配置MyUsernamePasswordAuthenticationFilter替换原来的UsernamePasswordAuthenticationFilter,设置路径,跟请求参数,最后在http.build()初始化后,再从httpSecurity对象获取AuthenticationManager.class设置到filter中,它使用DaoAuthenticationProvider用来执行数据库的用户名密码的校验流程:
    @Bean
    @Order(SecurityProperties.BASIC_AUTH_ORDER)
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        MyUsernamePasswordAuthenticationFilter myUsernamePasswordAuthenticationFilter = new MyUsernamePasswordAuthenticationFilter();
        myUsernamePasswordAuthenticationFilter.setFilterProcessesUrl("/user/login");
        myUsernamePasswordAuthenticationFilter.setUsernameParameter("userName");
        myUsernamePasswordAuthenticationFilter.setPasswordParameter("password");

        http.authorizeHttpRequests((requests) -> requests.anyRequest().authenticated())
                .formLogin(withDefaults())
                .addFilterAt(myUsernamePasswordAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                .csrf((t) -> {
                    t.ignoringRequestMatchers("/**");
                });
        DefaultSecurityFilterChain securityFilterChain = http.build();
        myUsernamePasswordAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
        return securityFilterChain;
    }
  1. 重写UserDetailsService,从我们的数据库中获取到User信息,同时需要对我们的User类跟Role类进行改造,分别实现UserDetails跟GrantedAuthority,返回用户跟角色的关联信息
    @Bean
    public UserDetailsService userDetailsService(ITUserService userService) {
        UserDetailsService userDetailsService = new MyUserDetailsService(userService);
        return userDetailsService;
    }

    class MyUserDetailsService implements UserDetailsService {

        private ITUserService userService;

        public MyUserDetailsService(ITUserService userService) {
            this.userService = userService;
        }

        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            return userService.loadUserByUsername(username);
        }
    }
@Getter
@Setter
@TableName("t_user")
public class TUser implements Serializable, UserDetails {

    private static final long serialVersionUID = 1L;

    @TableId(value = "user_id", type = IdType.AUTO)
    private Integer userId;

    private String userName;

    private String password;

    private LocalDateTime createTime;

    private Short status;

    @TableField(exist = false)
    private List<TRole> roles;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return roles;
    }

    @Override
    public String getUsername() {
        return userName;
    }
}

@Getter
@Setter
@TableName("t_role")
public class TRole implements Serializable, GrantedAuthority {

    private static final long serialVersionUID = 1L;

    @TableId(value = "role_id", type = IdType.AUTO)
    private Integer roleId;

    private String roleName;

    private String roleDesc;

    private Short status;

    @Override
    public String getAuthority() {
        return roleName;
    }
}
  1. userDetailsService就已经改造完了,现在可以通过前端传过来的用户名去数据库查询到用户信息了,接下来就是走PasswordEncoder,校验前端传来的password跟数据库查出来的User的password是否一致,这里先使用明文密码校验的方式,我们需要自定义一个PasswordEncoder,返回一个 DelegatingPasswordEncoder对象。

    正常来说的话,SpringSecurity框架存储密码的形式是{SHA-1}ajzcvkzbcz=,前面的{SHA-1}指定了密码的加密方式,它会根据我们数据库存储的密码的前缀,获取到加密方式,用来加密前端传来的密码,再对比两个密文是否一致,但这里使用的明文进行比较,需要使用到一个不安全的类NoOpPasswordEncoder,默认的DelegatingPasswordEncoder由PasswordEncoderFactories创建,直接复制它的代码,再加一行encoders.put(null, NoOpPasswordEncoder.getInstance());因为明文获取不到类似{SHA-1}这种标识,所以key是空时,就直接匹配明文校验器

    @Bean
    public PasswordEncoder passwordEncoder() {
        String encodingId = "bcrypt";
        Map<String, PasswordEncoder> encoders = new HashMap<>();
        encoders.put(encodingId, new BCryptPasswordEncoder());
        encoders.put("ldap", new org.springframework.security.crypto.password.LdapShaPasswordEncoder());
        encoders.put("MD4", new org.springframework.security.crypto.password.Md4PasswordEncoder());
        encoders.put("MD5", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("MD5"));
        encoders.put("noop", org.springframework.security.crypto.password.NoOpPasswordEncoder.getInstance());
        encoders.put("pbkdf2", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_5());
        encoders.put("pbkdf2@SpringSecurity_v5_8", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8());
        encoders.put("scrypt", SCryptPasswordEncoder.defaultsForSpringSecurity_v4_1());
        encoders.put("scrypt@SpringSecurity_v5_8", SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8());
        encoders.put("SHA-1", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-1"));
        encoders.put("SHA-256",
                new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-256"));
        encoders.put("sha256", new org.springframework.security.crypto.password.StandardPasswordEncoder());
        encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_2());
        encoders.put("argon2@SpringSecurity_v5_8", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8());
        encoders.put(null, NoOpPasswordEncoder.getInstance());
        return new DelegatingPasswordEncoder(encodingId, encoders);
    }
  1. 最终发起登录请求成功,后端验证成功后给出了302跳转应答
    在这里插入图片描述
  2. 把生成的Authentication信息跟http会话相关联
    上面是登录接口认证流程,如果我们需要访问其他接口,那么需要在登录成功后,把登录信息存储到当前会话中,需要修改UsernamePasswordAuthenticationFilter定义,增加一行SecurityContextRepository设置,指向HttpSessionSecurityContextRepository,这样就可以把Authentication存储到会话中:
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        MyUsernamePasswordAuthenticationFilter myUsernamePasswordAuthenticationFilter = new MyUsernamePasswordAuthenticationFilter();
        myUsernamePasswordAuthenticationFilter.setFilterProcessesUrl("/user/login");
        myUsernamePasswordAuthenticationFilter.setUsernameParameter("userName");
        myUsernamePasswordAuthenticationFilter.setPasswordParameter("password");
        myUsernamePasswordAuthenticationFilter.setSecurityContextRepository(new HttpSessionSecurityContextRepository());
}

在此发起登录认证,查看应答可以看到有Set-Cookie JSESSIONID=3D3BA03492282FB5E72ABDD2FFB987E9,有这个会话Id,让我们设置cookie,这里直接使用cookie存储会话Id会引起csrf攻击,但先不考虑csrf,把其他接口的调用流程调通先
在这里插入图片描述

  1. 会话校验逻辑
    通过上面Response Header中的SessionId,访问其他接口时Cookie带上该SessionId,就不会被AuthorizationFilter校验住,校验会话的大致流程如下:
SessionId
Authentication
request
SecurityContextHolderFilter
AuthorizationFilter
  • 请求传来了SessionId,SecurityContextHolderFilter使用SessionId获取关联的会话,从中获取Authentication信息-
  • AuthorizationFilter校验Authentication是否校验通过

SecurityContextHolderFilter中也会使用到SecurityContextRepository,但它只用来加载Authentication,默认配置带有HttpSessionSecurityContextRepository,使用到httpSession管理Authentication:
在这里插入图片描述

发起用户信息查询请求,Cookie加上会话SessionId,接口可以正常返回
在这里插入图片描述

如果不带上SessionId,或带上错误的SessionId,则接口的应答状态变成302,指示浏览器跳转到登录页面进行登录认证:
在这里插入图片描述
通过上面9个步骤,会话校验流程已经修改完毕


增加CSRF Token

上面登录认证流程实现了用户会话访问接口,但是SessionId存储在cookie中,这样会导致csrf攻击,所以前端还需要把SessionId存储到其他位置,比如localStorage,在请求时,把它放到Header中,避免csrf攻击,或者直接生成一个新的csrf token,在每次请求时把它放到header或parameter中,在后端服务进行验证。

首先是获取CsrfToken的流程:

/token GET
request.setAttribute CsrfToken
get CsrfToken From Request + Save token in session
Request
CsrfFilter
TokenController
Response
  1. Controller增加一个获取csrfToken的方法
@RestController
@RequestMapping("/token")
public class TokenController {

    @GetMapping
    public ResponseEntity<CsrfToken> getToken(HttpServletRequest request) {
        CsrfToken deferredCsrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
        return ResponseEntity.ok(deferredCsrfToken);
    }
}
  1. 进行csrfToken配置
    设置CsrfFilter不校验/token请求,因为这个是用来获取CsrfToken的接口,一开始是没有CsrfToken的:
    @Bean
    @Order(SecurityProperties.BASIC_AUTH_ORDER)
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf((t) -> {
                    t.ignoringRequestMatchers("/token");
                });
    }
  1. ·发起请求,从应答头获取SessionId,从应答内容获取csrfToken
    在这里插入图片描述

  2. 使用第3步获取的SessionId跟 csrfToken,发起登录请求

    请求头带上X-CSRF-TOKEN,Cookie带上SessionId,发起登录请求,需要注意登录成功时,需要更新当前的SessionId跟csrfToken,避免会话固定攻击,在UsernamePasswordAuthenticationFilter设置一些额外策略,可以帮助我们自动更新这两个值:

    • CsrfAuthenticationStrategy:清理掉Session中当前的CsrfToken,可以再次调用/token接口获取token,或者是新建一个Strategy类,把新的token设置到应答中

    • SessionFixationProtectionStrategy:重新生成会话,在应答的SetCookie中返回一个新的SessionId

      我这里把MyUsernamePasswordAuthenticationFilter 单独抽取出来定义,同时增加了一个CsrfSaveAuthenticationStrategy,在登录认证成功后,它会把CsrfAuthenticationStrategy生成的csrfToken放到response头部中:

    @Bean
    public MyUsernamePasswordAuthenticationFilter UsernamePasswordAuthenticationFilter(AuthenticationConfiguration authenticationConfiguration) {
        try {
            MyUsernamePasswordAuthenticationFilter myUsernamePasswordAuthenticationFilter = new MyUsernamePasswordAuthenticationFilter();
            myUsernamePasswordAuthenticationFilter.setFilterProcessesUrl("/user/login");
            myUsernamePasswordAuthenticationFilter.setUsernameParameter("userName");
            myUsernamePasswordAuthenticationFilter.setPasswordParameter("password");
            myUsernamePasswordAuthenticationFilter.setSecurityContextRepository(new HttpSessionSecurityContextRepository());

            // 增加策略,登录成功后更新csrftoken跟SessionId
            List<SessionAuthenticationStrategy> sessionAuthenticationStrategies = new ArrayList<>();
			// 下面这个策略是移除session中的老csrfToken,生成新的放到request的Attribute中
            sessionAuthenticationStrategies.add(new CsrfAuthenticationStrategy(new HttpSessionCsrfTokenRepository()));
            // 下面这个是重新生成一个Session,把原来的数据放到新Session中
            sessionAuthenticationStrategies.add(new SessionFixationProtectionStrategy());
            // 下面这个是我自定义的,把新的csrfToken从request的Atribute中取出,放到Response的Header中
            sessionAuthenticationStrategies.add(new CsrfSaveAuthenticationStrategy());
            myUsernamePasswordAuthenticationFilter.setSessionAuthenticationStrategy(new CompositeSessionAuthenticationStrategy(sessionAuthenticationStrategies));
            myUsernamePasswordAuthenticationFilter.setAuthenticationManager(authenticationConfiguration.getAuthenticationManager());
            return myUsernamePasswordAuthenticationFilter;
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    @Getter
    @Setter
    class CsrfSaveAuthenticationStrategy implements SessionAuthenticationStrategy {

        @Override
        public void onAuthentication(Authentication authentication, HttpServletRequest request, HttpServletResponse response) throws SessionAuthenticationException {
            CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
            String token = csrfToken.getToken();
            String headerName = csrfToken.getHeaderName();
            response.addHeader(headerName,token);
        }
    }

在这里插入图片描述

  1. 使用新的SessionId跟CsrfToken,请求其他接口,接口返回200,请求正常
    在这里插入图片描述
    如果是一个不存在的CsrfToken,则请求失败,应答码302,指示浏览器跳转到登录界面重新登录:
    在这里插入图片描述
    通过上面5个步骤,CsrfToken的添加就已经完成了,可以根据项目的实际需要修改。

http://www.kler.cn/a/372707.html

相关文章:

  • Java 使用Maven Surefire插件批量运行单元测试
  • Python语言核心15个必知语法细节
  • Java NIO2 异步IO支持
  • 【数据集】MODIS地表温度数据(MOD11)
  • ARM汇编指令速过
  • 使用VS2019将C#代码生成DLL文件在Unity3D里面使用(一)
  • 使用three.js 实现 自定义绘制平面的效果
  • Golang 并发编程入门:Goroutine 简介与基础用法
  • 适配器模式:连接不兼容接口的桥梁
  • NVR小程序接入平台/设备EasyNVR多个NVR同时管理多平台级联与上下级对接的高效应用
  • Nginx 的讲解和案例示范
  • SpringBoot+VUE2完成WebSocket聊天(数据入库)
  • ‌MySQL中‌between and的基本用法‌
  • macOS 15 Sequoia dmg格式转用于虚拟机的iso格式教程
  • 【Mac】安装 F5-TTS
  • 阿里云ECS访问GitHub解决方案
  • 公共命名空间,2024年10月的笔记
  • 6款IntelliJ IDEA插件,让Spring和Java开发如虎添翼
  • 微信小程序生成二维码
  • Vue中如何解析docx文件(word文档)
  • 零跑汽车嵌入式面试题汇总及参考答案
  • 如何理解Js中闭包
  • LeetCode 242 - 有效的字母异位词
  • (done) 什么 RPC 协议? remote procedure call 远程调用协议
  • Comsol基于亥姆霍兹声学超材料的通风式低频吸声器
  • 【Linux】文件切割排序 cut sort