[Spring] Refresh Token, Role 구현

이수민·2023년 2월 21일
1

spring

목록 보기
8/12

지난 포스팅에서 JWT를 이용해 인증 기능을 구현했었다.
이번 포스팅에서는 Refresh Token 생성 기능과 Role을 추가하려 한다.

Refresh Token

Refresh Token을 사용하는 이유

  • 기존 Access Tokenstateless하다는 단점이 있었다.

    • -> 누군가에게 탈취당하면 사용자의 정보가 노출되는 위험성이 있다.
  • 이 문제를 해결하기 위해 Access Token만료기한을 짧게 지정했다.

    • -> 하지만 이러면 사용자가 재인증을 자주 해야한다는 불편함이 생긴다.
    • 그럼 Access Token을 재발급해주는 Refresh Token을 사용하자!

로그인 -> Access Token, Refresh Token 발급 -> Access Token 만료 -> Refresh Token으로 Access Token 재발급 -> Refresh Token 만료 -> 이 경우 재로그인 필요

Refresh Token 생성 (DB 이용)

Access Token 생성 로직과 같게 구현할 것이다. (만료기한만 다르게)
Refresh Token의 경우, User Entity의 refreshToken Column에 값을 저장하고 인증 시 둘을 비교하여 검증할 것이다.

Refresh Token 생성 로직

// JwtTokenProvider.java

@RequiredArgsConstructor
@Component
@PropertySource("classpath:env.properties")
public class JwtTokenProvider {

    @Value("${auth.jwtSecret}")
    private String jwtSecret;

    @Value("${auth.jwtExpiration.accessToken}")
    private int jwtExpirationAccessToken;

    @Value("${auth.jwtExpiration.refreshToken}")
    private int jwtExpirationRefreshToken;

    ...
    
    // 기존의 AccessToken 생성 함수
    public String createAccessToken(final long payload) {
        return createToken(payload, jwtSecret, jwtExpirationAccessToken);
    }
	// 새로 추가된 RefreshToken 생성 함수
    public String createRefreshToken(final long payload) {
        return createToken(payload, jwtSecret, jwtExpirationRefreshToken);
    }
    
    ...
}

로그인 로직

// UserService.java

@Transactional
@RequiredArgsConstructor
@Service
public class UserService {
	...
    private final JwtTokenProvider jwtTokenProvider;
    ...
    
    // 로그인
    public LoginResponseDto login(User user) {
        String refreshToken = jwtTokenProvider.createRefreshToken(user.getId());
        authService.updateRefreshToken(user.getId(), refreshToken);

        return LoginResponseDto.from(
                jwtTokenProvider.createAccessToken(user.getId()),
                refreshToken);
    }
}

LoginResponseDto

// LoginResponseDto.java

@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class LoginResponseDto {
    @NotBlank
    private String accessToken;

    @NotBlank
    private String refreshToken;

    @Builder
    public LoginResponseDto(String accessToken, String refreshToken) {
        this.accessToken = accessToken;
        this.refreshToken = refreshToken;
    }

    public static LoginResponseDto from(String accessToken, String  refreshToken) {
        return LoginResponseDto.builder()
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .build();
    }
}

@Builder?

  • Builder 패턴 : 복합 객체의 생성 과정과 표현 방법을 분리하여 동일한 생성 절차에서 서로 다른 표현 결과를 만들 수 있게 하는 패턴.
  • 그때 그때 필요한 인자들만 생성할 수 있어 가독성이 좋다는 장점이 있다.
    (자세한 내용은 다음에 다뤄보려 한다.)

Refresh Token으로 Access Token 재발급

토큰 재발급 로직

// AuthService.java


@RequiredArgsConstructor
@Service
@PropertySource("classpath:env.properties")
public class AuthService {

    private final UserRepository userRepository;
    private final JwtTokenProvider jwtTokenProvider;
    
    ...
    // Access Token 리프레시
    public LoginResponseDto tokenRefresh(String refreshToken){
        boolean isValid = jwtTokenProvider.validateToken(refreshToken) == null;

        if (refreshToken == null || !isValid) {
            throw new BusinessException(ExceptionCode.FAIL_AUTHENTICATION);
        }

        Long userId = jwtTokenProvider.getJwtTokenPayload(refreshToken);
        String usersRefreshToken = userRepository.findRefreshTokenById(userId);

        if (!refreshToken.equals(usersRefreshToken)) {
            throw new BusinessException(ExceptionCode.FAIL_AUTHENTICATION);
        }

        String newRefreshToken = jwtTokenProvider.createRefreshToken(userId);
        updateRefreshToken(userId, newRefreshToken);

        return LoginResponseDto.from(
                jwtTokenProvider.createAccessToken(userId),
                newRefreshToken);
    }

