5장 인증 구현
이 블로그 내용은 아래 도서를 학습한 내용입니다.
학습 도서 정보
스프링 시큐리티 인 액션(Spring Security in Action)
로렌티우 스필카 지음
최민석 옮김
위키북스
- 맞춤형 AuthenticationProvider로 인증 논리 구현
- HTTP Basic 및 양식 기반 로그인 인증 메서드 이용
- SecuriryContext 구성 요소의 이해 및 관리
AuthenticationProvider 계층에서는 인증 논리를 담당하고 여기에서 요청을 허용할지 결정하는 조건과 명령을 발견할 수 있다.
AuthenticationManager는 HTTP 필터 계층에서 요청을 수신하고 이 책임을 AuthenticationProvider에 위임하는 구성요소다.
AUthenticationProvider는 인증 논리를 구현하고 SecuriryCotext는 인증된 요청에 대한 세부 정보를 저장한다.
- 요청하는 엔티티가 인증되지 않는다. 애플리케이션이 사용자를 인식하지 못해 권한 부여 프로세스에 위임하지 않고 요청을 거부한다. 일반적으로 이 경우 클라이언트에 HTTP 401 권한 없음 상태가 반환된다.
- 요청하는 엔티티가 인증된다. 요청자의 세부 정보가 저장돼 있어 애플리케이션이 이를 권한 부여에 이용할 수 있다. 현재 인증된 요청에 대한 세부 정보는 SecuriryContext 인터페이스의 인스턴스에 저장된다.
5.1 AuthenticationProvider의 이해
앤터프라이즈 애플리케이션에서는 사용자 이름과 암호 기반의 기본 인증 구현이 적합하지 않을 수 있다.
인증 관련해서 애플리케이션이 여러 시나리오를 구현해야 할 수 있다.
어떠한 시나리오가 필요하더라도 구현할 수 있게 해주는 것이 프레임워크의 목적이다.
AuthenticationProvider 계약으로 모든 맞춤형 인증 논리를 정의할 수 있다.

5.1.1 인증 프로세스 중 요청 나타내기
Authentication 인터페이스는 인증 요청 이벤트를 나타내며 애플리케이션에 접근을 요청한 엔티티의 세부 정보를 담는다.
주체(Principal)은 애플리케이션에 접근을 요청하는 사용자이다.

public interface Authentication extends Principal, Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
Object getCredentials();
Object getDetails();
Object getPrincipal();
boolean isAuthenticated();
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}
- isAuthenticated()
- 인증 프로세스가 끝났으면 true를 반환하고 아직 진행중이면 false를 반환한다.
- getCredentials()
- 인증 프로세스에 이용된 암호나 비밀을 반환한다.
- getAuthorities()
- 인증된 요청에 허가된 권한의 컬렉션을 반환한다.
5.1.2 맞춤형 인증 논리 구현
AuthenticationProvider 인터페이스의 기본 구현은 시스템의 사용자를 찾는 책임을 UserDetailsService에 위임하고 PasswordEncoder로 인증 프로세스에서 암호를 관리한다.
public interface AuthenticationProvider {
Authentication authenticate(Authentication authentication) throws AuthenticationException;
boolean supports(Class<?> authentication);
}
AuthenticationProvider 책임은 Authentication 계약과 강하게 결합돼 있다. authenticate() 메서드는 Authentication 객체를 매개 변수로 받고 Authentication 객체를 반환한다. 인증 논리를 정의하려면 authenticate() 메서드를 구현해야 하는데, authenticate() 메서드를 구현하는 방법을 다음 세 항목으로 요약할 수 있다.
- 인증이 실패하면 메서드는 AuthenticationException을 투척해야 한다.
- 메서드가 현재 AuthenticationProvider 구현에서 지원되지 않는 인증 객체를 받으면 null을 반환해야 한다. 이렇게 하면 HTTP 필터 수준에서 분리된 여러 Authentication 형식을 사용할 가능성이 생긴다. (9장, 11장)
- 메서드는 완전히 인증된 객체를 나타내는 Authetnication 인스턴스를 반환해야 한다. 이 인스턴스에 대해 isAuthenticated() 메서드는 true를 반환하며, 여기에는 인증된 엔티티의 모든 필수 세부 정보가 포함된다. 또한 일반적으로 애플리케이션은 이 인스턴스에서 암호와 같은 민감한 데이터를 제거해야 한다. 인증한 후에는 암호가 더는 필요 없으며 이러한 세부 정보를 그냥 두면 원치 않게 유출될 유려가 있다.
AuthenticationProvider 인터페이스의 두 번째 메서드는 supports(Class<?> authentication)다. 이 메서드는 현재 AuthenticationProvider가 Authentication 객체로 제공된 형식을 지원하면 true를 반환하도록 구현한다. 주의할 점은 이 메서드가 객체에 대해 true를 반환해도 authentication() 메서드가 null을 반환해 요청을 거부할 수 있다는 점이다. 스프링 시큐리티는 이와 같이 인증 유형만이 아니라 요청의 세부 정보를 기준으로 인증 요청을 거부하는 AuthenticationProvider를 구현할 수 있도록 설계됐다.

