지난 미션 리뷰
configure(AuthenticationManagerBuilder auth) 메소드 override
- passwordEncoder는 NoOpPasswordEncoder로 사용함
- 기본 로그인 계정을 AuthenticationManagerBuilder 클래스를 통해 추가
로그아웃, Cookie 기반 자동 로그인 (Remember-Me) 기능 설정하기
- HttpSecurity 클래스의 logout() API를 통해 로그아웃 기능을 설정
- 로그아웃 처리 path “/logout”
- 로그아웃 성공 후 리다이렉션 path “/”
- HttpSecurity 클래스의 rememberMe() API를 통해 Cookie 기반 자동 로그인 기능을 설정
- 파라미터명 “remember-me”
- 자동 로그인 토큰 유효기간 5분
솔루션
- 기본 로그인 계정을 추가할때 password 설정시 주의점
- Spring Security 5에서는 DelegatingPasswordEncoder 클래스가 기본 PasswordEncoder로 사용됨
- DelegatingPasswordEncoder 클래스는 패스워드 해시 알고리즘별로 PasswordEncoder를 제공하는데, 해시 알고리즘별 PasswordEncoder 선택을 위해 패스워드 앞에 prefix를 추가함
{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG {noop}password {pbkdf2}5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc {sha256}97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfffaf8410849f27605abcbc0
public static PasswordEncoder createDelegatingPasswordEncoder() { String encodingId = "bcrypt"; Map<String, PasswordEncoder> encoders = new HashMap<>(); encoders.put(encodingId, new BCryptPasswordEncoder()); encoders.put("ldap", new org.springframework.security.crypto.password.LdapShaPasswordEncoder()); encoders.put("MD4", new org.springframework.security.crypto.password.Md4PasswordEncoder()); encoders.put("MD5", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("MD5")); encoders.put("noop", org.springframework.security.crypto.password.NoOpPasswordEncoder.getInstance()); encoders.put("pbkdf2", new Pbkdf2PasswordEncoder()); encoders.put("scrypt", new SCryptPasswordEncoder()); encoders.put("SHA-1", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-1")); encoders.put("SHA-256", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-256")); encoders.put("sha256", new org.springframework.security.crypto.password.StandardPasswordEncoder()); encoders.put("argon2", new Argon2PasswordEncoder()); return new DelegatingPasswordEncoder(encodingId, encoders); }
InMemoryUserDetailsManager 객체를 사용한다면(보다 정확하게는 UserDetailsPasswordService 인터페이스 구현체) 최초 로그인 1회 성공시, {noop} 타입에서 → {bcrypt} 타입으로 PasswordEncoder가 변경된다.
- 전체 코드
@Configuration @EnableWebSecurity public class WebSecurityConfigure extends WebSecurityConfigurerAdapter { @Override public void configure(WebSecurity web) { web.ignoring().antMatchers("/assets/**"); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.inMemoryAuthentication() .withUser("user").password("{noop}user123").roles("USER") .and() .withUser("admin").password("{noop}admin123").roles("ADMIN") ; } @Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers("/me").hasAnyRole("USER", "ADMIN") .anyRequest().permitAll() .and() .formLogin() .defaultSuccessUrl("/") .permitAll() .and() /** * remember me 설정 */ .rememberMe() .rememberMeParameter("remember-me") .tokenValiditySeconds(300) .and() /** * 로그아웃 설정 */ .logout() .logoutRequestMatcher(new AntPathRequestMatcher("/logout")) .logoutSuccessUrl("/") .invalidateHttpSession(true) .clearAuthentication(true) ; } }

