티스토리 뷰

스프링 부트 REST API WEB 프로젝트

깃헙 링크

https://github.com/choiwoonsik/springboot_RestApi_App_Project/tree/main/restApiSpringBootApp

수행 목록

  1. 환경구성 및 helloworld 출력
  2. H2 DB 연동
  3. Swagger API 문서 연동
  4. REST API 설계
  5. RestControllerAdvice를 이용한 통합 예외 처리
  6. Entity - DTO 분리
  7. MessageSource를 이용해 예외 메시지 다국화
  8. JPA Aduting을 이용해 객체 생성시간/수정시간 적용
  9. 스프링 시큐리티 + Jwt를 이용해서 인증 및 권한 체크
  10. 스프링 시큐리티 AuthenticationEntryPoint, AccessDenied로 인증 및 인가 예외처리
  11. Jwt AccessToken + RefreshToken으로 보안성과 사용자 편의성 고도화하기
  12. JUnit Test (단위 테스트)
  13. JUnit Test (통합 테스트)
  14. OAuth 2.0 정리
  15. OAuth 2.0 카카오 로그인 part.1 Authorization code + Token 발급
  16. OAuth 2.0 카카오 로그인 part.2 토큰으로 회원 가입 / 로그인
  17. OAuth 2.0 카카오 로그인 테스트 검증
  18. 환경별 설정을 위해서profile 분리하기 

Access Token과 Refresh Token을 둘다 사용해서 보안성도 올리면서 사용자의 편의성도 올려보도록하겠다.

 

JWT 정리글

2021.08.16 - [Web/스프링] - Json Web Token 정리

 

전체적인 로직은 아래와 같이 진행

CLIENT 1. 로그인 / 회원가입 요청 ->   SERVER
  <- 아이디, 패스워드 검증 후 AccessToken, RefreshToken을 발급
2. AccessToken을 포함해서 API 요청 ->  
  <- AccessToken 검증 후 API 응답 또는 AccessToken 만료 응답
3. 재발급 요청 Request Body (AccessToken + RefreshToken) ->  
  <- 토큰 검증 후 AccessToken 재발급 & RefreshToken 재발급

1. JWT & Security

  • Jwt
    • JwtProvider : 유저 정보로 Jwt을 발급하거나, Jwt 정보로 유저 정보를 가져온다.
    • JwtAuthenticationFilter : Spring request 앞에서 처리할 Filter
  • Spring Security
    • SecurityConfiguration : JWT Filter를 추가, 스프링 시큐리티에 필요한 설정
    • AccessDeniedHandler : 접근권한이 부족할 때 처리 -> "/exception/accessDenied"
    • CustomAuthenticationEntryPoint : 접근권한이 없을 때 처리 -> "/exception/entryPoint"

 

JwtProvider 클래스를 수정 - refresh token 내용 추가

@Slf4j
@RequiredArgsConstructor
@Component
public class JwtProvider {

    @Value("spring.jwt.secret")
    private String secretKey;
    private String ROLES = "roles";
    private final Long accessTokenValidMillisecond = 60 * 60 * 1000L; // 1 hour
    private final Long refreshTokenValidMillisecond = 14 * 24 * 60 * 60 * 1000L; // 14 day
    private final UserDetailsService userDetailsService;

    @PostConstruct
    protected void init() {
        // Base64로 인코딩
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    // Jwt 생성
    public TokenDto createTokenDto(Long userPk, List<String> roles) {

        // Claims 에 user 구분을 위한 User pk 및 authorities 목록 삽입
        Claims claims = Jwts.claims().setSubject(String.valueOf(userPk));
        claims.put(ROLES, roles);

        // 생성날짜, 만료날짜를 위한 Date
        Date now = new Date();

        String accessToken = Jwts.builder()
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + accessTokenValidMillisecond))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();

        String refreshToken = Jwts.builder()
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
                .setExpiration(new Date(now.getTime() + refreshTokenValidMillisecond))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();