5.1.3 맞춤형 인증 논리 적용
- AuthenticationProvider 계약을 구현하는 클래스를 선언한다.
- 새 AuthenticationProvider가 어떤 종류의 Authentication 객체를 지원할지 결정한다.
- 정의하는 AuthenticationProvider가 지원하는 인증 유형을 나타내도록 Supports(Class<?> c) 메서드를 재정의 한다.
- authentication(Authentication a) 메서드를 재정의해 인증 논리를 구현한다.
- 새 AuthenticationProvider 구현의 인스턴스를 스프링 시큐리티에 등록한다.
@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public Authentication authenticate(Authentication authentication) {
String username = authentication.getName();
String password = authentication.getCredentials().toString();
UserDetails u = userDetailsService.loadUserByUsername(username);
if (passwordEncoder.matches(password, u.getPassword())) {
return new UsernamePasswordAuthenticationToken(username, password, u.getAuthorities());
} else {
throw new BadCredentialsException("Something went wrong!");
}
}
@Override
public boolean supports(Class<?> authenticationType) {
return authenticationType.equals(UsernamePasswordAuthenticationToken.class);
}
}
@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AuthenticationProvider authenticationProvider;
@Bean
public UserDetailsService userDetailsService(DataSource dataSource) {
return new JdbcUserDetailsManager(dataSource);
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) {
auth.authenticationProvider(authenticationProvider);
}
}

5.2 SecurityContext 이용
5.2.1 보안 컨텍스트를 위한 보유 전략 이용
AuthenticationManager는 인증 프로세스를 성공적으로 완료한 후 요청이 유지되는 동안 Authentication 인스턴스를 저장한다.
Authetication 객체를 저장하는 인스턴스를 보안 컨텍스트라고 한다.

스프링 시큐리티의 보안 컨텍스트는 SecurityContext 인터페이스로 기술된다.
public interface SecurityContext extends Serializable {
Authentication getAuthentication();
void setAuthentication(Authentication var1);
}
SecurityContext의 주 책임은 Authentication 객체를 저장하는 것이다.
SecurityContext를 관리하는 세 가지 전략
- MODE_THREADLOCAL
- 각 스레드가 보안 컨텍스트에 각자의 세부 정보를 저장할 수 있게 해준다. 요청당 스레드 방식의 웹 애플리케이션에서는 각 요청이 개별 스레드를가지므로 이는 일반적인 접근법이다.
- 스프링 시큐리티가 보안 컨텍스트를 관리하는 기본 전략이다.
- MODE_INHERITABLETHREADLOCAL
- MODE_THREADLOCAL과 비슷하지만 비동기 메서드의 경우 보안 컨텍스트를 다음 스레드로 복사하도록 스프링 시큐리티에 지시한다. 이 방식으로 @Async 메서드를 실행하는 새 스레드가 보안 컨텍스트를 상속하게 할 수 있다.
- MODE_GLOBAL
- 애플리케이션의 모든 스레드가 같은 보안 컨텍스트를 인스턴스를 보게 한다.

