[Spring] OAuth2 + JWT 로그인 : (2) OAuth2-client 라이브러리를 사용해 JWT 다중 토큰 발급하기
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);
}
}
<코드 뜯어보기>
-
기본 OAuth2User 정보를 가져옴
OAuth2UserRequest
에서 loadUser 함수를 사용해서OAuth2User
객체의 유저 정보를 가져옴 -
클라이언트 등록 ID 확인 (카카오만 지원)
registrationId
를 확인하여 provider 마다 response body의 형태가 다르기 때문에 분리 -
사용자 정보를 OAuth2Response 객체로 변환
각 provider 에서 받아온 유저 필드를 통일하기 위해 OAuth2Response 객체를 사용해서 변환
-
DB에서 사용자 조회 또는 새로운 사용자 생성
JPA를 통해 H2 database에 저장
-
UserDTO로 변환하여 CustomOAuth2User 객체 반환
Spring Security는 인증된 사용자 정보를 SecurityContext에 저장하여 관리
SecurityContext에서 가져오는 법
@AuthenticationPrincipal
어노테이션 사용:
@GetMapping("/user") public String user(@AuthenticationPrincipal CustomOAuth2User customOAuth2User) { UserDTO userDTO = customOAuth2User.getUserDTO(); return userDTO.getEmail(); }
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()
메서드는 클래스가 생성되고 의존성 주입이 완료된 후 자동으로 호출
secretKey
→Base64
로 인코딩 →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
메서드)
userName
을 subject
로 설정하여 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 (서명):
Header
와Payload
를 합친 후, 비밀키를 사용하여 서명한 값- 서명을 통해 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());
}
선택 기준
- 첫 번째 코드 (
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이 자동 진행
- 사실 이 함수도 프론트에서 진행해도 됨, 단순 링크를 전달해주는 용이라