Skip to content

jayden-lee/hello-spring-security

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

43 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Spring Security Study Repo

인프런 스프링 시큐리티 강좌를 학습하고 정리한 내용입니다

Prerequisites

  • Installing MySQL 5.7

Account Info

  • Normal User : user / 123
  • Admin User : admin / !@#

Password Encoder

비밀번호는 평문이 아닌 단방향 알고리즘으로 인코딩해서 저장해야 한다

  • {id}encodePassword
PasswordEncoder passwordEncoder = 
        PasswordEncoderFactories.createDelegatingPasswordEncoder();

Password Encoder 종류

  • BCryptPasswordEncoder
  • NoOpPasswordEncoder
  • Pbkdf2PasswordEncoder
  • ScryptPasswordEncoder
  • StandardPasswordEncoder

Spring Web Mock Mvc Test

@AutoConfigureMockMvc 를 사용하면 MockMvc 테스트를 진행할 수 있다

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class SpringBootTest {

}

Anonymous, User, Admin Test

Anonymous

@Test
@WithAnonymousUser
public void index_anonymous() throws Exception {
    mockMvc.perform(get(INDEX_PAGE))
        .andDo(print())
        .andExpect(status().isOk());
}

User

@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(username = "user", roles="USER")
public @interface WithNormalUser {
}

@Test
@WithNormalUser
public void index_user() throws Exception {
    mockMvc.perform(get(INDEX_PAGE))
        .andDo(print())
        .andExpect(status().isOk());
}

Admin

@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(username = "admin", roles="ADMIN")
public @interface WithAdminUser {
}

@Test
@WithAdminUser
public void admin_admin() throws Exception {
    mockMvc.perform(get(ADMIN_PAGE))
        .andDo(print())
        .andExpect(status().isOk());
}

SecurityContextHolder와 Authentication

  • SecurityContext 제공
  • 하나의 Thread에서 Authentication 공유하기 위해서 ThreadLocal 사용
  • AuthenticationPrincipalGrantAuthority 제공
    • Principal은 사용자에 대한 정보
    • GrantAuthority는 권한 정보 (인가 및 권한 확인할 때 사용)
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

// 사용자 정보
Object principal = authentication.getPrincipal();

// 사용자 권한
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();

// 인증 여부
boolean authenticated = authentication.isAuthenticated();

UserDetailsService 클래스는 DAO로 사용자 정보를 가져오는 작업을 수행한다. 실제 인증은 AuthenticationManager 인터페이스가 수행한다.

AuthenticationManager와 Authentication

  • 스프링 시큐리티에서 인증은 AuthenticationManager가 수행
  • SecurityContext는 인증 정보를 갖고 있음
  • 대부분 AuthenticationManager 인터페이스를 구현한 ProviderManager 구현체 클래스를 사용한다
public interface AuthenticationManager {

	Authentication authenticate(Authentication authentication)
			throws AuthenticationException;
}

DaoAuthenticationProvider

  • UsernamePasswordAuthenticationToken은 DaoAuthenticationProvider가 인증하는 작업을 처리
  • UserDetailsService 인터페이스를 구현한 클래스의 loadUserByUsername 메서드를 호출
  • AccountService 클래스의 loadUserByUsername 메서드는 User 객체를 반환
  • User 클래스는 UserDetails 인터페이스를 구현한 구체 클래스

DaoAuthenticationProvider

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();
    }
}

ThreadLocal

  • java.lang 패키지에서 제공하는 쓰레드 범위 변수
  • 쓰레드 수준의 데이터 저장소
  • 같은 쓰레드 내에서만 공유
  • 같은 쓰레드라면 해당 데이터를 메서드의 매개변수로 넘겨줄 필요 없음
public class AccountContext {

    private static final ThreadLocal<Account> ACCOUNT_THREAD_LOCAL
            = new ThreadLocal<>();

    public static void setAccount(Account account) {
        ACCOUNT_THREAD_LOCAL.set(account);
    }