SecurityContextHolder에서 SecurityContext 얻기
@GetMapping("/hello")
public String hello() {
SecurityContext context = SecurityContextHolder.getContext();
Authentication a = context.getAuthentication();
return "Hello, " + a.getName() + "!";
}
스프링은 인증 메서드를 매개 변수에 곧바로 주입할 수 있다.
@GetMapping("/hello")
public String hello(Authentication a) {
return "Hello, " + a.getName() + "!";
}
5.2.2 비동기 호출을 위한 보유 전략 이용
@GetMapping("/bye")
@Async
public void goodbye() {
SecurityContext context = SecurityContextHolder.getContext();
// nullPointerException 투척 (context가 null 이다.)
String username = context.getAuthentication().getName();
}
- 엔드포인트가 비동기가 되면 메서드를 실행하는 스레드와 요청을 수행하는 스레드가 다른 스레드가 된다.
- 메서드가 보안 컨텍스트를 상속하지 않는 다른 스레드에서 실행되기 때문에 NPE가 발생된다.
- MODE_INHERIATABLETHREADLOCAL 전략으로 이 문제를 해결할 수 있다.
- 이 전략을 설정하려면 SecurityContextHolder.setStrategyName() 메서드를 호출하거나 spring.security.strategy 시스템 속성을 이용하면 된다.
- 이 전략을 설정하면 프레임워크는 요청의 원레 스레드에 있는 세부 정보를 비동기 메서드의 새로 생성된 스레드로 복사한다.
- 이 방식은 프레임워크가 자체적으로 스레드를 만들 때만 작동한다.(예: @Async 메서드 이용)
InitializingBean을 이용해 SecurityContextHolder 모드 설정
@Configuration
@EnableAsync
public class ProjectConfig {
@Bean
public InitializingBean initializingBean() {
return () -> SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
}
}

5.2.3 독립형 애플리케이션을 위한 보유 전략 이용
- 보안 컨텍스트가 애플리케이션의 모든 스레드에 공유 되는 전략을 원한다면 MODE_GLOBAL을 이용하면 된다.
- 이 전략은 일반적인 애플리케이션의 그림에는 맞지 않기 때문에 웹 서버는 이용되지 않는다.
- 독립형 애플리케이션에는 공유하는 것이 좋은 전략일 수 있다.
보안 컨텍스트 관리 전략으로 MODE_GLOBAL을 이용하면 모든 스레드가 같은 보안 컨텍스트에 접근한다. 이것은 모든 스레드가 같은 데이터 접근하고 해당 정보를 변경할 수 있다는 의미다. 따라서 경합 상황이 발생할 수 있으므로 동기화를 처리해야한다.
@Configuration
@EnableAsync
public class ProjectConfig {
@Bean
public InitializingBean initializingBean() {
return () -> SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_GLOBAL);
}
}
5.2.4 DelegatingSecurityContextRunnable로 보안 컨텍스트 전달

DelegatingSecurityContextCallable은 Callable 객체의 데코레이터로 디자인됐다. 이러한 객체를 구축할 때는 애플리케이션이 비동기적으로 실행할 Callable 작업을 지정한다. DelegatingSecurityContextCallable은 보안 컨텍스트의 세부 정보를 새 스레드로 복사한 후 작업을 실행한다.
@GetMapping("/ciao")
public String ciao() throws Exception {
Callable<String> task = () -> {
SecurityContext context = SecurityContextHolder.getContext();
return context.getAuthentication().getName();
};
ExecutorService e = Executors.newCachedThreadPool();
try {
var contextTask = new DelegatingSecurityContextCallable<>(task);
return "Ciao, " + e.submit(contextTask).get() + "!";
} finally {
e.shutdown();
}
}
5.2.5 DelegatingSecurityContextExecutorService로 보안 컨텍스트 전달

