[Spring] OAuth2 + JWT 로그인 : (2) OAuth2-client 라이브러리를 사용해 JWT 다중 토큰 발급하기

8 분 소요

3. OAuth2 사용자 서비스 구현

  • CustomOAuth2UserService를 생성하여 카카오에서 받은 사용자 정보를 처리한다.
    • CustomOAuth2UserService: OAuth 2.0 로그인 시, 사용자 정보를 처리하는 서비스 사용자 정보를 기반으로 애플리케이션의 사용자 정보와 매핑하거나 새로운 사용자를 등록할 때 사용
    • 사용자가 소셜 로그인을 시도하면 CustomOAuth2UserService가 호출되어 사용자의 정보를 처리하고, 로그인 프로세스를 완료. 이를 통해 사용자 정보가 자동으로 데이터베이스에 저장되거나 기존 정보가 조회되며, 개발자는 별도로 사용자 관리를 구현할 필요가 없다.
      • 자동 실행 시점: 사용자가 소셜 로그인을 통해 인증을 시도하고, 인증에 성공하여 OAuth 2.0 제공자로부터 Access Token을 받은 후에 실행

CustomOAuth2UserService를 사용할 때의 장점

  • 인증 흐름에 최적화: OAuth2 인증과 사용자의 데이터베이스 정보 처리가 통합적으로 관리됩니다.
  • 자동화된 사용자 관리: 사용자 정보의 자동 저장 및 조회가 가능하여 개발자가 별도의 로직을 작성할 필요가 없습니다.
  • 보안 및 유지보수성 향상: Spring Security와의 연동을 통해 보안 설정이 일관되게 유지되고, 코드가 간결해집니다.
  • 예외 처리와 세션 관리의 간편화: OAuth2 인증 중 발생할 수 있는 예외를 간편하게 처리하고, 인증 후 세션 관리도 자동으로 처리됩니다.


/login/service/CustomOAuth2UserService.java 코드
@Slf4j
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;

    /**
     * 사용자가 OAuth 2.0 공급자에서 인증을 마친 후, 해당 사용자의 정보를 가져오는 역할
     */
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);

        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        OAuth2Response oAuth2Response = null;
        if (registrationId.equals("kakao")) {
            // 사용자 정보를 가져옴
            oAuth2Response = new OAuth2Response(oAuth2User.getAttributes(), "USER");
        } else {
            throw new OAuth2AuthenticationException("잘못된 registration id 입니다.");
        }

        OAuth2Response finalOAuth2Response = oAuth2Response;
        User user = userRepository.findByEmail(oAuth2Response.getEmail())
                .orElseGet(() -> createUser(finalOAuth2Response));

        UserDTO userDTO = new UserDTO();
        userDTO.setNickname(user.getNickname());
        userDTO.setEmail(user.getEmail());
        userDTO.setProvider(user.getProvider());
        userDTO.setRole(user.getRole().toString());

        return new CustomOAuth2User(userDTO);
    }

    private User createUser(OAuth2Response oAuth2Response) {
        User user = new User();
        user.setEmail(oAuth2Response.getEmail());
        user.setNickname(oAuth2Response.getNickName());
        if(Objects.equals(oAuth2Response.getRole(), "ADMIN")) {
            user.setRole(RoleType.ADMIN);
        } else {
            user.setRole(RoleType.USER);
        }
        user.setProvider(ProviderType.KAKAO);
        user.setProfileImage(oAuth2Response.getProfileImage());
        return userRepository.save(user);
    }
}

