인프런/스프링 시큐리티 완전 정복 [6.x 개정판]

5) 인증프로세스 - BasicAuthenticationFilter , rememberMe() , RememberMeAuthenticationFilter, anonymous()

backend dev 2024. 10. 10.

BasicAuthenticationFilter

 

• 이 필터는 기본 인증 서비스를 제공하는 데 사용된다

 

BasicAuthenticationConverter 를 사용해서 요청 헤더에 기술된 인증정보의 유효성을 체크하며

Base64 인코딩된 username 과 password 를 추출한다

[유효성 체크 ==> Authentication이라는 헤더가 있는지, Basic이라는 인증기술이 적혀있는지 확인하는 절차 ]

 

• 인증 이후 세션을 사용하는 경우와 사용하지 않는 경우에 따라 처리되는 흐름에 차이가 있다.

세션을 사용하는 경우 매 요청 마다 인증과정을 거치지 않으나

세션을 사용하지 않는 경우 매 요청마다 인증과정을 거쳐야 한다[ex) jwt같은]

 

BasicAuthenticationFilter를 통해 유효성 체크를 진행하고, 성공하면 Base64 인코딩된 username 과 password 를 추출해서 인증을 위해 UsernamePasswordAuthenticationToken에 Username,Password를 담아준다.

그리고 AuthenticationManager에 UsernamePasswordAuthenticationToken를 전달한다.

그리고 AuthenticationManager가 인증을 시도한다.

 


요청 컨텍스트에 SecurityContext가 저장된다 =>
하나의 HTTP 요청이 처리되는 동안 유지되는 실행 환경을 의미한다.

Spring Security는 SecurityContextHolder를 사용하여 SecurityContext를 저장하고 관리하고

SecurityContextHolder는 기본적으로 ThreadLocal 전략을 사용한다.

ThreadLocal은 각 스레드마다 독립적인 변수를 가질 수 있게 해주는 Java의 기능이다.
웹 애플리케이션에서 각 요청은 보통 별도의 스레드에서 처리된다.

SecurityContextHolder는 전역적으로 하나만 존재하고
SecurityContextHolder는 ThreadLocal을 사용하여 각 스레드(즉, 각 HTTP 요청)에 대해 

독립적인 SecurityContext를 관리한다.

 

각 HTTP 요청은 자신만의 SecurityContext를 가지며, 

SecurityContext는 전역적인 SecurityContextHolder를 통해 ThreadLocal에 저장되고 접근된다.

따라서 해당 HTTP 요청 처리 중 어느 시점에서든 SecurityContextHolder를 통해

현재 요청에 대한 인증 정보에 접근할 수 있다.

[SecurityContextHolder는 정적 메서드를 통해 현재 스레드의 SecurityContext에 접근할 수 있게 해주는 

유틸리티 클래스이다.
실제로 저장되는 것은 SecurityContext이며, 이는 Authentication 객체를 포함한다.
이러한 구조 덕분에 멀티스레드 환경에서도 각 요청의 보안 컨텍스트가 안전하게 격리되어 관리된다.]

 

form인증은 http request동안 인증이 살아있는것이 아닌 세션범위동안 살아있다. 

즉 세션이 유지되고있다면 인증이 살아있다.

 

 

 

form 인증같은 세션기반 인증  


1. 세션 기반 인증:

   - Form 인증은 일반적으로 세션 기반 인증 방식을 사용합니다.
   - 사용자가 성공적으로 로그인하면, 서버는 세션을 생성하고 이 세션에 인증 정보(SecurityContext)를 저장합니다.

2. 세션 유지 기간:
   - 인증 상태는 세션이 유효한 동안 유지됩니다.
   - 세션은 여러 HTTP 요청에 걸쳐 유지됩니다.

3. SecurityContext 저장 및 복원:
   - SecurityContextPersistenceFilter가 각 요청의 시작에 세션에서 SecurityContext를 로드하고, 요청 종료 시 다시 세션에 저장합니다.
   - 이로 인해 사용자는 한 번 로그인하면 세션이 유효한 동안 계속 인증된 상태를 유지할 수 있습니다.