DelegatingSecurityContextExecutorService는 ExecutorService를 장식해 작업을 제출하기 전에 보안 컨텍스트 세부 정보를 다음 스레드로 전파한다.
@GetMapping("/hola")
public String hola() throws Exception {
Callable<String> task = () -> {
SecurityContext context = SecurityContextHolder.getContext();
return context.getAuthentication().getName();
};
ExecutorService e = Executors.newCachedThreadPool();
e = new DelegatingSecurityContextExecutorService(e);
try {
return "Hola, " + e.submit(task).get() + "!";
} finally {
e.shutdown();
}
}
보안 컨텍스트를 별도의 스레드로 전파하는 객체
클래스 | 설명 |
DelegatingSecurityContextExecutor | Executor 인터페이스를 구현하며 Executor 객체를 장식하면 보안 컨텍스트를 해당 풀에 의해 생성된 스레드로 전달하는 기능을 제공하도록 디자인 됐다. |
DelegatingSecurityContextExecutorService | ExecutorService 인터페이스를 구현하며 ExecutorService 객체를 장식하면 보안 컨텍스트를 해당 풀에 의해 생성된 스레드로 전달하는 기능을 제공하도록 디자인 됐다. |
DelegatingSecurityContextScheduledService | ScheduledExecutorService 인터페이스를 구현하며 ScheduledExecutorService 객체를 장식하면 보안 컨텍스트를 해당 풀에 의해 생성된 스레드로 전달하는 기능을 제공하도록 디자인 됐다. |
DelegatingSecurityContextRunnable | Runnable 인터페이스를 구현하고 다른 스레드에서 실행되며 응답을 반환하지 않는 작업을 나타낸다. 일반 Runnable의 기능에 더해 새 스레드에서 이용하기 위한 보안 컨텍스트를 전파할 수 있다. |
DelegatingSecurityContextCallable | Callable 인터페이스를 구현하고 다른 스레드에서 실행되며 최종적으로 응답을 반환하는 작업을 나타낸다. 일반 Callable의 기능에 더해 새 스레드에서 이용하기 위해 보안 컨텍스트를 전파할 수 있다. |
5.3 HTTP Basic 인증과 양식 기반 로그인 인증 이해하기
5.3.1 HTTP Basic 이용 및 구성
HTTP Basic은 기본 인증 방식이다.
HTTP Basic을 명시적으로 설정하는 방법은 다음과 같다.
@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic();
}
}
@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic(c -> {
c.realmName("OTHER");
});
http.authorizeRequests().anyRequest().authenticated();
}
}
httpSecurity 인스턴스의 httpBasic() 메서드를 호출할 때 Customizer<HttpBasicConfigurer<HttpSecurity>> 형식의 객체를 지정할 수 있다.
Customizer로 할 수 있는 것
- realName()을 호출해 영역의 이름을 변경할 수 있다. (curl에 -v 플래그를 지정하세 자세한 응답을 보면 영역 이름 변경 확인 가능)
- 인증이 실패했을때 응답을 맞춤 구성할 수 있다. (하나 이상의 헤더를 추가하거나 제거할 수 있다.)
- 민감한 데이터를 클라이언트에 노출 하지 않도록 본문을 필터링하는 논리를 작성할 수 있다.
인증이 실패했을 때의 응답을 맞춤 구성하려면 AuthenticationExtryPoint를 구현한다.
public class CustomEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException {
// 헤더에 메시지를 추가할 수 있다.
httpServletResponse.addHeader("message", "Luke, I am your father!");
// 에러코드를 변경할 수 있다.
httpServletResponse.sendError(HttpStatus.UNAUTHORIZED.value());
}
@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic(c -> {
c.realmName("OTHER");
c.authenticationEntryPoint(new CustomEntryPoint());
});
http.authorizeRequests().anyRequest().authenticated();
}
}
HttpStatus.UNAUTHORIZED는 401이다.
401은 로그인이 필요한데 로그인 되지 않았음을 나타낸다.
403은 로그인을 했는데 리소스에 대한 권한이 없다는 것을 나타낸다.
이렇게 설정해주지 않았을 때는 기본적으로 403이 가게 된다. 그런데 이렇게 설정해주므로써 401로 커스텀 할 수 있다는 말인 것 같다.
5.3.2 양식 기반 로그인으로 인증 구현
작은 웹 애플리케이션에서는 양식 기반 인증 방식을 활용할 수 있다.
이 인증 방식을 작은 웹 애플리케이션과 연결한 이유는 보안 컨텍스트를 관리하는 데 서버 쪽 세션을 이용하기 때문이다. 수평 확장성이 필요한 대형 애플리케이션에서 보안 컨텍스트를 관리하는 데 서버쪽 세션을 이용하는 것은 좋지 않다. (OAuth2)

