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
27 changes: 27 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# 로컬 실행(./gradlew bootRun) 전에 이 값들을 실제 환경변수로 export 해야 합니다.
# 이 파일 자체는 예시일 뿐이며 실제 비밀값을 채워 넣더라도 커밋하지 마세요 (.gitignore에서 .env는 이미 제외됩니다).

# --- JWT ---
# HS256 서명 키. 각각 최소 32바이트 이상이어야 합니다.
JWT_ACCESS_SECRET=
JWT_REFRESH_SECRET=
# 선택값. 미설정 시 기본값(3600초 / 1209600초=14일) 사용
JWT_ACCESS_TOKEN_EXPIRATION_SECONDS=3600
JWT_REFRESH_TOKEN_EXPIRATION_SECONDS=1209600

# --- Swagger ---
# 선택값. true로 켜면 dev/prod 프로필에서는 아래 SWAGGER_AUTH_* 가 필수가 됩니다.
SWAGGER_ENABLE=false
SWAGGER_AUTH_USERNAME=
SWAGGER_AUTH_PASSWORD=

# --- 카카오 소셜 로그인 ---
# Kakao Developers(https://developers.kakao.com) 앱의 REST API 키
KAKAO_CLIENT_ID=
# "Client Secret" 사용 설정을 켠 경우에만 필요, 그렇지 않으면 비워둡니다.
KAKAO_CLIENT_SECRET=

# --- 구글 소셜 로그인 ---
# Google Cloud Console > API 및 서비스 > 사용자 인증 정보에서 발급받은 OAuth 2.0 클라이언트
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
24 changes: 23 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,37 @@ jobs:
steps:
- name: Deploy to EC2
uses: appleboy/ssh-action@v1.0.3
env:
JWT_ACCESS_SECRET: ${{ secrets.JWT_ACCESS_SECRET }}
JWT_REFRESH_SECRET: ${{ secrets.JWT_REFRESH_SECRET }}
SWAGGER_ENABLE: ${{ secrets.SWAGGER_ENABLE }}
SWAGGER_AUTH_USERNAME: ${{ secrets.SWAGGER_AUTH_USERNAME }}
SWAGGER_AUTH_PASSWORD: ${{ secrets.SWAGGER_AUTH_PASSWORD }}
KAKAO_CLIENT_ID: ${{ secrets.KAKAO_CLIENT_ID }}
KAKAO_CLIENT_SECRET: ${{ secrets.KAKAO_CLIENT_SECRET }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USERNAME }}
key: ${{ secrets.EC2_SSH_KEY }}
envs: JWT_ACCESS_SECRET,JWT_REFRESH_SECRET,SWAGGER_ENABLE,SWAGGER_AUTH_USERNAME,SWAGGER_AUTH_PASSWORD,KAKAO_CLIENT_ID,KAKAO_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET
script: |
docker pull ${{ secrets.DOCKERHUB_USERNAME }}/promsearch:latest
docker stop promsearch || true
docker rm promsearch || true
docker run -d --name promsearch --restart unless-stopped -p 8080:8080 ${{ secrets.DOCKERHUB_USERNAME }}/promsearch:latest
docker run -d --name promsearch --restart unless-stopped -p 8080:8080 \
-e SPRING_PROFILES_ACTIVE=prod \
-e JWT_ACCESS_SECRET="$JWT_ACCESS_SECRET" \
-e JWT_REFRESH_SECRET="$JWT_REFRESH_SECRET" \
-e SWAGGER_ENABLE="$SWAGGER_ENABLE" \
-e SWAGGER_AUTH_USERNAME="$SWAGGER_AUTH_USERNAME" \
-e SWAGGER_AUTH_PASSWORD="$SWAGGER_AUTH_PASSWORD" \
-e KAKAO_CLIENT_ID="$KAKAO_CLIENT_ID" \
-e KAKAO_CLIENT_SECRET="$KAKAO_CLIENT_SECRET" \
-e GOOGLE_CLIENT_ID="$GOOGLE_CLIENT_ID" \
-e GOOGLE_CLIENT_SECRET="$GOOGLE_CLIENT_SECRET" \
${{ secrets.DOCKERHUB_USERNAME }}/promsearch:latest
docker image prune -f

- name: Notify Discord on failure
Expand Down
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# CLAUDE.md