Spring Security Architecture
Conceptual Architecture
FilterChainProxy (Spring Security 필터 체인) 소개
- Spring Security의 실제적인 구현은 서블릿 필터 (javax.servlet.Filter 인터페이스 구현체) 를 통해 이루어짐
- 서블릿 필터는 웹 요청을 가로챈 후 전처리 또는 후처리를 수행하거나, 요청 자체를 리다이렉트 하기도 함
- FilterChainProxy 세부 내용은 WebSecurityConfigurerAdapter 추상 클래스를 상속하는 구현체에서 설정함 (보통 @EnableWebSecurity 어노테이션도 함께 사용)
- 웹 요청은 이러한 필터 체인을 차례로 통과하게 됨
- 웹 요청은 모든 필터를 통과하게 되지만, 모든 필터가 동작하는 것은 아님
- 각 필터는 웹 요청에 따라 동작 여부를 결정할 수 있고, 동작할 필요가 없다면 다음 필터로 웹 요청을 즉시 넘김
- 요청을 처리하고 응답을 반환하면 필터 체인 호출 스택은 모든 필터에 대해 역순으로 진행
- 보통 springSecurityFilterChain 이라는 이름으로 Bean 등록됨
- 웹 요청은 어떻게 FilterChainProxy로 전달될까?
- 웹 요청을 수신한 서블릿 컨테이너는 해당 요청을 DelegatingFilterProxy (javax.servlet.Filter 인터페이스 구현체) 로 전달함
- DelegatingFilterProxy Bean은 SecurityFilterAutoConfiguration 클래스에서 자동으로 등록됨
@Bean @ConditionalOnBean(name = DEFAULT_FILTER_NAME) public DelegatingFilterProxyRegistrationBean securityFilterChainRegistration(SecurityProperties securityProperties) { DelegatingFilterProxyRegistrationBean registration = new DelegatingFilterProxyRegistrationBean(DEFAULT_FILTER_NAME); registration.setOrder(securityProperties.getFilter().getOrder()); registration.setDispatcherTypes(getDispatcherTypes(securityProperties)); return registration; }
- Target Filter Bean은 바로 앞에서 알아본 FilterChainProxy
@Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws ServletException, IOException { // Lazily initialize the delegate if necessary. Filter delegateToUse = this.delegate; if (delegateToUse == null) { synchronized (this.delegateMonitor) { delegateToUse = this.delegate; if (delegateToUse == null) { WebApplicationContext wac = findWebApplicationContext(); if (wac == null) { throw new IllegalStateException("No WebApplicationContext found: " + "no ContextLoaderListener or DispatcherServlet registered?"); } delegateToUse = initDelegate(wac); } this.delegate = delegateToUse; } } // Let the delegate perform the actual doFilter operation. invokeDelegate(delegateToUse, request, response, filterChain); } protected Filter initDelegate(WebApplicationContext wac) throws ServletException { String targetBeanName = getTargetBeanName(); Assert.state(targetBeanName != null, "No target bean name set"); Filter delegate = wac.getBean(targetBeanName, Filter.class); if (isTargetFilterLifecycle()) { delegate.init(getFilterConfig()); } return delegate; } protected void invokeDelegate(Filter delegate, ServletRequest request, ServletResponse response, FilterChain filterChain) throws ServletException, IOException { delegate.doFilter(request, response, filterChain); }

FilterChainProxy를 구성하는 Filter 목록
- 정말 다양한 필터 구현을 제공함
- 결국 Spring Security를 잘 이해하고 활용한다는 것은 이들 Filter 를 이해하고, 적절하게 사용한다는 것을 의미함
간단한 몇 개의 Filter에 대해 자세히 알아보자.
인증 요청에 의해 가로채어진 원래 요청으로 이동하기 (RequestCacheAwareFilter)
- 익명 사용자가 보호 받는 리소스 (예: /me)에 접근할 경우
- 접근 권한이 없기 때문에 AccessDecisionManager 에서 접근 거부 예외가 발생함
- ExceptionTranslationFilter 접근 거부 예외를 처리함
- 현재 사용자가 익명 사용자라면, 보호 받는 리소스로의 접근을 캐시처리하고, 로그인 페이지로 이동 시킴
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); } } protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AuthenticationException reason) throws ServletException, IOException { // SEC-112: Clear the SecurityContextHolder's Authentication, as the // existing Authentication is no longer considered valid SecurityContextHolder.getContext().setAuthentication(null); this.requestCache.saveRequest(request, response); this.authenticationEntryPoint.commence(request, response, reason); }
- RequestCacheAwareFilter를 통해 위에서 살펴본 캐시된 요청을 처리할 수 있음
- 캐시된 요청이 있다면 캐시된 요청을 처리하고, 캐시된 요청이 없다면 현재 요청을 처리함
@Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest wrappedSavedRequest = this.requestCache.getMatchingRequest((HttpServletRequest) request, (HttpServletResponse) response); chain.doFilter((wrappedSavedRequest != null) ? wrappedSavedRequest : request, response); }
전송 레이어 보안 적용 (ChannelProcessingFilter)
전송 레이어 보안을 위해 SSL 인증서를 생성하고, 이를 Spring Boot 웹 어플리케이션에 적용한다. 이제 웹 어플리케이션은 HTTPS 프로토콜을 통해 서비스 된다.
HTTP와 HTTPS
- HTTP(Hyper Text Transfer Protocol)는 인터넷상에서 데이터를 주고 받기 위한 프로토콜
- 클라이언트와 서버가 주고 받는 데이터는 암호화되어 있지 않음
- 따라서, 악의적인 데이터 감청, 데이터 변조의 가능성이 있음
- HTTPS(HyperT ext Transfer Protocol Secure)는 HTTP 프로토콜의 암호화 버전
- 클라이언트와 서버가 주고 받는 모든 데이터는 암호화되어 있음
- 데이터 암호화를 위해 SSL(Secure Sockets Layer)을 사용
SSL은 Netscape가 개발했으며 SSL 3.0부터 TLS라는 이름으로 변경되었다. 일반적으로 SSL, TLS은 같은 의미를 지닌다. 그러나 SSL이란 용어가 더 많이 사용된다.
- SSL 암호화를 위해 SSL 인증서가 필요함
- 서버는 SSL인증서를 클라이언트에 전달함
- 클라이언트는 서버가 전달한 SSL 인증서를 검증하고, 신뢰할 수 있는 서버인지 확인함
- 신뢰할 수 있는 서버라면 SSL 인증서의 공개키를 이용해 실제 데이터 암호화에 사용될 암호화키를 암호화하여 서버에 전달함
- 실제 데이터 암복호화는 대칭키 방식
- 서버와 클라이언트 사이의 대칭키 공유를 위해 RSA 암호화를 사용함
SSL 인증서 생성
- keytool 도구를 이용해 임의로 SSL 인증서를 생성할 수 있음 (keytool은 Java 설치 경로 bin 디렉토리 아래에 위치함)
- 물론 실제 서비스에는 사용할 수 없으며, 어디까지나 로컬 테스트 용도로만 활용해야 함

