[Spring][Security] 스프링 시큐리티
들어가며
해당 게시글은 인프런 백기선 강사님의 스프링 시큐리티 강의를 바탕으로 쓰였음을 미리 밝힙니다.
스프링 시큐리티: 폼 인증
스프링 시큐리티 연동
- gradle 설정
implementation 'org.springframework.boot:spring-boot-starter-security'
- 기본 유저가 생성됨(ID: user)
Using generated security password: 114284e0-656a-4fdf-b623-9b552a85b6c8
- 모든 요청은 인증을 필요로함
스프링 시큐리티 설정하기
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .mvcMatchers("/", "/info").permitAll()
                .mvcMatchers("/admin").hasRole("ADMIN")
                .anyRequest().authenticated();
        http.formLogin();
        http.httpBasic();
    }
}
- 요청 URL별 인증 설정
스프링 시큐리티 커스터마이징: 인메모리 유저 추가
- UserDetailsServiceAutoConfiguration(클래스)에서 기본 유저를 추가
- SecurityProperties(클래스)에서 기본 유저의 정보 변경 가능
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.inMemoryAuthentication()
            .withUser("keesun").password("{noop}123").roles("USER").and() // 패스워드의 데이터베이스 저장용 인코딩 방식을 말한다.
            .withUser("admin").password("{noop}!@#").roles("ADMIN");
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
    return super.authenticationManagerBean();
}
- 인메모리 사용자 추가
- 로컬 AuthenticationManager를 빈으로 노출
스프링 시큐리티 커스터마이징: JPA 연동
@Entity
public class Account {
    @Id
    @GeneratedValue
    private Integer id;
    
    @Column(unique = true)
    private String username;
    
    private String password;
    private String role;
}
@Service
public class AccountService implements UserDetailsService {
    @Autowired
    AccountRepository accountRepository;
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Account account = accountRepository.findByUsername(username);
        if (account == null) {
            throw new UsernameNotFoundException(username);
        }
        return User.builder()
                .username(account.getUsername())
                .password(account.getPassword())
                .roles(account.getRole())
                .build();
    }
}
- User클래스를 통해- UserDetails인퍼테이스의 구현체를 간단히 생성 가능
스프링 시큐리티 커스터마이징: PasswordEncoder
@Bean
public PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@AllArgsConstructor
@Entity
@Data
@NoArgsConstructor
public class Account {
    ...
    public void encodePassword(PasswordEncoder passwordEncoder) {
        this.password = passwordEncoder.encode(this.password);
    }
    
