기존에는 WebSecurityConfigurerAdapter를 상속받아 설정을 오버라이딩 하는 방식이었는데 바뀐 방식에서는 상속받아 오버라이딩하지 않고 모두 Bean으로 등록합니다.
변경 전 코드
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final SecurityHandler securityHandler;
@Bean
public SHA256CodeEncoder sha256CodeEncoder() {
return new SHA256CodeEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.sessionManagement(session -> session.invalidSessionUrl("/account/login")).headers().frameOptions().sameOrigin()
// .. 생략
.addFilterAt(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
}
}
변경 후 코드
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {
private final SecurityHandler securityHandler;
@Bean
public SHA256CodeEncoder sha256CodeEncoder() {
return new SHA256CodeEncoder();
}
// 변경된 방식은 SecurityFilterChain을 빈으로 등록하는 방식
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.headers().frameOptions().sameOrigin()
// ... 생략 ...
return http.build(); // Bean으로 등록해서 스프링 컨테이너가 관리할 수 있도록 변경
}
}
대부분의 시스템에서는 회원 관리를 하고 있고, 그에 따른 인증(Authentication)과 인가(Authorization)에 대한 처리를 해줘야 한다. Spring에서는 Spring Security라는 별도의 프레임워크에서 관련된 기능을 제공하고 있다.
1. Spring Security
Spring 기반의 애플리케이션의 보안(인증과 권한, 인가 등)을 담당하는 스프링 하위 프레임워크다. Spring Security는 인증과 권한에 대한 부분을 Filter 흐름에 따라 처리하고 있다. Filter는 Dispatcher Servlet으로 가기 전에 적용되므로 가장 먼저 URL 요청을 받지만, Interceptor는 Dispatcher와 Controller 사이에 위치한다는 점에서 적용 시기의 차이가 있다. Spring Security는 보안과 관련해서 체계적으로 많은 옵션을 제공해준다.
[ 인증(Authentication)과 인가(Authorization) ]
- 인증(Authentication): 해당 사용자가 본인이 맞는지를 확인하는 절차
- 인가(Authorization): 인증된 사용자가 요청한 자원에 접근가능한지를 결정하는 절차
- 인증 성공 후 인가가 이루어 진다.
Spring Security는 기본적으로 인증 절차를 거친 후에 인가 절차를 진행하게 되며, 인가 과정에서 해당 리소스에 대한 접근 권한이 있는지 확인하게 된다. Spring Security에서는 이러한 인증과 인가를 위해 Principal을 아이디로, Credential을 비밀번호로 사용하는 Credential 기반의 인증 방식을 사용한다.
- Principal(접근 주체): 보호받는 Resource에 접근하는 대상
- Credential(비밀 번호): Resource에 접근하는 대상의 비밀번호
2. Spring Security 모듈
- SpringContextHolder
보안 주체의 세부 정보를 포함하여 응용프로그램의 현재 보안 컨텍스트에 대한 세부 정보가 저장된다. SecurityContextHolder는 기본적으로 SecurityContextHolder.MODE_INHERITABLETHREDLOCAL 방법과 SecurityContextxHolder.MODE_THREADLOCAL 방법을 제공한다.
- SecurityContext
Authentication을 보관하는 역할을 하며, SecurityContext를 통해 Authentication 객체를 가져올 수 있다.
- Authentication
현재 접근하는 주체의 정보와 권한을 담은 인터페이스이다. Authentication 객체는 Security Context에 저장되며, SecurityContextHolder를 통해 SecurityContext에 접근하고, SecurityContext를 통해 Authentication에 접근할 수 있다.
public interface Authentication extends Principal, Serializable {
// 현재 사용자의 권한 목록을 가져옴
Collection<? extends GrantedAuthority> getAuthorities();
// credential(주로 비밀번호)을 가져옴
Object getCredentials();
Object getDetails();
// Principal 객체를 가져옴
Object getPrincipal();
// 인증 여부를 가져옴
boolean isAuthenticated();
// 인증 여부를 설정함
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}
- UsernamePasswordAuthenticationToken
Authentication을 implements한 AbstractAuthenticationToken의 하위 클래스로 User의 ID가 Principal 역할을 하고, Password가 Credential의 역할을 한다. UsernamePasswordAuthenticationToken의 첫 번째 생성자는 인증 전의 객체를 생성하고, 두 번째 생성자는 인증이 완료된 객체를 생성한다.
public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
// 주로 사용자의 ID
private final Object principal;
// 주로 사용자의 password
private Object credentials;
// 인증 완료 정의 객체 생성
public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
super(null);
this.principal = principal;
this.credentials = credentials;
this.setAuthenticated(false); // super.setAuthenticated(false);
}
public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
supter.setAuthenticated(true);
}
// .. 생략 ..
}
public abstract class AbstractAuthenticationToken implements Authentication, CredentialsContainer {
}
- AuthenticationProvider
실제 인증에 대한 부분을 처리하는데, 인증 전의 Authentication 객체를 받아서 인증이 완료된 객체를 반환하는 역할을 한다. 아래와 같은 AuthenticationProvider 인터페이스를 구현해서 Custom한 AuthenticationProvider을 작성해서 AuthenticationManager에 등록하면 된다.
public interface AuthenticationProvider {
// 인증 전의 Authentication 객체를 받아서 인증된 Authentication 객체를 반환
Authentication authenticate(Authentication var1) throws AuthenticationException;
boolean supports(Class<?> var1);
}
- AuthenticationManager
인증에 대한 부분은 AuthenticationManager를 통해 처리하는데 실질적으로는 AuthenticationManager에 등록된 AuthenticationProvider에 의해 처리된다. 인증에 성공하면 2번째 생성자를 이용해 인증이 성공한 (isAuthenticated = true) 객체를 생성하여 SecurityContext에 저장한다. 인증 상태를 유지하기 위해 세션에 보관하며, 인증이 실패한 경우에는 AuthenticationException을 발생시킨다.
public interface AuthenticationManager {
Authentication authenticate(Authentication authentication) throws AuthenticationException;
}
AuthenticationManager를 implements한 ProviderManager는 실제 인증 과정에 대한 로직을 가지고 있는 AuthenticationProvider를 List로 가지고 있으며, ProviderManager는 loop를 통해 모든 provider를 조회하면서 authenticate 처리를 한다.
-UserDetails
인증에 성공하여 생성된 UserDetails는 Authentication 객체를 구현한 UsernamePasswordAuthenticationToken을 생성하기 위해 사용된다. UserDetails 인터페이스를 살펴보면 아래와 같이 정보를 반환하는 메서드를 가지고 있다. UserDetails 를 implements 하여 처리할 수 있다.
UserDetails 객체를 반환하는 단 하나의 메서드를 가지고 있는데 일반적으로 UserRepository에서 주입하여 DB와 연결하여 처리한다.
public interface UserDetailsService {
UserDetails loadUserByUsername(String var1) throws UsernameNotFoundException
}
- PasswordEncoding
AuthenticationManagerBuilder.userDetailsService().passwordEncoder()를 통해 패스워드 암호화에 사용될 PasswordEncoder 구현체를 지정할 수 있다.
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
- GrantedAuthority
GrantAuthority는 현재 사용자(principal)가 가지고 있는 권한을 의미한다. ROLE_ADMIN이나 ROLE_USER와 같이 ROLE_*의 형태로 사용하며, 보통 "roles"라고 한다. GrantedAuthority 객체는 UserDetailsService에 의해 불러올 수 있고, 특정 자원에 대한 권한이 있는지를 검사하여 접근 허용 여부를 결정한다.
class PostsController < ApplicationController
def index
@posts = Post.order("created_at DESC").page params[:page]
end
end
3. 한 목록당 게시물 갯수 설정
class Post < ApplicationRecord
paginates_per 5
...
end
혹은
class PostsController < ApplicationController
def index
@posts = Post.order("created_at DESC").page(params[:page]).per(5)
end
def show
@posts = Post.page(params[:page]).per(10)
end
end
컨트롤러에서 반복되는 로직을 실행해야하는 경우가 있다. 해당 엔드포인트에 대한 인증(Authentication)과 인가(Authorization) 기능이 대표적인 예이다. 아래 코드는 인증을 구현하기 위해 컨트롤러의 여러 메서드에서 중복 코드가 발생한 예시이다.
@GetMapping("/me")
public ResponseEntity<String> getMyInfo(@RequestHeader("Authorization") String token) {
// 인증이 필요한 컨트롤러 메서드마다 등장하는 중복된 인증 로직
if (!authService.validateToken(token)) {
throw new AuthException();
}
// 유저 정보를 가져오는 로직 (생략)
return ResponseEntity.ok("유저 정보");
}
@PatchMapping("/me")
public ResponseEntity<String> updateMyInfo(@RequestHeader("Authorization") String token) {
// 인증이 필요한 컨트롤러 메서드마다 등장하는 중복된 인증 로직
if (!authService.validateToken(token)) {
throw new AuthException();
}
// 유저 정보를 수정하는 로직 (생략)
return ResponseEntity.noContent().build();
}
@DeleteMapping("/me")
public ResponseEntity<String> deleteMyInfo(@RequestHeader("Authorization") String token) {
// 인증이 필요한 컨트롤러 메서드마다 등장하는 중복된 인증 로직
if (!authService.validateToken(token)) {
throw new AuthException();
}
// 유저 정보를 삭제하는 로직 (생략)
return ResponseEntity.noContent().build();
}
혹여 인증이 필요한 엔드포인트에서 인증 로직을 실수로 누락해버린다면, 큰 문제가 발생할 것 이다. 코드에서 중복은 해악이다.
Spring Interceptor
우리는 이 문제를 인터셉터(Interceptor)로 해결할 수 있다. 여러 컨트롤러에서 같은 관심사를 갖고 반복되어 사용하는 코드를 제거하고, 다수의 컨트롤러에 동일한 기능을 제공하기 위해 사용하는 것이 인터셉터이다.
인터셉터 호출 흐름
스프링의 인터셉터 동작은 크게 '컨트롤러 실행 전'/ '컨트롤러 실행 후, 뷰 실행 전'/ '뷰 실행 후' 이 세단계로 구분된다. 스프링 인터셉터를 만들기 위해서는 HandlerInterceptor 인터페이스를 구현해야 하는데, 해당 인터페이스는 preHandle(), postHandle(), afterCompletion()이라는 세 메서드를 제공한다.
1. preHandle()
컨트롤러 호출 전에 호출되는 메서드다. preHandle()의 반환타입은 boolean이다. 만약 preHandle()이 false를 반환한다면, 다음 HandlerInterceptor 혹은 컨트롤러를 실행하지 않는다.
2. postHandle()
컨트롤러가 정상적으로 실행된 이후에 실행되는 메서드다. 컨트롤러에서 예외가 발생한다면, postHandle() 메서드는 실행되지 않는다.
3. afterCompletion()
뷰가 클라이언트 응답을 전송한 뒤에 실행된다. 컨트롤러 실행과정에서 예외가 발생한 경우 해당 예외가 afterCompletion() 메서드의 4번째 파라미터로 전달되어 로그를 남기는 등 후처리를 위해 사용될 수 있다.
인터셉터 작성
HandlerInterceptor 인터페이스를 구현하는 SomeInterceptor라는 클래스를 작성하여 직접 스프링의 인터셉터를 사용해보자.
public class SomeInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
return true;
}
@Override
public void postHandler(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) {
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception exception) {
}
}
인터셉터 등록
WebMvcConfigurer 인터페이스를 구현하면 Spring MVC의 설정을 할 수 있다고 한다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SomeInterceptor());
}
}
인터셉터를 활용하여 인증 구현하기
이제 인터셉터가 대략 어떤 식으로 동작하는지 파악했다. 우리는 맨 처음의 코드에서 AuthService를 통해 토큰을 검증하는 로직과 유효하지 않은 토큰에 대한 응답을 반환하는 부분이 중복되었음을 확인했다.
@Component
public class AuthInterceptor implements HandlerInterceptor {
private final AuthService authService;
public AuthInterceptor(AuthService authService) {
this.authService = authService;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String token = request.getHeader("Authorization");
if (!authService.validateToken(token)) {
throw new AuthException();
}
return true;
}
}
preHandle() 메서드에서 HTTP Header를 통해 가져온 토큰을 AuthService의 validateToken()을 통해 검증하고, 유효하지 않은 토큰이 들어왔다면 예외를 발생시킨다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
public WebConfig(AuthInterceptor authInterceptor) {
this.authInterceptor = authInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor);
}
}
WebConfig는 빈으로 등록된 AuthInterceptor를 주입받아 인터셉터로 등록한다.
Interceptor 설정하기
그런데 만약 접근시 인증이 필요없는 엔드포인트가 존재한다면 어떻게 될까? 인터셉터는 현재 모든 컨트롤러 메서드에 대해 동작하고 있다. 특정 엔드포인트만 적용하거나, 제외하고 싶다면 별도의 설정이 필요하다.
WebConfig 클래스에서 인터셉터 등록을 위해 InterceptorRegistry의 addInterceptor() 메서드를 실행했다. 이 메서드는 InterceptorRegistation 타입의 객체를 반환하는데, 해당 객체는 addPathPatterns()와 excludePathPatterns()라는 메서드를 제공한다. 이 메서드는 Ant Pattern이라는 경로 패턴을 사용하여 인터셉터를 등록할 경로를 설정해줄 수 있다.
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/users/*");
}
[ Ant Pattern ]
Ant Pattern은 *, **, ? 이 3개의 특수문자를 이용하여 경로를 표현한다. 각 문자는 아래의 의미를 갖는다.
* : 0개 이상의 글자와 매칭
/users/*는 /users/me, /users/123 와는 매칭되지만, /users/123/items와는 매칭이 되지 않는다.
** : 0개 이상의 디렉터리 혹은 파일과 매칭
/users/**는/users/me,/users/342,/users/342/favorites와 모두 매칭된다. 또한/a/**/z는/a/b/c/d/e/z와 매칭된다.
? : 1개 글자와 매칭
/u?ers 는 /users, /uzers 와 매칭되지만 /ussers와는 매칭되지 않는다.
@Embeddable
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Account {
@NotEmpty @Column(name = "bank_name", nullable = false)
private String bankName;
@NotEmpty @Column(name = "account_number", nullable = false)
private String accountNumber;
@NotEmpty @Column(name = "account_holder", nullable = false)
private String accountHolder;
// 불완전한 객체 생성 패턴
@Builder
public Account(String bankName, String accountNumber, String accountHolder) {
this.bankName = bankName;
this.accountNumber = accountNumber;
this.accountHolder = accountHolder;
}
// 안전한 객체 생성 패턴
@Builder
public Account(String bankName, String accountNumber, String accountHolder) {
Assert.hasText(bankName, "bankName must not be empty");
Assert.hasText(accountNumber, "accountNumber must not be empty");
Assert.hasText(accountHolder, "accountHolder must not be empty");
this.bankName = bankName;
this.accountNumber = accountNumber;
this.accountHolder = accountHolder;
}
}
데이터베이스의 컬럼이 not null 인 경우에는 대부분 엔티티의 멤버값도 null이면 안된다.
Account account = Account.builder().build();
불완전한 객체 생성 패턴으로 생성했을 경우 account 객체에는 모든 멤버 필드의 값이 null로 지정된다. account 객체로 추가적인 작업을 진행하면 NPE가 발생하게 된다.
안전한 객체 생성 패턴으로 생성했을 경우는 객체 생성이 Assert로 객체 생성이 진행되지 않는다. 필요한 값이 없는 상태에서 객체를 생성하면 이후 작업에서 예외가 발생하게 된다. 객체가 필요한 값이 없는 경우에는 적절하게 Exception을 발생시켜서 흐름을 종료하는 것이 맞다.
@Entity
@Table(name = "orders")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Embedded
private Address address;
@OneToMany(mappedBy = "order")
private List<Product> products = new ArrayList<>();
@Builder
public Order(Address address, List<Product> products) {
Assert.notNull(address, "address must not be null");
Assert.notNull(products, "products must not be null");
Assert.notEmpty(products, "products must not be empty");
this.address = address;
this.products = products;
}
}
Builder 이름으로 책임을 부여하자
Builder의 이름을 명확하게 해서 책임을 부여하는 것이 좋다.
@Entity
@Table(name = "refund")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Refund {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Embedded
private Account account;
@Embedded
private CreditCard creditCard;
@OneToOne
@JoinColumn(name = "order_id", nullable = false, updatable = false)
private Order order;
@Builder(builderClassName = "ByAccountBuilder", builderMethodName = "ByAccountBuilder")
public Refund(Account account, Order order) {
Assert.notNull(account, "account must not be null");
Assert.notNull(order, "order must not be null");
this.order = order;
this.account = account;
}
@Builder(builderClassName = "ByCreditBuilder", builderMethodName = "ByCreditBuilder")
public Refund(CreditCard creditCard, Order order) {
Assert.notNull(creditCard, "creditCard must not be null");
Assert.notNull(order, "order must not be null");
this.order = order;
this.creditCard = creditCard;
}
}
빌더의 이름으로 책임을 명확하게 부여하고, 받아야 하는 인자도 명확하게 할 수 있다.
public class RefundTest {
// 생략
@Test
public void ByAccountBuilder_test() {
final Refund refund = Refund.ByAccountBuilder() // 빌더 이름
.account(account)
.order(order)
.build();
assertThat(refund.getAccount()).isEqualTo(account);
assertThat(refund.getOrder()).isEqualTo(order);
}
@Test
public void ByCreditBuilder_test() {
final Refund refund = Refund.ByCreditBuilder() // 빌더 이름
.creditCard(creditCard)
.order(order)
.build();
assertThat(refund.getCreditCard()).isEqualTo(creditCard);
assertThat(refund.getOrder()).isEqualTo(order);
}
}
댓글 개