<코드 뜯어보기>

  1. 기본 OAuth2User 정보를 가져옴

    OAuth2UserRequest 에서 loadUser 함수를 사용해서 OAuth2User 객체의 유저 정보를 가져옴

  2. 클라이언트 등록 ID 확인 (카카오만 지원)

    registrationId 를 확인하여 provider 마다 response body의 형태가 다르기 때문에 분리

  3. 사용자 정보를 OAuth2Response 객체로 변환

    각 provider 에서 받아온 유저 필드를 통일하기 위해 OAuth2Response 객체를 사용해서 변환

  4. DB에서 사용자 조회 또는 새로운 사용자 생성

    JPA를 통해 H2 database에 저장

  5. UserDTO로 변환하여 CustomOAuth2User 객체 반환

    Spring Security는 인증된 사용자 정보를 SecurityContext에 저장하여 관리

    SecurityContext에서 가져오는 법

    1. @AuthenticationPrincipal 어노테이션 사용:
     @GetMapping("/user")
     public String user(@AuthenticationPrincipal CustomOAuth2User customOAuth2User) {
         UserDTO userDTO = customOAuth2User.getUserDTO();
         return userDTO.getEmail(); 
     }
    
    1. Principal 파라미터로 직접 받기:
     @GetMapping("/info")
     public String userInfo(Principal principal) {
         CustomOAuth2User customOAuth2User = (CustomOAuth2User) ((Authentication) principal).getPrincipal();
         UserDTO userDTO = customOAuth2User.getUserDTO();
         return userDTO.getEmail();
     }
    

4. JWT 구현

  • JwtTokenProvider 클래스를 생성하여 JWT 생성 및 검증 로직을 구현합니다.
    • JwtTokenProvider: JWT 토큰의 생성, 검증 및 기타 관련 작업을 수행하는 클래스입니다. 인증 필터와 함께 사용하여 JWT 기반 인증을 처리합니다.
  • Access Token과 Refresh Token을 생성하고 관리하는 메서드를 추가합니다.


/login/security/JwtTokenProvider.java 코드
@Component
@Slf4j
public class JwtTokenProvider {

    @Value("${jwt.secret}")
    private String secretKey;

    @Value("${jwt.access-token-validity}")
    private long accessTokenValidity;

    @Value("${jwt.refresh-token-validity}")
    private long refreshTokenValidity;

    private Key key;
    private final static int BEARERTOKEN_START_NUMBER = 7;

    @PostConstruct
    protected void init() {
        this.key = hmacShaKeyFor(Base64.getEncoder().encode(secretKey.getBytes()));
    }

    /**
     * JWT Access Token 생성
     */
    public String createAccessToken(String userEmail, Collection<? extends GrantedAuthority> roles) {
        Claims claims = Jwts.claims().setSubject(userEmail);
        claims.put("roles", roles.stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList()));

        Date now = new Date();
        Date validity = new Date(now.getTime() + accessTokenValidity);

        String token = Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(validity)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
        return token;
    }

    /**
     * JWT Refresh Token 생성
     */
    public String createRefreshToken(String userEmail) {
        Date now = new Date();
        Date validity = new Date(now.getTime() + refreshTokenValidity);

        return Jwts.builder()
                .setSubject(userEmail)
                .setIssuedAt(now)
                .setExpiration(validity)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }

    /**
     * JWT 토큰에서 인증 정보 조회
     */
    public Authentication getAuthentication(String token) {
        UserDetails userDetails = new User(getUserEmail(token), "", getRoles(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    /**
     * JWT 토큰에서 권한 정보 추출
     */
    public Collection<? extends GrantedAuthority> getRoles(String token) {
        Claims claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
        List<String> roles = claims.get("roles", List.class);
        return roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
    }

    /**
     * JWT 토큰에서 사용자 이메일 추출
     */
    public String getUserEmail(String token) {
        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getSubject();
    }

    /**
     * JWT 토큰 유효성 검증
     */
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    /**
     * JWT 토큰의 만료 시간 확인
     */
    public boolean isTokenExpired(String token) {
        try {
            Date expiration = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getExpiration();
            return expiration.before(new Date()); //기한 날짜가 현재 날짜보다 전이면 true, 아니면 false
        } catch (Exception e) {
            return true;
        }
    }

    /**
     * Request Header에서 토큰 가져오기
     */
    public String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(BEARERTOKEN_START_NUMBER);
        }
        return null;
    }
}

<코드 뜯어보기>

