Try   HackMD

BFF實作Spring Security

spring boot層層filter, 安全控制設定為一個被稱為springSecurityFilterChain的Servlet filter,使用@EnableWebSecurity anonotation

  • 對應用程式所有 URL 作驗證
  • 允許使用者登出
  • 阻絕 CSRF 攻擊
  • 防止 Session Fixation

Spring Secutiry key concept

  1. Principle: user object, represents user or system that can execute operations in app
  2. Authority: user role, like admin, user
  3. Permission: describe the permission of the role

驗證Authentication與授權Authorization

  1. Authentication: 建立principle的過程, 通常是要求user提供username and password, 系統透過驗證這些資訊來完成驗證
  2. Authorization: 判斷該principle在app能執行那些操作, 此些規則在驗證階段就已建立好

核心物件 key object

  1. Authentication: 包含Principal, Credentials ,Authorities
  2. GrantedAuthority: Authorities 是List< GrantedAuthority>,設置賦予的最高權限, 預設的prefix為 "ROLE_"
  3. SecurityContext: 驗證過的Authentication物件, 像是UsernamePasswordAuthenticationToken
  4. SecurityContextHolder: 儲存驗證過的Authentication,aka SecurityContext管理員
  5. AuthenticationManager: 定義filter驗證的api
  6. AuthenticationProvider: 執行特定類型的身分驗證, 像是DaoAuthenticationProvider, JwtAuthenticationProvider
  7. ProviderManager: AuthenticationManager 常用的實作類別,負責管理AuthenticationProvider
  8. UserDetails: 封裝user details like name, password or authorities
  9. UserDetailsService: 載入user details, 進行安全驗證時, 收到請求的username, 找到相應的使用者資訊, 封裝成userDetails

初始webSecurity設置

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private AuthProvider authProvider;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    @Override
    protected AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }

    @Bean
    public DefaultWebSecurityExpressionHandler defaultWebSecurityExpressionHandler() {
        DefaultWebSecurityExpressionHandler defaultWebSecurityExpressionHandler = new DefaultWebSecurityExpressionHandler();
        defaultWebSecurityExpressionHandler.setDefaultRolePrefix("");
        return defaultWebSecurityExpressionHandler;
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(authProvider);
    }

    @Bean
    public GrantedAuthorityDefaults grantedAuthorityDefaults() {
        return new GrantedAuthorityDefaults(""); // Remove the ROLE_ prefix
    }

    @Override
    public void configure(WebSecurity web) {

        web.expressionHandler(new DefaultWebSecurityExpressionHandler() {
            @Override
            protected SecurityExpressionOperations createSecurityExpressionRoot(Authentication authentication,
                    FilterInvocation fi) {
                WebSecurityExpressionRoot root = (WebSecurityExpressionRoot) super.createSecurityExpressionRoot(
                    authentication, fi);
                root.setDefaultRolePrefix("");
                return root;
            }
        }).ignoring().antMatchers("/v2/api-docs",
        		"/swagger-resources/**",
        		"/configuration/security",
        		"/swagger-ui.html");
    }
    // 這邊進行設定
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        CharacterEncodingFilter filter = new CharacterEncodingFilter();
        filter.setEncoding("UTF-8");
        filter.setForceEncoding(true);

        http
            .authorizeRequests() // 請求需經過驗證
            .antMatchers("/webjars/**", "/actuator/**") // 以下開頭
            .permitAll() // 任何人皆可visit
            .and() // 結尾斷句
            .authorizeRequests()
            .antMatchers("/login/**")
            .permitAll()
            .and()
            .logout().logoutUrl("/logout").logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler(HttpStatus.OK))
            .and() 
            .authorizeRequests()
            .anyRequest().authenticated()
            .and()
            .exceptionHandling()
            .and()
            // X-Frame-Options預設為DENY, 表示可以加載frame頁面
            // 這種frame標籤的頁面可以讓網頁內嵌其他網頁
            // 需注意被惡意內嵌的攻擊 Clickjacking, 讓你誤按釣魚網站
            .headers().frameOptions().disable()
            .and()
            // 新增跨網域的filter, CorsFilter未指定bean或配置corsConfigurationSource 則使用預設的HandlerMappingIntrospector
            .cors()
            .and()
            // 關掉csrf攻擊, 使用別種token mechanism或簡化client和server之間的互動
            // csrf通常是在表單資訊, Restful API通常為stateless也不仰賴server sessions或browser cookies, 無CSRF傾向, 所以通常都關掉
            .csrf().disable() 
            .addFilterBefore(filter, CsrfFilter.class);
    }

}

