diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 00000000..df1c2f81 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,57 @@ +# Backend auth +SERVER_PORT=38010 + +# JWT_SECRET can be any long random string. Generate one with: openssl rand -base64 64 +JWT_SECRET=replace-with-long-random-jwt-secret +JWT_ACCESS_TTL_SECONDS=900 +JWT_REFRESH_TTL_SECONDS=1209600 +JWT_ACCESS_TOKEN_TYPE=Bearer + +# ENCRYPTION_KEY must decode to exactly 32 bytes. Generate one with: openssl rand -base64 32 +ENCRYPTION_KEY=replace-with-base64-encoded-32-byte-key + +# OAuth/PKCE runtime policy +OAUTH_STATE_TTL=5m +OAUTH_STATE_BYTE_LENGTH=32 +PKCE_CODE_VERIFIER_BYTE_LENGTH=32 +REFRESH_TOKEN_BYTE_LENGTH=32 + +# Refresh token cookie +REFRESH_TOKEN_COOKIE_NAME=refresh_token +REFRESH_TOKEN_COOKIE_PATH=/api/auth +REFRESH_TOKEN_COOKIE_SAME_SITE=Strict +REFRESH_TOKEN_COOKIE_SECURE=true +REFRESH_TOKEN_COOKIE_HTTP_ONLY=true + +# GitHub OAuth +GITHUB_OAUTH_CLIENT_ID=your-github-oauth-client-id +GITHUB_OAUTH_CLIENT_SECRET=your-github-oauth-client-secret +GITHUB_OAUTH_REDIRECT_URI=https://your-domain/auth/callback +GITHUB_OAUTH_BASE_URL=https://github.com +GITHUB_OAUTH_TOKEN_TYPE=bearer +GITHUB_OAUTH_CODE_CHALLENGE_METHOD=S256 +GITHUB_API_BASE_URL=https://api.github.com +GITHUB_API_VERSION=2022-11-28 +GITHUB_CONNECT_TIMEOUT=3s +GITHUB_READ_TIMEOUT=5s + +# CORS +CORS_ALLOWED_ORIGINS=https://your-domain + +# Infrastructure. +# In deployment, set these from the root compose or server environment. +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +POSTGRES_DB=stackup +POSTGRES_USER=stackup +POSTGRES_PASSWORD=stackup +RABBITMQ_HOST=rabbitmq +RABBITMQ_PORT=5672 +RABBITMQ_USER=stackup +RABBITMQ_PASSWORD=stackup +S3_ENDPOINT=http://minio:9000 +S3_ACCESS_KEY=minioadmin +S3_SECRET_KEY=minioadmin +S3_BUCKET=stackup +S3_REGION=us-east-1 +S3_PATH_STYLE=true diff --git a/backend/.gitignore b/backend/.gitignore index c2065bc2..344ef7cc 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -35,3 +35,8 @@ out/ ### VS Code ### .vscode/ + +### Environment ### +.env +.env.* +!.env.example diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..4bb53c0e --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,14 @@ +# Build stage +FROM gradle:8.12-jdk21 AS build +WORKDIR /home/gradle/src +COPY --chown=gradle:gradle . . +RUN chmod +x gradlew +RUN ./gradlew bootJar -x test --no-daemon + +# Run stage +FROM eclipse-temurin:21-jre-jammy +WORKDIR /app +COPY --from=build /home/gradle/src/build/libs/stackup-0.0.1-SNAPSHOT.jar app.jar + +EXPOSE 38010 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 00000000..c401fa63 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,280 @@ +# StackUp Core Server + +StackUp Core Server는 StackUp 서비스의 인증, 사용자, GitHub 연동, 분석 요청 중계를 담당하는 Spring Boot API 서버입니다. + +Core Server는 GitHub OAuth 로그인 흐름을 처리하고, GitHub access token을 암호화해서 보관합니다. GitHub access token은 Core Server 내부에서만 사용하며 프론트엔드나 AI 서버로 전달하지 않습니다. + +## 역할 + +- GitHub OAuth 로그인 URL 생성 +- OAuth `state` 및 PKCE `code_verifier` 저장/검증 +- GitHub OAuth `code`를 GitHub access token으로 교환 +- GitHub 사용자 프로필/이메일 조회 +- StackUp access token 발급 +- refresh token 발급, 저장, 회전, 폐기 +- 현재 사용자 프로필 조회 +- AI 서버와 분석 요청/완료 callback 연동 + +## 인증 흐름 + +1. 프론트엔드가 `POST /api/auth/github`를 호출합니다. +2. Core Server가 `state`, `code_verifier`, `code_challenge`를 생성합니다. +3. Core Server가 `state -> code_verifier`를 DB에 저장하고 GitHub 로그인 URL을 응답합니다. +4. 프론트엔드는 응답의 `authorizationUrl`로 브라우저를 이동시킵니다. +5. 사용자가 GitHub에서 권한을 승인합니다. +6. GitHub가 `GITHUB_OAUTH_REDIRECT_URI`로 `code`, `state`를 전달합니다. +7. 프론트엔드는 callback URL에서 `code`, `state`를 읽어 Core Server callback API로 전달합니다. +8. Core Server는 `state`로 `code_verifier`를 조회하고 해당 state를 삭제합니다. +9. Core Server는 GitHub token API에 `code`, `client_id`, `client_secret`, `redirect_uri`, `code_verifier`를 전달합니다. +10. GitHub가 PKCE를 검증하고 GitHub access token을 발급합니다. +11. Core Server는 GitHub access token으로 `/user`, `/user/emails`를 조회합니다. +12. Core Server는 GitHub numeric id 기준으로 사용자를 생성하거나 갱신합니다. +13. Core Server는 StackUp access token을 response body로 반환합니다. +14. Core Server는 refresh token raw 값을 HttpOnly cookie로 내려주고, DB에는 refresh token hash만 저장합니다. +15. 프론트엔드는 이후 API 요청에 `Authorization: Bearer {accessToken}`을 붙입니다. + +## 배포 포트 + +StackUp 서비스 포트는 아래 기준으로 고정합니다. + +| 대상 | 포트 | +| --- | ---: | +| Nginx | 38000 | +| Vite Frontend | 38001 | +| Core Backend | 38010 | +| Realtime Server | 38020 | +| AI Server | 38030 | +| PostgreSQL | 38040 | +| RabbitMQ AMQP | 38050 | +| RabbitMQ Management | 38051 | +| MinIO API | 38060 | +| MinIO Console | 38061 | + +Core Server의 내부 실행 포트는 `SERVER_PORT=38010`으로 맞춥니다. root compose, Nginx upstream, Dockerfile `EXPOSE`, `SERVER_PORT`는 모두 이 값을 기준으로 맞춰야 합니다. + +요청 흐름 예시는 아래와 같습니다. + +```text +브라우저 + -> Nginx:38000 + -> Core Backend:38010 + -> Spring Boot server.port=38010 +``` + +## 환경변수 주입 방식 + +Spring Boot 설정은 [application.yml](src/main/resources/application.yml)의 `${...}` 표현식을 통해 환경변수에서 읽습니다. + +예를 들어: + +```yaml +server: + port: ${SERVER_PORT:38010} +``` + +컨테이너 환경변수에 `SERVER_PORT=38010`이 들어가면 Spring Boot의 `server.port`가 `38010`이 됩니다. 값이 없으면 기본값 `38010`을 사용합니다. + +`.env` 파일은 Spring Boot가 자동으로 읽지 않습니다. root `docker-compose.yml`에서 `env_file` 또는 `environment`로 컨테이너 환경변수에 주입해야 실제로 적용됩니다. + +root compose 예시: + +```yaml +services: + backend: + build: + context: ./backend + env_file: + - ./backend/.env + environment: + SERVER_PORT: 38010 + POSTGRES_HOST: postgres + ports: + - "38010:38010" +``` + +## 필수 환경변수 + +인증: + +```env +SERVER_PORT=38010 +JWT_SECRET=replace-with-long-random-jwt-secret +JWT_ACCESS_TTL_SECONDS=900 +JWT_REFRESH_TTL_SECONDS=1209600 +JWT_ACCESS_TOKEN_TYPE=Bearer +ENCRYPTION_KEY=replace-with-base64-encoded-32-byte-key +``` + +`JWT_SECRET`은 StackUp JWT access token 서명에 사용합니다. Base64 형식일 필요는 없고 충분히 긴 랜덤 문자열이면 됩니다. + +생성 예시: + +```bash +openssl rand -base64 64 +``` + +`ENCRYPTION_KEY`는 GitHub access token 암호화에 사용합니다. Base64 decode 결과가 정확히 32바이트여야 합니다. + +생성 예시: + +```bash +openssl rand -base64 32 +``` + +OAuth/PKCE: + +```env +OAUTH_STATE_TTL=5m +OAUTH_STATE_BYTE_LENGTH=32 +PKCE_CODE_VERIFIER_BYTE_LENGTH=32 +REFRESH_TOKEN_BYTE_LENGTH=32 +``` + +Refresh token cookie: + +```env +REFRESH_TOKEN_COOKIE_NAME=refresh_token +REFRESH_TOKEN_COOKIE_PATH=/api/auth +REFRESH_TOKEN_COOKIE_SAME_SITE=Strict +REFRESH_TOKEN_COOKIE_SECURE=true +REFRESH_TOKEN_COOKIE_HTTP_ONLY=true +``` + +GitHub OAuth/API: + +```env +GITHUB_OAUTH_CLIENT_ID=your-github-oauth-client-id +GITHUB_OAUTH_CLIENT_SECRET=your-github-oauth-client-secret +GITHUB_OAUTH_REDIRECT_URI=https://your-domain/auth/callback +GITHUB_OAUTH_BASE_URL=https://github.com +GITHUB_OAUTH_TOKEN_TYPE=bearer +GITHUB_OAUTH_CODE_CHALLENGE_METHOD=S256 +GITHUB_API_BASE_URL=https://api.github.com +GITHUB_API_VERSION=2022-11-28 +GITHUB_CONNECT_TIMEOUT=3s +GITHUB_READ_TIMEOUT=5s +``` + +`GITHUB_OAUTH_REDIRECT_URI`는 GitHub OAuth App의 `Authorization callback URL`과 정확히 같아야 합니다. `http`/`https`, domain, port, path 중 하나라도 다르면 GitHub OAuth가 실패합니다. + +GitHub OAuth/프로필 조회는 사용자 로그인 흐름을 직접 막는 동기 요청이므로 timeout을 짧게 가져갑니다. + +- `GITHUB_CONNECT_TIMEOUT=3s`: 연결 자체가 3초 이상 걸리면 네트워크 문제로 보고 빠르게 실패 처리 +- `GITHUB_READ_TIMEOUT=5s`: OAuth/프로필 조회는 응답이 작으므로 5초 이상 지연되면 실패 처리 + +CORS: + +```env +CORS_ALLOWED_ORIGINS=https://your-domain +``` + +프론트엔드가 여러 origin에서 접근해야 하면 comma-separated 값으로 주입합니다. + +```env +CORS_ALLOWED_ORIGINS=https://your-domain,https://www.your-domain +``` + +인프라: + +```env +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +POSTGRES_DB=stackup +POSTGRES_USER=stackup +POSTGRES_PASSWORD=stackup + +RABBITMQ_HOST=rabbitmq +RABBITMQ_PORT=5672 +RABBITMQ_USER=stackup +RABBITMQ_PASSWORD=stackup + +S3_ENDPOINT=http://minio:9000 +S3_ACCESS_KEY=minioadmin +S3_SECRET_KEY=minioadmin +S3_BUCKET=stackup +S3_REGION=us-east-1 +S3_PATH_STYLE=true +``` + +외부에 노출되는 host port는 root compose에서 `38040`, `38050`, `38051`, `38060`, `38061`로 매핑합니다. 컨테이너 내부 통신은 서비스명과 내부 포트를 기준으로 맞춥니다. + +## Docker + +backend 디렉터리에는 Dockerfile만 둡니다. compose 구성은 repository root에서 관리합니다. + +이미지 빌드: + +```bash +docker build -t stackup-core-server ./backend +``` + +Dockerfile은 이미지를 만드는 책임만 가집니다. DB 주소, GitHub OAuth secret, 배포별 환경값은 root compose 또는 서버 환경변수로 전달합니다. + +root compose 포트 예시: + +```yaml +services: + nginx: + ports: + - "38000:80" + + frontend: + ports: + - "38001:38001" + + backend: + image: stackup-core-server + environment: + SERVER_PORT: 38010 + ports: + - "38010:38010" + + realtime: + ports: + - "38020:38020" + + ai: + ports: + - "38030:38030" + + postgres: + ports: + - "38040:5432" + + rabbitmq: + ports: + - "38050:5672" + - "38051:15672" + + minio: + ports: + - "38060:9000" + - "38061:9001" +``` + +Nginx reverse proxy를 사용할 때 `GITHUB_OAUTH_REDIRECT_URI`에는 내부 backend port를 넣지 않습니다. 브라우저가 실제 접근하는 프론트엔드 callback URL을 넣습니다. + +## 보안 기준 + +- GitHub client secret은 Core Server 환경변수로만 주입합니다. +- GitHub access token은 AES-GCM으로 암호화해서 DB에 저장합니다. +- GitHub access token은 Core Server 밖으로 전달하지 않습니다. +- refresh token raw 값은 DB에 저장하지 않고 SHA-256 hash만 저장합니다. +- refresh token raw 값은 HttpOnly cookie로만 전달합니다. +- GitHub client secret, GitHub access token, refresh token raw 값은 로그에 남기지 않습니다. +- AI 서버는 GitHub token을 직접 받지 않고, Core Server가 준비한 분석 입력 데이터 또는 S3 key를 사용합니다. + +## 검증 + +전체 테스트: + +```bash +./gradlew.bat test +``` + +배포 후 헬스 체크와 OAuth URL 발급 API는 Nginx 경유 주소로 확인합니다. + +```bash +curl https://your-domain/api/system/health +curl -X POST https://your-domain/api/auth/github +``` diff --git a/backend/src/main/java/com/stackup/stackup/auth/application/AuthService.java b/backend/src/main/java/com/stackup/stackup/auth/application/AuthService.java index 6a5d737f..002b5c0f 100644 --- a/backend/src/main/java/com/stackup/stackup/auth/application/AuthService.java +++ b/backend/src/main/java/com/stackup/stackup/auth/application/AuthService.java @@ -3,54 +3,91 @@ import com.stackup.stackup.auth.infrastructure.GithubOAuthClient; import com.stackup.stackup.auth.application.dto.GithubCallbackResult; import com.stackup.stackup.auth.application.dto.GithubLoginResult; +import com.stackup.stackup.auth.application.dto.GithubUserProfile; +import com.stackup.stackup.auth.application.dto.GithubUserUpsertResult; +import com.stackup.stackup.auth.application.dto.OAuthStateIssueResult; +import com.stackup.stackup.auth.application.dto.RefreshTokenIssueResult; import com.stackup.stackup.auth.application.dto.RefreshTokenResult; +import com.stackup.stackup.auth.application.dto.RefreshTokenRotationResult; import com.stackup.stackup.auth.application.dto.StreamTokenResult; +import com.stackup.stackup.common.config.properties.SecurityProperties; +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; import com.stackup.stackup.common.security.JwtTokenProvider; import com.stackup.stackup.common.security.StreamTokenProvider; -import java.util.UUID; +import com.stackup.stackup.github.infrastructure.GithubApiClient; +import com.stackup.stackup.github.infrastructure.GithubTokenCipher; +import com.stackup.stackup.github.infrastructure.dto.GithubUserResponse; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.stereotype.Service; @Service -public class AuthService { - - private static final long SKELETON_USER_ID = 1L; - private static final String SKELETON_GITHUB_USERNAME = "stackup-user"; - - private final GithubOAuthClient githubOAuthClient; - private final JwtTokenProvider jwtTokenProvider; - private final StreamTokenProvider streamTokenProvider; - - public AuthService( - GithubOAuthClient githubOAuthClient, - JwtTokenProvider jwtTokenProvider, - StreamTokenProvider streamTokenProvider - ) { - this.githubOAuthClient = githubOAuthClient; - this.jwtTokenProvider = jwtTokenProvider; - this.streamTokenProvider = streamTokenProvider; - } +public record AuthService( + GithubOAuthClient githubOAuthClient, + OAuthStateService oauthStateService, + GithubApiClient githubApiClient, + GithubTokenCipher githubTokenCipher, + GithubUserService githubUserService, + RefreshTokenService refreshTokenService, + JwtTokenProvider jwtTokenProvider, + StreamTokenProvider streamTokenProvider, + SecurityProperties securityProperties +) { public GithubLoginResult startGithubLogin() { - String state = UUID.randomUUID().toString(); - return new GithubLoginResult(githubOAuthClient.buildAuthorizationUrl(state), state); + OAuthStateIssueResult oauthState = oauthStateService.issueGithubStateWithPkce(); + return new GithubLoginResult( + githubOAuthClient.buildAuthorizationUrl(oauthState.state(), oauthState.codeChallenge()), + oauthState.state() + ); } public GithubCallbackResult completeGithubLogin(String code, String state) { + if (code == null || code.isBlank()) { + throw new DomainException(ApiErrorCode.AUTH_GITHUB_OAUTH_FAILED); + } + String codeVerifier = oauthStateService.consumeGithubCodeVerifier(state); + String githubAccessToken = githubOAuthClient.exchangeCode(code, codeVerifier); + GithubUserResponse githubUser = githubApiClient.getUser(githubAccessToken); + String email = githubUser.email() == null || githubUser.email().isBlank() + ? githubApiClient.getPrimaryVerifiedEmail(githubAccessToken).orElse(null) + : githubUser.email(); + String encryptedGithubAccessToken = githubTokenCipher.encrypt(githubAccessToken); + GithubUserUpsertResult upsertResult = githubUserService.upsertGithubUser( + new GithubUserProfile( + githubUser.id(), + githubUser.login(), + email, + githubUser.avatarUrl() + ), + encryptedGithubAccessToken + ); + RefreshTokenIssueResult refreshToken = refreshTokenService.issue(upsertResult.user().id()); + return new GithubCallbackResult( - jwtTokenProvider.createAccessToken(SKELETON_USER_ID), - createRefreshTokenStub(), - SKELETON_USER_ID, - SKELETON_GITHUB_USERNAME + jwtTokenProvider.createAccessToken(upsertResult.user().id()), + securityProperties.accessTokenType(), + securityProperties.accessTokenTtlSeconds(), + upsertResult.user(), + upsertResult.newUser(), + refreshToken.rawToken(), + refreshToken.maxAgeSeconds() ); } public RefreshTokenResult refresh(String refreshToken) { - return new RefreshTokenResult(jwtTokenProvider.createAccessToken(SKELETON_USER_ID), createRefreshTokenStub()); + RefreshTokenRotationResult rotation = refreshTokenService.rotate(refreshToken); + return new RefreshTokenResult( + jwtTokenProvider.createAccessToken(rotation.userId()), + securityProperties.accessTokenType(), + securityProperties.accessTokenTtlSeconds(), + rotation.refreshToken().rawToken(), + rotation.refreshToken().maxAgeSeconds() + ); } public void logout(String refreshToken) { - // Refresh token revocation is intentionally deferred to the auth implementation task. + refreshTokenService.revoke(refreshToken); } public StreamTokenResult createStreamToken(Long userId) { @@ -60,7 +97,4 @@ public StreamTokenResult createStreamToken(Long userId) { return new StreamTokenResult(streamTokenProvider.createStreamToken(userId)); } - private String createRefreshTokenStub() { - return "refresh-token-stub-" + UUID.randomUUID(); - } } diff --git a/backend/src/main/java/com/stackup/stackup/auth/application/GithubUserService.java b/backend/src/main/java/com/stackup/stackup/auth/application/GithubUserService.java new file mode 100644 index 00000000..cae6a99c --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/application/GithubUserService.java @@ -0,0 +1,62 @@ +package com.stackup.stackup.auth.application; + +import com.stackup.stackup.auth.application.dto.AuthenticatedUserResult; +import com.stackup.stackup.auth.application.dto.GithubUserProfile; +import com.stackup.stackup.auth.application.dto.GithubUserUpsertResult; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public record GithubUserService(UserRepository userRepository) { + + @Transactional + public GithubUserUpsertResult upsertGithubUser( + GithubUserProfile profile, + String encryptedGithubAccessToken + ) { + return userRepository.findByGithubIdAndDeletedFalse(profile.githubId()) + .map(user -> updateUser(user, profile, encryptedGithubAccessToken)) + .orElseGet(() -> createUser(profile, encryptedGithubAccessToken)); + } + + private GithubUserUpsertResult updateUser( + User user, + GithubUserProfile profile, + String encryptedGithubAccessToken + ) { + user.updateGithubProfile( + profile.githubUsername(), + profile.email(), + profile.avatarUrl(), + encryptedGithubAccessToken + ); + return new GithubUserUpsertResult(toResult(user), false); + } + + private GithubUserUpsertResult createUser( + GithubUserProfile profile, + String encryptedGithubAccessToken + ) { + User user = User.createGithubUser( + profile.githubId(), + profile.githubUsername(), + profile.email(), + profile.avatarUrl(), + encryptedGithubAccessToken + ); + User savedUser = userRepository.save(user); + return new GithubUserUpsertResult(toResult(savedUser), true); + } + + private AuthenticatedUserResult toResult(User user) { + return new AuthenticatedUserResult( + user.getId(), + user.getGithubId(), + user.getGithubUsername(), + user.getEmail(), + user.getAvatarUrl() + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/auth/application/OAuthStateService.java b/backend/src/main/java/com/stackup/stackup/auth/application/OAuthStateService.java new file mode 100644 index 00000000..35c1202c --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/application/OAuthStateService.java @@ -0,0 +1,89 @@ +package com.stackup.stackup.auth.application; + +import com.stackup.stackup.auth.application.dto.OAuthStateIssueResult; +import com.stackup.stackup.auth.domain.OAuthState; +import com.stackup.stackup.auth.domain.OAuthStateRepository; +import com.stackup.stackup.common.config.properties.SecurityProperties; +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Instant; +import java.util.Base64; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class OAuthStateService { + + private final OAuthStateRepository oauthStateRepository; + private final SecurityProperties securityProperties; + private final SecureRandom secureRandom; + private final Clock clock; + + public OAuthStateService( + OAuthStateRepository oauthStateRepository, + SecurityProperties securityProperties, + SecureRandom secureRandom, + Clock clock + ) { + this.oauthStateRepository = oauthStateRepository; + this.securityProperties = securityProperties; + this.secureRandom = secureRandom; + this.clock = clock; + } + + @Transactional + public OAuthStateIssueResult issueGithubStateWithPkce() { + Instant now = Instant.now(clock); + oauthStateRepository.deleteByExpiresAtBefore(now); + + String state = randomUrlSafeValue(securityProperties.oauthStateByteLength()); + String codeVerifier = randomUrlSafeValue(securityProperties.pkceCodeVerifierByteLength()); + String codeChallenge = s256Challenge(codeVerifier); + + oauthStateRepository.save(OAuthState.issueGithub(state, codeVerifier, now.plus(securityProperties.oauthStateTtl()))); + return new OAuthStateIssueResult(state, codeChallenge); + } + + @Transactional + public String consumeGithubCodeVerifier(String state) { + if (state == null || state.isBlank()) { + throw oauthFailed(); + } + + OAuthState oauthState = oauthStateRepository.findByState(state) + .orElseThrow(this::oauthFailed); + + oauthStateRepository.delete(oauthState); + + if (oauthState.isExpired(Instant.now(clock))) { + throw oauthFailed(); + } + + return oauthState.getCodeVerifier(); + } + + private String randomUrlSafeValue(int byteLength) { + byte[] bytes = new byte[byteLength]; + secureRandom.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private String s256Challenge(String codeVerifier) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(codeVerifier.getBytes(StandardCharsets.US_ASCII)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 algorithm is not available", exception); + } + } + + private DomainException oauthFailed() { + return new DomainException(ApiErrorCode.AUTH_GITHUB_OAUTH_FAILED); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/auth/application/RefreshTokenService.java b/backend/src/main/java/com/stackup/stackup/auth/application/RefreshTokenService.java new file mode 100644 index 00000000..11cc7da1 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/application/RefreshTokenService.java @@ -0,0 +1,108 @@ +package com.stackup.stackup.auth.application; + +import com.stackup.stackup.auth.application.dto.RefreshTokenIssueResult; +import com.stackup.stackup.auth.application.dto.RefreshTokenRotationResult; +import com.stackup.stackup.auth.domain.RefreshToken; +import com.stackup.stackup.auth.domain.RefreshTokenRepository; +import com.stackup.stackup.common.config.properties.SecurityProperties; +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Instant; +import java.util.Base64; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class RefreshTokenService { + + private final RefreshTokenRepository refreshTokenRepository; + private final UserRepository userRepository; + private final SecurityProperties securityProperties; + private final SecureRandom secureRandom; + private final Clock clock; + + public RefreshTokenService( + RefreshTokenRepository refreshTokenRepository, + UserRepository userRepository, + SecurityProperties securityProperties, + SecureRandom secureRandom, + Clock clock + ) { + this.refreshTokenRepository = refreshTokenRepository; + this.userRepository = userRepository; + this.securityProperties = securityProperties; + this.secureRandom = secureRandom; + this.clock = clock; + } + + @Transactional + public RefreshTokenIssueResult issue(Long userId) { + String rawToken = generateRawToken(); + String tokenHash = hash(rawToken); + User user = userRepository.getReferenceById(userId); + Instant expiresAt = Instant.now(clock).plusSeconds(securityProperties.refreshTokenTtlSeconds()); + + refreshTokenRepository.save(RefreshToken.issue(user, tokenHash, null, expiresAt)); + return new RefreshTokenIssueResult(rawToken, securityProperties.refreshTokenTtlSeconds()); + } + + @Transactional + public RefreshTokenRotationResult rotate(String rawRefreshToken) { + RefreshToken refreshToken = getUsableRefreshToken(rawRefreshToken); + Long userId = refreshToken.getUser().getId(); + refreshToken.revoke(); + return new RefreshTokenRotationResult(userId, issue(userId)); + } + + @Transactional + public void revoke(String rawRefreshToken) { + if (rawRefreshToken == null || rawRefreshToken.isBlank()) { + return; + } + + refreshTokenRepository.findByTokenHash(hash(rawRefreshToken)) + .ifPresent(RefreshToken::revoke); + } + + private RefreshToken getUsableRefreshToken(String rawRefreshToken) { + if (rawRefreshToken == null || rawRefreshToken.isBlank()) { + throw new DomainException(ApiErrorCode.AUTH_INVALID_TOKEN); + } + + RefreshToken refreshToken = refreshTokenRepository.findByTokenHash(hash(rawRefreshToken)) + .orElseThrow(() -> new DomainException(ApiErrorCode.AUTH_INVALID_TOKEN)); + + if (refreshToken.isRevoked()) { + throw new DomainException(ApiErrorCode.AUTH_REVOKED_TOKEN); + } + if (refreshToken.isExpired(Instant.now(clock))) { + refreshToken.revoke(); + throw new DomainException(ApiErrorCode.AUTH_EXPIRED_TOKEN); + } + + return refreshToken; + } + + private String generateRawToken() { + byte[] bytes = new byte[securityProperties.refreshTokenByteLength()]; + secureRandom.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private String hash(String rawToken) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(rawToken.getBytes(StandardCharsets.UTF_8)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 algorithm is not available", exception); + } + } +} diff --git a/backend/src/main/java/com/stackup/stackup/auth/application/dto/AuthenticatedUserResult.java b/backend/src/main/java/com/stackup/stackup/auth/application/dto/AuthenticatedUserResult.java new file mode 100644 index 00000000..a17acff5 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/application/dto/AuthenticatedUserResult.java @@ -0,0 +1,10 @@ +package com.stackup.stackup.auth.application.dto; + +public record AuthenticatedUserResult( + long id, + long githubId, + String githubUsername, + String email, + String avatarUrl +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/auth/application/dto/GithubCallbackResult.java b/backend/src/main/java/com/stackup/stackup/auth/application/dto/GithubCallbackResult.java index b8e13cf9..ac058eb2 100644 --- a/backend/src/main/java/com/stackup/stackup/auth/application/dto/GithubCallbackResult.java +++ b/backend/src/main/java/com/stackup/stackup/auth/application/dto/GithubCallbackResult.java @@ -2,8 +2,11 @@ public record GithubCallbackResult( String accessToken, - String refreshToken, - long userId, - String githubUsername + String tokenType, + long expiresIn, + AuthenticatedUserResult user, + boolean isNewUser, + String refreshTokenRawForCookie, + long refreshTokenMaxAgeSeconds ) { } diff --git a/backend/src/main/java/com/stackup/stackup/auth/application/dto/GithubUserProfile.java b/backend/src/main/java/com/stackup/stackup/auth/application/dto/GithubUserProfile.java new file mode 100644 index 00000000..947270ee --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/application/dto/GithubUserProfile.java @@ -0,0 +1,9 @@ +package com.stackup.stackup.auth.application.dto; + +public record GithubUserProfile( + Long githubId, + String githubUsername, + String email, + String avatarUrl +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/auth/application/dto/GithubUserUpsertResult.java b/backend/src/main/java/com/stackup/stackup/auth/application/dto/GithubUserUpsertResult.java new file mode 100644 index 00000000..8d4d3ec1 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/application/dto/GithubUserUpsertResult.java @@ -0,0 +1,7 @@ +package com.stackup.stackup.auth.application.dto; + +public record GithubUserUpsertResult( + AuthenticatedUserResult user, + boolean newUser +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/auth/application/dto/OAuthStateIssueResult.java b/backend/src/main/java/com/stackup/stackup/auth/application/dto/OAuthStateIssueResult.java new file mode 100644 index 00000000..15bf6e05 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/application/dto/OAuthStateIssueResult.java @@ -0,0 +1,7 @@ +package com.stackup.stackup.auth.application.dto; + +public record OAuthStateIssueResult( + String state, + String codeChallenge +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/auth/application/dto/RefreshTokenIssueResult.java b/backend/src/main/java/com/stackup/stackup/auth/application/dto/RefreshTokenIssueResult.java new file mode 100644 index 00000000..c266782a --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/application/dto/RefreshTokenIssueResult.java @@ -0,0 +1,7 @@ +package com.stackup.stackup.auth.application.dto; + +public record RefreshTokenIssueResult( + String rawToken, + long maxAgeSeconds +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/auth/application/dto/RefreshTokenResult.java b/backend/src/main/java/com/stackup/stackup/auth/application/dto/RefreshTokenResult.java index e2dce410..8301dcbb 100644 --- a/backend/src/main/java/com/stackup/stackup/auth/application/dto/RefreshTokenResult.java +++ b/backend/src/main/java/com/stackup/stackup/auth/application/dto/RefreshTokenResult.java @@ -2,6 +2,9 @@ public record RefreshTokenResult( String accessToken, - String refreshToken + String tokenType, + long expiresIn, + String refreshTokenRawForCookie, + long refreshTokenMaxAgeSeconds ) { } diff --git a/backend/src/main/java/com/stackup/stackup/auth/application/dto/RefreshTokenRotationResult.java b/backend/src/main/java/com/stackup/stackup/auth/application/dto/RefreshTokenRotationResult.java new file mode 100644 index 00000000..3290384f --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/application/dto/RefreshTokenRotationResult.java @@ -0,0 +1,7 @@ +package com.stackup.stackup.auth.application.dto; + +public record RefreshTokenRotationResult( + Long userId, + RefreshTokenIssueResult refreshToken +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/auth/domain/OAuthProvider.java b/backend/src/main/java/com/stackup/stackup/auth/domain/OAuthProvider.java new file mode 100644 index 00000000..1410eec4 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/domain/OAuthProvider.java @@ -0,0 +1,5 @@ +package com.stackup.stackup.auth.domain; + +public enum OAuthProvider { + GITHUB +} diff --git a/backend/src/main/java/com/stackup/stackup/auth/domain/OAuthState.java b/backend/src/main/java/com/stackup/stackup/auth/domain/OAuthState.java new file mode 100644 index 00000000..e3f549a2 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/domain/OAuthState.java @@ -0,0 +1,54 @@ +package com.stackup.stackup.auth.domain; + +import com.stackup.stackup.common.entity.BaseTimeEntity; +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 java.time.Instant; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "oauth_states") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OAuthState extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "state", nullable = false, unique = true, length = 128) + private String state; + + @Column(name = "code_verifier", nullable = false, length = 128) + private String codeVerifier; + + @Enumerated(EnumType.STRING) + @Column(name = "provider", nullable = false, length = 20) + private OAuthProvider provider; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + private OAuthState(String state, String codeVerifier, OAuthProvider provider, Instant expiresAt) { + this.state = state; + this.codeVerifier = codeVerifier; + this.provider = provider; + this.expiresAt = expiresAt; + } + + public static OAuthState issueGithub(String state, String codeVerifier, Instant expiresAt) { + return new OAuthState(state, codeVerifier, OAuthProvider.GITHUB, expiresAt); + } + + public boolean isExpired(Instant now) { + return !expiresAt.isAfter(now); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/auth/domain/OAuthStateRepository.java b/backend/src/main/java/com/stackup/stackup/auth/domain/OAuthStateRepository.java new file mode 100644 index 00000000..2b081a59 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/domain/OAuthStateRepository.java @@ -0,0 +1,12 @@ +package com.stackup.stackup.auth.domain; + +import java.time.Instant; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OAuthStateRepository extends JpaRepository { + + Optional findByState(String state); + + void deleteByExpiresAtBefore(Instant now); +} diff --git a/backend/src/main/java/com/stackup/stackup/auth/domain/RefreshToken.java b/backend/src/main/java/com/stackup/stackup/auth/domain/RefreshToken.java index e305c16b..ee567949 100644 --- a/backend/src/main/java/com/stackup/stackup/auth/domain/RefreshToken.java +++ b/backend/src/main/java/com/stackup/stackup/auth/domain/RefreshToken.java @@ -8,7 +8,6 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; @@ -20,14 +19,7 @@ @Getter @Entity -@Table( - name = "refresh_tokens", - indexes = { - @Index(name = "idx_refresh_tokens_user", columnList = "user_id"), - @Index(name = "idx_refresh_tokens_hash", columnList = "token_hash"), - @Index(name = "idx_refresh_tokens_expires", columnList = "expires_at") - } -) +@Table(name = "refresh_tokens") @NoArgsConstructor(access = AccessLevel.PROTECTED) public class RefreshToken extends BaseTimeEntity { @@ -50,4 +42,23 @@ public class RefreshToken extends BaseTimeEntity { @Column(name = "is_revoked", nullable = false) private boolean revoked = false; + + private RefreshToken(User user, String tokenHash, String deviceInfo, Instant expiresAt) { + this.user = user; + this.tokenHash = tokenHash; + this.deviceInfo = deviceInfo; + this.expiresAt = expiresAt; + } + + public static RefreshToken issue(User user, String tokenHash, String deviceInfo, Instant expiresAt) { + return new RefreshToken(user, tokenHash, deviceInfo, expiresAt); + } + + public boolean isExpired(Instant now) { + return !expiresAt.isAfter(now); + } + + public void revoke() { + this.revoked = true; + } } diff --git a/backend/src/main/java/com/stackup/stackup/auth/infrastructure/GithubOAuthClient.java b/backend/src/main/java/com/stackup/stackup/auth/infrastructure/GithubOAuthClient.java index 00baa979..095b77b6 100644 --- a/backend/src/main/java/com/stackup/stackup/auth/infrastructure/GithubOAuthClient.java +++ b/backend/src/main/java/com/stackup/stackup/auth/infrastructure/GithubOAuthClient.java @@ -1,25 +1,98 @@ package com.stackup.stackup.auth.infrastructure; +import com.stackup.stackup.auth.infrastructure.dto.GithubTokenResponse; import com.stackup.stackup.common.config.properties.GithubOAuthProperties; +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClientException; import org.springframework.web.util.UriComponentsBuilder; @Component public class GithubOAuthClient { private final GithubOAuthProperties githubOAuthProperties; + private final GithubOAuthHttpClient githubOAuthHttpClient; - public GithubOAuthClient(GithubOAuthProperties githubOAuthProperties) { + public GithubOAuthClient( + GithubOAuthProperties githubOAuthProperties, + GithubOAuthHttpClient githubOAuthHttpClient + ) { this.githubOAuthProperties = githubOAuthProperties; + this.githubOAuthHttpClient = githubOAuthHttpClient; } - public String buildAuthorizationUrl(String state) { - return UriComponentsBuilder.fromUriString("https://github.com/login/oauth/authorize") + public String buildAuthorizationUrl(String state, String codeChallenge) { + return UriComponentsBuilder.fromUri(githubOAuthProperties.oauthBaseUrl()) + .path("/login/oauth/authorize") .queryParam("client_id", githubOAuthProperties.clientId()) .queryParam("redirect_uri", githubOAuthProperties.redirectUri()) - .queryParam("scope", "read:user user:email repo") + .queryParam("scope", githubOAuthProperties.scopes()) .queryParam("state", state) + .queryParam("code_challenge", codeChallenge) + .queryParam("code_challenge_method", githubOAuthProperties.codeChallengeMethod()) .build() .toUriString(); } + + public String exchangeCode(String code, String codeVerifier) { + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("client_id", githubOAuthProperties.clientId()); + body.add("client_secret", githubOAuthProperties.clientSecret()); + body.add("code", code); + body.add("redirect_uri", githubOAuthProperties.redirectUri().toString()); + body.add("code_verifier", codeVerifier); + + try { + GithubTokenResponse response = githubOAuthHttpClient.exchangeCode(body); + return validateTokenResponse(response); + } catch (RestClientException exception) { + throw oauthFailed(exception); + } + } + + private String validateTokenResponse(GithubTokenResponse response) { + if (response == null || response.error() != null || response.accessToken() == null + || response.accessToken().isBlank()) { + throw oauthFailed(); + } + if (response.tokenType() == null || !githubOAuthProperties.tokenType().equalsIgnoreCase(response.tokenType())) { + throw oauthFailed(); + } + if (!hasRequiredScopes(response.scope())) { + throw oauthFailed(); + } + return response.accessToken(); + } + + private boolean hasRequiredScopes(String grantedScopes) { + if (grantedScopes == null || grantedScopes.isBlank()) { + return false; + } + + Set granted = Arrays.stream(grantedScopes.split("[,\\s]+")) + .filter(scope -> !scope.isBlank()) + .collect(Collectors.toSet()); + + return Arrays.stream(githubOAuthProperties.scopes().split("\\s+")) + .filter(scope -> !scope.isBlank()) + .allMatch(granted::contains); + } + + private DomainException oauthFailed() { + return new DomainException(ApiErrorCode.AUTH_GITHUB_OAUTH_FAILED); + } + + private DomainException oauthFailed(Throwable cause) { + return new DomainException( + ApiErrorCode.AUTH_GITHUB_OAUTH_FAILED, + ApiErrorCode.AUTH_GITHUB_OAUTH_FAILED.getDefaultMessage(), + cause + ); + } } diff --git a/backend/src/main/java/com/stackup/stackup/auth/infrastructure/GithubOAuthHttpClient.java b/backend/src/main/java/com/stackup/stackup/auth/infrastructure/GithubOAuthHttpClient.java new file mode 100644 index 00000000..ac36aad3 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/infrastructure/GithubOAuthHttpClient.java @@ -0,0 +1,17 @@ +package com.stackup.stackup.auth.infrastructure; + +import com.stackup.stackup.auth.infrastructure.dto.GithubTokenResponse; +import org.springframework.http.MediaType; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.service.annotation.PostExchange; + +public interface GithubOAuthHttpClient { + + @PostExchange( + url = "/login/oauth/access_token", + contentType = MediaType.APPLICATION_FORM_URLENCODED_VALUE, + accept = MediaType.APPLICATION_JSON_VALUE + ) + GithubTokenResponse exchangeCode(@RequestBody MultiValueMap body); +} diff --git a/backend/src/main/java/com/stackup/stackup/auth/infrastructure/GithubOAuthHttpClientConfig.java b/backend/src/main/java/com/stackup/stackup/auth/infrastructure/GithubOAuthHttpClientConfig.java new file mode 100644 index 00000000..de413bf7 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/infrastructure/GithubOAuthHttpClientConfig.java @@ -0,0 +1,31 @@ +package com.stackup.stackup.auth.infrastructure; + +import com.stackup.stackup.common.config.properties.GithubOAuthProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.support.RestClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +@Configuration +public class GithubOAuthHttpClientConfig { + + @Bean + public GithubOAuthHttpClient githubOAuthHttpClient(GithubOAuthProperties properties) { + RestClient restClient = RestClient.builder() + .requestFactory(requestFactory(properties)) + .baseUrl(properties.oauthBaseUrl().toString()) + .build(); + RestClientAdapter adapter = RestClientAdapter.create(restClient); + HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build(); + return factory.createClient(GithubOAuthHttpClient.class); + } + + private SimpleClientHttpRequestFactory requestFactory(GithubOAuthProperties properties) { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(properties.connectTimeout()); + requestFactory.setReadTimeout(properties.readTimeout()); + return requestFactory; + } +} diff --git a/backend/src/main/java/com/stackup/stackup/auth/infrastructure/dto/GithubTokenResponse.java b/backend/src/main/java/com/stackup/stackup/auth/infrastructure/dto/GithubTokenResponse.java new file mode 100644 index 00000000..ed33b0aa --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/infrastructure/dto/GithubTokenResponse.java @@ -0,0 +1,19 @@ +package com.stackup.stackup.auth.infrastructure.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record GithubTokenResponse( + @JsonProperty("access_token") + String accessToken, + + @JsonProperty("token_type") + String tokenType, + + String scope, + + String error, + + @JsonProperty("error_description") + String errorDescription +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/auth/presentation/AuthController.java b/backend/src/main/java/com/stackup/stackup/auth/presentation/AuthController.java index 60048a5d..62291791 100644 --- a/backend/src/main/java/com/stackup/stackup/auth/presentation/AuthController.java +++ b/backend/src/main/java/com/stackup/stackup/auth/presentation/AuthController.java @@ -5,68 +5,117 @@ import com.stackup.stackup.auth.application.dto.GithubLoginResult; import com.stackup.stackup.auth.application.dto.RefreshTokenResult; import com.stackup.stackup.auth.application.dto.StreamTokenResult; -import com.stackup.stackup.auth.presentation.dto.GithubAuthRequest; +import com.stackup.stackup.auth.presentation.dto.AuthUserResponse; import com.stackup.stackup.auth.presentation.dto.GithubCallbackResponse; import com.stackup.stackup.auth.presentation.dto.GithubLoginResponse; -import com.stackup.stackup.auth.presentation.dto.LogoutRequest; -import com.stackup.stackup.auth.presentation.dto.RefreshTokenRequest; import com.stackup.stackup.auth.presentation.dto.RefreshTokenResponse; import com.stackup.stackup.auth.presentation.dto.StreamTokenResponse; +import com.stackup.stackup.common.config.properties.SecurityProperties; import com.stackup.stackup.common.security.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.Duration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; 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.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/auth") -public class AuthController { - - private final AuthService authService; - - public AuthController(AuthService authService) { - this.authService = authService; - } +@Tag(name = "Auth", description = "GitHub OAuth login and authentication token APIs") +public record AuthController(AuthService authService, SecurityProperties securityProperties) { + @Operation(operationId = "startGithubLogin", summary = "Create GitHub OAuth authorization URL") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "GitHub authorization URL created") + }) @PostMapping("/github") - public ResponseEntity startGithubLogin(@RequestBody(required = false) GithubAuthRequest request) { + public ResponseEntity startGithubLogin() { GithubLoginResult result = authService.startGithubLogin(); return ResponseEntity.ok(new GithubLoginResponse(result.authorizationUrl(), result.state())); } + @Operation(operationId = "completeGithubLogin", summary = "Complete GitHub OAuth login") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Login completed"), + @ApiResponse(responseCode = "401", description = "GitHub OAuth login failed") + }) @GetMapping("/github/callback") public ResponseEntity githubCallback( @RequestParam(required = false) String code, @RequestParam(required = false) String state ) { GithubCallbackResult result = authService.completeGithubLogin(code, state); - return ResponseEntity.ok(new GithubCallbackResponse( - result.accessToken(), - result.refreshToken(), - result.userId(), - result.githubUsername() - )); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, refreshTokenCookie( + result.refreshTokenRawForCookie(), + result.refreshTokenMaxAgeSeconds() + ).toString()) + .body(new GithubCallbackResponse( + result.accessToken(), + result.tokenType(), + result.expiresIn(), + new AuthUserResponse( + result.user().id(), + result.user().githubId(), + result.user().githubUsername(), + result.user().email(), + result.user().avatarUrl() + ), + result.isNewUser() + )); } + @Operation(operationId = "refreshAccessToken", summary = "Refresh StackUp access token") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Access token refreshed"), + @ApiResponse(responseCode = "401", description = "Refresh token is missing or invalid") + }) @PostMapping("/refresh") - public ResponseEntity refresh(@RequestBody(required = false) RefreshTokenRequest request) { - String refreshToken = request == null ? null : request.refreshToken(); + public ResponseEntity refresh( + @CookieValue(name = "${app.security.refresh-token-cookie-name}", required = false) String refreshToken + ) { RefreshTokenResult result = authService.refresh(refreshToken); - return ResponseEntity.ok(new RefreshTokenResponse(result.accessToken(), result.refreshToken())); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, refreshTokenCookie( + result.refreshTokenRawForCookie(), + result.refreshTokenMaxAgeSeconds() + ).toString()) + .body(new RefreshTokenResponse( + result.accessToken(), + result.tokenType(), + result.expiresIn() + )); } + @Operation(operationId = "logout", summary = "Logout and revoke refresh token") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "Logged out") + }) @DeleteMapping("/logout") - public ResponseEntity logout(@RequestBody(required = false) LogoutRequest request) { - String refreshToken = request == null ? null : request.refreshToken(); + public ResponseEntity logout( + @CookieValue(name = "${app.security.refresh-token-cookie-name}", required = false) String refreshToken + ) { authService.logout(refreshToken); - return ResponseEntity.noContent().build(); + return ResponseEntity.noContent() + .header(HttpHeaders.SET_COOKIE, expiredRefreshTokenCookie().toString()) + .build(); } + @Operation(operationId = "createStreamToken", summary = "Create SSE stream token") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Stream token created"), + @ApiResponse(responseCode = "401", description = "Authentication is required") + }) @PostMapping("/stream-token") public ResponseEntity createStreamToken( @AuthenticationPrincipal UserPrincipal principal @@ -75,4 +124,24 @@ public ResponseEntity createStreamToken( StreamTokenResult result = authService.createStreamToken(userId); return ResponseEntity.ok(new StreamTokenResponse(result.streamToken())); } + + private ResponseCookie refreshTokenCookie(String refreshToken, long maxAgeSeconds) { + return ResponseCookie.from(securityProperties.refreshTokenCookieName(), refreshToken) + .httpOnly(securityProperties.refreshTokenCookieHttpOnly()) + .secure(securityProperties.refreshTokenCookieSecure()) + .sameSite(securityProperties.refreshTokenCookieSameSite()) + .path(securityProperties.refreshTokenCookiePath()) + .maxAge(Duration.ofSeconds(maxAgeSeconds)) + .build(); + } + + private ResponseCookie expiredRefreshTokenCookie() { + return ResponseCookie.from(securityProperties.refreshTokenCookieName(), "") + .httpOnly(securityProperties.refreshTokenCookieHttpOnly()) + .secure(securityProperties.refreshTokenCookieSecure()) + .sameSite(securityProperties.refreshTokenCookieSameSite()) + .path(securityProperties.refreshTokenCookiePath()) + .maxAge(Duration.ZERO) + .build(); + } } diff --git a/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/AuthUserResponse.java b/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/AuthUserResponse.java new file mode 100644 index 00000000..3d9ab3a1 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/AuthUserResponse.java @@ -0,0 +1,21 @@ +package com.stackup.stackup.auth.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record AuthUserResponse( + @Schema(description = "StackUp user id", example = "1", nullable = false) + long id, + + @Schema(description = "Stable GitHub user id", example = "123456", nullable = false) + long githubId, + + @Schema(description = "GitHub username at login time", example = "octocat", nullable = false) + String githubUsername, + + @Schema(description = "Primary GitHub email when available", example = "octocat@example.com", nullable = true) + String email, + + @Schema(description = "GitHub avatar URL", example = "https://avatars.githubusercontent.com/u/123456", nullable = true) + String avatarUrl +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/GithubAuthRequest.java b/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/GithubAuthRequest.java deleted file mode 100644 index 7f907d48..00000000 --- a/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/GithubAuthRequest.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.stackup.stackup.auth.presentation.dto; - -public record GithubAuthRequest( - String code, - String state -) { -} diff --git a/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/GithubCallbackResponse.java b/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/GithubCallbackResponse.java index 5f3bb7fa..42b7f4f8 100644 --- a/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/GithubCallbackResponse.java +++ b/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/GithubCallbackResponse.java @@ -1,9 +1,21 @@ package com.stackup.stackup.auth.presentation.dto; +import io.swagger.v3.oas.annotations.media.Schema; + public record GithubCallbackResponse( + @Schema(description = "StackUp JWT access token", example = "our-jwt-access-token", nullable = false) String accessToken, - String refreshToken, - long userId, - String githubUsername + + @Schema(description = "Access token type", example = "Bearer", nullable = false) + String tokenType, + + @Schema(description = "Access token TTL in seconds", example = "900", nullable = false) + long expiresIn, + + @Schema(description = "Authenticated user profile", nullable = false) + AuthUserResponse user, + + @Schema(description = "Whether a new user was created during this login", example = "false", nullable = false) + boolean isNewUser ) { } diff --git a/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/GithubLoginResponse.java b/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/GithubLoginResponse.java index 66ddc41d..9a455c75 100644 --- a/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/GithubLoginResponse.java +++ b/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/GithubLoginResponse.java @@ -1,7 +1,12 @@ package com.stackup.stackup.auth.presentation.dto; +import io.swagger.v3.oas.annotations.media.Schema; + public record GithubLoginResponse( + @Schema(description = "GitHub OAuth authorization URL", example = "https://github.com/login/oauth/authorize?client_id=...&redirect_uri=...&scope=read:user%20user:email%20repo&state=generated-state", nullable = false) String authorizationUrl, + + @Schema(description = "CSRF protection state value to be verified on callback", example = "generated-state", nullable = false) String state ) { } diff --git a/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/LogoutRequest.java b/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/LogoutRequest.java deleted file mode 100644 index 7d859702..00000000 --- a/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/LogoutRequest.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.stackup.stackup.auth.presentation.dto; - -public record LogoutRequest( - String refreshToken -) { -} diff --git a/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/RefreshTokenRequest.java b/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/RefreshTokenRequest.java deleted file mode 100644 index c739e4fb..00000000 --- a/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/RefreshTokenRequest.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.stackup.stackup.auth.presentation.dto; - -public record RefreshTokenRequest( - String refreshToken -) { -} diff --git a/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/RefreshTokenResponse.java b/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/RefreshTokenResponse.java index eaa4de45..9dcdb47e 100644 --- a/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/RefreshTokenResponse.java +++ b/backend/src/main/java/com/stackup/stackup/auth/presentation/dto/RefreshTokenResponse.java @@ -1,7 +1,15 @@ package com.stackup.stackup.auth.presentation.dto; +import io.swagger.v3.oas.annotations.media.Schema; + public record RefreshTokenResponse( + @Schema(description = "New StackUp JWT access token", example = "new-our-jwt-access-token", nullable = false) String accessToken, - String refreshToken + + @Schema(description = "Access token type", example = "Bearer", nullable = false) + String tokenType, + + @Schema(description = "Access token TTL in seconds", example = "900", nullable = false) + long expiresIn ) { } diff --git a/backend/src/main/java/com/stackup/stackup/common/config/SecurityBeanConfig.java b/backend/src/main/java/com/stackup/stackup/common/config/SecurityBeanConfig.java new file mode 100644 index 00000000..6de65329 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/config/SecurityBeanConfig.java @@ -0,0 +1,20 @@ +package com.stackup.stackup.common.config; + +import java.security.SecureRandom; +import java.time.Clock; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SecurityBeanConfig { + + @Bean + public SecureRandom secureRandom() { + return new SecureRandom(); + } + + @Bean + public Clock clock() { + return Clock.systemUTC(); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/common/config/properties/GithubOAuthProperties.java b/backend/src/main/java/com/stackup/stackup/common/config/properties/GithubOAuthProperties.java index 2f13e3c7..f304d43d 100644 --- a/backend/src/main/java/com/stackup/stackup/common/config/properties/GithubOAuthProperties.java +++ b/backend/src/main/java/com/stackup/stackup/common/config/properties/GithubOAuthProperties.java @@ -3,6 +3,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import java.net.URI; +import java.time.Duration; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.validation.annotation.Validated; @@ -12,6 +13,40 @@ public record GithubOAuthProperties( @NotBlank String clientId, @NotBlank String clientSecret, @NotNull - URI redirectUri + URI redirectUri, + @NotBlank String scopes, + @NotNull URI oauthBaseUrl, + @NotBlank String tokenType, + @NotBlank String codeChallengeMethod, + @NotBlank String apiBaseUrl, + @NotBlank String apiVersion, + @NotNull Duration connectTimeout, + @NotNull Duration readTimeout ) { + + public GithubOAuthProperties { + validatePositive("connectTimeout", connectTimeout); + validatePositive("readTimeout", readTimeout); + } + + @Override + public String toString() { + return "GithubOAuthProperties[clientId=" + clientId + + ", clientSecret=******" + + ", redirectUri=" + redirectUri + + ", scopes=" + scopes + + ", oauthBaseUrl=" + oauthBaseUrl + + ", tokenType=" + tokenType + + ", codeChallengeMethod=" + codeChallengeMethod + + ", apiBaseUrl=" + apiBaseUrl + + ", apiVersion=" + apiVersion + + ", connectTimeout=" + connectTimeout + + ", readTimeout=" + readTimeout + "]"; + } + + private static void validatePositive(String name, Duration value) { + if (value != null && !value.isPositive()) { + throw new IllegalArgumentException(name + " must be positive"); + } + } } diff --git a/backend/src/main/java/com/stackup/stackup/common/config/properties/SecurityProperties.java b/backend/src/main/java/com/stackup/stackup/common/config/properties/SecurityProperties.java index c88be9b1..36d0734a 100644 --- a/backend/src/main/java/com/stackup/stackup/common/config/properties/SecurityProperties.java +++ b/backend/src/main/java/com/stackup/stackup/common/config/properties/SecurityProperties.java @@ -1,7 +1,9 @@ package com.stackup.stackup.common.config.properties; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; +import java.time.Duration; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.validation.annotation.Validated; @@ -11,6 +13,16 @@ public record SecurityProperties( @NotBlank String jwtSecret, @NotBlank String encryptionKey, @Positive long accessTokenTtlSeconds, - @Positive long refreshTokenTtlSeconds + @Positive long refreshTokenTtlSeconds, + @NotBlank String accessTokenType, + @NotNull Duration oauthStateTtl, + @Positive int oauthStateByteLength, + @Positive int pkceCodeVerifierByteLength, + @Positive int refreshTokenByteLength, + @NotBlank String refreshTokenCookieName, + @NotBlank String refreshTokenCookiePath, + @NotBlank String refreshTokenCookieSameSite, + boolean refreshTokenCookieSecure, + boolean refreshTokenCookieHttpOnly ) { } diff --git a/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java b/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java index 337fda27..e95296ea 100644 --- a/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java +++ b/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java @@ -37,6 +37,7 @@ public enum ApiErrorCode { SYS_RATE_LIMITED(HttpStatus.TOO_MANY_REQUESTS, "요청이 너무 많습니다."), SYS_DEPENDENCY_DOWN(HttpStatus.SERVICE_UNAVAILABLE, "필수 외부 의존성이 응답하지 않습니다."), + SYS_NOT_IMPLEMENTED(HttpStatus.NOT_IMPLEMENTED, "아직 구현되지 않은 기능입니다."), SYS_INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 오류가 발생했습니다."); private final HttpStatus status; diff --git a/backend/src/main/java/com/stackup/stackup/common/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/stackup/stackup/common/exception/GlobalExceptionHandler.java index ea9c4e63..aa0729b8 100644 --- a/backend/src/main/java/com/stackup/stackup/common/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/stackup/stackup/common/exception/GlobalExceptionHandler.java @@ -1,6 +1,7 @@ package com.stackup.stackup.common.exception; import com.stackup.stackup.common.response.ApiErrorResponse; +import com.stackup.stackup.common.storage.StorageException; import com.stackup.stackup.common.trace.TraceContext; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; @@ -9,6 +10,8 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.ResponseEntity; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.AuthenticationException; @@ -20,9 +23,13 @@ @RestControllerAdvice public class GlobalExceptionHandler { + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + @ExceptionHandler(DomainException.class) public ResponseEntity handleDomainException(DomainException exception) { ApiErrorCode errorCode = exception.getErrorCode(); + log.warn("Domain exception. code={}, traceId={}, details={}", + errorCode.name(), TraceContext.getTraceId(), exception.getDetails()); return buildResponse(errorCode, resolveMessage(exception, errorCode), exception.getDetails()); } @@ -51,19 +58,33 @@ public ResponseEntity handleConstraintViolation(ConstraintViol @ExceptionHandler(AuthenticationException.class) public ResponseEntity handleAuthentication(AuthenticationException exception) { + ApiErrorCode errorCode = ApiErrorCode.AUTH_INVALID_TOKEN; + log.warn("Authentication exception. code={}, traceId={}", errorCode.name(), TraceContext.getTraceId()); return buildResponse(ApiErrorCode.AUTH_INVALID_TOKEN, ApiErrorCode.AUTH_INVALID_TOKEN.getDefaultMessage(), Map.of()); } @ExceptionHandler(AccessDeniedException.class) public ResponseEntity handleAccessDenied(AccessDeniedException exception) { - return buildResponse(ApiErrorCode.ACCESS_DENIED, ApiErrorCode.ACCESS_DENIED.getDefaultMessage(), Map.of()); + ApiErrorCode errorCode = ApiErrorCode.ACCESS_DENIED; + log.warn("Access denied exception. code={}, traceId={}", errorCode.name(), TraceContext.getTraceId()); + return buildResponse(errorCode, errorCode.getDefaultMessage(), Map.of()); + } + + @ExceptionHandler(StorageException.class) + public ResponseEntity handleStorageException(StorageException exception) { + return buildResponse( + ApiErrorCode.SYS_DEPENDENCY_DOWN, + ApiErrorCode.SYS_DEPENDENCY_DOWN.getDefaultMessage(), + Map.of("storageErrorType", exception.getType().name()) + ); } @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception exception) { - return buildResponse(ApiErrorCode.SYS_INTERNAL_ERROR, ApiErrorCode.SYS_INTERNAL_ERROR.getDefaultMessage(), - Map.of()); + ApiErrorCode errorCode = ApiErrorCode.SYS_INTERNAL_ERROR; + log.error("Unhandled exception. code={}, traceId={}", errorCode.name(), TraceContext.getTraceId(), exception); + return buildResponse(errorCode, errorCode.getDefaultMessage(), Map.of()); } private ResponseEntity buildResponse( diff --git a/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java b/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java index 1498a1d1..9481d827 100644 --- a/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java +++ b/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java @@ -7,7 +7,7 @@ import org.springframework.amqp.core.TopicExchange; import org.springframework.amqp.rabbit.connection.ConnectionFactory; import org.springframework.amqp.rabbit.core.RabbitTemplate; -import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.amqp.support.converter.JacksonJsonMessageConverter; import org.springframework.amqp.support.converter.MessageConverter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -100,7 +100,7 @@ public Declarables rabbitDeclarables( @Bean public MessageConverter messageConverter() { - return new Jackson2JsonMessageConverter(); + return new JacksonJsonMessageConverter(); } @Bean diff --git a/backend/src/main/java/com/stackup/stackup/common/security/JwtAuthenticationException.java b/backend/src/main/java/com/stackup/stackup/common/security/JwtAuthenticationException.java new file mode 100644 index 00000000..72bc1a94 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/security/JwtAuthenticationException.java @@ -0,0 +1,18 @@ +package com.stackup.stackup.common.security; + +import com.stackup.stackup.common.exception.ApiErrorCode; +import org.springframework.security.authentication.BadCredentialsException; + +public class JwtAuthenticationException extends BadCredentialsException { + + private final ApiErrorCode errorCode; + + public JwtAuthenticationException(ApiErrorCode errorCode) { + super(errorCode.getDefaultMessage()); + this.errorCode = errorCode; + } + + public ApiErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/backend/src/main/java/com/stackup/stackup/common/security/JwtAuthenticationFilter.java b/backend/src/main/java/com/stackup/stackup/common/security/JwtAuthenticationFilter.java index 261b557a..57b1e80d 100644 --- a/backend/src/main/java/com/stackup/stackup/common/security/JwtAuthenticationFilter.java +++ b/backend/src/main/java/com/stackup/stackup/common/security/JwtAuthenticationFilter.java @@ -1,11 +1,14 @@ package com.stackup.stackup.common.security; +import com.stackup.stackup.common.exception.ApiErrorCode; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; @@ -15,6 +18,9 @@ @Component public class JwtAuthenticationFilter extends OncePerRequestFilter { + public static final String AUTH_ERROR_CODE_ATTRIBUTE = JwtAuthenticationFilter.class.getName() + ".AUTH_ERROR_CODE"; + + private static final Logger log = LoggerFactory.getLogger(JwtAuthenticationFilter.class); private static final String AUTHORIZATION_HEADER = "Authorization"; private static final String BEARER_PREFIX = "Bearer "; @@ -40,8 +46,14 @@ protected void doFilterInternal( UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(principal, token, principal.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (JwtAuthenticationException exception) { + request.setAttribute(AUTH_ERROR_CODE_ATTRIBUTE, exception.getErrorCode()); + SecurityContextHolder.clearContext(); + log.warn("JWT authentication failed. code={}, uri={}", exception.getErrorCode().name(), request.getRequestURI()); } catch (RuntimeException exception) { + request.setAttribute(AUTH_ERROR_CODE_ATTRIBUTE, ApiErrorCode.AUTH_INVALID_TOKEN); SecurityContextHolder.clearContext(); + log.warn("JWT authentication failed. code={}, uri={}", ApiErrorCode.AUTH_INVALID_TOKEN.name(), request.getRequestURI()); } } diff --git a/backend/src/main/java/com/stackup/stackup/common/security/JwtTokenProvider.java b/backend/src/main/java/com/stackup/stackup/common/security/JwtTokenProvider.java index 5e791ff3..9636a17c 100644 --- a/backend/src/main/java/com/stackup/stackup/common/security/JwtTokenProvider.java +++ b/backend/src/main/java/com/stackup/stackup/common/security/JwtTokenProvider.java @@ -8,13 +8,13 @@ import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.SignedJWT; import com.stackup.stackup.common.config.properties.SecurityProperties; +import com.stackup.stackup.common.exception.ApiErrorCode; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.text.ParseException; import java.time.Instant; import java.util.Date; -import org.springframework.security.authentication.BadCredentialsException; import org.springframework.stereotype.Component; @Component @@ -54,7 +54,7 @@ public JWTClaimsSet parseAndValidate(String token) { JWTClaimsSet claimsSet = signedJwt.getJWTClaimsSet(); if (claimsSet.getExpirationTime() == null || claimsSet.getExpirationTime().before(new Date())) { - throw invalidToken(); + throw expiredToken(); } if (!ACCESS_TOKEN_TYPE.equals(claimsSet.getStringClaim(TOKEN_TYPE_CLAIM))) { throw invalidToken(); @@ -87,8 +87,12 @@ private String sign(JWTClaimsSet claimsSet) { } } - private BadCredentialsException invalidToken() { - return new BadCredentialsException("Invalid JWT token"); + private JwtAuthenticationException invalidToken() { + return new JwtAuthenticationException(ApiErrorCode.AUTH_INVALID_TOKEN); + } + + private JwtAuthenticationException expiredToken() { + return new JwtAuthenticationException(ApiErrorCode.AUTH_EXPIRED_TOKEN); } private static byte[] sha256(String value) { diff --git a/backend/src/main/java/com/stackup/stackup/common/security/SecurityConfig.java b/backend/src/main/java/com/stackup/stackup/common/security/SecurityConfig.java index e7fc1f92..2995f5f2 100644 --- a/backend/src/main/java/com/stackup/stackup/common/security/SecurityConfig.java +++ b/backend/src/main/java/com/stackup/stackup/common/security/SecurityConfig.java @@ -4,7 +4,10 @@ import com.stackup.stackup.common.exception.ApiErrorCode; import com.stackup.stackup.common.trace.TraceContext; import jakarta.servlet.http.HttpServletResponse; +import java.time.Instant; 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.HttpHeaders; @@ -14,6 +17,7 @@ import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; @@ -22,6 +26,8 @@ @Configuration public class SecurityConfig { + private static final Logger log = LoggerFactory.getLogger(SecurityConfig.class); + private final JwtAuthenticationFilter jwtAuthenticationFilter; private final CorsProperties corsProperties; @@ -39,7 +45,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .csrf(AbstractHttpConfigurer::disable) .cors(cors -> cors.configurationSource(corsConfigurationSource())) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .exceptionHandling(exception -> exception.authenticationEntryPoint(authenticationEntryPoint())) + .exceptionHandling(exception -> exception + .authenticationEntryPoint(authenticationEntryPoint()) + .accessDeniedHandler(accessDeniedHandler()) + ) .authorizeHttpRequests(authorize -> authorize .requestMatchers( "/api/auth/github", @@ -78,20 +87,45 @@ public CorsConfigurationSource corsConfigurationSource() { @Bean public AuthenticationEntryPoint authenticationEntryPoint() { return (request, response, authException) -> { - ApiErrorCode errorCode = ApiErrorCode.AUTH_INVALID_TOKEN; - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setContentType(MediaType.APPLICATION_JSON_VALUE); - response.getWriter().write(""" - {"code":"%s","message":"%s","traceId":"%s","timestamp":"%s","details":{}} - """.formatted( - escape(errorCode.name()), - escape(errorCode.getDefaultMessage()), - escape(TraceContext.getTraceId()), - escape(java.time.Instant.now().toString()) + ApiErrorCode errorCode = resolveAuthenticationErrorCode(request.getAttribute( + JwtAuthenticationFilter.AUTH_ERROR_CODE_ATTRIBUTE )); + log.warn("Authentication failed. code={}, traceId={}, uri={}", + errorCode.name(), TraceContext.getTraceId(), request.getRequestURI()); + writeErrorResponse(response, errorCode); + }; + } + + @Bean + public AccessDeniedHandler accessDeniedHandler() { + return (request, response, accessDeniedException) -> { + ApiErrorCode errorCode = ApiErrorCode.ACCESS_DENIED; + log.warn("Access denied. code={}, traceId={}, uri={}", + errorCode.name(), TraceContext.getTraceId(), request.getRequestURI()); + writeErrorResponse(response, errorCode); }; } + private ApiErrorCode resolveAuthenticationErrorCode(Object errorCodeAttribute) { + if (errorCodeAttribute instanceof ApiErrorCode errorCode) { + return errorCode; + } + return ApiErrorCode.AUTH_INVALID_TOKEN; + } + + private void writeErrorResponse(HttpServletResponse response, ApiErrorCode errorCode) throws java.io.IOException { + response.setStatus(errorCode.getStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write(""" + {"code":"%s","message":"%s","traceId":"%s","timestamp":"%s","details":{}} + """.formatted( + escape(errorCode.name()), + escape(errorCode.getDefaultMessage()), + escape(TraceContext.getTraceId()), + escape(Instant.now().toString()) + )); + } + private static String escape(String value) { if (value == null) { return ""; diff --git a/backend/src/main/java/com/stackup/stackup/common/sse/SseController.java b/backend/src/main/java/com/stackup/stackup/common/sse/SseController.java index 28c87fc4..98ba9635 100644 --- a/backend/src/main/java/com/stackup/stackup/common/sse/SseController.java +++ b/backend/src/main/java/com/stackup/stackup/common/sse/SseController.java @@ -12,13 +12,7 @@ @RestController @RequestMapping("/api/stream") -public class SseController { - - private final SseEmitterRegistry registry; - - public SseController(SseEmitterRegistry registry) { - this.registry = registry; - } +public record SseController(SseEmitterRegistry registry) { @GetMapping(value = "/me", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter streamMe(@AuthenticationPrincipal UserPrincipal principal) { diff --git a/backend/src/main/java/com/stackup/stackup/common/storage/S3ObjectStorageClient.java b/backend/src/main/java/com/stackup/stackup/common/storage/S3ObjectStorageClient.java index 0e37549a..1c8abbb7 100644 --- a/backend/src/main/java/com/stackup/stackup/common/storage/S3ObjectStorageClient.java +++ b/backend/src/main/java/com/stackup/stackup/common/storage/S3ObjectStorageClient.java @@ -69,7 +69,7 @@ public StoredObject put(String key, InputStream content, long size, String conte s3Client.putObject(requestBuilder.build(), RequestBody.fromInputStream(content, size)); return new StoredObject(properties.bucket(), key, size, contentType); } catch (SdkException e) { - throw new StorageException("Failed to upload object to S3", e); + throw new StorageException(StorageErrorType.UPLOAD_FAILED, "Failed to upload object to S3", e); } } @@ -85,7 +85,7 @@ public InputStream get(String key) { ); return response; } catch (SdkException e) { - throw new StorageException("Failed to download object from S3", e); + throw new StorageException(StorageErrorType.DOWNLOAD_FAILED, "Failed to download object from S3", e); } } @@ -100,7 +100,7 @@ public void delete(String key) { .build() ); } catch (SdkException e) { - throw new StorageException("Failed to delete object from S3", e); + throw new StorageException(StorageErrorType.DELETE_FAILED, "Failed to delete object from S3", e); } } @@ -120,7 +120,11 @@ public URI createPresignedGetUrl(String key, Duration ttl) { ); return presignedRequest.url().toURI(); } catch (SdkException | URISyntaxException e) { - throw new StorageException("Failed to create presigned URL for S3 object", e); + throw new StorageException( + StorageErrorType.PRESIGNED_URL_FAILED, + "Failed to create presigned URL for S3 object", + e + ); } } @@ -132,7 +136,7 @@ public void close() { private static void requireKey(String key) { if (!hasText(key)) { - throw new StorageException("Object key must not be blank"); + throw new StorageException(StorageErrorType.INVALID_OBJECT_KEY, "Object key must not be blank"); } } diff --git a/backend/src/main/java/com/stackup/stackup/common/storage/StorageErrorType.java b/backend/src/main/java/com/stackup/stackup/common/storage/StorageErrorType.java new file mode 100644 index 00000000..d4cb51a7 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/storage/StorageErrorType.java @@ -0,0 +1,9 @@ +package com.stackup.stackup.common.storage; + +public enum StorageErrorType { + INVALID_OBJECT_KEY, + UPLOAD_FAILED, + DOWNLOAD_FAILED, + DELETE_FAILED, + PRESIGNED_URL_FAILED +} diff --git a/backend/src/main/java/com/stackup/stackup/common/storage/StorageException.java b/backend/src/main/java/com/stackup/stackup/common/storage/StorageException.java index 4a7fab09..500ed0f3 100644 --- a/backend/src/main/java/com/stackup/stackup/common/storage/StorageException.java +++ b/backend/src/main/java/com/stackup/stackup/common/storage/StorageException.java @@ -2,11 +2,19 @@ public class StorageException extends RuntimeException { - public StorageException(String message) { + private final StorageErrorType type; + + public StorageException(StorageErrorType type, String message) { super(message); + this.type = type; } - public StorageException(String message, Throwable cause) { + public StorageException(StorageErrorType type, String message, Throwable cause) { super(message, cause); + this.type = type; + } + + public StorageErrorType getType() { + return type; } } diff --git a/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiClient.java b/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiClient.java new file mode 100644 index 00000000..a5d948da --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiClient.java @@ -0,0 +1,68 @@ +package com.stackup.stackup.github.infrastructure; + +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.github.infrastructure.dto.GithubEmailResponse; +import com.stackup.stackup.github.infrastructure.dto.GithubUserResponse; +import java.util.Arrays; +import java.util.Optional; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClientException; + +@Component +public class GithubApiClient { + + private final GithubApiHttpClient githubApiHttpClient; + + public GithubApiClient(GithubApiHttpClient githubApiHttpClient) { + this.githubApiHttpClient = githubApiHttpClient; + } + + public GithubUserResponse getUser(String githubAccessToken) { + try { + GithubUserResponse response = githubApiHttpClient.getUser(bearer(githubAccessToken)); + + if (response == null || response.id() == null || response.login() == null || response.login().isBlank()) { + throw oauthFailed(); + } + return response; + } catch (RestClientException exception) { + throw oauthFailed(exception); + } + } + + public Optional getPrimaryVerifiedEmail(String githubAccessToken) { + try { + GithubEmailResponse[] response = githubApiHttpClient.getUserEmails(bearer(githubAccessToken)); + + if (response == null) { + return Optional.empty(); + } + + return Arrays.stream(response) + .filter(GithubEmailResponse::primary) + .filter(GithubEmailResponse::verified) + .map(GithubEmailResponse::email) + .filter(email -> email != null && !email.isBlank()) + .findFirst(); + } catch (RestClientException exception) { + throw oauthFailed(exception); + } + } + + private String bearer(String accessToken) { + return "Bearer " + accessToken; + } + + private DomainException oauthFailed() { + return new DomainException(ApiErrorCode.AUTH_GITHUB_OAUTH_FAILED); + } + + private DomainException oauthFailed(Throwable cause) { + return new DomainException( + ApiErrorCode.AUTH_GITHUB_OAUTH_FAILED, + ApiErrorCode.AUTH_GITHUB_OAUTH_FAILED.getDefaultMessage(), + cause + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiHttpClient.java b/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiHttpClient.java new file mode 100644 index 00000000..66f5fe95 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiHttpClient.java @@ -0,0 +1,15 @@ +package com.stackup.stackup.github.infrastructure; + +import com.stackup.stackup.github.infrastructure.dto.GithubEmailResponse; +import com.stackup.stackup.github.infrastructure.dto.GithubUserResponse; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.service.annotation.GetExchange; + +public interface GithubApiHttpClient { + + @GetExchange("/user") + GithubUserResponse getUser(@RequestHeader("Authorization") String authorization); + + @GetExchange("/user/emails") + GithubEmailResponse[] getUserEmails(@RequestHeader("Authorization") String authorization); +} diff --git a/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiHttpClientConfig.java b/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiHttpClientConfig.java new file mode 100644 index 00000000..68a854ee --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiHttpClientConfig.java @@ -0,0 +1,34 @@ +package com.stackup.stackup.github.infrastructure; + +import com.stackup.stackup.common.config.properties.GithubOAuthProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.support.RestClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +@Configuration +public class GithubApiHttpClientConfig { + + @Bean + public GithubApiHttpClient githubApiHttpClient(GithubOAuthProperties properties) { + RestClient restClient = RestClient.builder() + .requestFactory(requestFactory(properties)) + .baseUrl(properties.apiBaseUrl()) + .defaultHeader(HttpHeaders.ACCEPT, "application/vnd.github+json") + .defaultHeader("X-GitHub-Api-Version", properties.apiVersion()) + .build(); + RestClientAdapter adapter = RestClientAdapter.create(restClient); + HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build(); + return factory.createClient(GithubApiHttpClient.class); + } + + private SimpleClientHttpRequestFactory requestFactory(GithubOAuthProperties properties) { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(properties.connectTimeout()); + requestFactory.setReadTimeout(properties.readTimeout()); + return requestFactory; + } +} diff --git a/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubTokenCipher.java b/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubTokenCipher.java new file mode 100644 index 00000000..21caa4e0 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubTokenCipher.java @@ -0,0 +1,102 @@ +package com.stackup.stackup.github.infrastructure; + +import com.stackup.stackup.common.config.properties.SecurityProperties; +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Base64; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.stereotype.Component; + +@Component +public class GithubTokenCipher { + + private static final String CIPHER_TRANSFORMATION = "AES/GCM/NoPadding"; + private static final String KEY_ALGORITHM = "AES"; + private static final int GCM_TAG_LENGTH_BITS = 128; + private static final int IV_LENGTH_BYTES = 12; + private static final int AES_256_KEY_BYTES = 32; + + private final SecurityProperties securityProperties; + private final SecureRandom secureRandom = new SecureRandom(); + + public GithubTokenCipher(SecurityProperties securityProperties) { + this.securityProperties = securityProperties; + } + + public String encrypt(String plainToken) { + if (plainToken == null || plainToken.isBlank()) { + throw oauthFailed(); + } + + byte[] iv = new byte[IV_LENGTH_BYTES]; + secureRandom.nextBytes(iv); + + try { + Cipher cipher = Cipher.getInstance(CIPHER_TRANSFORMATION); + cipher.init(Cipher.ENCRYPT_MODE, secretKey(), new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)); + byte[] cipherText = cipher.doFinal(plainToken.getBytes(StandardCharsets.UTF_8)); + + ByteBuffer buffer = ByteBuffer.allocate(iv.length + cipherText.length); + buffer.put(iv); + buffer.put(cipherText); + return Base64.getEncoder().encodeToString(buffer.array()); + } catch (GeneralSecurityException exception) { + throw oauthFailed(exception); + } + } + + public String decrypt(String encryptedToken) { + if (encryptedToken == null || encryptedToken.isBlank()) { + throw oauthFailed(); + } + + try { + byte[] payload = Base64.getDecoder().decode(encryptedToken); + if (payload.length <= IV_LENGTH_BYTES) { + throw oauthFailed(); + } + + ByteBuffer buffer = ByteBuffer.wrap(payload); + byte[] iv = new byte[IV_LENGTH_BYTES]; + buffer.get(iv); + byte[] cipherText = new byte[buffer.remaining()]; + buffer.get(cipherText); + + Cipher cipher = Cipher.getInstance(CIPHER_TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, secretKey(), new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)); + return new String(cipher.doFinal(cipherText), StandardCharsets.UTF_8); + } catch (IllegalArgumentException | GeneralSecurityException exception) { + throw oauthFailed(exception); + } + } + + private SecretKeySpec secretKey() { + try { + byte[] key = Base64.getDecoder().decode(securityProperties.encryptionKey()); + if (key.length != AES_256_KEY_BYTES) { + throw oauthFailed(); + } + return new SecretKeySpec(key, KEY_ALGORITHM); + } catch (IllegalArgumentException exception) { + throw oauthFailed(exception); + } + } + + private DomainException oauthFailed() { + return new DomainException(ApiErrorCode.AUTH_GITHUB_OAUTH_FAILED); + } + + private DomainException oauthFailed(Throwable cause) { + return new DomainException( + ApiErrorCode.AUTH_GITHUB_OAUTH_FAILED, + ApiErrorCode.AUTH_GITHUB_OAUTH_FAILED.getDefaultMessage(), + cause + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/github/infrastructure/dto/GithubEmailResponse.java b/backend/src/main/java/com/stackup/stackup/github/infrastructure/dto/GithubEmailResponse.java new file mode 100644 index 00000000..5d9df81f --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/infrastructure/dto/GithubEmailResponse.java @@ -0,0 +1,9 @@ +package com.stackup.stackup.github.infrastructure.dto; + +public record GithubEmailResponse( + String email, + boolean primary, + boolean verified, + String visibility +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/github/infrastructure/dto/GithubUserResponse.java b/backend/src/main/java/com/stackup/stackup/github/infrastructure/dto/GithubUserResponse.java new file mode 100644 index 00000000..384dc1eb --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/infrastructure/dto/GithubUserResponse.java @@ -0,0 +1,13 @@ +package com.stackup.stackup.github.infrastructure.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record GithubUserResponse( + Long id, + String login, + String email, + + @JsonProperty("avatar_url") + String avatarUrl +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/system/presentation/SystemController.java b/backend/src/main/java/com/stackup/stackup/system/presentation/SystemController.java index cdcbd631..acad7966 100644 --- a/backend/src/main/java/com/stackup/stackup/system/presentation/SystemController.java +++ b/backend/src/main/java/com/stackup/stackup/system/presentation/SystemController.java @@ -10,13 +10,7 @@ @RestController @RequestMapping("/api/system") -public class SystemController { - - private final SystemHealthService systemHealthService; - - public SystemController(SystemHealthService systemHealthService) { - this.systemHealthService = systemHealthService; - } +public record SystemController(SystemHealthService systemHealthService) { @GetMapping("/live") public ResponseEntity live() { diff --git a/backend/src/main/java/com/stackup/stackup/user/application/UserService.java b/backend/src/main/java/com/stackup/stackup/user/application/UserService.java new file mode 100644 index 00000000..d42925d3 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/user/application/UserService.java @@ -0,0 +1,31 @@ +package com.stackup.stackup.user.application; + +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.user.application.dto.UserProfileResult; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public record UserService(UserRepository userRepository) { + + @Transactional(readOnly = true) + public UserProfileResult getCurrentUser(Long userId) { + if (userId == null) { + throw new DomainException(ApiErrorCode.AUTH_INVALID_TOKEN); + } + + User user = userRepository.findByIdAndDeletedFalse(userId) + .orElseThrow(() -> new DomainException(ApiErrorCode.USER_NOT_FOUND)); + + return new UserProfileResult( + user.getId(), + user.getGithubId(), + user.getGithubUsername(), + user.getEmail(), + user.getAvatarUrl() + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/user/application/dto/UserProfileResult.java b/backend/src/main/java/com/stackup/stackup/user/application/dto/UserProfileResult.java new file mode 100644 index 00000000..0d93727e --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/user/application/dto/UserProfileResult.java @@ -0,0 +1,10 @@ +package com.stackup.stackup.user.application.dto; + +public record UserProfileResult( + long id, + long githubId, + String githubUsername, + String email, + String avatarUrl +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/user/domain/User.java b/backend/src/main/java/com/stackup/stackup/user/domain/User.java index 88eaf6d3..6e59fa76 100644 --- a/backend/src/main/java/com/stackup/stackup/user/domain/User.java +++ b/backend/src/main/java/com/stackup/stackup/user/domain/User.java @@ -6,7 +6,6 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.Index; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Getter; @@ -14,12 +13,7 @@ @Getter @Entity -@Table( - name = "users", - indexes = { - @Index(name = "idx_users_github_id", columnList = "github_id") - } -) +@Table(name = "users") @NoArgsConstructor(access = AccessLevel.PROTECTED) public class User extends BaseSoftDeleteEntity { @@ -27,7 +21,7 @@ public class User extends BaseSoftDeleteEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(name = "github_id", nullable = false, unique = true) + @Column(name = "github_id", nullable = false) private Long githubId; @Column(name = "github_username", nullable = false, length = 100) @@ -41,4 +35,40 @@ public class User extends BaseSoftDeleteEntity { @Column(name = "encrypted_github_access_token", nullable = false, length = 1000) private String encryptedGithubAccessToken; + + private User( + Long githubId, + String githubUsername, + String email, + String avatarUrl, + String encryptedGithubAccessToken + ) { + this.githubId = githubId; + this.githubUsername = githubUsername; + this.email = email; + this.avatarUrl = avatarUrl; + this.encryptedGithubAccessToken = encryptedGithubAccessToken; + } + + public static User createGithubUser( + Long githubId, + String githubUsername, + String email, + String avatarUrl, + String encryptedGithubAccessToken + ) { + return new User(githubId, githubUsername, email, avatarUrl, encryptedGithubAccessToken); + } + + public void updateGithubProfile( + String githubUsername, + String email, + String avatarUrl, + String encryptedGithubAccessToken + ) { + this.githubUsername = githubUsername; + this.email = email; + this.avatarUrl = avatarUrl; + this.encryptedGithubAccessToken = encryptedGithubAccessToken; + } } diff --git a/backend/src/main/java/com/stackup/stackup/user/domain/UserRepository.java b/backend/src/main/java/com/stackup/stackup/user/domain/UserRepository.java index 98cd2e75..0bbd169e 100644 --- a/backend/src/main/java/com/stackup/stackup/user/domain/UserRepository.java +++ b/backend/src/main/java/com/stackup/stackup/user/domain/UserRepository.java @@ -7,5 +7,9 @@ public interface UserRepository extends JpaRepository { Optional findByGithubId(Long githubId); + Optional findByGithubIdAndDeletedFalse(Long githubId); + + Optional findByIdAndDeletedFalse(Long id); + Optional findByGithubUsername(String githubUsername); } diff --git a/backend/src/main/java/com/stackup/stackup/user/presentation/UserController.java b/backend/src/main/java/com/stackup/stackup/user/presentation/UserController.java new file mode 100644 index 00000000..ab50937d --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/user/presentation/UserController.java @@ -0,0 +1,42 @@ +package com.stackup.stackup.user.presentation; + +import com.stackup.stackup.common.security.UserPrincipal; +import com.stackup.stackup.user.application.UserService; +import com.stackup.stackup.user.application.dto.UserProfileResult; +import com.stackup.stackup.user.presentation.dto.UserProfileResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/users") +@Tag(name = "Users", description = "Authenticated user APIs") +public record UserController(UserService userService) { + + @Operation(operationId = "getCurrentUser", summary = "Get current authenticated user profile") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Current user profile returned"), + @ApiResponse(responseCode = "401", description = "Authentication is required"), + @ApiResponse(responseCode = "404", description = "User not found") + }) + @GetMapping("/me") + public ResponseEntity getCurrentUser( + @AuthenticationPrincipal UserPrincipal principal + ) { + Long userId = principal == null ? null : principal.userId(); + UserProfileResult result = userService.getCurrentUser(userId); + return ResponseEntity.ok(new UserProfileResponse( + result.id(), + result.githubId(), + result.githubUsername(), + result.email(), + result.avatarUrl() + )); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/user/presentation/dto/UserProfileResponse.java b/backend/src/main/java/com/stackup/stackup/user/presentation/dto/UserProfileResponse.java new file mode 100644 index 00000000..3868eb13 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/user/presentation/dto/UserProfileResponse.java @@ -0,0 +1,21 @@ +package com.stackup.stackup.user.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record UserProfileResponse( + @Schema(description = "StackUp user id", example = "1", nullable = false) + long id, + + @Schema(description = "Stable GitHub user id", example = "123456", nullable = false) + long githubId, + + @Schema(description = "GitHub username at login time", example = "octocat", nullable = false) + String githubUsername, + + @Schema(description = "Primary GitHub email when available", example = "octocat@example.com", nullable = true) + String email, + + @Schema(description = "GitHub avatar URL", example = "https://avatars.githubusercontent.com/u/123456", nullable = true) + String avatarUrl +) { +} diff --git a/backend/src/main/resources/application-local.yml b/backend/src/main/resources/application-local.yml index e7d1c4d7..7653cec3 100644 --- a/backend/src/main/resources/application-local.yml +++ b/backend/src/main/resources/application-local.yml @@ -16,3 +16,5 @@ app: github: client-id: ${GITHUB_OAUTH_CLIENT_ID:local-github-client-id} client-secret: ${GITHUB_OAUTH_CLIENT_SECRET:local-github-client-secret} + redirect-uri: ${GITHUB_OAUTH_REDIRECT_URI:http://localhost:5173/auth/callback} + scopes: read:user user:email repo diff --git a/backend/src/main/resources/application-test.yml b/backend/src/main/resources/application-test.yml index 19e71fb4..5fdfeacf 100644 --- a/backend/src/main/resources/application-test.yml +++ b/backend/src/main/resources/application-test.yml @@ -15,3 +15,5 @@ app: github: client-id: test-github-client-id client-secret: test-github-client-secret + redirect-uri: http://localhost:5173/auth/callback + scopes: read:user user:email repo diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index b4b34973..3ce77217 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -15,6 +15,9 @@ spring: max-file-size: 20MB max-request-size: 20MB +server: + port: ${SERVER_PORT:38010} + management: endpoints: web: @@ -36,13 +39,31 @@ app: jwt-secret: ${JWT_SECRET:} access-token-ttl-seconds: ${JWT_ACCESS_TTL_SECONDS:900} refresh-token-ttl-seconds: ${JWT_REFRESH_TTL_SECONDS:1209600} + access-token-type: ${JWT_ACCESS_TOKEN_TYPE:Bearer} encryption-key: ${ENCRYPTION_KEY:} + oauth-state-ttl: ${OAUTH_STATE_TTL:5m} + oauth-state-byte-length: ${OAUTH_STATE_BYTE_LENGTH:32} + pkce-code-verifier-byte-length: ${PKCE_CODE_VERIFIER_BYTE_LENGTH:32} + refresh-token-byte-length: ${REFRESH_TOKEN_BYTE_LENGTH:32} + refresh-token-cookie-name: ${REFRESH_TOKEN_COOKIE_NAME:refresh_token} + refresh-token-cookie-path: ${REFRESH_TOKEN_COOKIE_PATH:/api/auth} + refresh-token-cookie-same-site: ${REFRESH_TOKEN_COOKIE_SAME_SITE:Strict} + refresh-token-cookie-secure: ${REFRESH_TOKEN_COOKIE_SECURE:true} + refresh-token-cookie-http-only: ${REFRESH_TOKEN_COOKIE_HTTP_ONLY:true} github: client-id: ${GITHUB_OAUTH_CLIENT_ID:} client-secret: ${GITHUB_OAUTH_CLIENT_SECRET:} - redirect-uri: ${GITHUB_OAUTH_REDIRECT_URI:http://localhost:5173/auth/callback} + redirect-uri: ${GITHUB_OAUTH_REDIRECT_URI:} + scopes: read:user user:email repo + oauth-base-url: ${GITHUB_OAUTH_BASE_URL:https://github.com} + token-type: ${GITHUB_OAUTH_TOKEN_TYPE:bearer} + code-challenge-method: ${GITHUB_OAUTH_CODE_CHALLENGE_METHOD:S256} + api-base-url: ${GITHUB_API_BASE_URL:https://api.github.com} + api-version: ${GITHUB_API_VERSION:2022-11-28} + connect-timeout: ${GITHUB_CONNECT_TIMEOUT:3s} + read-timeout: ${GITHUB_READ_TIMEOUT:5s} s3: - endpoint: ${S3_ENDPOINT:http://localhost:9000} + endpoint: ${S3_ENDPOINT:http://minio:9000} access-key: ${S3_ACCESS_KEY:minioadmin} secret-key: ${S3_SECRET_KEY:minioadmin} bucket: ${S3_BUCKET:stackup} @@ -85,5 +106,4 @@ app: keep-alive-interval: 30s stream-token-ttl: 60s cors: - allowed-origins: - - http://localhost:5173 + allowed-origins: ${CORS_ALLOWED_ORIGINS:https://your-domain} diff --git a/backend/src/main/resources/db/migration/V2__add_oauth_states.sql b/backend/src/main/resources/db/migration/V2__add_oauth_states.sql new file mode 100644 index 00000000..612c3f39 --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__add_oauth_states.sql @@ -0,0 +1,9 @@ +CREATE TABLE oauth_states ( + id BIGSERIAL PRIMARY KEY, + state VARCHAR(128) NOT NULL UNIQUE, + code_verifier VARCHAR(128) NOT NULL, + provider VARCHAR(20) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_oauth_states_provider CHECK (provider IN ('GITHUB')) +); diff --git a/backend/src/main/resources/db/migration/V3__use_partial_unique_user_github_id.sql b/backend/src/main/resources/db/migration/V3__use_partial_unique_user_github_id.sql new file mode 100644 index 00000000..6d08bc77 --- /dev/null +++ b/backend/src/main/resources/db/migration/V3__use_partial_unique_user_github_id.sql @@ -0,0 +1,5 @@ +ALTER TABLE users DROP CONSTRAINT IF EXISTS users_github_id_key; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_users_github_id_active + ON users (github_id) + WHERE is_deleted = FALSE; diff --git a/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java b/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java index c43aa8ff..e1eea7e1 100644 --- a/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java +++ b/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java @@ -1,11 +1,24 @@ package com.stackup.stackup; +import com.stackup.stackup.auth.domain.OAuthStateRepository; +import com.stackup.stackup.auth.domain.RefreshTokenRepository; +import com.stackup.stackup.user.domain.UserRepository; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; @SpringBootTest class StackupApplicationTests { + @MockitoBean + private OAuthStateRepository oauthStateRepository; + + @MockitoBean + private RefreshTokenRepository refreshTokenRepository; + + @MockitoBean + private UserRepository userRepository; + @Test void contextLoads() { } diff --git a/backend/src/test/java/com/stackup/stackup/auth/application/GithubUserServiceTest.java b/backend/src/test/java/com/stackup/stackup/auth/application/GithubUserServiceTest.java new file mode 100644 index 00000000..74957209 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/auth/application/GithubUserServiceTest.java @@ -0,0 +1,64 @@ +package com.stackup.stackup.auth.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.stackup.stackup.auth.application.dto.GithubUserProfile; +import com.stackup.stackup.auth.application.dto.GithubUserUpsertResult; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class GithubUserServiceTest { + + @Mock + private UserRepository userRepository; + + @Test + void upsertGithubUser_createsNewUser() { + GithubUserService service = new GithubUserService(userRepository); + GithubUserProfile profile = new GithubUserProfile(123L, "octocat", "octocat@example.com", "avatar"); + when(userRepository.findByGithubIdAndDeletedFalse(123L)).thenReturn(Optional.empty()); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> { + User user = invocation.getArgument(0); + ReflectionTestUtils.setField(user, "id", 1L); + return user; + }); + + GithubUserUpsertResult result = service.upsertGithubUser(profile, "encrypted-token"); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(userCaptor.capture()); + User savedUser = userCaptor.getValue(); + assertThat(savedUser.getGithubId()).isEqualTo(123L); + assertThat(savedUser.getGithubUsername()).isEqualTo("octocat"); + assertThat(savedUser.getEncryptedGithubAccessToken()).isEqualTo("encrypted-token"); + assertThat(result.newUser()).isTrue(); + } + + @Test + void upsertGithubUser_updatesExistingUser() { + GithubUserService service = new GithubUserService(userRepository); + User user = User.createGithubUser(123L, "old", null, null, "old-token"); + ReflectionTestUtils.setField(user, "id", 1L); + GithubUserProfile profile = new GithubUserProfile(123L, "octocat", "octocat@example.com", "avatar"); + when(userRepository.findByGithubIdAndDeletedFalse(123L)).thenReturn(Optional.of(user)); + + GithubUserUpsertResult result = service.upsertGithubUser(profile, "encrypted-token"); + + assertThat(user.getGithubUsername()).isEqualTo("octocat"); + assertThat(user.getEmail()).isEqualTo("octocat@example.com"); + assertThat(user.getAvatarUrl()).isEqualTo("avatar"); + assertThat(user.getEncryptedGithubAccessToken()).isEqualTo("encrypted-token"); + assertThat(result.newUser()).isFalse(); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/auth/application/OAuthStateServiceTest.java b/backend/src/test/java/com/stackup/stackup/auth/application/OAuthStateServiceTest.java new file mode 100644 index 00000000..79aeaeb4 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/auth/application/OAuthStateServiceTest.java @@ -0,0 +1,137 @@ +package com.stackup.stackup.auth.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.stackup.stackup.auth.application.dto.OAuthStateIssueResult; +import com.stackup.stackup.auth.domain.OAuthState; +import com.stackup.stackup.auth.domain.OAuthStateRepository; +import com.stackup.stackup.common.config.properties.SecurityProperties; +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Base64; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class OAuthStateServiceTest { + + private static final Instant NOW = Instant.parse("2026-05-14T00:00:00Z"); + + @Mock + private OAuthStateRepository oauthStateRepository; + + @Test + void issueGithubStateWithPkce_savesStateAndReturnsChallenge() throws Exception { + OAuthStateService service = new OAuthStateService( + oauthStateRepository, + securityProperties(), + new SecureRandom(), + Clock.fixed(NOW, ZoneOffset.UTC) + ); + + OAuthStateIssueResult result = service.issueGithubStateWithPkce(); + + ArgumentCaptor stateCaptor = ArgumentCaptor.forClass(OAuthState.class); + verify(oauthStateRepository).deleteByExpiresAtBefore(NOW); + verify(oauthStateRepository).save(stateCaptor.capture()); + + OAuthState savedState = stateCaptor.getValue(); + assertThat(result.state()).isEqualTo(savedState.getState()); + assertThat(result.state()).isNotBlank(); + assertThat(savedState.getCodeVerifier()).isNotBlank(); + assertThat(result.codeChallenge()).isEqualTo(s256(savedState.getCodeVerifier())); + assertThat(savedState.getExpiresAt()).isEqualTo(NOW.plusSeconds(300)); + } + + @Test + void consumeGithubCodeVerifier_deletesStateAndReturnsVerifier() { + OAuthState oauthState = OAuthState.issueGithub("state", "code-verifier", NOW.plusSeconds(60)); + when(oauthStateRepository.findByState("state")).thenReturn(Optional.of(oauthState)); + + OAuthStateService service = new OAuthStateService( + oauthStateRepository, + securityProperties(), + new SecureRandom(), + Clock.fixed(NOW, ZoneOffset.UTC) + ); + + String codeVerifier = service.consumeGithubCodeVerifier("state"); + + assertThat(codeVerifier).isEqualTo("code-verifier"); + verify(oauthStateRepository).delete(oauthState); + } + + @Test + void consumeGithubCodeVerifier_rejectsExpiredState() { + OAuthState oauthState = OAuthState.issueGithub("state", "code-verifier", NOW.minusSeconds(1)); + when(oauthStateRepository.findByState("state")).thenReturn(Optional.of(oauthState)); + + OAuthStateService service = new OAuthStateService( + oauthStateRepository, + securityProperties(), + new SecureRandom(), + Clock.fixed(NOW, ZoneOffset.UTC) + ); + + assertThatThrownBy(() -> service.consumeGithubCodeVerifier("state")) + .isInstanceOfSatisfying(DomainException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(ApiErrorCode.AUTH_GITHUB_OAUTH_FAILED) + ); + verify(oauthStateRepository).delete(oauthState); + } + + @Test + void consumeGithubCodeVerifier_rejectsMissingState() { + OAuthStateService service = new OAuthStateService( + oauthStateRepository, + securityProperties(), + new SecureRandom(), + Clock.fixed(NOW, ZoneOffset.UTC) + ); + + assertThatThrownBy(() -> service.consumeGithubCodeVerifier(" ")) + .isInstanceOfSatisfying(DomainException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(ApiErrorCode.AUTH_GITHUB_OAUTH_FAILED) + ); + } + + private static String s256(String value) throws Exception { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.US_ASCII)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } + + private static SecurityProperties securityProperties() { + return new SecurityProperties( + "jwt-secret", + "encryption-key", + 900, + 1209600, + "Bearer", + Duration.ofMinutes(5), + 32, + 32, + 32, + "refresh_token", + "/api/auth", + "Strict", + true, + true + ); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/auth/application/RefreshTokenServiceTest.java b/backend/src/test/java/com/stackup/stackup/auth/application/RefreshTokenServiceTest.java new file mode 100644 index 00000000..b2d0de35 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/auth/application/RefreshTokenServiceTest.java @@ -0,0 +1,148 @@ +package com.stackup.stackup.auth.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.stackup.stackup.auth.application.dto.RefreshTokenIssueResult; +import com.stackup.stackup.auth.application.dto.RefreshTokenRotationResult; +import com.stackup.stackup.auth.domain.RefreshToken; +import com.stackup.stackup.auth.domain.RefreshTokenRepository; +import com.stackup.stackup.common.config.properties.SecurityProperties; +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Base64; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class RefreshTokenServiceTest { + + private static final Instant NOW = Instant.parse("2026-05-14T00:00:00Z"); + + @Mock + private RefreshTokenRepository refreshTokenRepository; + + @Mock + private UserRepository userRepository; + + @Test + void issue_savesOnlyTokenHash() { + User user = user(1L); + when(userRepository.getReferenceById(1L)).thenReturn(user); + RefreshTokenService service = service(); + + RefreshTokenIssueResult result = service.issue(1L); + + ArgumentCaptor tokenCaptor = ArgumentCaptor.forClass(RefreshToken.class); + verify(refreshTokenRepository).save(tokenCaptor.capture()); + + RefreshToken savedToken = tokenCaptor.getValue(); + assertThat(result.rawToken()).isNotBlank(); + assertThat(result.maxAgeSeconds()).isEqualTo(1209600); + assertThat(savedToken.getTokenHash()).isEqualTo(hash(result.rawToken())); + assertThat(savedToken.getTokenHash()).isNotEqualTo(result.rawToken()); + assertThat(savedToken.getExpiresAt()).isEqualTo(NOW.plusSeconds(1209600)); + } + + @Test + void rotate_revokesOldTokenAndIssuesNewToken() { + User user = user(1L); + RefreshToken oldToken = RefreshToken.issue(user, hash("old-refresh-token"), null, NOW.plusSeconds(60)); + when(refreshTokenRepository.findByTokenHash(hash("old-refresh-token"))).thenReturn(Optional.of(oldToken)); + when(userRepository.getReferenceById(1L)).thenReturn(user); + RefreshTokenService service = service(); + + RefreshTokenRotationResult result = service.rotate("old-refresh-token"); + + assertThat(oldToken.isRevoked()).isTrue(); + assertThat(result.userId()).isEqualTo(1L); + assertThat(result.refreshToken().rawToken()).isNotBlank(); + verify(refreshTokenRepository).save(any(RefreshToken.class)); + } + + @Test + void rotate_rejectsRevokedToken() { + User user = user(1L); + RefreshToken oldToken = RefreshToken.issue(user, hash("old-refresh-token"), null, NOW.plusSeconds(60)); + oldToken.revoke(); + when(refreshTokenRepository.findByTokenHash(hash("old-refresh-token"))).thenReturn(Optional.of(oldToken)); + RefreshTokenService service = service(); + + assertThatThrownBy(() -> service.rotate("old-refresh-token")) + .isInstanceOfSatisfying(DomainException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(ApiErrorCode.AUTH_REVOKED_TOKEN) + ); + } + + @Test + void rotate_revokesAndRejectsExpiredToken() { + User user = user(1L); + RefreshToken oldToken = RefreshToken.issue(user, hash("old-refresh-token"), null, NOW.minusSeconds(1)); + when(refreshTokenRepository.findByTokenHash(hash("old-refresh-token"))).thenReturn(Optional.of(oldToken)); + RefreshTokenService service = service(); + + assertThatThrownBy(() -> service.rotate("old-refresh-token")) + .isInstanceOfSatisfying(DomainException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(ApiErrorCode.AUTH_EXPIRED_TOKEN) + ); + assertThat(oldToken.isRevoked()).isTrue(); + } + + private RefreshTokenService service() { + return new RefreshTokenService( + refreshTokenRepository, + userRepository, + new SecurityProperties( + "jwt-secret", + "encryption-key", + 900, + 1209600, + "Bearer", + Duration.ofMinutes(5), + 32, + 32, + 32, + "refresh_token", + "/api/auth", + "Strict", + true, + true + ), + new SecureRandom(), + Clock.fixed(NOW, ZoneOffset.UTC) + ); + } + + private static User user(Long id) { + User user = User.createGithubUser(123L, "octocat", null, null, "encrypted-token"); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private static String hash(String rawToken) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(rawToken.getBytes(StandardCharsets.UTF_8)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + } +} diff --git a/backend/src/test/java/com/stackup/stackup/github/infrastructure/GithubTokenCipherTest.java b/backend/src/test/java/com/stackup/stackup/github/infrastructure/GithubTokenCipherTest.java new file mode 100644 index 00000000..720b560c --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/github/infrastructure/GithubTokenCipherTest.java @@ -0,0 +1,61 @@ +package com.stackup.stackup.github.infrastructure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.stackup.stackup.common.config.properties.SecurityProperties; +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import java.security.SecureRandom; +import java.time.Duration; +import java.util.Base64; +import org.junit.jupiter.api.Test; + +class GithubTokenCipherTest { + + @Test + void encryptAndDecrypt() { + GithubTokenCipher cipher = new GithubTokenCipher(securityProperties(validEncryptionKey())); + + String encrypted = cipher.encrypt("github-access-token"); + String decrypted = cipher.decrypt(encrypted); + + assertThat(encrypted).isNotEqualTo("github-access-token"); + assertThat(decrypted).isEqualTo("github-access-token"); + } + + @Test + void encryptRejectsInvalidKey() { + GithubTokenCipher cipher = new GithubTokenCipher(securityProperties("invalid-key")); + + assertThatThrownBy(() -> cipher.encrypt("github-access-token")) + .isInstanceOfSatisfying(DomainException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(ApiErrorCode.AUTH_GITHUB_OAUTH_FAILED) + ); + } + + private static SecurityProperties securityProperties(String encryptionKey) { + return new SecurityProperties( + "test-jwt-secret", + encryptionKey, + 900, + 1209600, + "Bearer", + Duration.ofMinutes(5), + 32, + 32, + 32, + "refresh_token", + "/api/auth", + "Strict", + true, + true + ); + } + + private static String validEncryptionKey() { + byte[] bytes = new byte[32]; + new SecureRandom().nextBytes(bytes); + return Base64.getEncoder().encodeToString(bytes); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/user/application/UserServiceTest.java b/backend/src/test/java/com/stackup/stackup/user/application/UserServiceTest.java new file mode 100644 index 00000000..3527604c --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/user/application/UserServiceTest.java @@ -0,0 +1,67 @@ +package com.stackup.stackup.user.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.user.application.dto.UserProfileResult; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @Mock + private UserRepository userRepository; + + @Test + void getCurrentUser_returnsAuthenticatedUserProfile() { + User user = User.createGithubUser( + 123L, + "octocat", + "octocat@example.com", + "https://avatars.githubusercontent.com/u/123456", + "encrypted-token" + ); + ReflectionTestUtils.setField(user, "id", 1L); + when(userRepository.findByIdAndDeletedFalse(1L)).thenReturn(Optional.of(user)); + UserService service = new UserService(userRepository); + + UserProfileResult result = service.getCurrentUser(1L); + + assertThat(result.id()).isEqualTo(1L); + assertThat(result.githubId()).isEqualTo(123L); + assertThat(result.githubUsername()).isEqualTo("octocat"); + assertThat(result.email()).isEqualTo("octocat@example.com"); + assertThat(result.avatarUrl()).isEqualTo("https://avatars.githubusercontent.com/u/123456"); + } + + @Test + void getCurrentUser_rejectsMissingPrincipal() { + UserService service = new UserService(userRepository); + + assertThatThrownBy(() -> service.getCurrentUser(null)) + .isInstanceOf(DomainException.class) + .extracting("errorCode") + .isEqualTo(ApiErrorCode.AUTH_INVALID_TOKEN); + } + + @Test + void getCurrentUser_rejectsMissingUser() { + when(userRepository.findByIdAndDeletedFalse(1L)).thenReturn(Optional.empty()); + UserService service = new UserService(userRepository); + + assertThatThrownBy(() -> service.getCurrentUser(1L)) + .isInstanceOf(DomainException.class) + .extracting("errorCode") + .isEqualTo(ApiErrorCode.USER_NOT_FOUND); + } +}