인증은 당신이 누구냐에 대한 것이다. 인가는 당신이 내 집에서 할 수 있는 것들, 즉 사용할 수 있는 자원을 정의한다. Spring Security는 애플리케이션의 보안(인증과 권한, 인가 등)을 담당하는 스프링 하위 프레임워크이다.
이번 캡스톤디자인 프로젝트에서 만들게 될 플랫폼 서비스는 기본적으로 회원 도메인이 들어가는 서비스이기에 이에 대한 인증/인가 처리를 유연하게 할 수 있도록 Spring Security를 활용하고자 하였다.
또한 Frontend와 Backend가 분리된 구조로 팀 프로젝트를 진행하기 때문에, 다양한 방법 중 JWT Token 인증/인가 방식으로 회원가입/로그인 로직을 구현하였다.
// JWT 방식에 대한 자세한 사항은 다른 포스팅으로 게시할 예정
// 이미지 출처: jwt.io
구현했던 주요 로직은 다음과 같다.
전체적인 패키지 구조는 다음과 같다. 현재는 member 도메인과 모든 도메인에 공통적으로 들어갈 전체적인 뼈대만 작업했기 때문에 요구사항 변경 시 내부 내용물들은 바뀔 수도 있다.
WaitForm
├── src
│ ├── domain # 각 도메인의 내부 구조는 같다.
│ ├── member
│ ├── controller
│ ├── service
│ ├── repository
│ ├── exception
│ ├── entity
│ └── dto
│ ├── board
│ ├── chatting
│ └── like
│ └── global
│ ├── config
│ ├── jwt # JWT 설정 클래스들
│ └── SecurityConfig.java, 기타 설정 클래스들
│ ├── error # 전역적인 Exception Handling을 위한 클래스들
│ └── result # 응답 데이터 통합을 위한 클래스들
│ └── test # Test Code
└──
JWT를 적용하기 위한 큰 틀은 이 방법이 무조건 정답은 아니겠지만, 구글링했던 결과들을 종합하면 가장 나은 방법이라 생각했었다. 세부적인 코드는 크게 공개하지 않고 각 클래스들이 어떤 역할을 하는지만 언급하고 넘어가겠다.
// 정은구님의 Inflearn 강의 Spring Boot JWT Tutorial를 참고하자.
JWT 관련
TokenProvider
: 유저 정보로 JWT 토큰을 만들거나 토큰을 바탕으로 유저 정보를 가져온다.JwtFilter
: Spring Request 앞단에 붙일 Custom FilterSpring Security 관련
JwtSecurityConfig
: JWT Filter를 추가JwtAccessDeniedHandler
: 접근 권한 없을 때 403 에러 반환JwtAuthenticationEntryPoint
: 인증 정보 없을 때 401 에러 반환SecurityConfig
: 스프링 시큐리티에 필요한 설정SecurityUtil
: SecurityContext
에서 전역으로 유저 정보를 제공하는 유틸 클래스이 중 TokenProvider
, SecurityConfig
만 요약하자면 다음과 같다.
JWT Token에 관련된 암호화, 복호화, 검증 로직은 모두 이 클래스에서 이루어진다.
generateTokenDto
authentication.getName()
이 username을 가져온다.getAuthentication
accessToken
을 파리미터로validateToken
Jwts
모듈이 알아서 Exception을 던져준다.package me.ramos.WaitForm.global.config;
import lombok.RequiredArgsConstructor;
import me.ramos.WaitForm.global.config.jwt.JwtAccessDeniedHandler;
import me.ramos.WaitForm.global.config.jwt.JwtAuthenticationEntryPoint;
import me.ramos.WaitForm.global.config.jwt.JwtSecurityConfig;
import me.ramos.WaitForm.global.config.jwt.TokenProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final TokenProvider tokenProvider;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
// Swagger 3.x 설정
private static final String[] PERMIT_URL_ARRAY = {
/* swagger v2 */
"/v2/api-docs",
"/swagger-resources",
"/swagger-resources/**",
"/configuration/ui",
"/configuration/security",
"/swagger-ui.html",
"/webjars/**",
/* swagger v3 */
"/v3/api-docs/**",
"/swagger-ui/**"
};
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
// CORS 설정
@Bean
public CorsConfigurationSource configurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.addAllowedOriginPattern("*");
configuration.addAllowedHeader("*");
configuration.addAllowedMethod("*");
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Override
public void configure(WebSecurity web) throws Exception {
web
.ignoring()
.antMatchers("/h2-console/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http
.logout()
.disable();
http
.cors().configurationSource(configurationSource())
.and()
.csrf().disable()
// exceptionHandling customizing
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
.and()
.authorizeRequests()
.antMatchers("/", "/auth/login", "/auth/signup").permitAll()
.antMatchers(PERMIT_URL_ARRAY).permitAll()
.anyRequest().authenticated()
// JwtFilter를 addFilterBefore로 등록했던 JwtSecurityConfig를 등록
.and()
.apply(new JwtSecurityConfig(tokenProvider));
}
}
Swagger 설정과, 몇 가지 커스터마이징 할 요소들이 있어 시큐리티 필터에서 제외해야 하거나 기타 설정이 필요한 내용들을 위와 같이 작업하였다.
Frontend에서 사용자가 입력한 email, password, nickname 값을 JSON 형식으로 요청하면 다음과 같이 정상 응답을 반환한다.
Frontend에서 사용자가 입력한 email, password 값을 JSON 형식으로 요청하면 다음과 같이 accessToken과 refreshToken과 함께 정상 응답을 반환한다.
테스트로 사용중인 H2 Database 내부를 보면 회원의 password는 BCryptPasswordEncoder
에 의해 암호화되어 저장되어있고, Refresh Token 역시 정상적으로 DB에 저장되어 있다.
다만, Access Token의 유효 기간과 이에 대한 재발급을 위한 용도이고, 현재 아키텍쳐 구조상 DB에서 불러오는 I/O가 성능 이슈를 발생할 여지가 있기 때문에 추 후 캐시 역할을 할 Redis로 토큰 저장소를 변경하고자 한다.
토큰 재발급의 경우, HTTP Header Authorization에 Bearer {accessToken}
으로 셋팅된 상태로 accessToken, refreshToken 값을 서버로 전송해야 한다.
정상 요청의 경우 다음과 같이 두 토큰이 재발급 된다.
현재 Postman에서 보이는 Response Body의 값들을 보면, 응답 값들만 나타나있고 상태코드나 메시지에 대한 내용은 전혀 없으며 또한 에러 발생 시 이를 핸들링해서 일관된 형식으로 나타낼 수 없는 구조이다.
이에 대한 처리를 한 과정들을 다음 포스팅에 게시할 예정이다.