REF:

// 此annotation設定此方法的跨域限制, 像這個是全開
@CrossOrigin(origins = {"*"} ,allowCredentials = "true")
@PostMapping(path = "/secAuth", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<BaseResponse<LoginResponse>> loginFromSecAuth(@Valid @RequestBody LoginRequest oAuthRequest, HttpServletRequest request) { ... }

Spring Security的應用流程

  1. 使用者用 username 及 password 登入
  2. 系統成功驗證對於該 username 的 password 正確
  3. 系統取得有關該使用者的 context 資訊,例如角色列表等
  4. 系統為該使用者建立 security context
    使用者繼續執行某些受到控管的行動,資源控管機制會依照 security context 檢查執行該行動的權限

前三步基本組成了權限驗證的程序,而 Spring Security 大概是這樣做的:

  1. 資訊裝箱: 取得 username 及 password 並放入 UsernamePasswordAuthenticationToken(實作了Authentication介面) 的實例中

    ​​​​UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
    ​​​​                oAuthRequest.getUserId(), oAuthRequest.getToken());
    
  2. 箱子送驗證: 該 token 被傳遞給 AuthenticationManager 的實例作驗證 validation,若驗證成功,AuthenticationManager 會回傳一個裝著該使用者 context 資訊的 Authentication 實例

    Image Not Showing Possible Reasons
    • The image file may be corrupted
    • The server hosting the image is unavailable
    • The image path is incorrect
    • The image format is not supported
    Learn More →
    需實作AuthenticationProvider,定義自己的驗證邏輯, 驗證時透過AuthenticationManager來操作

    ​​​​Authentication authentication = authenticationManager.authenticate(authRequest);
    
  3. 建立驗證成功的context: Spring Security 容器呼叫SecurityContextHolder.getContext().setAuthentication() 方法,並將剛得到的 Authentication 物件當參數傳入,以建立 security context

    ​​​​SecurityContextHolder.getContext().setAuthentication(authentication);
    ​​​​// 抓取principal資訊回傳response
    ​​​​CheckTokenResponse checkTokenResponse = UserDetailUtil.getUserDetail();
    
    ​​​​// 確認context非null,  若驗證成功則取得目標類型的principal
    ​​​​public class UserDetailUtil {
    ​​​​    public static CheckTokenResponse getUserDetail() {
    ​​​​        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    ​​​​        if (auth != null) {
    ​​​​            Object checkTokenResponse = auth.getPrincipal();
    ​​​​            if (checkTokenResponse instanceof CheckTokenResponse) {
    ​​​​                return (CheckTokenResponse) checkTokenResponse;
    ​​​​            }			
    ​​​​        }
    ​​​​        return null;
    ​​​​    }
    ​​​​}
    
  4. 從這之後,該使用者就會被視為通過驗證,只有 SecurityContextHolder 能取得裝著使用者完整 context 資訊的 Authentication

建立驗證邏輯

    @Component
    @Slf4j
        public class AuthProvider implements AuthenticationProvider {

            @Autowired
            private AuthService authService;


            @Override
            public Authentication authenticate(Authentication authentication) throws AuthenticationException {
                // 1. 初始的principal為傳入的username
                // 預期在authenticate後建構更完整的principal(user detail, 可以寫專門的userderail工具處理)
                // 2. credentials為傳入的password/token
                String userId = (String) authentication.getPrincipal();
                String token = (String) authentication.getCredentials();

                log.info("[Auth] authenticate userId: {}, token: {}", userId, token);
                CheckTokenResponse  ssoResponse = new CheckTokenResponse();

                try {
                    // 3. 核准的權限角色 前綴+角色名稱,例如 ROLE_ADMIN
                    Set<String> authoritySet = new HashSet<>();

                    // 自定義的服務, 回傳檢查結果及人員資訊
                    ssoResponse = authService.checkToken(new CheckTokenRequest(token, userId));
                        log.info("[Auth] authenticate checkToken result --> userId: {}, token: {}, ssoResponse: {}", userId, token, ssoResponse);
                    // 設定權限角色, 但好像傳入空值?
                    ssoResponse.setAuthorities(authoritySet.toArray(new String[authoritySet.size()]));
                } catch (AccessDeniedException e) {
                    throw new AccessDeniedException(e.getMessage(), e);
                } catch (IOException e) {
                    throw new AccessDeniedException("Connect to oauth server failed", e);
                } catch (JAXBException e) {
                    throw new AccessDeniedException("Jaxb Marshaller failed", e);
                } catch (Exception e) {
                    throw new AccessDeniedException(e.getMessage(), e);
                }
                // 驗證後, 設定更仔細的principal: 參數分別是principal, password, authorities
                // UsernamePasswordAuthenticationToken 繼承了 AbstractAuthenticationToken 則實作 Authentication
                // 故回傳值屬於Authentication
                return new UsernamePasswordAuthenticationToken(ssoResponse, token, ssoResponse.getAuthorities()); 

            }

            @Override
            public boolean supports(Class<?> authentication) {
                return authentication.equals(UsernamePasswordAuthenticationToken.class);
            }

        }