4. 세션 만료:
   - 세션이 만료되면 인증 정보도 함께 사라집니다.
   - 세션 만료는 설정된 시간이 지나거나, 서버가 재시작되거나, 사용자가 로그아웃할 때 발생할 수 있습니다.

5. 요청 간 인증 상태 유지:
   - 각 HTTP 요청은 독립적이지만, 세션을 통해 요청 간에 인증 상태가 유지됩니다.
   - 클라이언트는 보통 쿠키를 통해 세션 ID를 서버에 전달하여 자신의 세션을 식별합니다.

6. SecurityContextHolder의 역할:
   - 각 요청 처리 중에는 ThreadLocal을 통해 SecurityContext에 접근합니다.
   - 요청 간에는 세션을 통해 SecurityContext가 유지됩니다.

따라서 귀하의 말씀대로, Form 인증에서는 인증 상태가 단일 HTTP 요청의 범위를 넘어 세션의 수명 동안 유지됩니다. 이는 사용자 경험을 개선하고, 매 요청마다 재인증할 필요가 없게 해줍니다.

다만, 이러한 방식은 보안과 사용자 편의성 사이의 균형을 고려해야 합니다. 긴 세션 유지 시간은 편리하지만, 보안 위험을 증가시킬 수 있으므로 적절한 세션 관리 정책(예: 적절한 세션 타임아웃 설정, 중요한 작업 시 재인증 요구 등)이 필요합니다.

 

 

 

AuthenticationManager가 인증에 실패했을 때

AuthenticationManager가 인증에 실패했을 때, 정확히 말하면 SecurityContextHolder 안의 SecurityContext를 지우는 것입니다. 이 과정을 단계별로 설명하겠습니다

  1. 인증 실패 발생:
    • AuthenticationManager가 인증을 시도하고 실패하면 AuthenticationException을 던집니다.
  2. 예외 처리:
    • 이 예외는 일반적으로 인증을 처리하는 필터(예: UsernamePasswordAuthenticationFilter 또는 BasicAuthenticationFilter)에서 잡힙니다.
  3. SecurityContext 클리어:
    • 필터는 SecurityContextHolder.clearContext() 메소드를 호출합니다.
    • 이 메소드는 현재 스레드의 SecurityContext를 제거합니다.
  4. 구체적인 동작
SecurityContextHolder.clearContext();
  • 이 메소드는 SecurityContextHolder 자체를 삭제하는 것이 아니라, 현재 ThreadLocal에 저장된 SecurityContext를 null로 설정합니다.
  • SecurityContextHolder는 싱글톤 유틸리티 클래스로, 삭제되는 것이 아닙니다.
  1. 결과:
    • 이 작업 후에 SecurityContextHolder.getContext()를 호출하면 새로운 빈 SecurityContext 객체가 반환됩니다.
    • 즉, 인증 정보가 제거된 상태가 됩니다.
  2. 추가 처리:
    • 인증 실패 후, 일반적으로 AuthenticationEntryPoint가 호출되어 클라이언트에게 적절한 응답(예: 401 Unauthorized)을 보냅니다.

 

  • Remember Me 처리:
    • Remember Me 기능이 활성화되어 있다면, 기존의 Remember Me 토큰도 제거됩니다.
     
    if (rememberMeServices != null) { rememberMeServices.loginFail(request, response); }
  • 세션 처리:
    • 설정에 따라 현재 세션을 무효화할 수 있습니다.

    HttpSession session = request.getSession(false); if (session != null) { session.invalidate(); }
  • AuthenticationEntryPoint 호출:
    • 클라이언트에게 인증 실패를 알리고 적절한 응답을 보내기 위해 AuthenticationEntryPoint가 호출됩니다.
  • WWW-Authenticate 헤더 설정:
    • AuthenticationEntryPoint는 WWW-Authenticate 헤더를 설정합니다.
     
    response.addHeader("WWW-Authenticate", "Basic realm=\"" + getRealmName() + "\""); response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
  • 인증 실패 이벤트 발생:
    • AuthenticationFailureBadCredentialsEvent와 같은 이벤트가 발생할 수 있습니다.
     
    if (eventPublisher != null) { eventPublisher.publishEvent(new AuthenticationFailureBadCredentialsEvent( authenticationToken, new BadCredentialsException("Bad credentials"))); }
  • 로깅:
    • 인증 실패에 대한 로그가 기록됩니다.
     
    logger.warn("Authentication attempt failed: " + exception.getMessage());

 

 

 