    ...
}
- 스프링 시큐리티가 제공하는 PasswordEndoer는 특정한 포맷으로 동작함.
- {id}encodedPassword: 각 패스워드에 맞는 인코더를 활용해 회원의 패스워드를 암호화
- 다양한 해싱 전략의 패스워드를 지원할 수 있다는 장점이 있습니다.
- 기본 전략인 bcrypt로 암호화 해서 저장하며 비교할 때는 {id}를 확인해서 다양한 인코딩을 지원합니다.
스프링 시큐리티 테스트
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
@Rollback
class AccountControllerTest {
    @Autowired
    MockMvc mockMvc;
    @Autowired
    AccountService accountService;
    @Test
    @WithAnonymousUser
    public void index_anonymous() throws Exception {
        mockMvc.perform(get("/"))
                .andDo(print())
                .andExpect(status().isOk());
    }
    @Test
    @WithUser
    public void index_user() throws Exception {
        mockMvc.perform(get("/"))
                .andDo(print())
                .andExpect(status().isOk());
    }
    @Test
    @WithUser
    public void admin_user() throws Exception {
        mockMvc.perform(get("/admin"))
                .andDo(print())
                .andExpect(status().isForbidden());
    }
    @Test
    @WithMockUser(username = "yhw", roles = "ADMIN")
    public void admin_admin() throws Exception {
        mockMvc.perform(get("/admin"))
                .andDo(print())
                .andExpect(status().isOk());
    }
    @Test
    public void login_success1() throws Exception {
        Account account = new Account();
        account.setUsername("yhw");
        account.setPassword("123");
        account.setRole("USER");
        accountService.createNew(account);
        mockMvc.perform(formLogin().user("yhw").password("123"))
                .andExpect(authenticated());
    }
    @Test
    public void login_fail() throws Exception {
        Account account = new Account();
        account.setUsername("yhw");
        account.setPassword("123");
        account.setRole("USER");
        accountService.createNew(account);
        mockMvc.perform(formLogin().user("yhw").password("12345"))
                .andExpect(unauthenticated());
    }
    @Test
    public void login_success2() throws Exception {
        Account account = new Account();
        account.setUsername("yhw");
        account.setPassword("123");
        account.setRole("USER");
        accountService.createNew(account);
        mockMvc.perform(formLogin().user("yhw").password("123"))
                .andExpect(authenticated());
    }
@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(username = "yhw", roles = "USER")
public @interface WithUser {
}
- @WithUser라는 애노테이션 직접 생성
- @With~애노테이션은 이미 그러한 사용자로 로그인한 상황을 가정함
- 응답 유형 확인
    - authenticated()
- unauthenticated()
 
스프링 시큐리티: 아키텍처
SecurityContextHolder와 Authentication
 
      
    
  
  
- SecurityContextHolder:- SecurityContext제공, 기본적으로 ThreadLocal을 사용한다.
- SecuuryContext:- Authentication제공.
- Authentication: Principal과 GrantAuthority 제공.(- UsernamePasswordAuthenticationToken)
- Principal: “누구”에 해당하는 정보.- UserDetailsService에서 리턴한 그 객체.(- User클래스), 객체는- UserDetails(인퍼테이스) 타입.
- UserDetails: 애플리케이션이 가지고 있는 유저 정보와 스프링 시큐리티가 사용하는- Authentication객체 사이의 어댑터.
- UserDetailsService: 유저 정보를- UserDetails타입으로 가져오는 DAO (Data Access Object) 인터페이스.
- SecurityContextHolder
private static void initialize() {
    if (!StringUtils.hasText(strategyName)) {
        // Set default
        strategyName = MODE_THREADLOCAL;
    }
    if (strategyName.equals(MODE_THREADLOCAL)) {
        strategy = new ThreadLocalSecurityContextHolderStrategy();
    } else if (strategyName.equals(MODE_INHERITABLETHREADLOCAL)) {
        strategy = new InheritableThreadLocalSecurityContextHolderStrategy();
    } else if (strategyName.equals(MODE_GLOBAL)) {
        strategy = new GlobalSecurityContextHolderStrategy();
    } else {
        // Try to load a custom strategy
        try {
            Class<?> clazz = Class.forName(strategyName);
            Constructor<?> customStrategy = clazz.getConstructor();
            strategy = (SecurityContextHolderStrategy) customStrategy.newInstance();
        } catch (Exception ex) {
            ReflectionUtils.handleReflectionException(ex);
        }
    }
    initializeCount++;
}
- ThreadLocalSecurityContextHolderStrategy
final class ThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy {
    private static final ThreadLocal<SecurityContext> contextHolder = new ThreadLocal<>();
    @Override
    public void clearContext() {
        contextHolder.remove();
    }
    @Override
    public SecurityContext getContext() {
        SecurityContext ctx = contextHolder.get();
        if (ctx == null) {
            ctx = createEmptyContext();
            contextHolder.set(ctx);
        }
        return ctx;
    }
    @Override
    public void setContext(SecurityContext context) {
        Assert.notNull(context, "Only non-null SecurityContext instances are permitted");
        contextHolder.set(context);
    }
    @Override
    public SecurityContext createEmptyContext() {
        return new SecurityContextImpl();
    }
}
- http 요청이 들어오면 setContext메소드에서 인증된SecuiryContext객체를 매개변수로 받아 TLS 변수로 저장한다. 이후 같은 쓰레드에서SecuiryContext에 접근하면 항상 같은SecuiryContext객체를 얻을수 있게 된다.
AuthenticationManager와 Authentication
- 스프링 시큐리티에서 인증(Authentication)은 AuthenticationManager(인터페이스)가 한다.(구현체는ProviderManager)
Authentication authenticate(Authentication authentication) throws AuthenticationException
- 인자로 받은 Authentication이 유효한 인증인지 확인하고UserDetailService에서 반환한UserDetails를 담은Authetication객체를 리턴한다.
- 인자로 받은 Authentication
    - 사용자가 입력한 인증에 필요한 정보(username, password)로 만든 객체. (폼 인증인 경우)
 
- Authentication
    - Principal: “yhw”
- Credentials: “123”
 
- 유효한 인증인지 확인
    - 사용자가 입력한 password가 UserDetailsService를 통해 읽어온UserDetails객체에 들어있는 password와 일치하는지 확인
 
- 사용자가 입력한 password가 
- Authentication객체를 리턴- Principal: UserDetailsService에서 리턴한 그 객체 (- User)
- Credentials:
- GrantedAuthorities
 
ThreadLocal
- Java.lang패키지에서 제공하는 쓰레드 범위 변수. static 변수와 비슷한 개념으로써 메소드 상관없이 쓰레드 단위로 접근, 변경 가능하다.- 같은 쓰레드 내에서만 공유.
- 따라서 같은 쓰레드라면 해당 데이터를 메소드 매개변수로 넘겨줄 필요 없음.
- SecurityContextHolder의 기본 전략.
 
Authencation과 SecurityContextHodler
- UsernamePasswordAuthenticationFilter- 폼 인증을 처리하는 시큐리티 필터
- ProviderManager에서 인증, 반환된- Authentication객체(- UserDetails를 담음)를- SecurityContext에 넣어주는 필터
- SecurityContextHolder.getContext().setAuthentication(authentication)(추상 부모 클래스에서)
 
- SecurityContextPersistenceFilter- SecurityContext를 HTTP session에 캐시(기본 전략)하여 여러 요청에서- Authentication을 공유할수 있는 필터.
- 매 요청마다 세션에 저장된 SecurityContext가 있는지 확인하고 요청이 끝나고 나갈때 해당 쓰레드의SecurityContext를 비우고 세션에SecurityContext를 저장한다. 따라서 같은 HTTP session의 경우 같은SecurityContext를 공유하게 된다.
- SecurityContextRepository를 교체하여 세션을 HTTP session이 아닌 다른 곳에 저장하는 것도 가능하다.
 
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
    ...
    
    HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request, response);
    SecurityContext contextBeforeChainExecution = this.repo.loadContext(holder);
    try {
        SecurityContextHolder.setContext(contextBeforeChainExecution);
        if (contextBeforeChainExecution.getAuthentication() == null) {
            logger.debug("Set SecurityContextHolder to empty SecurityContext");
        }
        else {
            if (this.logger.isDebugEnabled()) {
                this.logger
                        .debug(LogMessage.format("Set SecurityContextHolder to %s", contextBeforeChainExecution));
            }
        }
        chain.doFilter(holder.getRequest(), holder.getResponse());
    }
    finally {
        SecurityContext contextAfterChainExecution = SecurityContextHolder.getContext();
        // Crucial removal of SecurityContextHolder contents before anything else.
        SecurityContextHolder.clearContext();
        this.repo.saveContext(contextAfterChainExecution, holder.getRequest(), holder.getResponse());
        request.removeAttribute(FILTER_APPLIED);
        this.logger.debug("Cleared SecurityContextHolder to complete request");
    }
}
스프링 시큐리티 Filter와 FilterChainProxy
 
      
    
  
  
- WebAsyncManagerIntergrationFilter
- SecurityContextPersistenceFilter
- HeaderWriterFilter
- CsrfFilter
- LogoutFilter
- UsernamePasswordAuthenticationFilter
- DefaultLoginPageGeneratingFilter
- DefaultLogoutPageGeneratingFilter
- BasicAuthenticationFilter
- RequestCacheAwareFtiler
- SecurityContextHolderAwareReqeustFilter
- AnonymouseAuthenticationFilter
- SessionManagementFilter
- ExeptionTranslationFilter
- FilterSecurityInterceptor
- 이 모든 필터는 FilterChainProxy가 호출한다.
private List<Filter> getFilters(HttpServletRequest request) {
    int count = 0;
    for (SecurityFilterChain chain : this.filterChains) {
        if (logger.isTraceEnabled()) {
            logger.trace(LogMessage.format("Trying to match request against %s (%d/%d)", chain, ++count,
                    this.filterChains.size()));
        }
        if (chain.matches(request)) {
            return chain.getFilters();
        }
    }
    return null;
}
- 여러개의 필터체인에서 antMatcher에 상응하는 체인을 가져오고 이를 토대로 여러개의 필터를 순서대로 거친다.
DelegatingFilterProxy와 FilterChainProxy
 
      
    
  
  
- DelegatingFilterProxy- 일반적인 서블릿 필터.
- 서블릿 필터 처리를 스프링에 들어있는 빈으로 위임하고 싶을 때 사용하는 서블릿 필터.
- 타겟 빈 이름을 설정한다.
- 스프링 부트를 사용할 때는 자동으로 등록 된다. (SecurityFilterAutoConfiguration)
 
- FilterChainProxy- “springSecurityFilterChain” 이라는 이름의 빈으로 등록된다.
 
AccessDecisionManager 1부
- 인증이 아닌 인가
@Override
@SuppressWarnings({"rawtypes", "unchecked"})
public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException {
    int deny = 0;
    
    for (AccessDecisionVoter voter : getDecisionVoters()) {
        int result = voter.vote(authentication, object, configAttributes);
        switch (result) {
            case AccessDecisionVoter.ACCESS_GRANTED:
                return;
            case AccessDecisionVoter.ACCESS_DENIED:
                deny++;
                break;
            default:
                break;
        }
    }
    if (deny > 0) {
        throw new AccessDeniedException(
                this.messages.getMessage("AbstractAccessDecisionManager.accessDenied", "Access is denied"));
    }
    // To get this far, every AccessDecisionVoter abstained
    checkAllowIfAllAbstainDecisions();
}
- Access Control 결정을 내리는 인터페이스로, 구현체 3가지를 기본으로 제공한다.
    - AffirmativeBased: 여러 Voter중에 한명이라도 허용하면 허용. 기본 전략.