        return TokenDto.builder()
                .grantType("bearer")
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .accessTokenExpireDate(accessTokenValidMillisecond)
                .build();
    }

    // Jwt 로 인증정보를 조회
    public Authentication getAuthentication(String token) {

        // Jwt 에서 claims 추출
        Claims claims = parseClaims(token);

        // 권한 정보가 없음
        if (claims.get(ROLES) == null) {
            throw new CAuthenticationEntryPointException();
        }

        UserDetails userDetails = userDetailsService.loadUserByUsername(claims.getSubject());
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    // Jwt 토큰 복호화해서 가져오기
    private Claims parseClaims(String token) {
        try {
            return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }

    // HTTP Request 의 Header 에서 Token Parsing -> "X-AUTH-TOKEN: jwt"
    public String resolveToken(HttpServletRequest request) {
        return request.getHeader("X-AUTH-TOKEN");
    }

    // jwt 의 유효성 및 만료일자 확인
    public boolean validationToken(String token) {
        try {
            Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            log.error(e.toString());
            return false;
        }
    }
}

JwtProvider에서 Jwt 생성, 유저 정보 매핑, Jwt 암호화, 복호화, 검증이 이뤄진다.

 

@Value("spring.jwt.secret")

  • 암호화키는 매우 중요하므로 따로 빼서 관리한다. git에도 올리지 않도록 주의하자.
  • application.yml에서 참고하도록 했다.
  • spring:
      profiles:
        include: jwt

init()

  • Jwt 생성 시 서명으로 사용할 secretKey를 Base64로 인코딩

createTokenDto()

  • 토큰에 저장할 유저 pk와 권한 리스트를 매개변수로 받는다.
  • pk는 setSubject로 저장하고, roles들은 key-value 형태로 넣어준다. ("roles" : {"권한1", "권한2", ...})
  • access, refresh토큰을 각각 만들어서 tokenDto로 만든 후 반환.

getAuthentication()

  • Jwt에서 권한정보를 확인하기 위해 시크릿키으로 검증 후 권한 목록을 가져온다 (만약 키에 문제가 있다면 SignatureException이 발생한다
  • claims를 토큰에서 빼온 후 권한이 있는지 확인하고 있다면 pk값을 가지고 loadUserByUsername()을 통해 유저 엔티티를 받는다.
  • User 엔티티가 UserDetails를 상속받아서 getAuthorize()를 재정의하였으므로 사용하면 된다.

parseClaims()

  • 만료된 토큰이여도 refresh token을 검증 후 재발급할 수 있도록 claims를 반환해 준다.

resolveToken()

  • Request Header에 "X-AUTH-TOKEN" 이 있으면 탈취해서 Jwt값으로 취한다.

validationToken()

  • Jwts에서 제공하는 예외처리를 이용한다.

 

JwtAuthenticationFilter 클래스

@Slf4j
public class JwtAuthenticationFilter extends GenericFilterBean {

    private final JwtProvider jwtProvider;

    public JwtAuthenticationFilter(JwtProvider jwtProvider) {
        this.jwtProvider = jwtProvider;
    }

    // request 로 들어오는 Jwt 의 유효성을 검증 - JwtProvider.validationToken() 을 필터로서 FilterChain 에 추가
    @Override
    public void doFilter(ServletRequest request,
                         ServletResponse response,
                         FilterChain filterChain) throws IOException, ServletException {

        // request 에서 token 을 취한다.
        String token = jwtProvider.resolveToken((HttpServletRequest) request);

        // 검증
        log.info("[Verifying token]");
        log.info(((HttpServletRequest) request).getRequestURL().toString());

        if (token != null && jwtProvider.validationToken(token)) {
            Authentication authentication = jwtProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        filterChain.doFilter(request, response);
    }
}

Request Header에서 토큰 쿼리값에 해당하는 value값을 통해 토큰을 가져와서 검증을 한다.

 

검증이 완료되면  토큰에서 유저 정보를 추출해 Anthentication 객체로 반환하고, 해당 정보를 SecurityContextHolder에 저장한다. 이로서 해당 유저는 Authenticate (인증)된다.

2021-08-13 13:17:48.259  INFO 68224 --- [nio-8080-exec-3] c.r.r.c.s.JwtAuthenticationFilter        : [Verifying token]
2021-08-13 13:17:48.260  INFO 68224 --- [nio-8080-exec-3] c.r.r.c.s.JwtAuthenticationFilter        : http://localhost:8080/v1/sign/signup
2021-08-13 13:17:51.737  INFO 68224 --- [nio-8080-exec-2] c.r.r.c.s.JwtAuthenticationFilter        : [Verifying token]
2021-08-13 13:17:51.738  INFO 68224 --- [nio-8080-exec-2] c.r.r.c.s.JwtAuthenticationFilter        : http://localhost:8080/v1/sign/login
2021-08-13 13:20:06.026  INFO 68224 --- [nio-8080-exec-6] c.r.r.c.s.JwtAuthenticationFilter        : [Verifying token]
2021-08-13 13:20:06.031  INFO 68224 --- [nio-8080-exec-6] c.r.r.c.s.JwtAuthenticationFilter        : http://localhost:8080/v1/users

위와같이 호출때마다 토큰검증이 이뤄진다.

 

SecurityConfiguration 클래스 (수정 x)

@RequiredArgsConstructor
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    private final JwtProvider jwtProvider;
    private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
    private final CustomAccessDeniedHandler customAccessDeniedHandler;

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .httpBasic().disable()
                .csrf().disable()

                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                .and()
                .authorizeRequests()
                .antMatchers("/*/login", "/*/signup").permitAll()
                .antMatchers(HttpMethod.GET, "/exception/**").permitAll()
                .anyRequest().hasRole("USER")


                .and()
                .exceptionHandling()
                .authenticationEntryPoint(customAuthenticationEntryPoint)
                .accessDeniedHandler(customAccessDeniedHandler)

                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class);
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/v2/api-docs", "/swagger-resources/**",
                "/swagger-ui.html", "/webjars/**", "/swagger/**");
    }
}

Spring Security를 위한 설정 + JWT 설정등이 이뤄진다.

 

AuthenticationEntryPointException & AccessDeniedException을 처리하기 위한 Controller (수정 x)

@RequiredArgsConstructor
@RestController
@RequestMapping("/exception")
public class ExceptionController {

    @GetMapping("/entryPoint")
    public CommonResult entrypointException() {
        throw new CAuthenticationEntryPointException();
    }

    @GetMapping("/accessDenied")
    public CommonResult accessDeniedException() {
        throw new AccessDeniedException("");
    }
}

2. Refresh Token 작업

Refresh Token Entity 생성

@Entity
@Table(name = "refresh_token")
@Getter
@NoArgsConstructor
public class RefreshToken extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private String id;

    @Column(nullable = false)
    private Long key;

    @Column(nullable = false)
    private String token;

    public RefreshToken updateToken(String token) {
        this.token = token;
        return this;
    }

    @Builder
    public RefreshToken(Long key, String token) {
        this.key = key;
        this.token = token;
    }
}

token이 DB에 저장되는 index로서 Id를 지정해주고 객체를 가져오기 위한 용도로서 key값은 따로 만들어준다. 토큰의 claims의 값은 공개값이므로 유저를 특정할 수 없는 userPk값을 key값으로 해준다.

 

BasetimeEntity는 ceatedDate, modifedDate가 존재하므로 상속받아서 추후 expire 시간과 비교해서 만료시켜준다.

 

Refresh Token Jpa Repo 생성

public interface RefreshTokenJpaRepo extends JpaRepository<RefreshToken, String> {

    Optional<RefreshToken> findByKey(Long key);
}

 

TokenDto 생성

@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class tokenDto {
    private String grantType;
    private String accessToken;
    private String refreshToken;
    private Long accessTokenExpireDate;
}

 

TokenRequestDto 생성

@Getter
@Setter
@NoArgsConstructor
public class TokenRequestDto {
    String accessToken;
    String refreshToken;

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

3. 사용자 인증 시 Jwt 토큰 발급 처리

실제 로그인 / 회원가입 시 Jwt (access, refresh)를 발급하도록 하자.

SignController

@Api(tags = {"1. SignUp/LogIn"})
@RequiredArgsConstructor
@RestController
@RequestMapping("/v1/sign")
public class SignController {

    private final securityService securityService;
    private final ResponseService responseService;

    @ApiOperation(value = "로그인", notes = "이메일로 로그인을 합니다.")
    @PostMapping("/login")
    public SingleResult<TokenDto> login(
            @ApiParam(value = "로그인 요청 DTO", required = true)
            @RequestBody UserLoginRequestDto userLoginRequestDto) {

        TokenDto tokenDto = securityService.login(userLoginRequestDto);
        return responseService.getSingleResult(tokenDto);
    }

    @ApiOperation(value = "회원가입", notes = "회원가입을 합니다.")
    @PostMapping("/signup")
    public SingleResult<Long> signup(
            @ApiParam(value = "회원 가입 요청 DTO", required = true)
            @RequestBody UserSignupRequestDto userSignupRequestDto) {
        Long signupId = securityService.signup(userSignupRequestDto);
        return responseService.getSingleResult(signupId);
    }

    @ApiOperation(
            value = "액세스, 리프레시 토큰 재발급",
            notes = "엑세스 토큰 만료시 회원 검증 후 리프레쉬 토큰을 검증해서 액세스 토큰과 리프레시 토큰을 재발급합니다.")
    @PostMapping("/reissue")
    public SingleResult<TokenDto> reissue(
            @ApiParam(value = "토큰 재발급 요청 DTO", required = true)
            @RequestBody TokenRequestDto tokenRequestDto) {
        return responseService.getSingleResult(securityService.reissue(tokenRequestDto));
    }
}

로그인, 회원가입, 재발급 요청은 모두 RequestBody로 이뤄진다.

검증 로직은 SecurityService에서 한다.

 

SecurityService

@Slf4j
@Service
@RequiredArgsConstructor
public class securityService {
    private final UserJpaRepo userJpaRepo;
    private final PasswordEncoder passwordEncoder;
    private final JwtProvider jwtProvider;
    private final RefreshTokenJpaRepo tokenJpaRepo;

    @Transactional
    public TokenDto login(UserLoginRequestDto userLoginRequestDto) {

        // 회원 정보 존재하는지 확인
        User user = userJpaRepo.findByEmail(userLoginRequestDto.getEmail())
                .orElseThrow(CEmailLoginFailedException::new);

        // 회원 패스워드 일치 여부 확인
        if (!passwordEncoder.matches(userLoginRequestDto.getPassword(), user.getPassword()))
            throw new CEmailLoginFailedException();

        // AccessToken, RefreshToken 발급
        TokenDto tokenDto = jwtProvider.createTokenDto(user.getUserId(), user.getRoles());

        // RefreshToken 저장
        RefreshToken refreshToken = RefreshToken.builder()
                .key(user.getUserId())
                .token(tokenDto.getRefreshToken())
                .build();
        tokenJpaRepo.save(refreshToken);
        return tokenDto;
    }

    @Transactional
    public Long signup(UserSignupRequestDto userSignupDto) {
        if (userJpaRepo.findByEmail(userSignupDto.getEmail()).isPresent())
            throw new CEmailSignupFailedException();
        return userJpaRepo.save(userSignupDto.toEntity(passwordEncoder)).getUserId();
    }

    @Transactional
    public TokenDto reissue(TokenRequestDto tokenRequestDto) {
        // 만료된 refresh token 에러
        if (!jwtProvider.validationToken(tokenRequestDto.getRefreshToken())) {
            throw new CRefreshTokenException();
        }

        // AccessToken 에서 Username (pk) 가져오기
        String accessToken = tokenRequestDto.getAccessToken();
        Authentication authentication = jwtProvider.getAuthentication(accessToken);

        // user pk로 유저 검색 / repo 에 저장된 Refresh Token 이 없음
        User user = userJpaRepo.findById(Long.parseLong(authentication.getName()))
                .orElseThrow(CUserNotFoundException::new);
        RefreshToken refreshToken = tokenJpaRepo.findByKey(user.getUserId())
                .orElseThrow(CRefreshTokenException::new);

        // 리프레시 토큰 불일치 에러
        if (!refreshToken.getToken().equals(tokenRequestDto.getRefreshToken()))
            throw new CRefreshTokenException();

        // AccessToken, RefreshToken 토큰 재발급, 리프레쉬 토큰 저장
        TokenDto newCreatedToken = jwtProvider.createTokenDto(user.getUserId(), user.getRoles());
        RefreshToken updateRefreshToken = refreshToken.updateToken(newCreatedToken.getRefreshToken());
        tokenJpaRepo.save(updateRefreshToken);

        return newCreatedToken;
    }
}

로그인

  • 유저가 이메일, 패스워드가 담긴 UserLoginRequestDto로 로그인을 시도하면
    1. 회원이 존재하는지 확인
    2. DB의 패스워드와 전달한 패스워드 일치여부 확인
  • 위 로직을 통과하면 AccessToken & RefreshToken (TokenDto)을 생성해서 발급해준다
  • 액세스 토큰에 유저의 Pk와 권한을 넣어준다
    • 이때 Pk값은 외부에서 유저를 특정할 수 없는 값으로 하는것이 좋다.
  • 리프레시 토큰은 리프레시 토큰 저장소에 저장해준다. 유저의 pk값을 키값으로 한다
    • 추후 액세스 토큰만료시 검증용도로 사용한다

회원가입

  • UserSignupRequestDto로 회원가입을 요청하는 회원의 아이디와 패스워드값을 받는다.
  • 이미 존재하는 아이디가 아니라면 저장시켜준다.
  • 여기서는 토큰을 발급하지 않는다.

재발급

  • TokenRequestDto을 통해 액세스 토큰 재발급을 요청한다
    1. 리프레시 토큰의 만료를 검증한다. -> 만료시 재로그인 요청
    2. 액세스 토큰으로 Authentication 유저를 찾는다. authentication.getName()은 User객체에서 오버라이드한 메소드로 반환값은 DB에 저장되는 인덱스값이자 ID인 userId이다.
      • 이메일같은 값으로 하면 토큰의 클레임에서 볼수 있으므로 유저를 특정할 수 있다. 따라서 외부에서 유저를 특정할 수없는 db의 id값으로 취한다.
    3. 리프레시 토큰 저장소에서 유저의 pk값으로 리프레시 토큰을 찾는다 -> 없다면 재로그인 요청
    4. 전달받은 리프레시 토큰과 DB에 저장되있던 리프레시 토큰을 비교한다 -> 다르다면 재로그인 요청
    5. 위 로직이 통과되면 새롭게 액세스 토큰과 리프레시 토큰을 발급한다
      • 유저의 리프레시 토큰값을 변경하고, 리프레시 토큰을 다시 저장소에 저장한다. DB가 더티체크를 통해 저장되있는 userPk-리프레시 토큰값을 바꿔준다.
  • 새로 발급한 TokenDto를 반환한다.

 

UserDetailsService

@RequiredArgsConstructor
@Service
public class CustomUserDetailsService implements UserDetailsService {

    private final UserJpaRepo userJpaRepo;

    @Override
    @Transactional
    public UserDetails loadUserByUsername(String userPk) throws UsernameNotFoundException {
        return userJpaRepo.findById(Long.parseLong(userPk))
                .orElseThrow(CUserNotFoundException::new);
    }
}

 

User 엔티티는 UserDetails을 (implemetns)상속받아서 구현하고 있다. UserDetailsService는 UserDetails 객체를 반환하는 loadUserByUsername() 메소드를 갖고 있다.

 

loadUserByUsername()메소드를 오버라이드한다. 전달받은 userPk값을 통해 userJpaRepo에서 유저를 찾아서 반환해준다.

 

loadUserByUsername()

loadUserByUsername 메소드는 다양한 곳에서 사용되고 있는데 한번 추적해보자.

 

DaoAuthenticationProvider 클래스는 AbstractUserDetailsAuthenticationProvider를 implements받아서 구현하는 클래스이며 retrieveUser()에서 사용한다.

 

retrieveUser에서 loadUserByUsername()을 통해 받은 유저를 반환하고 있다. 해당 유저를 additionalAuthenticationChecks()에서 검증한다.

 

additionalAuthenticationChecks()DaoAuthenticationProvider 클래스에서 재정의해서 사용하고 있다. 여기서 authentication한 유저의 패스워드와 loadUserByUsername() -> retrieveUser()를 거쳐서 전달받은 유저의 패스워드를 비교한다.

 

additionalAuthenticationChecks()authenticate()에서 사용한다. authenticate()는 지금까지 사용한 적이 없는데 어디서 사용할까?

authenticate()는 AuthenticationProvider 인터페이스의 구현체였다. 해당 메소드를 구현하는 곳이 ProviderManager클래스이다.


4. 테스트

회원가입

 

로그인

재발급

재발급 후 유저검색

 

반응형
Comments
반응형
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday