1일차 미션
configure(AuthenticationManagerBuilder auth) 메소드 override
- passwordEncoder는 NoOpPasswordEncoder로 사용함 (힌트: DelegatingPasswordEncoder)
- 기본 로그인 계정을 AuthenticationManagerBuilder 클래스를 통해 추가
로그아웃, Cookie 기반 자동 로그인 (Remember-Me) 기능 설정하기 어렵지 않아요
- HttpSecurity 클래스의 logout() API를 통해 로그아웃 기능을 설정
- 로그아웃 처리 path “/logout”
- 로그아웃 성공 후 리다이렉션 path “/”
- HttpSecurity 클래스의 rememberMe() API를 통해 Cookie 기반 자동 로그인 기능을 설정
- 파라미터명 “remember-me”
- 자동 로그인 토큰 유효기간 5분
1일차 미션 리뷰
솔루션
- 기본 로그인 계정을 추가할때 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) ; } }

2일차 미션
- AnonymousAuthenticationFilter, ExceptionTranslationFilter 에 대해 정리해보기
- 대칭 키 암호화, RSA 암호화에 대해 정리해보기
- SSL 인증서를 직접 생성해보고, Spring Boot 프로젝트에 적용해보기
2일차 미션 리뷰
AnonymousAuthenticationFilter, ExceptionTranslationFilter 에 대해 정리해보기
- AnonymousAuthenticationFilter
- 해당 필터에 요청이 도달할때까지 사용자가 인증되지 않았다면, 사용자를 null 대신 Anonymous 인증 타입으로 표현
- 사용자가 null 인지 확인하는것보다 어떤 구체적인 타입으로 확인할수 있도록 함
@Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { if (SecurityContextHolder.getContext().getAuthentication() == null) { SecurityContextHolder.getContext().setAuthentication(createAuthentication((HttpServletRequest) req)); if (this.logger.isTraceEnabled()) { this.logger.trace(LogMessage.of(() -> "Set SecurityContextHolder to " + SecurityContextHolder.getContext().getAuthentication())); } else { this.logger.debug("Set SecurityContextHolder to anonymous SecurityContext"); } } else { if (this.logger.isTraceEnabled()) { this.logger.trace(LogMessage.of(() -> "Did not set SecurityContextHolder since already authenticated " + SecurityContextHolder.getContext().getAuthentication())); } } chain.doFilter(req, res); } protected Authentication createAuthentication(HttpServletRequest request) { AnonymousAuthenticationToken token = new AnonymousAuthenticationToken(this.key, this.principal, this.authorities); token.setDetails(this.authenticationDetailsSource.buildDetails(request)); return token; }
- ExceptionTranslationFilter
- FilterSecurityInterceptor 바로 위에 위치하며, FilterSecurityInterceptor 실행 중 발생할 수 있는 예외를 잡고 처리함
- FilterSecurityInterceptor 실행 중 발생 가능한 AuthenticationException, AccessDeniedException 예외에 대한 처리를 담당
- AuthenticationException 예외는 인증 관련 예외이며, 사용자를 로그인 페이지로 보냄
- AccessDeniedException 예외는 AccessDecisionManager에 의해 접근 거부가 발생했을 때 접근 거부 페이지를 보여주거나 사용자를 로그인 페이지로 보냄
- AuthenticationEntryPoint
- 인증되지 않은 사용자 요청을 처리할때 핵심 적인 역할을 수행함 — 보통 사용자를 로그인 요청 페이지로 포워딩하는 역할을 함
- 폼 기반 로그인 인증 외의 다른 인증 매커니즘을 처리해야 할때도 AuthenticationEntryPoint를 이용할 수 있음
- 예를 들어 CAS 인증 처리가 필요하다면 CAS 포탈로 사용자를 이동시킴
- 서드 파티 시스템과 연동이 필요한 경우 AuthenticationEntryPoint를 직접 구현할 수도 있음
필터 체인 상에서 ExceptionTranslationFilter의 위치를 주의해서 볼 필요가 있다. ExceptionTranslationFilter는 필터 체인 실행 스택에서 자기 아래에 오는 필터들에서 발생하는 예외들에 대해서만 처리할 수 있다. 커스텀 필터를 추가해야 하는 경우 이 내용을 잘 기억하고, 커스텀 필터를 적당한 위치에 두어야 한다.