클래스 레벨 구성 및 필드 초기화

  • secretKey: JWT 서명에 사용할 비밀키
  • accessTokenValidity: Access Token의 유효 시간 (1800000ms ⇒ 30분)
  • refreshTokenValidity: Refresh Token의 유효 시간 (604800000ms ⇒ 7일)
  • key: JWT 서명 및 검증에 사용할 Key 객체, secretKey 값을 기반으로 초기화

클래스 초기화 (@PostConstruct)

@PostConstruct: init() 메서드는 클래스가 생성되고 의존성 주입이 완료된 후 자동으로 호출

  • secretKeyBase64로 인코딩 → Key 객체로 변환(hmacShaKeyFor()) → key 필드 할당

JWT Access Token 생성 (createAccessToken 메서드)

JWT의 Claims 객체에 subject(사용자 이름)와 roles(사용자 권한)를 추가하여 JWT의 페이로드를 구성

  • setIssuedAt(now): 토큰의 발급 시간 설정
  • setExpiration(validity): 토큰의 만료 시간 설정
  • signWith(key, SignatureAlgorithm.HS256): key 객체를 통해 HS256 알고리즘으로 서명
  • compact(): 최종적으로 JWT 문자열을 생성하여 반환

JWT Refresh Token 생성 (createRefreshToken 메서드)

userNamesubject로 설정하여 Refresh Token을 생성

roles와 같은 추가 정보는 포함되지 않습니다.

  • 자원에 접근하는 데 사용되지 않기 때문에 용도가 다르다.

JWT 토큰에서 정보 가져오는 법

Jwts.*parserBuilder*().setSigningKey(key).build().parseClaimsJws(token).getBody()

Claim claims = Jwts.*parserBuilder*().setSigningKey(key).build().parseClaimsJws(token).getBody();
  • 유효하지 않을 때는 에러가 발생 ⇒ 에러 처리
  • roles(List) 가져오기: `claims.get("roles", List.class)`
  • username(String) 가져오기: claims.getSubject()
  • expiration(Date) 가져오기: claims.getExpiration()

<알아보기>

JWT 구조

JWT (Json Web Token)은 사용자 인증과 정보를 안전하게 전달하기 위해 널리 사용되는 토큰 형식

JWT는 세 부분으로 나뉘며, 이 세 부분은 마침표(.)로 구분된다.

  • Header (헤더)
  • Payload (페이로드)
  • Signature (서명)
<Header>.<Payload>.<Signature>
ex) eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Header (헤더):

  • 토큰의 타입 (typ)과 서명 알고리즘 (alg) 정보를 포함
  • 예시:

      {
        "typ": "JWT",
        "alg": "HS256"
      }
    
  • Base64URL로 인코딩된 후, JWT의 첫 번째 부분이 된다.

Payload (페이로드):

  • JWT의 본문 부분으로, 사용자 정보나 토큰의 클레임(claims) 등을 포함
  • Payload는 주로 사용자 정보나 토큰의 부가적인 데이터가 저장되는 부분으로, 보안이 중요한 데이터는 담지 않는다.
    • 누구나 Base64로 인코딩된 JWT를 디코딩하여 내용을 볼 수 있다. → 비밀 데이터(예: 비밀번호, 금융 정보 등)를 저장하면 안 된다.
    • 페이로드의 내용을 보호하려면 JWE (JSON Web Encryption)를 사용하여 JWT 자체를 암호화하는 것이 좋습니다.
  • 예시:

      {
        "sub": "1234567890",
        "name": "John Doe",
        "iat": 1516239022,
        "roles": ["ROLE_USER"]
      }
    
  • Base64URL로 인코딩된 후, JWT의 두 번째 부분이 된다.
    • iss (Issuer): 토큰 발급자 (예: auth-server)
    • sub (Subject): 토큰의 주체 (예: 사용자 ID)
    • aud (Audience): 토큰의 대상 (예: my-app)
    • exp (Expiration): 토큰의 만료 시간 (Unix Timestamp)
    • nbf (Not Before): 토큰의 활성화 시간 (Unix Timestamp)
    • iat (Issued At): 토큰의 발급 시간 (Unix Timestamp)
    • jti (JWT ID): JWT의 고유 식별자

