From 5dcf2fbfdea5e03e2f4fb4f5aede53921f66a4bf Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:50:28 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20KConnect=20OAuth=20=ED=81=B4?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 17 ++ infrastructure/client/build.gradle.kts | 1 + .../oauth/kconnect/KConnectClientConfig.java | 36 +++++ .../oauth/kconnect/KConnectOAuthClient.java | 148 ++++++++++++++++++ .../oauth/kconnect/KConnectProperties.java | 58 +++++++ .../oauth/kconnect/KConnectTokenResponse.java | 10 ++ .../application-infrastructure-client.yml | 17 ++ 7 files changed, 287 insertions(+) create mode 100644 infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectClientConfig.java create mode 100644 infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectOAuthClient.java create mode 100644 infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectProperties.java create mode 100644 infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectTokenResponse.java diff --git a/.env.example b/.env.example index e83f4b92..a255ec55 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/infrastructure/client/build.gradle.kts b/infrastructure/client/build.gradle.kts index f7205cb8..ed5bd55e 100644 --- a/infrastructure/client/build.gradle.kts +++ b/infrastructure/client/build.gradle.kts @@ -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)) diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectClientConfig.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectClientConfig.java new file mode 100644 index 00000000..dfba9b0f --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectClientConfig.java @@ -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 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(); + } +} diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectOAuthClient.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectOAuthClient.java new file mode 100644 index 00000000..b93cd181 --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectOAuthClient.java @@ -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은 사용자 조회에만 쓰고 저장하지 않는다. + *

+ * 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> 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 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 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 requestUserInfo(String accessToken) { + try { + Map 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 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 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); + } +} diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectProperties.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectProperties.java new file mode 100644 index 00000000..361d6b92 --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectProperties.java @@ -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 명세(주소·경로·응답 필드)를 코드에 두지 않고 환경 변수로만 받는다. + *