- ConsensusBased: 다수결
- UnanimousBased: 만장일치
 
- AccessDecisionVoter(인터페이스)- 해당 Authentication이 특정한 Object에 접근할 때 필요한 ConfigAttributes를 만족하는지 확인한다.
- WebExpressionVoter: 웹 시큐리티에서 사용하는 기본 구현체, ROLE_Xxxx가 매치하는지 확인.
- RoleHierarchyVoter: 계층형 ROLE 지원. ADMIN > MANAGER > USER
 
AccessDecisionManager 2부
- AccessDecisionManager또는 Voter를 커스터마이징 하는 방법
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    public SecurityExpressionHandler expressionHandler() {
        RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
        roleHierarchy.setHierarchy("ROLE_ADMIN > ROLE_USER");
        DefaultWebSecurityExpressionHandler handler = new DefaultWebSecurityExpressionHandler();
        handler.setRoleHierarchy(roleHierarchy);
        return handler;
    }
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .mvcMatchers("/", "/info", "/account/**").permitAll()
                .mvcMatchers("/admin").hasRole("ADMIN")
                .mvcMatchers("/user").hasRole("USER")
                .anyRequest().authenticated()
                .expressionHandler(expressionHandler());
        http.formLogin();
        http.httpBasic();
    }
}
- WebExpressionVoter
private SecurityExpressionHandler<FilterInvocation> expressionHandler = new DefaultWebSecurityExpressionHandler();
...
public void setExpressionHandler(SecurityExpressionHandler<FilterInvocation> expressionHandler) {
    this.expressionHandler = expressionHandler;
}
- 위계를 이해할 수 있는 handler를 AffirmativeBased의WebExpressionVoter에 주입한다.
FilterSecurityInterceptor
- AccessDecisionManager를 사용하여 Access Control 또는 예외 처리 하는 필터. 대부분의 경우- FilterChainProxy에 제일 마지막 필터로 들어있다.
private void attemptAuthorization(Object object, Collection<ConfigAttribute> attributes, Authentication authenticated) {
    try {
        this.accessDecisionManager.decide(authenticated, object, attributes);
    } catch (AccessDeniedException ex) {
        if (this.logger.isTraceEnabled()) {
            this.logger.trace(LogMessage.format("Failed to authorize %s with attributes %s using %s", object,
                    attributes, this.accessDecisionManager));
        } else if (this.logger.isDebugEnabled()) {
            this.logger.debug(LogMessage.format("Failed to authorize %s with attributes %s", object, attributes));
        }
        publishEvent(new AuthorizationFailureEvent(object, attributes, authenticated, ex));
        throw ex;
    }
}
ExceptionTranslationFilter
- 필터 체인에서 발생하는 AccessDeniedException과AuthenticationException을 처리하는 필터
- AuthenticationException발생 시- AuthenticationEntryPoint실행
- AbstractSecurityInterceptor하위 클래스(예,- FilterSecurityInterceptor)에서 발생하는 예외만 처리.
 
- AccessDeniedException발생 시- 익명 사용자라면 AuthenticationEntryPoint실행
- 익명 사용자가 아니면 AccessDeniedHandler에게 위임
 