Signature (서명):

  • HeaderPayload를 합친 후, 비밀키를 사용하여 서명한 값
  • 서명을 통해 JWT의 무결성을 확인할 수 있으며, 클라이언트가 임의로 데이터를 변조할 수 없도록 보장
  • 예시: HMAC SHA256 알고리즘을 사용한 서명 방식 ⇒ HS256

      HMACSHA256(
        base64UrlEncode(header) + "." + base64UrlEncode(payload),
        secret
      )
    

<코드 차이 알아보기>

@Value("${jwt.secret}")
private String secretKey;

private Key key;

@PostConstruct
protected void init() {
    this.key = hmacShaKeyFor(Base64.getEncoder().encode(secretKey.getBytes()));
}

private SecretKey secretKey;

public JWTUtil(@Value("jwt.secret") String secret) {
    secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
}

image

선택 기준

  • 첫 번째 코드 (hmacShaKeyFor 사용):
    • JJWT 라이브러리에서 제공하는 유틸리티 메서드를 사용하는 방식으로, Base64로 인코딩된 키를 생성하여 더 간편하게 사용. JWT 라이브러리와의 일관성이 높다.
    • Spring의 생명주기에 따라 키가 초기화되므로 빈 초기화 시점에 키가 세팅된다.
  • 두 번째 코드 (SecretKeySpec 사용):
    • 표준 자바의 암호화 관련 클래스(SecretKeySpec)를 사용한 방식으로, JJWT 라이브러리의 유틸리티 메서드를 사용하지 않고도 JWT 서명을 위한 키를 만들 수 있다.
    • 더 표준적인 자바 암호화 클래스를 사용하므로, JWT 라이브러리 외의 다른 암호화 관련 작업에서도 사용할 수 있습니다.

5. 인증 컨트롤러 구현

  • AuthController를 생성하여 로그인 및 토큰 갱신 엔드포인트를 구현합니다.
login/controller/AuthController.java 코드
@Slf4j
@RestController
@RequestMapping("/oauth2/authorization")
@RequiredArgsConstructor
public class AuthController {

    private final AuthService authService;

/**
     *카카오 로그인URL로 리디렉션
*/
@GetMapping("/kakao")
    public ResponseEntity<Void> redirectKakaoLogin() {
        String kakaoLoginUrl = authService.getKakaoLoginUrl();
        return ResponseEntity.status(HttpStatus.FOUND).location(URI.create(kakaoLoginUrl)).build();
    }

/**
     *사용자 정보 업데이트(Access Token사용)
     */
@PatchMapping("/update")
    public ResponseEntity<String> updateUserInfo(
            @RequestHeader("Authorization") String accessToken,
            @RequestBody UpdateUserInfoRequest request) {

        try {
            // "Bearer " 제거하고 토큰만 전달
            accessToken = accessToken.replace("Bearer ", "");
            authService.updateUserInfo(accessToken, request);
            return ResponseEntity.ok("정보 업데이트 성공");
        } catch (Exception e) {
log.error("정보 업데이트 실패: {}", e.getMessage());
            return ResponseEntity.status(500).body("정보 업데이트 실패");
        }
    }

/**
     * Refresh Token을 사용하여JWT Access Token갱신
*/
@PostMapping("/refresh")
    public ResponseEntity<TokenResponseDto> refreshToken(HttpServletResponse response, @RequestBody TokenRefreshRequest request) {
        try {
            TokenResponseDto tokenResponse = authService.refreshJwtTokens(response, request.getRefreshToken());
            return ResponseEntity.ok(tokenResponse);
        } catch (Exception e) {
log.error("토큰 갱신 실패: {}", e.getMessage());
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null);
        }
    }
}
login/service/AuthService.java 코드
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {

    private final UserRepository userRepository;
    private final JwtTokenProvider jwtTokenProvider;
    private final RedisService redisService;

    @Value("${spring.security.oauth2.client.registration.kakao.client-id}")
    private String kakaoClientId;

    @Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}")
    private String kakaoRedirectUri;

    @Value("${spring.security.oauth2.client.provider.kakao.authorization-uri}")
    private String kakaoAuthBaseUrl;

