이전까지 회원가입 및 로그인을 구현하였고, 로그인 성공 시 JWT 토큰까지 발급하였다.
이제부터는 JWT 토큰을 가지고 서버에서는 사용자의 요청에 대한 권한 확인을 하게 될 텐데, 그 기능을 JWT 필터를 통해서 구현해본다.
1. UserDetails, UserDetailsService 생성
- Spring security에서 사용하는 인증 객체인 Authentication을 사용하기 위하여 UserDeatils와 UserDetails 서비스를 생성해야한다.
@Data
public class CustomUserDetails implements UserDetails {
private User user;
@Builder
public CustomUserDetails(User user) {
this.user = user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return user.getRole().getValue();
}
});
return collection;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.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;
}
}
- Spring Security의 UserDetails를 상속받는 CustomUserDetails를 생성 후, 내 프로젝트의 User 객체를 생성자를 통해 주입한 후 남은 메서드들을 오버라이딩해준다.
@Service
@RequiredArgsConstructor
@Slf4j
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
User user = userRepository.findByEmail(email).orElseThrow(() -> new UsernameNotFoundException(email));
return CustomUserDetails.builder().user(user).build();
}
}
- UserDetailsService를 상속받는 CustomUserDetailsService는 User DB에서 입력받은 email로 유저를 찾아 Custom한 UserDetails 객체로 반환해주는 loadUserByUsername 메서드를 오버라이딩한다.
2. JwtTokenProvider 토큰 변환, 인증 기능 구현
- JwtTokenProvider 에서 입력받은 토큰에서 email 값을 뽑아내는 메서드와, 토큰이 유효한지 검증해주는 메서드를 추가한다.
@Component
@Slf4j
@RequiredArgsConstructor
public class JwtTokenProvider {
private final CustomUserDetailsService customUserDetailsService;
...
public Authentication getAuthentication(String token) {
Claims claims = Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token).getBody();
String email = claims.get("email").toString();
UserDetails principal = customUserDetailsService.loadUserByUsername(email);
return new UsernamePasswordAuthenticationToken(principal, "", principal.getAuthorities());
}
public int validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token);
return 1;
} catch (Exception e) {
return -1;
}
}
}
- getAutentication은 토큰에서 Claim의 이메일 값을 추출하여, 1번에서 생성한 CustomUserDetilasService의 loadUserByname 메서드의 파라미터로 넘겨주고 그 결과를 principal 이름으로 생성한다. 그 후, 스프링에서 사용하는 AuthenticationManager에서 사용하는 Authentication 객체 -> UsernamePasswordAuthenticationToken 객체에 principal과 권한 값을 주입하여 리턴한다.
- validToken은 주어진 token이 유효하면 1을, 그렇지 않다면 -1을 리턴해준다.
3. JwtFilter 구현
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtFilter extends OncePerRequestFilter {
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String BEARER_PREFIX = "Bearer ";
private static final String[] PERMIT_URL_ARRAY = {
"v2",
"swagger-resources",
"configuration",
"swagger-ui",
"webjars",
"v3",
"users"
};
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 요청 Url의 시작이 PERMIT_URL_ARRAY에 포함되는지 검증하여, 해당되면 다음 필터로 요청을 전달한다.
String prePath = request.getServletPath().split("/")[1];
if(Arrays.asList(PERMIT_URL_ARRAY).contains(prePath)) {
filterChain.doFilter(request, response);
} else {
String token = getToken(request);
log.info("Token : {}", token);
if(StringUtils.hasText(token)) {
int flag = jwtTokenProvider.validateToken(token);
// 토큰이 유효할 때, 인증 객체를 생성하여 SecurityContextHolder에 삽입 후, 다음 필터로 넘어간다.
if(flag == 1) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
doFilter(request, response, filterChain);
}
else if (flag == -1) {
log.warn("토큰 값이 잘못되었습니다.");
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setCharacterEncoding("UTF-8");
}
} else {
log.warn("토큰 값이 비었습니다.");
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setCharacterEncoding("UTF-8");
}
}
}
public String getToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
log.info("bearerToken : {}", bearerToken);
if(StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(7);
}
return null;
}
}
- 요청 URL이 Swagger나 로그인, 회원가입에 관련되었다면, JWT 필터를 스킵한다.
- 토큰이 유효하다면, 스프링 시큐리티에서 제공하는 SecurityContextHolder에 JwtTokenProvider에서 생성한 Authentication 객체를 삽입 후 다음 필터로 넘어간다.
4. SecurityConfig 수정
- 앞에서는 WebSecurityConfigurerAdapter를 상속받아 사용했지만 이제는 deprecated 되었다고 하여 SecurityFilterChain을 빈으로 등록하여 사용하도록 수정하였다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtFilter jwtFilter;
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/**",
/* Auth */
"/users/**"
};
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.httpBasic().disable()
.csrf().disable()
.cors().and()
.authorizeRequests()
.antMatchers(PERMIT_URL_ARRAY).permitAll()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class).build();
}
}
- 생성한 jwtFilter는 UsernamePassAuthenticationFilter가 실행 되기 전으로 순서를 위치시킨다.
5. @CurrentUser 생성
- 매번 SecurityContextHolder에서 Authentication 객체를 뽑아오는 것은 매우 귀찮기 때문에, AuthenticationPrincipal을 활요하여 CurrentUser 어노테이션을 생성한다.
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@AuthenticationPrincipal(expression = "User")
public @interface CurrentUser {
}
- 이렇게 생성하면 다음부터는 아래와 같이 어노테이션으로 User 객체를 가져와 사용할 수 있다.
@RestController
@Slf4j
public class FollowController {
@GetMapping("/follow")
public void follow(@CurrentUser User user) {
log.info(user.toString());
}
}
'Web > 인스타 클론 코딩' 카테고리의 다른 글
[인스타그램 클론코딩] 05. 기본 화면 구성(Front-End) (0) | 2023.02.02 |
---|---|
[인스타그램 클론코딩] 04. JWT 필터 구현(Front-End) (0) | 2023.02.01 |
[인스타그램 클론코딩] 03. 로그인 구현(Front-End) (0) | 2023.02.01 |
[인스타그램 클론코딩] 03. 로그인 구현(Back-End) (0) | 2023.02.01 |
[인스타그램 클론코딩] 02. 회원 가입 구현(Front-End) (0) | 2023.01.31 |