OAuth - 인증 서버에 엑세스 토큰 요청 및 리소스 서버에 사용자 정보 요청 - 1
OAuth에 필요한 설정과 클라이언트를 개발하였으니 스프링을 개발해보자. 프로젝트는 스프링 부트 + Spring data JPA * MySQL을 사용한다. 현재의 목표는 다음과 같다.
- 구글, 카카오 OAuth 구현
- 인증 코드 요청 시, 서비스에서는 OAuth 제공 클래스의 구현체를 신경 쓸 필요가 없어야 함
참고로 패키지 구조는 다음과 같다.
└─playground
├─global
│ ├─code
│ ├─config
│ ├─entity
│ ├─exception
│ ├─message
│ └─response
└─login
├─application
│ ├─exception
│ └─response
├─domain
│ └─type
├─dto
├─infrastructure
│ ├─provider
│ └─repository
└─presentation
└─request
사전 설정
의존성 설정
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-validation', version: '3.4.0'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
yml에 변수 설정
OAuth에 필요한 값을 자바 코드에 그대로 하드코드 하는 것은 권장되지 않는다. 보통 IDE나 시스템의 환경 변수로 설정을 해두고 불러오지만, 스프링에서는 설정 파일을 @Value
를 통해서 불러올 수 있기 때문에 이 기능을 활용하도록 하겠다.
# application-auth.yml
oauth2:
provider:
google:
client-id: CLIENT_ID
client-secret: CLIENT_SECRET
redirect-uri: http://localhost:5500/login.html
token-uri: https://oauth2.googleapis.com/token
info-uri: https://www.googleapis.com/userinfo/v2/me
kakao:
client-id: CLIENT_ID
client-secret: CLIENT_SECRET
redirect-uri: http://localhost:5500/login.html?provider=kakao
token-uri: https://kauth.kakao.com/oauth/token
info-uri: https://kapi.kakao.com/v2/user/me
yml 파일을 사용한 이유는 계층 구조로 값을 설정할 수 있기 때문이다. properties나 xml(아마도?)을 사용하는 것도 가능하다.
엔티티 매핑
JPA를 사용할 것이기 때문에 시간 관련 공용 클래스와 User
라는 이름의 엔티티를 만들었다.
@Getter
@MappedSuperclass
@EnableJpaAuditing
@EntityListeners(AuditingEntityListener.class)
public class BaseTimeEntity {
@CreatedDate
@Column(name = "created_time", updatable = false, nullable = false)
private Timestamp createdTime;
@LastModifiedDate
@Column(name = "modified_time", nullable = false)
private Timestamp modifiedTime;
}
@EnableJpaAuditing
@Configuration
public class JpaAuditingConfig {
}
public enum AuthType {
GOOGLE, KAKAO, NAVER
}
인증 타입을 지정할 enum
값이다. 네이버는 다음 기회에…
@Entity
@Table(name = "user")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column
private String email;
@Column(nullable = false)
private String nickname;
@Enumerated(EnumType.STRING)
@Column(name = "auth_provider", nullable = false)
private AuthType authType;
@Column(nullable = false)
@ColumnDefault(value = "false")
private Boolean deleted = false;
@Builder
private User(Long id, String email, String nickname, AuthType authType, Boolean deleted) {
this.email = email;
this.nickname = nickname;
this.authType = authType;
}
}
CORS 허용하기
마지막으로 클라이언트로부터 요청을 받아야 하기 때문에 특정 클라이언트의 주소를 허용해줄 필요가 있다.
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(final CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:5500")
.allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")
.allowCredentials(true);
WebMvcConfigurer.super.addCorsMappings(registry);
}
}
OAuth 제공자
서비스는 OAuth 제공자 클래스의 구현체를 신경 쓸 필요가 없도록 설계하고 싶다고 했기 때문에 인터페이스를 구현하는 구조로 만들고, 서비스 또는 컨트롤러는 인터페이스에만 의존하도록 설계하였다. 이를 구현하기 위해서 우테코 프로젝트의 OAuth 부분을 참고하였다.
public interface OAuthProvider {
RestTemplate restTemplate = new RestTemplate();
boolean is(String providerName);
UserInfoDto authenticate(String code);
// 중복 코드 리팩토링
default ResponseEntity<OAuthAccessToken> requestAccessToken(String... args) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("code", args[0]);
body.add("client_id", args[1]);
body.add("client_secret", args[2]);
body.add("redirect_uri", args[3]);
body.add("grant_type", "authorization_code");
HttpEntity<MultiValueMap<String, String>> accessTokenRequestEntity = new HttpEntity<>(body, headers);
// 요청을 보내고 .class로 지정한 객체로 변환하여 ResponseEntity<>로 응답한다.
return restTemplate.exchange(
args[4],
HttpMethod.POST,
accessTokenRequestEntity,
OAuthAccessToken.class
);
}
}
RestTemplate
객체는 간단한 HTTP 요청을 보내기 위해서 스프링 프레임워크에서 제공하는 간단한 요청 객체이다. 최근에는 WebFlux와 함께 WebClient
라는 새로운 HTTP 클라이언트가 등장하면서 훗날 deprecated될 예정이라고 하지만 어차피 나중에 스프링 시큐리티로 변경할 예정이니 이걸 사용했다.
DTO 구성
public record OAuthAccessToken(
@JsonProperty("access_token")
String accessToken,
@JsonProperty("expires_in")
int expiresIn,
@JsonProperty("token_in")
int tokenIn,
@JsonProperty("scope")
String scope,
@JsonProperty("refresh_token")
String refreshToken
) {
}
@Getter
public class UserInfoDto {
private String email;
private String name;
private AuthType authType;
@Builder
private UserInfoDto(String email, String name, AuthType authType) {
this.email = email;
this.name = name;
this.authType = authType;
}
}
public record GoogleUserInfo(
@JsonProperty("email")
String email,
@JsonProperty("name")
String name
) {
public UserInfoDto toUserInfoDto() {
return UserInfoDto
.builder()
.email(email)
.name(name)
.authType(AuthType.GOOGLE)
.build();
}
}
public record KakaoUserInfo(
String name
) {
public UserInfoDto toUserInfoDto() {
return UserInfoDto
.builder()
.name(name)
.authType(AuthType.KAKAO)
.build();
}
}
구글 OAuth 제공자
@Component
public class GoogleOAuthProvider implements OAuthProvider {
private static final String PROVIDER = "google";
// application-auth.yml 로부터 값을 읽어옴
@Value("${oauth2.provider.google.client-id}")
private String clientId;
@Value("${oauth2.provider.google.client-secret}")
private String clientSecret;
@Value("${oauth2.provider.google.redirect-uri}")
private String redirectUri;
@Value("${oauth2.provider.google.token-uri}")
private String tokenUri;
@Value("${oauth2.provider.google.info-uri}")
private String infoUri;
@Override
public boolean is(String providerName) {
return PROVIDER.equals(providerName);
}
@Override
public UserInfoDto authenticate(String code) {
return requestUserInfo(code);
}
// 엑세스 토큰으로 사용자 정보를 가져오기 위한 메소드
private UserInfoDto requestUserInfo(String code) {
String accessToken = getAccessToken(code);
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
HttpEntity<MultiValueMap<String, String>> userInfoRequestEntity = new HttpEntity<>(headers);
ResponseEntity<GoogleUserInfo> userResponse = restTemplate.exchange(
infoUri,
HttpMethod.GET,
userInfoRequestEntity,
GoogleUserInfo.class
);
return Optional.ofNullable(userResponse.getBody())
.orElseThrow(() -> new CommonException(INVALID_AUTHORIZATION_CODE))
.toUserInfoDto();
}
// 인증 서버로부터 엑세스 토큰을 받기 위한 메소드
private String getAccessToken(final String code) {
ResponseEntity<OAuthAccessToken> accessTokenResponse = requestAccessToken(code,
clientId,
clientSecret,
redirectUri,
tokenUri
);
return Optional.ofNullable(accessTokenResponse.getBody())
.orElseThrow(() -> new CommonException(INVALID_AUTHORIZATION_CODE))
.accessToken();
}
}
카카오 OAuth 제공자
카카오의 경우에는 다른 부분은 다 똑같고, requestUserInfo()
가 구글과 리소스 서버로부터의 응답값이 달라 다르게 구현하였다.
private UserInfoDto requestUserInfo(String code) {
String accessToken = getAccessToken(code);
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
HttpEntity<MultiValueMap<String, String>> userInfoRequestEntity = new HttpEntity<>(headers);
ResponseEntity<Map<String, Object>> userResponse = restTemplate.exchange(
infoUri,
HttpMethod.GET,
userInfoRequestEntity,
new ParameterizedTypeReference<>() {
}
);
Map<String, Object> result = userResponse.getBody();
Map<String, Object> properties = (Map<String, Object>) result.get("properties");
return new KakaoUserInfo(properties.get("nickname").toString())
.toUserInfoDto();
}
댓글남기기