    public static Account getAccount() {
        return ACCOUNT_THREAD_LOCAL.get();
    }

}

SecurityContextHolder에 Authentication 정보를 제공하는 필터

  1. UsernamePasswordAuthenticationFilter

    • AuthenticationManager를 이용해서 사용자가 입력한 로그인 정보(이름, 비밀번호)를 인증 UsernamePasswordAuthenticationFilter
    • 인증에 성공하면 successfulAuthentication 메서드를 호출
    • SecurityContextHolder의 SecurityContext에 인증 정보를 저장 AbstractAuthenticationProcessingFilter
  2. SecurityContextPersistenceFilter

    • HttpSessionSecurityContextRepository 저장소를 통해 SecurityContext 정보를 가져온다
    • 기본 전략으로 Http 세션에 저장하고 복원한다
    • Repository에서 가져온 SecurityContext 정보를 다시 SecurityContextHolder에 넣어 준다

스프링 시큐리티 Filter와 FilterChainProxy

  • FilterChainProxy는 요청(HttpServletRequest)에 따라 적합한 SecurityFilterChain을 사용
  • 기본 전략으로 DefaultSecurityFilterChain을 사용
  • DefaultSecurityFilterChain는 Filter 리스트를 가지고 있다
  • SecurityFilterChain을 여러개 만들고 싶으면 SecurityConfig 클래스를 여러개 만든다
    • 이 때 SecurityConfig가 상충할 수 있으니 Order 어노테이션을 통해 우선순위를 지정한다
  • Filter 개수는 SecurityConfig 설정에 따라 달라진다
  • FilterChainProxy는 필터를 호출하고 실행한다
  1. WebAsyncManagerIntergrationFilter
  2. SecurityContextPersistenceFilter
  3. HeaderWriterFilter
  4. CsrfFilter
  5. LogoutFilter
  6. UsernamePasswordAuthenticationFilter
  7. DefaultLoginPageGeneratingFilter
  8. DefaultLogoutPageGeneratingFilter
  9. BasicAuthenticationFilter
  10. RequestCacheAwareFilter
  11. SecurityContextHolderAwareReqeustFilter
  12. AnonymouseAuthenticationFilter
  13. SessionManagementFilter
  14. ExeptionTranslationFilter
  15. FilterSecurityInterceptor
@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();
    }

}

DelegatingFilterProxy

  • 일반적인 서블릿 필터
  • 서블릿 필터 처리를 스프링에 들어있는 빈으로 위임하고 싶을 때 사용하는 서블릿 필터
  • 타겟 빈 이름을 설정
  • 스프링 부트(자동 설정) 없이 스프링 시큐리티 설정할 때는 AbstractSecurityWebApplicationInitializer를 사용해서 등록
  • 스프링 부트를 사용할 때는 자동으로 등록 (SecurityFilterAutoConfiguration)
  • FilterChainProxyspringSecurityFilterChain 이름으로 빈 등록
public abstract class AbstractSecurityWebApplicationInitializer
		implements WebApplicationInitializer {

	private static final String SERVLET_CONTEXT_PREFIX = "org.springframework.web.servlet.FrameworkServlet.CONTEXT.";

	public static final String DEFAULT_FILTER_NAME = "springSecurityFilterChain";

    ...
}

SecurityFilterAutoConfiguration

AccessDecisionManager

Access Control 결정을 내리는 인터페이스, 구현체 3가지를 기본으로 제공한다

  • AffirmativeBased : 여러 Voter 중에 한 명이라도 허용하면 인가 (기본 전략)
  • ConsensusBased : 다수결
  • UnanimousBased : 만장일치
public interface AccessDecisionManager {

	void decide(Authentication authentication, Object object,
			Collection<ConfigAttribute> configAttributes) throws AccessDeniedException,
			InsufficientAuthenticationException;

	boolean supports(ConfigAttribute attribute);


	boolean supports(Class<?> clazz);
}

