diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/ErrorStatus.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/ErrorStatus.java index 74ca85dc..3ebad06b 100644 --- a/core/common/src/main/java/kr/ac/kookmin/stream/common/ErrorStatus.java +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/ErrorStatus.java @@ -12,4 +12,5 @@ public final class ErrorStatus { public static final int BAD_REQUEST = 400; public static final int CONFLICT = 409; public static final int FORBIDDEN = 403; + public static final int BAD_GATEWAY = 502; } diff --git a/core/domain/auth/build.gradle.kts b/core/domain/auth/build.gradle.kts index 0854131d..122cec81 100644 --- a/core/domain/auth/build.gradle.kts +++ b/core/domain/auth/build.gradle.kts @@ -4,6 +4,14 @@ plugins { description = "auth 도메인 — gateway:auth(Security/JWT)와는 별개의 도메인 계층 로직" +// gateway:auth와 프로젝트 이름(auth)이 같아 기본 좌표(kr.ac.kookmin:auth)가 겹치면 Gradle이 두 모듈을 같은 모듈로 보고 +// 한쪽을 다른 쪽으로 치환한다. group을 달리해 좌표를 분리하고, bootJar 안에서 jar 파일명도 겹치지 않게 바꾼다 +group = "kr.ac.kookmin.domain" + +base { + archivesName.set("domain-auth") +} + dependencies { implementation(project(":core:common")) diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/client/OAuthClient.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/client/OAuthClient.java new file mode 100644 index 00000000..7100c894 --- /dev/null +++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/client/OAuthClient.java @@ -0,0 +1,23 @@ +package kr.ac.kookmin.stream.auth.domain.oauth.client; + +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; + +/** + * provider별 OAuth 로그인 전략. 구현체는 provider마다 하나씩 모두 빈으로 등록하고, + * 요청의 provider에 맞는 구현체를 OAuthClientRegistry가 고른다. + */ +public interface OAuthClient { + + OAuthProvider provider(); + + /** provider에 등록한 redirect URI와 정확히 일치하는지. 외부 호출 전에 거르는 용도다. */ + boolean isAllowedRedirectUri(String redirectUri); + + /** + * code를 provider 토큰으로 교환하고 사용자 정보를 조회한다. + * provider 토큰은 이 메서드 안에서만 쓰고 밖으로 내보내지 않는다. + */ + OAuthUserInfo fetchUserInfo(OAuthLoginCommand command); +} diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthAccount.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthAccount.java new file mode 100644 index 00000000..eb75571e --- /dev/null +++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthAccount.java @@ -0,0 +1,28 @@ +package kr.ac.kookmin.stream.auth.domain.oauth.domain; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; + +/** + * provider 계정과 회원의 연결. 한 회원은 provider마다 계정을 하나씩 연결할 수 있다. + */ +@Getter +@EqualsAndHashCode +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class OAuthAccount { + + private Long id; + private Long memberId; + private OAuthProvider provider; + private String providerUserId; + + public static OAuthAccount create(Long memberId, OAuthProvider provider, String providerUserId) { + return new OAuthAccount(null, memberId, provider, providerUserId); + } + + public static OAuthAccount of(Long id, Long memberId, OAuthProvider provider, String providerUserId) { + return new OAuthAccount(id, memberId, provider, providerUserId); + } +} diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthErrorCode.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthErrorCode.java new file mode 100644 index 00000000..66884559 --- /dev/null +++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthErrorCode.java @@ -0,0 +1,21 @@ +package kr.ac.kookmin.stream.auth.domain.oauth.domain; + +import kr.ac.kookmin.stream.common.ErrorCode; +import kr.ac.kookmin.stream.common.ErrorStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.experimental.Accessors; + +@Getter +@Accessors(fluent = true) +@AllArgsConstructor +public enum OAuthErrorCode implements ErrorCode { + + UNSUPPORTED_OAUTH_PROVIDER(ErrorStatus.BAD_REQUEST, "지원하지 않는 로그인 방식입니다."), + REDIRECT_URI_NOT_ALLOWED(ErrorStatus.BAD_REQUEST, "허용되지 않은 redirect URI입니다."), + INVALID_AUTHORIZATION_CODE(ErrorStatus.UNAUTHORIZED, "로그인이 만료되었습니다. 다시 로그인해 주세요."), + OAUTH_PROVIDER_UNAVAILABLE(ErrorStatus.BAD_GATEWAY, "로그인 서버에 연결할 수 없습니다. 잠시 후 다시 시도해 주세요."); + + private final int status; + private final String message; +} diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthLoginCommand.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthLoginCommand.java new file mode 100644 index 00000000..71dcb3eb --- /dev/null +++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthLoginCommand.java @@ -0,0 +1,8 @@ +package kr.ac.kookmin.stream.auth.domain.oauth.domain; + +public record OAuthLoginCommand( + OAuthProvider provider, + String code, + String codeVerifier, + String redirectUri +) {} diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthProvider.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthProvider.java new file mode 100644 index 00000000..62826c66 --- /dev/null +++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthProvider.java @@ -0,0 +1,16 @@ +package kr.ac.kookmin.stream.auth.domain.oauth.domain; + +import java.util.Arrays; +import kr.ac.kookmin.stream.common.BusinessException; + +public enum OAuthProvider { + KCONNECT; + + /** 로그인 경로의 provider 값(예: "kconnect")을 대소문자 구분 없이 변환한다. */ + public static OAuthProvider from(String value) { + return Arrays.stream(values()) + .filter(provider -> provider.name().equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new BusinessException(OAuthErrorCode.UNSUPPORTED_OAUTH_PROVIDER)); + } +} diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthUserInfo.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthUserInfo.java new file mode 100644 index 00000000..697009b8 --- /dev/null +++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthUserInfo.java @@ -0,0 +1,17 @@ +package kr.ac.kookmin.stream.auth.domain.oauth.domain; + +/** + * provider에서 확인한 사용자 정보. + *

+ * providerUserId는 provider 안에서 바뀌지 않는 고유 ID로, 회원을 찾는 키가 된다. + * studentId·major·academicStatus는 학번·학과·학적을 주지 않는 provider(구글·카카오)에서 null일 수 있다. + * academicStatus는 provider가 주는 학적 상태 값을 그대로 담는다. + */ +public record OAuthUserInfo( + OAuthProvider provider, + String providerUserId, + String studentId, + String name, + String major, + String academicStatus +) {} diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/repository/OAuthAccountRepository.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/repository/OAuthAccountRepository.java new file mode 100644 index 00000000..1544c562 --- /dev/null +++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/repository/OAuthAccountRepository.java @@ -0,0 +1,10 @@ +package kr.ac.kookmin.stream.auth.domain.oauth.repository; + +import java.util.Optional; +import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthAccount; +import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthProvider; + +public interface OAuthAccountRepository { + Optional findByProviderAndProviderUserId(OAuthProvider provider, String providerUserId); + OAuthAccount save(OAuthAccount oauthAccount); +} diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/OAuthService.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/OAuthService.java new file mode 100644 index 00000000..14fd2c3f --- /dev/null +++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/OAuthService.java @@ -0,0 +1,12 @@ +package kr.ac.kookmin.stream.auth.domain.oauth.service; + +import java.util.Optional; +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; + +public interface OAuthService { + OAuthUserInfo authenticate(OAuthLoginCommand command); + Optional findMemberId(OAuthProvider provider, String providerUserId); + void link(OAuthProvider provider, String providerUserId, Long memberId); +} diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/impl/OAuthClientRegistry.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/impl/OAuthClientRegistry.java new file mode 100644 index 00000000..46417aa3 --- /dev/null +++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/impl/OAuthClientRegistry.java @@ -0,0 +1,42 @@ +package kr.ac.kookmin.stream.auth.domain.oauth.service.impl; + +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +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.OAuthProvider; +import kr.ac.kookmin.stream.common.BusinessException; +import org.springframework.stereotype.Component; + +/** + * 빈으로 등록된 OAuthClient 구현체를 provider별로 모아 두고, 요청의 provider에 맞는 구현체를 돌려준다. + * 같은 provider의 구현체가 둘이면 기동 시 실패한다. + */ +@Component +class OAuthClientRegistry { + + private final Map clients; + + OAuthClientRegistry(List clients) { + this.clients = clients.stream() + .collect(Collectors.toMap( + OAuthClient::provider, + Function.identity(), + (first, second) -> { + throw new IllegalStateException("OAuthClient 구현체가 중복됐습니다: " + first.provider()); + }, + () -> new EnumMap<>(OAuthProvider.class) + )); + } + + OAuthClient get(OAuthProvider provider) { + OAuthClient client = clients.get(provider); + if (client == null) { + throw new BusinessException(OAuthErrorCode.UNSUPPORTED_OAUTH_PROVIDER); + } + return client; + } +} diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/impl/OAuthServiceImpl.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/impl/OAuthServiceImpl.java new file mode 100644 index 00000000..ac0f3413 --- /dev/null +++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/impl/OAuthServiceImpl.java @@ -0,0 +1,46 @@ +package kr.ac.kookmin.stream.auth.domain.oauth.service.impl; + +import java.util.Optional; +import kr.ac.kookmin.stream.auth.domain.oauth.client.OAuthClient; +import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthAccount; +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.auth.domain.oauth.repository.OAuthAccountRepository; +import kr.ac.kookmin.stream.auth.domain.oauth.service.OAuthService; +import kr.ac.kookmin.stream.common.BusinessException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +class OAuthServiceImpl implements OAuthService { + + private final OAuthClientRegistry oauthClientRegistry; + private final OAuthAccountRepository oauthAccountRepository; + + // 외부 호출만 하므로 트랜잭션을 걸지 않는다 + @Override + public OAuthUserInfo authenticate(OAuthLoginCommand command) { + OAuthClient client = oauthClientRegistry.get(command.provider()); + if (!client.isAllowedRedirectUri(command.redirectUri())) { + throw new BusinessException(OAuthErrorCode.REDIRECT_URI_NOT_ALLOWED); + } + + return client.fetchUserInfo(command); + } + + @Override + public Optional findMemberId(OAuthProvider provider, String providerUserId) { + return oauthAccountRepository.findByProviderAndProviderUserId(provider, providerUserId) + .map(OAuthAccount::getMemberId); + } + + @Override + @Transactional + public void link(OAuthProvider provider, String providerUserId, Long memberId) { + oauthAccountRepository.save(OAuthAccount.create(memberId, provider, providerUserId)); + } +} diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/package-info.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/package-info.java new file mode 100644 index 00000000..ae0c6da6 --- /dev/null +++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/package-info.java @@ -0,0 +1,8 @@ +/** + * 계층 패키지(domain/{도메인}/{domain|repository|service})를 그대로 공개하기 위해 OPEN으로 둔다. + * service.impl 접근 차단은 bootstrap의 DomainImplAccessTests(ArchUnit)가 담당한다. + */ +@ApplicationModule(type = ApplicationModule.Type.OPEN) +package kr.ac.kookmin.stream.auth; + +import org.springframework.modulith.ApplicationModule; diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index 893436cd..35e7486d 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -517,6 +517,32 @@ public class S3FileStorageClient implements FileStorageClient { ... } public class LocalFileStorageClient implements FileStorageClient { ... } ``` +- **단, 요청마다 구현체를 골라 써야 하는 포트는 구현체를 모두 Bean으로 띄우고 레지스트리로 고른다.** 설정값으로 하나를 고르는 위 경우와 달리, 여러 구현체가 동시에 쓰이는 경우다(예: 로그인 provider별 `OAuthClient` — 요청 경로의 `{provider}`로 고른다). 포트에 자신의 키를 돌려주는 메서드를 두고, 도메인의 `service.impl`에 둔 package-private 레지스트리가 `List<포트>`를 주입받아 `EnumMap`으로 모은다. 같은 키의 구현체가 둘이면 기동 시 실패시키고, 없는 키를 요청하면 도메인 `ErrorCode`로 400을 던진다. 새 구현체를 추가할 때 서비스·UseCase·컨트롤러는 고치지 않는다. + +```java +// core:domain:auth — domain/oauth/client (공개 포트) +public interface OAuthClient { + OAuthProvider provider(); + OAuthUserInfo fetchUserInfo(OAuthLoginCommand command); +} + +// core:domain:auth — domain/oauth/service/impl (비공개) +@Component +class OAuthClientRegistry { + + private final Map clients; + + OAuthClientRegistry(List clients) { + this.clients = clients.stream().collect(Collectors.toMap( + OAuthClient::provider, Function.identity(), + (first, second) -> { throw new IllegalStateException("OAuthClient 구현체가 중복됐습니다: " + first.provider()); }, + () -> new EnumMap<>(OAuthProvider.class))); + } + + OAuthClient get(OAuthProvider provider) { ... } // 없으면 BusinessException(UNSUPPORTED_OAUTH_PROVIDER) +} +``` + - **외부 SDK 클라이언트(`S3Client`, `S3Presigner` 등)는 구현체 생성자에서 만들지 않고, 같은 패키지의 설정 클래스(`@Configuration`)에서 `@Bean`으로 등록해 주입받는다.** 설정 클래스에도 구현체와 같은 `@ConditionalOnProperty`를 붙인다. SDK 클라이언트는 `close()`가 필요한 자원인데, 빈으로 등록하면 종료 시 스프링이 대신 호출한다. ```java diff --git a/infrastructure/db/build.gradle.kts b/infrastructure/db/build.gradle.kts index 903dc34b..2b98ca65 100644 --- a/infrastructure/db/build.gradle.kts +++ b/infrastructure/db/build.gradle.kts @@ -6,6 +6,7 @@ description = "JPA Entity, RepositoryImpl, Flyway 마이그레이션 (MySQL)" dependencies { implementation(project(":core:common")) + implementation(project(":core:domain:auth")) implementation(project(":core:domain:member")) implementation(project(":core:domain:event")) implementation(project(":core:domain:welfare")) diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/auth/OAuthAccountJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/auth/OAuthAccountJpaEntity.java new file mode 100644 index 00000000..2810a3d7 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/auth/OAuthAccountJpaEntity.java @@ -0,0 +1,64 @@ +package kr.ac.kookmin.stream.db.auth; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthAccount; +import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthProvider; +import kr.ac.kookmin.stream.db.common.BaseTimeEntity; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "oauth_accounts", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_oauth_accounts_provider_provider_user_id", + columnNames = {"provider", "provider_user_id"} + ), + @UniqueConstraint( + name = "uk_oauth_accounts_member_id_provider", + columnNames = {"member_id", "provider"} + ) + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OAuthAccountJpaEntity extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "oauth_account_id") + private Long id; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private OAuthProvider provider; + + @Column(name = "provider_user_id", nullable = false) + private String providerUserId; + + private OAuthAccountJpaEntity(OAuthAccount oauthAccount) { + this.id = oauthAccount.getId(); + this.memberId = oauthAccount.getMemberId(); + this.provider = oauthAccount.getProvider(); + this.providerUserId = oauthAccount.getProviderUserId(); + } + + public static OAuthAccountJpaEntity from(OAuthAccount oauthAccount) { + return new OAuthAccountJpaEntity(oauthAccount); + } + + public OAuthAccount toDomain() { + return OAuthAccount.of(id, memberId, provider, providerUserId); + } +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/auth/OAuthAccountJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/auth/OAuthAccountJpaRepository.java new file mode 100644 index 00000000..801f2aab --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/auth/OAuthAccountJpaRepository.java @@ -0,0 +1,9 @@ +package kr.ac.kookmin.stream.db.auth; + +import java.util.Optional; +import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthProvider; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OAuthAccountJpaRepository extends JpaRepository { + Optional findByProviderAndProviderUserId(OAuthProvider provider, String providerUserId); +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/auth/OAuthAccountRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/auth/OAuthAccountRepositoryImpl.java new file mode 100644 index 00000000..f2f74583 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/auth/OAuthAccountRepositoryImpl.java @@ -0,0 +1,26 @@ +package kr.ac.kookmin.stream.db.auth; + +import java.util.Optional; +import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthAccount; +import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthProvider; +import kr.ac.kookmin.stream.auth.domain.oauth.repository.OAuthAccountRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class OAuthAccountRepositoryImpl implements OAuthAccountRepository { + + private final OAuthAccountJpaRepository oauthAccountJpaRepository; + + @Override + public Optional findByProviderAndProviderUserId(OAuthProvider provider, String providerUserId) { + return oauthAccountJpaRepository.findByProviderAndProviderUserId(provider, providerUserId) + .map(OAuthAccountJpaEntity::toDomain); + } + + @Override + public OAuthAccount save(OAuthAccount oauthAccount) { + return oauthAccountJpaRepository.save(OAuthAccountJpaEntity.from(oauthAccount)).toDomain(); + } +} diff --git a/infrastructure/db/src/main/resources/db/migration/V13__create_oauth_accounts_table.sql b/infrastructure/db/src/main/resources/db/migration/V13__create_oauth_accounts_table.sql new file mode 100644 index 00000000..99237dab --- /dev/null +++ b/infrastructure/db/src/main/resources/db/migration/V13__create_oauth_accounts_table.sql @@ -0,0 +1,16 @@ +CREATE TABLE oauth_accounts ( + oauth_account_id BIGINT AUTO_INCREMENT PRIMARY KEY, + member_id BIGINT NOT NULL, + provider VARCHAR(20) NOT NULL, -- OAuthProvider.name() + provider_user_id VARCHAR(255) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +-- 로그인할 때 provider 계정으로 회원을 찾는다 +CREATE UNIQUE INDEX uk_oauth_accounts_provider_provider_user_id + ON oauth_accounts (provider, provider_user_id); + +-- 한 회원에게 같은 provider 계정이 둘 연결되지 않게 막는다 +CREATE UNIQUE INDEX uk_oauth_accounts_member_id_provider + ON oauth_accounts (member_id, provider);