인증 / 인가 처리를 위해 선택할 수 있는 방법은 다양하다. 모든 필터 레벨에서 일어나는 일을 직접 처리할 수도 있고, OACC같은 프레임워크를 사용할 수도 있다. 인증을 위해 쿠키를 이용하거나 세션을 이용할 수도 있다. 나는 Spring Security 프레임워크를 활용해 로그인 과정을 처리하고, 인증에 사용되는 정보를 JWT를 이용해 클라이언트와 소통하기로 결정했다.
Spring Security는 기본적으로 Spring의 하위 프레임워크다. 따라서 Spring 기반의 프로젝트에서 호환성이 좋아 쉽게 사용 가능하다. JWT를 사용하는 이유는 쿠키 - 세션이 지닌 문제점 때문이다. 쿠키는 보안에 지나치게 취약하고, 세션은 서버에 부하가 많이 걸린다. 관련해서 쉽게 설명해준 영상이 있으니 보고 오면 좋다. 코드에 구현하기 이전에 Spring Security가 어떻게 동작하는지를 먼저 알아보고 싶다면 해당 링크를 참조하자.
아무튼 Spring Security와 JWT를 적용해보자. 이 둘을 위해선 일단 build.gradle파일에 의존성을 추가해야한다.
dependencies {
...
//Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
//JWT
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
...
}
다음으로는 모든 인증 처리의 시작점인 UsernamePasswordAuthenticationFilter에 JWT를 적용해서 구현해보자.
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JwtTokenizer jwtTokenizer;
public JwtAuthenticationFilter(AuthenticationManager authenticationManager, JwtTokenizer jwtTokenizer) {
this.authenticationManager = authenticationManager;
this.jwtTokenizer = jwtTokenizer;
}
@SneakyThrows
@Override
public Authentication attemptAuthentication(HttpServletRequest request
, HttpServletResponse response) throws AuthenticationException {
ObjectMapper objectMapper = new ObjectMapper();
SignInDto signInDto = objectMapper.readValue(request.getInputStream(), SignInDto.class);
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(signInDto.getEmail(), signInDto.getPassword());
return authenticationManager.authenticate(authenticationToken);
}
@Override
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authResult) throws IOException, ServletException {
Member member = (Member) authResult.getPrincipal();
String accessToken = getAccessTokenFromJwtTokenizer(member);
String refreshToken = getRefreshTokenFromJwtTokenizer(member);
response.setHeader("Authorization", "Bearer " + accessToken);
response.setHeader("Refresh", refreshToken);
response.setIntHeader("MemberId", (int) member.getId());
this.getSuccessHandler().onAuthenticationSuccess(request, response, authResult);
}
private String getAccessTokenFromJwtTokenizer(Member member) {
Map<String, Object> claims = new HashMap<>();
claims.put("email", member.getEmail());
claims.put("roles", member.getRoles());
String subject = member.getEmail();
Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
return jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);
}
private String getRefreshTokenFromJwtTokenizer(Member member) {
String subject = member.getEmail();
Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes());
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
return jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);
}
}
인증 과정을 시작해줄 AuthenticationManager와 JWT 토큰을 만들어줄 JwtTokentizer를 DI받는다. 메서드들을 살펴보면
- attemptAuthentication() : 인증을 시도한다. 로그인 시 받아온 정보를 통해 SignInDto를 만들고 이를 바탕으로 authenticationToken을 만들어 인증을 시도한다.
- successfulAuthentication() : 인증에 성공했으면 해당 사용자를 위한 AccessToken과 RefreshToken을 만들고 이를 Response Header에 담아 전송한다.
- getAccess / getFresh : JwtTokenizer를 통해 AccessToken과 RefreshToken을 발급받는다.
별 거 없다. 해야 될 일들을 한다. 그럼 토큰을 만들어주는 JwtTokenizer를 살펴보자.
@Getter
@Component
public class JwtTokenizer {
@Value("${jwt.key}")
private String secretKey;
@Value("${jwt.access-token-expiration-minutes}")
private int accessTokenExpirationMinutes;
@Value("${jwt.refresh-token-expiration-minutes}")
private int refreshTokenExpirationMinutes;
public String encodeBase64SecretKey(String secretKey) {
return Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8));
}
public String generateAccessToken(Map<String, Object> claims,
String subject,
Date expiration,
String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(Calendar.getInstance().getTime())
.setExpiration(expiration)
.signWith(key)
.compact();
}
public String generateRefreshToken(String subject, Date expiration, String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
return Jwts.builder()
.setSubject(subject)
.setIssuedAt(Calendar.getInstance().getTime())
.setExpiration(expiration)
.signWith(key)
.compact();
}
public Jws<Claims> getClaims(String jws, String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(jws);
}
public Date getTokenExpiration(int expirationMinutes) {
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.MINUTE, expirationMinutes);
return calendar.getTime();
}
public String getTokenFromHeader(String tokenHeader) {
return tokenHeader.replace("Bearer ", "");
}
private Key getKeyFromBase64EncodedKey(String base64EncodedSecretKey) {
byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey);
return Keys.hmacShaKeyFor(keyBytes);
}
}
- encodeBase64SecretKey() : Secret Key의 byte 배열을 인코딩한다.
- generateAccessToken() : 주어진 정보들을 바탕으로 AccessToken을 만든다.
- generateRefreshToekn() : 주어진 정보들을 바탕으로 RefreshToken을 만든다.
- getter : 주어진 정보들에서 값을 뽑아온다.
살펴볼 부분은 토큰을 만들어주는 부분과, Key를 만드는 부분이다. AccessToken을 만드는 부분엔 사용자의 정보가 담기는 claims가 함께 들어간다. 토큰 발급에 공통적으로 발급 시간, 만료 시간, 서명을 위한 키 값이 들어간다. Key를 만드는 부분은 단순히 Base64로 인크립트 된 문자열을 디크립트해 hmac알고리즘을 통해 다시 암호화 한다.
AuthenticationManager와 AuthenticationProvider는 Spring Security에서 기본으로 제공하는 기능을 사용한다.
다음으로 UserDetailsService를 구현해보자.
@Service
public class MemberDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
private final CustomAuthorityUtils customAuthorityUtils;
public MemberDetailsService(MemberRepository memberRepository, CustomAuthorityUtils customAuthorityUtils) {
this.memberRepository = memberRepository;
this.customAuthorityUtils = customAuthorityUtils;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<Member> member = memberRepository.findByEmail(username);
Member findMember = member.orElseThrow(() -> new CustomAuthenticationException(ExceptionCode.MEMBER_NOT_FOUND));
return new MemberDetails(findMember);
}
public final class MemberDetails extends Member implements UserDetails {
MemberDetails(Member member) {
setId(member.getId());
setEmail(member.getEmail());
setName(member.getName());
setPassword(member.getPassword());
setRoles(member.getRoles());
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return customAuthorityUtils.createAuthorities(this.getRoles());
}
@Override
public String getUsername() {
return getEmail();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
}
- loadUserByUsername() : 넘어온 username을 바탕으로 Credential 저장소에서 Credential을 뽑아온다. 그냥 Member 그대로 사용하면 된다. 저장된 Member에도 비밀번호는 암호화되어있다.
주목할 부분은 UserDetails를 상속받은 MemberDetails다. MemberDetails는 Member도 상속받아 Member와 같은 외형을 유지할 수 있다. getUsername()은 username으로 사용할 이메일을 반환하고 아래의 boolean 타입 메서드들은 현재 사용하지 않는 기능 들이다. 각 기능들에 대한 설명은 해당 링크에서 확인할 수 있다.
권한을 설정하는 customAuthorityUtils를 사용하고 있는데, 아래의 유틸리티 클래스이다.
@Component
public class CustomAuthorityUtils{
public List<GrantedAuthority> createAuthorities(List<String> roles) {
return roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toList());
}
}
이제 인증 필터에 대한 처리는 얼추 마친 것 같다. 이 설정들을 적용해보자... 라고 하려 했는데 분량이 너무 많아질 것 같아서 이 부분은 인증된 사용자 정보를 가져오는 파트에서 나머지 필터와 핸들러들을 만들면서 설명하겠다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtTokenizer jwtTokenizer;
public SecurityConfig(JwtTokenizer jwtTokenizer) {
this.jwtTokenizer = jwtTokenizer;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers().frameOptions().sameOrigin()
.and()
.userDetailsService(memberDetailsService)
.csrf().disable()
.cors().configurationSource(corsConfigurationSource())
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.formLogin().disable()
.httpBasic().disable()
.exceptionHandling()
.authenticationEntryPoint(new MemberAuthenticationEntryPoint())
.accessDeniedHandler(new MemberAccessDeniedHandler())
.and()
.apply(new CustomFilterConfigurer())
.and()
.authorizeHttpRequests(authorize -> authorize
.anyRequest().permitAll()
);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("https://grooming-zone.com", "http://localhost:3000", "http://localhost:8080"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PATCH", "DELETE", "OPTIONS"));
configuration.setAllowCredentials(true);
configuration.addAllowedHeader("*");
configuration.setExposedHeaders(Arrays.asList("Authorization", "Refresh", "MemberId"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> {
@Override
public void configure(HttpSecurity builder) {
AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtTokenizer);
jwtAuthenticationFilter.setFilterProcessesUrl("/auth/sign-in");
jwtAuthenticationFilter.setAuthenticationSuccessHandler(new MemberAuthenticationSuccessHandler());
jwtAuthenticationFilter.setAuthenticationFailureHandler(new MemberAuthenticationFailureHandler());
builder
.addFilter(jwtAuthenticationFilter);
}
}
}
일단 로그인 로직 자체는 구현이 완성됐다. /auth/sign-in 을 통해 로그인 요청을 받는다. 테스트를 작성해보자.
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureRestDocs
class SpringSecurityTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private MemberServiceManager memberServiceManager;
@Autowired
private Gson gson;
@BeforeEach
void createUser() {
String email = "jk@gmail.com";
String password = "123";
String name = "JKROH";
String phoneNumber = "010-1111-2222";
String role = "BARBER";
MemberDto.Post dto = new MemberDto.Post();
dto.email = email;
dto.password = password;
dto.name = name;
dto.phoneNumber = phoneNumber;
dto.role = role;
memberServiceManager.postMember(dto);
}
@Test
void testSignIn() throws Exception {
String email = "jk@gmail.com";
String password = "123";
SignInDto signInDto = new SignInDto();
signInDto.setEmail(email);
signInDto.setPassword(password);
String content = gson.toJson(signInDto);
mockMvc.perform(
post("/auth/sign-in")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(content)
).andExpect(status().isOk())
.andExpect(header().exists("Authorization"))
.andExpect(header().exists("Refresh"))
.andDo(document(
"sign-in",
getRequestPreProcessor(),
getResponsePreProcessor(),
requestFields(
List.of(
fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"),
fieldWithPath("password").type(JsonFieldType.STRING).description("비밀번호")
)
)
));
}
}
로그인 로직은 내가 적용한 설정이 Spring Security에 적용되어야 하며, Spring Security의 작동 영역인 서블릿 영역을 테스트 한다. 따라서 @SpringBootTest를 통해 통합 테스트를 설정했다. TDD랑은 거리가 멀어도 한참 멀지만... 아무튼 회원 가입을 하나 시켜놓고, 로그인하면 Response Header에 토큰 정보가 잘 포함되어 담겨옴을 확인할 수 있다.
전체 코드는 링크에서 확인할 수 있다.
'Programming > TDD Project' 카테고리의 다른 글
Pull Request 023. 인증된 사용자 객체 사용 TDD로 구현하기 - 자유 게시판 등록 시 작성자 등록하기 (0) | 2023.12.08 |
---|---|
Pull Request 022. 사용자 인증 및 권한 확인 기능 TDD로 구현하기 - Spring Security 설정 마무리 (0) | 2023.12.07 |
Pull Request 020. 회원 가입 및 로그인 기능 TDD로 구현하기 - 회원 가입 Service 계층 구현 (1) | 2023.12.04 |
Pull Request 019. 페이징 처리 시 페이지 정보도 Response에 함께 담아주기 (0) | 2023.12.01 |
Pull Request 018. 자유 게시글 검색 기능 구현 - Querydsl 사용해보기 (1) | 2023.11.30 |
댓글