問答區

Q: 如何避免session遺失,後端的Pod有多個的時候,就會遇到session在request導到不同Pods時候,Session遺失的問題
A:

  • 固定單個pod, 不作HPA
  • K8S service設定sessionAffinity: ClientIP
  • AP層的session工具,例如redis來做到session共用

Q: 什麼是X509Certificate
A: 用於標準格式的公鑰證書

Q: 配置configure時, WebSecurity和HttpSecurity的差別?
public class WebSecurityConfig extends WebSecurityConfigurerAdapter{}
A: WebSecurity是global的filter設定, HttpSecurity則是單一filter設定,詳細如下:
HttpSecurity用來建置SecurityFilterChain,一個程式可以有多個SecurityFilterChain,因此需要有一個中介者FilterChainProxy來管理這些SecurityFilterChain;WebSecurity則是用來建置FilterChainProxy,額外還有清理SecurityContext以避免memory leak, 定義防火牆, 啟用Spring security的debug mode等。最後再由FilterChainProxy結合Spring的bridge proxy DelegatingFilterProxy,將此些security的filter融入程式

Q: 在使用filter或AOP時,從HttpServletRequest輸入流讀取InputStream(確切來說是ServletInputStream, 繼承者),後續將無法再獲得請求內容
A: HttpServletRequest只能讀取一次,讀取InputStream時有pointer指示位置,讀取完畢則回傳-1,若要重讀則需呼叫InputStream.reset(),能否reset(),取決markSupport方法,而ServletInputStream沒有override此些方法,InputStream也不實現reset()

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
所以要處理的話,以http request包裝容器存儲HttpServletRequestWrapper,再往後傳即可解決

Q: 自定義filter後,請求不會自己傳到後續的filter
A: 若filter要使請求繼續處理,一定要調用filterChain.doFilter()

Q: 什麼是Servlet(Server Applet)
A: 基於協議requesr, response service的java class, 在java EE是Servlet規範, aka java實現的介面, servlet廣義來講是指直線了這個servlet介面的java class; servlet的容器收到http請求後,將request封裝成一個servletRequest,並加封裝一個servletResponse,呼叫servlet應用的service方法,將servletRequest及servletResponse傳入,方法執行後將servlet將servletResponse回應給broswer

Q: Interceptor和Filter的差別
A:

filter interceptor description
定義位置 java.servlet handlerInterceptor定義在org.springframework.web.servlet 分別在servlet類別, 及spring框架中定義
配置 web.yml - -
作用 servlet前後作用, 不考慮servlet實現 方法, exception前後, 更靈活解耦合 spring框架可以優先選用interceptor
範圍 servlet規定 web app, application及swing皆可用
規範 servlet規定 存在spring容器, 由spring框架支持
與spring關係 不可使用容器內資源 屬於spring組件, 可以使用spring內的任何資源,透過IOC注入到interceptor即可
調用方 被server(e.g. tomcat)所使用 spring調用 filter早於interceptor執行
實現 函數回調 基於java反射及動態代理機制
觸發時機 請求進入容器後, 請求進入servlet之前, 返回亦然

request -> filter > servlet > interceptor > controller
controller > interceptor > servlet > filter ->response

For SameSiteCookies.NONE be aware, that cookies are also Secure (SSL used), otherwise they couldn't be applied.
By default since Chrome 80 cookies considered as SameSite=Lax!