인증 방식을 양식 기반 로그인으로 변경하려면 구성 클래스의 configure(HttpSecurity http) 메서드에서 HttpSecurity 매개 변수의 httpBasic() 대신 formLogin() 메서드를 호출한다.
@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin();
http.authorizeRequests().anyRequest().authenticated();
}
}
기본적으로 login 양식과 logout 양식을 제공해준다.
/login
/logout
로그인하지 않고 아무 경로에 접근하려고 하면 자동으로 로그인 페이지로 리디렉션되며 로그인하면 원래 접근하려고 했던 페이지로 리디렉션된다. 해당 경로가 존재하지 않으면 애플리케이션이 기본 오류 페이지를 표시한다.
해당 경로가 없을때 기본 페이지로 리디렉션 되도록 하려면 defaultSuccessUrl() 메서드를 설정하면 된다.
formLogin() 메서드는 FormLoginConfigurer<HttpSecurity> 형식의 객체를 반환하며 이를 이용해 맞춤 구성할 수 있다.
defaultSuccessUrl() 메서드를 호출하도록 맞춤 구성
@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin().defaultSuccessUrl("/home", true);
http.authorizeRequests().anyRequest().authenticated();
}
}
더 세부적인 맞춤 구성이 필요하면 AuthenticationSuccessHandler 및 AuthenticationFailureHandler 객체를 이용할 수 있다.
- AuthenticationSuccessHandler: 인증이 성공했을 때의 논리를 맞춤 구성, 로그인한 사용자에게 부여된 권한에 따라 다른 리디렉션을 수행할 수 있다.
@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException {
var authorities = authentication.getAuthorities();
var auth = authorities.stream()
.filter(a -> a.getAuthority().equals("read"))
.findFirst();
if (auth.isPresent()) {
// 권한이 있으면 /home으로 리디렉션
httpServletResponse.sendRedirect("/home");
} else {
// 권한이 없으면 /error로 리디렉션
httpServletResponse.sendRedirect("/error");
}
}
}
- AuthenticationFailureHandler: 인증이 실패했을 때 애플리케이션이 실행할 논리를 맞춤 구성할 수 있다. Header의 민감 데이터를 빼거나, 401 권한없음 응답 코드를 다른 HTTP 상태코드로 변경할 수 있다.
@Component
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) {
httpServletResponse.setHeader("failed", LocalDateTime.now().toString());
}
}
@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomAuthenticationSuccessHandler authenticationSuccessHandler;
@Autowired
private CustomAuthenticationFailureHandler authenticationFailureHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailureHandler)
.and()
.httpBasic();
http.authorizeRequests()
.anyRequest().authenticated();
}
}
위 코드 처럼 HTTP Basic과 양식 기반 로그인 방식을 모두 지원하도록 구성을 변경할 수 있다.
요약
- AuthenticationProvider 구성 요소를 이용하면 맞춤형 인증 논리를 구현할 수 있다.
- 맞춤형 인증 논리를 구현할 떄는 책임을 분리하는 것이 좋다. AuthenticationProvider는 사용자 관리는 UserDetailsService에 위임하고 암호 검증 책임은 PasswordEncoder에 위임한다.
- SecurityContext는 인증이 성공한 후 인증된 엔티티에 대한 세부 정보를 유지한다.
- 보안 컨텍스트를 관리하는 데는 MODE_THREADLOCAL, MODE_INHERITABLETHREADLOCAL, MODE_GLOBAL의 세 전략을 이용할 수 있으며, 선택한 전략에 따라 다른 스레드에서 보안 컨ㅌ엑스트 세부 정보에 접근하는 방법이 달라진다.
- 공유 스레드 로컬 전략을 사용할 떄는 스프링이 관리하는 스레드에만 전략이 적용된다는 것을 기억하자. 프레임워크는 자신이 관리하지 않는 스레드에는 보안 컨텍스트를 복사하지 않는다.
- 스프링 시큐리티는 코드에서 생성했지만 프레임워크가 인식한 스레드를 관리할 수 있는 우수한 유틸리티 클래스를 제공한다. 코드에서 생성한 스레드의 SecurityContext를 관리하기 위해 다음 클래스를 이용할 수 있다.
- DelegatingSecurityContextRunnable
- DelegatingSecurityCOntextCallable
- DelegatingSecurityContextExecutor
- 스프링 시큐리티는 양식 기반 로그인 인증 메서드인 formLogin()으로 로그인 양식과 로그아웃하는 옵션을 자동으로 구성한다. 작은 웹 애플리케이션의 경우 직관적으로 이용할 수 있다.
- formLogin 인증 메서드는 세부적으로 맞춤 구성이 가능하며 HTTP Basic 방식과 함께 이용해 두 인증 유형을 모두 지원할 수도 있다.