AccessDecisionVoter

  • Authentication이 특정한 Object에 접근할 때 필요한 ConfigAttribute를 만족하는지 확인
  • WebExpressionVoter : 웹 시큐리티에서 사용하는 기본 구현체, ROLE_XXX 일치하는지 확인
  • RoleHierarchyVoter : 계층형 Role 지원

Custom AccessDecisionManager

  • RoleHierarchyImpl 객체에 Role 계층을 설정
public AccessDecisionManager accessDecisionManager() {
    RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
    roleHierarchy.setHierarchy("ROLE_ADMIN > ROLE_USER");

    DefaultWebSecurityExpressionHandler handler = new DefaultWebSecurityExpressionHandler();
    handler.setRoleHierarchy(roleHierarchy);

    WebExpressionVoter webExpressionVoter = new WebExpressionVoter();
    webExpressionVoter.setExpressionHandler(handler);

    List<AccessDecisionVoter<? extends Object>> voters = Arrays.asList(webExpressionVoter);
    return new AffirmativeBased(voters);
}

FilterSecurityInterceptor

  • FilterChainProxy가 호출하는 시큐리티 필터 목록 중에 하나이며, 대부분 가장 마지막에 위치함
  • 인증이 된 상태에서 특정 리소스에 접근할 수 있는지 Role을 확인함
  • AccessDecisionManager를 사용해서 Access Control 또는 예외 처리하는 필터

AbstractSecurityInterceptor

  • FilterSecurityInterceptor 클래스의 부모 클래스

AbstractSecurityInterceptor

ExceptionTranslationFilter

  • 필터 체인에서 발생하는 AccessDeniedExceptionAuthenticationException을 처리하는 필터

ExceptionTranslationFilter

AuthenticationException

  • 인증에 실패할 때 발생하는 예외
  • AbstractSecurityInterceptor 하위 클래스에서 발생하는 예외만 처리

AccessDeniedException

  • 익명 사용자라면 AuthenticationEntryPoint 실행 (로그인 페이지로 이동)
  • 익명 사용자가 아니라면 AccessDeniedHandler에게 위임

스프링 시큐리티 적용 무시하기 (ignoring)

인증이 필요없는 페이지를 접속할 때 favicon.ico와 같은 정적 자원을 요청하는 경우에 FilterChainProxy 리스트의 필터를 타게 된다. 아래 이미지에서 favicon.ico를 요청하면 DefaultLoginPageGeneratingFilter 필터가 인증을 위해서 login 요청을 다시 하게된다.

before_ignoring

이러한 정적 자원을 필터에서 제외하기 위해서는 다음과 같이 WebSecurity에 ignoring을 설정해야 한다. CommonLocations은 5개의 자원에 대해 필터를 무시하도록 한다.

@Override
public void configure(WebSecurity web) {
    web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}

StaticResourceLocation

WebSecurityConfigurerAdapter 상속 받은 클래스에서 정적 자원을 무시하도록 설정하고, 다시 인증이 필요없는 페이지를 접속하게 되면 다음과 같이 스프링 필터를 적용하지 않고 바로 정적 자원을 전달한다.

after_ignoring

WebAsyncManagerIntegrationFilter

스프링 MVC의 Async 기능을 사용할 때도 SecurityContext를 공유하도록 도와주는 필터

  • PreProcess: SecurityContext를 설정한다.
  • Callable: 비록 다른 쓰레드지만 그 안에서는 동일한 SecurityContext를 참조할 수 있다.
  • PostProcess: SecurityContext를 정리(clean up)한다.

MVC 요청이 들어오는 쓰레드 작업을 완료하고 나서도 SecurityContextHolder에서는 사용자 정보를 동일하게 얻을 수 있다. 그 역할을 WebAsyncManagerIntegrationFilter가 수행한다.

@Controller
public class SampleController {

    @GetMapping("/async-handler")
    @ResponseBody
    public Callable<String> asyncHandler() {
        // http-nio-8080-exec 쓰레드
        SecurityLogger.log("MVC");

        return () -> {
            // task-1 쓰레드
            SecurityLogger.log("Callable");
            return "Async Handler";
        };
    }
    
}

