스프링 시큐리티 : 웹 애플리케이션 시큐리티
Security ignoring 설정
- WebSecurity의
ignoring()
을 사용해서 시큐리티 필터 적용을 제외할 요청을 할 수 있다.
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().mvcMatchers("/favicon.ico");
}
- 스프링 부트가 제공하는 PathRequest를 사용해서 정적 자원 요청을 스프링 시큐리티 필터를 적용하지 않도록 설정한다.
- WebSecurity 설정이 아닌 HttpSecurity에 설정을 할 수 도 있다.
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
@Overide
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.requestMatchers(PathRequest.toStaticResources().atCommonLocations())
.permitAll();
}
- 결과적으로 같지만, 필터를 모두 타지만 허용하는 것.
- WebSecurity ignoring 설정의 경우에는 필터 자체를 거치지 않는다.
- 따라서 시큐리티를 아예 적용하고 싶지않은 부분은(정적 리소스라던지..) WebSecurity설정이 더 좋다.
- 동적 리소스는
http.authorizeRequests()
에서 처리하는것을 권장하고, 정적 리소스는 web.ignore()
를 권장한다. (인증이 필요한 정적 자원의 경우, web.ignore()
에서 exclude하고 http.authorizeRequests()
에서 처리하는것을 권장한다.
Async 웹 MVC를 지원하는 필터 : WebAsyncManagerIntegrationFilter
- 가장 먼저 타는 SecurityFilter
- 스프링 MVC의 Async 기능 (핸들러에서 Callable을 리턴할 수 있는 기능)을 사용할때에도 SecurityContext를 공유하도록 도와주는 필터
- SecurityContext는 ThreadLocal 같은 Thread에서만 공유하지만, Async 기능에서는 다른 Thread를 사용하게되는데 이런 경우에도 그 Thread에서도 동일한 SecurityContext를 사용할 수 있도록 지원해주는 필터
- PreProcess: SecurityContext를 설정한다. (새로운 Thread에)
- Callable: 비록 다른 쓰레드지만 그 안에서는 동일한 SecurityContext를 참조할 수 있다.
- PostProcess: SecurityContext를 정리(clean up)한다.
-
SecurityContext 영속화 필터 : SecurityContextPersistenceFilter
- 보통 두번째 등록된 필터
- 여러 요청간에 SecurityContext를 공유할 수 있는 기능을 제공한다.
- 기본적으로 SecurityContextRepository에 위임하여, 기존의 SecurityContext를 읽어오거나 비어 있다면 초기화 한다.
- 기본전략인 SecurityContextRepository 의 기본 구현체가 HttpSessionSecurityContextRepository이다.
- 즉, HttpSession 에서 읽어온다.
- 모든 인증관련 필터들 보다 더 위에 선언이 되어 있어야 한다.
- 이곳에서 읽어왔다면, 인증필터들을 생략해야하기 때문에
- 응답헤더에 특정 시큐리티 관련 헤더들을 추가해주는 필터
- 우리가 설정할 일이 거의 없는 필터
- 추가해주는 헤더 정보
- XContentTypeOptionsHeaderWriter : 마임타입 스니핑 방어
- 실행할 수 없는 마임타입을 실행하려고 시도하면서 보안상 이슈가 발생하는 공격
X-Content-Type-Options: nosniff
: contentType에 명시된 마임타입으로만 실행할 수 있게 한다.
- XXssProtectionHeaderWriter : 브라우저 내장된 XSS 필터 적용
- 해당 필터로 XSS 공격을 막을 수는 없지만 1차적으로 방어가 가능하다.
XXssProtectionHeaderWriter: 1; mode=block
- CacheControlHEadersWriter : 캐시 히스토리 취약점 방어
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
- HstsHeaderWriter : HTTPS만 소통하도록 강제
- XFrameOptionsHeaderWriter : clickJacking 방어 헤더
- click jaking : 보이지 않는 영역에 이상한 사이트의 정보가 들어와 있어 어떤영역을 클릭 시, 나의 민감한 정보가 보내지거나 하는등의 공격
CSRF 어택 방지 필터: CsrfFilter
- CSRF 어택 방지 필터
- Cross Site Request Forgery
- 인증된 유저의 계정을 사용해 악의적인 변경 요청을 만들어 보내는 기법
-
- CORS를 허용할 때, 특히 주의해야 한다.
- 타 도메인에서 보내오는 요청을 허용하기 때문에…
- 의도한 사용자만 리소스를 변경할 수 있도록 허용하는 필터
- CSRF 토큰을 사용하여 방지
- 서버쪽에서 토큰을 만들어 요청에 같이 보내도록 한다.
- 서버에서 제공하는 Form 에만 CSRF를 포함하도록 한다. (
_csrf
이름으로 hidden 으로 처리)
사용예제
@RequiredArgsConstructor
@RequestMapping("/signup")
@Controller
public class SignUpController {
private final AccountService accountService;
@GetMapping
public String signUpForm(Model model) {
model.addAttribute("account", new Account());
return "signup";
}
@PostMapping
public String processSignUp(@ModelAttribute Account account) {
account.setRole("USER");
accountService.create(account);
return "redirect:/";
}
}
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>SignUp</title>
</head>
<body>
<h1>Sign Up</h1>
<form action="/signup" th:action="@{/signup}" th:object="${account}" method="post">
<p>Username : <input type="text" th:field="*{username}" /></p>
<p>Password : <input type="text" th:field="*{password}" /></p>
<p><input type="submit" value="SignUp" /></p>
</form>
</body>
</html>
- 뷰페이지를 개발자모드로 확인 시
- html에 파일에 넣어주지 않은
_csrf
가 들어가 있다.
- thymeleaf 2.1 버전 이후 버전 or JSP 사용 시, form 태그를 사용 시, 자동으로 넣어준다.
-
- Post요청의 경우에 토큰값을 체크하도록 되어있다.
- GET, HEAD, TRACE, OPTIONS 인경우를 제외하고 확인하도록 되어있다. (CsrfFilter)
-
- Test
@ExtendWith(SpringExtension.class)
@AutoConfigureMockMvc
@SpringBootTest
class SignUpControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void signUpForm() throws Exception {
mockMvc.perform(get("/signup"))
.andDo(print())
.andExpect(status().isOk())
.andExpect(content().string(containsString("_csrf")));
}
@Test
void processSignup() throws Exception {
mockMvc.perform(post("/signup")
.param("username", "yrchoi")
.param("password", "123")
.with(csrf())
)
.andDo(print())
.andExpect(status().is3xxRedirection());
}
}
로그아웃 처리 필터 : LogoutFilter
- LogoutHandler를 사용하여 로그아웃 시, 필요한 처리를 하며 이후에는 LogoutSuccessHandler를 사용하여 로그아웃 후처리를 한다.
- LougoutHandler는 Composite객체로 여러가지 로그아웃 핸들러를 가지고 있다.
- 기본적으로 CsrfLogoutHandler, SecurityContextLogoutHandler
- LogoutSuccessHandler 의 기본은 SimpleUrlLogoutSuccessHandler로 명시된 url로 redirect 시켜준다.
- Logout 페이지는 LogoutFilter가 만들어주는 것이 아니라, DefaultLogoutPageGeneratingFilter가 만들어주는 것이다.
- logout으로 가는 post 요청만 걸리게 될 것이다.
설정
@Override
protected void configure(HttpSecurity http) throws Exception {
http.logout()
.logoutUrl("/my/logout")
.logoutSuccessUrl("/")
// .invalidateHttpSession(true)
// .deleteCookies()
// .addLogoutHandler()
// .logoutSuccessHandler()
;
}
- logoutUrl : logout을 요청할 url을 커스텀할 때 설정 (기본값은
logout
)
- logoutSuccessHandler : 로그아웃 후, 이동할 url
- addLogoutHandler : 로그아웃 핸들러 추가, 로그아웃 할때 필요한 처리를 추가하고 싶을 때, 설정
- logoutSuccessHandler : 로그아웃 성공 처리를 별도로 구현해서 하고 싶을때 설정
- invliadateHttpSession : HttpSession을 invalid 처리할것인가 (기본값 true)
- deleteCookies : 로그아웃 시, 삭제할 쿠키이름 추가한다.
폼 인증 처리 필터 : UsernamePasswordAuthenticationFilter
- 폼 로그인을 처리하는 필터
- Username과 password를 가져와서 AuthenticationToken을 만들고 AuthenticationManager를 사용해서 인증을 시도한다.
- AuthenticationManager의 구현체인 ProviderManager가 AuthenticationProvider들을 사용해서 인증을 처리한다.
- 그 중 DaoAuthenticationProvider가 UserDetailsService를 사용하여 loadByUsername을 사용해서 DB에 있는 유저객체와 입력한 pasword가 일치하는지 확인한다.
- 실패할 경우 다시 로그인 페이지를 보여주는데, 이부분은 UsernamePasswordAuthenticationFilter가 하는것은 아니다.
DefaultLoginPageGeneratingFilter
- 기본 로그인 폼 페이지를 생성해주는 필터
- 로그인 폼 커스터마이징
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.usernameParameter("my-username")
.passwordParameter("my-parameter");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.loginPage("/my-login");
}
- 커스텀하게 설정하는 경우, DefaultLoginPageGeneratingFilter, DefaultLogoutPageGeneratingFilter가 등록되지 않는다.
로그인/로그아웃 폼 커스터마이징
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.usernameParameter("my-username")
.passwordParameter("my-password")
.loginProcessingUrl("/my-login")
.loginPage("/signin").permitAll();
}
- loginPage는 로그인페이지로 이동시켜 줄때 해당 페이지를 찾는다.
- loginProcessingUrl을 설정해주지 않으면 로그인 POST 요청 또한 loginPage에서 설정한 url로 요청해야한다.
@Controller
public class LoginController {
@GetMapping("/signin")
public String loginForm() {
return "sign-in";
}
}
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>SignIn</title>
</head>
<body>
<h1>Sign In</h1>
<div th:if="${param.error}">
Invalid username or password
</div>
<form action="/my-login" method="post" th:action="@{/my-login}">
<p>Username : <input type="text" name="my-username" /></p>
<p>Username : <input type="password" name="my-password" /></p>
<p><input type="submit" value="login"></p>
</form>
</body>
</html>
- 에러가 발생한 경우 ,
th:if="${param.error}"
에 의해 에러메세지를 보여줄 수 있다.
- 커스텀 시, 로그아웃 페이지도 함께 등록이 안되므로 로그아웃 페이지도 필요하다면 만들어 줘야한다.
- 로그아웃 페이지 라우팅 컨트롤러
@GetMapping("logout")
public String logoutPage() {
return "logout";
}
- 뷰 페이지
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Logout</title>
</head>
<body>
<h1>Logout</h1>
<form action="/my/logout" method="post" th:action="@{/my/logout}" >
<p><input type="submit" value="logout"></p>
</form>
</body>
</html>
Basic 인증 처리 필터 : BasicAuthenticationFilter
- Http Basic 인증 지원
- Basic 인증이란?
- Http 스펙중 하나
- 요청 헤더에 Header에 username, password를 Base64 로 인코딩하여 실어 보내면 브라우저 또는 서버가 그 값을 읽어서 인증
- ex ) Authorization: Basic QWxhZGRpbjpPcGVuU2VzYW1l (keesun:123 을 BASE 64)
- 보안이 취약하여, 반드시 https를 사용할 것을 권장한다.
- curl -u {username:password} {url} 로 요청 가능
- ex)
curl -u keesun:123 http://localhost:8080/dashboard
- 이 필터도 인증이되면 SecurityContextHodler 에 인증된 Authentication를 저장시켜준다.
- AuthenticationManager를 사용
- UsernamePasswordAuthenticationFilter를 사용한다.
- 읽어오는 방법이 다를뿐 (Header에서 읽어온다.)
- success 시,
successHandler.onAuthenticationSuccess()
에서는 아무것도 하지 않는다.
- UsernamePasswordAuthenticationFilter는 SessionRepository에 캐싱해둔다.
요청 캐시 필터 : RequestCacheAwareFilter
- 현재 요청과 관련 있는 캐시된 요청이 있는지 찾아서 적용
- 캐시된 요청이 있다면, 해당 캐시된 요청 처리
- 캐시된 요청이 없다면, 현재 요청 처리
- 예를들어 권한 없는 페이지에 요청 시, 로그인 페이지로 redirect 되고 이후, 로그인 과정을 거치면 원래 요청했던 페이지로 요청을 해준다.
- 처음 요청한 페이지를 캐싱한다.
- 로그인
- 캐싱했던 페이지를 처리한다.
- 크게 신경쓰지 않아도 되는 필터
시큐리티 관련 서블릿 스펙 구현 필터 : SecurityContextAwareRequestFilter
- Servlet3 스펙을 지원하는 일을 한다. (특히 시큐리티 관련)
- 시큐리티 관련 서블릿 API를 구현해주는 필터
- HttpServletReuqest#authenticate(HttpServletResponse)
- 응답을 보고 인증이 되지 않으면 로그인 페이지로 보내준다.
- HttpServletReuqest#login
- HttpServletReuqest#logout
- AsyncContext#start
- SecurityContextHolder를 공유해주는 것.
- WebAsyncManagerIntegrationFilter 와 비슷한 일을 한다.
- 크게 신경쓰지 않아도 되는 필터
익명 인증 필터 : AnonymousAuthenticationFilter
- 현재 SecurityContext 에 Authenticaion이 null 이면 AnonymousAuthentication을 만들어 넣어주고, null이 아니면 아무일도 하지 않는다.
- 기본으로 만들어 사용할 AnonymousAuthentication 객체를 설정할 수 있다. (아래는 기본값들)
http.anonymous()
.principal("anonymousUser")
.authorities("ROLE_ANONYMOUS")
.key(?);
- 굳이 만들어 쓸 이유는 모르겠다.
- null check가 아닌 null을 대변하는 객체를 만들어 넣어 사용하는 패턴
세션 관리 필터 : SessionManagementFitler
- 세션 변조 방지 전략 설정
- 유효하지 않은 세션을 리다이렉트 시킬 URL 설정
http.sessionManagement()
.sessionFixation()
.none()
.invalidSessionUrl("/");
http.sessionManagement()
.sessionFixation()
.none()
.invalidSessionUrl("/")
.maximumSessions(1)
.maxSessionsPreventsLogin(false)
.expiredUrl("/");
- maxSessionsPreventsLogin(기본값 false) : 로그인은 허용하지만 기존 로그인세션을 만료시킨다.
- true로 변경 시, 뒤에 사용자 로그인 안됨.
- expiredUrl : 만료 시, 이동 url
- 세션 생성 전략 : sessionCreationPolicy
http.sessionManagement()
.sessionFixation()
.none()
.invalidSessionUrl("/")
.maximumSessions(1)
.maxSessionsPreventsLogin(false)
.expiredUrl("/").and()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
- IF_REQUIRED(기본값) : 필요하면 만든다.
- NEVER : 스프링 시큐리티에서는 만들지 않겠다. 그러나 있다면 사용한다. (대부분 이미 있다.)
- STATELESS : session이 있어도 session을 사용하지 않는다. (REST API 서버)
- RequestCacheAware도 세션에 저장해서 사용 불가하게 된다.
- ALWAYS
- Multi Server 환경이라 세션을 여러서버에서 공유해야 한다면 Spring Session 을 고려해보자.
인증/인가 예외 처리 필터 : ExceptionTranslationFilter
- ExceptionTreanslatorFilter -> FilterSecurityInterceptor
- FilterSecurityInterceptor 보다 앞에 있어야 한다.
- FilterSecurityInterceptor를 try/catch 블럭으로 감싸고 실행되야한다.
- FilterSecurityInterceptor가 AccessDecisionManager, AffirmativeBased 구현체를 사용하여 인가처리를 판단
- 인증에러 (AuthenticationException) -> AuthenticationEntryPoint
- 인가(권한)에러 (AccessDeniedException) -> AccessDeniedHandler
http.exceptionHandling()
// .accessDeniedPage("/access-denied") 페이지만 리다이렉트
// 로그를 남기고 페이지로 리다이렉트
.accessDeniedHandler((request, response, accessDeniedException) -> {
UserDetails principal = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
String username = principal.getUsername();
System.out.println(username + " is denied to access " + request.getRequestURI());
response.sendRedirect("/access-denied");
})
;
FilterSecurityInterceptor
- hasAuthority의 하위로 hasRole
- hasAuthority를 사용하면
ROLE_
을 붙여서 사용해야 한다.
fullyAuthenticated()
rememberMe 대상의 경우, 다시 인증을 요구한다.
RememberMeAuthenticationFilter
- 세션이 사라지거나 만료가 되더라도 쿠키 또는 DB를 사용하여 저장된 토큰 기반으로 인증을 지원하는 필터
- 명시적으로 로그아웃을 하기 전까지 탭을 닫거나 해도 로그인 유지
- 세션보다 수명이 긴 쿠키나, 토큰값을 브라우저에 쿠키로 남거나 서버에 DB로 저장되어 인증을 유지 시켜준다.
- 시큐리티 설정
http.rememberMe()
.rememberMeParameter("rememer")
.tokenValiditySeconds(100000)
.useSecureCookie(true)
.userDetails(userDetails)
.key("rembmer-me-sample");
- 쿠키에 rememberMe를 저장한다.
- rememberMeParameter : parameter 이름 설정가능 (true/false)
- tokenValiditySeconds : 쿠키 유지 시간 기본값은 2weeks
- useSecureCookie : secure 쿠키를 사용하여 https만 쿠키에 접근이 허용가능하도록 할 수 있다.
- alwaysRemember : 모두 기억하겠다. form에서 값을 넘기지 않아도 (기본값 false)
- filter의 위치는 SecurityContextHolderAwareRequestFilter 뒤 AnonymousAuthenticationFIlter 앞에 위치한다.
- SecurityContextHolder에서 인증된 사용자가 있는지 확인
- 인증된 사용자가 없으면 rememeberMe를 체크한다.
- rememberMeAuth 설정한 쿠키로 인해 만들어진 토큰으로 인증을 한다.
- 인증된 Authentication을 SecurityContextHolder에 넣어준다.
- UsernamePasswordAuthenticationToken이 아닌 RemeberMeAuthenticationFilter
커스텀 필터 추가하기
@Override
protected void configure(HttpSecurity http) throws Exception {
http.addFilterBefore(new LoggingFilter(), WebAsyncManagerIntegrationFilter.class);
}
public class LoggingFilter extends GenericFilterBean {
private Logger logger = LoggerFactory.getLogger(this.getClass());
@Override
public void doFilter(ServletRequest request, ServletResponse servletResponse, FilterChain filterChain)
throws IOException, ServletException {
StopWatch stopWatch = new StopWatch();
stopWatch.start(((HttpServletRequest) request).getRequestURI());
filterChain.doFilter(request, servletResponse);
stopWatch.stop();
logger.info(stopWatch.prettyPrint());
}
}
- 일반적인 서블릿 필터를 사용한다.
- 일반적인 Filter를 구현하거나, 조금 더 손쉽게 만들려면 GenericFilterBean을 상속받아 만들 수 있다.
doFilter()
하나만 구현해도 된다.