- 익명 사용자라면 
- ExceptionTranslationFilter
private void handleAuthenticationException(HttpServletRequest request, HttpServletResponse response,
                                           FilterChain chain, AuthenticationException exception) throws ServletException, IOException {
    this.logger.trace("Sending to authentication entry point since authentication failed", exception);
    sendStartAuthentication(request, response, chain, exception);
}
private void handleAccessDeniedException(HttpServletRequest request, HttpServletResponse response,
                                         FilterChain chain, AccessDeniedException exception) throws ServletException, IOException {
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    boolean isAnonymous = this.authenticationTrustResolver.isAnonymous(authentication);
    if (isAnonymous || this.authenticationTrustResolver.isRememberMe(authentication)) {
        if (logger.isTraceEnabled()) {
            logger.trace(LogMessage.format("Sending %s to authentication entry point since access is denied",
                    authentication), exception);
        }
        sendStartAuthentication(request, response, chain,
                new InsufficientAuthenticationException(
                        this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication",
                                "Full authentication is required to access this resource")));
    } else {
        if (logger.isTraceEnabled()) {
            logger.trace(
                    LogMessage.format("Sending %s to access denied handler since access is denied", authentication),
                    exception);
        }
        this.accessDeniedHandler.handle(request, response, exception);
    }
}
- UsernamePasswordAuthenticationFilter에서 발생한 인증 에러(- AbstractAuthenticationProcessingFilter에서 처리)
...
catch (AuthenticationException ex) {
// Authentication failed
unsuccessfulAuthentication(request, response, ex);
}
...
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
                                          AuthenticationException failed) throws IOException, ServletException {
    SecurityContextHolder.clearContext();
    this.logger.trace("Failed to process authentication request", failed);
    this.logger.trace("Cleared SecurityContextHolder");
    this.logger.trace("Handling authentication failure");
    this.rememberMeServices.loginFail(request, response);
    this.failureHandler.onAuthenticationFailure(request, response, failed);
}
...
- SimpleUrlAuthenticationFailureHandler
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
                                    AuthenticationException exception) throws IOException, ServletException {
    if (this.defaultFailureUrl == null) {
        if (this.logger.isTraceEnabled()) {
            this.logger.trace("Sending 401 Unauthorized error since no failure URL is set");
        } else {
            this.logger.debug("Sending 401 Unauthorized error");
        }
        response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
        return;
    }
    saveException(request, exception);
    if (this.forwardToDestination) {
        this.logger.debug("Forwarding to " + this.defaultFailureUrl);
        request.getRequestDispatcher(this.defaultFailureUrl).forward(request, response);
    } else {
        this.redirectStrategy.sendRedirect(request, response, this.defaultFailureUrl);
    }
}
스프링 시큐리티 아키텍처 정리
 
      
    
  
  
- https://spring.io/guides/topicals/spring-security-architecture
- https://docs.spring.io/spring-security/site/docs/5.1.5.RELEASE/reference/htmlsingle/#overall-architecture
웹 애플리케이션 시큐리티
스프링 시큐리티 ignoring() 1부
- WebSecurity의- ignoring()을 사용해서 시큐리티 필터 적용을 제외할 요청을 설정할 수 있다.
- 스프링 부트가 제공하는 PathRequest를 사용해서 정적 자원 요청을 스프링 시큐리티 필터를 적용하지 않도록 설정.
@Override
public void configure(WebSecurity web) throws Exception {
    web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
- favicon.ico 리다이렉트의 경우 필터의 목록이 0이다. 즉 시큐리티 필터를 거치지 않는다. 따라서 /login 으로 redirect 되지도 않는다.
스프링 시큐리티 ignoring() 2부
http.authorizeRequests()
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
- 이런 설정으로도 같은 결과를 볼 수는 있지만 스프링 시큐리티 필터가 적용된다는 차이가 있다.
    - 필터가 그대로 적용되고 마지막 FilterSecurityInterceptor에서AccessDecisionManager를 통해 요청이 허가된다.
- 동적 리소스는 http.authorizeRequests()에서 처리하는 것을 권장
- 정적 리소스는 WebSecurity.ignore()를 권장하며 예외적인 정적 자원 (인증이 필요한 정적자원이 있는 경우)는 http.authorizeRequests()를 사용
 
- 필터가 그대로 적용되고 마지막 
Async 웹 MVC를 지원하는 필터: WebAsyncManagerIntegrationFilter
- 스프링 MVC의 Async 기능(핸들러에서 Callable을 리턴할 수 있는 기능)을 사용할 때에도SecurityContext를 공유하도록 도와주는 필터.- PreProcess:- SecurityContext를 설정한다.
- Callable: 비록 다른 쓰레드지만 그 안에서는 동일한- SecurityContext를 참조할 수 있다.
- PostProcess:- SecurityContext를 정리(clean up)한다.
 
스프링 시큐리티와 @Async
- @Async를 사용한 서비스를 호출하는 경우 쓰레드가 다르기 때문에- SecurityContext를 공유받지 못한다.
@Override
protected void configure(HttpSecurity http) throws Exception {
    ...
    SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
    ...
}
@SpringBootApplication
@EnableAsync
public class DemoSpringSecurityFormApplication {
    ...
}
- SecurityContext를 자식 쓰레드에도 공유하는 전략.
- @Async를 처리하는 쓰레드에서도- SecurityContext를 공유받을 수 있다.
SecurityContext 영속화 필터: SecurityContextPersistenceFilter
- SecurityContextRepository를 사용해서 기존의- SecurityContext를 읽어오거나 초기화 한다.(구현제- HttpSessionSecurityContextRepository)- 첫 요청이어서 세션 저장소에 저장된 SecurityContext가 없는 경우null값을 가지는Authentication을 담은SecurityContext를 반환한다.
- 기본으로 사용하는 전략은 HTTP Session을 사용한다.
- Spring-Session과 연동하여 세션 클러스터를 구현할 수 있다.
 
- 첫 요청이어서 세션 저장소에 저장된 
CSRF 어택 방지 필터: CsrfFilter
 
      
    
  
  
- form 기반 웹 페이지의 경우 인증된 유저의 계정을 사용해 악의적인 변경 요청을 막아내기 위한 기법
- 서버에서 form 보낼때 csrf 토큰을 생성하여 hidden 필드로 보내고 요청이 들어올때 실제 csrf 토큰과 일치하는지 여부를 검사
로그아웃 처리 필터: LogoutFilter
- 여러 LogoutHandler를 사용하여 로그아웃시 필요한 처리를 하며 이후에는LogoutSuccessHandler를 사용하여 로그아웃 후처리를 한다.
- LogoutHandler
    - CsrfLogoutHandler
- SecurityContextLogoutHandler
 
- LogoutSuccessHandler
    - SimplUrlLogoutSuccessHandler
 
- LogoutFilter
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
        throws IOException, ServletException {
    if (requiresLogout(request, response)) {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (this.logger.isDebugEnabled()) {
            this.logger.debug(LogMessage.format("Logging out [%s]", auth));
        }
        this.handler.logout(request, response, auth);
        this.logoutSuccessHandler.onLogoutSuccess(request, response, auth);
        return;
    }
    chain.doFilter(request, response);
}
- CompositeLogoutHandler(- LogoutHandler구현체로 여러 종류의- LogoutHandler를 가지고 있음.)
@Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
    for (LogoutHandler handler : this.logoutHandlers) {
        handler.logout(request, response, authentication);
    }
}
- 로그아웃 필터 설정
http.logout()
        .logoutUrl("/logout")
        .logoutSuccessUrl("/") 
        .logoutRequestMatcher()
        .invalidateHttpSession(true)
        .deleteCookies()
        .addLogoutHandler()
        .logoutSuccessHandler();