SecurityContextCallableProcessingInterceptor

WebAsyncManagerIntegrationFilter는 SecurityContextCallableProcessingInterceptor를 사용해서 SecurityContextHolder에 SecurityContext 정보를 저장한다.

SecurityContextCallableProcessingInterceptor

@Async 서비스에서 SecurityContextHolder 공유

  • SecurityContextHolder 기본 전략은 ThreadLocal
  • @Async 서비스에서 SecurityContextHolder가 공유 되지 않는 문제가 발생함
  • SecurityContextHolder 전략을 다음 코드와 같이 바꾸면 쓰레드 계층 사이에서도 SecurityContextHolder 정보가 공유된다
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);

SecurityContextPersistenceFilter

SecurityContextRepository를 사용해서 기존의 SecurityContext 정보를 읽어오거나 초기화한다

  • 기본으로 사용하는 전략은 HTTP Session 사용 (HttpSessionSecurityContextRepository)
  • Spring-Session과 연동하여 세션 클러스터를 구현할 수 있다

HeaderWriterFilter

응답 헤더에 시큐리티 관련 헤더를 추가해주는 필터

  • XContentTypeOptionsHeaderWriter : 마임 타입 스니핑 방어.
  • XXssProtectionHeaderWriter : 브라우저에 내장된 XSS 필터 적용.
  • CacheControlHeadersWriter : 캐시 히스토리 취약점 방어.
  • HstsHeaderWriter : HTTPS로만 소통하도록 강제.
  • XFrameOptionsHeaderWriter : clickjacking 방어.

response-headers

CsrfFilter

CSRF 어택 방지 필터

  • 인증된 유저의 계정을 사용해서 악의적인 변경 요청을 만들어 보내는 기법
  • 의도한 사용자만 리소스를 변경할 수 있도록 허용하는 필터
  • CSRF 토큰을 사용하여 체크

CsrfFilter Token

form 형식에 hidden 타입으로 csrf 토큰 값이 포함되어 있다

csrf-token

Postman을 이용해서 /signup POST 요청을 보내면, 401 Unauthorized 에러가 발생한다. 이유는 csrf 토큰 값이 없어서 폼 인증이 되지 않기 때문에 발생한다.

csrf-401-code

CsrfFilter Test

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class SignUpControllerTest {

    @Autowired
    MockMvc mockMvc;

    // SignUp Get 요청
    @Test
    public void signUpForm() throws Exception {
        mockMvc.perform(get("/signup"))
                .andExpect(status().isOk())
                .andExpect(content().string(containsString("_csrf")));
    }

    // SignUp Post 요청, csrf 토큰을 포함 
    @Test
    public void processSignUp() throws Exception {
        mockMvc.perform(post("/signup")
                .param("username", "jayden")
                .param("password", "123")
                .with(csrf()))
                .andDo(print())
                .andExpect(status().is3xxRedirection());
    }
}

CsrfFilter 비활성화

http.csrf().disable();

LogoutFilter

여러 LogoutHanlder를 사용하여 로그아웃시 필요한 작업을 수행한다. 그리고 LogoutSuccessHandler를 사용해서 로그아웃 후처리를 한다.

Default LogoutHanlder

  • CsrfLogoutHandler
  • SecurityContextLogoutHandler

Default LogoutSuccessHandler

  • SimpleUrlLogoutSuccessHandler

UsernamePasswordAuthenticationFilter

폼 로그인을 처리하는 인증 필터

  • 사용자가 폼에 입력한 정보를 토대로 Authentication 객체를 생성하고 AuthenticationManager를 사용하여 인증을 시도한다
  • AuthenticationManager(ProviderManager)는 여러 AuthenticationProvider를 사용하여 인증을 시도하는데, 그 중 DaoAuthenticationProvider는 UserDetailsService를 사용하여 UserDetails 정보를 가져와서 사용자가 입력한 정보와 동일한지 비교한다

DefaultLoginPageGeneratingFilter

기본 로그인 페이지를 생성하는 필터