/**
     *카카오 로그인URL생성
*/
public String getKakaoLoginUrl() {
        return kakaoAuthBaseUrl + "?response_type=code&client_id="
                + kakaoClientId + "&redirect_uri=" + kakaoRedirectUri;
    }

/**
     *현재 인증된 사용자를 기준으로JWT Access Token및Refresh Token생성
*/
public TokenResponseDto createJwtTokens(Authentication authentication) {

        if (authentication == null || !(authentication.getPrincipal() instanceof OAuth2User)) {
            throw new RuntimeException("OAuth2 사용자 정보가 없습니다.");
        }
        CustomOAuth2User customUserDetails = (CustomOAuth2User) authentication.getPrincipal();
        String email = customUserDetails.getEmail();

        Optional<User> userOptional = userRepository.findByEmail(email);
        if (userOptional.isEmpty()) {
            throw new RuntimeException("사용자 정보를 찾을 수 없습니다.");
        }

        User user = userOptional.get();

        // JWT Access Token 및 Refresh Token 생성
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();

        String accessToken = jwtTokenProvider.createAccessToken(user.getEmail(), authorities);
        String refreshToken = jwtTokenProvider.createRefreshToken(user.getEmail());

        return new TokenResponseDto(accessToken, refreshToken, user);
    }

/**
     * Refresh Token을 사용하여 새로운Access Token발급
*/
public TokenResponseDto refreshJwtTokens(HttpServletResponse response, String refreshToken) {
        String email = jwtTokenProvider.getUserEmail(refreshToken);
        // Refresh Token이 Redis에 존재하는지 확인
        String storedRefreshToken = redisService.getRefreshToken(email);

        if (storedRefreshToken == null || !storedRefreshToken.equals(refreshToken)) {
            //refresh token도 재생성 -> 하면 안됨 -> 탈취된 토큰을 가져왔을 때 재생성됨 -> 로그인창으로 보내서 새로 로그인하게 만들게 하면 refresh token은 알아서 생성됨
            if (!storedRefreshToken.equals(refreshToken)) {
                redisService.deleteRefreshToken(storedRefreshToken);
            }
            throw new IllegalArgumentException("Refresh Token이 유효하지 않거나 만료되었습니다.");
        }
        String newAccessToken = jwtTokenProvider.createAccessToken(email, jwtTokenProvider.getRoles(refreshToken));
        String newRefreshToken = jwtTokenProvider.createRefreshToken(email);

        redisService.deleteRefreshToken(storedRefreshToken);
        redisService.saveRefreshToken(email, newRefreshToken, Duration.ofDays(7));

        response.setHeader("access", newAccessToken);
        response.addCookie(createCookie("refresh", newRefreshToken, Duration.ofDays(7)));
        return new TokenResponseDto(newAccessToken, newRefreshToken, null);
    }

    public void updateUserInfo(String accessToken, UpdateUserInfoRequest request) {
        String email = jwtTokenProvider.getUserEmail(accessToken);
        User user = userRepository.findByEmail(email)
                .orElseThrow(() -> new IllegalArgumentException("해당 사용자를 찾을 수 없습니다."));

        user.setAccount(request.getAccount());
        user.setRealname(request.getRealname());
        userRepository.save(user);
    }

    private Cookie createCookie(String key, String value, Duration duration) {

        Cookie cookie = new Cookie(key, value);
        cookie.setMaxAge((int) duration.getSeconds());
        //cookie.setSecure(true); https
        cookie.setPath("/");
        cookie.setHttpOnly(true);

        return cookie;
    }
}
  • layered architecture에 맞게 controller는 Presentation layer, service는 Business layer로 구분
  • redirectKakaoLogin : 카카오 로그인 버튼을 누를시 url을 넘겨주는 함수
    • 이 함수만으로 login이 자동 진행
    • 사실 이 함수도 프론트에서 진행해도 됨, 단순 링크를 전달해주는 용이라

카테고리:

업데이트: