스프링 시큐리티란?
스프링 시큐리티는 인증, 인가 및 보호 기능을 제공하는 프레임워크다.
보안이란 여간 까다로운 작업이 아니다. 하지만, 스프링 시큐리티를 이용하면, 시큐리티가 제공하는 기능들을 통해 보다 쉽게 보안작업을 진행할 수 있다.
인증 vs 인가
- 인증 (Authentication) : 서비스의 사용자임을 확인받는것. (쉽게말해 로그인)
- 인가 (Authorization) : 인증된 사용자가 서비스에 접근하고자할때 요청된 자원에 접근가능한지를 허가해주는것.
결국, 스프링 시큐리티는 먼저 유저정보를 인증하고, 인증된 유저의 정보의 권한을 추출하여, 요청한 자원에 해당 권한이 접근가능한지를 판단하여 보안을 수행하는 프레임워크라고 할 수 있다.
스프링 시큐리티 필터
스프링 시큐리티는 필터(Filter) 방식으로 동작합니다.
스프링 시큐리티를 학습함에 있어, 가장 먼저 알아야하는 개념이다. 스프링 시큐리티는 필터방식으로 동작한다.
다시말해서, 인증 & 인가에 대한 처리를 여러개의 필터를 순차적으로 통과하면서 수행한다는 뜻이다.
스프링 시큐리티가 어렵다고 하는 가장 큰 이유는 `스프링 시큐리티의 정확한 흐름을 몰라서` 라고 생각한다. 아래의 필터들은 스프링시큐리티 내부적으로 동작하는 필터들이다. 이 필터들은 당연히 필요에 의해 커스터마이징 할 수 있으며, 이때 시큐리티의 동작 흐름을 모르면 난해하므로 시큐리티의 흐름도를 잘 이해해야한다.
시큐리티는 인증, 인가에 대한 처리를 여러개의 필터를 연쇄적으로 실행하여 수행한다. 이 때, 설정에 따라서 필요한 필터가 있고 필요 없는 필터가 있을 수 있는데 이 시큐리티에 관한 설정은 WebSecurityConfigurerAdapter를 구현한 config설정 파일로 생성한다. HttpSecurity가 실제 필터를 생성하고 해당 필터들은 WebSecurity클래스를 통해서 FilterChainProxy의 인자들로 전달된다.
@Configuration
@EnableWebSecurity // 스프링 시큐리티 설정을 해줌 (debug = true)
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.addFilterBefore(new Filter3(), BasicAuthenticationFilter.class)
.authorizeRequests()
.antMatchers("/user/**").authenticated() // 인증만 되면 들어갈수있음
// admin이나 manager권한이 있어야만 들어올수있고
.antMatchers("/manager/**").access("hasRole('ROLE_ADMIN') or hasRole('ROLE_MANAGER')")
.antMatchers("/admin/**").access("hasRole('ROLE_ADMIN')")
.anyRequest().permitAll() // 위 3개 경로가 아닌것은 권한이 다있음
.and()
// 위 3개 경로는 인증이 필요하다면 무조건 로그인페이지(/loginForm)를 먼저띄움
.formLogin().loginPage("/loginForm")
.loginProcessingUrl("/login") // /login 주소가 호출되면 시큐리티가 낚아채서 대신 로그인 진행
.defaultSuccessUrl("/") // loginPage(/loginForm)을 이용해서 로그인이 되면 /로 이동하도록함
.and()
.logout()
.logoutSuccessUrl("/loginForm")
.and();
}
}
사용자가 요청을하면, DelegatingFilterProxy가 가장 먼저 그 요청을 받고, FilterChainProxy에게 요청을 위임한다.
위임받은 요청에 해당하는 SecurityFilterChain이 필터를 수행 -> 넘김 -> 수행 -> 넘김 순으로 진행하여 최종적으로 서블릿에 도달하여 리소스를 사용자에게 보여준다.
커스터마이징시 `chain.doFilter(request, response);` 를 사용하지않으면 다음 필터로 넘어가지 않으니 주의
모든 필터마다 역할이 있지만, 중요한 필터는 아래와 같다.
SecurityContextPersistenceFilter
SecurityContextRepository에서 SecurityContext를 가져오거나 생성 (세션공간)
LogoutFilter
로그아웃 요청 처리(로그아웃시에만)
UsernamePasswordAuthenticationFilter
ID와 Password를 사용하는 실제 Form 기반 유저 인증을 처리
ConcurrentSessionFilter
동시 세션과 관련된 필터
RememberMeAuthenticationFilter
세션이 사라지거나 만료 되더라도, 쿠키 또는 DB를 사용하여 저장된 토큰 기반으로 인증을 처리
AnonymousAuthenticationFilter
사용자 정보가 인증되지 않았다면 익명 사용자 토큰을 반환
SessionManagementFilter
로그인 후 Session과 관련된 작업을 처리
ExceptionTranslationFilter
필터 체인 내에서 발생되는 인증, 인가 예외를 처리
FilterSecurityInterceptor
권한 부여와 관련한 결정을 AccessDecisionManager에게 위임해 권한부여 결정 및 접근 제어 처리
시큐리티 아키텍처
위의 그림으로는 시큐리티의 상세 동작을 이해하기 어려우므로 아키텍쳐를 확인한다.
시큐리티가 자신만의 세션공간(SecurityContext)을 가지고있는데, 해당 세션에 들어갈수있는 객체는 Authentication객체 뿐이며, Authentication객체는 UserDetails, OAuth2User객체를 가질 수 있다.
세션에 Authentication객체가 들어가는것이 '로그인되었다'는 의미이므로 아래의 과정을 통하여 SecurityContext에 Authentication객체가 들어갈 수 있도록 Authentication객체를 만드는 것이 스프링 시큐리티의 핵심이다.
사용자로부터 요청을 받았을때, 스프링시큐리티는 위와 같은 흐름을 보여준다.
1. 사용자가 로그인(id, pw)을 통하여 인증 요청
2. AuthenticationFilter가 요청을 가로채고 넘어온 id,pw를 가지고 UsernamePasswordAuthenticationToken을 생성
3. AuthenticationManager에게 token객체를 전달
4. AuthenticationManager는 for문을 돌면서 로그인 방식을 지원하는 provider들을 조회하여 인증을 요구
(db로 id,pw를 조회한다면 DaoAuthenticationProvider가 실행됨)
5. provider는 UserDetailsService를 구현한 클래스를 찾아서 loadUserByUsername(String username)을 실행
6. 유저 저장소 (DB / LDAP / IN-Memory)에 접근하여 값이 있다면 UserDetails을 객체를 가져옴
7. 리턴된 객체의 getPassword값과 로그인때 사용된 pw값이 일치하는지 확인
8. 값이 일치하다면 인증완료로 판단하여 리턴된 UserDetails 객체 + 권한을 넣고 Authentication객체를 생성하여 리턴
9. Authentication객체를 AuthenticationFilter에게 전달
10. Authentication객체를 SecurityContext에 저장
UsernamePasswordAuthenticationFilter.java
@RequiredArgsConstructor
public class TestAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
// /login 요청을 하면 로그인 시도를 위해서 실행되는 함수
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
System.out.println("로그인 시도중");
// 토큰 생성
UsernamePasswordAuthenticationToken token =
new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());
// PrincipalDetailsService가 호출되어 loadUserByUsername메서드가 실행됨
Authentication authentication = authenticationManager.authenticate(token);
PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
System.out.println(principalDetails.getUser().getUsername() + "로그인완료");
// 정상일시 PrincipalDetails가 리턴
return authentication;
}
userDetailsService.java
@Service
public class PrincipalDetailsService implements UserDetailsService {
@Autowired
UserRepository userRepository;
/*
* 시큐리티 session(내부Authecntication(내부UserDetails))
* 오버라이딩 하지않아도 기본적으로 작동
* 해당 함수 종료시 @AuthenticationPrincipal 어노테이션이 만들어진다.
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username);
if (user != null) {
// 리턴 객체는 authentication안으로 들어간다.
return new PrincipalDetails(user);
}
throw new UsernameNotFoundException("User '" + username + "' not found");
}
UserDetails.java
package security.config.auth;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Map;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import lombok.Data;
import security.model.User;
/*
* Security Session 여기에 들어갈 객체는 정해져있음
* -> Authentication 이 객체고 이객체도 UserDetails로 타입이 정해져있음
*/
@Data
public class PrincipalDetails implements UserDetails, OAuth2User {
private User user;
private Map<String, Object> attributes;
// 일반 로그인
public PrincipalDetails(User user) {
this.user = user;
}
// oauth 로그인
public PrincipalDetails(User user, Map<String, Object> attributes) {
this.user = user;
this.attributes = attributes;
}
// 해당 User의 권한을 리턴하는 곳
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collect = new ArrayList<>();
collect.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return user.getRole();
}
});
return collect;
}
@Override
public String getPassword() { // 이값을 들어온 비밀번호와 일치하는지 비교 bCryptPasswordEncoder가 디폴트
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;
}
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
@Override
public String getName() {
return null;
}
}
로그아웃
1. LogoutFilter가 낚아채서 요청이 logout Url인지 확인
2. 맞다면 SecurityContext에서 Authentication 객체 꺼내옴
3. SecurityContextLogoutHandler 에서 세션무효화, 쿠키삭제 등을 통해 SecurityContext객체를 삭제
4. SimpleUrlLogoutSuccessHandler를 통해 로그인페이리로 리다이렉트
(커스터마이징 하지않았다면 `/login?logout` url로 리다이렉트됨)
예외 처리
스프링 시큐리티가 관리하는 보안 필터중 ExceptionTranslationFilter가 try-catch로 감싼 후 다음 필터를 호출하므로 FilterSecurityInterceptor에서 발생하는 에러를 모두 throw한다.
인증 예외처리 : AuthenticationException
예외 발생 이전에 유저가 가고자 했던 요청정보를 DefaultSavedRequest객체에 저장.
1. 인증 실패 - 로그인 페이지로 리다이렉트 시킨다.
2. 인증 성공 - DefaultSavedRequest객체에 가고자 했던 url로 이동
인가 예외처리 : AccessDeniedException
인가 관련 예외처리
1. 익명사용자가 `/user`에 접근요청
2. 해당 url이 익명사용자가 접근할 수 있는지 확인 후, 불가능하다면 인가예외
3. AccessDeniedException 내부에서 (익명사용자 or RememberMe사용자) 인경우 AuthenticationException에서 처리하도록 설정
4. AuthenticationException가 인증 시도 후 처리
스프링 시큐리티 사용
pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
스프링 시큐리티 디펜던시만 추가해주면 서버 기동 시 시큐리티 보안설정이 적용된다.
- 별도의 설정없이 기본적인 웹 보안 기능이 현재 시스템에 연동
- 모든 요청은 인증이 되어야 자원에 접근가능.
- 인증 방식은 폼 로그인방식 & httpBasic로그인 방식 제공
- 기본 로그인 페이지 제공
- 기본계정은 user 1개로 제한
username : user
password : 콘솔에 찍힌 랜덤 문자열
이대로 해당 유저로 접근할 수 있지만, 다수의 계정생성 & 권한 추가 등 여러 제약사항이 있다. 또한, 기본 로그인폼을 사용할 서비스는 없기때문에 사용자 정의를 따로 해야한다.
config 설정
스프링 시큐리티는 설정파일을 생성함으로써 웹 보안기능 초기화 및 설정이 가능하다.
(자동으로 SpringSecurityFilterChain에 등록됨)
기존에는 'WebSecurityConfigurerAdapter'을 상속받아서 정의하였지만 스프링 시큐리티 5.7.0-M2 부터 WebSecurityConfigurerAdapter클래스 deprecated되었고 6버전부터는 아예 사용이 불가능하다.
// 예전방식 사용x
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authz) -> authz
.anyRequest().authenticated()
)
.httpBasic(withDefaults());
}
}
// 권장방식
@Configuration
public class SecurityConfiguration {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authz) -> authz
.anyRequest().authenticated()
)
.httpBasic(withDefaults());
return http.build();
}
}
바뀐 새로운 방식에서는 configure 메서드를 오버라이딩하는 방식이 아닌 설정들을 하나의 Bean으로 등록하고 SecurityFilterChain를 리턴한다.
package security.config;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import security.config.auth.oauth.PrincipalOauth2UserService;
import security.filter.Filter3;
@Configuration
@EnableWebSecurity // 스프링 시큐리티 설정을 해줌 (debug = true)
// @Secured 활성화 - 특정메서드에 간단하게 권한을 부여
// @preAuthorize, postAuthorize 활성화
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// 글로벌하게 해당 경로에해당하는 설정을 걸어준다.
// 어떤 특정한것만 권한을 걸어주고싶을때 @Secured를 사용
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.addFilterBefore(new Filter3(), BasicAuthenticationFilter.class)
.authorizeRequests()
.antMatchers("/user/**").authenticated() // 인증만 되면 들어갈수있음
// admin이나 manager권한이 있어야만 들어올수있고
.antMatchers("/manager/**").access("hasRole('ROLE_ADMIN') or hasRole('ROLE_MANAGER')")
// admin권한이 있어야 들어올수있다.
.antMatchers("/admin/tt").access("hasRole('ROLE_ADMIN')")
.antMatchers("/admin/**").access("hasRole('ROLE_ADMIN') or hasRole('ROLE_MANAGER')")
.anyRequest().permitAll() // 위 3개 경로가 아닌것은 권한이 다있음
.and()
// 위 3개 경로는 인증이 필요하다면 무조건 로그인페이지(/loginForm)를 먼저띄움
.formLogin().loginPage("/loginForm")
// .usernameParameter("username2") // form에서 넘어온 username2값을 username으로 받아들임
// /login 주소가 호출되면 시큐리티가 낚아채서 대신 로그인 진행 *
.loginProcessingUrl("/login")
// loginPage(/loginForm, true)을 이용해서 로그인이 되면 /로 이동하도록함 (true면 전에 어떤페이지에있었는지무관하게 무조건 /로 이동)
.defaultSuccessUrl("/", true)
.and()
.logout()
.invalidateHttpSession(true) // 로그아웃 세션정보 제거 여부
.logoutSuccessUrl("/loginForm");
}
}
authorizeRequests()를 사용하여 URL경로와 패턴 및 해당 경로의 보안 요구사항을 구상할 수 있다.
이런규칙을 지정할때는 순서가 중요한데, antMatchers()에서 지정된 경로의 패턴일치를 검사하므로 먼저 지정된 보안 규칙이 우선적용된다. 위 예시에서 `/admin/tt`와 `/admin/**` 순서를 바꾸게되면 `/admin/**` 로 먼저 규칙이 적용되기때문에 `/admin/tt`의 효력이 없어진다.
스프링부트는 요청처리의 기본적인 보안 규칙을 제공한다. 그러나 각 메서드에 정의된 보안 규칙만 사용된다는 제약이 있다. 따라서 이의 대안으로 access()메서드를 사용해서 무궁무진한 보안 규칙을 선언하기위해 SpEL(spring expression language)를 사용할 수 있다.
예를들어, `/delivery`와 `/orders`요청은 화요일만 ROLE_USER권한을 가진 사용자에게만 허락하는 예시이다.
http
.authorizeRequest()
.antMatchers("/delivery", "/orders")
.access("hasRole('ROLE_USER') && " +
"T(java.util.Calender).getInstance().get(" +
"T(java.util.Calender).DAY_OF_WEEK) == T(java.util.Calender).MONDAY")
.antMatchers("/", "/**").access("permitAll");
스프링 시큐리티의 기본 로그인화면대신 원하는 로그인페이지를 입력하며, 어떤 url로 로그인을 실행했을때 스프링 시큐리티가 가로챌수있도록 설정한다(디폴트 /login). 또한 로그인 성공시 어떤 url로 이동할지 설정. 로그아웃도 마찬가지
http
.formLogin()
.loginPage("/loginForm") // /login 주소가 호출되면 시큐리티가 낚아채서 대신 로그인 진행
.loginProcessingUrl("/login")
.usernameParameter("id")
.passwordParameter("pw")
// 로그인을 성공하면면 /로 이동하도록함 (true면 전에 어떤페이지에있었는지무관하게 무조건 /로 이동)
.defaultSuccessUrl("/", true)
.successHandler(new MyLoginSuccessHandler())
.and()
.logout()
.logoutUrl("/logout")
.logoutSuccessUrl("/loginForm");
필터 커스터마이징
스프링 시큐리티는 필터를 커스터마이징하지 않아도 내부적으로 알아서 동작한다. 하지만 필요에 의해 커스터마이징 시,
config파일에 지정해주어야한다.
1. addFilter
해당 기능은 SpringSecurity FilterComparator에 등록되어있는 필터들을 활성화할때 사용한다.
새로운 커스터마이징필터를 추가하면 comparator에 등록이 되어있지 않아 에러가 발생.
-> 커스텀필터는 addFilterBefore & addFilterAfter로 등록
http.addFilter(new LoggingFilter());
2. addFilterBefore
특정 필터 이전에 동작하도록 order 설정.
LogginFilter()는 WebAsyncManagerIntegrationFilter보다 먼저 실행됨
http.addFilterBefore(new LoggingFilter(), WebAsyncManagerIntegrationFilter.class);
3. addFilterAfter
특정 필터 이후에 동작하도록 order 설정.
LogginFilter()는 WebAsyncManagerIntegrationFilter실행 후에 실행됨
http.addFilterAfter(new LoggingFilter(), WebAsyncManagerIntegrationFilter.class);
4. addFilterAt
특정 필터와 같은 order순서로 등록.
무엇이 먼저 실행될지 보장 x
http.addFilterAt(new LoggingFilter(), WebAsyncManagerIntegrationFilter.class);
SecurityFilterChain에 내가 원하는 필터를 껴넣을 수 있고, 애초에 SecurityFilterChain를 쓰지않고 따로 구현도 가능.
다만, SecurityFilterChain보다 뒤에 실행됨에 주의
@Configuration
public class FilterConfig {
@Bean
public FilterRegistrationBean<Filter1> filter1() {
FilterRegistrationBean<Filter1> bean = new FilterRegistrationBean<>(new Filter1());
bean.addUrlPatterns("/*"); // 모든 요청
bean.setOrder(0); // 낮은 번호가 필터중에서 가장먼저 실행됨.
return bean;
}
@Bean
public FilterRegistrationBean<Filter2> filter2() {
FilterRegistrationBean<Filter2> bean = new FilterRegistrationBean<>(new Filter2());
bean.addUrlPatterns("/*"); // 모든 요청
bean.setOrder(1); // 낮은 번호가 필터중에서 가장먼저 실행됨.
return bean;
}
}
public class Filter1 implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
System.out.println("필터1");
// 계속 체인으로 옮겨다닐수있도록 설정
chain.doFilter(request, response);
}
}
세션 관리
스프링 시큐리티는 세션 관리를 제공한다. 인증된 유저를 위해 세션자체를 어떤 방식으로 생성할지
1개의 유저 아이디로 다수의 사람들이 로그인할때 다중접속을 어떻게 허용할 것인지에 관한 정책을 설정할 수 있다.
동시 세션 제어
접속중인 세션으로 다른 사람이 접속했을때 취할 정책을 정의한다. 동시 세션을 제어하는 방법은 2가지이며, 설정파일을 통해 설정 가능하다.
1. 새로운 사용자가 로그인되었을때 기존사용자의 세션만료
2. 로그인이 되어있다면 새로운 사용자의 인증 실패
http
.sessionManagement() //세션 관리 기능이 작동함
.maximumSessions(1)//최대 허용 가능 세션 수, (-1: 무제한)
.maxSessionsPreventsLogin(true)//동시 로그인 차단함, false: 기존 세션 만료(default)
.expiredUrl("/expired");//세션이 만료된 경우 이동할 페이지
세션 고정 보호
고정된 세션 아이디를 가진 세션을 사용하다보면, 아래와 같이 공격자가 미리 접속하여 받아놓은 세션id를 사용자에게 넘겨줄시, 사용자와 공격자는 동일한 세션을 사용하게 되어 공격자가 사용자의 정보를 빼낼수있는 문제가 발생한다.
이런 경우를 방지하기 위해 시큐리티가 설정은 지원한다.
http.sessionManagement().sessionFixation()
.changeSessionId() // 세션은 계속 유지하되, 세션아이디는 새로 발급 (서블릿 3.1 이후 디폴트)
// .none() // 세션이 새로 생성되지 않고 유지됨. 아무런 보호x
// .migrateSession() // 세션 새롭게 생성 + 세션아이디도 발급 + 이전의 속성값 유지 (서블릿 3.1 이전 디폴트)
// .newSession() // 세션 새롭게 생성 + 세션아이디도 발급 + 이전의 속성값 유지불가
따로 설정하지않더라도 스프링시큐리티가 기본적으로 적용
세션 생성 정책
스프링 시큐리티를 통해 인증을 성공하면 시큐리티 내부의 세션공간에 인증객체를 집어넣는다. 하지만 JWT같이 세션이 굳이 필요하지않은 경우나 OAuth처럼 외부 서비스를 통해 인증 토큰을 발급 받는 방식도 존재한다. 이런 경우에 시큐리티에서 세션을 생성할 필요는 없다.
http
.sessionManagement()// 세션 관리 기능이 작동함.
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED); // 스프링 시큐리티가 필요 시 생성 (디폴트)
//.sessionCreationPolicy(SessionCreationPolicy.Always); // 스프링 시큐리티가 항상 세션 생성
//.sessionCreationPolicy(SessionCreationPolicy.Never); // 스프링 시큐리티가 세션을 생성하지는 않지만 존재하면 사용
//.sessionCreationPolicy(SessionCreationPolicy.Stateless); // 스프링 시큐리티가 생성하지도않고 존재해도 사용하지않음.
세션정보를 추출 방법
스프링시큐리티에서느 로그인된 사용자는 Authentication객체 형태로 SecurityContext에 저장된다. 시큐리티에서 인증 객체의 정보를 확인하는 방법은 여러가지가 존재한다.
public @ResponseBody String testLogin(Authentication authentication,
@AuthenticationPrincipal PrincipalDetails userDetails) {
System.out.println("==================");
PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
System.out.println("authentication : " + principalDetails.getUser());
System.out.println("userDetails : " + userDetails.getUser());
Authentication authentication2 = SecurityContextHolder.getContext().getAuthentication();
System.out.println("context : " + authentication2.getPrincipal());
return "세션 정보 확인하기";
}
- 로그인 후, authentication객체를 통해 세션정보를 확인할 수 있다. (UserDetails객체로 다운캐스팅)
- @AuthenticationPrincipal를 통해서 세션정보에 접근할 수 있다.
- SecurityContextHolder.getContext().getAuthentication() : 보안컨텍스트로부터 얻어서 사용. 어디서든 접근가능
CSRF 방어 ( Cross-Site Request Forgery - 사이트 간 요청 위조)
사용자가 인증후, 공격자 사이트 링크를 클릭하면 사용자 요청과 무관하게 공격자가 서비스를 요청하게 된다. 이때 서버는 사용자로 인식하여 정상적으로 동작하는 문제 발생.
CSRF 공격을 막기 위해 애플리케이션에서는 폼의 hidden 필드에 넣을 CSRF을 생성한다. 서버에서 토큰을 발급 및 검증하고 클라이언트에서는 발급받은 토큰을 요청 값에 포함시켜 보내는 방식으로 CSRF 공격을 막을 수 있다.
스프링시큐리티는 로그인폼에 _csrf라는 이름의 요청속성을 넣는것으로 간단히 지원한다.
Thymeleaf나 jsp는 필드가 자동 생성됨 / mustache는 따로 작업필요
<form th:action="@{/login}" method="POST">
<input type="text" name="username" placeholder="Username"/><br/>
<input type="password" name="password" placeholder="Password"/><br/>
<input type="hidden" name="_csrf" value="${_csrf.token}"/><br/>
<button>로그인</button>
</form>
따로 설정하지않다면 스프링 시큐리티가 기본적으로 적용해놓고있기때문에 _csrf토큰 필드를 만들지않으면 로그인 되지않으므로 주의.
rest api방식은 session 기반 인증과는 다르게 stateless하기 때문에 서버에 인증정보를 보관하지 않는다. rest api에서 client는 권한이 필요한 요청을 하기 위해서는 요청에 필요한 인증 정보를(JWT토큰 등)을 포함시켜야 한다. 따라서 서버에 인증정보를 저장하지 않기 때문에 굳이 불필요한 csrf 코드들을 작성할 필요가 없으므로 해제.
http.csfr().diable()
OAuth 로그인
스프링 시큐리티는 OAuth 로그인을 지원하므로 신뢰할수있는 다른 사이트에서 인증과 인가를 진행할 수 있다.
OAuth란?
OAuth란 인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단으로서 사용되는, 접근 위임을 위한 개방형 표준이다.
OAuth의 탄생 배경
웹 서핑중 회원인증이 필요할 시, third party에 아이디와 비밀번호를 제공하고 싶지 않다.
또한, third party가 안전할것이라는 보장도 없기때문에 인증&인가를 다른 사이트에서 가져오는자는 것이 탄생 배경.
최초에는 트위터 주도로 OAuth1.0 을 만들었으나 HMAC를 통해 암호화를 하는 번거로움 & 인증토큰이 만료되지않는 단점 존재 → OAuth2.0 탄생
- https가 필수라 암호화를 https에 맡김
- 1.0에 비해 다양한 인증방식
- 1.0a는 만들어진 다음 표준이 된 반면 2.0은 처음부터 표준 프로세스로 만들어짐
OAuth 로그인 적용
시큐리티에서 OAuth를 사용하여 로그인 기능을 구현하려면 설정정보의 등록이 필요하다.
pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
우선, OAuth인증을 요청할 서비스에가서 (ex) google api console) oauth 로그인 기능을 서비스를 시작한다.
oauth 로그인 정보 제공 목록과 client-id / client-secret 키를 발급 받은 후, 설정파일에 명시한다.
oauth2-client라이브러리에서 oauthclient를 제공해주는 provider가 있는데 google, facebook, twitter 같은 세계적인 서비스들은 기본제공자로 내장하고 있으므로 정보를 입력만 해주면되지만, naver같이 provider를 제공하지않는 서비스는 직접 provider까지 생성해야한다.
application.yml
security:
oauth2:
client:
registration:
google:
client-id: xxxxxxx
client-secret: xxxxxxxxx
#redirect-uri: http://localhost:8088/login/oauth2/code/google 굳이 안써줘도됨
scope:
- email
- profile
facebook:
client-id: xxxxxxxxx
client-secret: xxxxxx
#redirect-uri: http://localhost:8088/login/oauth2/code/facebook
scope:
- email
- public_profile
naver:
client-id: xxx
client-secret: xxx
scope:
- name
- email
client-name: Naver
authorization-grant-type: authorization_code
redirect-uri: http://localhost:8088/login/oauth2/code/naver # provider를 기본적으로 제공해주지 않으므로 써줘야함
provider:
naver:
authorization-uri: https://nid.naver.com/oauth2.0/authorize # 네이버 로그인을 누른순간 이주소로 요청을 날림
token-uri: https://nid.naver.com/oauth2.0/token
user-info-uri: https://openapi.naver.com/v1/nid/me
user-name-attribute: response # 회원정보를 json으로 받는데, response라는 키값으로 네이버가 리턴해줌.
config.java
http.authorizeRequests()
.......
.and()
.oauth2Login().loginPage("/loginForm")
// oauth 로그인이 완료된 뒤의 후처리 (엑세스토큰+사용자프로필정보)
.userInfoEndpoint().userService(principalOauth2UserService);
일반 로그인에서 `/login` 요청이 들어오면 UserDetailsService객체를 구현한 클래스의 `loadUserByUsername`메서드가 실행된다. oauth 로그인인 경우, DefaultOAuth2UserService를 상속한 클래스의 `loadUser`가 실행된다. 해당 메서드의 리턴값은 OAuth2User다.
Authentication객체는 UserDetail(일반로그인), OAuth2User(oauth로그인) 2개의 타입을 가질 수 있다.
@Service
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {
/*
* 구글로 부터 받은 userRequest 데이터에 대한 후처리되는 함수
* 오버라이딩 하지않아도 기본적으로 작동
*/
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
return super.loadUser(userRequest);
}
}
세션정보를 추출 방법
oauth로그인도 일반로그인과 마찬가지로 세션정보 추출이 가능하다.
@GetMapping("/test/oauth/login")
public @ResponseBody String testOAuthLogin(Authentication authentication,
@AuthenticationPrincipal OAuth2User oauth) {
System.out.println("==================");
// oauth로 로그인하면 UserDetails가 아닌 OAuth2User로 캐스팅해야함
OAuth2User oauth2User = (OAuth2User) authentication.getPrincipal();
System.out.println("authentication : " + oauth2User.getAttributes());
System.out.println("oauth : " + oauth.getAttributes());
return "oauth 세션 정보 확인하기";
}
- 로그인 후, authentication객체를 통해 세션정보를 확인할 수 있다. (OAuth2User객체로 다운캐스팅)
- @AuthenticationPrincipal를 통해서 세션정보에 접근할 수 있다.
로그인 방식에 따라 세션을 호출하는 방법이 분기가 되어지면 개발 및 유지보수함에 불편함이 증가
-> userDetail, OAuth2User 객체를 모두 구현하는 인터페이스로 묶으면 해결
// 일반로그인, oauth로그인 둘다 같은타입으로 받을 수 있어짐.
@GetMapping("/user")
public @ResponseBody String user(@AuthenticationPrincipal PrincipalDetails principalDetails) {
System.out.println("principalDetails : " + principalDetails.getUser());
return "user";
}
public class PrincipalDetails implements UserDetails, OAuth2User {
private User user;
...
}
JWT 방식
스프링 시큐리티는 JWT방식으로 인증을 지원한다.
JWT가 무엇인지 알아보기전에 세션을 통한 JWT의 등장 배경에 관해 먼저 설명한다.
세션
세션이 필요한이유는 http가 stateless이기때문.
stateless는 서버로 가는 모든 요청이 이전 요청과 독립성을 띔. 요청끼리 연결이 없다. 한번 요청이 끝나면 서버는 브라우저가 누구인지 잊어버림. 그래서 요청할때마다 우리가 누구인지 알려줘야하는데 이를 하는 방법중 하나가 바로 ’세션’
세션의 동작방식
- 브라우저가 서버로 요청함
- 서버는 세션id가 없는것을 확인하고 세션id를 발급 & 세션db(메모리공유서버 ex: redis)에 저장후, 쿠키에 실어서 브라우저에게 보냄.
- 브라우저는 전달받은 세션id를 따로 저장했다가, 모든 요청시에 쿠키에 실어서 보냄.
- 서버는 세션id를 통해 사용자 식별.
- 로그인을 시도시, id와 비밀번호를 서버에보냄.
- 서버는 대조 후 검증된 사용자면 세션id를 새롭게 발급하여 쿠키에 넣어서 브라우저로 보내줌.
- 브라우저는 전달받은 세션id를 따로 저장했다가, 모든 요청시에 쿠키에 실어서 보냄.
- 서버는 세션id를 통해 사용자 식별.
세션이 사라지는 2가지 경우
- 서버쪽에서 세션공간을 건들여서 세션값을 없앤다.
- 브라우저창을 다 닫는다. (브라우저 쿠키에 저장된 세션id값이 사라진다). 세션id를 들고오지않았으므로 새롭게 로그인을 진행하고 기존 세션은 세션db에는 남아있는데, 특정시간이 지나면 사라짐
세션을 사용함에 있어서 중요한점은 현재 로그인한 모든 유저들의 세션id를 어딘가에 저장해야함. 요청이 들어올때마다 세션id를 체크해서 검증을해야때문에 모든 요청마다 세션공간에 접근해야하고 유저가 늘어남에따라 리소스가 많이 필요하게되는 단점이 존재함.
이러한 단점이 아래의 JWT의 등장배경이 된다.
JWT (JSON WEB TOKEN)
당사자간의 정보를 json객체로 안전하게 전송하기위한 개방형 표준(RFC 7519).
토큰을 서명하여 인증을 함. 세션이 필요없음.
JWT 동작방식
- 유저가 로그인을 할때, ID과 비밀번호를 서버에보냄.
- 서버는 대조 후 검증된 사용자면 알고리즘을 이용하여 사인.
- 사인된 정보를 string형태로 다시 되돌려줌
- 요청을 할때 토큰을 서버에 보냄
- 서버는 토큰을 받고 해당 사인이 유효한지 체크하고(조작되지 않았는지 등) 유효하다면 우리를 유저로 인증
eyJhbGciOiJIUzI.1NiIsInR5cCI6.IkpXVCJ9
해더 . 페이로드 . 서명
- 헤더는 토큰의 타입이나 서명생성에 어떤 알고리즘이 사용되었는지 저장
- 페이로드는 토큰에 대한 property를 key-value형태로 저장 - 해더와 페이로드는 base64로 인코딩한것일뿐 암호화되어있지않기때문에 민감한 정보는 담지않는다. (암호화의 목적이 아니라 서명의 목적이기떄문)
- 서명은 헤더 인코딩값 + 페이로드 인코딩값 + 개인키를 합치고 서버만 알고있는 비밀키로 암호화. 클라이언트가 보낸 토큰을 디코딩하여 헤더, 페이로드, 서명을 추출. 서명을 비밀키로 복호화하여 헤더, 페이로드값과 일치하는지 확인
세션 vs JWT
세션은 세션id만 던져주고, 필요한정보를 세션db에 저장하지만 jwt는 필요한 정보를 토큰에 저장하고 그걸 사용자에게 돌려줌. 페이지를 요청하면, 서버는 해당 토큰이 유효한지만 검증하면됨.
세션은 모든 정보가 서버db에 들어가있기때문에 추척이 가능하다. 대신 리소스가 많이 필요함.
jwt는 생성된 토큰을 추척하지않음. 서버가 아는 유일한것은 토큰이 유효한가 아닌가 일뿐. 서버가 리소스가 필요없는 대신 토큰이 만료되기전까지는 통제 불가능.
개선책으로 짧은 수명의 access토큰과 / refresh토큰 2개를 동시에 발급.
refresh는 저장소에 저장하여 refresh토큰이 만료되기 전까지 access토큰이 만료되면 재발급하는 방법도 존재
JWT 인증 구현
JWT를 생성할때 직접 String을 만들 수 도있으나, 귀찮은 작업이므로 lib추가
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
</dependency>
시큐리티 설정
JWT방식으로 구현하려면 세션방식과 시큐리티 설정이 다른부분 존재
@Configuration
@EnableWebSecurity // 스프링 시큐리티 활성화
@RequiredArgsConstructor
// 최신버전은 extends WebSecurityConfigurerAdapter 방식은 Deprecate 되었기때문에 변경해야함.
public class SecurityConfig {
private final CorsFilter corsFilter;
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf().disable() // http csfr 사용안함
// 세션을 사용하지않는다.
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.formLogin().disable() // 폼로그인 사용안함
.httpBasic().disable() // 기본적인 http로그인 방식 사용안함
.apply(new MyCustomDsl()) // 커스텀 필터 등록
.and()
.authorizeRequests(
authroize ->
authroize.antMatchers("/api/v1/user/**")// 이쪽 경로로 들어오면
.access("hasRole('ROLE_USER') or hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')") // 이런 권한만 들어올수있고
.antMatchers("/api/v1/manager/**") // 이쪽 경로로 들어오면
.access("hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')") // 이런 권한만 들어올수있고
.antMatchers("/api/v1/admin/**")
.access("hasRole('ROLE_ADMIN')")
.anyRequest().permitAll()) // 나머지경로는 누구나 접근가능
.build();
}
}
id & pw 확인 후, 인증된 사용자라면 세션에 저장하지않고 response헤더에 비밀키로 서명된 토큰을 전달
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
...
@Override
// 인증이 정상적으로 되었으면 해당함수 실행
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authResult) throws IOException, ServletException {
PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();
String jwtToken = JWT.create()
.withSubject(principalDetails.getUsername())
.withExpiresAt(new Date(System.currentTimeMillis() + 60000 * 5))
.withClaim("id", principalDetails.getUser().getId())
.withClaim("username", principalDetails.getUser().getUsername())
.sign(Algorithm.HMAC512("고유키")); // 서버만 알고있는 고유키로 사인
response.addHeader("Authorization", jwtToken);
}
}
권한이나 인증이 필요한 특정주소를 요청했을때 헤더로 부터 넘겨받은 토큰을 확인하여 인가 실행
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
...
// 권한이나 인증이 필요한 특정 주소를 요청했을때 해당 필터를 무조건 실행
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
String jwtHeader = request.getHeader("Authorization");
System.out.println("jwtHeader : " + jwtHeader);
// 원하는 토큰값이 존재하는지 확인
if (jwtHeader == null) {
chain.doFilter(request, response);
return;
}
// JWT 토큰을 검증하여 유효한지 확인
String token = request.getHeader("Authorization").replace("Bearer: ", "");
String username = JWT.require(Algorithm.HMAC512("고유키")).build().verify(token).getClaim("username").asString();
// 다음필터로 넘어가도록 설정
chain.doFilter(request, response);
}
}
참고 : https://velog.io/@hope0206/Spring-Security-%EA%B5%AC%EC%A1%B0-%ED%9D%90%EB%A6%84-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EC%97%AD%ED%95%A0-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0
https://catsbi.oopy.io/c0a4f395-24b2-44e5-8eeb-275d19e2a536
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0
'IT > 스프링' 카테고리의 다른 글
mvc (0) | 2023.03.26 |
---|---|
Spring boot (0) | 2023.03.26 |
Spring (0) | 2023.03.26 |
JDBC & Mybatis 설정법 (0) | 2022.01.24 |
Spring이란 & @Transactional (0) | 2022.01.24 |
댓글