폼 인증 처리 필터: UsernamePasswordAuthenticationFilter
- 폼 로그인을 처리하는 인증 필터
    - 사용자가 폼에 입력한 username과 password로 Authentcation을 만들고AuthenticationManager(ProviderManager)를 사용하여 인증을 시도한다.
- AuthenticationManager(- ProviderManager)는 여러- AuthenticationProvider를 사용하여 인증을 시도하는데, 그 중에- DaoAuthenticationProvider는- UserDetailsServivce를 사용하여- UserDetails정보를 가져와 사용자가 입력한 password와 비교한다.
 
- 사용자가 폼에 입력한 username과 password로 
- UsernamePasswordAuthenticationFilter
@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());
    }
    String username = obtainUsername(request);
    username = (username != null) ? username : "";
    username = username.trim();
    String password = obtainPassword(request);
    password = (password != null) ? password : "";
    UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
    // Allow subclasses to set the "details" property
    setDetails(request, authRequest);
    return this.getAuthenticationManager().authenticate(authRequest);
}
- ProviderManager
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    ...
    for (AuthenticationProvider provider : getProviders()) {
        if (!provider.supports(toTest)) {
            continue;
        }
        if (logger.isTraceEnabled()) {
            logger.trace(LogMessage.format("Authenticating request with %s (%d/%d)",
                    provider.getClass().getSimpleName(), ++currentPosition, size));
        }
        try {
            result = provider.authenticate(authentication);
            if (result != null) {
                copyDetails(authentication, result);
                break;
            }
        }
    }
    
    ...
}
- DaoAuthenticationProvider는 인증 완료 후- UserDetails를 포함하는- Authentication을 반환하고 이후- UsernamePasswordAuthenticationFilter의 추상 부모 클래스에서 쓰레드 변수로- SecurityContext를 저장한다.
로그인/로그아웃 폼 커스터마이징
- DefaultLoginPageGeneratingFilter,- DefaultLogoutPageGeneratingFilter가 빠짐(기본 로그인, 로그아웃 폼 페이지를 생성해주는 필터),- LogoutFilter는 그대로 있음
http.formLogin().loginPage("/login").permitAll();
http.logout().logoutSuccessUrl("/");
- /login 처리하는 핸들러 코딩해야 하며 직접 로그인 폼을 만들어야 한다. 마찬가지로 /logout 처리하는 핸들러와 로그아웃 폼을 만들어야 한다.
Basic 인증 처리 필터: BasicAuthenticationFilter
- 요청 헤더에 username와 password를 실어 보내면 브라우저 또는 서버가 그 값을 읽어서 인증하는 방식. 예) Authorization: Basic QWxhZGRpbjpPcGVuU2VzYW1l (keesun:123 을 BASE 64)
- 보통, 브라우저 기반 요청이 클라이언트의 요청을 처리할 때 자주 사용.
- 보안에 취약하기 때문에 반드시 HTTPS를 사용할 것을 권장.
요청 캐시 필터: RequestCacheAwareFilter
- 현재 요청과 관련 있는 캐시된 요청이 있는지 찾아서 적용하는 필터.
    - 캐시된 요청이 없다면, 현재 요청 처리