주의할 점:

  • SecurityContextHolder 자체는 삭제되지 않습니다. 이는 애플리케이션 전반에 걸쳐 사용되는 정적 유틸리티 클래스입니다.
  • 실제로 삭제되는 것은 현재 스레드에 연결된 SecurityContext 인스턴스입니다.
  • 이 작업은 현재 요청의 컨텍스트에만 영향을 미칩니다. 다른 동시 요청의 SecurityContext에는 영향을 주지 않습니다.
  • WWW-Authenticate 헤더는 클라이언트에게 서버가 기대하는 인증 방식을 알려줍니다.
  • Remember Me 토큰 제거는 지속적인 인증 실패로 인한 보안 위험을 방지합니다.

요약: AuthenticationManager가 인증에 실패했을 때, SecurityContextHolder 안의 SecurityContext가 제거(클리어)되고, Remember Me 토큰이 제거되며, 적절한 응답 헤더가 설정됩니다. 또한, 인증 실패 이벤트가 발생하고 로깅이 이루어집니다. 이 과정을 통해 보안을 강화하고, 클라이언트에게 적절한 피드백을 제공하며, 시스템 관리자가 인증 실패를 모니터링할 수 있게 됩니다.

 


 

기억하기 인증 ­-  rememberMe()

 

 

RememberMe 인증

• 사용자가 웹 사이트나 애플리케이션에 로그인할 때 자동으로 인증 정보를 기억하는 기능이다

 

• UsernamePasswordAuthenticationFilter 와 함께 사용되며, AbstractAuthenticationProcessingFilter 슈퍼클래스에서 훅을 통해 구현된다

  • 인증 성공 시 RememberMeServices.loginSuccess() 를 통해 RememberMe 토큰을 생성하고 쿠키로 전달한다
  • 인증 실패 시 RememberMeServices.loginFail() 를 통해 쿠키를 지운다
  • LogoutFilter 와 연계해서 로그아웃 시 쿠키를 지운다

 

토큰 생성

기본적으로 암호화된 토큰으로 생성 되어지며 브라우저에 쿠키를 보내고,

향후 세션에서 이 쿠키를 감지하여 자동 로그인이 이루어지는 방식으로 달성된다

 

 

username: UserDetailsService 로 식별 가능한 사용자 이름

 

password: 검색된 UserDetails 에 일치하는 비밀번호

 

expirationTime: remember-me 토큰이 만료되는 날짜와 시간, 밀리초로 표현

 

key: remember-me 토큰의 수정을 방지하기 위한 개인 키

 

algorithmName: remember-me 토큰 서명을 생성하고 검증하는 데 사용되는 알고리즘(기본적으로 SHA-256 알고리즘을 사용)

 

-algorithmHex는 해당 내용[username..등]으로 암호화된다.

 

 

 

RememberMeServices 구현체

 

TokenBasedRememberMeServices - 쿠키 기반 토큰의 보안을 위해 해싱을 사용한다

 

PersistentTokenBasedRememberMeServices -생성된 토큰을 저장하기 위해 데이터베이스나 다른 영구 저장 매체를 사용한다

 

• 두 구현 모두 사용자의 정보를 검색하기 위한 UserDetailsService 가 필요하다

 

 

 

rememberMe() API

 