- keystore 만들기
- keytool -genkey -alias [keystore 별칭] -keyalg RSA -storetype PKCS12 -keystore [keystore 파일]
iyboklee@DESKTOP-6I1BVIA:/mnt/c/Users/iybok/stores$ keytool -genkey -alias prgrms_keystore -keyalg RSA -storetype PKCS12 -keystore prgrms_keystore.p12 Enter keystore password: Re-enter new password: What is your first and last name? [Unknown]: localhost What is the name of your organizational unit? [Unknown]: Prgrms What is the name of your organization? [Unknown]: Prgrms What is the name of your City or Locality? [Unknown]: Seoul What is the name of your State or Province? [Unknown]: Seoul What is the two-letter country code for this unit? [Unknown]: KR Is CN=localhost, OU=Prgrms, O=Prgrms, L=Seoul, ST=Seoul, C=KR correct? [no]: y iyboklee@DESKTOP-6I1BVIA:/mnt/c/Users/iybok/stores$ ls prgrms_keystore.p12
- keystore 에서 인증서 추출하기
- keytool -export -alias [keystore 별칭] -keystore [keystore 파일] -rfc -file [인증서 파일]
iyboklee@DESKTOP-6I1BVIA:/mnt/c/Users/iybok/stores$ keytool -export -alias prgrms_keystore -keystore prgrms_keystore.p12 -rfc -file prgrms.cer Enter keystore password: Certificate stored in file <prgrms.cer> iyboklee@DESKTOP-6I1BVIA:/mnt/c/Users/iybok/stores$ ls prgrms.cer prgrms_keystore.p12
- trust-store 만들기
- keytool -import -alias [trust keystore 별칭] -file [인증서 파일] -keystore [trust keystore 파일]
iyboklee@DESKTOP-6I1BVIA:/mnt/c/Users/iybok/stores$ keytool -import -alias prgrms_truststore -file prgrms.cer -keystore prgrms_truststore.p12 Enter keystore password: Re-enter new password: Owner: CN=localhost, OU=Prgrms, O=Prgrms, L=Seoul, ST=Seoul, C=KR Issuer: CN=localhost, OU=Prgrms, O=Prgrms, L=Seoul, ST=Seoul, C=KR Serial number: 16cd5188 Valid from: Thu Aug 19 19:37:07 KST 2021 until: Wed Nov 17 19:37:07 KST 2021 Certificate fingerprints: MD5: 26:91:CD:3D:BC:B9:2E:C7:6B:23:2C:B0:3C:DF:E2:BB SHA1: 3E:85:57:2A:7B:51:2B:20:5A:F8:FB:92:41:87:6C:41:A4:1E:01:A5 SHA256: 63:AD:A4:85:49:08:B7:01:75:36:34:A6:02:B6:2A:9B:1F:16:C0:5D:63:CE:F2:66:68:71:65:6E:31:1E:4B:D6 Signature algorithm name: SHA256withRSA Subject Public Key Algorithm: 2048-bit RSA key Version: 3 Extensions: #1: ObjectId: 2.5.29.14 Criticality=false SubjectKeyIdentifier [ KeyIdentifier [ 0000: DB 81 03 CF 01 A9 25 34 70 46 F4 FF EF 8D BA 3D ......%4pF.....= 0010: 24 C7 3B 6C $.;l ] ] Trust this certificate? [no]: y Certificate was added to keystore iyboklee@DESKTOP-6I1BVIA:/mnt/c/Users/iybok/stores$ ls prgrms.cer prgrms_keystore.p12 prgrms_truststore.p12
SSL 인증서 적용
- prgrms_keystore.p12, prgrms_truststore.p12 2개 파일을 resources 디렉토리로 복사 후 application.xml 파일에 설정 추가
- 포트를 443으로 변경 (HTTPS 기본 포트)
- server.ssl 설정 추가
spring: application: name: spring security 01 autoconfigure: exclude: org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration thymeleaf: cache: true security: user: name: user password: user123 roles: USER messages: basename: i18n/messages encoding: UTF-8 cache-duration: PT1H server: port: 443 ssl: enabled: true key-alias: prgrms_keystore key-store: classpath:prgrms_keystore.p12 key-store-password: prgrms123 key-password: prgrms123 trust-store: classpath:prgrms_truststore.p12 trust-store-password: prgrms123
- 웹 어플리케이션을 시작하면 443 (https) 포트를 통해 서비스가 기동하는 것을 로그로 확인할 수 있음