- 캐시된 요청이 있다면, 해당 캐시된 요청 처리, 즉 /dashboard 요청 후 로그인 페이지로 redirect되서 로그인을 진행하면 관련 있는 과거 요청인 /dashboard로 다시 redirect 시켜준다
 
익명 인증 필터: AnonymousAuthenticationFilter
- 현재 SecurityContext에Authentication이null이면 “익명 Authentication”(AnonymousAuthenticationToken)을 만들어 넣어주고,null이 아니면 아무일도 하지 않는다.
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
        throws IOException, ServletException {
    if (SecurityContextHolder.getContext().getAuthentication() == null) {
        SecurityContextHolder.getContext().setAuthentication(createAuthentication((HttpServletRequest) req));
        if (this.logger.isTraceEnabled()) {
            this.logger.trace(LogMessage.of(() -> "Set SecurityContextHolder to "
                    + SecurityContextHolder.getContext().getAuthentication()));
        } else {
            this.logger.debug("Set SecurityContextHolder to anonymous SecurityContext");
        }
    } else {
        if (this.logger.isTraceEnabled()) {
            this.logger.trace(LogMessage.of(() -> "Did not set SecurityContextHolder since already authenticated "
                    + SecurityContextHolder.getContext().getAuthentication()));
        }
    }
    chain.doFilter(req, res);
}
protected Authentication createAuthentication(HttpServletRequest request) {
    AnonymousAuthenticationToken token = new AnonymousAuthenticationToken(this.key, this.principal,
            this.authorities);
    token.setDetails(this.authenticationDetailsSource.buildDetails(request));
    return token;
}
세션 관리 필터: SessionManagementFilter
- 세션 변조 방지 전략 설정: sessionFixation
    - migrateSession (서블릿 3.0- 컨테이너 사용시 기본값)
- changeSessionId (서브릿 3.1+ 컨테이너 사용시 기본값)
 
- 유효하지 않은 세션을 리다이렉트 시킬 URL 설정: invalidSessionUrl
- 동시성 제어: maximumSessions
    - 추가 로그인을 막을지 여부 설정 (기본값, false)
 
- 세션 생성 전략: sessionCreationPolicy
    - IF_REQUIRED
- NEVER
- STATELESS
- ALWAYS
 
인증/인가 예외 처리 필터: ExceptionTranslationFilter
- ExceptionTranslatorFilter->- FilterSecurityInterceptor(- AccessDecisionManager,- AffrimativeBased)
- AuthenticationException->- AuthenticationEntryPoint
- AccessDeniedException->- AccessDeniedHandler
- AccessDeniedHandler커스터 마이징
http.exceptionHandling().accessDeniedHandler(new AccessDeniedHandler() {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        UserDetails principal = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        String username = principal.getUsername();
        System.out.println(username + " is denied to access " + request.getRequestURI());
        response.sendRedirect("/access-denied");
    }
});
토큰 기반 인증 필터 : RememberMeAuthenticationFilter
 
      
    
  
  
- RememberMeAuthenticationFilter: 세션이 사라지거나 만료가 되더라도 쿠키 또는 DB를 사용하여 저장된 토큰 기반으로 인증을 지원하는 필터
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
        throws IOException, ServletException {
   ...
    
    Authentication rememberMeAuth = this.rememberMeServices.autoLogin(request, response);
    if (rememberMeAuth != null) {
        // Attempt authenticaton via AuthenticationManager
        try {
            rememberMeAuth = this.authenticationManager.authenticate(rememberMeAuth);
            // Store to SecurityContextHolder
            SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);
            onSuccessfulAuthentication(request, response, rememberMeAuth);
           ...
        }
        
        ...
}
- RememberMeAuthenticationFilter에서 쿠키를 이용하여- RememberMeAuthenticationToken을 반환, 특유의 hashcode를 가지고 있다.
- authenticationManager:- PoviderManager
- AuthenticationProvider:- RememberMeAuthenticationProvider(특유의 hashcode를 비교하여 인증을 처리한다.),- DaoAuthenticationProvider의 경우는- UserDetailsService에서 반환한- UserDetails의 패스워드를 기준으로 인증한다.
- SecurityContext에- RememberMeAuthenticationToken을 쓰레드 변수로 저장
- RememberMeAuthenticationProvider
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    if (!supports(authentication.getClass())) {
        return null;
    }
    if (this.key.hashCode() != ((RememberMeAuthenticationToken) authentication).getKeyHash()) {
        throw new BadCredentialsException(this.messages.getMessage("RememberMeAuthenticationProvider.incorrectKey",
                "The presented RememberMeAuthenticationToken does not contain the expected key"));
    }
    return authentication;
}
- 설정
http.rememberMe() 
    .userDetailsService(accountService) 
    .key("remember-me-sample"); // 리멤버미 기능으로 사용할 쿠키의 이름