RememberMeConfigurer 설정 클래스를 통해 여러 API 들을 설정할 수 있다

 

• 내부적으로 RememberMeAuthenticationFilter 가 생성되어 자동 인증 처리를 담당하게 된다

 

 

 

rememberMe() 실습

@EnableWebSecurity
@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http, HttpSecurity httpSecurity) throws Exception {
        http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
                .formLogin(Customizer.withDefaults())
                .rememberMe(rememberMe -> rememberMe
                        .alwaysRemember(true) // true시 기억하기 체크박스를 체크하지않더라도 기억하기 기능을 사용하겠다라는 뜻
                        .tokenValiditySeconds(3600) // 토큰 만료시간 설정
                        .userDetailsService(userDetailsManager()) // / UserDetails 를 조회하기 위해 사용되는 UserDetailsService를 지정
                        .rememberMeParameter("remember") // 로그인 시 사용자를 기억하기 위해 사용되는 HTTP 매개변수이며 기본값은 'remember-me' 이다
                        .rememberMeCookieName("remember") // 기억하기(remember-me) 인증을 위한 토큰을 저장하는 쿠키 이름이며 기본값은 'remember-me' 이다
                        .key("security") // 기억하기(remember-me) 인증을 위해 생성된 토큰을 식별하는 키를 설정한다
                );

        return http.build();
    }


    @Bean
    public UserDetailsManager userDetailsManager() {
        UserDetails user = User.withUsername("user")
                .password("{noop}1234")
                .authorities("ROLE_USER")
                .build();
        UserDetails user2 = User.withUsername("user2")
                .password("{noop}1234")
                .authorities("ROLE_USER")
                .build();
        UserDetails user3 = User.withUsername("user3")
                .password("{noop}1234")
                .authorities("ROLE_USER")
                .build();
        return new InMemoryUserDetailsManager(user, user2, user3);
    }

}

 

remember me -> 로그인정보 저장 체크박스가 생성된걸 확인 가능하다.
.rememberMeParameter("remember") 로 인해 name이 remember로 되어있는것을 확인할 수 있다.

 

 

remember를 체크하고 로그인성공하면

쿠키에 remember라는 쿠키가 생성된것을 확인할 수 있다.

쿠키명이 remember인 이유는 

rememberMeCookieName("remember") 

위의 설정떄문이다.

 

로그인하기도전에 jsessionID가 있는 이유는 다음과 같다. [ claude]

 

Spring Security의 기본 동작과 관련이 있습니다. Spring Security를 사용할 때 JSESSIONID가 로그인 전에 생성되는 것은 일반적인 동작입니다. 이에 대해 좀 더 자세히 설명드리겠습니다:

  1. 세션 생성: Spring Security는 기본적으로 사용자가 애플리케이션에 첫 요청을 보낼 때 세션을 생성합니다. 이는 로그인 여부와 관계없이 발생합니다.
  2. 인증 전 세션: 이 세션은 사용자가 아직 인증되지 않은 상태에서도 생성됩니다. 이를 '인증 전 세션'이라고 부르기도 합니다.
  3. SecurityContextHolder: Spring Security는 이 세션을 사용하여 SecurityContextHolder를 관리합니다. SecurityContextHolder는 현재 사용자의 보안 컨텍스트 정보를 저장합니다.
  4. 세션 고정 보호: 이 방식은 세션 고정 공격(Session Fixation Attack)을 방지하는 데 도움이 됩니다. 로그인 성공 시 Spring Security는 새로운 세션 ID를 생성하여 이전 세션 ID를 대체합니다.
  5. 설정 변경 가능: 필요에 따라 이 동작을 변경할 수 있습니다. 예를 들어, sessionCreationPolicy를 STATELESS로 설정하면 세션을 생성하지 않게 할 수 있습니다.

예시 코드:

http.sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS);

 

성능과 확장성: 대부분의 경우 이 기본 동작은 문제가 되지 않지만, 매우 높은 트래픽을 처리해야 하는 경우에는 불필요한 세션 생성이 서버 부하를 증가시킬 수 있습니다.

 

 

 

