이 블로그 내용은 아래 도서를 학습한 내용입니다. 학습 도서 정보 스프링 시큐리티 인 액션(Spring Security in Action) 로렌티우 스필카 지음 최민석 옮김 위키북스
CSRF 보호 구현
CSRF 보호 맞춤 구성
CORS 구성 적용
스프링 시큐리티에는 CORF 보호를 적용하는 필터와 CORS 구성과 관련된 필터처럼 필터 체인에 추가하는 자체 필터도 있다.
10. 1 애플리케이션에 CSRF (사이트 간 요청 위조) 보호 적용
CSRF는 광범위한 공격 유형이며 CSRF에 취약한 애플리케이션은 인증 후 사용자가 웹 애플리케이션에서 원치 않는 작업을 실행하게 할 수 있다.
CSRF가 무엇이고 어떻게 작동하는지 검토
CSRF 취약성을 완화하는데 이용되는 CSRF 토큰 메커니즘에 대해 검토
HTTP POST 방식으로 엔드포인트를 호출하는 방법을 학습
REST 엔드포인트를 이용하는 작은 애플리케이션 학습
CSRF 토큰 메커니즘을 맞춤 구성하는 방법을 학습
10. 1. 1 스프링 시큐리티의 CSRF 보호가 작동하는 방식
CSRF 공격은 사용자가 웹 애플리케이션에 로그인했다고 가정하며 사용자는 공격자에게 속아서 작업중인 같은 애플리케이션에서 작업을 실행하는 스크립트가 포함된 페이지를 연다.
사용자가 이미 로그인했기 때문에 위조 코드는 이제 사용자를 가장하고 사용자 대신 작업을 수행할 수 있다.
CSRF 공격 예시
CSRF 보호는 웹 애플리케이션에서 프런트 엔드만 변경 작업을 수행할 수 있게 보장한다.(관례상 GET, HEAD, TRACE, OPTIONS 외의 HTTP 방식)
데이터를 변경하는 작업을 수행하려면 먼저 사용자가 적어도 한 번은 HTTP GET으로 웹 페이지를 요청해야 한다.
이때 애플리케이션은 고유한 토큰을 생성한다. 다음부터의 변경작업 시 토큰을 헤더에 넣어서 요청한다. (POST, PUT, DELETE 등)
애플리케이션은 토큰의 값을 안다는 것은 다른 시스템이 아닌 애플리케이션 자체가 변경 요청을 보낸 증거라고 본다.
CsrfFilter
CSRF 보호의 시작점은 필터 체인의 CsrfFilter라는 한 필터다. CsrfFilter는 요청을 가로채고 GET, HEAD, TRACE, OPTIONS를 포함하는 HTTP 방식의 요청을 모두 허용하고 다른 모든 요청에는 토큰이 포함된 헤더가 있는지 확인한다. 이 헤더가 없거나 헤더에 잘못된 토큰 값이 포함된 경우 애플리케이션은 요청을 거부하고 응답의 상태를 '403 금지됨'으로 설정한다.
POST 요청을 수행하려면 클라이언트가 CSRF 토큰이 포함된 헤더를 추가해야 한다. 애플리케이션은 GET 요청으로 페이지가 로드 될 때 CSRF 토큰을 생성한다. 이 토큰은 로드된 페이지에서 수행하는 모든 요청에 추가된다. 이와 같이 로드된 페이지만 변경 요청을 할 수 있다.
CsrfFilter는 CsrfTokenRepository 구성 요소를 이용해 새 토큰 생성, 토큰 저장, 토큰 검증에 필요한 CSRF 토큰 값을 관리한다. 기본적으로 CsrfTokenRepository는 토큰을 HTTP 세션에 저장하고 랜덤 UUID로 토큰을 생성한다. 대부분은 이것으로 충분하지만 구현할 요구 사항이 기본 구현으로 해결되지 않으면 CsrfTokenRepository를 직접 구현하는 방법이 있다. (=> 10.3절)
CsrfFilter는 필터 체인에 있는 필터 중 하나로서 요청을 받고 체인의 다음 필터로 전달한다.
Csrf 토큰을 로그로 출력하는 예제
CsrfFilter는 생성된 CSRF 토큰을 HTTP 요청의 _csrf 특성에 추가한다.
@RestController
public class HelloController {
@GetMapping("/hello")
public String getHello() {
return "Get Hello!";
}
@PostMapping("/hello")
public String postHello() {
return "Post Hello!";
}
}
이 블로그 내용은 아래 도서를 학습한 내용입니다. 학습 도서 정보 스프링 시큐리티 인 액션(Spring Security in Action) 로렌티우 스필카 지음 최민석 옮김 위키북스
필터 체인 이용하기
맞춤형 필터 정의
Filter 인터페이스를 구현하는 스프링 시큐리티 클래스 이용
스프링 시큐리티에서 HTTP 필터는 HTTP 요청에 적용되는 다양한 책임을 위임한다.
인증필터: 인증 책임을 인증 관리자에게 위임
권한 부여 필터: 인증 성공 후 권한 부여 구성을 처리
스프링 시큐리티의 HTTP 필터는 일반적으로 요청에 적용해야 하는 각 책임을 관리하며 책임의 체인을 형성한다.
필터는 요청을 수신하고 그 논리를 실행하며 최종적으로 체인의 다음 필터에 요청을 위임한다.
스프링 시큐리티는 필터 체인을 원하는 방식으로 모델링할 수 있는 유연성을 제공한다.
사용자 이메일 주소 검사를 추가해 인증을 보강
일회용 암호(OTP)를 이용하는 단계를 추가해 인증을 보강
인증 감사가 이용되는 동안의 디버깅
다른 인증 전략을 구현
권한 부여 이벤트에 대해 외부 시스템에 알림
인증, 권한 부여 이후 추적과 감사에 필요한 성공 또는 실패 기록
9.1 스프링 시큐리티 아키텍처의 필터 구현
스프링 시큐리티 아키텍처의 필터는 일반적인 HTTP 필터다. 필터를 만들려면 javax.servlet 패키지의 Filter 인터페이스를 구현한다. 다른 HTTP 필터와 마찬가지로 doFilter() 메서드를 재정의해 논리를 구현한다.
ServletRequest: HTTP 요청을 나타낸다. ServletRequest 객체를 이용해 요청에 대한 세부 정보를 얻는다.
ServletResponse: HTTP 응답을 나타낸다. ServletResponse 객체를 이용해 응답을 클라이언트로 다시 보내기 전에 또는 더 나아가 필터 체인에서 응답을 변경한다.
FilterChain: 필터 체인을 나타낸다. FilterChain 객체는 체인의 다음 필터로 요청을 전달한다.
필터 치엔은 필터가 작동하는 순서가 정의된 필터의 모음을 나타낸다. 스프링 시큐리티에는 몇 가지 필터 구현과 순서가 있다.
BasicAuthenticationFilter는 HTTP Basic 인증을 처리한다.
CsrfFilter는 CSRF(사이트 간 요청 위조)를 처리한다.
CorsFilter는 CORS(교차 출처 리소스 공유) 권한 부여 규칙을 처리한다.
애플리케이션이 필터 체인에 이러한 모든 필터의 인스턴스를 반드시 가질 필요는 없다. 필터 체인은 애플리케이션을 구성하는 방법에 따라 더 길어지거나 짧아질 수 있다. 예를들어 Basic 인증 방식을 이용하기 위해 httpBasic() 메서드를 호출하면 필터 체인에 BasicAuthenticationFilter가 추가된다. 이처럼 개발자가 작성하는 구성에 따라 필터 체인의 정의가 영향을 받는다.
각 필터에는 순서 번호가 있다. 이 순서 번호에 따라 요청에 필터가 적용되는 순서가 결정된다. 스프링 시큐리티가 제공하는 필터와 함께 맞춤형 필터를 추가할 수 있다.
새 필터는 필터 체인의 다른 필터를 기준으로 추가된다.
기존 필터 위치 또는 앞이나 뒤에 새 필터를 추가할 수 있다.
같은 위치에 필터를 두 개 이상 추가할 수 있다. 하지만 이는 개발자들 사이에 혼란을 유발한다. 여러 필터가 같은 위치에 있으면 필터가 호출되는 순서는 정해지지 않는다.
9.2 체인에서 기존 필터 앞에 필터 추가
* 애플리케이션 요구사항 *
모든 요청에 Request-Id 헤더가 있다고 가정한다.
이 애플리케이션은 이 헤더로 요청을 추적하므로 헤더는 필수이며 인증을 수행하기 전에 헤더가 있는지 검증하려고 한다. 인증 프로세스에는 데이터베이스 쿼리나 다른 리소스를 소비하는 작업이 포함될 수 있으므로 요청의 형식이 유효하지 않으면 이런 작업을 실행할 필요가 없다.
1. 필터를 구현한다. 요청에 필요한 헤더가 있는지 확인하는 RequestValidationFilter 클래스를 만든다.
public class AuthenticationLoggingFilter implements Filter {
private final Logger logger =
Logger.getLogger(AuthenticationLoggingFilter.class.getName());
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
var httpRequest = (HttpServletRequest) request;
String requestId = httpRequest.getHeader("Request-Id");
// 요청 ID의 값과 이벤트 기록
logger.info("Successfully authenticated request with id " + requestId);
// 요청을 필터 체인의 다음 필터에 전달
filterChain.doFilter(request, response);
}
}
@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.
addFilterBefore(
new RequestValidationFilter(),
BasicAuthenticationFilter.class)
// 필터 체인에서 인증 필터 다음에 AuthenticationLoggingFilter의 인스턴스 추가
.addFilterAfter(
new AuthenticationLoggingFilter(),
BasicAuthenticationFilter.class)
.authorizeRequests()
.anyRequest()
.permitAll();
}
}
9.4 필터 체인의 다른 필터 위치에 필터 추가
이제까지 인증 방식 중 BasicAuthetnication과 FormLoginAuthentication 방식을 살펴보았다. 이 두가지 인증방식 이외의 다른 인증 방식을 원한다. 그런데 스프링 시큐리티의 기능을 그대로 사용하고 싶다면 어떻게 해야하는가? BasicAuthentication이나 FormLoginAuthentication의 필터 대신에 커스텀 필터를 위치 시키면 된다.
커스텀 인증방식 예시
인증을 위한 정적 헤더 값에 기반을 둔 식별
대칭 키를 이용해 인증 요청 서명
인증 프로세스에 OTP(일회용 암호) 이용
단, 커스텀 필터를 위치시키되
1. basicAuthentication이나 FormLogingAuthentication은 활성화 시키지 않는다.
2. 스프링시큐리티는 기본적으로 UserDetailsService를 구성하게 된다. 만약 커스텀한 인증 방식에서 UserDetailsService를 사용하지 않는다면, 비활성화 시킨다.
// 구성 클래스에서 값을 주입할 수 있도록 스프링 컨텍스트에 클래스의 인스턴스 추가
@Component
public class StaticKeyAuthenticationFilter implements Filter {
// 예제를 간단하기 위해 키를 속성 파일에 작성, 운영서버스에서는 볼트에 저장하는 것이 좋다.
@Value("${authorization.key}")
private String authorizationKey;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
var httpRequest = (HttpServletRequest) request;
var httpResponse = (HttpServletResponse) response;
// 요청의 Authorization 헤더에서 값을 얻고 정적 헤더와 비교
String authentication = httpRequest.getHeader("Authorization");
if (authorizationKey.equals(authentication)) {
filterChain.doFilter(request, response);
} else {
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
}
}
}
@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {
// 스프링 컨텍스트에서 필터의 인스턴스를 주입
@Autowired
private StaticKeyAuthenticationFilter filter;
@Override
protected void configure(HttpSecurity http) throws Exception {
// 필터 체인에서 기본 인증 필터의 위치에 필터 추가
http.addFilterAt(filter, BasicAuthenticationFilter.class)
.authorizeRequests().anyRequest().permitAll();
}
}
@SpringBootApplication(exclude = {UserDetailsServiceAutoConfiguration.class })
public class Main {
public static void main(String[] args) {
SpringApplication.run(Main.class, args);
}
}
이절에서는 스프링 시큐리티에 기본적으로 있고 Filter 인터페이스를 구현하는 클래스에 관해 알아본다. 이 단원의 에제에서는 이 인터페이스를 직접 구현해 필터를 정의한다.
스프링 시큐리티에는 Filter 인터페이스를 구현하는 여러 추상 클래스가 있으며, 이를 위해 필터 정의를 확장할 수 있다. 또한 이러한 클래스는 구현을 확장할 때 이점을 얻을 수 있는 기능을 추가한다.
로깅 기능은 같은 요청이 여러 번 기록되지 않게 해야하므로 OncePerRequestFilter를 이용할 수 있는 좋은 후보다.스프링 시큐리티는 필터가 두 번 이상 호출되지 않게 보장하지 않으므로 개발자가 직접 처리해야 한다. 가장 쉬운 방법은 OncePerRequestFilter 클래스를 이용해 필터를 구현하는 것이다.
// Filter 인터페이스를 구현하지 않고 OncePerRequestFilter 클래스를 확장
public class AuthenticationLoggingFilter extends OncePerRequestFilter {
private final Logger logger =
Logger.getLogger(AuthenticationLoggingFilter.class.getName());
// Filter 인터페이스의 doFilter() 메서드의 용도를 대체하는 doFilterInternal() 재정의
// OncePerRequestFilter는 HTTP 필터만 지원한다.
// 이 때문에 HttpServletRequest 및 HttpServletResponse로 매개변수를 직접 지정했다.
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String requestId = request.getHeader("Request-Id");
logger.info("Successfully authenticated request with id " + requestId);
filterChain.doFilter(request, response);
}
}
@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.addFilterBefore(
new RequestValidationFilter(),
BasicAuthenticationFilter.class)
.addFilterAfter(
new AuthenticationLoggingFilter(),
BasicAuthenticationFilter.class)
.authorizeRequests()
.anyRequest()
.permitAll();
}
}
* 연산자는 한 경로 이름만 대체한다. 이 경우 /a/b 또는 /a/c와는 일치하지만, /a/b/c 와는 일치하지 않는다.
/a/**
** 여러 경로 이름을 대체한다. 이 경우 /a, /a/b, /a/b/c 가 모두 이 식과 일치한다.
/a/{param}
이 식은 주어진 경로 매개 변수를 포함한 /a 경로에 적용된다.
/a/{param:regex}
이 식은 매개 변수 값과 주어진 정규식이 일치할 때만 주어진 경로 매개 변수를 포함한 /a 경로에 적용 된다.
8.3 앤트 선택기로 권한을 부여할 요청 선택
앤트 선택기보다 MVC 선택기를 권장한다. 그러나 이전에는 많은 애플리케이션에서 앤트 선택기가 이용됐다.
antMathcers(HttpMethod method, String patterns):
antMatchers(String patterns):
antMatchers(HttpMethod):
MVC 선택기 권장 이유
antMatchers("/hello").authenticated(); 라고 적으면 /hello는 보안 적용되지만, /hello/는 보안이 적용되지 않는다!!
mvcMathcers("/hello").authenticated(); 라고 적으면 /hello와 /hello/ 둘다 보안이 적용된다!!
이는 아주 중요한 특징이다! 이를 모르는 개발자가 앤트 선택기를 이용하면 의도치 않게 경로를 보호되지 않는 상태로 방치할 수 있다. 이는 애플리케이션에 중대한 보안 침해를 야기한다.
MVC 선택기를 이용하는 것이 좋다. MVC 선택기를 이용하면 스프링의 경로 및 작업 매핑과 관련한 몇가지 위험을 예방할 수 있다. 그 이유는 권한 부여 규칙을 위하 경로를 해석하는 방법과 스프링이 경로를 엔드포인트에 매핑하기 위해 해석하는 방법이 같기 때문이다. 반면에 앤트 선택기를 이용하려면 권한 부여 규칙을 적용할 모든 경로에 확실하게 적용되게 식을 작성해야 한다.
스프링 시큐리티의 사용자를 나타내는 계약인 UserDetails는 GrantedAuthority 인스턴스의 컬렉션을 가진다.
한 사용자는 하나 이상의 이용 권리를 가질 수 있으며 getAutorities() 메서드는 GrantedAuthority 인스턴스의 컬렉션을 반환한다.
인증이 완료되면 로그인한 사용자의 세부 정보에 권한이 포함되며 애플리케이션은 이를 바탕으로 이용 권한을 부여할 수 있다.
7.1.1 사용자 권한을 기준으로 모든 엔드포인트에 접근 제한
운영 단계의 앱에서는 인증하지 않아도 호출할 수 있는 엔드포인트도 있고 특별한 이용 권리가 있어야 접근 가능한 엔드포인트도 있다.
권한은 사용자가 수행할 수 있는 작업이다.
권한을 기반으로 권한 부여 규칙이 구현된다.
특정 권한이 있는 사용자만 특정 엔드포인트에 요청 할 수 있다.
권한부여 세 메서드
hasAuthority(): 애플리케이션이 제한을 구성하는 하나의 권한만 매개 변수로 받는다. 해당 권한이 있는 사용자만 엔드포인트를 호출할 수 있다.
hasAnyAuthority(): 애플리케이션이 제한을 구성하는 권한을 하나 이상 받을 수 있다. "주어진 권한 중 하나라도 있을 때" 라고 기억한다. 사용자는 요청하려면 지정된 권한 중 하나라도 있어야 한다.
access(): SpEL(Spring Expression Languate)을 기반으로 권한 부여 규칙을 구축하므로 액세스를 구성하는 데 무한한 가능성이 있지만 코드를 읽고 디버그하기 어려운 단점이 있다. 따라서 이 메서드는 추천하지 않으며, hasAnyAuthority()또는 hasAuthority() 메서드를 적용할 수 없을 때만 이용하는 것이 좋다.
그러나 access() 메서드는 매개 변수로 지정하는 식으로 규칙을 맞춤 구성할 수 있다는 이점이 있다. 이는 아주 강력한 기능이며 SpEL 식을 이용ㅇ하면 사실상 어떤 조건이라도정의할 수 있다.
authorizeRequests() : 엔드포인트에 권한 부여 규칙을 지정
anyRequest() : 이용된 URL이나 HTTP 방식과 관계없이 모든 요청에 대해 규칙을 적용한다.
permitAll() : 인증 여부와 관계없이 모든 요청에 대해 접근을 허용한다.
@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {
@Override
@Bean
public UserDetailsService userDetailsService() {
var manager = new InMemoryUserDetailsManager();
var user1 = User.withUsername("john")
.password("12345")
.authorities("READ")
.build();
var user2 = User.withUsername("jane")
.password("12345")
.authorities("WRITE")
.build();
manager.createUser(user1);
manager.createUser(user2);
return manager;
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic();
//http.authorizeRequests().anyRequest().hasAnyAuthority("WRITE", "READ");
//http.authorizeRequests().anyRequest().hasAuthority("WRITE");
http.authorizeRequests().anyRequest().access("hasAuthority('WRITE')");
}
}
hasAuthority('WRITE') : 엔드포인트를 호출하려면 사용자에게 WRITE 권한이 필요하다고 지정한다.
hasAnyAuthoriy('READ', 'WRITE') : 사용자에게 READ 또는 WRITE 권한이 필요하다고 지정한다. 이 식을 이용하면 액세스를 허용할 모든 권한을 나열할 수 있다.
읽기 권한이 있는 사용자는 접근할 수 있지만, 삭제 권한이 있는 사용자는 접근할 수 없다.
=> 이런 복잡한 접근 제어는 access() 메서드를 사용하여 구현한다.
"hasAuthority('read') and !hasAuthority('delete')" : 읽기권한이 있어야 하지만 삭제 권한은 없는 사용자에게 허용
@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {
@Override
@Bean
public UserDetailsService userDetailsService() {
var manager = new InMemoryUserDetailsManager();
var user1 = User.withUsername("john")
.password("12345")
.authorities("read")
.build();
var user2 = User.withUsername("jane")
.password("12345")
.authorities("read", "write", "delete")
.build();
manager.createUser(user1);
manager.createUser(user2);
return manager;
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic();
String expression = "hasAuthority('read') and !hasAuthority('delete')";
http.authorizeRequests()
.anyRequest().access(expression);
}
}
7.1.2 사용자 역할을 기준으로 모든 엔드포인트에 대한 접근을 제한
역할은 사용자가 수행할 수 있는 작업을 나타내는 다른 방법이다. 실제 애플리케이션에서는 권한과 함께 역할도 이용되므로 역할의 개념, 그리고 역할과 권한의 차이점을 이해하는 것이 중요하다.
특정 역할이 있는 사용자는 해당 역할에 허가된 작업만 할 수 있다. 이 철학을 권한 부여에 적용하면 시스템에서 사용자의 목적을 기준으로 요청이 히용되고 특정 역할을 보유한 사용자만 특정 엔드포인트를 호출할 수 있다.
애플리케이션에서 역할을 이용하면 더는 권한을 정의할 필요가 없다. 이때 권한은 개념상으로 존재하고 구현 요구 사항에도 나올 수 있지만 애플리케이션에서는 사용자가 이용 권리를 가진 하나 이싱의 작업을 포함하는 역할만 정의하면 된다.
스프링시큐리티에서 역할도 권한과 같은 GrantedAuthority 계약으로 나타낸다. 역할을 정의할 때 역할 이름은 ROLE_ 접두사로 시작해야 한다. 구현 수준에서 이 접두사는 역할과 권한 간의 차이를 나타낸다.
@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {
@Override
@Bean
public UserDetailsService userDetailsService() {
var manager = new InMemoryUserDetailsManager();
var user1 = User.withUsername("john")
.password("12345")
// authorities로 지정할때는 ROLE_ 접두사를 적는다.
.authorities("ROLE_ADMIN")
// role로 저정할 때는 ROLE_ 접두사를 적지 않는다.
.build();
var user2 = User.withUsername("jane")
.password("12345")
// authorities로 지정할때는 ROLE 접두사를 적는다.
.authorities("ROLE_MANAGER")
// role로 저정할 때는 ROLE_ 접두사를 적지 않는다.
// .roles("MANAGER")
.build();
manager.createUser(user1);
manager.createUser(user2);
return manager;
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic();
// 여기에는 ROLE_ 접두사를 적지 않는다.
http.authorizeRequests().anyRequest().hasRole("ADMIN");
}
}
hasRole() : 애플리케이션이 요청을 승인할 하나의 역할 이름을 매개 변수로 받는다.
hasAnyRole() : 애플리케이션이 요청을 승인할 여러 역할 이름을 매개 변수로 받는다.
access() : 애플리케이션이 요청을 승인할 역할을 스프링 식으로 지정한다. 역할을 지정하는 데는 hasRole() 또는 hasAnyRole()을 SpEL 식으로 이용할 수 있다.
가능하면 hasRole() 또는 hasAnyRole() 메서드를 우선적으로 이용하고, 이러한 메서드로 해결할 수 없는 상황에서만 access()를 이용한다.
authorities() 메서드를 호출할 때는 ROLE_ 접두사를 지정하지만, roles() 메서드에는 ROLE_ 접두사를 포함하지 않는다.
access() 메서드를 이용하면 사실상 어떤 규칙이라도 구현할 수있고 가능성이 무한하다.
쉬운 예로 정오 이후에만 엔드포인트로 접근을 허용하는 경우를 들 수 있다. 이때는 다음과 같은 SpEL 식을 이용할 수 있다.
경로 변수로 이메일 주소를 받는 엔드포인트가 있을때, .com으로 끝나는 매개 변수 값으로 엔드포인트를 호출하면 요청이 수락된다. .net으로 끝나는 이메일 주소를 지정하고 엔드포인트를 호출하면 요청이 거부된다. 이러한 동작을 구현하려면 매개 변수 값이 .com으로 끝나지 않는 모든 호출을 denyAll() 메서드로 거부하면 된다.
요약
권한 부여는 애플리케이션이 인증된 요청을 허가할지 결정하는 프로세스다. 권한 부여는 항상 인증 이후에 수행한다.
애플리케이션이 인증된 사용자의 권한과 역할에 따라 권한을 부여하는 방법을 구성할 수 있다.
애플리케이션에서 인증되지 않은 사용자가 특정 요청을 수행할 수 있게 지정할 수도 있다.
denyAll() 메소드로 앱이 모든 요청을 거부하고 permitAll() 메서드로 모든 요청을 수락하게 할 수 있다.
이 블로그 내용은 아래 도서를 학습한 내용입니다. 학습 도서 정보 스프링 시큐리티 인 액션(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)은 애플리케이션에 접근을 요청하는 사용자이다.
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 객체를 저장하는 인스턴스를 보안 컨텍스트라고 한다.
엔드포인트가 비동기가 되면 메서드를 실행하는 스레드와 요청을 수행하는 스레드가 다른 스레드가 된다.
메서드가 보안 컨텍스트를 상속하지 않는 다른 스레드에서 실행되기 때문에 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은 보안 컨텍스트의 세부 정보를 새 스레드로 복사한 후 작업을 실행한다.
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());
}
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());
}
}
encode: 암호의 해시를 제공하거나 암호화를 수행하는 일을 한다. matches: 인코딩된 문자열이 원시 암호화 일치하는지 확인한다. upgradeEncoding: true를 반환하도록 재정의 하면 인코딩된 암호를 보안 향상을 위해 다시 인코딩 한다.
4.1.2 PasswordEncoder 계약의 구현
PasswordEncoder의 가장 단순한 구현
public class PlainTextPasswordEncoder implements PasswordEncoder {
@Override
public String encode(CharSequence rawPassword) {
return rawPassword.toString();
}
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return rawPassword.equals(encodedPassword);
}
}
SHA-512를 이용ㅇ하는 PasswordEncoder 구현
public class Sha512PasswordEncoder implements PasswordEncoder {
@Override
public String encode(CharSequence rawPassword) {
return hashWithSHA512(rawPassword.toString());
}
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return encodedPassword.equals(encode(rawPassword));
}
private String hashWithSHA512(String input) {
StringBuilder result = new StringBuilder();
try {
MessageDigest md = MessageDigest.getInstance("SHA-512");
byte [] digested = md.digest(input.getBytes());
for (byte b : digested) {
result.append(Integer.toHexString(0xFF & b));
}
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("Bad algorithm");
}
return result.toString();
}
}
NoOpPasswordEncoder: 암호를 인코딩하지 않고 일반 텍스트로 유지한다.
StandardPasswordEncoder: SHA-256을 이용해 암호를 해시한다. 이 구현은 이제 구식이며 새 구현에는 쓰지 말아야 한다.
Pbkdf2PasswordEncoder: PBKDF2를 이용한다
BCryptPasswordEncoder: bycrypt 강력 해싱 함수로 암호를 인코딩 한다.
SCryptPasswordENcoder: scrypt 해싱 함수로 암호를 인코딩 한다.
4.1.3 PasswordEncoder의 제공된 구현 선택
NoOpPasswordEncoder
PasswordEncoder p = NoOpPasswordEncoder.getInstance();
NoOpPasswordEncoder 클러스는 싱글톤으로 설계돼서 클래스 바깥에서는 생성자를 직접 호출할 수 없지만, getInstance() 메서드로 클래스의 인스턴스를 얻을 수 있다.
StandardPasswordEncoder
PasswordEncoder p = new StandardPasswordEncoder();
PasswordEncoder p = new StandardPasswordEncoder("secret");
비밀값은 생성자의 매개변수로 전달한다. StandardPasswordEncoder는 이제 구식이므로 쓰지않는게 좋다.
Pbkdf2PasswordEncoder
PasswordEncoder p = new Pbkdf2PasswordEncoder();
PasswordEncoder p = new Pbkdf2PasswordEncoder("secret");
PasswordEncoder p = new Pbkdf2PasswordEncoder("secret", 185000, 256);
Params
프로세스에 이용되는 키의 값
암호 인코딩의 반복 횟수
해시의 크기
두번째와 세번째 값을 늘릴수록 암호는 더 강력해진다. 그러나 성능에 영향을 주므로 신중하게 절충해야한다.
BCryptPasswordEncoder
PasswordEncoder p = new BCryptPasswordEncoder();
PasswordEncoder p = new BCryptPasswordEncoder(4);
SecureRandom s = SecureRandom.getInstanceStrong();
PasswordEncoder p = new BCryptPasswordEncoder(4, s);
Params
로그라운드: 해싱 작업이 이용하는 반복 횟수에 영향을 준다. 반복 횟수는 2 로그라운드로 계산된다. 반복 횟수를 계산하기 위한 로그라운드 값은 4~31 사이어야 한다.
SecureRandom: 인코딩에 이용되는 인스턴스
반복을 결정하는 값과, 해시에 사용되는 인스턴스를 지정해준다는 말인듯 인수가 없는 인스턴스로 만들어서 사용해도 된다.
SCryptPasswordEncoder
PasswordEncoder p = new SCryptPasswordEncoder();
PasswordEncoder p = new SCryptPasswordEncoder(16384, 8, 1, 32, 64);
Params
CPU 비용
메모리 비용
병렬화 계수
키 길이
솔트 길이
4.1.4 DelegatingPasswordEncoder를 이용한 여러 인코딩 전략
DelegatingPasswordEncoder는 여러종류의 해시를 지원한다. 현재 사용되는 알고리즘에서 취약성이 발견되어 신규 등록 사용자의 자격 증명을 변경하고 싶지만, 기존 자격 증명을 변경하기 쉽지 않을때 사용할 수 있다.
DelegatingPasswordEncoder는 PasswordEncoder 인터페이스의 한 구현이며 자체 인코딩 알고리즘을 구현하는 대신 같은 계약의 다른 구현 인스턴스에 작업을 위임한다. 해시는 해당 해시를 의미하는 알고리즘의 이름을 나타내는 접두사로 시작한다. DelegatingPasswordEncoder는 암호의 접두사를 기준으로 올바른 PasswordEncoder 구현에 작업을 위임한다.
@Configuration
public class ProjectConfig {
@Bean
public PasswordEncoder passwordEncoder() {
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("bcrypt", new BCryptPasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
// 첫 번째 인수로 기본 인코더를 지정
// 접두가사 없으면 기본 인코더를 사용함
return new DelegatingPasswordEncoder("bcrypt", encoders);
}
}
{noop}12345 => NoOpPasswordEncoder 사용 {bcrypt}$2a$10... => BCryptPasswordEncoder 사용
스프링 시큐리티는 편의를 위해 모든 표준 제공 PasswordEncoder의 구현에 대한 맵을 가진 DelegatingPasswordEncoder를 생성하는 방법을 제공한다.
> 인코딩(Encoding) 주어진 입력에 대한 모든 변환을 의미한다. 예를 들어 문자열을 뒤집는 함수 x가 있을 때 x -> y를 ABCD에 적용하면 DCBA가 나온다.
> 암호화(Encryption) 출력을 얻기 위해 입력 값과 키를 모두 지정하는 특정한 유형의 인코딩이다. 키를 이용하면 나중에 누가 출력에서 입력을 얻는 함수를 호출할 수 있는지 선택할 수 있다. 암호화를 함수로 나타낸 가장 간단한 형식은 다음과 같다. (x, y) -> y 여기에서 x는 입력이고 k는 키며 y는 암호화 결과다. 이 방식으로 알려진 함수에 키를 이용해 출력에서 입력을 얻을 수 있다. (y, k) -> x 이를 역함수 복호화(Reverse Function Decryption)라고 한다. 암호화에 쓰는 키와 복호화에 쓰는 키가 같으면 이를 일반적으로 이를 대칭 키 라고 한다.
암호화((x, k1) -> y) 및 복호화 ((y, k2) -> x)에 다른 키를 쓰면 비대칭 키(Asymmetric Key)로 암호화가 수행된다고 말한다. 그리고 (k1, k2)를 키 쌍(Key Pair)이라고 한다. 암호화에 이용되는k1을 공개 키(Public key)라고 하고 복호화에 이용되는k2를 개인 키(Private Key)라고 한다. 이와 같이 개인 키의 소유자는 데이터를 복호화할 수 있다.
> 해싱(Hashing) 함수가 한 방향으로만 작동하는 특정한 유형의 인코딩이다. 따라서 해싱 함수의 출력 y에서 입력 x를 얻을 수 없다. 그러나 출력 y가 입력 x에 해당하는지 확인할 수 있는 방법이 반드시 있어야 하므로 해시는 인코딩과 일치를 위한 한 쌍의 함수로 볼 수 있다. 해싱 함수가 x -> y라면 일치 함수 (x, y) -> boolean도 있다. 때때로 해싱 함수는 입력에 임의의 값을 추가할 수도 있다: (x, k) -> y. 이 값을 솔트(Salt)라고 한다. 솔트는 함수를 더 강하게 만들어 결과에서 입력을 얻는 역함수의 적용 난도를 높인다.
스프링 시큐리티에서 인증 흐름을 위한 주 계약을 나타내는 인터페이스
UserDetails
스프링 시큐리티가 관리하는 사용자를 나타낸다.
GrantedAuthority
애플리케이션의 목적 내에서 사용자에게 허용되는 작업을 정의한다(예: 읽기, 쓰기, 삭제 등).
UserDetailsService
사용자 이름으로 사용자 세부 정보를 검색하는 객체를 나타낸다.
UserDetailsManager
UserDetailsService의 더 구체적인 계약이다. 사용자 이름으로 사용자를 검색하는 것 외에도 사용자 컬렉션이나 특정 사용자를 변경할 수도 있다.
PasswordEncoder
암호를 암호화 또는 해시하는 방법과 주어진 인코딩된 문자열을 일반 텍스트 암호와 비교하는 방법을 지정한다.
4.2 스프링 시큐리티 암호화 모듈에 관한 추가 정보
4.2.1 키 생성기 이용
4.2.2 암호화 복호화 작업에 암호기 이용
요약
PasswordEncoder는 인증 논리에서 암호를 처리하는 가장 중요한 책임을 담당한다.
스프링 시큐리티는 해싱 알고리즘에 여러 대안을 제공하므로 필요한 구현을 선택하기만 하면 된다.
스프링 시큐리티 암호화 모듈(SSCM)에는 키 생성기와 암호기를 구현하는 여러 대안이 있다.
이 블로그 내용은 아래 도서를 학습한 내용입니다. 학습 도서 정보 스프링 시큐리티 인 액션(Spring Security in Action) 로렌티우 스필카 지음 최민석 옮김 위키북스
userDetails 인터페이스로 사용자 기술하기
인증 흐름에 UserDetailsService 이용하기
UserDetailsService의 맞춤형 구현 만들기
UserDetailsManager의 맞춤형 구현 만들기
인증 흐름에 JdbcUserDetailsManager 이용하기
UserDetailsService에 대해서
스프링 시큐리티에서 사용자를 기술하는 UserDetails
사용자가 실행할 수 있는 작업을 정의하는 GrantedAuthority
UserDetailsService 계약을 확장하는 UserDetailsManager. 상속된 동작 외에 사용자 만들기, 사용자의 암호 수정이나 삭제 등의 작업도 지원한다.
UserDetailsService 및 PasswordEncoder에 대해서
스프링 시큐리티에 있는 구현과 이를 이용하는 방법
계약을 위한 맞춤형 구현을 정의하는 방법과 시기
실제 애플리케이션에서 볼 수 있는 인터페이스를 구현하는 방법
이러한 인터페이스 이용의 모범 사례
3.1 스프링 시큐리티의 인증 구현
UserDetailsService는 사용자 이름으로 사용자를 검색하는 역할만 한다. UserDetailsManager는 대부분의 애플리케이션에 필요한 사용자 추가, 수정, 삭제 작업을 추가한다. UserDetails로 계약을 구현한다. GrantedAuthority는 이용 권리의 집합이다.
사용자를 인증하는 기능만 필요한 경우 UserDetailsService 계약만 구현하면 필요한 기능을 제공할 수 있다. 사용자를 관리하려면 UserDetailsService 및 UserDetailsManager 구성요소에 사용자를 나타내는 방법이 필요하다. 두 계약간 분리는 인터페이스 분리 원칙의 훌륭한 예다.
3.2 사용자 기술하기
스프링 시큐리티에서 사용자 정의는 UserDetails 계약을 준수해야 한다. UserDetails 계약은 스프링 시큐리티가 이해하는 방식으로 사용자를 타나낸다.
3.2.1 UserDetails 계약의 정의 이해하기
public interface UserDetails extends Serializable {
// 앱 사용자가 수행할 수 있는 작업을 GrantedAuthority 인스턴스의 컬랙션으로 반환
Collection<? extends GrantedAuthority> getAuthorities();
// 사용자 자격 증명을 반환하는 두 메서드
String getPassword();
String getUsername();
// 사용자 계정을 필요에 따라 활성화 또는 비활성화하는 네 메서드
// 애플리케이션에서 네 메서드의 기능을 구현할 필요가 없다면 네 메서드가 true를 반환하게 하면 된다.
boolean isAccountNonExpired(); // 계정 만료
boolean isAccountNonLocked(); // 계정 잠금
boolean isCredentialsNonExpired(); // 자격 증명 만료
boolean isEnabled(); // 계정 비활성화
}
3.2.2 GrantedAuthority 계약 살펴보기
public interface GrantedAuthority extends Serializable {
String getAuthority();
}
String 형식의 권한 이름 만들기
GrantedAuthority g1 = ()-> "READ";
GrantedAuthority g2 = new SimpleGrantedAuthority("READ");
3.2.3 최소한의 UserDetails 구현 작성
public class User implements UserDetails {
private final String username;
private final String password;
private final String authority;
public User(String username, String password, String authority) {
this.username = username;
this.password = password;
this.authority = authority;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(() -> authority);
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
3.2.4 빌더를 이용해 UserDetails 형식의 인스턴스 만들기
일부 단순한 애플리케이션에는 UserDetails 인터페이스의 맞춤형 구현이 필요 없다.
// user 빌더 클래스로 사용자 구성
UserDetails u = User.withUsername("bill")
.password("12345")
.authorities("read", "write")
.accountExpired(false)
.disabled(true)
.build();
// User.UserBuilder 인스턴스 만들기
User.UserBuilder builder1 = User.withUsername("bill");
UserDetails u1 = builder1
.password("12345")
.authorities("read", "write")
.passwordEncoder(p -> encode(p)) // 암호 인코더는 인코딩을 수행하는 함수에 불과하다.
.accountExpired(false)
.disabled(true)
.build();
// 기존의 UserDetails 인스턴스에서 사용자를 만들 수도 있다.
User.UserBuilder builder2 = User.withUserDetails(u);
UserDetails u2 = builder2.build();
3.2.5 사용자와 연관된 여러 책임 결합
JPA 사용시 User 엔티티와 스프링 시큐리티의 User Details 구현체를 함께 구현하면 복잡하다. SecurityUser 클래스를 구현해 User 엔티티를 래핑한다.
@Entity
@Getter
public class User {
@Id
private int id;
private String username;
private String password;
private String authority;
}
public class SecurityUser implements UserDetails {
private final User user;
public SecurityUser(User user) {
this.user = user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(() -> user.getAuthority());
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
3.3 스프링 시큐리티가 사용자를 관리하는 방법 지정
3.3.1 UserDetailsService 계약의 이해
public interface UserDetailsService {
UserDetails loadUserByUsername(String var1) throws UsernameNotFoundException;
}
3.3.2 UserDetailsService 계약 구현
인메모리로 UserDetailsService 구현
public class User implements UserDetails {
private final String username;
private final String password;
private final String authority;
public User(String username, String password, String authority) {
this.username = username;
this.password = password;
this.authority = authority;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(() -> authority);
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
public class InMemoryUserDetailsService implements UserDetailsService {
private final List<UserDetails> users;
public InMemoryUserDetailsService(List<UserDetails> users) {
this.users = users;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return users.stream()
.filter(u -> u.getUsername().equals(username))
.findFirst()
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
}
}
@Configuration
public class ProjectConfig {
@Bean
public UserDetailsService userDetailsService() {
UserDetails u = new User("john", "12345", "read");
List<UserDetails> users = List.of(u);
return new InMemoryUserDetailsService(users);
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
}
create schema spring;
CREATE TABLE IF NOT EXISTS `spring`.`users` (
`id` INT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(45) NULL,
`password` VARCHAR(45) NULL,
`enabled` INT NOT NULL,
PRIMARY KEY (`id`));
CREATE TABLE IF NOT EXISTS `spring`.`authorities` (
`id` INT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(45) NULL,
`authority` VARCHAR(45) NULL,
PRIMARY KEY (`id`));
INSERT INTO `spring`.`authorities` VALUES (NULL, 'john', 'write');
INSERT INTO `spring`.`users` VALUES (NULL, 'john', '12345', '1');
@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {
@Bean
public UserDetailsService userDetailsService(DataSource dataSource) {
String usersByUsernameQuery = "select username, password, enabled from spring.users where username = ?";
String authsByUserQuery = "select username, authority from spring.authorities where username = ?";
var userDetailsManager = new JdbcUserDetailsManager(dataSource);
userDetailsManager.setUsersByUsernameQuery(usersByUsernameQuery);
userDetailsManager.setAuthoritiesByUsernameQuery(authsByUserQuery);
return userDetailsManager;
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
}
사용자 관리에 LdapUserDetailsManager 이용
실제 LDAP 서버를 사용할 수 없으므로 임베디드로 LDAP 서버를 설정한다.
LDIF (DATA Interchange Format) 파일 설정
src/main/resources/server.ldif
dn: dc=springframework,dc=org
objectclass: top
objectclass: domain
objectclass: extensibleObject
dc: springframework
dn: ou=groups,dc=springframework,dc=org
objectclass: top
objectclass: organizationalUnit
ou: groups
dn: uid=john,ou=groups,dc=springframework,dc=org
objectclass: top
objectclass: person
objectclass: organizationalPerson
objectclass: inetOrgPerson
cn: John
sn: John
uid: john
userPassword: 12345
@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {
@Override
@Bean
public UserDetailsService userDetailsService() {
// 컨텍스트 소스를 생성해 LDAP 서버의 주소 지정
var cs = new DefaultSpringSecurityContextSource("ldap://127.0.0.1:33389/dc=springframework,dc=org");
cs.afterPropertiesSet();
// LdapuserDetailsManager 인스턴스 생성
LdapUserDetailsManager manager = new LdapUserDetailsManager(cs);
// 사용자 이름 매퍼를 설정해 LdapUserDetailsManager에 사용자를 검색 하는 방법 지시
manager.setUsernameMapper(
new DefaultLdapUsernameToDnMapper("ou=groups", "uid"));
// 앱이 사용자를 검색하는 데 필요한 그룹 검색 기준 설정
manager.setGroupSearchBase("ou=groups");
return manager;
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
}
요약
UserDetails 인터페이스는 스프링 시큐리티에서 사용자를 기술하는 데 이용되는 계약이다.
UserDetailsService 인터페이스는 애플리케이션이 사용자 세부 정보를 얻는 방법을 설명하기 위해 스프링 시큐리티의 인증 아키텍처에서 구현해야 하는 계약이다.
UserDetailsManager 인터페이스는 UserDetailsSErvice를 확장하고 사용자 생성, 변경, 삭제와 관련된 동작을 추가한다.
스프링 시큐리티는 UserDetailsManager 계약의 여러 구현을 제공한다. 이러한 구현에는 InMemoryUserDetailsManager, JdbcUserDetailsManager, LdapUserDetailsManager가 있다.
JdbcUserDetailsManager는 JDBC를 직접 이용하므로 애플리케이션이 다른 프레임워크에 고정되지 않는다는 이점이 있다.
이 블로그 내용은 아래 도서를 학습한 내용입니다. 학습 도서 정보 스프링 시큐리티 인 액션(Spring Security in Action) 로렌티우 스필카 지음 최민석 옮김 위키북스
1. 스프링 시큐리티와 웹 종속성만 있는 프로젝트를 만들어서 추가된 구성이 없으면 어떻게 작동하는지 확인한다. 이를 통해 인증과 권한 부여의 기본 구성에서 무엇을 기대해야할 지 이해할 수 있다. 2. 기본 구성을 재정의하고 맞춤형 사용자와 암호를 정의해서 프로젝트에 사용자 관리 기능을 추가한다. 3. 애플리케이션이 기본적으로 모든 엔드포인트를 인증하는 것을 확인하고 이 동작도 맞춤 구성할 수 있다는 것을 배운다. 4. 모범 사례를 이해할 수 있도록 동일한 구성에 대해 다른 스타일을 적용해 본다.
AuthenticationProvider는 인증공급자이다. UserDetailsService는 사용자 세부 정보 서비스이다. PasswordEncoder는 암호 인코더이다.
인증 필터는 인증 요청을 인증 관리자에 위임하고 응답을 바탕으로 보안 컨텍스트를 구성한다.
인증 관리자는 인증 공급자를 이용해 인증을 처리한다.
인증 공급자는 인증 논리를 구현한다.
인증 공급자는 사용자 관리 책임을 구현하는 사용자 세부 정보 서비스를 인증 논리에 이용한다.
보안 컨텍스트는 인증 프로세스 후 인증 데이터를 유지한다.
사용자 세부 정보 서비스는 UserDetailsService 계약을 구현하는 객체가 관리한다. PasswordEncoder는 다음 두 가지 일을 한다.
암호를 인코딩 한다.
암호가 기존 인코딩과 일치하는지 확인한다.
2.3 기본 구성 재정의
2.3.1 UserDetailsService 구성 요소 재 정의
@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {
@Override
@Bean
public UserDetailsService userDetailsService() {
var userDetailsService = new InMemoryUserDetailsManager();
var user = User.withUsername("john")
.password("12345")
.authorities("read")
.build();
userDetailsService.createUser(user);
return userDetailsService;
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
}
curl -u john:12345 http://localhost:8080/hello
2.3.2 엔드포인트 권한 부여 구성 재정의
...
@Override
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic();
http.authorizeRequests().anyRequest().authenticated(); // 모든 요청에 인증이 필요하다.
http.authorizeRequests().anyRequest().permitAll(); // 모든 요청에 인증이 필요하다.
}
...
2.3.3 다른 방법으로 구성 설정
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
var userDetailsService = new InMemoryUserDetailsManager();
var user = User.withUsername("john")
.password("12345")
.authorities("read")
.build();
userDetailsService.createUser(user);
auth.userDetailsService(userDetailsService)
.passwordEncoder(NoOpPasswordEncoder.getInstance());
}
위와 같이 configure에서 UserDetailsService 및 PasswordEncoder를 설정하면 어디서 UserDetailsService 및 PasswordEncoder가 연결되어있는지 알기 쉽지 않다. 2.3.1에서 구성한 것과 같이 책임을 분리해서 작성해야 한다. (1책임당 1클래스로 구성하는게 좋다)
2.3.4 AuthenticationProvider 구현 재정의
AuthenticationProvider는 인증 논리를 구현하고 사용자 관리와 암호 관리를 각각 UserDetailsService 및 PasswordEncoder에 위임한다.
@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
// Principal 인터페이스의 getName() 메서드를 Authentication에서 상속받는다.
String username = authentication.getName();
String password = String.valueOf(authentication.getCredentials());
// 이 조건은 일반적으로 UserDetialsService 및 PasswordEncoder를 호출해서 사용자 이름과 암호를 테스트한다.
if ("john".equals(username) && "12345".equals(password)) {
return new UsernamePasswordAuthenticationToken(username, password, Arrays.asList());
} else {
throw new AuthenticationCredentialsNotFoundException("Error!");
}
}
@Override
public boolean supports(Class<?> authenticationType) {
// Authentication 형식의 구현을 추가할 위치
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authenticationType);
}
}
authentication(Authentication authentication) 메서드는 인증의 전체 논리를 나타낸다.