This project's agent instructions live in [AGENTS.md](AGENTS.md). Read and follow it in full before working in this repository — it takes precedence over generic workflow defaults for architecture, naming, domain rules, and Git/PR conventions.
11 changes: 11 additions & 0 deletions INFRA_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@ GitHub Actions ── build & push ──▶ Docker Hub ── pull ──▶ EC
| `EC2_USERNAME` | EC2 SSH 접속 계정 |
| `EC2_SSH_KEY` | EC2 SSH 접속 프라이빗 키 |
| `DISCORD_WEBHOOK` | 배포 실패 알림용 Discord 웹훅 URL |
| `JWT_ACCESS_SECRET` | Access Token 서명 키 (HS256, 32바이트 이상) |
| `JWT_REFRESH_SECRET` | Refresh Token 서명 키 (HS256, 32바이트 이상) |
| `SWAGGER_ENABLE` | prod에서 Swagger 문서 노출 여부 (보통 `false`) |
| `SWAGGER_AUTH_USERNAME` | `SWAGGER_ENABLE=true`일 때 Swagger Basic Auth 계정 |
| `SWAGGER_AUTH_PASSWORD` | `SWAGGER_ENABLE=true`일 때 Swagger Basic Auth 비밀번호 |
| `KAKAO_CLIENT_ID` | 카카오 소셜 로그인 REST API 키 |
| `KAKAO_CLIENT_SECRET` | 카카오 Client Secret 사용 시에만 필요 (아니면 빈 값) |
| `GOOGLE_CLIENT_ID` | 구글 소셜 로그인 OAuth 2.0 클라이언트 ID |
| `GOOGLE_CLIENT_SECRET` | 구글 소셜 로그인 OAuth 2.0 클라이언트 Secret |

`deploy.yml`은 위 값들을 `docker run -e`로 컨테이너에 직접 전달합니다 (`docker run`에 아무 환경변수도 넘기지 않던 이전 버전에서는 컨테이너가 필수 설정값 검증에서 부팅에 실패했습니다).

## 브랜치 ↔ 파이프라인 매핑