쿠키에서 jsessionId를 삭제해도 remember가 있기 때문에 자동으로 재인증이 진행되기때문에 로그인페이지로 이동해서 다시 로그인할 필요가 없다. [ 재인증시 새로운 세션 생성 ]

 

.alwaysRemember(true) // true시 기억하기 체크박스를 체크하지않더라도 기억하기 기능을 사용하겠다라는 뜻

 

해당 설정을 이용하면 체크박스를 체크하지않더라도 rememberMe 쿠키가 생성되므로 위험할 수 있다.

 


 

기억하기 인증 필터 -  RememberMeAuthenticationFilter

 

RememberMeAuthenticationFilter

 

SecurityContextHolderAuthentication이 포함되지 않은 경우 실행되는 필터이다

 

• 세션이 만료되었거나 어플리케이션 종료로 인해 인증 상태가 소멸된 경우

토큰 기반 인증을 사용해 유효성을 검사하고 토큰이 검증되면 자동 로그인 처리를 수행한다

 

  1. RememberMeAuthenticationFilter의 동작:
    • 이 필터는 SecurityContextHolderAuthentication이 없을 때 동작합니다.
    • 주로 세션 만료나 애플리케이션 재시작 후 첫 요청 시 활성화됩니다.
  2. SecurityContextHolderSecurityContext:
    • SecurityContextHolderSecurityContext를 포함합니다.
    • SecurityContext 안에 Authentication 객체가 저장됩니다.
    • 정확한 구조는 다음과 같습니다: SecurityContextHolder > SecurityContext > Authentication
  3. 필터의 처리 과정:
    • SecurityContextHolder에서 SecurityContext를 가져옵니다.
    • SecurityContextAuthentication 객체의 존재 여부를 확인합니다.
    • Authentication이 없으면, Remember-Me 토큰을 확인합니다.
  4. 토큰 기반 인증:
    • Remember-Me 쿠키에서 토큰을 추출합니다.
    • 토큰의 유효성을 검사합니다 (만료 여부, 서명 확인 등).
    • 토큰이 유효하면 해당 사용자 정보로 새 Authentication 객체를 생성합니다.
  5. 자동 로그인 처리:
    • 새로 생성된 Authentication 객체를 SecurityContext에 설정합니다.
    • SecurityContextSecurityContextHolder에 저장합니다.
    • 결과적으로 사용자는 자동으로 로그인된 상태가 됩니다.
  6. 새 세션 생성:
    • 이 과정에서 새로운 세션이 생성되고, 새 JSESSIONID가 발급됩니다.

이 과정을 통해 Remember-Me 기능은 사용자가 명시적으로 로그아웃하지 않았다면, 브라우저를 닫았다가 다시 열거나 세션이 만료된 후에도 자동으로 로그인 상태를 유지할 수 있게 합니다.

 


익명 사용자 ­- anonymous()

 

익명 사용자

• 스프링 시큐리티에서 "익명으로 인증된" 사용자와 인증되지 않은 사용자 간에 실제 개념적 차이는 없으

단지 액세스 제어 속성을 구성하는 더 편리한 방법을 제공한다고 볼 수 있다

[ 익명 사용자 => 인증되지않은 사용자 ]

 

SecurityContextHolder 가 항상 Authentication 객체를 포함하고 null 을 포함하지 않는다는 것을

규칙을 세우게 되면 클래스를 더 견고하게 작성할 수 있다

[ 이 접근 방식으로 인해 null 체크가 필요 없어져 코드의 견고성이 향상됩니다.]

 

• 인증 사용자와 익명 인증 사용자를 구분해서 어떤 기능을 수행하고자 할 때 유용할 수 있으며

익명 인증 객체를 세션에 저장하지 않는다

 

• 익명 인증 사용자의 권한을 별도로 운용할 수 있다. 즉 인증 된 사용자가 접근할 수 없도록 구성이 가능하다

 

 

