Skip to content
Merged
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
2 changes: 2 additions & 0 deletions api/app-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
@@ -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<OAuthLoginResponse> login(String provider, OAuthLoginRequest request);
}
Original file line number Diff line number Diff line change
@@ -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<OAuthLoginResponse> login(
@PathVariable String provider,
@Valid @RequestBody OAuthLoginRequest request
) {
return ApiResponse.success(oauthLoginUseCase.login(request.toCommand(OAuthProvider.from(provider))));
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package kr.ac.kookmin.stream.api.app.core.auth.response;

/**
* @param termsAgreementRequired 필수 약관에 아직 동의하지 않았으면 true. 앱은 이 값으로 약관 화면을 띄운다
*/
public record OAuthLoginResponse(
String accessToken,
boolean termsAgreementRequired
) {}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

provider를 호출하는 동안 DB 커넥션을 잡지 않으려고 UseCase 전체를 트랜잭션으로 묶지 않고, 연결이 실패하면 다음 로그인 때 학번으로 다시 연결되게 하신 부분 이해했습니다!

Original file line number Diff line number Diff line change
@@ -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 로그인으로 사용자를 확인하고, 회원을 찾거나 만들어 서비스 토큰을 발급한다.
* <p>
* 전체를 한 트랜잭션으로 묶지 않는다. 묶으면 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());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지금 보면 약관 동의 전에도 토큰이 정상 발급돼서, 앱 화면을 거치지 않고 api를 직접 호출하면 동의 없이도 다른 기능을 쓸 수 있을 것 같더라고요.
약관 동의 API를 붙일 때 미동의 회원의 api 접근도 같이 막을 계획이신지 궁금합니다!!

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;
}
}
2 changes: 1 addition & 1 deletion docs/conventions/config-and-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ ApiResponse<Void> 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})
// ...
}
```
Expand Down
Original file line number Diff line number Diff line change
@@ -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));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

app-api단에서 JwtProvider를 직접 호출하면 모듈 내부를 참조하여 Modulith 원칙에 어긋나고 검증에 걸리기 때문에, 외부에 노출할 수 있는 AuthTokenIssuer 파사드를 따로 두어 구현한 점 이해했습니다!

}

// 운영진도 학생 앱(/v1/app/**, STUDENT)을 쓰므로 ADMIN에게는 STUDENT를 함께 준다
private Set<Role> rolesOf(Role role) {
return role == Role.ADMIN ? EnumSet.of(Role.ADMIN, Role.STUDENT) : EnumSet.of(role);
}

// 학생회 부서 권한은 ADMIN에게만 싣는다
private Set<CouncilDepartment> councilDepartmentsOf(Role role, CouncilDepartment councilDepartment) {
if (role != Role.ADMIN || councilDepartment == null) {
return EnumSet.noneOf(CouncilDepartment.class);
}
return EnumSet.of(councilDepartment);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathPattern> ALL_PATH_PATTERNS = Arrays.stream(values())
Expand Down
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();
}
}
Loading