사용자 이름과 비밀번호 파라미터 이름 변경

http.formLogin()
        .usernameParameter("app_username")
        .passwordParameter("app_password");

DefaultLoginPageGeneratingFilter

커스텀 로그인 페이지

커스텀 로그인 페이지를 등록하면 FilterChainProxy에서 DefaultLoginPageGeneratingFilterDefaultLogoutPageGeneratingFilter 두 필터가 제외됨

http.formLogin()
        .loginPage("/login");

DefaultLogoutPageGeneratingFilter

기본 로그아웃 페이지를 생성하는 필터

로그인/로그아웃 폼 커스터마이징

로그인/로그아웃 폼 페이지를 커스터마이징 하기 위해서 LogInOutController를 생성한다. 이 컨트롤러는 Get 요청으로 로그인/로그아웃 페이지를 반환한다.

@Controller
public class LogInOutController {

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

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

}

SpirngSecurity 설정에서 로그인 폼 페이지 URL과 로그아웃 URL을 설정한다.

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
            .mvcMatchers("/", "/info", "/signup").permitAll()
            .mvcMatchers("/admin").hasRole("ADMIN")
            .mvcMatchers("/user").hasRole("USER")
            .anyRequest().authenticated()
            .accessDecisionManager(accessDecisionManager());


    http.httpBasic();

    http.formLogin()
            .loginPage("/login")
            .permitAll();

    http.logout()
            .logoutUrl("/logout")
            .logoutSuccessUrl("/");

    SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
}

BasicAuthenticationFilter

  • Http Basic 인증을 지원하는 필터
  • 요청 헤더에 아이디와 패스워드를 보내면 브라우저 또는 서버가 그 값을 읽어서 인증하는 방식
  • 정보는 Base64 인코딩 되어 보내지고 읽을 때 다시 디코딩해서 값을 읽는다
  • 스니핑하면 요청 정보를 쉽게 취득하는 위험이 있기 때문에 HTTPS를 사용할 것을 권장
http.httpBasic();

RequestCacheAwareFilter

현재 요청과 관련 있는 캐시된 요청이 있는지 찾아서 적용하는 필터

  • 캐시된 요청이 없다면, 현재 요청 처리
  • 캐시된 요청이 있다면, 캐시된 요청 처리

대시보드(로그인이 필요한 페이지) 페이지를 접속하려고 하면 로그인 페이지로 이동한다. 로그인 페이지에서 로그인 인증을 수행하고 나면, RequestCacheAwareFilter에서 캐시한 요청(대시보드 페이지로 이동하려는 요청)을 수행한다.

public class RequestCacheAwareFilter extends GenericFilterBean {

	private RequestCache requestCache;

	public RequestCacheAwareFilter() {
		this(new HttpSessionRequestCache());
	}

	public RequestCacheAwareFilter(RequestCache requestCache) {
		Assert.notNull(requestCache, "requestCache cannot be null");
		this.requestCache = requestCache;
	}

	public void doFilter(ServletRequest request, ServletResponse response,
			FilterChain chain) throws IOException, ServletException {

		HttpServletRequest wrappedSavedRequest = requestCache.getMatchingRequest(
				(HttpServletRequest) request, (HttpServletResponse) response);

        // 캐시된 요청이 있는지 체크하고 현재 요청을 처리할지 캐시된 요청을 처리할지 결정
		chain.doFilter(wrappedSavedRequest == null ? request : wrappedSavedRequest,
				response);
	}

}

SecurityContextHolderAwareRequestFilter

시큐리티 관련 서블릿 API를 구현해주는 필터

  • HttpServletRequest#authenticate(HttpServletResponse)
  • HttpServletRequest#login(String, String)
  • HttpServletRequest#logout()
  • AsyncContext#start(Runnable)

AnonymousAuthenticationFilter

SecurityContext에 Authentication이 null 값이면, 익명 Authentication을 생성해서 넣어준다. Authentication이 null 값이 아니면, 아무일도 하지 않는 필터이다. (null object pattern)