    public void updateRefreshToken(Long userId, String refreshToken) {
        User user = userRepository.findById(userId).orElseThrow(() -> new NotFoundException());
        user.setRefreshToken(refreshToken);
        userRepository.save(user);
    }
}

Role

Role 부여

일반 회원과 관리자를 구분하기 위해 Role을 설정해 부여할 것이다.

  • 먼저, UserDetails에 authorities가 세팅되어 있어야, API별 role이나 권한 체크를 진행할 수 있다.
    -> 지난 포스팅에서 생성한 UserPrincipal.java 파일을 보자.
// UserPrincipal.java


public class UserPrincipal implements UserDetails {

    private final Long userId;
    
    // role을 저장할 변수
    private final Collection<? extends GrantedAuthority> authorities;

    public UserPrincipal(Long userId, Collection<? extends GrantedAuthority> authorities) {
        this.userId = userId;
        this.authorities = authorities;
    }

	// User의 role column을 가져와 Role(authorities) 저장
    public static UserPrincipal create(User user) {
        var authorities = Collections.singletonList(new SimpleGrantedAuthority(user.getRole().getValue()));
        return new UserPrincipal(user.getId(), authorities);
    }

    public Long getUserId() {
        return userId;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public String getPassword() {
        return null;
    }

    ...
}

Role.java

참고로 그냥 KAKAO("KAKAO").. 로 지정하면 Security에서 .hasRoleRole을 인식하지 못한다. 따라서 아래와 같이 수정해주었다.

// Role.java

@Getter
@RequiredArgsConstructor
public enum Role {
    KAKAO("ROLE_KAKAO"), // 카카오 유저
    NAVER("ROLE_NAVER"), // 네이버 유저
    ADMIN("ROLE_ADMIN"); // 관리자

    private final String value;
}

SecurityConfig에서 관리자 전용 URL 매핑

Spring Boot 2.7버전부터 WebSecurityConfigurerAdapterdeprecated 되어 아래와 같이 수정했다. (@Bean으로 등록)

// SecurityConfig.java

@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {

    private final ObjectMapper objectMapper;
    private final JwtAuthenticationFilter jwtAuthenticationFilter;

	@Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration authenticationConfiguration
    ) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }


    @Bean
    public WebSecurityCustomizer configure() {
        return (web) -> web.ignoring().mvcMatchers(
                "/v3/api-docs/**",
                "/swagger-ui/**",
                "/api/users/kakao-login",
                "/api/users/naver-login",
                "/api/auth/token-refresh"
        );
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http.csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()

                .authorizeRequests()
                // "ROLE_ADMIN" 권한을 가진 유저만 요청 가능
                .antMatchers("/api/admins/**").hasRole("ADMIN")
                .antMatchers().permitAll()
                .anyRequest().authenticated()
                .and()

                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)

                .exceptionHandling()
                .authenticationEntryPoint(((request, response, authException) -> {
                    if(request.getAttribute("exception") == ExceptionCode.TOKEN_EXPIRED){
                        response.setStatus(HttpStatus.UNAUTHORIZED.value());
                        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                        objectMapper.writeValue(
                                response.getOutputStream(),
                                ExceptionResponse.of(ExceptionCode.TOKEN_EXPIRED)
                        );
                    }
                    response.setStatus(HttpStatus.UNAUTHORIZED.value());
                    response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                    objectMapper.writeValue(
                            response.getOutputStream(),
                            ExceptionResponse.of(ExceptionCode.FAIL_AUTHENTICATION)
                    );
                }))
                .accessDeniedHandler(((request, response, accessDeniedException) -> {
                    response.setStatus(HttpStatus.FORBIDDEN.value());
                    response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                    objectMapper.writeValue(
                            response.getOutputStream(),
                            ExceptionResponse.of(ExceptionCode.FAIL_AUTHORIZATION)
                    );
                })).and().build();
    }
}
profile
BE 개발자를 꿈꾸는 학생입니다🐣

0개의 댓글