스프링 MVC 에서 익명 인증 사용하기

public String method(Authentication authentication) {
    if (authentication instanceof AnonymousAuthenticationToken) {
        return "anonymous";
    } else {
        return "not anonymous";
    }
}

스프링 MVC가 파라미터로 authentication 객체를 넣어줄때 httpServlet의 getPrincipal()을 이용하여 넣어준다.

익명사용자일경우에는 authentication에 null을 넣어서 반환한다. 

[ null이 반환되므로 익명요청이지만 결과는 not anonymous가 나오게 된다. ]

 

그래서

익명 요청에서 Authentication 을 얻고 싶다면 @CurrentSecurityContext를 사용하면 된다.

해당 어노테이션이 붙어있으면 CurrentSecurityContextArgumentResolver 에서 요청을 가로채어 넣어준다.

public String method(@CurrentSecurityContext SecurityContext context){
    return context.getAuthentication().getName();
}

 

 

AnonymousAuthenticationFilter

SecurityContextHolderAuthentication 객체가 없을 경우 감지하고 필요한 경우 새로운 Authentication 객체로 채운다

 

 

Authentication이 null이라면 AnonymousAuthenticationToken을 생성한다. 

이 토큰에는 보통 "anonymousUser"라는 principal과 "ROLE_ANONYMOUS" 권한이 포함된다.

 

생성된 AnonymousAuthenticationToken을 SecurityContextHolder의 SecurityContext에 설

 

 

테스트

@EnableWebSecurity
@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http, HttpSecurity httpSecurity) throws Exception {
        http.authorizeHttpRequests(auth -> auth
                        .requestMatchers("/anonymous").hasRole("GUEST") // 해당 경로에는 GUEST 권한을 가진 사용자만 접근가능
                        .requestMatchers("/anonymousContext","/authentication").permitAll() // 해당 경로는 아무 사용자나 접근가능
                        .anyRequest().authenticated())
                .formLogin(Customizer.withDefaults())
                .anonymous(httpSecurityAnonymousConfigurer -> httpSecurityAnonymousConfigurer
                                .principal("guest") // principal의 기본값은 anonymousUser 이다.
                                .authorities("ROLE_GUEST") // 기본값은 ROLE_ANONYMOUS
                        );
        return http.build();
    }

 

@GetMapping("/anonymous")
public String anonymous() {
    return "anonymous";
}

@GetMapping("/authentication")
public String authentication(Authentication authentication) {
    if(authentication instanceof AnonymousAuthenticationToken)
    {
        return "anonymous";
    }
    else{
        return "not anonymous";
    }
}

@GetMapping("/anonymousContext")
public String anonymousContext(@CurrentSecurityContext SecurityContext context) {
    return context.getAuthentication().getName();
}

 

 

로그인하지않은 상태에서 /anonymous 경로로 접근하면 

다음과 같이 경로에 접근가능하다.

.anonymous(httpSecurityAnonymousConfigurer -> httpSecurityAnonymousConfigurer
                .principal("guest") // principal의 기본값은 anonymousUser 이다.
                .authorities("ROLE_GUEST") // 기본값은 ROLE_ANONYMOUS
        );

이 설정으로 인해 인증되지않은 사용자 [익명사용자]는 GUEST라는 권한을 가지게 되고

/anonymous 경로는 GUEST 권한을 가진 사용자만 접근이 가능하기 때문이다.

 

 

로그인하고 다시 /anonymous에 접근해보면

403이라는 권한에러가 발생한다. 

 

 

 

logout은 controller에 따로 경로설정없어도 spring security가 구현해놓았다.

 

 

 

 

로그아웃후 http://localhost:8080/anonymousContext에 접근해보면

익명사용자의 principal이 잘 보이는것을 확인가능하다.

@GetMapping("/anonymousContext")
public String anonymousContext(@CurrentSecurityContext SecurityContext context) {
    return context.getAuthentication().getName();
}

 

 

댓글