- 웹 브라우저 주소에 https://localhost 를 입력
- 정상적으로 페이지 접근을 확인할 수 있음
- 로그인/로그아웃도 정상적으로 수행 가능
- SSL 인증서가 유효하지 않기 때문에 경고가 뜸 (유효한 인증서라면 경고가 뜨지 않음)
- 브라우저 마다 경고가 다를 수 있음 (크롬의 경우 HTTPS 연결이 사용되지 않았다는 경고 메시지 발생)

Spring Security 설정하기
- ChannelProcessingFilter 설정을 통해 HTTPS 채널을 통해 처리해야 하는 웹 요청을 정의할 수 있음
- FilterInvocationSecurityMetadataSource 클래스에 HTTPS 프로토콜로 처리해야 URL 정보가 담김
- 실제적인 처리를 ChannelDecisionManager 클래스로 위임함
public class ChannelProcessingFilter extends GenericFilterBean { private ChannelDecisionManager channelDecisionManager; private FilterInvocationSecurityMetadataSource securityMetadataSource; // ...생략... @Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) res; FilterInvocation filterInvocation = new FilterInvocation(request, response, chain); Collection<ConfigAttribute> attributes = this.securityMetadataSource.getAttributes(filterInvocation); if (attributes != null) { this.logger.debug(LogMessage.format("Request: %s; ConfigAttributes: %s", filterInvocation, attributes)); this.channelDecisionManager.decide(filterInvocation, attributes); if (filterInvocation.getResponse().isCommitted()) { return; } } chain.doFilter(request, response); } // ...생략... }
- HttpSecurity 클래스를 통해 ChannelProcessingFilter 세부 설정을 할 수 있음
@Override protected void configure(HttpSecurity http) throws Exception { http /** * HTTP 요청을 HTTPS 요청으로 리다이렉트 */ .requiresChannel() .anyRequest().requiresSecure() ; }
미션
- AnonymousAuthenticationFilter, ExceptionTranslationFilter 에 대해 정리해보기
- 대칭 키 암호화, RSA 암호화에 대해 정리해보기
- SSL 인증서를 직접 생성해보고, Spring Boot 프로젝트에 적용해보기