Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,20 @@ R2_SECRET_KEY=replace-with-r2-secret-key
# R2는 region을 auto로 고정한다 (변경 불필요)
R2_REGION=auto
S3_UPLOAD_URL_EXPIRY_SECONDS=600

### infrastructure:client — KConnect OAuth ###
# KConnect 측 요청으로 API 명세(주소·경로·응답 필드)는 저장소에 남기지 않는다. 실제 값은 팀 내부 문서를 참고한다
KCONNECT_BASE_URL=replace-with-kconnect-base-url
KCONNECT_TOKEN_PATH=replace-with-token-path
# KConnect 관리자에게 받은 값. Client Secret은 코드·로그에 남기지 않는다
KCONNECT_CLIENT_ID=replace-with-kconnect-client-id
KCONNECT_CLIENT_SECRET=replace-with-kconnect-client-secret
# KConnect에 등록한 redirect URI (쉼표 구분). 운영 환경에는 localhost를 넣지 않는다
KCONNECT_ALLOWED_REDIRECT_URIS=kmustream://oauth/kconnect,http://localhost:5173/oauth/kconnect
# 사용자 정보 API 경로와 응답에서 읽을 필드 이름
KCONNECT_USER_INFO_PATH=replace-with-user-info-path
KCONNECT_USER_INFO_ID_FIELD=replace-with-field-name
KCONNECT_USER_INFO_STUDENT_ID_FIELD=replace-with-field-name
KCONNECT_USER_INFO_NAME_FIELD=replace-with-field-name
KCONNECT_USER_INFO_MAJOR_FIELD=replace-with-field-name
KCONNECT_USER_INFO_ACADEMIC_STATUS_FIELD=replace-with-field-name
1 change: 1 addition & 0 deletions infrastructure/client/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ description = "외부 API 클라이언트 구현체"