private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { try { chain.doFilter(request, response); } catch (IOException ex) { throw ex; } catch (Exception ex) { // Try to extract a SpringSecurityException from the stacktrace Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(ex); RuntimeException securityException = (AuthenticationException) this.throwableAnalyzer .getFirstThrowableOfType(AuthenticationException.class, causeChain); if (securityException == null) { securityException = (AccessDeniedException) this.throwableAnalyzer .getFirstThrowableOfType(AccessDeniedException.class, causeChain); } if (securityException == null) { rethrow(ex); } if (response.isCommitted()) { throw new ServletException("Unable to handle the Spring Security Exception " + "because the response is already committed.", ex); } handleSpringSecurityException(request, response, chain, securityException); } } private void handleSpringSecurityException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, RuntimeException exception) throws IOException, ServletException { if (exception instanceof AuthenticationException) { handleAuthenticationException(request, response, chain, (AuthenticationException) exception); } else if (exception instanceof AccessDeniedException) { handleAccessDeniedException(request, response, chain, (AccessDeniedException) exception); } } private void handleAuthenticationException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AuthenticationException exception) throws ServletException, IOException { this.logger.trace("Sending to authentication entry point since authentication failed", exception); sendStartAuthentication(request, response, chain, exception); } 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); } }
- AccessDeniedException 예외에 대한 핸들러 설정이 가능함
- 기본 구현은 org.springframework.security.web.access.AccessDeniedHandlerImpl 클래스
- HttpSecurity 클래스의 exceptionHandling() 메소드를 통해 사용자 정의 핸들러를 설정함
- 접근 거부 요청에 대한 로깅 처리
- HTTP 403 응답 생성
@Override protected void configure(HttpSecurity http) throws Exception { http /** * 예외처리 핸들러 */ .exceptionHandling() .accessDeniedHandler((request, response, e) -> { Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); log.warn("{} is denied", principal, e); response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.getWriter().write("ACCESS DENIED"); response.getWriter().flush(); response.getWriter().close(); }) ; }
대칭 키 암호화, RSA 암복호화에 대해 정리해보기
해당 미션에 대해서는 따로 영상을 통해 리뷰가 진행되지 않습니다. 아래 정리된 내용보다 자세한 내용은 검색을 통해 쉽게 확인해볼 수 있습니다.
- 대칭 키 암호화
- 암호화와 복호화에 같은 암호 키를 쓰는 알고리즘을 의미
- 대칭 키 암호는 공개 키 암호와 비교하여 계산 속도가 빠르다는 장점이 있음
- 암호화를 하는 측과 복호화를 하는 측이 같은 암호 키를 공유해야 한다는 단점이 있음
- 대표적인 알고리즘으로 AES, DES, SEED 등이 있음
- RSA 암호화
- Rivet, Shamir, Adelman 세사람의 첫이름을 따 RSA 라고 하며, 공개키 암호 알고리즘 (사실상 표준 공개키 암호 알고리즘)
- 공개 키, 비밀 키가 한 쌍으로 존재하며 공개 키는 누구나 알 수 있지만 그에 대응하는 비밀 키는 키의 소유자만이 알 수 있어야함
- 공개 키 암호 방식은 크게 두 가지 종류로 나눌 수 있음
- 공개 키 암호 — 특정한 비밀 키를 가지고 있는 사용자만 내용을 열어볼 수 있음
- 공개 키 서명 — 특정한 비밀 키로 만들었다는 것을 누구나 확인할 수 있음
- 일반적으로 공개 키 암호 방식은 대칭 키 암호화보다 느림
- 실제 데이터를 암호화하기 위한 대칭 키를 공개 키로 암호화하고 통신 상대에게 전달하는 방식으로 많이 쓰임
3일차 미션
- Remember-Me 인증에 대해 정리해보기
- Remember-Me 인증을 처리하기 위한 Security Filter는 어떤 클래스 인가?
- Remember-Me 인증을 처리하기 위한 Authentication 인터페이스 구현 클래스는 무엇인가?
- Remember-Me 인증을 처리하기 위한 AuthenticationProvider 인터페이스 구현 클래스는 무엇인가?
3일차 미션 리뷰
Remember-Me 인증에 대해 정리해보기
- RememberMeAuthenticationFilter
- 인증되지 않은 사용자의 HTTP 요청이 remember-me 쿠키(Cookie)를 갖고 있다면, 사용자를 자동으로 인증처리함
- key — remember-me 쿠키에 대한 고유 식별 키
- 미입력시 자동으로 랜덤 텍스트가 입력 됨
- rememberMeParameter — remember-me 쿠키 파라미터명 (기본값 remember-me)
- tokenValiditySeconds — 쿠키 만료 시간 (초 단위)
- alwaysRemember — 항상 remember-me 를 활성화 시킴 (기본값 false)
http // ... 생략 ... .rememberMe() .key("my-remember-me") .rememberMeParameter("remember-me") .tokenValiditySeconds(300) .alwaysRemember(false) .and() // ... 생략 ...
- TokenBasedRememberMeServices — MD5 해시 알고리즘 기반 쿠키 검증
- PersistentTokenBasedRememberMeServices — 외부 데이터베이스에서 인증에 필요한 데이터를 가져오고 검증함
- 사용자마다 고유의 Series 식별자가 생성되고, 인증 시 마다 매번 갱신되는 임의의 토큰 값을 사용하여 보다 높은 보안성을 제공함
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { if (SecurityContextHolder.getContext().getAuthentication() != null) { this.logger.debug(LogMessage .of(() -> "SecurityContextHolder not populated with remember-me token, as it already contained: '" + SecurityContextHolder.getContext().getAuthentication() + "'")); chain.doFilter(request, response); return; } Authentication rememberMeAuth = this.rememberMeServices.autoLogin(request, response); if (rememberMeAuth != null) { // Attempt authenticaton via AuthenticationManager try { rememberMeAuth = this.authenticationManager.authenticate(rememberMeAuth); // Store to SecurityContextHolder SecurityContextHolder.getContext().setAuthentication(rememberMeAuth); onSuccessfulAuthentication(request, response, rememberMeAuth); this.logger.debug(LogMessage.of(() -> "SecurityContextHolder populated with remember-me token: '" + SecurityContextHolder.getContext().getAuthentication() + "'")); if (this.eventPublisher != null) { this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent( SecurityContextHolder.getContext().getAuthentication(), this.getClass())); } if (this.successHandler != null) { this.successHandler.onAuthenticationSuccess(request, response, rememberMeAuth); return; } } catch (AuthenticationException ex) { this.logger.debug(LogMessage .format("SecurityContextHolder not populated with remember-me token, as AuthenticationManager " + "rejected Authentication returned by RememberMeServices: '%s'; " + "invalidating remember-me token", rememberMeAuth), ex); this.rememberMeServices.loginFail(request, response); onUnsuccessfulAuthentication(request, response, ex); } } chain.doFilter(request, response); }
- RememberMeAuthenticationToken
- remember-me 기반 Authentication 인터페이스 구현체
- RememberMeAuthenticationToken 객체는 언제나 인증이 완료된 상태만 존재함
- RememberMeAuthenticationProvider
- RememberMeAuthenticationToken 기반 인증 처리를 위한 AuthenticationProvider
- 앞서 remember-me 설정 시 입력한 key 값을 검증함
@Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { if (!supports(authentication.getClass())) { return null; } if (this.key.hashCode() != ((RememberMeAuthenticationToken) authentication).getKeyHash()) { throw new BadCredentialsException(this.messages.getMessage("RememberMeAuthenticationProvider.incorrectKey", "The presented RememberMeAuthenticationToken does not contain the expected key")); } return authentication; }
- 명시적인 로그인 아이디/비밀번호 기반 인증 사용와 권한 구분
- remember-me 기반 인증과 로그인 아이디/비밀번호 기반 인증 결과가 명백히 다르것에 주목
- remember-me 기반 인증 결과 — RememberMeAuthenticationToken
- 로그인 아이디/비밀번호 기반 인증 결과 — UsernamePasswordAuthenticationToken
- remember-me 기반 인증은 로그인 기반 인증 보다 보안상 다소 약한 인증
- 따라서, 모두 동일하게 인증된 사용자라 하더라도 권한을 분리할 수 있음
- isFullyAuthenticated — 명시적인 로그인 아이디/비밀번호 기반으로 인증된 사용자만 접근 가능
@Override public final boolean isFullyAuthenticated() { return !this.trustResolver.isAnonymous(this.authentication) && !this.trustResolver.isRememberMe(this.authentication); }

4일차 미션
"/admin" URL 접근에 대한 접근 권한 검사를 SpEL 표현식 방식에서 voter 방식으로 변경해보기 (OddAdminVoter 클래스)
- AccessDecisionVoter<FilterInvocation> 인터페이스를 구현하는 OddAdminVoter 클래스 추가
- Admin 사용자의 로그인 아이디 끝 숫자가 홀수 인 경우 접근 허용
- URL이 "/admin" 이 아닌 경우 접근을 승인함
- expressionHandler(expressionHandler()) 부분 삭제 — 기본 expressionHandler를 사용함
- 표현식에서 oddAdmin 부분 삭제 (삭제 후 표현식은 아래와 같음)
antMatchers("/admin").access("isFullyAuthenticated() and hasRole('ADMIN')")
- AccessDecisionManager 인터페이스 구현체 중 UnanimousBased를 사용하도록 설정하고, 아래 voter를 추가
- WebExpressionVoter
- OddAdminVoter
4일차 미션 리뷰
"/admin" URL 접근에 대한 접근 권한 검사를 SpEL 표현식 방식에서 voter 방식으로 변경해보기 (OddAdminVoter 클래스)
- AccessDecisionVoter<FilterInvocation> 인터페이스를 구현하는 OddAdminVoter 클래스 추가
- Admin 사용자의 로그인 아이디 끝 숫자가 홀수 인 경우 접근 허용
- URL이 "/admin" 이 아닌 경우 접근을 승인함
- expressionHandler(expressionHandler()) 부분 삭제 — 기본 expressionHandler를 사용함
- 표현식에서 oddAdmin 부분 삭제 (삭제 후 표현식은 아래와 같음)
antMatchers("/admin").access("isFullyAuthenticated() and hasRole('ADMIN')")
- AccessDecisionManager 인터페이스 구현체 중 UnanimousBased를 사용하도록 설정하고, 아래 voter를 추가
- WebExpressionVoter
- OddAdminVoter
솔루션
- OddAdminVoter 구현
- RequestMatcher — URL이 "/admin" 이 아닌 경우를 확인하고 접근을 승인 처리함
- 그 외 구현은 기존과 동일
public class OddAdminVoter implements AccessDecisionVoter<FilterInvocation> { static final Pattern PATTERN = Pattern.compile("[0-9]+$"); private final RequestMatcher requiresAuthorizationRequestMatcher; public OddAdminVoter(RequestMatcher requiresAuthorizationRequestMatcher) { this.requiresAuthorizationRequestMatcher = requiresAuthorizationRequestMatcher; } @Override public int vote(Authentication authentication, FilterInvocation fi, Collection<ConfigAttribute> attributes) { HttpServletRequest request = fi.getRequest(); if (!requiresAuthorization(request)) { return ACCESS_GRANTED; } User user = (User) authentication.getPrincipal(); String name = user.getUsername(); Matcher matcher = PATTERN.matcher(name); if (matcher.find()) { int number = toInt(matcher.group(), 0); if (number % 2 == 1) { return ACCESS_GRANTED; } } return ACCESS_DENIED; } private boolean requiresAuthorization(HttpServletRequest request) { return requiresAuthorizationRequestMatcher.matches(request); } @Override public boolean supports(ConfigAttribute attribute) { return true; } @Override public boolean supports(Class<?> clazz) { return FilterInvocation.class.isAssignableFrom(clazz); } }
- HttpSecurity 설정
- AccessDecisionManager 구현체로 UnanimousBased 구현체를 사용
- 순차적으로 AccessDecisionVoter 추가
- WebExpressionVoter
- OddAdminVoter — 생성자 인자로 해당 voter가 처리해야 하는 URL 패턴을 넘김
@Bean public AccessDecisionManager accessDecisionManager() { List<AccessDecisionVoter<?>> decisionVoters = new ArrayList<>(); decisionVoters.add(new WebExpressionVoter()); decisionVoters.add(new OddAdminVoter(new AntPathRequestMatcher("/admin"))); return new UnanimousBased(decisionVoters); } @Override protected void configure(HttpSecurity http) throws Exception { http // ... 생략 ... .authorizeRequests() .antMatchers("/me").hasAnyRole("USER", "ADMIN") .antMatchers("/admin").access("isFullyAuthenticated() and hasRole('ADMIN')") .anyRequest().permitAll() .accessDecisionManager(accessDecisionManager()) .and() // ... 생략 ... }
- user 계정으로 로그인 하면 어떻게 될까?
- UnanimousBased 구현에서는 순차적으로 실행되는 voter 중 접근 거부(ACCESS_DENIED)가 발생하면 즉시 AccessDeniedException 예외를 발생시킴
- voter 목록 중 WebExpressionVoter가 먼저 실행되며, ROLE_ADMIN 권한 검사가 먼저 이루어짐
- 이 과정에서 접근 거부되고, 예외가 발생함
- 따라서, OddAdminVoter는 실행 조차 되지 않음
5일차 미션
- Spring Security의 주요 개념과 필터들에 대해 정리해보기