Expand Down
14 changes: 14 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,17 @@ dependencies {
tasks.named('test') {
useJUnitPlatform()
}

/* 로컬 실행 시 .env가 있으면 bootRun에만 자동으로 주입한다 (커밋되지 않는 파일, CI/배포엔 영향 없음). */
tasks.named('bootRun') {
def envFile = file('.env')
if (envFile.exists()) {
environment envFile.readLines()
.findAll { line -> line.trim() && !line.trim().startsWith('#') && line.contains('=') }
.collectEntries { line ->
int separatorIndex = line.indexOf('=')
[(line.substring(0, separatorIndex).trim()): line.substring(separatorIndex + 1).trim()]
}
.findAll { key, value -> value }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.promsearch.auth.application;

import com.promsearch.auth.application.port.out.SocialLoginClient;
import com.promsearch.auth.application.port.out.SocialLoginClient.SocialUserInfo;
import com.promsearch.auth.domain.enums.SocialProvider;
import com.promsearch.auth.domain.exception.AuthDomainException;
import com.promsearch.auth.domain.exception.AuthErrorCode;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Service
@Slf4j
@RequiredArgsConstructor
public class SocialAuthCommandService implements SocialLoginUseCase {

private final List<SocialLoginClient> socialLoginClients;
private final SocialLoginTransactionService socialLoginTransactionService;

@Override
public LoginInfo socialLogin(SocialLoginCommand command) {
SocialLoginClient client = resolveClient(command.provider());
SocialUserInfo socialUserInfo = client.exchangeCodeAndFetchUserInfo(
command.authorizationCode(), command.redirectUri());

LoginInfo loginInfo = socialLoginTransactionService.completeLogin(command.provider(), socialUserInfo);

log.info("auth_social_login_succeeded userId={} provider={}", loginInfo.userId(), command.provider());

return loginInfo;
}

private SocialLoginClient resolveClient(SocialProvider provider) {
return socialLoginClients.stream()
.filter(client -> client.provider() == provider)
.findFirst()
.orElseThrow(() -> new AuthDomainException(AuthErrorCode.UNSUPPORTED_OAUTH_PROVIDER));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.promsearch.auth.application;

import com.promsearch.auth.domain.enums.SocialProvider;

public record SocialLoginCommand(
SocialProvider provider,
String authorizationCode,
String redirectUri
) {

public static SocialLoginCommand of(String provider, String authorizationCode, String redirectUri) {
return new SocialLoginCommand(SocialProvider.from(provider), authorizationCode, redirectUri);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package com.promsearch.auth.application;

import com.promsearch.auth.application.port.out.AccessTokenProvider;
import com.promsearch.auth.application.port.out.RefreshTokenProvider;
import com.promsearch.auth.application.port.out.RefreshTokenProvider.RefreshToken;
import com.promsearch.auth.application.port.out.RefreshTokenSessionRepository;
import com.promsearch.auth.application.port.out.SocialAccountRepository;
import com.promsearch.auth.application.port.out.SocialLoginClient.SocialUserInfo;
import com.promsearch.auth.application.port.out.TokenHasher;
import com.promsearch.auth.domain.RefreshTokenSession;
import com.promsearch.auth.domain.SocialAccount;
import com.promsearch.auth.domain.enums.SocialProvider;
import com.promsearch.auth.domain.exception.AuthDomainException;
import com.promsearch.auth.domain.exception.AuthErrorCode;
import com.promsearch.user.application.AuthUserInfo;
import com.promsearch.user.application.GetUserCredentialUseCase;
import com.promsearch.user.application.RegisterSocialUserCommand;
import com.promsearch.user.application.RegisterSocialUserUseCase;
import com.promsearch.user.application.SignupInfo;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Slf4j
@RequiredArgsConstructor
public class SocialLoginTransactionService {

private final SocialAccountRepository socialAccountRepository;
private final GetUserCredentialUseCase getUserCredentialUseCase;
private final RegisterSocialUserUseCase registerSocialUserUseCase;
private final AccessTokenProvider accessTokenProvider;
private final RefreshTokenProvider refreshTokenProvider;
private final RefreshTokenSessionRepository refreshTokenSessionRepository;
private final TokenHasher tokenHasher;

@Transactional
public LoginInfo completeLogin(SocialProvider provider, SocialUserInfo socialUserInfo) {
Long userId = socialAccountRepository
.findByProviderAndProviderUserId(provider, socialUserInfo.providerUserId())
.map(SocialAccount::getUserId)
.orElseGet(() -> provisionSocialUser(provider, socialUserInfo));

AuthUserInfo user = getUserCredentialUseCase.findById(userId)
.orElseThrow(() -> new AuthDomainException(AuthErrorCode.OAUTH_AUTHENTICATION_FAILED));
validateActiveUser(user);
AuthenticatedUserInfo authenticatedUser = AuthenticatedUserInfo.from(user);

RefreshToken refreshToken = refreshTokenProvider.createRefreshToken(authenticatedUser);
saveRefreshTokenSession(authenticatedUser.userId(), refreshToken, UUID.randomUUID().toString());

return LoginInfo.of(
accessTokenProvider.createAccessToken(authenticatedUser),
refreshToken.value(),
accessTokenProvider.getAccessTokenExpirationSeconds(),
authenticatedUser,
user.name(),
user.nickname()
);
}

private Long provisionSocialUser(SocialProvider provider, SocialUserInfo socialUserInfo) {
SignupInfo signupInfo = registerSocialUserUseCase.registerSocialUser(RegisterSocialUserCommand.of(
socialUserInfo.email(),
socialUserInfo.nickname(),
socialUserInfo.nickname(),
socialUserInfo.profileImageUrl()
));

socialAccountRepository.save(SocialAccount.create(
signupInfo.userId(), provider, socialUserInfo.providerUserId()));
log.info("auth_social_account_linked userId={} provider={}", signupInfo.userId(), provider);

return signupInfo.userId();
}

private void saveRefreshTokenSession(Long userId, RefreshToken refreshToken, String familyId) {
refreshTokenSessionRepository.save(RefreshTokenSession.create(
userId, tokenHasher.hash(refreshToken.value()), familyId, refreshToken.expiresAt()));
}

private void validateActiveUser(AuthUserInfo user) {
if (!user.active()) {
throw new AuthDomainException(AuthErrorCode.OAUTH_AUTHENTICATION_FAILED);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.promsearch.auth.application;

public interface SocialLoginUseCase {

LoginInfo socialLogin(SocialLoginCommand command);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.promsearch.auth.application.port.out;

import com.promsearch.auth.domain.SocialAccount;
import com.promsearch.auth.domain.enums.SocialProvider;
import java.util.Optional;

public interface SocialAccountRepository {

SocialAccount save(SocialAccount socialAccount);

Optional<SocialAccount> findByProviderAndProviderUserId(SocialProvider provider, String providerUserId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.promsearch.auth.application.port.out;

import com.promsearch.auth.domain.enums.SocialProvider;

public interface SocialLoginClient {

SocialProvider provider();

SocialUserInfo exchangeCodeAndFetchUserInfo(String authorizationCode, String redirectUri);

record SocialUserInfo(
String providerUserId,
String email,
String nickname,
String profileImageUrl
) {
}
}
42 changes: 42 additions & 0 deletions src/main/java/com/promsearch/auth/domain/SocialAccount.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.promsearch.auth.domain;

import com.promsearch.auth.domain.enums.SocialProvider;

public class SocialAccount {

private final Long id;
private final Long userId;
private final SocialProvider provider;
private final String providerUserId;

private SocialAccount(Long id, Long userId, SocialProvider provider, String providerUserId) {
this.id = id;
this.userId = userId;
this.provider = provider;
this.providerUserId = providerUserId;
}

public static SocialAccount create(Long userId, SocialProvider provider, String providerUserId) {
return new SocialAccount(null, userId, provider, providerUserId);
}

public static SocialAccount reconstruct(Long id, Long userId, SocialProvider provider, String providerUserId) {
return new SocialAccount(id, userId, provider, providerUserId);
}

public Long getId() {
return id;
}

public Long getUserId() {
return userId;
}

public SocialProvider getProvider() {
return provider;
}

public String getProviderUserId() {
return providerUserId;
}
}
21 changes: 21 additions & 0 deletions src/main/java/com/promsearch/auth/domain/enums/SocialProvider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.promsearch.auth.domain.enums;

import com.promsearch.auth.domain.exception.AuthDomainException;
import com.promsearch.auth.domain.exception.AuthErrorCode;

public enum SocialProvider {

KAKAO,
GOOGLE;

public static SocialProvider from(String value) {
if (value == null || value.isBlank()) {
throw new AuthDomainException(AuthErrorCode.UNSUPPORTED_OAUTH_PROVIDER);
}
try {
return SocialProvider.valueOf(value.trim().toUpperCase());
} catch (IllegalArgumentException e) {
throw new AuthDomainException(AuthErrorCode.UNSUPPORTED_OAUTH_PROVIDER);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@ public enum AuthErrorCode implements BaseCode {
AUTHENTICATION_REQUIRED(HttpStatus.UNAUTHORIZED, "AUTH-002", "로그인이 필요합니다."),
ACCESS_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "AUTH-003", "액세스 토큰이 만료되었습니다."),
INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH-004", "유효하지 않은 토큰입니다."),
FORBIDDEN(HttpStatus.FORBIDDEN, "AUTH-005", "접근 권한이 없습니다.");
FORBIDDEN(HttpStatus.FORBIDDEN, "AUTH-005", "접근 권한이 없습니다."),
UNSUPPORTED_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST, "AUTH-006", "지원하지 않는 소셜 로그인 제공자입니다."),
OAUTH_AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "AUTH-007", "소셜 로그인 인증에 실패했습니다."),
OAUTH_EMAIL_NOT_AVAILABLE(HttpStatus.BAD_REQUEST, "AUTH-008", "소셜 계정에서 이메일 정보를 가져올 수 없습니다."),
SOCIAL_ACCOUNT_ALREADY_LINKED(HttpStatus.CONFLICT, "AUTH-009", "이미 연동된 소셜 계정입니다."),
OAUTH_PROVIDER_RATE_LIMITED(HttpStatus.TOO_MANY_REQUESTS, "AUTH-010", "소셜 로그인 제공자 요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요."),
OAUTH_PROVIDER_BAD_RESPONSE(HttpStatus.BAD_GATEWAY, "AUTH-011", "소셜 로그인 제공자로부터 올바르지 않은 응답을 받았습니다."),
OAUTH_PROVIDER_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "AUTH-012", "소셜 로그인 제공자에 일시적으로 연결할 수 없습니다.");

private final HttpStatus httpStatus;
private final String code;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.promsearch.auth.infrastructure.oauth;

import jakarta.validation.constraints.NotBlank;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;

@ConfigurationProperties(prefix = "auth.oauth.google")
@Validated
public record GoogleOAuthProperties(
@NotBlank String clientId,
String clientSecret,
@NotBlank String tokenUri,
@NotBlank String userInfoUri
) {
}
Loading
Loading