AnonymousAuthenticationFilter_dofilter

스프링 시큐리티는 별도의 설정이 없어도 AnonymousUser를 기본적으로 생성한다. Principal은 anonymousUser이고 권한은 ROLE_ANONYMOUS로 설정한다.

AnonymousAuthenticationFilter

SessionManagementFilter

  • 세션 변조 방지 전략 설정
    • 세션 변조 방지 전략으로 changeSessionId로 설정
    http.sessionManagement()
            .sessionFixation()
            .changeSessionId();
  • 유효하지 않은 세션을 리다이렉트 시킬 URL 설정
  • 동시성 제어
    • 세션 개수 제어
    • 추가 로그인을 막을지 여부 (기본값은 false)
    http.sessionManagement()
            .maximumSessions(1)
            .maxSessionsPreventsLogin(true);
  • 세션 생성 전략
    1. ALWAYS
    2. NEVER
    3. IF_REQUIRED
    4. STATELESS

ExceptionTranslationFilter

  • try-catch 구문으로 감싸고 FilterSecurityInterceptor를 처리한다
  • FilterSecurityInterceptor는 AccessDecisionManager를 이용해서 인가 처리를 함
  • AuthenticationEntryPoint, AccessDeniedException 예외를 처리함

FilterSecurityInterceptor

  • Http 리소스 시큐리티 처리를 담당하는 필터
  • AccessDecisionManager를 사용하여 인가를 처리
http.authorizeRequests()
        .mvcMatchers("/", "/info", "/signup").permitAll()
        .mvcMatchers("/admin").hasRole("ADMIN")
        .mvcMatchers("/user").hasRole("USER")
        .anyRequest().authenticated()
        .accessDecisionManager(accessDecisionManager());

security-filter-list

RememberMeAuthenticationFilter

  • 세션이 사라지거나 만료가 되더라도 쿠키 또는 DB를 사용하여 저장된 토큰 기반으로 인증을 지원하는 필터

RememberMe 설정

페이지에 접속하면 서버에서 세션이 생성되고 웹 브라우저 쿠키에 세션 아이디 정보가 담긴다. 로그인 하고 나면 서버는 해당 세션을 인증된 세션으로 취급한다.

사용자가 웹 브라우저 쿠키에서 세션 아이디를 삭제하게 되면, 인증된 세션이 아니기 때문에 서버는 다시 로그인 창으로 리다이렉트 된다.

세션 아이디를 삭제하면 SecurityContextHolder에서 인증 정보를 가져올 수 없기 때문에 서버는 인증되지 않은 사용자로 판단하고 인증이 필요한 페이지의 접속을 막는다.

session-id

다음과 같이 rememberMe 설정을 하고 로그인할 때, remember-me 파라미터를 넘기면 remember-me 쿠키 정보가 생기게 된다. remember-me 쿠키에는 사용자 이름과 유효 기간 정보를 포함하고 있다.

http.rememberMe()
        .userDetailsService(accountService)
        .key("remember-me");

remember-me

앞에서 한 것처럼 다시 세션 아이디를 삭제하고 나서 다시 인증이 필요한 페이지를 요청하면 로그인 페이지로 리다이렉트 하지 않는다. 필터 체인 목록에서 RememberMeAuthenticationFilterRememberMeAuthenticationToken 정보를 이용해서 인증하고, 인증된 정보를 다시 SecurityContextHolder에 넣어준다.

RememberMeAuthenticationFilter

크롬 웹 브라우저에서 현재 접속한 페이지의 쿠키 정보를 쉽게 확인할 수 있는 플러그인으로 EditThisCookie를 설치해서 사용했다.

커스텀 필터 추가하기

Filter를 생성하는 것은 여러 방법이 있지만 이번에 추가하는 LoggingFilter는 GenericFilterBean 클래스를 상속 받아서 구현하도록 한다. GenericFilterBean 클래스에는 기본적인 설정이 되어 있기 때문에 상속 받은 클래스가 doFilter 메서드만 오버라이드 하면 된다.