+ * allowedRedirectUris는 KConnect에 등록한 redirect URI와 글자 하나까지 같아야 한다. 운영 환경에는 localhost를 넣지 않는다. + */ +@ConfigurationProperties(prefix = "oauth.kconnect") +public record KConnectProperties( + String baseUrl, + String tokenPath, + String clientId, + String clientSecret, + Set 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 missingSettings() { + UserInfo info = userInfo != null ? userInfo : new UserInfo(null, null, null, null, null, null); + + Map 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(); + } +} diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectTokenResponse.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectTokenResponse.java new file mode 100644 index 00000000..285a49fd --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/oauth/kconnect/KConnectTokenResponse.java @@ -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 +) {} diff --git a/infrastructure/client/src/main/resources/application-infrastructure-client.yml b/infrastructure/client/src/main/resources/application-infrastructure-client.yml index 95f32b32..6b8b40ba 100644 --- a/infrastructure/client/src/main/resources/application-infrastructure-client.yml +++ b/infrastructure/client/src/main/resources/application-infrastructure-client.yml @@ -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 From 7b02b6f0484b408d3495fe5e901f285f5e559ad3 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:50:41 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EB=B0=9C=EA=B8=89=20=ED=8C=8C=EC=82=AC?= =?UTF-8?q?=EB=93=9C=EC=99=80=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B3=B5?= =?UTF-8?q?=EA=B0=9C=20=EA=B2=BD=EB=A1=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/security/AuthTokenIssuer.java | 37 +++++++++++++++++++ .../stream/security/PublicEndpoints.java | 3 ++ 2 files changed, 40 insertions(+) create mode 100644 gateway/auth/src/main/java/kr/ac/kookmin/stream/security/AuthTokenIssuer.java diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/AuthTokenIssuer.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/AuthTokenIssuer.java new file mode 100644 index 00000000..5dc056b0 --- /dev/null +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/AuthTokenIssuer.java @@ -0,0 +1,37 @@ +package kr.ac.kookmin.stream.security; + +import java.util.EnumSet; +import java.util.Set; +import kr.ac.kookmin.stream.common.CouncilDepartment; +import kr.ac.kookmin.stream.common.Role; +import kr.ac.kookmin.stream.security.jwt.JwtProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 로그인 API가 서비스 토큰을 발급할 때 쓰는 진입점. + * JwtProvider는 Modulith가 내부로 보는 security.jwt 패키지에 있어 api 모듈이 직접 부를 수 없으므로 여기서 감싼다. + */ +@Component +@RequiredArgsConstructor +public class AuthTokenIssuer { + + private final JwtProvider jwtProvider; + + public String issue(Long memberId, Role role, CouncilDepartment councilDepartment) { + return jwtProvider.generateAccessToken(memberId, rolesOf(role), councilDepartmentsOf(role, councilDepartment)); + } + + // 운영진도 학생 앱(/v1/app/**, STUDENT)을 쓰므로 ADMIN에게는 STUDENT를 함께 준다 + private Set rolesOf(Role role) { + return role == Role.ADMIN ? EnumSet.of(Role.ADMIN, Role.STUDENT) : EnumSet.of(role); + } + + // 학생회 부서 권한은 ADMIN에게만 싣는다 + private Set councilDepartmentsOf(Role role, CouncilDepartment councilDepartment) { + if (role != Role.ADMIN || councilDepartment == null) { + return EnumSet.noneOf(CouncilDepartment.class); + } + return EnumSet.of(councilDepartment); + } +} diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/PublicEndpoints.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/PublicEndpoints.java index b8557575..2f775c68 100644 --- a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/PublicEndpoints.java +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/PublicEndpoints.java @@ -22,6 +22,9 @@ public enum PublicEndpoints { "/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**" + )), + AUTH(List.of( + "/v1/auth/login/*" )); private static final List ALL_PATH_PATTERNS = Arrays.stream(values()) From e6c2f86a312b4e9e1bc4c461ed174a8aca1b5a15 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:50:41 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20OAuth=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app-api/build.gradle.kts | 2 + .../stream/api/app/core/auth/AppAuthApi.java | 34 ++++++++++++ .../api/app/core/auth/AppAuthController.java | 31 +++++++++++ .../core/auth/request/OAuthLoginRequest.java | 24 ++++++++ .../auth/response/OAuthLoginResponse.java | 9 +++ .../core/auth/usecase/OAuthLoginUseCase.java | 55 +++++++++++++++++++ 6 files changed, 155 insertions(+) create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/AppAuthApi.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/AppAuthController.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/request/OAuthLoginRequest.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/response/OAuthLoginResponse.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/usecase/OAuthLoginUseCase.java diff --git a/api/app-api/build.gradle.kts b/api/app-api/build.gradle.kts index 73240a99..a0c912c9 100644 --- a/api/app-api/build.gradle.kts +++ b/api/app-api/build.gradle.kts @@ -7,8 +7,10 @@ description = "학생 앱 — STUDENT, /v1/app/**" dependencies { implementation(project(":api:common-api")) implementation(project(":core:common")) + implementation(project(":core:domain:auth")) implementation(project(":core:domain:event")) implementation(project(":core:domain:internal")) + implementation(project(":core:domain:member")) implementation(project(":core:domain:welfare")) implementation(project(":gateway:auth")) implementation(project(":gateway:logging")) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/AppAuthApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/AppAuthApi.java new file mode 100644 index 00000000..24e04d2c --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/AppAuthApi.java @@ -0,0 +1,34 @@ +package kr.ac.kookmin.stream.api.app.core.auth; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.ac.kookmin.stream.api.app.core.auth.request.OAuthLoginRequest; +import kr.ac.kookmin.stream.api.app.core.auth.response.OAuthLoginResponse; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; +import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthErrorCode; +import kr.ac.kookmin.stream.common.CommonErrorCode; +import kr.ac.kookmin.stream.member.domain.member.domain.MemberErrorCode; + +/** + * 로그인 API의 문서 명세. 구현은 {@link AppAuthController}가 맡는다. + * 학생 앱과 운영진 콘솔이 같이 쓰며, 접근 범위는 발급된 토큰의 role로 나뉜다. + */ +@Tag(name = "인증", description = "OAuth 로그인") +public interface AppAuthApi { + + /** provider 로그인. 앱·웹이 PKCE 로그인으로 받은 code를 서비스 토큰으로 바꾼다. */ + @Operation(summary = "OAuth 로그인", + description = """ + provider(현재 kconnect)에서 받은 code·codeVerifier·redirectUri로 로그인한다. + 처음 로그인하면 회원이 만들어진다. termsAgreementRequired가 true면 필수 약관 동의가 필요하다.""") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode(type = OAuthErrorCode.class, codes = { + "UNSUPPORTED_OAUTH_PROVIDER", + "REDIRECT_URI_NOT_ALLOWED", + "INVALID_AUTHORIZATION_CODE", + "OAUTH_PROVIDER_UNAVAILABLE" + }) + @ApiErrorCode(type = MemberErrorCode.class, codes = {"DEPARTMENT_NOT_ALLOWED"}) + ApiResponse login(String provider, OAuthLoginRequest request); +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/AppAuthController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/AppAuthController.java new file mode 100644 index 00000000..92f94dde --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/AppAuthController.java @@ -0,0 +1,31 @@ +package kr.ac.kookmin.stream.api.app.core.auth; + +import jakarta.validation.Valid; +import kr.ac.kookmin.stream.api.app.core.auth.request.OAuthLoginRequest; +import kr.ac.kookmin.stream.api.app.core.auth.response.OAuthLoginResponse; +import kr.ac.kookmin.stream.api.app.core.auth.usecase.OAuthLoginUseCase; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/auth") +@RequiredArgsConstructor +public class AppAuthController implements AppAuthApi { + + private final OAuthLoginUseCase oauthLoginUseCase; + + @Override + @PostMapping("/login/{provider}") + public ApiResponse login( + @PathVariable String provider, + @Valid @RequestBody OAuthLoginRequest request + ) { + return ApiResponse.success(oauthLoginUseCase.login(request.toCommand(OAuthProvider.from(provider)))); + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/request/OAuthLoginRequest.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/request/OAuthLoginRequest.java new file mode 100644 index 00000000..bc859a89 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/request/OAuthLoginRequest.java @@ -0,0 +1,24 @@ +package kr.ac.kookmin.stream.api.app.core.auth.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthLoginCommand; +import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthProvider; + +public record OAuthLoginRequest( + @NotBlank(message = "인가 코드를 입력해 주세요.") + String code, + + // PKCE code verifier 규격 (RFC 7636) + @NotBlank(message = "code verifier를 입력해 주세요.") + @Pattern(regexp = "^[A-Za-z0-9._~-]{43,128}$", message = "code verifier 형식이 올바르지 않습니다.") + String codeVerifier, + + @NotBlank(message = "redirect URI를 입력해 주세요.") + String redirectUri +) { + + public OAuthLoginCommand toCommand(OAuthProvider provider) { + return new OAuthLoginCommand(provider, code, codeVerifier, redirectUri); + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/response/OAuthLoginResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/response/OAuthLoginResponse.java new file mode 100644 index 00000000..3b26ba62 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/response/OAuthLoginResponse.java @@ -0,0 +1,9 @@ +package kr.ac.kookmin.stream.api.app.core.auth.response; + +/** + * @param termsAgreementRequired 필수 약관에 아직 동의하지 않았으면 true. 앱은 이 값으로 약관 화면을 띄운다 + */ +public record OAuthLoginResponse( + String accessToken, + boolean termsAgreementRequired +) {} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/usecase/OAuthLoginUseCase.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/usecase/OAuthLoginUseCase.java new file mode 100644 index 00000000..1f887967 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/usecase/OAuthLoginUseCase.java @@ -0,0 +1,55 @@ +package kr.ac.kookmin.stream.api.app.core.auth.usecase; + +import kr.ac.kookmin.stream.api.app.core.auth.response.OAuthLoginResponse; +import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthLoginCommand; +import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthUserInfo; +import kr.ac.kookmin.stream.auth.domain.oauth.service.OAuthService; +import kr.ac.kookmin.stream.member.domain.member.domain.Member; +import kr.ac.kookmin.stream.member.domain.member.domain.MemberProfileCommand; +import kr.ac.kookmin.stream.member.domain.member.service.MemberService; +import kr.ac.kookmin.stream.member.domain.member.service.MemberTermService; +import kr.ac.kookmin.stream.security.AuthTokenIssuer; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * provider 로그인으로 사용자를 확인하고, 회원을 찾거나 만들어 서비스 토큰을 발급한다. + *

+ * 전체를 한 트랜잭션으로 묶지 않는다. 묶으면 provider를 호출하는 동안 DB 커넥션을 잡고 있게 된다. + * 가입 뒤 계정 연결이 실패해도 다음 로그인에서 학번으로 회원을 찾아 다시 연결하므로 회원이 고아로 남지 않는다. + */ +@Component +@RequiredArgsConstructor +public class OAuthLoginUseCase { + + private final OAuthService oauthService; + private final MemberService memberService; + private final MemberTermService memberTermService; + private final AuthTokenIssuer authTokenIssuer; + + public OAuthLoginResponse login(OAuthLoginCommand command) { + OAuthUserInfo userInfo = oauthService.authenticate(command); + Member member = findOrRegister(userInfo); + + String accessToken = authTokenIssuer.issue(member.getId(), member.getRole(), member.getCouncilDepartment()); + // 회원을 이번에 만들었는지가 아니라 동의 기록으로 판단한다. 가입 후 동의하지 않고 이탈한 회원도 다시 약관 화면을 본다 + boolean termsAgreementRequired = !memberTermService.hasAgreedRequiredTerms(member.getId()); + return new OAuthLoginResponse(accessToken, termsAgreementRequired); + } + + private Member findOrRegister(OAuthUserInfo userInfo) { + MemberProfileCommand profile = new MemberProfileCommand( + userInfo.studentId(), userInfo.name(), userInfo.major(), userInfo.academicStatus()); + + return oauthService.findMemberId(userInfo.provider(), userInfo.providerUserId()) + .map(memberId -> memberService.updateProfile(memberId, profile)) + .orElseGet(() -> registerAndLink(userInfo, profile)); + } + + // 연결된 계정이 없으면 학번 기준으로 회원을 가입·갱신한다. 이관 회원이나 연결이 누락된 회원은 여기서 다시 연결된다 + private Member registerAndLink(OAuthUserInfo userInfo, MemberProfileCommand profile) { + Member member = memberService.registerOrUpdateByStudentId(profile); + oauthService.link(userInfo.provider(), userInfo.providerUserId(), member.getId()); + return member; + } +} From d016c360c939a9faeff574f3dc70fa8c6086da6e Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:50:41 +0900 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EA=B3=B5=EA=B0=9C=20=EA=B2=BD=EB=A1=9C=20=EC=98=88=EC=8B=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=8B=A4=EC=A0=9C=20=EA=B2=BD=EB=A1=9C=EB=A1=9C=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conventions/config-and-auth.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conventions/config-and-auth.md b/docs/conventions/config-and-auth.md index 926c1c8b..573237ab 100644 --- a/docs/conventions/config-and-auth.md +++ b/docs/conventions/config-and-auth.md @@ -225,7 +225,7 @@ ApiResponse apply( public enum PublicEndpoints { HEALTH_CHECK(List.of("/actuator/health")), SWAGGER(List.of("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**")), - AUTH(List.of("/auth/login")); // 예: 로그인 + AUTH(List.of("/v1/auth/login/*")); // OAuth 로그인 (/v1/auth/login/{provider}) // ... } ```