-
Notifications
You must be signed in to change notification settings - Fork 0
[Feat/#87] OAuth 로그인 API 추가 #92
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5dcf2fb
7b02b6f
e6c2f86
d016c36
4f450f0
97b5dbb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| ) {} |
| 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()); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 지금 보면 약관 동의 전에도 토큰이 정상 발급돼서, 앱 화면을 거치지 않고 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; | ||
| } | ||
| } | ||
| 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)); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
|---|---|---|
| @@ -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(); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
provider를 호출하는 동안 DB 커넥션을 잡지 않으려고 UseCase 전체를 트랜잭션으로 묶지 않고, 연결이 실패하면 다음 로그인 때 학번으로 다시 연결되게 하신 부분 이해했습니다!