public class LoggingFilter extends GenericFilterBean {

    private Logger logger = LoggerFactory.getLogger(this.getClass());

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();

        chain.doFilter(request, response);

        stopWatch.stop();
        logger.info(stopWatch.prettyPrint());
    }
}

새로 생성한 LoggingFilter 필터를 필터 목록에서 원하는 위치로 설정할 수 있다. WebAsyncManagerIntegrationFilter 필터는 필터 목록에서 가장 첫 번째에 위치하는 필터이다.

http.addFilterBefore(new LoggingFilter(), WebAsyncManagerIntegrationFilter.class);

loggingfilter

메서드 시큐리티

  • 스프링 시큐리티 기능을 웹 또는 데스크탑 애플리케이션에서도 사용할 수 있도록 도와주는 기능
  • 메서드 시큐리티를 사용하기 위해서는 다음과 같은 설정 클래스를 생성해야 한다
@Configuration
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true, jsr250Enabled = true)
public class MethodSecurity {
    
}
  • 권한에 따라 특정 메서드를 실행 유무를 설정하고 싶으면 @Secured 애노테이션과 함께 ROLE_USER 이름을 추가한다
  • @Secured, @RolesAllowed, PreAuthorize 애노테이션들은 dashboard 메서드를 호출하기 전에 권한 검사를 수행한다
  • @PostAuthorize 애노테이션은 dashboard 메서드를 실행한 이후에 권한을 체크한다
@Secured("ROLE_USER")
@RolesAllowed("ROLE_USER")
@PreAuthorize("hasRole(USER)")
public void dashboard() {
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    UserDetails userDetails = (UserDetails) authentication.getPrincipal();
    System.out.println("================");
    System.out.println(authentication);
    System.out.println(authentication.getName());
}

@AuthenticationPrincipal

기존에 Principal 정보를 얻으려면 다음과 같은 두 가지 방법을 사용했다. 첫번째 방법을 통해 얻은 Principal 객체에서는 사용자 이름 정보만 가져올 수 있는 단점이 있다. 두 번째 방법은 우리가 선언한 도메인 타입의 클래스로 변환하면 이름, 역할, 비밀번호 정보를 얻을 수 있다.

  1. Argument에 Principal를 추가

    @GetMapping(value = "/dashboard")
    public String dashboard(Model model, Principal principal) {
        model.addAttribute("message", "Hello " + principal.getName());
        AccountContext.setAccount(accountRepository.findByUsername(principal.getName()));
        sampleService.dashboard();
        return "dashboard";
    }
  2. SecurityContextHolder에서 가져오는 방법

    Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();

이번에 살펴볼 @AuthenticationPrincipal 애노테이션을 사용하면 우리가 선언한 도메인 타입의 Principal 정보를 매개변수로 받을 수 있다. 애노테이션을 확인하고 ArgumentResolver가 현재 로그인한 사용자 정보를 만들어서 넣어준다.

@GetMapping(value = "/")
public String index(Model model, @AuthenticationPrincipal UserAccount userAccount) {
    if (userAccount == null) {
        model.addAttribute("message", "Hello Spring Security");
    } else {
        model.addAttribute("message", "Hello " + userAccount.getUsername());
    }
    return "index";
}

UserAccount 클래스는 User 클래스를 상속 받아 구현한다.

@Getter
public class UserAccount extends User {

    private Account account;

    public UserAccount(Account account) {
        super(account.getUsername(), account.getPassword(), List.of(new SimpleGrantedAuthority("ROLE_" + account.getRole())));
        this.account = account;
    }

}

UserAccount 객체에서 Account 정보만 가져오고 싶을 때는 다음과 같은 방법을 사용한다

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : account")
public @interface CurrentUser {
}
@GetMapping(value = "/")
public String index(Model model, @CurrentUser Account account) {
    if (account == null) {
        model.addAttribute("message", "Hello Spring Security");
    } else {
        model.addAttribute("message", "Hello " + account.getUsername());
    }
    return "index";
}