커스텀 필터 추가하기
public class LoggingFilter extends GenericFilterBean {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        ...
        chain.doFilter(request, response);
        ...
    }
}
http.addFilterAfter(new LoggingFilter(), UsernamePasswordAuthenticationFilter.class);
스프링 시큐리티 그밖에
타임리프 스프링 시큐리티 확장팩
- Authentication 과 Authorization 참조
implementation group: 'org.thymeleaf.extras', name: 'thymeleaf-extras-springsecurity5'
<div th:if="${#authorization.expr('isAuthenticated()')}">
    <h2 th:text="${#authentication.name}"></h2>
    <a href="/logout" th:href="@{/logout}">Logout</a>
</div>
<div th:unless="${#authorization.expr('isAuthenticated()')}">
    <a href="/login" th:href="@{/login}">Login</a>
</div>
sec 네임스페이스
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
<div sec:authorize="isAuthenticated()">
    <h2 sec:authentication="name">Name</h2>
    <a href="/logout" th:href="@{/logout}">Logout</a>
</div>
<div sec:authorize="!isAuthenticated()">
    <a href="/login" th:href="@{/login}">Login</a>
</div>
메소드 시큐리티
@Configuration
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true, jsr250Enabled = true)
public class MethodSecurityConfig extends GlobalMethodSecurityConfiguration {
    @Override
    protected AccessDecisionManager accessDecisionManager() {
        RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
        roleHierarchy.setHierarchy("ROLE_ADMIN > ROLE_USER");
        AffirmativeBased accessDecisionManager = (AffirmativeBased)
                super.accessDecisionManager();
        accessDecisionManager.getDecisionVoters().add(new
                RoleHierarchyVoter(roleHierarchy));
        return accessDecisionManager;
    }
}
- @Secured와- @RollAllowed- 메소드 호출 이전에 권한을 확인한다.
 
- @PreAuthorize와- @PostAuthorize- 메소드 호출 이전에 권한을 확인하고 반환값이 있는 경우 @PostAuthorize를 통해 권한 확인이 가능하다.
 
- 메소드 호출 이전에 권한을 확인하고 반환값이 있는 경우 
@Service
public class SampleService {
//   @PreAuthorize("hasRole('USER')")
//   @PostAuthorize()
    @Secured({"ROLE_USER", "ROLE_ADMIN"})
    public void dashboard() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        System.out.println(userDetails.getUsername());
    }
}
@Test
// @WithMockUser
public void dashboard() throws Exception {
    Account account = new Account();
    account.setRole("ADMIN");
    account.setUsername("yhw");
    account.setPassword("123");
    accountService.createNew(account);
    UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken("yhw", "123");
    Authentication authenticate = authenticationManager.authenticate(token);
    SecurityContextHolder.getContext().setAuthentication(authenticate);
    sampleService.dashboard();
}
@AuthenticationPrincipal
- 커스텀 유저 클래스 구현하기
public class UserAccount extends User {
    private final Account account;
    public UserAccount(Account account) {
        super(account.getUsername(), account.getPassword(), List.of(new SimpleGrantedAuthority("ROLE_" + account.getRole())));
        this.account = account;
    }
    public Account getAccount() {
        return account;
    }
}
- AccountService수정
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    Account account = accountRepository.findByUsername(username);
    if (account == null) {
        throw new UsernameNotFoundException(username);
    }
    return new UserAccount(account);
}
@AuthenticationPrincipal UserAccount userAccount
- UserDetailsService구현체에서 리턴하는 객체를 매개변수로 받을 수 있다.
- 그 안에 들어있는 Account객체를 getter를 통해 참조할 수 있다.
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : account") Account account
- 익명 Authentication인 경우 (“anonymousUser”, AnonymousAuthenticationToken)에는null, 아닌 경우에는account필드를 사용한다.
- Account를 바로 참조할 수 있다.
@CurrentUser Account account
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : account") 
public @interface CurrentUser {
}
스프링 데이터 연동
implementation group: 'org.springframework.security', name: 'spring-security-data', version: '5.5.2'
@Query("select b from Book b where b.author.id = ?#{principal.account.id}")
List<Book> findCurrentUserBooks();
- 쿼리 메서드에서 principal참조 가능
 
      
    
댓글남기기