스프링 시큐리티(Spring Security)
pring Security는 강력하고 사용자 정의가 가능한 인증 및 액세스 제어 프레임워크입니다. 이는 Spring 기반 애플리케이션 보안을 위한 사실상의 표준입니다.
Spring Security는 Java 애플리케이션에 인증과 권한 부여를 모두 제공하는 데 중점을 둔 프레임워크입니다. 모든 Spring 프로젝트와 마찬가지로 Spring Security의 진정한 힘은 사용자 정의 요구 사항을 충족하기 위해 얼마나 쉽게 확장할 수 있는지에 있습니다.
-Spring Seurity 공식 홈페이지-
스프링 시큐리티는 스프링 공식 홈페이지의 설명처럼 인증(Authentication) 및 인가(Authorization) 프레임워크이다.
이커머스 서비스를 예시로 생각해 보면 해당 이커머스를 이용하는 일반 이용자와 상품을 등록하고 판매하는 특정 이용자, 그리고 서비스를 관리해 주는 관리자가 있다.
이러한 이용자들이 공통적으로 거치게 되는 과정이 바로 회원가입과 로그인이다.
회원가입과 로그인을 생각해보면 이용자에 관한 개인 정보가 들어있기 때문에 보안을 생각해야 된다. 또한 같은 서비스 이용자라고 하더라도 어떤 이용자는 구매만 할 수 있고, 어떤 이용자는 상품을 등록하고 판매할 수 있어야 하기 때문에 이용자들마다 권한이 달라져야 한다.
예시를 통해 알 수 있지만 개발자 입장에서는 로그인과 회원가입 기능을 구현하면서 거기에 맞는 인증 절차, 보안, 권한 설정 및 권한에 맞는 접근 제어를 구현해야 된다.
어차피 거의 모든 서비스에 로그인과 회원가입 기능이 들어가게 되고 그에 따른 인증과 인가 과정이 필요하게 되기 때문에 반복적인 로그인 및 회원가입 기능 개발과 거기에 필요한 인증과 인가에 대한 작업에 대해서 하나의 틀을 만들어 보다 편리하고 손쉽게 개발할 수 있도록 스프링 시큐리티가 만들어졌다.
하나씩 살펴보면 스프링 시큐리티도 프레임워크이기 때문에 프레임워크의 특징인 IoC를 통해서 보안의 관한 부분들을 자동으로 처리해 주고, 개발자가 보안에 관련된 코드를 작성하기 위한 일종의 틀이 이미 짜여져있다.
그럼 시큐리티에 관한 설명 중 인증과 인가는 무엇일까?
인증과 인가를 이해할 수 있는 것으로 궁예의 관심법이 있을 것 같다.
인증(Authentication)
who are you?(누구인가?)
인증은 보호된 리소스에 접근한 대상이 누구인지, 애플리케이션의 작업을 수행해도 되는 주체인지 확인하는 과정이다.
즉, 내가 개발한 서비스에 접속하는 사용자의 신원을 검증하는 프로세스로 ID와 PW를 통해 로그인하는 행위를 인증이라고 할 수 있다.
인가(Authorization)
what are you allowed to do?(어떤 것을 할 수 있는가?)
인가는 인증 과정을 거친 후 해당 유저가 특정 리소스에 대해 접근 가능한 권한을 가지고 있는지 확인하는 과정이다.
인증된 유저라고 해도 특정 리소스에 대한 접근 권한이 없을 수도 있다. 이러한 접근 권한을 확인하는 절차를 인가라고 한다.
쿠키와 세션
시큐리티의 기본 동작 과정을 공부하기 전에 먼저 쿠키와 세션에 관해서 이해해 보자.
웹 사이트는 기본적으로 HTTP 통신 위에서 동작한다. 따라서 웹 사이트 내의 모든 요청과 응답은 HTTP의 stateless(무상태)한 특성을 가지게 된다.
stateless 특성으로 인하여 서버는 클라이언트의 이전 상태를 기억하고 있지 않기 때문에 로그인을 통해서 인증을 거쳐도 이후 요청에서는 이전의 인증된 상태를 유지하지 않는다.
그렇다면 이러한 stateless 특성의 문제를 해결하기 위해서는 인증되었다는 정보를 어딘가에 저장을 해야 되는데 이때 사용하는 것이 쿠키와 세션이다.
쿠키(Cookie)
HTTP 쿠키란 서버에서 사용자 브라우저로 전송하는 작은 데이터를 뜻한다.
먼저 사용자가 로그인을 했을 경우 서버에서 해당 정보에 쿠키를 더하여 다시 브라우저로 응답하게 된다. 그다음 브라우저는 받은 데이터(쿠키)를 저장해 놓았다가 동일한 서버로 재요청 시 서버에서 받았던 데이터(쿠키)를 함께 전송하게 된다.
이렇게 하면 쿠키를 가지고 있는 사용자가 인증을 받은 사용자라는 것을 서버가 알고 있기 때문에 다시 로그인하게 되는 문제가 발생하지 않는다.
쿠키의 특징
- 사용자 인증이 유효한 시간을 명시할 수 있으며 유효 시간이 정해지면 브라우저가 종료되어도 인증이 유지된다는 특징이 있다.
- 쿠키는 클라이언트의 상태 정보를 로컬에 저장했다가 참조하게 된다.
- 클라이언트에 최대 300개까지 쿠키를 저장할 수 있으며 하나의 도메인당 20개의 값만 가질 수 있다.
- 쿠키는 사용자가 따로 요청하지 않아도 브라우저가 Request시에 Request Header에 쿠키를 넣어서 자동으로 서버에 전송한다.
쿠키를 통해서 HTTP의 stateless 특성을 해결할 수 있지만 사용자의 주요 정보를 매번 요청에 담아야 하기 때문에 보안상의 문제를 가지고 있다.
세션(Session)
세션은 매번 로그인 정보를 요청에 담아 보내는 것이 보안상 문제가 발생할 수 있기 때문에 이러한 문제를 해결하고자 나온 것이 세션이다.
세션은 사용자의 주요 정보가 아닌 식별할 수 있는 값(세션 아이디)을 생성해 쿠키로 주고받게 된다.
세션의 특징
- 웹 브라우저가 서버에 접속해서 브라우저를 종료할 때까지 인증 상태를 유지한다.
- 접속 시간에 제한을 두어 일정 시간 응답이 없으면 정보가 유지되지 않게 설정할 수 있다.
- 세션 정보를 서버에서 저장하기 때문에 사용자가 많아질수록 서버 메모리를 많이 차지하게 된다.
세션을 사용하면 보안적인 면에서 이득이 있지만 이러한 세션 방식에도 문제점이 존재하는데 결국 사용자를 식별할 수 있는 어떠한 값(세션 아이디)을 서버 어딘가에는 저장해두어야 한다는 것이다.
만약에 서버 이중화를 통해서 서버가 여러 대로 늘어난다면 각 서버마다 가지고 있는 세션 정보가 달라질 것이다. 하지만 이러한 문제를 해결하겠다고 세션 정보를 저장할 수 있는 데이터베이스를 만든다고 한다면 사용자 요청 때마다 매번 확인을 해야 되니 전체적인 서비스가 느려질 수밖에 없다.
위와 같이 쿠키와 세션을 이용하는 방법을 세션 기반의 인증 방식이라고 하는데 이러한 문제점들을 보완한 토큰 기반의 인증방식을 많이 이용한다.
스프링 시큐리티의 동작
이제 본격적인 스프링 시큐리티 부분을 공부해 보자.
Spring Security는 인증과 인가에 대한 부분을 Filter 흐름에 따라 처리하게 된다.
Filter는 DispatcherServlet으로 가기 전에 적용되므로 가장 먼저 URL 요청을 받게 된다.
스프링 시큐리티의 필터
스프링 시큐리티에는 여러 가지의 필터가 존재하는데 하나씩 살펴보자.
1. SecurityContextPersistenceFilter
SecurityContextRepository에서 SecurityContext를 가져오거나 생성하는 필터이다.
SecurityContextRepository에서 loadContext 메서드로 인증을 시도한 사용자가 이전에 세션에 저장한 이력이 있는지 확인하게 된다.
처음 인증하거나 혹은 익명 사용자일 경우 세션에 저장된 정보가 없기 때문에 SecurityContext를 생성하고 SecurityContextHolder안에 저장을 하게 된다.
만약 세션에 저장한 이력이 있을 경우 SecurityContext를 꺼내와서 SecurityContextHolder에 저장하게 된다. (따로 SecurityContext를 생성하지 않음.)
모든 작업을 마치고 최종적으로 클라이언트에게 인증하기 직전에는 항상 ClearSecurityContext를 실행하게 된다.
2. LogOutFilter
로그아웃 요청 시에만 실행되는 필터이다.
3. UsernamePasswordAuthenticationFilter
ID와 Password를 사용하는 실제 Form 기반 유저 인증을 처리한다.
4. DefaultLoginPageGeneratingFilter
Form기반 또는 OpenID 기반 인증에 사용하는 가상 URL에 대한 요청을 감시하고 로그인 Form 기능을 수행하는데 필요한 HTML 파일을 생성한다.
5. BasicAuthenticationFilter
HTTP 기본 인증 헤더를 감시하고 이를 처리한다.
6. RememberMeAuthenticationFilter
세션이 사라지거나 만료되더라도 쿠키 또는 DB를 사용하여 저장된 토큰 기반으로 인증을 처리한다.
세션이 만료되거나 무효화돼서 세션 안에 있는 SecurityContext내의 인증 객체가 null일 경우 해당 필터를 작동한다.
7. SecurityContextHolderAwareRequestFilter
시큐리티 관련 서블릿 API를 지원해 주는 필터이다.
8. AnonymousAuthenticationFilter
사용자 정보가 인증되지 않았다면 익명 사용자 토큰을 반환한다.
해당 필터가 호출되는 시점까지 인증 시도를 하지 않고 권한도 없이 어떤 자원에 바로 접속을 시도하는 경우 해당 필터가 실행된다.
인증되지 않은 사용자가 접근했을 때 익명 사용자에 대한 토큰을 만들어 SecurityContext 객체에 저장하게 된다.
9. SessionManagementFilter
로그인 후 세션과 관련된 작업을 처리한다.
10. ExceptionTranslationFilter
필터 체인 내에서 발생되는 인증, 인가 예외를 처리한다.
인증 및 인가에서 예외가 발생할 경우 실행되는 필터로 AccessDeniedException, AuthenticationException을 던진다.
따라서 chain.doFilter로 다음 필터로 넘기는 것을 try-catch 문으로 감싸서 예외를 처리한다.
11. FilterSecurityInterceptor
권한 부여와 관련한 결정을 AccessDecisionManager에게 위임해 권한부여 결정 및 접근 제어를 처리한다.
로그인이 이뤄지는 과정
로그인 요청이 들어왔을 때 어떻게 필터가 작동되고 인증이 이루어지는지 확인해 보자
@Service
@RequiredArgsConstructor
public class MemberService implements UserDetailsService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
public void signup(String username, String password) {
if(!memberRepository.findByUsername(username).isPresent()) {
memberRepository.save(Member.builder()
.username(username)
.password(passwordEncoder.encode(password))
.authority("ROLE_USER")
.build());
}
}
}
먼저 회원가입부터 진행하면 유저의 비밀번호가 암호화된 방식으로 저장되어야 하기 때문에 PasswordEncoder를 통해서 비밀번호를 암호화시켜 주고 authority로 유저의 권한 정보를 저장한다.
@RestController
@RequestMapping("/member")
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
private final AuthenticationManager authenticationManager;
@RequestMapping(method = RequestMethod.POST, value = "/signup")
public ResponseEntity<Object> signUp (String username, String password) {
memberService.signup(username, password);
return ResponseEntity.ok().body("ok");
}
@RequestMapping(method = RequestMethod.POST, value = "/login")
public ResponseEntity<Object> login(String username, String password) {
Authentication authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
SecurityContextHolder.getContext().setAuthentication(authentication);
return ResponseEntity.ok().body(((Member)authentication.getPrincipal()).getId());
}
}
UsernamePasswordAuthenticationFilter를 사용하기 위해서는 먼저 AuthenticationManager가 필요하기 때문에 의존성 주입을 받아주고 authenticate() 메서드로 UsernamePasswordAuthenticationToken 객체를 새로 생성하여 넘겨준다.
authenticate() 메서드를 구현한 ProviderManager를 통해서 AuthenticationProvider로 앞서 생성한 UsernamePasswordAuthenticationToken을 넘겨주게 된다.
public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
private static final Log logger = LogFactory.getLog(ProviderManager.class);
private AuthenticationEventPublisher eventPublisher;
private List<AuthenticationProvider> providers;
protected MessageSourceAccessor messages;
private AuthenticationManager parent;
private boolean eraseCredentialsAfterAuthentication;
// ..... 메서드
}
AuthenticationProvider는 UserDetailsService를 호출하게 되는데 이때 해당 인터페이스를 구현한 MemberService의 loadByUserName() 메서드가 호출된다.
@Service
@RequiredArgsConstructor
public class MemberService implements UserDetailsService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
public void signup(String username, String password) {
if(!memberRepository.findByUsername(username).isPresent()) {
memberRepository.save(Member.builder()
.username(username)
.password(passwordEncoder.encode(password))
.authority("ROLE_USER")
.build());
}
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<Member> result = memberRepository.findByUsername(username);
Member member = null;
if(result.isPresent()) {
member = result.get();
}
return member;
}
}
loadByUserName() 메서드를 통해서 전달받은 username을 DB에서 찾아서 있다면 반환해 주게 되는데 이때 반환 타입이 UserDetails가 된다.
@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Member implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private String username;
private String password;
private String authority;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.singleton((GrantedAuthority) () -> authority);
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
UserDetails 인터페이스는 Member 엔티티가 implements 하여 메서드들을 오버라이딩해서 해당 객체를 호출하는 곳에서 메서드들을 사용하게 된다.
Authentication authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
SecurityContextHolder.getContext().setAuthentication(authentication);
위의 과정이 다 끝나면 인증을 받은 유저로 Authentication을 반환하게 되고 해당 변수를 SecurityContext에 저장한다.
어떻게 저장되는지 보면 스프링 시큐리티가 관리하는 세션인 SecurityContextHolder에 SecurityContext로 인증을 받은 유저의 정보가 저장되게 되는데 유저의 정보와 암호, 권한이 저장된다.
참고 자료
'스프링' 카테고리의 다른 글
스프링 - 스프링 부트 프로젝트 배포하기 (0) | 2024.01.10 |
---|---|
소셜 로그인 (1) | 2024.01.04 |
스프링 - JWT (0) | 2023.12.28 |
스프링 - 레이어드 아키텍처 (0) | 2023.12.21 |
스프링 - 스프링 프레임워크의 기초 (0) | 2023.12.20 |