이전 편에서는 OAuth 개념과 네이버, 카카오 로그인 앱 등록을 알아보았어요.
이번 편은 java, springboot를 활용해서 구현하는 방법을 진행하려 해요.
그 전에 설계 목적을 정리했어요.
위의 목적을 바탕으로 구현을 진행했어요.
백엔드가 구현해야 할 부분은 다음과 같아요.
이 부분은 추가적인 블로그 글로 작성해보려 해요.
이 내용들은 이 글을 다 읽고 다시 보면 더 좋을 것 같아요 !!
위 처럼 추상화를 하기 위해 인터페이스를 많이 사용했는데 다음과 같은 장점이 있습니다.
jwt:
secret-key: Z29nby10bS1zZXJ2ZXItZGxyamVvYW9yb3JodG9kZ290c3Atam9vbmdhbmduaW0teWVvbHNpbWhpaGFswvuqoxyve
oauth:
kakao:
client-id: 54741f54e8f9f560bd898abbbc955c6d
url:
auth: https://kauth.kakao.com
api: https://kapi.kakao.com
naver:
client-id: MyHoVO2X8kk7l9O3xpjF
client-secret: ETZvY5VEs_
url:
auth: https://nid.naver.com
api: https://openapi.naver.com
secret-key: JWT 토큰 생성을 위해 필요하고 외부에 노출되지 않게 해야 합니다.
(위에 드러난 secret-key는 임시에요)
각자 본인이 등록한 Client ID 를 사용해야 합니다. (게시글 작성 후 앱을 삭제했어요)
위 과정에서 http 요청을 보내야 하기에 RestTemplate을 스프링 빈으로 등록했어요.
@Configuration
public class ClientConfig {
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
@Getter
@NoArgsConstructor
public class User {
private String email;
private String password;
private OAuthProvider provider;
@Builder
public User(
int id,
String email,
String password,
OAuthProvider provider,
// ...
) {
this.email = email;
this.password = password;
this.provider = provider;
// ...
}
}
회원 정보를 담은 유저 클래스에요.
프로젝트를 첫 설계할 때 닉네임은 필요하지 않다고 생각하여 필드에 넣지 않았어요.
OAuthProvider는 어떤 플랫폼을 통해 로그인했는지를 알아내는 클래스로 인증 제공자의 역할이에요.
package com.jinddung2.givemeticon.oauth.domain.oauth;
public enum OAuthProvider {
KAKAO, NAVER;
}
외부 API 역할을 하는 client를 만들거에요. 다만, api를 요청하는 RestTemplate, WebClient, Apache HttpClient 등이 있지만, Spring Boot 자동 설정에 포함되어 있고 동기이면서 간단하게 API요청을 보낼 수 있는 RestTemplate을 사용했어요.
API 요청과 관련된 클래스와 역할은 다음과 같아요.
OAuthLoginParams
: OAuth 플랫폼 요청에 필요한 데이터를 갖고 있는 파라미터 (인터페이스)KakaoTokens
, NaverTokens
: OAuth 플랫폼에서 응답해준 데이터로 인증 API 역할OAuthUserInfo
: 회원 정보 API 응답 (인터페이스)OAuthClient
: OAuth 플랫폼 API 요청 후 응답값을 리턴해주는 (인터페이스)RequestOAuthInfoService
: 외부 API 요청의 중복되는 로직을 공통화한 클래스public interface OAuthLoginParams {
OAuthProvider oAuthProvider();
MultiValueMap<String, String> makeBody();
}
OAuth 요청을 위한 즉, OAuth 플랫폼에 요청하기 위한 파라미터 값들을 담는 역할을 하는 메서드들을 추상화하기 위해 만들었어요.
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class KakaoLoginParam implements OAuthLoginParams {
private String authorizationCode;
@Override
public OAuthProvider oAuthProvider() {
return OAuthProvider.KAKAO;
}
@Override
public MultiValueMap<String, String> makeBody() {
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("code", authorizationCode);
return body;
}
}
카카오 API 요청에는 authorizationCode
를 필요로 해요.
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class NaverLoginParam implements OAuthLoginParams {
private String authorizationCode;
private String state;
@Override
public OAuthProvider oAuthProvider() {
return OAuthProvider.NAVER;
}
@Override
public MultiValueMap<String, String> makeBody() {
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("code", authorizationCode);
body.add("state", state);
return body;
}
}
네이버 API 요청에는 authorizationCode
와 state
를 필요로 해요.
Authorization Code를 기반으로 타 플랫폼 Access Token을 받아오기 위한 Response Model이에요.
여러 가지 값을 받아오지만 여기서 사용할 부분은 Access Token 뿐이죠.
public record KaKaoToken(
@JsonProperty("token_type")
String tokenType,
@JsonProperty("access_token")
String accessToken,
@JsonProperty("expires_in")
String expiresIn,
@JsonProperty("refresh_token")
String refreshToken,
@JsonProperty("refresh_token_expires_in")
String refreshTokenExpiresIn,
@JsonProperty("scope")
String scope
) {
}
Kakao Developers - 카카오 로그인 토큰 받기 의 응답값 부분을 참고하여 만들었고, https://kauth.kakao.com/oauth/token
요청에 대한 응답 데이터에요.
public record NaverToken(
@JsonProperty("access_token")
String accessToken,
@JsonProperty("refresh_token")
String refreshToken,
@JsonProperty("token_type")
String tokenType,
@JsonProperty("expires_in")
String expiresIn
) {
}
Naver Developers - 로그인 API 명세의 접근 토큰 발급 요청 응답값 부분을 참고하여 만들었고, https://nid.naver.com/oauth2.0/token
요청에 대한 응답 데이터에요.
public interface OAuthUserInfo {
String getEmail();
OAuthProvider getOAuthProvider();
}
Access Token으로 요청한 외부 API 프로필 응답값을 사용하기 위해 우리 서비스에서 데이터를 받는 역할을 하는 인터페이스에요.
OAuth 플랫폼의 email 정보를 필요로 하기 때문에 getXXX
네이밍의 메서드들이 있는 공간이에요.
@Getter
@JsonIgnoreProperties(ignoreUnknown = true)
public class KaKaoUserInfo implements OAuthUserInfo {
@JsonProperty("kakao_account")
private KakaouAccount kakaouAccount;
@JsonIgnoreProperties(ignoreUnknown = true)
record KakaouAccount(String email) {
}
@Override
public String getEmail() {
return kakaouAccount.email;
}
@Override
public OAuthProvider getOAuthProvider() {
return OAuthProvider.KAKAO;
}
}
https://kapi.kakao.com/v2/user/me
에 요청하면 받는 값들로 Kakao Developers - 사용자 정보 가져오기 를 참고해서 만들었어요.
원래는 더 많은 응답 데이터들이 와요. 하지만 우리에게 필요한 데이터만 추려내기 위해 @JsonIgnoreProperties(ignoreUnknown = true)
를 사용했어요.
@Getter
@JsonIgnoreProperties(ignoreUnknown = true)
public class NaverUserInfo implements OAuthUserInfo {
@JsonProperty(value = "response")
private Response response;
@JsonIgnoreProperties(ignoreUnknown = true)
record Response(String email) {
}
@Override
public String getEmail() {
return response.email;
}
@Override
public OAuthProvider getOAuthProvider() {
return OAuthProvider.NAVER;
}
}
https://openapi.naver.com/v1/nid/me
에 요청하는 받는 값들로 Naver Devlopers - 네이버 회원 프로필 조회 API 명세 를 참고해서 만들었어요.
public interface OAuthClient {
OAuthProvider oAuthProvider();
String requestAccessToken(OAuthLoginParams params);
OAuthUserInfo requestOAuthInfo(String accessToken);
}
OAuth 요청을 위한 Client 클래스로서 각각 메서드는 다음과 같은 역할이에요.
oAuthProvider()
: Client 의 타입 반환requestAccessToken
: Authorization Code 를 기반으로 인증 API 를 요청해서 Access Token 을 획득requestOauthInfo
: Access Token 을 기반으로 Email 등이 포함된 프로필 정보를 획득public class OAuthConstant {
public static final String GRANT_TYPE= "authorization_code";
}
authorization_code
를 네이버 클라이언트와 카카오 클라이언트 모두 사용해서 상수로 만들어 관리하려 해요.
@Component
@RequiredArgsConstructor
@Slf4j
public class KaKaoApiClient implements OAuthClient {
@Value("${oauth.kakao.url.auth}")
private String authUrl;
@Value("${oauth.kakao.url.api}")
private String apiUrl;
@Value("${oauth.kakao.client-id}")
private String clientId;
private final RestTemplate restTemplate;
@Override
public OAuthProvider oauthProvider() {
return OAuthProvider.KAKAO;
}
@Override
public String requestAccessToken(OAuthLoginParams params) {
String url = authUrl + "/oauth/token";
HttpEntity<MultiValueMap<String, String>> request = generateHttpRequest(params);
KaKaoToken kaKaoToken = restTemplate.postForObject(url, request, KaKaoToken.class);
Objects.requireNonNull(kaKaoToken);
return kaKaoToken.accessToken();
}
@Override
public OAuthUserInfo requestOAuthInfo(String accessToken) {
String url = apiUrl + "/v2/user/me";
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
String requestToken = "Bearer " + accessToken;
httpHeaders.set("Authorization", requestToken);
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("property_keys", "[\"kakao_account.email\"]");
HttpEntity<?> request = new HttpEntity<>(body, httpHeaders);
return restTemplate.postForObject(url, request, KaKaoUserInfo.class);
}
/**
* https://kauth.kakao.com/oauth/token?
* grant_type=authorization_code (추가할 param)
* &client_id=${REST_API_KEY} (추가할 param)
* &redirect_uri=${REDIRECT_URI} (KaKaoLoginParams에 포함되어 있음)
* &code=${code} (KaKaoLoginParams에 포함되어 있음)
*/
private HttpEntity<MultiValueMap<String, String>> generateHttpRequest(OAuthLoginParams params) {
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> body = params.makeBody();
log.info("age body={}", body);
body.add("grant_type", OAuthConstant.GRANT_TYPE);
body.add("client_id", clientId);
return new HttpEntity<>(body, httpHeaders);
}
}
Kakao Develpers의 카카오 로그인 토큰 받기 와 사용자 정보 가져오기를 참고하여 만들었어요.
RestTemplate
을 활용해서 외부 요청 후 미리 정의해둔 KakaoTokens
, KakaoInfoResponse
로 응답값을 받을 수 있어요.
@Component
@RequiredArgsConstructor
@Slf4j
public class NaverApiClient implements OAuthClient {
@Value("${oauth.naver.url.auth}")
private String authUrl;
@Value("${oauth.naver.url.api}")
private String apiUrl;
@Value("${oauth.naver.client-id}")
private String clientId;
@Value("${oauth.naver.client-secret}")
private String clientSecret;
private final RestTemplate restTemplate;
@Override
public OAuthProvider oauthProvider() {
return OAuthProvider.NAVER;
}
@Override
public String requestAccessToken(OAuthLoginParams params) {
String url = authUrl + "/oauth2.0/token";
HttpEntity<MultiValueMap<String, String>> request = generateHttpRequest(params);
NaverToken naverToken = restTemplate.postForObject(url, request, NaverToken.class);
log.info("url={}", url);
log.info("request={}", request);
Objects.requireNonNull(naverToken);
return naverToken.accessToken();
}
@Override
public OAuthUserInfo requestOAuthInfo(String accessToken) {
String url = apiUrl + "/v1/nid/me";
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
String requestToken = "Bearer " + accessToken;
httpHeaders.set("Authorization", requestToken);
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
HttpEntity<?> request = new HttpEntity<>(body, httpHeaders);
log.info("request hearer={}, body={}", request.getHeaders(), request.getBody());
return restTemplate.postForObject(url, request, NaverUserInfo.class);
}
/**
* "https://nid.naver.com/oauth2.0/token?
* grant_type=authorization_code (추가할param)
* &client_id=jyvqXeaVOVmV (추가할param)
* &client_secret=527300A0_COq1_XV33cf (추가할param)
* &code=EIc5bFrl4RibFls (NaverLoginParams에 포함되어 있음)
* 1&state=9kgsGTfH4j7IyAkg" (NaverLoginParams에 포함되어 있음)
*/
private HttpEntity<MultiValueMap<String, String>> generateHttpRequest(OAuthLoginParams params) {
HttpHeaders httpHeaders = new HttpHeaders();
// 접근 토큰 갱신 / 삭제 요청시 access_token 값은 URL 인코딩하셔야 합니다.
httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> body = params.makeBody();
log.info("age body={}", body);
body.add("grant_type", OAuthConstant.GRANT_TYPE);
body.add("client_id", clientId);
body.add("client_secret", clientSecret);
return new HttpEntity<>(body, httpHeaders);
}
}
Naver Developers 의 로그인 API 명세 와 회원 프로필 조회 API 명세 를 참고하여 만들었어요.
RestTemplate
을 활용해서 외부 요청 후 미리 정의해둔 NaverTokens
, NaverInfoResponse
로 응답값을 받을 수 있어요.
@Service
public class RequestOAuthInfoService {
private final Map<OAuthProvider, OAuthClient> clients;
public RequestOAuthInfoService(List<OAuthClient> clients) {
this.clients = clients.stream().collect(
Collectors.toUnmodifiableMap(OAuthClient::oauthProvider, Function.identity())
);
}
public OAuthUserInfo request(OAuthLoginParams params) {
OAuthClient client = clients.get(params.oAuthProvider());
String accessToken = client.requestAccessToken(params);
return client.requestOAuthInfo(accessToken);
}
}
지금까지 만든 OAuthApiClient
를 사용하는 Service 클래스입니다.
KakaoApiClient
, NaverApiClient
를 직접 주입받아서 사용하면 중복되는 코드가 많아지기 때문에List<OAuthApiClient>
를 주입 받아서 Map 으로 만들어 사용했어요.
OAuth 요청을 하면 클라이언트에게 Access Token 데이터를 넘겨주어야 해요.
여기서 Access Token은 내 서비스 로그인 인증을 위한 토큰이에요.
OAuth 플랫폼들의 Access Token을 클라이언트에게 전달하여 사용하면 플랫폼 별로 만료 기간 관리도 번거롭고 혹여나 탈취라도 당하면 안되기 때문에 직접 토큰을 만들었어요.
@Component
@Slf4j
public class JwtTokenProvider {
private final Key key;
public JwtTokenProvider(@Value("${jwt.secret-key}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public String generate(String subject, Date expiredDate) {
return Jwts.builder()
.setSubject(subject)
.setExpiration(expiredDate)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
public String extractSubject(String accessToken) {
Claims claims = parseClaims(accessToken);
return claims.getSubject();
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.warn("Invalid JWT Token", e);
} catch (ExpiredJwtException e) {
log.warn("Expired JWT Token", e);
} catch (UnsupportedJwtException e) {
log.warn("Unsupported JWT Token", e);
} catch (IllegalArgumentException e) {
log.warn("JWT claims string is empty.", e);
}
return false;
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(accessToken)
.getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
JWT 토큰을 만들어주는 유틸리티 클래스에요.
@Getter
public class AuthToken {
private String accessToken;
private String refreshToken;
private String grantType;
private Long expiresIn;
private AuthToken(String accessToken, String refreshToken, String grantType, Long expiresIn) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
this.grantType = grantType;
this.expiresIn = expiresIn;
}
public static AuthToken of(String accessToken, String refreshToken, String grantType, Long expiresIn) {
return new AuthToken(accessToken, refreshToken, grantType, expiresIn);
}
}
사용자에게 전달해주는 인증 토큰이에요.
인스턴스 생성과 관련된 로직을 더욱 명확하고 안전하게 관리할 수 있으며, 코드의 가독성과 유지보수성을 향상 목적으로 정적 팩토리 메서드를 사용했어요.
@Component
@RequiredArgsConstructor
public class AuthTokenGenerator {
private static final StringBEARER_TYPE= "Bearer";
private static final longACCESS_TOKEN_EXPIRE_TIME= 1000 * 60 * 30; // 30분
private static final longREFRESH_TOKEN_EXPIRE_TIME= 1000 * 60 * 60 * 24 * 7; // 7일
private final JwtTokenProvider jwtTokenProvider;
public AuthToken generate(Integer userId) {
long now = (new Date()).getTime();
Date accessTokenExpiredDate = new Date(now +ACCESS_TOKEN_EXPIRE_TIME);
Date refreshTokenExpiredDate = new Date(now +REFRESH_TOKEN_EXPIRE_TIME);
String subject = userId.toString();
String accessToken = jwtTokenProvider.generate(subject, accessTokenExpiredDate);
String refreshToken = jwtTokenProvider.generate(subject, refreshTokenExpiredDate);
return AuthToken.of(accessToken, refreshToken,BEARER_TYPE,ACCESS_TOKEN_EXPIRE_TIME/ 1000L);
}
public Long extractUserId(String accessToken) {
return Long.valueOf(jwtTokenProvider.extractSubject(accessToken));
}
}
AuthToken
을 발급해주는 클래스에요.
@Service
@RequiredArgsConstructor
public class OAuthLoginService {
private final UserMapper userMapper;
private final AuthTokenGenerator authTokenGenerator;
private final RequestOAuthInfoService requestOAuthInfoService;
private final PasswordEncoder passwordEncoder;
public AuthToken login(OAuthLoginParams params) {
OAuthUserInfo oAuthUserInfo = requestOAuthInfoService.request(params);
Integer userId = findOrCreateUser(oAuthUserInfo);
return authTokenGenerator.generate(userId);
}
private Integer findOrCreateUser(OAuthUserInfo oAuthUserInfo) {
return userMapper.findById(oAuthUserInfo.getEmail())
.map(User::getId)
.orElseGet(() -> newUser(oAuthUserInfo));
}
private Integer newUser(OAuthUserInfo oAuthUserInfo) {
User user = User.builder()
.email(oAuthUserInfo.getEmail())
.password(passwordEncoder.encode(oAuthUserInfo.getEmail()))
.provider(oAuthUserInfo.getOAuthProvider())
.isActive(true)
.build();
userMapper.save(user);
return user.getId();
}
}
findOrCreateUser
메서드를 통해 제가 원했던 방안을 넣었어요.
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/auth")
@Slf4j
public class OAuthController {
private final OAuthLoginService oAuthLoginService;
@PostMapping("/naver")
public ResponseEntity<ApiResponse<AuthToken>> naverLogin(@RequestBody NaverLoginParam param) {
AuthToken authToken = oAuthLoginService.login(param);
return new ResponseEntity<>(ApiResponse.success(authToken), HttpStatus.OK);
}
@PostMapping("/kakao")
public ResponseEntity<ApiResponse<AuthToken>> kakaoLogin(@RequestBody KakaoLoginParam param) {
log.info("code={}", param.getAuthorizationCode());
AuthToken authToken = oAuthLoginService.login(param);
return new ResponseEntity<>(ApiResponse.success(authToken), HttpStatus.OK);
}
}
@Component
@RequiredArgsConstructor
@Slf4j
public class AuthInterceptor implements HandlerInterceptor {
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_TYPE = "Bearer";
private final LoginService loginService;
private final JwtTokenProvider jwtTokenProvider;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
log.debug("Login Interceptor preHandler");
// 추가된 부분
String authToken = resolveToken(request);
if (authToken != null && jwtTokenProvider.validateToken(authToken)) {
String email = getUserIdFromToken(authToken);
loginService.login(email);
}
// 기존
loginService.getLoginUser()
.orElseThrow(UnauthorizedUserException::new);
return true;
}
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_TYPE)) {
return bearerToken.substring(7);
}
return null;
}
private String getUserIdFromToken(String accessToken) {
return jwtTokenProvider.extractSubject(accessToken);
}
}
기존에는 session을 redis에서 꺼내와 로그인한 유저인지 확인했어요.
하지만 토큰은 클라이언트가 가지고 있기에 http request 헤더에서 “Authorization”을 꺼내와 토큰의 존재 유무를 확인하여 로그인 유저를 식별하는 인터셉터를 추가함으로써 마무리했어요.
Talend API Tester를 사용하여 api 요청을 보내보았어요.
지금까지 Spring Boot 에서 OAuth 2.0 을 활용한 인증 기능을 개발하는 과정을 정리했어요.
기능의 목적을 자세히 정의하고 하나하나 구현해 나가는 과정이 너무 뿌듯했어요. 또한, 스프링 시큐리티를 활용하지 않아 모든 과정을 구현하다보니 OAuth에 대해 더 자세하게 알게 된 계기가 되었어요.
OAuth를 구현하는 과정에서 다음과 같은 것도 배울 수 있었어요.
OAuth 2.0 동작 방식의 이해
OAuth 2.0 개념과 동작원리
Spring Boot 에서 Kakao, Naver 로그인하기 2편 (OAuth 2.0) - 코드 구현
OAuth2RefreshTokenGrantRequestEntityConverter
스프링 부트와 AWS로 혼자 구현하는 웹 서비스(이동욱 저자)