dependencies {
implementation(project(":core:common"))
implementation(project(":core:domain:auth"))
implementation(project(":core:domain:internal"))

implementation(platform(libs.springBootDependenciesBom))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package kr.ac.kookmin.stream.client.oauth.kconnect;

import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestClient;

/**
* {@link KConnectOAuthClient}가 쓰는 RestClient 빈 설정. 로그인 요청이 KConnect 응답을 기다리며 오래 묶이지 않도록 타임아웃을 건다.
*/
@Configuration
public class KConnectClientConfig {

private static final Logger log = LoggerFactory.getLogger(KConnectClientConfig.class);

@Bean
public RestClient kconnectRestClient(KConnectProperties properties) {
// 로컬처럼 KConnect 설정 없이도 기동은 되게 두고, 빠진 설정을 알려만 준다. 로그인하면 500이 난다
List<String> missingSettings = properties.missingSettings();
if (!missingSettings.isEmpty()) {
log.warn("KConnect 설정이 비어 있어 KConnect 로그인을 쓸 수 없습니다: {}", missingSettings);
}

SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(properties.connectTimeout());
requestFactory.setReadTimeout(properties.readTimeout());

return RestClient.builder()
.baseUrl(properties.baseUrl())
.requestFactory(requestFactory)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package kr.ac.kookmin.stream.client.oauth.kconnect;

import java.util.List;
import java.util.Map;
import java.util.Objects;
import kr.ac.kookmin.stream.auth.domain.oauth.client.OAuthClient;
import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthErrorCode;
import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthLoginCommand;
import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthProvider;
import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthUserInfo;
import kr.ac.kookmin.stream.common.BusinessException;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;

/**
* KConnect(국민대 학생용 OAuth) 구현체. 앱·웹이 PKCE 로그인으로 받은 code를 access token으로 교환하고,
* 그 토큰으로 사용자 정보 API를 불러 사용자를 확인한다. access token은 사용자 조회에만 쓰고 저장하지 않는다.
* <p>
* KConnect 측 요청으로 API 명세는 코드에 두지 않는다. 경로와 응답 필드 이름은 {@link KConnectProperties}로 받는다.
* code·code verifier·access token·client secret은 로그와 예외 메시지에 남기지 않는다.
*/
@Component
@RequiredArgsConstructor
public class KConnectOAuthClient implements OAuthClient {

private static final Logger log = LoggerFactory.getLogger(KConnectOAuthClient.class);

// RFC 6749 표준 에러 코드: code 만료·재사용·verifier 불일치
private static final String INVALID_GRANT = "invalid_grant";
private static final ParameterizedTypeReference<Map<String, Object>> JSON_OBJECT = new ParameterizedTypeReference<>() {};

private final KConnectProperties properties;

@Qualifier("kconnectRestClient")
private final RestClient kconnectRestClient;

@Override
public OAuthProvider provider() {
return OAuthProvider.KCONNECT;
}

@Override
public boolean isAllowedRedirectUri(String redirectUri) {
return properties.allowedRedirectUris().contains(redirectUri);
}

@Override
public OAuthUserInfo fetchUserInfo(OAuthLoginCommand command) {
List<String> missingSettings = properties.missingSettings();
if (!missingSettings.isEmpty()) {
throw new IllegalStateException("KConnect 설정이 비어 있습니다: " + missingSettings);
}

String accessToken = exchangeCode(command);
return toOAuthUserInfo(requestUserInfo(accessToken));
}

// code는 한 번만 쓸 수 있어 두 번째 요청은 항상 invalid_grant가 된다. 그래서 재시도하지 않는다
private String exchangeCode(OAuthLoginCommand command) {
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
form.add("grant_type", "authorization_code");
form.add("code", command.code());
form.add("redirect_uri", command.redirectUri());
form.add("code_verifier", command.codeVerifier());
form.add("client_id", properties.clientId());
form.add("client_secret", properties.clientSecret());

try {
KConnectTokenResponse response = kconnectRestClient.post()
.uri(properties.tokenPath())
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(form)
.retrieve()
.body(KConnectTokenResponse.class);
if (response == null || response.accessToken() == null) {
throw new IllegalStateException("KConnect 토큰 응답에 access_token이 없습니다");
}
return response.accessToken();
} catch (HttpClientErrorException e) {
// 사용자가 다시 로그인하면 되는 경우
if (e.getResponseBodyAsString().contains(INVALID_GRANT)) {
throw new BusinessException(OAuthErrorCode.INVALID_AUTHORIZATION_CODE);
}
// invalid_client 등: Client ID·Secret 설정 오류라 서버에서 알아야 하므로 500으로 남긴다
throw new IllegalStateException(
"KConnect 토큰 교환 실패: " + e.getStatusCode() + " " + e.getResponseBodyAsString(), e);
} catch (HttpServerErrorException | ResourceAccessException e) {
throw providerUnavailable(e);
}
}

private Map<String, Object> requestUserInfo(String accessToken) {
try {
Map<String, Object> response = kconnectRestClient.get()
.uri(properties.userInfo().path())
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
.retrieve()
.body(JSON_OBJECT);
if (response == null) {
throw new IllegalStateException("KConnect 사용자 정보 응답이 비어 있습니다");
}
return response;
} catch (HttpClientErrorException e) {
// 방금 발급받은 토큰이 거절되면 scope·경로 등 설정 문제다
throw new IllegalStateException("KConnect 사용자 정보 조회 실패: " + e.getStatusCode(), e);
} catch (HttpServerErrorException | ResourceAccessException e) {
throw providerUnavailable(e);
}
}

private OAuthUserInfo toOAuthUserInfo(Map<String, Object> response) {
KConnectProperties.UserInfo fields = properties.userInfo();
String providerUserId = field(response, fields.idField());
if (providerUserId == null) {
throw new IllegalStateException("KConnect 사용자 정보 응답에 고유 ID가 없습니다");
}

return new OAuthUserInfo(
OAuthProvider.KCONNECT,
providerUserId,
field(response, fields.studentIdField()),
field(response, fields.nameField()),
field(response, fields.majorField()),
field(response, fields.academicStatusField())
);
}

private static String field(Map<String, Object> response, String name) {
return Objects.toString(response.get(name), null);
}

private BusinessException providerUnavailable(Exception e) {
log.warn("KConnect 호출 실패: {}", e.getMessage());
return new BusinessException(OAuthErrorCode.OAUTH_PROVIDER_UNAVAILABLE);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package kr.ac.kookmin.stream.client.oauth.kconnect;

import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.boot.context.properties.ConfigurationProperties;

/**
* KConnect 연동 설정. KConnect 측 요청으로 API 명세(주소·경로·응답 필드)를 코드에 두지 않고 환경 변수로만 받는다.
* <p>
* allowedRedirectUris는 KConnect에 등록한 redirect URI와 글자 하나까지 같아야 한다. 운영 환경에는 localhost를 넣지 않는다.
*/
@ConfigurationProperties(prefix = "oauth.kconnect")
public record KConnectProperties(
String baseUrl,
String tokenPath,
String clientId,
String clientSecret,
Set<String> allowedRedirectUris,
UserInfo userInfo,
Duration connectTimeout,
Duration readTimeout
) {

/** 사용자 정보 API의 경로와, 응답에서 읽을 필드 이름. */
public record UserInfo(
String path,
String idField,
String studentIdField,
String nameField,
String majorField,
String academicStatusField
) {}

/** 비어 있는 필수 설정의 키 이름. 로그에 남기므로 값은 담지 않는다. */
public List<String> missingSettings() {
UserInfo info = userInfo != null ? userInfo : new UserInfo(null, null, null, null, null, null);

Map<String, String> required = new LinkedHashMap<>();
required.put("base-url", baseUrl);
required.put("token-path", tokenPath);
required.put("client-id", clientId);
required.put("client-secret", clientSecret);
required.put("user-info.path", info.path());
required.put("user-info.id-field", info.idField());
required.put("user-info.student-id-field", info.studentIdField());
required.put("user-info.name-field", info.nameField());
required.put("user-info.major-field", info.majorField());
required.put("user-info.academic-status-field", info.academicStatusField());

return required.entrySet().stream()
.filter(entry -> entry.getValue() == null || entry.getValue().isBlank())
.map(Map.Entry::getKey)
.toList();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package kr.ac.kookmin.stream.client.oauth.kconnect;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;

// 토큰 교환 응답. access token만 쓰고 refresh token 등 나머지는 받지 않는다
@JsonIgnoreProperties(ignoreUnknown = true)
record KConnectTokenResponse(
@JsonProperty("access_token") String accessToken
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,20 @@ file:
access-key: ${R2_ACCESS_KEY:}
secret-key: ${R2_SECRET_KEY:}
upload-url-expiry-seconds: ${S3_UPLOAD_URL_EXPIRY_SECONDS:600}
# KConnect 측 요청으로 API 명세(주소·경로·응답 필드)는 환경 변수로만 주입하고 저장소에 남기지 않는다
oauth:
kconnect:
base-url: ${KCONNECT_BASE_URL:}
token-path: ${KCONNECT_TOKEN_PATH:}
client-id: ${KCONNECT_CLIENT_ID:}
client-secret: ${KCONNECT_CLIENT_SECRET:}
allowed-redirect-uris: ${KCONNECT_ALLOWED_REDIRECT_URIS:kmustream://oauth/kconnect}
user-info:
path: ${KCONNECT_USER_INFO_PATH:}
id-field: ${KCONNECT_USER_INFO_ID_FIELD:}
student-id-field: ${KCONNECT_USER_INFO_STUDENT_ID_FIELD:}
name-field: ${KCONNECT_USER_INFO_NAME_FIELD:}
major-field: ${KCONNECT_USER_INFO_MAJOR_FIELD:}
academic-status-field: ${KCONNECT_USER_INFO_ACADEMIC_STATUS_FIELD:}
connect-timeout: 3s
read-timeout: 5s
Loading