From 6588e1e88d339ff0400b6d145fba292c1012cc39 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Wed, 13 May 2026 01:42:14 +0900 Subject: [PATCH 01/29] =?UTF-8?q?refactor:=20JacksonJsonMessageConverter?= =?UTF-8?q?=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 스프링 부트 4부터는 JacksonJsonMessageConverter으로 변경 --- .../com/stackup/stackup/common/messaging/RabbitMqConfig.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From ad9143cfadcb112b6c6ff46b85787359c4dd26f2 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Wed, 13 May 2026 02:04:20 +0900 Subject: [PATCH 02/29] =?UTF-8?q?feat:=20Storage=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/exception/GlobalExceptionHandler.java | 10 ++++++++++ .../common/storage/S3ObjectStorageClient.java | 14 +++++++++----- .../stackup/common/storage/StorageErrorType.java | 9 +++++++++ .../stackup/common/storage/StorageException.java | 12 ++++++++++-- 4 files changed, 38 insertions(+), 7 deletions(-) create mode 100644 backend/src/main/java/com/stackup/stackup/common/storage/StorageErrorType.java 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..89b6633c 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; @@ -60,6 +61,15 @@ public ResponseEntity handleAccessDenied(AccessDeniedException return buildResponse(ApiErrorCode.ACCESS_DENIED, ApiErrorCode.ACCESS_DENIED.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(), 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; } } From 47768861f2b3bd92006a82961cc538df42b1282c Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Wed, 13 May 2026 14:12:48 +0900 Subject: [PATCH 03/29] =?UTF-8?q?refactor:=20record=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=EB=A1=9C=20controller=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=8B=A8=EC=88=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stackup/stackup/auth/presentation/AuthController.java | 8 +------- .../com/stackup/stackup/common/sse/SseController.java | 8 +------- .../stackup/system/presentation/SystemController.java | 8 +------- 3 files changed, 3 insertions(+), 21 deletions(-) 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..91f0aa96 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 @@ -25,13 +25,7 @@ @RestController @RequestMapping("/api/auth") -public class AuthController { - - private final AuthService authService; - - public AuthController(AuthService authService) { - this.authService = authService; - } +public record AuthController(AuthService authService) { @PostMapping("/github") public ResponseEntity startGithubLogin(@RequestBody(required = false) GithubAuthRequest request) { 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/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() { From a801cb2435a11a1ca3bf7d49a9070a1431cd8941 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Thu, 14 May 2026 18:47:57 +0900 Subject: [PATCH 04/29] =?UTF-8?q?feat:=20Github=20OAuth=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.example | 10 ++++++++++ .../auth/infrastructure/GithubOAuthClient.java | 2 +- .../config/properties/GithubOAuthProperties.java | 11 ++++++++++- backend/src/main/resources/application-local.yml | 2 ++ backend/src/main/resources/application-test.yml | 2 ++ backend/src/main/resources/application.yml | 3 ++- 6 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 backend/.env.example diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 00000000..8f02f9d1 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,10 @@ +# Backend auth +JWT_SECRET=base64-encoded-32-byte-or-longer-secret +JWT_ACCESS_TTL_SECONDS=900 +JWT_REFRESH_TTL_SECONDS=1209600 +ENCRYPTION_KEY=base64-encoded-32-byte-key + +# GitHub OAuth +GITHUB_OAUTH_CLIENT_ID=your-github-oauth-client-id +GITHUB_OAUTH_CLIENT_SECRET=your-github-oauth-client-secret +GITHUB_OAUTH_REDIRECT_URI=http://localhost:5173/auth/callback 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..21be7eb0 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 @@ -17,7 +17,7 @@ public String buildAuthorizationUrl(String state) { return UriComponentsBuilder.fromUriString("https://github.com/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) .build() .toUriString(); 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..69fde98e 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 @@ -12,6 +12,15 @@ public record GithubOAuthProperties( @NotBlank String clientId, @NotBlank String clientSecret, @NotNull - URI redirectUri + URI redirectUri, + @NotBlank String scopes ) { + + @Override + public String toString() { + return "GithubOAuthProperties[clientId=" + clientId + + ", clientSecret=******" + + ", redirectUri=" + redirectUri + + ", scopes=" + scopes + "]"; + } } 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..2e90e42b 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -40,7 +40,8 @@ app: 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 s3: endpoint: ${S3_ENDPOINT:http://localhost:9000} access-key: ${S3_ACCESS_KEY:minioadmin} From 38c6304a0a938c7505628624b0b25d4cc07b4446 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Thu, 14 May 2026 19:03:02 +0900 Subject: [PATCH 05/29] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20DTO=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/AuthenticatedUserResult.java | 10 +++++++++ .../application/dto/GithubCallbackResult.java | 8 ++++--- .../application/dto/RefreshTokenResult.java | 4 +++- .../presentation/dto/AuthUserResponse.java | 21 +++++++++++++++++++ .../presentation/dto/GithubAuthRequest.java | 7 ------- .../dto/GithubCallbackResponse.java | 18 +++++++++++++--- .../presentation/dto/GithubLoginResponse.java | 5 +++++ .../auth/presentation/dto/LogoutRequest.java | 6 ------ .../presentation/dto/RefreshTokenRequest.java | 6 ------ .../dto/RefreshTokenResponse.java | 10 ++++++++- 10 files changed, 68 insertions(+), 27 deletions(-) create mode 100644 backend/src/main/java/com/stackup/stackup/auth/application/dto/AuthenticatedUserResult.java create mode 100644 backend/src/main/java/com/stackup/stackup/auth/presentation/dto/AuthUserResponse.java delete mode 100644 backend/src/main/java/com/stackup/stackup/auth/presentation/dto/GithubAuthRequest.java delete mode 100644 backend/src/main/java/com/stackup/stackup/auth/presentation/dto/LogoutRequest.java delete mode 100644 backend/src/main/java/com/stackup/stackup/auth/presentation/dto/RefreshTokenRequest.java 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..1d0747b9 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,10 @@ public record GithubCallbackResult( String accessToken, - String refreshToken, - long userId, - String githubUsername + String tokenType, + long expiresIn, + AuthenticatedUserResult user, + boolean isNewUser, + String refreshTokenRawForCookie ) { } 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..eafb4385 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,8 @@ public record RefreshTokenResult( String accessToken, - String refreshToken + String tokenType, + long expiresIn, + String refreshTokenRawForCookie ) { } 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 ) { } From 59e9426d46e33df4ce4f2f2f1b7fcc43ad262150 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Thu, 14 May 2026 19:03:33 +0900 Subject: [PATCH 06/29] =?UTF-8?q?feat:=20=EA=B9=83=ED=97=88=EB=B8=8C=20=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stackup/auth/application/AuthService.java | 30 ++++++-- .../auth/presentation/AuthController.java | 68 +++++++++++++++---- 2 files changed, 80 insertions(+), 18 deletions(-) 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..05d97c39 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 @@ -1,10 +1,12 @@ package com.stackup.stackup.auth.application; import com.stackup.stackup.auth.infrastructure.GithubOAuthClient; +import com.stackup.stackup.auth.application.dto.AuthenticatedUserResult; import com.stackup.stackup.auth.application.dto.GithubCallbackResult; 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.common.config.properties.SecurityProperties; import com.stackup.stackup.common.security.JwtTokenProvider; import com.stackup.stackup.common.security.StreamTokenProvider; import java.util.UUID; @@ -15,20 +17,25 @@ public class AuthService { private static final long SKELETON_USER_ID = 1L; + private static final long SKELETON_GITHUB_ID = 123456L; private static final String SKELETON_GITHUB_USERNAME = "stackup-user"; + private static final String TOKEN_TYPE_BEARER = "Bearer"; private final GithubOAuthClient githubOAuthClient; private final JwtTokenProvider jwtTokenProvider; private final StreamTokenProvider streamTokenProvider; + private final SecurityProperties securityProperties; public AuthService( GithubOAuthClient githubOAuthClient, JwtTokenProvider jwtTokenProvider, - StreamTokenProvider streamTokenProvider + StreamTokenProvider streamTokenProvider, + SecurityProperties securityProperties ) { this.githubOAuthClient = githubOAuthClient; this.jwtTokenProvider = jwtTokenProvider; this.streamTokenProvider = streamTokenProvider; + this.securityProperties = securityProperties; } public GithubLoginResult startGithubLogin() { @@ -39,14 +46,27 @@ public GithubLoginResult startGithubLogin() { public GithubCallbackResult completeGithubLogin(String code, String state) { return new GithubCallbackResult( jwtTokenProvider.createAccessToken(SKELETON_USER_ID), - createRefreshTokenStub(), - SKELETON_USER_ID, - SKELETON_GITHUB_USERNAME + TOKEN_TYPE_BEARER, + securityProperties.accessTokenTtlSeconds(), + new AuthenticatedUserResult( + SKELETON_USER_ID, + SKELETON_GITHUB_ID, + SKELETON_GITHUB_USERNAME, + "stackup-user@example.com", + "https://avatars.githubusercontent.com/u/123456" + ), + true, + createRefreshTokenStub() ); } public RefreshTokenResult refresh(String refreshToken) { - return new RefreshTokenResult(jwtTokenProvider.createAccessToken(SKELETON_USER_ID), createRefreshTokenStub()); + return new RefreshTokenResult( + jwtTokenProvider.createAccessToken(SKELETON_USER_ID), + TOKEN_TYPE_BEARER, + securityProperties.accessTokenTtlSeconds(), + createRefreshTokenStub() + ); } public void logout(String refreshToken) { 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 91f0aa96..35771ee4 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,62 +5,104 @@ 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.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 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") +@Tag(name = "Auth", description = "GitHub OAuth login and authentication token APIs") public record AuthController(AuthService authService) { + @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); + // TODO: Set refresh_token HttpOnly cookie when refresh token persistence is implemented. return ResponseEntity.ok(new GithubCallbackResponse( result.accessToken(), - result.refreshToken(), - result.userId(), - result.githubUsername() + 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 = "refresh_token", required = false) String refreshToken + ) { RefreshTokenResult result = authService.refresh(refreshToken); - return ResponseEntity.ok(new RefreshTokenResponse(result.accessToken(), result.refreshToken())); + // TODO: Rotate refresh_token HttpOnly cookie when refresh token persistence is implemented. + return ResponseEntity.ok(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 = "refresh_token", required = false) String refreshToken + ) { authService.logout(refreshToken); + // TODO: Expire refresh_token HttpOnly cookie when refresh token persistence is implemented. return ResponseEntity.noContent().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 From b5f64dc85d7868d094c6e4728a40ae4fb842a44f Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Thu, 14 May 2026 19:27:55 +0900 Subject: [PATCH 07/29] =?UTF-8?q?feat:=20OAuth=20state=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=B0=8F=20DB=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stackup/auth/domain/OAuthProvider.java | 5 ++ .../stackup/auth/domain/OAuthState.java | 61 +++++++++++++++++++ .../auth/domain/OAuthStateRepository.java | 12 ++++ .../db/migration/V2__add_oauth_states.sql | 12 ++++ 4 files changed, 90 insertions(+) create mode 100644 backend/src/main/java/com/stackup/stackup/auth/domain/OAuthProvider.java create mode 100644 backend/src/main/java/com/stackup/stackup/auth/domain/OAuthState.java create mode 100644 backend/src/main/java/com/stackup/stackup/auth/domain/OAuthStateRepository.java create mode 100644 backend/src/main/resources/db/migration/V2__add_oauth_states.sql 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..6bdabbec --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/domain/OAuthState.java @@ -0,0 +1,61 @@ +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.Index; +import jakarta.persistence.Table; +import java.time.Instant; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table( + name = "oauth_states", + indexes = { + @Index(name = "idx_oauth_states_state", columnList = "state"), + @Index(name = "idx_oauth_states_expires_at", columnList = "expires_at") + } +) +@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/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..219777d6 --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__add_oauth_states.sql @@ -0,0 +1,12 @@ +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')) +); + +CREATE INDEX idx_oauth_states_state ON oauth_states (state); +CREATE INDEX idx_oauth_states_expires_at ON oauth_states (expires_at); From 9c32c0d61e9b798a59c0334fc1c556842c4239da Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Thu, 14 May 2026 19:29:20 +0900 Subject: [PATCH 08/29] =?UTF-8?q?PKCE=20state=20=EB=B0=9C=EA=B8=89=20?= =?UTF-8?q?=EB=B0=8F=20callback=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stackup/auth/application/AuthService.java | 18 ++- .../auth/application/OAuthStateService.java | 92 ++++++++++++++ .../dto/OAuthStateIssueResult.java | 7 ++ .../infrastructure/GithubOAuthClient.java | 4 +- .../stackup/StackupApplicationTests.java | 5 + .../application/OAuthStateServiceTest.java | 112 ++++++++++++++++++ 6 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 backend/src/main/java/com/stackup/stackup/auth/application/OAuthStateService.java create mode 100644 backend/src/main/java/com/stackup/stackup/auth/application/dto/OAuthStateIssueResult.java create mode 100644 backend/src/test/java/com/stackup/stackup/auth/application/OAuthStateServiceTest.java 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 05d97c39..dd040e7e 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 @@ -4,9 +4,12 @@ import com.stackup.stackup.auth.application.dto.AuthenticatedUserResult; import com.stackup.stackup.auth.application.dto.GithubCallbackResult; import com.stackup.stackup.auth.application.dto.GithubLoginResult; +import com.stackup.stackup.auth.application.dto.OAuthStateIssueResult; import com.stackup.stackup.auth.application.dto.RefreshTokenResult; 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; @@ -22,28 +25,39 @@ public class AuthService { private static final String TOKEN_TYPE_BEARER = "Bearer"; private final GithubOAuthClient githubOAuthClient; + private final OAuthStateService oauthStateService; private final JwtTokenProvider jwtTokenProvider; private final StreamTokenProvider streamTokenProvider; private final SecurityProperties securityProperties; public AuthService( GithubOAuthClient githubOAuthClient, + OAuthStateService oauthStateService, JwtTokenProvider jwtTokenProvider, StreamTokenProvider streamTokenProvider, SecurityProperties securityProperties ) { this.githubOAuthClient = githubOAuthClient; + this.oauthStateService = oauthStateService; this.jwtTokenProvider = jwtTokenProvider; this.streamTokenProvider = streamTokenProvider; this.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); + // TODO: Use codeVerifier for GitHub token exchange in the next OAuth implementation phase. return new GithubCallbackResult( jwtTokenProvider.createAccessToken(SKELETON_USER_ID), TOKEN_TYPE_BEARER, 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..8f748801 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/application/OAuthStateService.java @@ -0,0 +1,92 @@ +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.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.Duration; +import java.time.Instant; +import java.util.Base64; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class OAuthStateService { + + private static final Duration STATE_TTL = Duration.ofMinutes(5); + private static final int STATE_BYTES = 32; + private static final int CODE_VERIFIER_BYTES = 32; + + private final OAuthStateRepository oauthStateRepository; + private final SecureRandom secureRandom; + private final Clock clock; + + @Autowired + public OAuthStateService(OAuthStateRepository oauthStateRepository) { + this(oauthStateRepository, new SecureRandom(), Clock.systemUTC()); + } + + OAuthStateService(OAuthStateRepository oauthStateRepository, SecureRandom secureRandom, Clock clock) { + this.oauthStateRepository = oauthStateRepository; + this.secureRandom = secureRandom; + this.clock = clock; + } + + @Transactional + public OAuthStateIssueResult issueGithubStateWithPkce() { + Instant now = Instant.now(clock); + oauthStateRepository.deleteByExpiresAtBefore(now); + + String state = randomUrlSafeValue(STATE_BYTES); + String codeVerifier = randomUrlSafeValue(CODE_VERIFIER_BYTES); + String codeChallenge = s256Challenge(codeVerifier); + + oauthStateRepository.save(OAuthState.issueGithub(state, codeVerifier, now.plus(STATE_TTL))); + 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/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/infrastructure/GithubOAuthClient.java b/backend/src/main/java/com/stackup/stackup/auth/infrastructure/GithubOAuthClient.java index 21be7eb0..d5e44f06 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 @@ -13,12 +13,14 @@ public GithubOAuthClient(GithubOAuthProperties githubOAuthProperties) { this.githubOAuthProperties = githubOAuthProperties; } - public String buildAuthorizationUrl(String state) { + public String buildAuthorizationUrl(String state, String codeChallenge) { return UriComponentsBuilder.fromUriString("https://github.com/login/oauth/authorize") .queryParam("client_id", githubOAuthProperties.clientId()) .queryParam("redirect_uri", githubOAuthProperties.redirectUri()) .queryParam("scope", githubOAuthProperties.scopes()) .queryParam("state", state) + .queryParam("code_challenge", codeChallenge) + .queryParam("code_challenge_method", "S256") .build() .toUriString(); } diff --git a/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java b/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java index c43aa8ff..003f44f7 100644 --- a/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java +++ b/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java @@ -1,11 +1,16 @@ package com.stackup.stackup; +import com.stackup.stackup.auth.domain.OAuthStateRepository; 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; + @Test void contextLoads() { } 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..755d4403 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/auth/application/OAuthStateServiceTest.java @@ -0,0 +1,112 @@ +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.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.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, + 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, + 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, + 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, + 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); + } +} From 9e1558fe69cbf8dcfe7895452afa6a7430049aca Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Thu, 14 May 2026 19:42:00 +0900 Subject: [PATCH 09/29] =?UTF-8?q?feat:=20GGithub=20OAuth=20token=20?= =?UTF-8?q?=EA=B5=90=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/GithubOAuthClient.java | 79 ++++++++++++++ .../dto/GithubTokenResponse.java | 19 ++++ .../infrastructure/GithubApiClient.java | 84 +++++++++++++++ .../infrastructure/GithubTokenCipher.java | 102 ++++++++++++++++++ .../dto/GithubEmailResponse.java | 9 ++ .../dto/GithubUserResponse.java | 13 +++ 6 files changed, 306 insertions(+) create mode 100644 backend/src/main/java/com/stackup/stackup/auth/infrastructure/dto/GithubTokenResponse.java create mode 100644 backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiClient.java create mode 100644 backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubTokenCipher.java create mode 100644 backend/src/main/java/com/stackup/stackup/github/infrastructure/dto/GithubEmailResponse.java create mode 100644 backend/src/main/java/com/stackup/stackup/github/infrastructure/dto/GithubUserResponse.java 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 d5e44f06..afb60379 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,16 +1,32 @@ 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.http.MediaType; import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; import org.springframework.web.util.UriComponentsBuilder; @Component public class GithubOAuthClient { + private static final String TOKEN_URL = "https://github.com/login/oauth/access_token"; + private static final String BEARER_TOKEN_TYPE = "bearer"; + private final GithubOAuthProperties githubOAuthProperties; + private final RestClient restClient; public GithubOAuthClient(GithubOAuthProperties githubOAuthProperties) { this.githubOAuthProperties = githubOAuthProperties; + this.restClient = RestClient.builder().build(); } public String buildAuthorizationUrl(String state, String codeChallenge) { @@ -24,4 +40,67 @@ public String buildAuthorizationUrl(String state, String codeChallenge) { .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 = restClient.post() + .uri(TOKEN_URL) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(body) + .retrieve() + .body(GithubTokenResponse.class); + + 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 || !BEARER_TOKEN_TYPE.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/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/github/infrastructure/GithubApiClient.java b/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiClient.java new file mode 100644 index 00000000..64a55c93 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiClient.java @@ -0,0 +1,84 @@ +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.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +@Component +public class GithubApiClient { + + private static final String API_BASE_URL = "https://api.github.com"; + private static final String GITHUB_API_VERSION = "2022-11-28"; + + private final RestClient restClient; + + public GithubApiClient() { + this.restClient = RestClient.builder() + .baseUrl(API_BASE_URL) + .defaultHeader(HttpHeaders.ACCEPT, "application/vnd.github+json") + .defaultHeader("X-GitHub-Api-Version", GITHUB_API_VERSION) + .build(); + } + + public GithubUserResponse getUser(String githubAccessToken) { + try { + GithubUserResponse response = restClient.get() + .uri("/user") + .headers(headers -> headers.setBearerAuth(githubAccessToken)) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .body(GithubUserResponse.class); + + 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 = restClient.get() + .uri("/user/emails") + .headers(headers -> headers.setBearerAuth(githubAccessToken)) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .body(GithubEmailResponse[].class); + + 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 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/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 +) { +} From 809bd1f84175177210f797d28ba385f739993ec1 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Thu, 14 May 2026 19:43:35 +0900 Subject: [PATCH 10/29] =?UTF-8?q?feat:=20Github=20access=20token=20?= =?UTF-8?q?=EC=95=94=ED=98=B8=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/GithubTokenCipherTest.java | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 backend/src/test/java/com/stackup/stackup/github/infrastructure/GithubTokenCipherTest.java 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..b941e6bc --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/github/infrastructure/GithubTokenCipherTest.java @@ -0,0 +1,50 @@ +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.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 + ); + } + + private static String validEncryptionKey() { + byte[] bytes = new byte[32]; + new SecureRandom().nextBytes(bytes); + return Base64.getEncoder().encodeToString(bytes); + } +} From 1678664375db51be63c2163fcef1c99c48f40a69 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Thu, 14 May 2026 19:44:11 +0900 Subject: [PATCH 11/29] =?UTF-8?q?feat:=20Github=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EA=B0=B1=EC=8B=A0?= =?UTF-8?q?=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stackup/auth/application/AuthService.java | 48 +++++++++---- .../auth/application/GithubUserService.java | 68 +++++++++++++++++++ .../application/dto/GithubUserProfile.java | 9 +++ .../dto/GithubUserUpsertResult.java | 7 ++ .../com/stackup/stackup/user/domain/User.java | 38 ++++++++++- .../stackup/user/domain/UserRepository.java | 2 + .../V3__use_partial_unique_user_github_id.sql | 5 ++ .../stackup/StackupApplicationTests.java | 4 ++ .../application/GithubUserServiceTest.java | 64 +++++++++++++++++ 9 files changed, 230 insertions(+), 15 deletions(-) create mode 100644 backend/src/main/java/com/stackup/stackup/auth/application/GithubUserService.java create mode 100644 backend/src/main/java/com/stackup/stackup/auth/application/dto/GithubUserProfile.java create mode 100644 backend/src/main/java/com/stackup/stackup/auth/application/dto/GithubUserUpsertResult.java create mode 100644 backend/src/main/resources/db/migration/V3__use_partial_unique_user_github_id.sql create mode 100644 backend/src/test/java/com/stackup/stackup/auth/application/GithubUserServiceTest.java 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 dd040e7e..ab24e6dc 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 @@ -1,9 +1,10 @@ package com.stackup.stackup.auth.application; import com.stackup.stackup.auth.infrastructure.GithubOAuthClient; -import com.stackup.stackup.auth.application.dto.AuthenticatedUserResult; 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.RefreshTokenResult; import com.stackup.stackup.auth.application.dto.StreamTokenResult; @@ -12,6 +13,9 @@ import com.stackup.stackup.common.exception.DomainException; import com.stackup.stackup.common.security.JwtTokenProvider; import com.stackup.stackup.common.security.StreamTokenProvider; +import com.stackup.stackup.github.infrastructure.GithubApiClient; +import com.stackup.stackup.github.infrastructure.GithubTokenCipher; +import com.stackup.stackup.github.infrastructure.dto.GithubUserResponse; import java.util.UUID; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.stereotype.Service; @@ -19,13 +23,14 @@ @Service public class AuthService { - private static final long SKELETON_USER_ID = 1L; - private static final long SKELETON_GITHUB_ID = 123456L; - private static final String SKELETON_GITHUB_USERNAME = "stackup-user"; private static final String TOKEN_TYPE_BEARER = "Bearer"; + private static final long SKELETON_USER_ID = 1L; private final GithubOAuthClient githubOAuthClient; private final OAuthStateService oauthStateService; + private final GithubApiClient githubApiClient; + private final GithubTokenCipher githubTokenCipher; + private final GithubUserService githubUserService; private final JwtTokenProvider jwtTokenProvider; private final StreamTokenProvider streamTokenProvider; private final SecurityProperties securityProperties; @@ -33,12 +38,18 @@ public class AuthService { public AuthService( GithubOAuthClient githubOAuthClient, OAuthStateService oauthStateService, + GithubApiClient githubApiClient, + GithubTokenCipher githubTokenCipher, + GithubUserService githubUserService, JwtTokenProvider jwtTokenProvider, StreamTokenProvider streamTokenProvider, SecurityProperties securityProperties ) { this.githubOAuthClient = githubOAuthClient; this.oauthStateService = oauthStateService; + this.githubApiClient = githubApiClient; + this.githubTokenCipher = githubTokenCipher; + this.githubUserService = githubUserService; this.jwtTokenProvider = jwtTokenProvider; this.streamTokenProvider = streamTokenProvider; this.securityProperties = securityProperties; @@ -57,19 +68,28 @@ public GithubCallbackResult completeGithubLogin(String code, String state) { throw new DomainException(ApiErrorCode.AUTH_GITHUB_OAUTH_FAILED); } String codeVerifier = oauthStateService.consumeGithubCodeVerifier(state); - // TODO: Use codeVerifier for GitHub token exchange in the next OAuth implementation phase. + 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 + ); + return new GithubCallbackResult( - jwtTokenProvider.createAccessToken(SKELETON_USER_ID), + jwtTokenProvider.createAccessToken(upsertResult.user().id()), TOKEN_TYPE_BEARER, securityProperties.accessTokenTtlSeconds(), - new AuthenticatedUserResult( - SKELETON_USER_ID, - SKELETON_GITHUB_ID, - SKELETON_GITHUB_USERNAME, - "stackup-user@example.com", - "https://avatars.githubusercontent.com/u/123456" - ), - true, + upsertResult.user(), + upsertResult.newUser(), createRefreshTokenStub() ); } 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..d66ad45e --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/application/GithubUserService.java @@ -0,0 +1,68 @@ +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 class GithubUserService { + + private final UserRepository userRepository; + + public GithubUserService(UserRepository userRepository) { + this.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/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/user/domain/User.java b/backend/src/main/java/com/stackup/stackup/user/domain/User.java index 88eaf6d3..c89c0b7c 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 @@ -27,7 +27,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 +41,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..8d33200d 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,7 @@ public interface UserRepository extends JpaRepository { Optional findByGithubId(Long githubId); + Optional findByGithubIdAndDeletedFalse(Long githubId); + Optional findByGithubUsername(String githubUsername); } 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 003f44f7..afd2a7ba 100644 --- a/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java +++ b/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java @@ -1,6 +1,7 @@ package com.stackup.stackup; import com.stackup.stackup.auth.domain.OAuthStateRepository; +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; @@ -11,6 +12,9 @@ class StackupApplicationTests { @MockitoBean private OAuthStateRepository oauthStateRepository; + @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(); + } +} From 3336c967d65006069280b5a4577ed365c54a48dd Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Thu, 14 May 2026 19:52:40 +0900 Subject: [PATCH 12/29] =?UTF-8?q?feat:=20refresh=20token=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/application/RefreshTokenService.java | 120 ++++++++++++++++ .../dto/RefreshTokenIssueResult.java | 7 + .../dto/RefreshTokenRotationResult.java | 7 + .../stackup/auth/domain/RefreshToken.java | 19 +++ .../stackup/StackupApplicationTests.java | 4 + .../application/RefreshTokenServiceTest.java | 132 ++++++++++++++++++ 6 files changed, 289 insertions(+) create mode 100644 backend/src/main/java/com/stackup/stackup/auth/application/RefreshTokenService.java create mode 100644 backend/src/main/java/com/stackup/stackup/auth/application/dto/RefreshTokenIssueResult.java create mode 100644 backend/src/main/java/com/stackup/stackup/auth/application/dto/RefreshTokenRotationResult.java create mode 100644 backend/src/test/java/com/stackup/stackup/auth/application/RefreshTokenServiceTest.java 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..ca36dcf3 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/application/RefreshTokenService.java @@ -0,0 +1,120 @@ +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.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class RefreshTokenService { + + private static final int REFRESH_TOKEN_BYTES = 32; + + private final RefreshTokenRepository refreshTokenRepository; + private final UserRepository userRepository; + private final SecurityProperties securityProperties; + private final SecureRandom secureRandom; + private final Clock clock; + + @Autowired + public RefreshTokenService( + RefreshTokenRepository refreshTokenRepository, + UserRepository userRepository, + SecurityProperties securityProperties + ) { + this(refreshTokenRepository, userRepository, securityProperties, new SecureRandom(), Clock.systemUTC()); + } + + 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[REFRESH_TOKEN_BYTES]; + 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/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/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/RefreshToken.java b/backend/src/main/java/com/stackup/stackup/auth/domain/RefreshToken.java index e305c16b..895738cc 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 @@ -50,4 +50,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/test/java/com/stackup/stackup/StackupApplicationTests.java b/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java index afd2a7ba..e1eea7e1 100644 --- a/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java +++ b/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java @@ -1,6 +1,7 @@ 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; @@ -12,6 +13,9 @@ class StackupApplicationTests { @MockitoBean private OAuthStateRepository oauthStateRepository; + @MockitoBean + private RefreshTokenRepository refreshTokenRepository; + @MockitoBean private UserRepository userRepository; 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..eaefdea7 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/auth/application/RefreshTokenServiceTest.java @@ -0,0 +1,132 @@ +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.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), + 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); + } + } +} From 0750d587eb68b1ef4c6a645944bca571057dd264 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Thu, 14 May 2026 19:53:21 +0900 Subject: [PATCH 13/29] =?UTF-8?q?feat:=20refresh=20token=20cookie=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stackup/auth/application/AuthService.java | 22 +++--- .../application/dto/GithubCallbackResult.java | 3 +- .../application/dto/RefreshTokenResult.java | 3 +- .../auth/presentation/AuthController.java | 79 +++++++++++++------ 4 files changed, 74 insertions(+), 33 deletions(-) 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 ab24e6dc..bef67e5b 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 @@ -6,7 +6,9 @@ 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; @@ -16,7 +18,6 @@ import com.stackup.stackup.github.infrastructure.GithubApiClient; import com.stackup.stackup.github.infrastructure.GithubTokenCipher; import com.stackup.stackup.github.infrastructure.dto.GithubUserResponse; -import java.util.UUID; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.stereotype.Service; @@ -24,13 +25,13 @@ public class AuthService { private static final String TOKEN_TYPE_BEARER = "Bearer"; - private static final long SKELETON_USER_ID = 1L; private final GithubOAuthClient githubOAuthClient; private final OAuthStateService oauthStateService; private final GithubApiClient githubApiClient; private final GithubTokenCipher githubTokenCipher; private final GithubUserService githubUserService; + private final RefreshTokenService refreshTokenService; private final JwtTokenProvider jwtTokenProvider; private final StreamTokenProvider streamTokenProvider; private final SecurityProperties securityProperties; @@ -41,6 +42,7 @@ public AuthService( GithubApiClient githubApiClient, GithubTokenCipher githubTokenCipher, GithubUserService githubUserService, + RefreshTokenService refreshTokenService, JwtTokenProvider jwtTokenProvider, StreamTokenProvider streamTokenProvider, SecurityProperties securityProperties @@ -50,6 +52,7 @@ public AuthService( this.githubApiClient = githubApiClient; this.githubTokenCipher = githubTokenCipher; this.githubUserService = githubUserService; + this.refreshTokenService = refreshTokenService; this.jwtTokenProvider = jwtTokenProvider; this.streamTokenProvider = streamTokenProvider; this.securityProperties = securityProperties; @@ -83,6 +86,7 @@ public GithubCallbackResult completeGithubLogin(String code, String state) { ), encryptedGithubAccessToken ); + RefreshTokenIssueResult refreshToken = refreshTokenService.issue(upsertResult.user().id()); return new GithubCallbackResult( jwtTokenProvider.createAccessToken(upsertResult.user().id()), @@ -90,21 +94,24 @@ public GithubCallbackResult completeGithubLogin(String code, String state) { securityProperties.accessTokenTtlSeconds(), upsertResult.user(), upsertResult.newUser(), - createRefreshTokenStub() + refreshToken.rawToken(), + refreshToken.maxAgeSeconds() ); } public RefreshTokenResult refresh(String refreshToken) { + RefreshTokenRotationResult rotation = refreshTokenService.rotate(refreshToken); return new RefreshTokenResult( - jwtTokenProvider.createAccessToken(SKELETON_USER_ID), + jwtTokenProvider.createAccessToken(rotation.userId()), TOKEN_TYPE_BEARER, securityProperties.accessTokenTtlSeconds(), - createRefreshTokenStub() + 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) { @@ -114,7 +121,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/dto/GithubCallbackResult.java b/backend/src/main/java/com/stackup/stackup/auth/application/dto/GithubCallbackResult.java index 1d0747b9..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 @@ -6,6 +6,7 @@ public record GithubCallbackResult( long expiresIn, AuthenticatedUserResult user, boolean isNewUser, - String refreshTokenRawForCookie + String refreshTokenRawForCookie, + long refreshTokenMaxAgeSeconds ) { } 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 eafb4385..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 @@ -4,6 +4,7 @@ public record RefreshTokenResult( String accessToken, String tokenType, long expiresIn, - String refreshTokenRawForCookie + String refreshTokenRawForCookie, + long refreshTokenMaxAgeSeconds ) { } 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 35771ee4..4f8e9198 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 @@ -15,6 +15,9 @@ 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; @@ -30,6 +33,9 @@ @Tag(name = "Auth", description = "GitHub OAuth login and authentication token APIs") public record AuthController(AuthService authService) { + private static final String REFRESH_TOKEN_COOKIE_NAME = "refresh_token"; + private static final String REFRESH_TOKEN_COOKIE_PATH = "/api/auth"; + @Operation(operationId = "startGithubLogin", summary = "Create GitHub OAuth authorization URL") @ApiResponses({ @ApiResponse(responseCode = "200", description = "GitHub authorization URL created") @@ -51,20 +57,24 @@ public ResponseEntity githubCallback( @RequestParam(required = false) String state ) { GithubCallbackResult result = authService.completeGithubLogin(code, state); - // TODO: Set refresh_token HttpOnly cookie when refresh token persistence is implemented. - return ResponseEntity.ok(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() - )); + 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") @@ -77,12 +87,16 @@ public ResponseEntity refresh( @CookieValue(name = "refresh_token", required = false) String refreshToken ) { RefreshTokenResult result = authService.refresh(refreshToken); - // TODO: Rotate refresh_token HttpOnly cookie when refresh token persistence is implemented. - return ResponseEntity.ok(new RefreshTokenResponse( - result.accessToken(), - result.tokenType(), - result.expiresIn() - )); + 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") @@ -94,8 +108,9 @@ public ResponseEntity logout( @CookieValue(name = "refresh_token", required = false) String refreshToken ) { authService.logout(refreshToken); - // TODO: Expire refresh_token HttpOnly cookie when refresh token persistence is implemented. - return ResponseEntity.noContent().build(); + return ResponseEntity.noContent() + .header(HttpHeaders.SET_COOKIE, expiredRefreshTokenCookie().toString()) + .build(); } @Operation(operationId = "createStreamToken", summary = "Create SSE stream token") @@ -111,4 +126,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(REFRESH_TOKEN_COOKIE_NAME, refreshToken) + .httpOnly(true) + .secure(true) + .sameSite("Strict") + .path(REFRESH_TOKEN_COOKIE_PATH) + .maxAge(Duration.ofSeconds(maxAgeSeconds)) + .build(); + } + + private ResponseCookie expiredRefreshTokenCookie() { + return ResponseCookie.from(REFRESH_TOKEN_COOKIE_NAME, "") + .httpOnly(true) + .secure(true) + .sameSite("Strict") + .path(REFRESH_TOKEN_COOKIE_PATH) + .maxAge(Duration.ZERO) + .build(); + } } From a24c5e7abf882802e5c5e4cedfa80acd8a313d99 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Thu, 14 May 2026 20:00:46 +0900 Subject: [PATCH 14/29] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EC=A1=B0=ED=9A=8C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stackup/user/application/UserService.java | 37 ++++++++++ .../application/dto/UserProfileResult.java | 10 +++ .../stackup/user/domain/UserRepository.java | 2 + .../user/presentation/UserController.java | 42 ++++++++++++ .../presentation/dto/UserProfileResponse.java | 21 ++++++ .../user/application/UserServiceTest.java | 67 +++++++++++++++++++ 6 files changed, 179 insertions(+) create mode 100644 backend/src/main/java/com/stackup/stackup/user/application/UserService.java create mode 100644 backend/src/main/java/com/stackup/stackup/user/application/dto/UserProfileResult.java create mode 100644 backend/src/main/java/com/stackup/stackup/user/presentation/UserController.java create mode 100644 backend/src/main/java/com/stackup/stackup/user/presentation/dto/UserProfileResponse.java create mode 100644 backend/src/test/java/com/stackup/stackup/user/application/UserServiceTest.java 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..9bb54f9c --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/user/application/UserService.java @@ -0,0 +1,37 @@ +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 class UserService { + + private final UserRepository userRepository; + + public UserService(UserRepository userRepository) { + this.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/UserRepository.java b/backend/src/main/java/com/stackup/stackup/user/domain/UserRepository.java index 8d33200d..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 @@ -9,5 +9,7 @@ public interface UserRepository extends JpaRepository { 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/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); + } +} From 55eddd59410d02fcc82222d8887787e24551caf4 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Thu, 14 May 2026 20:07:10 +0900 Subject: [PATCH 15/29] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=9D=91=EB=8B=B5=20=EC=BD=94=EB=93=9C=20=EC=84=B8?= =?UTF-8?q?=EB=B6=84=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/JwtAuthenticationException.java | 18 ++++++ .../security/JwtAuthenticationFilter.java | 12 ++++ .../common/security/JwtTokenProvider.java | 12 ++-- .../common/security/SecurityConfig.java | 56 +++++++++++++++---- 4 files changed, 83 insertions(+), 15 deletions(-) create mode 100644 backend/src/main/java/com/stackup/stackup/common/security/JwtAuthenticationException.java 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 ""; From 837e13927cea50de13374455e43db5fc8fe1f412 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Thu, 14 May 2026 20:07:39 +0900 Subject: [PATCH 16/29] =?UTF-8?q?feat:=20=EA=B3=B5=ED=86=B5=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=EC=BD=94=EB=93=9C=20=EB=B0=8F=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stackup/common/exception/ApiErrorCode.java | 1 + .../exception/GlobalExceptionHandler.java | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) 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 89b6633c..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 @@ -10,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; @@ -21,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()); } @@ -52,13 +58,17 @@ 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) @@ -72,8 +82,9 @@ public ResponseEntity handleStorageException(StorageException @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( From 723e09e22c89f428bc9e3d3810d38352179b0e51 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Fri, 15 May 2026 09:57:41 +0900 Subject: [PATCH 17/29] =?UTF-8?q?chore:=20=EA=B9=83=ED=97=88=EB=B8=8C=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EA=B4=80=EB=A0=A4=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=EB=B3=80=EC=88=98=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.example | 25 ++++- backend/.gitignore | 5 + backend/README.md | 237 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 265 insertions(+), 2 deletions(-) create mode 100644 backend/README.md diff --git a/backend/.env.example b/backend/.env.example index 8f02f9d1..7dfdca78 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,10 +1,31 @@ # Backend auth -JWT_SECRET=base64-encoded-32-byte-or-longer-secret +# 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 -ENCRYPTION_KEY=base64-encoded-32-byte-key + +# ENCRYPTION_KEY must decode to exactly 32 bytes. Generate one with: openssl rand -base64 32 +ENCRYPTION_KEY=replace-with-base64-encoded-32-byte-key # GitHub OAuth GITHUB_OAUTH_CLIENT_ID=your-github-oauth-client-id GITHUB_OAUTH_CLIENT_SECRET=your-github-oauth-client-secret GITHUB_OAUTH_REDIRECT_URI=http://localhost:5173/auth/callback + +# Optional local infrastructure overrides. +# In deployment, set these from the root compose or server environment. +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_DB=stackup +POSTGRES_USER=stackup +POSTGRES_PASSWORD=stackup +RABBITMQ_HOST=localhost +RABBITMQ_PORT=5672 +RABBITMQ_USER=stackup +RABBITMQ_PASSWORD=stackup +S3_ENDPOINT=http://localhost: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/README.md b/backend/README.md new file mode 100644 index 00000000..dd4b156b --- /dev/null +++ b/backend/README.md @@ -0,0 +1,237 @@ +# StackUp Core Server + +StackUp Core Server는 프론트엔드, GitHub, AI 서버, DB, 메시지 브로커 사이에서 인증과 핵심 도메인 흐름을 조정하는 Spring Boot API 서버입니다. + +이 서버는 사용자 인증, 사용자 프로필, GitHub repository 연동, repository 분석 요청, 분석 결과 저장의 기준 소스가 됩니다. 프론트엔드는 Core Server가 발급한 우리 서비스 access token으로 API를 호출하고, GitHub access token이나 GitHub client secret은 직접 다루지 않습니다. + +## Core Server 책임 + +Core Server의 주요 책임은 아래와 같습니다. + +인증: + +- `POST /api/auth/github` + + - GitHub 로그인 URL을 생성합니다. + - CSRF 방지를 위한 `state`를 DB에 저장합니다. + - PKCE 검증을 위한 `code_verifier`를 DB에 저장하고 `code_challenge`를 GitHub URL에 포함합니다. + +- `GET /api/auth/github/callback` + + - 프론트가 전달한 `code`, `state`를 검증합니다. + - `state`에 매칭되는 `code_verifier`를 조회하고 삭제합니다. + - GitHub에 `code`를 보내 GitHub access token을 발급받습니다. + - GitHub user/email API로 사용자 정보를 조회합니다. + - GitHub access token을 암호화해 DB에 저장합니다. + - 우리 서비스 access token과 refresh token을 발급합니다. + +- `POST /api/auth/refresh` + + - `refresh_token` HttpOnly cookie를 검증합니다. + - 기존 refresh token을 revoke하고 새 refresh token을 발급합니다. + - 새 access token을 반환합니다. + +- `DELETE /api/auth/logout` + + - refresh token을 revoke합니다. + - refresh cookie를 만료시킵니다. + +- `GET /api/users/me` + - `Authorization: Bearer {accessToken}`으로 현재 사용자 프로필을 반환합니다. + +GitHub 연동: + +- GitHub OAuth `code`를 GitHub access token으로 교환합니다. +- GitHub `/user`, `/user/emails` API를 호출해 사용자 식별 정보를 가져옵니다. +- 사용자는 GitHub username이 아니라 GitHub numeric id 기준으로 식별합니다. +- GitHub access token은 프론트나 AI 서버로 직접 전달하지 않습니다. +- GitHub access token은 AES-GCM으로 암호화해 DB에 저장합니다. + +Repository 분석: + +- GitHub repository 후보 조회와 Core DB 등록 기준을 관리합니다. +- repository 소유자와 현재 인증 사용자가 일치하는지 검증합니다. +- 분석 요청 시 repository 상태를 변경하고 메시지 발행 기준을 관리합니다. +- AI 서버 callback을 받아 분석 결과를 DB에 반영하는 책임을 가집니다. + +보안 경계: + +- 프론트에는 우리 서비스 access token만 응답합니다. +- refresh token은 HttpOnly cookie로만 내려줍니다. +- GitHub client secret은 서버 환경변수에만 존재해야 합니다. +- GitHub access token raw 값은 응답, 로그, RabbitMQ payload에 노출하지 않습니다. +- 에러 응답에는 `code`, `message`, `traceId`, `timestamp`, `details`를 포함합니다. + +## 인증 흐름 + +StackUp의 로그인은 GitHub OAuth 인증을 우리 서비스 인증 세션으로 변환하는 흐름입니다. GitHub OAuth App의 client secret과 GitHub access token은 Core Server 내부에서만 사용합니다. + +1. 프론트는 로그인 시작 시 `POST /api/auth/github`를 호출합니다. +2. Core Server는 `state`, `code_verifier`, `code_challenge`를 생성합니다. +3. Core Server는 `state`, `code_verifier`를 DB에 저장하고, GitHub authorization 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에 `code`, `client_id`, `client_secret`, `redirect_uri`, `code_verifier`를 전달해 GitHub access token으로 교환합니다. +10. Core Server는 GitHub access token으로 GitHub `/user`, `/user/emails` API를 호출합니다. +11. Core Server는 GitHub numeric id 기준으로 사용자를 생성하거나 갱신합니다. +12. Core Server는 GitHub access token을 AES-GCM으로 암호화해 DB에 저장합니다. +13. Core Server는 우리 서비스 access token을 response body로 반환합니다. +14. Core Server는 refresh token raw 값은 cookie로 내려주고, DB에는 refresh token hash만 저장합니다. +15. 프론트는 이후 API 요청에 `Authorization: Bearer {accessToken}`을 붙입니다. + +프론트 책임: + +- `authorizationUrl`을 그대로 사용해야 합니다. +- GitHub callback에서 `code`, `state`를 둘 다 읽어야 합니다. +- Core Server callback API는 `GET /api/auth/github/callback?code=...&state=...` 형식으로 호출해야 합니다. +- `code`, `state`는 request body가 아니라 query parameter로 전달해야 합니다. +- Core Server callback 응답의 `accessToken`을 이후 API 요청의 Bearer token으로 사용해야 합니다. + +Core Server 책임: + +- OAuth `state`를 생성, 저장, 검증, 1회 사용 후 삭제합니다. +- PKCE `code_verifier`를 서버 DB에만 저장합니다. +- GitHub `client_secret`을 서버 환경변수로만 관리합니다. +- GitHub access token raw 값을 프론트 응답, 로그, 메시지 payload에 노출하지 않습니다. +- GitHub access token은 암호화해 DB에 저장합니다. +- 자체 access token과 refresh token을 발급합니다. + +인증 후 사용자 조회: + +```http +GET /api/users/me +Authorization: Bearer {accessToken} +``` + +로그인 성공 전이나 access token 없이 호출하면 `AUTH_INVALID_TOKEN`이 반환됩니다. + +## Environment Variables + +환경변수는 실행 환경에서 주입합니다. `.env.example`은 필요한 키와 값의 형식을 보여주는 템플릿입니다. + +`.env` 파일은 로컬 개발이나 서버 수동 실행에서만 사용하고, git에 올리지 않습니다. + +```bash +cp .env.example .env +``` + +필수 인증 변수: + +```env +JWT_SECRET=replace-with-long-random-jwt-secret +JWT_ACCESS_TTL_SECONDS=900 +JWT_REFRESH_TTL_SECONDS=1209600 +ENCRYPTION_KEY=replace-with-base64-encoded-32-byte-key +``` + +GitHub OAuth 변수: + +```env +GITHUB_OAUTH_CLIENT_ID=your-github-oauth-client-id +GITHUB_OAUTH_CLIENT_SECRET=your-github-oauth-client-secret +GITHUB_OAUTH_REDIRECT_URI=http://localhost:5173/auth/callback +``` + +`JWT_SECRET`: + +- 우리 서비스 JWT access token 서명에 사용됩니다. +- Base64 형식일 필요는 없습니다. +- 충분히 긴 랜덤 문자열을 사용합니다. +- 이 값을 바꾸면 기존 access token은 모두 무효화됩니다. + +```bash +openssl rand -base64 64 +``` + +`ENCRYPTION_KEY`: + +- GitHub access token을 DB에 암호화 저장할 때 사용됩니다. +- 반드시 Base64 decode 결과가 32바이트여야 합니다. +- 아래 명령으로 생성합니다. +- 이 값을 바꾸면 기존에 암호화 저장된 GitHub access token은 복호화할 수 없습니다. + +```bash +openssl rand -base64 32 +``` + +`GITHUB_OAUTH_REDIRECT_URI`: + +- GitHub OAuth App의 `Authorization callback URL`과 정확히 같아야 합니다. +- authorize 단계와 token exchange 단계에서 같은 값이 사용됩니다. +- `http`/`https`, domain, port, path가 하나라도 다르면 GitHub OAuth가 실패합니다. +- 로컬 프론트 테스트: + +```env +GITHUB_OAUTH_REDIRECT_URI=http://localhost:5173/auth/callback +``` + +- 배포 프론트 테스트: + +```env +GITHUB_OAUTH_REDIRECT_URI=https://www.udangtang.site/auth/callback +``` + +인프라 변수: + +```env +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_DB=stackup +POSTGRES_USER=stackup +POSTGRES_PASSWORD=stackup +RABBITMQ_HOST=localhost +RABBITMQ_PORT=5672 +RABBITMQ_USER=stackup +RABBITMQ_PASSWORD=stackup +S3_ENDPOINT=http://localhost:9000 +S3_ACCESS_KEY=minioadmin +S3_SECRET_KEY=minioadmin +S3_BUCKET=stackup +S3_REGION=us-east-1 +S3_PATH_STYLE=true +``` + +배포 환경변수 처리 기준: + +- root compose 또는 서버 환경변수에서 주입합니다. +- backend 내부에 `docker-compose.yml`을 두지 않습니다. +- backend `Dockerfile`은 이미지를 만드는 책임만 가집니다. +- 운영/배포 secret은 repository에 커밋하지 않습니다. +- `.env.example`에는 실제 secret 값을 넣지 않습니다. +- `GITHUB_OAUTH_CLIENT_SECRET`, `JWT_SECRET`, `ENCRYPTION_KEY`, DB password, S3 secret은 secret으로 취급합니다. +- GitHub client secret이 노출되면 GitHub OAuth App에서 즉시 새 secret으로 교체합니다. + +## Docker + +backend 디렉토리에는 Dockerfile만 둡니다. compose 구성은 repository root에서 관리합니다. + +이미지 빌드: + +```bash +docker build -t stackup-backend ./backend +``` + +컨테이너 실행 시 최소로 필요한 외부 의존성: + +- PostgreSQL +- RabbitMQ +- S3 호환 스토리지 또는 MinIO +- GitHub OAuth App + +백엔드 기본 포트: + +```text +40000 +``` + +Nginx reverse proxy를 사용할 경우, OAuth redirect URI에는 내부 포트 `40000`을 넣지 않습니다. 외부 공개 URL만 사용합니다. + +예: + +```text +외부 공개 URL: https://www.udangtang.site +내부 백엔드: http://127.0.0.1:40000 +``` From dd833d73d2199b3651742d9152829c28bea080c2 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Fri, 15 May 2026 09:58:58 +0900 Subject: [PATCH 18/29] =?UTF-8?q?chore:=20core=20=EC=84=9C=EB=B2=84=20Dock?= =?UTF-8?q?erfile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Dockerfile | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 backend/Dockerfile diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..f0bb80e3 --- /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 40000 +ENTRYPOINT ["java", "-Dserver.port=40000", "-jar", "app.jar"] From d0333876831dcb8d34d55fd75eebd373c88c72f3 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Fri, 15 May 2026 12:28:28 +0900 Subject: [PATCH 19/29] =?UTF-8?q?refactor:=20=EB=82=98=EC=A4=91=EC=97=90?= =?UTF-8?q?=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=ED=95=9C=EB=B2=88=EC=97=90=20?= =?UTF-8?q?=EC=9E=A1=EA=B8=B0=20=EC=9C=84=ED=95=B4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 확인 못했던 인덱스 제거 한번에 인덱스를 추가하기 위함 --- .../com/stackup/stackup/auth/domain/OAuthState.java | 9 +-------- .../com/stackup/stackup/auth/domain/RefreshToken.java | 10 +--------- .../java/com/stackup/stackup/user/domain/User.java | 8 +------- .../resources/db/migration/V2__add_oauth_states.sql | 3 --- 4 files changed, 3 insertions(+), 27 deletions(-) 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 index 6bdabbec..e3f549a2 100644 --- a/backend/src/main/java/com/stackup/stackup/auth/domain/OAuthState.java +++ b/backend/src/main/java/com/stackup/stackup/auth/domain/OAuthState.java @@ -8,7 +8,6 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.Index; import jakarta.persistence.Table; import java.time.Instant; import lombok.AccessLevel; @@ -17,13 +16,7 @@ @Getter @Entity -@Table( - name = "oauth_states", - indexes = { - @Index(name = "idx_oauth_states_state", columnList = "state"), - @Index(name = "idx_oauth_states_expires_at", columnList = "expires_at") - } -) +@Table(name = "oauth_states") @NoArgsConstructor(access = AccessLevel.PROTECTED) public class OAuthState extends BaseTimeEntity { 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 895738cc..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 { 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 c89c0b7c..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 { 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 index 219777d6..612c3f39 100644 --- a/backend/src/main/resources/db/migration/V2__add_oauth_states.sql +++ b/backend/src/main/resources/db/migration/V2__add_oauth_states.sql @@ -7,6 +7,3 @@ CREATE TABLE oauth_states ( created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT chk_oauth_states_provider CHECK (provider IN ('GITHUB')) ); - -CREATE INDEX idx_oauth_states_state ON oauth_states (state); -CREATE INDEX idx_oauth_states_expires_at ON oauth_states (expires_at); From f531114f73626c07ba9bbcc7402d1bd9127563c3 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Fri, 15 May 2026 12:47:40 +0900 Subject: [PATCH 20/29] =?UTF-8?q?refactor:=20DI=20=EA=B0=84=EC=86=8C?= =?UTF-8?q?=ED=99=94=EB=A5=BC=20=EC=9C=84=ED=95=9C=20record=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=EB=A1=9C=20=EB=B3=80=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stackup/auth/application/AuthService.java | 44 +++++-------------- .../auth/application/GithubUserService.java | 8 +--- .../common/security/SecurityConfig.java | 16 ++----- .../stackup/user/application/UserService.java | 8 +--- 4 files changed, 17 insertions(+), 59 deletions(-) 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 bef67e5b..1d20e422 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 @@ -22,42 +22,20 @@ import org.springframework.stereotype.Service; @Service -public class AuthService { +public record AuthService( + GithubOAuthClient githubOAuthClient, + OAuthStateService oauthStateService, + GithubApiClient githubApiClient, + GithubTokenCipher githubTokenCipher, + GithubUserService githubUserService, + RefreshTokenService refreshTokenService, + JwtTokenProvider jwtTokenProvider, + StreamTokenProvider streamTokenProvider, + SecurityProperties securityProperties +) { private static final String TOKEN_TYPE_BEARER = "Bearer"; - private final GithubOAuthClient githubOAuthClient; - private final OAuthStateService oauthStateService; - private final GithubApiClient githubApiClient; - private final GithubTokenCipher githubTokenCipher; - private final GithubUserService githubUserService; - private final RefreshTokenService refreshTokenService; - private final JwtTokenProvider jwtTokenProvider; - private final StreamTokenProvider streamTokenProvider; - private final SecurityProperties securityProperties; - - public AuthService( - GithubOAuthClient githubOAuthClient, - OAuthStateService oauthStateService, - GithubApiClient githubApiClient, - GithubTokenCipher githubTokenCipher, - GithubUserService githubUserService, - RefreshTokenService refreshTokenService, - JwtTokenProvider jwtTokenProvider, - StreamTokenProvider streamTokenProvider, - SecurityProperties securityProperties - ) { - this.githubOAuthClient = githubOAuthClient; - this.oauthStateService = oauthStateService; - this.githubApiClient = githubApiClient; - this.githubTokenCipher = githubTokenCipher; - this.githubUserService = githubUserService; - this.refreshTokenService = refreshTokenService; - this.jwtTokenProvider = jwtTokenProvider; - this.streamTokenProvider = streamTokenProvider; - this.securityProperties = securityProperties; - } - public GithubLoginResult startGithubLogin() { OAuthStateIssueResult oauthState = oauthStateService.issueGithubStateWithPkce(); return new GithubLoginResult( 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 index d66ad45e..cae6a99c 100644 --- a/backend/src/main/java/com/stackup/stackup/auth/application/GithubUserService.java +++ b/backend/src/main/java/com/stackup/stackup/auth/application/GithubUserService.java @@ -9,13 +9,7 @@ import org.springframework.transaction.annotation.Transactional; @Service -public class GithubUserService { - - private final UserRepository userRepository; - - public GithubUserService(UserRepository userRepository) { - this.userRepository = userRepository; - } +public record GithubUserService(UserRepository userRepository) { @Transactional public GithubUserUpsertResult upsertGithubUser( 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 2995f5f2..d5dd786a 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 @@ -24,21 +24,13 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @Configuration -public class SecurityConfig { +public record SecurityConfig( + JwtAuthenticationFilter jwtAuthenticationFilter, + CorsProperties corsProperties +) { private static final Logger log = LoggerFactory.getLogger(SecurityConfig.class); - private final JwtAuthenticationFilter jwtAuthenticationFilter; - private final CorsProperties corsProperties; - - public SecurityConfig( - JwtAuthenticationFilter jwtAuthenticationFilter, - CorsProperties corsProperties - ) { - this.jwtAuthenticationFilter = jwtAuthenticationFilter; - this.corsProperties = corsProperties; - } - @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http 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 index 9bb54f9c..d42925d3 100644 --- a/backend/src/main/java/com/stackup/stackup/user/application/UserService.java +++ b/backend/src/main/java/com/stackup/stackup/user/application/UserService.java @@ -9,13 +9,7 @@ import org.springframework.transaction.annotation.Transactional; @Service -public class UserService { - - private final UserRepository userRepository; - - public UserService(UserRepository userRepository) { - this.userRepository = userRepository; - } +public record UserService(UserRepository userRepository) { @Transactional(readOnly = true) public UserProfileResult getCurrentUser(Long userId) { From a6c1a97acf85a942bf2ceb1285d12b4e933bf57a Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Fri, 15 May 2026 17:53:53 +0900 Subject: [PATCH 21/29] =?UTF-8?q?chore:=20=EA=B9=83=ED=97=88=EB=B8=8C=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=84=A4=EC=A0=95=EA=B0=92=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20=EB=B3=80=EC=88=98=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.example | 20 +++++++++++++++++++ .../properties/GithubOAuthProperties.java | 16 +++++++++++++-- .../config/properties/SecurityProperties.java | 14 ++++++++++++- backend/src/main/resources/application.yml | 16 +++++++++++++++ 4 files changed, 63 insertions(+), 3 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 7dfdca78..8b6cc20c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -3,14 +3,34 @@ 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=http://localhost:5173/auth/callback +GITHUB_OAUTH_AUTHORIZATION_URL=https://github.com/login/oauth/authorize +GITHUB_OAUTH_TOKEN_URL=https://github.com/login/oauth/access_token +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 # Optional local infrastructure overrides. # In deployment, set these from the root compose or server environment. 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 69fde98e..41958080 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 @@ -13,7 +13,13 @@ public record GithubOAuthProperties( @NotBlank String clientSecret, @NotNull URI redirectUri, - @NotBlank String scopes + @NotBlank String scopes, + @NotNull URI authorizationUrl, + @NotNull URI tokenUrl, + @NotBlank String tokenType, + @NotBlank String codeChallengeMethod, + @NotBlank String apiBaseUrl, + @NotBlank String apiVersion ) { @Override @@ -21,6 +27,12 @@ public String toString() { return "GithubOAuthProperties[clientId=" + clientId + ", clientSecret=******" + ", redirectUri=" + redirectUri - + ", scopes=" + scopes + "]"; + + ", scopes=" + scopes + + ", authorizationUrl=" + authorizationUrl + + ", tokenUrl=" + tokenUrl + + ", tokenType=" + tokenType + + ", codeChallengeMethod=" + codeChallengeMethod + + ", apiBaseUrl=" + apiBaseUrl + + ", apiVersion=" + apiVersion + "]"; } } 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/resources/application.yml b/backend/src/main/resources/application.yml index 2e90e42b..38a36078 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -36,12 +36,28 @@ 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:} scopes: read:user user:email repo + authorization-url: ${GITHUB_OAUTH_AUTHORIZATION_URL:https://github.com/login/oauth/authorize} + token-url: ${GITHUB_OAUTH_TOKEN_URL:https://github.com/login/oauth/access_token} + 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} s3: endpoint: ${S3_ENDPOINT:http://localhost:9000} access-key: ${S3_ACCESS_KEY:minioadmin} From 88123f25ebc124ac00d581bdf2cc125a086f25b8 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Fri, 15 May 2026 17:55:34 +0900 Subject: [PATCH 22/29] =?UTF-8?q?refactor:=20=EA=B9=83=ED=97=88=EB=B8=8C?= =?UTF-8?q?=20=EC=9D=B8=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=EA=B0=92=20=EC=A3=BC=EC=9E=85=EC=9C=BC=EB=A1=9C=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stackup/auth/application/AuthService.java | 6 ++-- .../auth/application/OAuthStateService.java | 25 +++++++++------- .../auth/application/RefreshTokenService.java | 4 +-- .../infrastructure/GithubOAuthClient.java | 11 +++---- .../auth/presentation/AuthController.java | 30 +++++++++---------- .../infrastructure/GithubApiClient.java | 10 +++---- .../application/OAuthStateServiceTest.java | 25 ++++++++++++++++ .../application/RefreshTokenServiceTest.java | 18 ++++++++++- .../infrastructure/GithubTokenCipherTest.java | 13 +++++++- 9 files changed, 93 insertions(+), 49 deletions(-) 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 1d20e422..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 @@ -34,8 +34,6 @@ public record AuthService( SecurityProperties securityProperties ) { - private static final String TOKEN_TYPE_BEARER = "Bearer"; - public GithubLoginResult startGithubLogin() { OAuthStateIssueResult oauthState = oauthStateService.issueGithubStateWithPkce(); return new GithubLoginResult( @@ -68,7 +66,7 @@ public GithubCallbackResult completeGithubLogin(String code, String state) { return new GithubCallbackResult( jwtTokenProvider.createAccessToken(upsertResult.user().id()), - TOKEN_TYPE_BEARER, + securityProperties.accessTokenType(), securityProperties.accessTokenTtlSeconds(), upsertResult.user(), upsertResult.newUser(), @@ -81,7 +79,7 @@ public RefreshTokenResult refresh(String refreshToken) { RefreshTokenRotationResult rotation = refreshTokenService.rotate(refreshToken); return new RefreshTokenResult( jwtTokenProvider.createAccessToken(rotation.userId()), - TOKEN_TYPE_BEARER, + securityProperties.accessTokenType(), securityProperties.accessTokenTtlSeconds(), rotation.refreshToken().rawToken(), rotation.refreshToken().maxAgeSeconds() 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 index 8f748801..d0779410 100644 --- a/backend/src/main/java/com/stackup/stackup/auth/application/OAuthStateService.java +++ b/backend/src/main/java/com/stackup/stackup/auth/application/OAuthStateService.java @@ -3,6 +3,7 @@ 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; @@ -10,7 +11,6 @@ import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.time.Clock; -import java.time.Duration; import java.time.Instant; import java.util.Base64; import org.springframework.beans.factory.annotation.Autowired; @@ -20,21 +20,24 @@ @Service public class OAuthStateService { - private static final Duration STATE_TTL = Duration.ofMinutes(5); - private static final int STATE_BYTES = 32; - private static final int CODE_VERIFIER_BYTES = 32; - private final OAuthStateRepository oauthStateRepository; + private final SecurityProperties securityProperties; private final SecureRandom secureRandom; private final Clock clock; @Autowired - public OAuthStateService(OAuthStateRepository oauthStateRepository) { - this(oauthStateRepository, new SecureRandom(), Clock.systemUTC()); + public OAuthStateService(OAuthStateRepository oauthStateRepository, SecurityProperties securityProperties) { + this(oauthStateRepository, securityProperties, new SecureRandom(), Clock.systemUTC()); } - OAuthStateService(OAuthStateRepository oauthStateRepository, SecureRandom secureRandom, Clock clock) { + OAuthStateService( + OAuthStateRepository oauthStateRepository, + SecurityProperties securityProperties, + SecureRandom secureRandom, + Clock clock + ) { this.oauthStateRepository = oauthStateRepository; + this.securityProperties = securityProperties; this.secureRandom = secureRandom; this.clock = clock; } @@ -44,11 +47,11 @@ public OAuthStateIssueResult issueGithubStateWithPkce() { Instant now = Instant.now(clock); oauthStateRepository.deleteByExpiresAtBefore(now); - String state = randomUrlSafeValue(STATE_BYTES); - String codeVerifier = randomUrlSafeValue(CODE_VERIFIER_BYTES); + String state = randomUrlSafeValue(securityProperties.oauthStateByteLength()); + String codeVerifier = randomUrlSafeValue(securityProperties.pkceCodeVerifierByteLength()); String codeChallenge = s256Challenge(codeVerifier); - oauthStateRepository.save(OAuthState.issueGithub(state, codeVerifier, now.plus(STATE_TTL))); + oauthStateRepository.save(OAuthState.issueGithub(state, codeVerifier, now.plus(securityProperties.oauthStateTtl()))); return new OAuthStateIssueResult(state, codeChallenge); } 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 index ca36dcf3..751a1f43 100644 --- a/backend/src/main/java/com/stackup/stackup/auth/application/RefreshTokenService.java +++ b/backend/src/main/java/com/stackup/stackup/auth/application/RefreshTokenService.java @@ -23,8 +23,6 @@ @Service public class RefreshTokenService { - private static final int REFRESH_TOKEN_BYTES = 32; - private final RefreshTokenRepository refreshTokenRepository; private final UserRepository userRepository; private final SecurityProperties securityProperties; @@ -103,7 +101,7 @@ private RefreshToken getUsableRefreshToken(String rawRefreshToken) { } private String generateRawToken() { - byte[] bytes = new byte[REFRESH_TOKEN_BYTES]; + byte[] bytes = new byte[securityProperties.refreshTokenByteLength()]; secureRandom.nextBytes(bytes); return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); } 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 afb60379..fc4fcb40 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 @@ -18,9 +18,6 @@ @Component public class GithubOAuthClient { - private static final String TOKEN_URL = "https://github.com/login/oauth/access_token"; - private static final String BEARER_TOKEN_TYPE = "bearer"; - private final GithubOAuthProperties githubOAuthProperties; private final RestClient restClient; @@ -30,13 +27,13 @@ public GithubOAuthClient(GithubOAuthProperties githubOAuthProperties) { } public String buildAuthorizationUrl(String state, String codeChallenge) { - return UriComponentsBuilder.fromUriString("https://github.com/login/oauth/authorize") + return UriComponentsBuilder.fromUri(githubOAuthProperties.authorizationUrl()) .queryParam("client_id", githubOAuthProperties.clientId()) .queryParam("redirect_uri", githubOAuthProperties.redirectUri()) .queryParam("scope", githubOAuthProperties.scopes()) .queryParam("state", state) .queryParam("code_challenge", codeChallenge) - .queryParam("code_challenge_method", "S256") + .queryParam("code_challenge_method", githubOAuthProperties.codeChallengeMethod()) .build() .toUriString(); } @@ -51,7 +48,7 @@ public String exchangeCode(String code, String codeVerifier) { try { GithubTokenResponse response = restClient.post() - .uri(TOKEN_URL) + .uri(githubOAuthProperties.tokenUrl()) .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_FORM_URLENCODED) .body(body) @@ -69,7 +66,7 @@ private String validateTokenResponse(GithubTokenResponse response) { || response.accessToken().isBlank()) { throw oauthFailed(); } - if (response.tokenType() == null || !BEARER_TOKEN_TYPE.equalsIgnoreCase(response.tokenType())) { + if (response.tokenType() == null || !githubOAuthProperties.tokenType().equalsIgnoreCase(response.tokenType())) { throw oauthFailed(); } if (!hasRequiredScopes(response.scope())) { 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 4f8e9198..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 @@ -10,6 +10,7 @@ import com.stackup.stackup.auth.presentation.dto.GithubLoginResponse; 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; @@ -31,10 +32,7 @@ @RestController @RequestMapping("/api/auth") @Tag(name = "Auth", description = "GitHub OAuth login and authentication token APIs") -public record AuthController(AuthService authService) { - - private static final String REFRESH_TOKEN_COOKIE_NAME = "refresh_token"; - private static final String REFRESH_TOKEN_COOKIE_PATH = "/api/auth"; +public record AuthController(AuthService authService, SecurityProperties securityProperties) { @Operation(operationId = "startGithubLogin", summary = "Create GitHub OAuth authorization URL") @ApiResponses({ @@ -84,7 +82,7 @@ public ResponseEntity githubCallback( }) @PostMapping("/refresh") public ResponseEntity refresh( - @CookieValue(name = "refresh_token", required = false) String refreshToken + @CookieValue(name = "${app.security.refresh-token-cookie-name}", required = false) String refreshToken ) { RefreshTokenResult result = authService.refresh(refreshToken); return ResponseEntity.ok() @@ -105,7 +103,7 @@ public ResponseEntity refresh( }) @DeleteMapping("/logout") public ResponseEntity logout( - @CookieValue(name = "refresh_token", required = false) String refreshToken + @CookieValue(name = "${app.security.refresh-token-cookie-name}", required = false) String refreshToken ) { authService.logout(refreshToken); return ResponseEntity.noContent() @@ -128,21 +126,21 @@ public ResponseEntity createStreamToken( } private ResponseCookie refreshTokenCookie(String refreshToken, long maxAgeSeconds) { - return ResponseCookie.from(REFRESH_TOKEN_COOKIE_NAME, refreshToken) - .httpOnly(true) - .secure(true) - .sameSite("Strict") - .path(REFRESH_TOKEN_COOKIE_PATH) + 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(REFRESH_TOKEN_COOKIE_NAME, "") - .httpOnly(true) - .secure(true) - .sameSite("Strict") - .path(REFRESH_TOKEN_COOKIE_PATH) + 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/github/infrastructure/GithubApiClient.java b/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiClient.java index 64a55c93..3128046c 100644 --- a/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiClient.java +++ b/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiClient.java @@ -1,5 +1,6 @@ package com.stackup.stackup.github.infrastructure; +import com.stackup.stackup.common.config.properties.GithubOAuthProperties; import com.stackup.stackup.common.exception.ApiErrorCode; import com.stackup.stackup.common.exception.DomainException; import com.stackup.stackup.github.infrastructure.dto.GithubEmailResponse; @@ -15,16 +16,13 @@ @Component public class GithubApiClient { - private static final String API_BASE_URL = "https://api.github.com"; - private static final String GITHUB_API_VERSION = "2022-11-28"; - private final RestClient restClient; - public GithubApiClient() { + public GithubApiClient(GithubOAuthProperties githubOAuthProperties) { this.restClient = RestClient.builder() - .baseUrl(API_BASE_URL) + .baseUrl(githubOAuthProperties.apiBaseUrl()) .defaultHeader(HttpHeaders.ACCEPT, "application/vnd.github+json") - .defaultHeader("X-GitHub-Api-Version", GITHUB_API_VERSION) + .defaultHeader("X-GitHub-Api-Version", githubOAuthProperties.apiVersion()) .build(); } 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 index 755d4403..79aeaeb4 100644 --- a/backend/src/test/java/com/stackup/stackup/auth/application/OAuthStateServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/auth/application/OAuthStateServiceTest.java @@ -9,12 +9,14 @@ 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; @@ -37,6 +39,7 @@ class OAuthStateServiceTest { void issueGithubStateWithPkce_savesStateAndReturnsChallenge() throws Exception { OAuthStateService service = new OAuthStateService( oauthStateRepository, + securityProperties(), new SecureRandom(), Clock.fixed(NOW, ZoneOffset.UTC) ); @@ -62,6 +65,7 @@ void consumeGithubCodeVerifier_deletesStateAndReturnsVerifier() { OAuthStateService service = new OAuthStateService( oauthStateRepository, + securityProperties(), new SecureRandom(), Clock.fixed(NOW, ZoneOffset.UTC) ); @@ -79,6 +83,7 @@ void consumeGithubCodeVerifier_rejectsExpiredState() { OAuthStateService service = new OAuthStateService( oauthStateRepository, + securityProperties(), new SecureRandom(), Clock.fixed(NOW, ZoneOffset.UTC) ); @@ -94,6 +99,7 @@ void consumeGithubCodeVerifier_rejectsExpiredState() { void consumeGithubCodeVerifier_rejectsMissingState() { OAuthStateService service = new OAuthStateService( oauthStateRepository, + securityProperties(), new SecureRandom(), Clock.fixed(NOW, ZoneOffset.UTC) ); @@ -109,4 +115,23 @@ private static String s256(String value) throws Exception { .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 index eaefdea7..b2d0de35 100644 --- a/backend/src/test/java/com/stackup/stackup/auth/application/RefreshTokenServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/auth/application/RefreshTokenServiceTest.java @@ -19,6 +19,7 @@ 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; @@ -108,7 +109,22 @@ private RefreshTokenService service() { return new RefreshTokenService( refreshTokenRepository, userRepository, - new SecurityProperties("jwt-secret", "encryption-key", 900, 1209600), + 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) ); 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 index b941e6bc..720b560c 100644 --- a/backend/src/test/java/com/stackup/stackup/github/infrastructure/GithubTokenCipherTest.java +++ b/backend/src/test/java/com/stackup/stackup/github/infrastructure/GithubTokenCipherTest.java @@ -7,6 +7,7 @@ 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; @@ -38,7 +39,17 @@ private static SecurityProperties securityProperties(String encryptionKey) { "test-jwt-secret", encryptionKey, 900, - 1209600 + 1209600, + "Bearer", + Duration.ofMinutes(5), + 32, + 32, + 32, + "refresh_token", + "/api/auth", + "Strict", + true, + true ); } From 43198066e5302656fda011343429f00da1b1325c Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Fri, 15 May 2026 18:09:05 +0900 Subject: [PATCH 23/29] =?UTF-8?q?fix:=20SecurityConfig=20record=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=9D=BC=EB=B0=98=20=ED=81=B4=EB=9E=98=EC=8A=A4?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit record로 진행할시 final이되어 문제 발생해 일반 클래스로 되돌림 --- .../stackup/common/security/SecurityConfig.java | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) 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 d5dd786a..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 @@ -24,13 +24,21 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @Configuration -public record SecurityConfig( - JwtAuthenticationFilter jwtAuthenticationFilter, - CorsProperties corsProperties -) { +public class SecurityConfig { private static final Logger log = LoggerFactory.getLogger(SecurityConfig.class); + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final CorsProperties corsProperties; + + public SecurityConfig( + JwtAuthenticationFilter jwtAuthenticationFilter, + CorsProperties corsProperties + ) { + this.jwtAuthenticationFilter = jwtAuthenticationFilter; + this.corsProperties = corsProperties; + } + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http From 80f7c352c7b901f5c85b9fdbeebd39f26176902c Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Fri, 15 May 2026 18:45:30 +0900 Subject: [PATCH 24/29] =?UTF-8?q?refactor:=20core=EC=84=9C=EB=B2=84=203801?= =?UTF-8?q?0=ED=8F=AC=ED=8A=B8=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.example | 2 + backend/Dockerfile | 4 +- backend/README.md | 302 +++++++++++++-------- backend/src/main/resources/application.yml | 3 + 4 files changed, 191 insertions(+), 120 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 8b6cc20c..affa4453 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,4 +1,6 @@ # 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 diff --git a/backend/Dockerfile b/backend/Dockerfile index f0bb80e3..4bb53c0e 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -10,5 +10,5 @@ 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 40000 -ENTRYPOINT ["java", "-Dserver.port=40000", "-jar", "app.jar"] +EXPOSE 38010 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/backend/README.md b/backend/README.md index dd4b156b..8b72b9c6 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,146 +1,141 @@ # StackUp Core Server -StackUp Core Server는 프론트엔드, GitHub, AI 서버, DB, 메시지 브로커 사이에서 인증과 핵심 도메인 흐름을 조정하는 Spring Boot API 서버입니다. +StackUp Core Server는 프론트엔드, GitHub, AI 서버, PostgreSQL, RabbitMQ 사이에서 인증과 핵심 도메인 흐름을 담당하는 Spring Boot API 서버입니다. -이 서버는 사용자 인증, 사용자 프로필, GitHub repository 연동, repository 분석 요청, 분석 결과 저장의 기준 소스가 됩니다. 프론트엔드는 Core Server가 발급한 우리 서비스 access token으로 API를 호출하고, GitHub access token이나 GitHub client secret은 직접 다루지 않습니다. +프론트엔드는 Core Server가 발급한 StackUp access token으로 API를 호출합니다. GitHub client secret과 GitHub access token은 프론트엔드에서 직접 다루지 않습니다. -## Core Server 책임 - -Core Server의 주요 책임은 아래와 같습니다. +## Core Server 역할 인증: - `POST /api/auth/github` - - GitHub 로그인 URL을 생성합니다. - - CSRF 방지를 위한 `state`를 DB에 저장합니다. - - PKCE 검증을 위한 `code_verifier`를 DB에 저장하고 `code_challenge`를 GitHub URL에 포함합니다. + - CSRF 방지용 `state`를 생성해 DB에 저장합니다. + - PKCE 검증용 `code_verifier`를 DB에 저장하고, `code_challenge`를 GitHub URL에 포함합니다. - `GET /api/auth/github/callback` - - 프론트가 전달한 `code`, `state`를 검증합니다. - - `state`에 매칭되는 `code_verifier`를 조회하고 삭제합니다. - - GitHub에 `code`를 보내 GitHub access token을 발급받습니다. + - `state`로 저장된 `code_verifier`를 조회한 뒤 삭제합니다. + - GitHub에 `code + code_verifier`를 보내 GitHub access token으로 교환합니다. - GitHub user/email API로 사용자 정보를 조회합니다. - GitHub access token을 암호화해 DB에 저장합니다. - - 우리 서비스 access token과 refresh token을 발급합니다. + - StackUp access token과 refresh token을 발급합니다. - `POST /api/auth/refresh` - - - `refresh_token` HttpOnly cookie를 검증합니다. - - 기존 refresh token을 revoke하고 새 refresh token을 발급합니다. - - 새 access token을 반환합니다. + - HttpOnly cookie의 refresh token을 검증합니다. + - 기존 refresh token을 revoke하고 새 refresh token으로 rotation합니다. + - 새 StackUp access token을 응답합니다. - `DELETE /api/auth/logout` - - refresh token을 revoke합니다. - refresh cookie를 만료시킵니다. - `GET /api/users/me` - `Authorization: Bearer {accessToken}`으로 현재 사용자 프로필을 반환합니다. -GitHub 연동: +보안 경계: -- GitHub OAuth `code`를 GitHub access token으로 교환합니다. -- GitHub `/user`, `/user/emails` API를 호출해 사용자 식별 정보를 가져옵니다. -- 사용자는 GitHub username이 아니라 GitHub numeric id 기준으로 식별합니다. -- GitHub access token은 프론트나 AI 서버로 직접 전달하지 않습니다. +- access token은 프론트에 응답 body로 전달합니다. +- refresh token은 HttpOnly cookie로만 전달합니다. +- refresh token 원문은 DB에 저장하지 않고 SHA-256 hash만 저장합니다. - GitHub access token은 AES-GCM으로 암호화해 DB에 저장합니다. +- GitHub client secret, GitHub access token, refresh token 원문은 로그와 응답에 남기지 않습니다. -Repository 분석: +## 인증 흐름 -- GitHub repository 후보 조회와 Core DB 등록 기준을 관리합니다. -- repository 소유자와 현재 인증 사용자가 일치하는지 검증합니다. -- 분석 요청 시 repository 상태를 변경하고 메시지 발행 기준을 관리합니다. -- AI 서버 callback을 받아 분석 결과를 DB에 반영하는 책임을 가집니다. +1. 프론트가 로그인 시작 시 `POST /api/auth/github`를 호출합니다. +2. Core Server가 `state`, `code_verifier`, `code_challenge`를 생성합니다. +3. Core Server는 `state -> code_verifier`를 DB에 저장하고 GitHub authorization 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 값을 cookie로 내려주고, DB에는 refresh token hash만 저장합니다. +15. 프론트는 이후 API 요청에 `Authorization: Bearer {accessToken}`을 붙입니다. -보안 경계: +## 환경변수 전달 방식 -- 프론트에는 우리 서비스 access token만 응답합니다. -- refresh token은 HttpOnly cookie로만 내려줍니다. -- GitHub client secret은 서버 환경변수에만 존재해야 합니다. -- GitHub access token raw 값은 응답, 로그, RabbitMQ payload에 노출하지 않습니다. -- 에러 응답에는 `code`, `message`, `traceId`, `timestamp`, `details`를 포함합니다. +Spring Boot 설정은 [application.yml](src/main/resources/application.yml)에서 환경변수를 읽습니다. -## 인증 흐름 +예를 들어 [application.yml](src/main/resources/application.yml)에 아래처럼 정의되어 있습니다. -StackUp의 로그인은 GitHub OAuth 인증을 우리 서비스 인증 세션으로 변환하는 흐름입니다. GitHub OAuth App의 client secret과 GitHub access token은 Core Server 내부에서만 사용합니다. - -1. 프론트는 로그인 시작 시 `POST /api/auth/github`를 호출합니다. -2. Core Server는 `state`, `code_verifier`, `code_challenge`를 생성합니다. -3. Core Server는 `state`, `code_verifier`를 DB에 저장하고, GitHub authorization 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에 `code`, `client_id`, `client_secret`, `redirect_uri`, `code_verifier`를 전달해 GitHub access token으로 교환합니다. -10. Core Server는 GitHub access token으로 GitHub `/user`, `/user/emails` API를 호출합니다. -11. Core Server는 GitHub numeric id 기준으로 사용자를 생성하거나 갱신합니다. -12. Core Server는 GitHub access token을 AES-GCM으로 암호화해 DB에 저장합니다. -13. Core Server는 우리 서비스 access token을 response body로 반환합니다. -14. Core Server는 refresh token raw 값은 cookie로 내려주고, DB에는 refresh token hash만 저장합니다. -15. 프론트는 이후 API 요청에 `Authorization: Bearer {accessToken}`을 붙입니다. +```yaml +server: + port: ${SERVER_PORT:38010} +``` -프론트 책임: +컨테이너 또는 Java 프로세스 환경변수에 `SERVER_PORT=38010`이 들어가면 Spring Boot의 `server.port` 값이 `38010`이 됩니다. 값이 없으면 기본값 `38010`을 사용합니다. -- `authorizationUrl`을 그대로 사용해야 합니다. -- GitHub callback에서 `code`, `state`를 둘 다 읽어야 합니다. -- Core Server callback API는 `GET /api/auth/github/callback?code=...&state=...` 형식으로 호출해야 합니다. -- `code`, `state`는 request body가 아니라 query parameter로 전달해야 합니다. -- Core Server callback 응답의 `accessToken`을 이후 API 요청의 Bearer token으로 사용해야 합니다. +같은 방식으로 `JWT_SECRET`, `GITHUB_OAUTH_CLIENT_ID`, `POSTGRES_HOST` 같은 값도 [application.yml](src/main/resources/application.yml)의 `${...}` 표현식을 통해 환경변수에서 읽습니다. -Core Server 책임: +로컬에서는 backend 디렉토리에 `.env`를 만들 수 있습니다. -- OAuth `state`를 생성, 저장, 검증, 1회 사용 후 삭제합니다. -- PKCE `code_verifier`를 서버 DB에만 저장합니다. -- GitHub `client_secret`을 서버 환경변수로만 관리합니다. -- GitHub access token raw 값을 프론트 응답, 로그, 메시지 payload에 노출하지 않습니다. -- GitHub access token은 암호화해 DB에 저장합니다. -- 자체 access token과 refresh token을 발급합니다. +```bash +cp .env.example .env +``` + +단, Spring Boot 애플리케이션이 `.env` 파일을 자동으로 읽는 것은 아닙니다. `.env` 파일의 값이 실제로 적용되려면 Docker Compose의 `env_file`, `environment`, shell `export`, IntelliJ Run Configuration, systemd 환경변수 같은 방식으로 프로세스 환경변수에 주입되어야 합니다. -인증 후 사용자 조회: +서버 배포에서는 root `docker-compose.yml`에서 `env_file` 또는 `environment`로 값을 컨테이너에 전달하는 방식을 권장합니다. 실제 secret 값은 repository에 커밋하지 않습니다. -```http -GET /api/users/me -Authorization: Bearer {accessToken} +root compose 예시: + +```yaml +services: + backend: + build: + context: ./backend + env_file: + - ./backend/.env + environment: + SERVER_PORT: 38010 + POSTGRES_HOST: postgres + ports: + - "38010:38010" ``` -로그인 성공 전이나 access token 없이 호출하면 `AUTH_INVALID_TOKEN`이 반환됩니다. +위 설정의 의미: -## Environment Variables +- `env_file`은 `./backend/.env`의 값을 컨테이너 환경변수로 넣습니다. +- `environment`는 필요한 값을 직접 덮어씁니다. +- `SERVER_PORT=38010`은 Spring Boot 내부 실행 포트가 됩니다. +- `38010:38010`은 서버의 호스트 포트 `38010`을 컨테이너 내부 포트 `38010`으로 연결합니다. -환경변수는 실행 환경에서 주입합니다. `.env.example`은 필요한 키와 값의 형식을 보여주는 템플릿입니다. +포트 역할: -`.env` 파일은 로컬 개발이나 서버 수동 실행에서만 사용하고, git에 올리지 않습니다. +| 값 | 위치 | 의미 | +| --- | --- | --- | +| `SERVER_PORT=38010` | `.env`, compose `environment`, 서버 환경변수 | 컨테이너 내부 Spring Boot 실행 포트 | +| `38010:38010` | root `docker-compose.yml`의 `ports` | 호스트 `38010` 요청을 컨테이너 `38010`으로 전달 | +| `http://127.0.0.1:38010` | Nginx upstream | Nginx가 백엔드로 프록시할 주소 | +| `GITHUB_OAUTH_REDIRECT_URI` | GitHub OAuth App + 백엔드 환경변수 | 브라우저가 접근하는 프론트 callback URL | -```bash -cp .env.example .env -``` +## 필수 환경변수 -필수 인증 변수: +인증: ```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 ``` -GitHub OAuth 변수: - -```env -GITHUB_OAUTH_CLIENT_ID=your-github-oauth-client-id -GITHUB_OAUTH_CLIENT_SECRET=your-github-oauth-client-secret -GITHUB_OAUTH_REDIRECT_URI=http://localhost:5173/auth/callback -``` - `JWT_SECRET`: -- 우리 서비스 JWT access token 서명에 사용됩니다. +- StackUp JWT access token 서명에 사용합니다. - Base64 형식일 필요는 없습니다. - 충분히 긴 랜덤 문자열을 사용합니다. -- 이 값을 바꾸면 기존 access token은 모두 무효화됩니다. + +생성 예시: ```bash openssl rand -base64 64 @@ -148,33 +143,63 @@ openssl rand -base64 64 `ENCRYPTION_KEY`: -- GitHub access token을 DB에 암호화 저장할 때 사용됩니다. +- GitHub access token 암호화에 사용합니다. - 반드시 Base64 decode 결과가 32바이트여야 합니다. -- 아래 명령으로 생성합니다. -- 이 값을 바꾸면 기존에 암호화 저장된 GitHub access token은 복호화할 수 없습니다. + +생성 예시: ```bash openssl rand -base64 32 ``` -`GITHUB_OAUTH_REDIRECT_URI`: +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: + +```env +GITHUB_OAUTH_CLIENT_ID=your-github-oauth-client-id +GITHUB_OAUTH_CLIENT_SECRET=your-github-oauth-client-secret +GITHUB_OAUTH_REDIRECT_URI=http://localhost:5173/auth/callback +GITHUB_OAUTH_AUTHORIZATION_URL=https://github.com/login/oauth/authorize +GITHUB_OAUTH_TOKEN_URL=https://github.com/login/oauth/access_token +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_OAUTH_REDIRECT_URI`는 GitHub OAuth App의 `Authorization callback URL`과 정확히 같아야 합니다. `http`/`https`, domain, port, path 중 하나라도 다르면 GitHub OAuth가 실패합니다. -- GitHub OAuth App의 `Authorization callback URL`과 정확히 같아야 합니다. -- authorize 단계와 token exchange 단계에서 같은 값이 사용됩니다. -- `http`/`https`, domain, port, path가 하나라도 다르면 GitHub OAuth가 실패합니다. -- 로컬 프론트 테스트: +로컬 프론트 테스트: ```env GITHUB_OAUTH_REDIRECT_URI=http://localhost:5173/auth/callback ``` -- 배포 프론트 테스트: +배포 프론트 테스트: ```env GITHUB_OAUTH_REDIRECT_URI=https://www.udangtang.site/auth/callback ``` -인프라 변수: +인프라: ```env POSTGRES_HOST=localhost @@ -194,16 +219,6 @@ S3_REGION=us-east-1 S3_PATH_STYLE=true ``` -배포 환경변수 처리 기준: - -- root compose 또는 서버 환경변수에서 주입합니다. -- backend 내부에 `docker-compose.yml`을 두지 않습니다. -- backend `Dockerfile`은 이미지를 만드는 책임만 가집니다. -- 운영/배포 secret은 repository에 커밋하지 않습니다. -- `.env.example`에는 실제 secret 값을 넣지 않습니다. -- `GITHUB_OAUTH_CLIENT_SECRET`, `JWT_SECRET`, `ENCRYPTION_KEY`, DB password, S3 secret은 secret으로 취급합니다. -- GitHub client secret이 노출되면 GitHub OAuth App에서 즉시 새 secret으로 교체합니다. - ## Docker backend 디렉토리에는 Dockerfile만 둡니다. compose 구성은 repository root에서 관리합니다. @@ -214,24 +229,75 @@ backend 디렉토리에는 Dockerfile만 둡니다. compose 구성은 repository docker build -t stackup-backend ./backend ``` -컨테이너 실행 시 최소로 필요한 외부 의존성: +Dockerfile은 이미지를 만드는 책임만 가집니다. 실제 DB 주소, GitHub OAuth secret, 배포별 환경값은 root compose 또는 서버 환경변수로 전달합니다. -- PostgreSQL -- RabbitMQ -- S3 호환 스토리지 또는 MinIO -- GitHub OAuth App +포트 전달 예시: + +```yaml +services: + backend: + image: stackup-backend + environment: + SERVER_PORT: 38010 + ports: + - "38010:38010" +``` -백엔드 기본 포트: +위 예시에서 `SERVER_PORT`는 컨테이너 내부의 Spring Boot 포트로 전달됩니다. `ports`의 왼쪽 값인 `38010`은 호스트 포트이고, 오른쪽 값인 `38010`은 컨테이너 내부 포트입니다. + +요청 흐름: ```text -40000 +브라우저/외부 요청 + -> https://www.udangtang.site/api + -> Nginx + -> http://127.0.0.1:38010 + -> container:38010 + -> Spring Boot server.port=38010 ``` -Nginx reverse proxy를 사용할 경우, OAuth redirect URI에는 내부 포트 `40000`을 넣지 않습니다. 외부 공개 URL만 사용합니다. +`38010`은 Core Server의 확정 포트입니다. root compose, Nginx upstream, `SERVER_PORT`, Dockerfile `EXPOSE`를 모두 같은 값으로 맞춥니다. -예: +Nginx reverse proxy를 사용할 때 `GITHUB_OAUTH_REDIRECT_URI`에는 내부 백엔드 API 포트를 넣지 않습니다. 브라우저가 실제 접근하는 프론트 callback URL을 넣습니다. -```text -외부 공개 URL: https://www.udangtang.site -내부 백엔드: http://127.0.0.1:40000 +## 로컬 실행 + +로컬에서 backend만 실행하려면 `.env` 값을 현재 shell 환경변수로 올린 뒤 Spring Boot를 실행합니다. + +PowerShell 예시: + +```powershell +Get-Content .env | ForEach-Object { + if ($_ -match '^\s*#|^\s*$') { return } + $pair = $_ -split '=', 2 + if ($pair.Length -eq 2) { + Set-Item -Path "Env:$($pair[0].Trim())" -Value $pair[1].Trim() + } +} + +.\gradlew.bat bootRun ``` + +IntelliJ에서는 Run Configuration의 Environment variables에 `.env.example`의 키를 실제 값으로 넣습니다. + +## 검증 + +전체 테스트: + +```bash +./gradlew.bat test +``` + +로그인 URL 발급 확인: + +```bash +curl -X POST http://localhost:38010/api/auth/github +``` + +배포 환경에서 Nginx를 거친 API 확인: + +```bash +curl -X POST https://www.udangtang.site/api/auth/github +``` + +응답의 `authorizationUrl`에 들어간 `redirect_uri`가 GitHub OAuth App callback URL과 같은지 확인합니다. diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 38a36078..323c497d 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: From 1e0da84c23e22c101b4d9b29cbe3936447be3ef1 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Fri, 15 May 2026 23:17:20 +0900 Subject: [PATCH 25/29] =?UTF-8?q?refactor:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EC=9E=90=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B9=88=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/application/OAuthStateService.java | 8 +------- .../auth/application/RefreshTokenService.java | 10 ---------- .../common/config/SecurityBeanConfig.java | 20 +++++++++++++++++++ 3 files changed, 21 insertions(+), 17 deletions(-) create mode 100644 backend/src/main/java/com/stackup/stackup/common/config/SecurityBeanConfig.java 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 index d0779410..35c1202c 100644 --- a/backend/src/main/java/com/stackup/stackup/auth/application/OAuthStateService.java +++ b/backend/src/main/java/com/stackup/stackup/auth/application/OAuthStateService.java @@ -13,7 +13,6 @@ import java.time.Clock; import java.time.Instant; import java.util.Base64; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,12 +24,7 @@ public class OAuthStateService { private final SecureRandom secureRandom; private final Clock clock; - @Autowired - public OAuthStateService(OAuthStateRepository oauthStateRepository, SecurityProperties securityProperties) { - this(oauthStateRepository, securityProperties, new SecureRandom(), Clock.systemUTC()); - } - - OAuthStateService( + public OAuthStateService( OAuthStateRepository oauthStateRepository, SecurityProperties securityProperties, SecureRandom secureRandom, 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 index 751a1f43..11cc7da1 100644 --- a/backend/src/main/java/com/stackup/stackup/auth/application/RefreshTokenService.java +++ b/backend/src/main/java/com/stackup/stackup/auth/application/RefreshTokenService.java @@ -16,7 +16,6 @@ import java.time.Clock; import java.time.Instant; import java.util.Base64; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -29,16 +28,7 @@ public class RefreshTokenService { private final SecureRandom secureRandom; private final Clock clock; - @Autowired public RefreshTokenService( - RefreshTokenRepository refreshTokenRepository, - UserRepository userRepository, - SecurityProperties securityProperties - ) { - this(refreshTokenRepository, userRepository, securityProperties, new SecureRandom(), Clock.systemUTC()); - } - - RefreshTokenService( RefreshTokenRepository refreshTokenRepository, UserRepository userRepository, SecurityProperties securityProperties, 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(); + } +} From f11d2f92d84db95c72cf6d0671ef396c7a5998af Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Fri, 15 May 2026 23:37:06 +0900 Subject: [PATCH 26/29] =?UTF-8?q?refactor:=20Github=20=EC=99=B8=EB=B6=80?= =?UTF-8?q?=20=ED=98=B8=EC=B6=9C=20HHTTP=20Interface=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/GithubOAuthClient.java | 23 ++++++------- .../infrastructure/GithubOAuthHttpClient.java | 17 ++++++++++ .../GithubOAuthHttpClientConfig.java | 22 +++++++++++++ .../infrastructure/GithubApiClient.java | 32 ++++++------------- .../infrastructure/GithubApiHttpClient.java | 15 +++++++++ .../GithubApiHttpClientConfig.java | 25 +++++++++++++++ 6 files changed, 97 insertions(+), 37 deletions(-) create mode 100644 backend/src/main/java/com/stackup/stackup/auth/infrastructure/GithubOAuthHttpClient.java create mode 100644 backend/src/main/java/com/stackup/stackup/auth/infrastructure/GithubOAuthHttpClientConfig.java create mode 100644 backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiHttpClient.java create mode 100644 backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiHttpClientConfig.java 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 fc4fcb40..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 @@ -7,11 +7,9 @@ import java.util.Arrays; import java.util.Set; import java.util.stream.Collectors; -import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; import org.springframework.web.util.UriComponentsBuilder; @@ -19,15 +17,19 @@ public class GithubOAuthClient { private final GithubOAuthProperties githubOAuthProperties; - private final RestClient restClient; + private final GithubOAuthHttpClient githubOAuthHttpClient; - public GithubOAuthClient(GithubOAuthProperties githubOAuthProperties) { + public GithubOAuthClient( + GithubOAuthProperties githubOAuthProperties, + GithubOAuthHttpClient githubOAuthHttpClient + ) { this.githubOAuthProperties = githubOAuthProperties; - this.restClient = RestClient.builder().build(); + this.githubOAuthHttpClient = githubOAuthHttpClient; } public String buildAuthorizationUrl(String state, String codeChallenge) { - return UriComponentsBuilder.fromUri(githubOAuthProperties.authorizationUrl()) + return UriComponentsBuilder.fromUri(githubOAuthProperties.oauthBaseUrl()) + .path("/login/oauth/authorize") .queryParam("client_id", githubOAuthProperties.clientId()) .queryParam("redirect_uri", githubOAuthProperties.redirectUri()) .queryParam("scope", githubOAuthProperties.scopes()) @@ -47,14 +49,7 @@ public String exchangeCode(String code, String codeVerifier) { body.add("code_verifier", codeVerifier); try { - GithubTokenResponse response = restClient.post() - .uri(githubOAuthProperties.tokenUrl()) - .accept(MediaType.APPLICATION_JSON) - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .body(body) - .retrieve() - .body(GithubTokenResponse.class); - + GithubTokenResponse response = githubOAuthHttpClient.exchangeCode(body); return validateTokenResponse(response); } catch (RestClientException exception) { throw oauthFailed(exception); 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..1f013fca --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/infrastructure/GithubOAuthHttpClientConfig.java @@ -0,0 +1,22 @@ +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.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() + .baseUrl(properties.oauthBaseUrl().toString()) + .build(); + RestClientAdapter adapter = RestClientAdapter.create(restClient); + HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build(); + return factory.createClient(GithubOAuthHttpClient.class); + } +} 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 index 3128046c..a5d948da 100644 --- a/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiClient.java +++ b/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiClient.java @@ -1,39 +1,26 @@ package com.stackup.stackup.github.infrastructure; -import com.stackup.stackup.common.config.properties.GithubOAuthProperties; 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.http.HttpHeaders; -import org.springframework.http.MediaType; import org.springframework.stereotype.Component; -import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; @Component public class GithubApiClient { - private final RestClient restClient; + private final GithubApiHttpClient githubApiHttpClient; - public GithubApiClient(GithubOAuthProperties githubOAuthProperties) { - this.restClient = RestClient.builder() - .baseUrl(githubOAuthProperties.apiBaseUrl()) - .defaultHeader(HttpHeaders.ACCEPT, "application/vnd.github+json") - .defaultHeader("X-GitHub-Api-Version", githubOAuthProperties.apiVersion()) - .build(); + public GithubApiClient(GithubApiHttpClient githubApiHttpClient) { + this.githubApiHttpClient = githubApiHttpClient; } public GithubUserResponse getUser(String githubAccessToken) { try { - GithubUserResponse response = restClient.get() - .uri("/user") - .headers(headers -> headers.setBearerAuth(githubAccessToken)) - .accept(MediaType.APPLICATION_JSON) - .retrieve() - .body(GithubUserResponse.class); + GithubUserResponse response = githubApiHttpClient.getUser(bearer(githubAccessToken)); if (response == null || response.id() == null || response.login() == null || response.login().isBlank()) { throw oauthFailed(); @@ -46,12 +33,7 @@ public GithubUserResponse getUser(String githubAccessToken) { public Optional getPrimaryVerifiedEmail(String githubAccessToken) { try { - GithubEmailResponse[] response = restClient.get() - .uri("/user/emails") - .headers(headers -> headers.setBearerAuth(githubAccessToken)) - .accept(MediaType.APPLICATION_JSON) - .retrieve() - .body(GithubEmailResponse[].class); + GithubEmailResponse[] response = githubApiHttpClient.getUserEmails(bearer(githubAccessToken)); if (response == null) { return Optional.empty(); @@ -68,6 +50,10 @@ public Optional getPrimaryVerifiedEmail(String githubAccessToken) { } } + private String bearer(String accessToken) { + return "Bearer " + accessToken; + } + private DomainException oauthFailed() { return new DomainException(ApiErrorCode.AUTH_GITHUB_OAUTH_FAILED); } 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..7f090558 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiHttpClientConfig.java @@ -0,0 +1,25 @@ +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.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() + .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); + } +} From 68977c9542c787e0422bf7276d301b4d36cb84b0 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Fri, 15 May 2026 23:37:49 +0900 Subject: [PATCH 27/29] =?UTF-8?q?chore:=20Github=20OAuth=20URL=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20base=20URL=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.example | 3 +-- .../common/config/properties/GithubOAuthProperties.java | 6 ++---- backend/src/main/resources/application.yml | 3 +-- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index affa4453..041bb985 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -27,8 +27,7 @@ REFRESH_TOKEN_COOKIE_HTTP_ONLY=true GITHUB_OAUTH_CLIENT_ID=your-github-oauth-client-id GITHUB_OAUTH_CLIENT_SECRET=your-github-oauth-client-secret GITHUB_OAUTH_REDIRECT_URI=http://localhost:5173/auth/callback -GITHUB_OAUTH_AUTHORIZATION_URL=https://github.com/login/oauth/authorize -GITHUB_OAUTH_TOKEN_URL=https://github.com/login/oauth/access_token +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 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 41958080..3f14fc0f 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 @@ -14,8 +14,7 @@ public record GithubOAuthProperties( @NotNull URI redirectUri, @NotBlank String scopes, - @NotNull URI authorizationUrl, - @NotNull URI tokenUrl, + @NotNull URI oauthBaseUrl, @NotBlank String tokenType, @NotBlank String codeChallengeMethod, @NotBlank String apiBaseUrl, @@ -28,8 +27,7 @@ public String toString() { + ", clientSecret=******" + ", redirectUri=" + redirectUri + ", scopes=" + scopes - + ", authorizationUrl=" + authorizationUrl - + ", tokenUrl=" + tokenUrl + + ", oauthBaseUrl=" + oauthBaseUrl + ", tokenType=" + tokenType + ", codeChallengeMethod=" + codeChallengeMethod + ", apiBaseUrl=" + apiBaseUrl diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 323c497d..240e929b 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -55,8 +55,7 @@ app: client-secret: ${GITHUB_OAUTH_CLIENT_SECRET:} redirect-uri: ${GITHUB_OAUTH_REDIRECT_URI:} scopes: read:user user:email repo - authorization-url: ${GITHUB_OAUTH_AUTHORIZATION_URL:https://github.com/login/oauth/authorize} - token-url: ${GITHUB_OAUTH_TOKEN_URL:https://github.com/login/oauth/access_token} + 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} From 388a7ff81a9421f1a639a19ee2d4fdcae1803058 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Fri, 15 May 2026 23:49:11 +0900 Subject: [PATCH 28/29] =?UTF-8?q?chore:=20GitHub=20=EC=99=B8=EB=B6=80=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20=ED=83=80=EC=9E=84=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.example | 2 ++ .../GithubOAuthHttpClientConfig.java | 9 +++++++++ .../properties/GithubOAuthProperties.java | 20 +++++++++++++++++-- .../GithubApiHttpClientConfig.java | 9 +++++++++ backend/src/main/resources/application.yml | 2 ++ 5 files changed, 40 insertions(+), 2 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 041bb985..c885a9b8 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -32,6 +32,8 @@ 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 # Optional local infrastructure overrides. # In deployment, set these from the root compose or server environment. 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 index 1f013fca..de413bf7 100644 --- a/backend/src/main/java/com/stackup/stackup/auth/infrastructure/GithubOAuthHttpClientConfig.java +++ b/backend/src/main/java/com/stackup/stackup/auth/infrastructure/GithubOAuthHttpClientConfig.java @@ -3,6 +3,7 @@ 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; @@ -13,10 +14,18 @@ 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/common/config/properties/GithubOAuthProperties.java b/backend/src/main/java/com/stackup/stackup/common/config/properties/GithubOAuthProperties.java index 3f14fc0f..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; @@ -18,9 +19,16 @@ public record GithubOAuthProperties( @NotBlank String tokenType, @NotBlank String codeChallengeMethod, @NotBlank String apiBaseUrl, - @NotBlank String apiVersion + @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 @@ -31,6 +39,14 @@ public String toString() { + ", tokenType=" + tokenType + ", codeChallengeMethod=" + codeChallengeMethod + ", apiBaseUrl=" + apiBaseUrl - + ", apiVersion=" + apiVersion + "]"; + + ", 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/github/infrastructure/GithubApiHttpClientConfig.java b/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiHttpClientConfig.java index 7f090558..68a854ee 100644 --- a/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiHttpClientConfig.java +++ b/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiHttpClientConfig.java @@ -4,6 +4,7 @@ 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; @@ -14,6 +15,7 @@ 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()) @@ -22,4 +24,11 @@ public GithubApiHttpClient githubApiHttpClient(GithubOAuthProperties properties) 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/resources/application.yml b/backend/src/main/resources/application.yml index 240e929b..c4c982e7 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -60,6 +60,8 @@ app: 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} access-key: ${S3_ACCESS_KEY:minioadmin} From 7895ab250fa08cccb9e42e70d51871dcd43e81dd Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Sat, 16 May 2026 00:01:16 +0900 Subject: [PATCH 29/29] =?UTF-8?q?chore:=20Core=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20=ED=99=98=EA=B2=BD=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존에 빠르게 테스트 하기위해 했던 흔적 제거 --- backend/.env.example | 13 +- backend/README.md | 253 ++++++++++----------- backend/src/main/resources/application.yml | 5 +- 3 files changed, 125 insertions(+), 146 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index c885a9b8..df1c2f81 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -26,7 +26,7 @@ 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=http://localhost:5173/auth/callback +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 @@ -35,18 +35,21 @@ GITHUB_API_VERSION=2022-11-28 GITHUB_CONNECT_TIMEOUT=3s GITHUB_READ_TIMEOUT=5s -# Optional local infrastructure overrides. +# CORS +CORS_ALLOWED_ORIGINS=https://your-domain + +# Infrastructure. # In deployment, set these from the root compose or server environment. -POSTGRES_HOST=localhost +POSTGRES_HOST=postgres POSTGRES_PORT=5432 POSTGRES_DB=stackup POSTGRES_USER=stackup POSTGRES_PASSWORD=stackup -RABBITMQ_HOST=localhost +RABBITMQ_HOST=rabbitmq RABBITMQ_PORT=5672 RABBITMQ_USER=stackup RABBITMQ_PASSWORD=stackup -S3_ENDPOINT=http://localhost:9000 +S3_ENDPOINT=http://minio:9000 S3_ACCESS_KEY=minioadmin S3_SECRET_KEY=minioadmin S3_BUCKET=stackup diff --git a/backend/README.md b/backend/README.md index 8b72b9c6..c401fa63 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,88 +1,80 @@ # StackUp Core Server -StackUp Core Server는 프론트엔드, GitHub, AI 서버, PostgreSQL, RabbitMQ 사이에서 인증과 핵심 도메인 흐름을 담당하는 Spring Boot API 서버입니다. +StackUp Core Server는 StackUp 서비스의 인증, 사용자, GitHub 연동, 분석 요청 중계를 담당하는 Spring Boot API 서버입니다. -프론트엔드는 Core Server가 발급한 StackUp access token으로 API를 호출합니다. GitHub client secret과 GitHub access token은 프론트엔드에서 직접 다루지 않습니다. +Core Server는 GitHub OAuth 로그인 흐름을 처리하고, GitHub access token을 암호화해서 보관합니다. GitHub access token은 Core Server 내부에서만 사용하며 프론트엔드나 AI 서버로 전달하지 않습니다. -## Core Server 역할 +## 역할 -인증: - -- `POST /api/auth/github` - - GitHub 로그인 URL을 생성합니다. - - CSRF 방지용 `state`를 생성해 DB에 저장합니다. - - PKCE 검증용 `code_verifier`를 DB에 저장하고, `code_challenge`를 GitHub URL에 포함합니다. - -- `GET /api/auth/github/callback` - - 프론트가 전달한 `code`, `state`를 검증합니다. - - `state`로 저장된 `code_verifier`를 조회한 뒤 삭제합니다. - - GitHub에 `code + code_verifier`를 보내 GitHub access token으로 교환합니다. - - GitHub user/email API로 사용자 정보를 조회합니다. - - GitHub access token을 암호화해 DB에 저장합니다. - - StackUp access token과 refresh token을 발급합니다. - -- `POST /api/auth/refresh` - - HttpOnly cookie의 refresh token을 검증합니다. - - 기존 refresh token을 revoke하고 새 refresh token으로 rotation합니다. - - 새 StackUp access token을 응답합니다. - -- `DELETE /api/auth/logout` - - refresh token을 revoke합니다. - - refresh cookie를 만료시킵니다. - -- `GET /api/users/me` - - `Authorization: Bearer {accessToken}`으로 현재 사용자 프로필을 반환합니다. - -보안 경계: - -- access token은 프론트에 응답 body로 전달합니다. -- refresh token은 HttpOnly cookie로만 전달합니다. -- refresh token 원문은 DB에 저장하지 않고 SHA-256 hash만 저장합니다. -- GitHub access token은 AES-GCM으로 암호화해 DB에 저장합니다. -- GitHub client secret, GitHub access token, refresh token 원문은 로그와 응답에 남기지 않습니다. +- 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`를 호출합니다. +1. 프론트엔드가 `POST /api/auth/github`를 호출합니다. 2. Core Server가 `state`, `code_verifier`, `code_challenge`를 생성합니다. -3. Core Server는 `state -> code_verifier`를 DB에 저장하고 GitHub authorization URL을 응답합니다. -4. 프론트는 응답의 `authorizationUrl`로 브라우저를 이동시킵니다. +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로 전달합니다. +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을 발급합니다. +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 값을 cookie로 내려주고, DB에는 refresh token hash만 저장합니다. -15. 프론트는 이후 API 요청에 `Authorization: Bearer {accessToken}`을 붙입니다. +14. Core Server는 refresh token raw 값을 HttpOnly cookie로 내려주고, DB에는 refresh token hash만 저장합니다. +15. 프론트엔드는 이후 API 요청에 `Authorization: Bearer {accessToken}`을 붙입니다. -## 환경변수 전달 방식 +## 배포 포트 -Spring Boot 설정은 [application.yml](src/main/resources/application.yml)에서 환경변수를 읽습니다. +StackUp 서비스 포트는 아래 기준으로 고정합니다. -예를 들어 [application.yml](src/main/resources/application.yml)에 아래처럼 정의되어 있습니다. +| 대상 | 포트 | +| --- | ---: | +| 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 | -```yaml -server: - port: ${SERVER_PORT:38010} +Core Server의 내부 실행 포트는 `SERVER_PORT=38010`으로 맞춥니다. root compose, Nginx upstream, Dockerfile `EXPOSE`, `SERVER_PORT`는 모두 이 값을 기준으로 맞춰야 합니다. + +요청 흐름 예시는 아래와 같습니다. + +```text +브라우저 + -> Nginx:38000 + -> Core Backend:38010 + -> Spring Boot server.port=38010 ``` -컨테이너 또는 Java 프로세스 환경변수에 `SERVER_PORT=38010`이 들어가면 Spring Boot의 `server.port` 값이 `38010`이 됩니다. 값이 없으면 기본값 `38010`을 사용합니다. +## 환경변수 주입 방식 -같은 방식으로 `JWT_SECRET`, `GITHUB_OAUTH_CLIENT_ID`, `POSTGRES_HOST` 같은 값도 [application.yml](src/main/resources/application.yml)의 `${...}` 표현식을 통해 환경변수에서 읽습니다. +Spring Boot 설정은 [application.yml](src/main/resources/application.yml)의 `${...}` 표현식을 통해 환경변수에서 읽습니다. -로컬에서는 backend 디렉토리에 `.env`를 만들 수 있습니다. +예를 들어: -```bash -cp .env.example .env +```yaml +server: + port: ${SERVER_PORT:38010} ``` -단, Spring Boot 애플리케이션이 `.env` 파일을 자동으로 읽는 것은 아닙니다. `.env` 파일의 값이 실제로 적용되려면 Docker Compose의 `env_file`, `environment`, shell `export`, IntelliJ Run Configuration, systemd 환경변수 같은 방식으로 프로세스 환경변수에 주입되어야 합니다. +컨테이너 환경변수에 `SERVER_PORT=38010`이 들어가면 Spring Boot의 `server.port`가 `38010`이 됩니다. 값이 없으면 기본값 `38010`을 사용합니다. -서버 배포에서는 root `docker-compose.yml`에서 `env_file` 또는 `environment`로 값을 컨테이너에 전달하는 방식을 권장합니다. 실제 secret 값은 repository에 커밋하지 않습니다. +`.env` 파일은 Spring Boot가 자동으로 읽지 않습니다. root `docker-compose.yml`에서 `env_file` 또는 `environment`로 컨테이너 환경변수에 주입해야 실제로 적용됩니다. root compose 예시: @@ -100,22 +92,6 @@ services: - "38010:38010" ``` -위 설정의 의미: - -- `env_file`은 `./backend/.env`의 값을 컨테이너 환경변수로 넣습니다. -- `environment`는 필요한 값을 직접 덮어씁니다. -- `SERVER_PORT=38010`은 Spring Boot 내부 실행 포트가 됩니다. -- `38010:38010`은 서버의 호스트 포트 `38010`을 컨테이너 내부 포트 `38010`으로 연결합니다. - -포트 역할: - -| 값 | 위치 | 의미 | -| --- | --- | --- | -| `SERVER_PORT=38010` | `.env`, compose `environment`, 서버 환경변수 | 컨테이너 내부 Spring Boot 실행 포트 | -| `38010:38010` | root `docker-compose.yml`의 `ports` | 호스트 `38010` 요청을 컨테이너 `38010`으로 전달 | -| `http://127.0.0.1:38010` | Nginx upstream | Nginx가 백엔드로 프록시할 주소 | -| `GITHUB_OAUTH_REDIRECT_URI` | GitHub OAuth App + 백엔드 환경변수 | 브라우저가 접근하는 프론트 callback URL | - ## 필수 환경변수 인증: @@ -129,11 +105,7 @@ JWT_ACCESS_TOKEN_TYPE=Bearer ENCRYPTION_KEY=replace-with-base64-encoded-32-byte-key ``` -`JWT_SECRET`: - -- StackUp JWT access token 서명에 사용합니다. -- Base64 형식일 필요는 없습니다. -- 충분히 긴 랜덤 문자열을 사용합니다. +`JWT_SECRET`은 StackUp JWT access token 서명에 사용합니다. Base64 형식일 필요는 없고 충분히 긴 랜덤 문자열이면 됩니다. 생성 예시: @@ -141,10 +113,7 @@ ENCRYPTION_KEY=replace-with-base64-encoded-32-byte-key openssl rand -base64 64 ``` -`ENCRYPTION_KEY`: - -- GitHub access token 암호화에 사용합니다. -- 반드시 Base64 decode 결과가 32바이트여야 합니다. +`ENCRYPTION_KEY`는 GitHub access token 암호화에 사용합니다. Base64 decode 결과가 정확히 32바이트여야 합니다. 생성 예시: @@ -171,47 +140,55 @@ REFRESH_TOKEN_COOKIE_SECURE=true REFRESH_TOKEN_COOKIE_HTTP_ONLY=true ``` -GitHub OAuth: +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=http://localhost:5173/auth/callback -GITHUB_OAUTH_AUTHORIZATION_URL=https://github.com/login/oauth/authorize -GITHUB_OAUTH_TOKEN_URL=https://github.com/login/oauth/access_token +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 -GITHUB_OAUTH_REDIRECT_URI=http://localhost:5173/auth/callback +CORS_ALLOWED_ORIGINS=https://your-domain ``` -배포 프론트 테스트: +프론트엔드가 여러 origin에서 접근해야 하면 comma-separated 값으로 주입합니다. ```env -GITHUB_OAUTH_REDIRECT_URI=https://www.udangtang.site/auth/callback +CORS_ALLOWED_ORIGINS=https://your-domain,https://www.your-domain ``` 인프라: ```env -POSTGRES_HOST=localhost +POSTGRES_HOST=postgres POSTGRES_PORT=5432 POSTGRES_DB=stackup POSTGRES_USER=stackup POSTGRES_PASSWORD=stackup -RABBITMQ_HOST=localhost + +RABBITMQ_HOST=rabbitmq RABBITMQ_PORT=5672 RABBITMQ_USER=stackup RABBITMQ_PASSWORD=stackup -S3_ENDPOINT=http://localhost:9000 + +S3_ENDPOINT=http://minio:9000 S3_ACCESS_KEY=minioadmin S3_SECRET_KEY=minioadmin S3_BUCKET=stackup @@ -219,66 +196,73 @@ S3_REGION=us-east-1 S3_PATH_STYLE=true ``` +외부에 노출되는 host port는 root compose에서 `38040`, `38050`, `38051`, `38060`, `38061`로 매핑합니다. 컨테이너 내부 통신은 서비스명과 내부 포트를 기준으로 맞춥니다. + ## Docker -backend 디렉토리에는 Dockerfile만 둡니다. compose 구성은 repository root에서 관리합니다. +backend 디렉터리에는 Dockerfile만 둡니다. compose 구성은 repository root에서 관리합니다. 이미지 빌드: ```bash -docker build -t stackup-backend ./backend +docker build -t stackup-core-server ./backend ``` -Dockerfile은 이미지를 만드는 책임만 가집니다. 실제 DB 주소, GitHub OAuth secret, 배포별 환경값은 root compose 또는 서버 환경변수로 전달합니다. +Dockerfile은 이미지를 만드는 책임만 가집니다. DB 주소, GitHub OAuth secret, 배포별 환경값은 root compose 또는 서버 환경변수로 전달합니다. -포트 전달 예시: +root compose 포트 예시: ```yaml services: + nginx: + ports: + - "38000:80" + + frontend: + ports: + - "38001:38001" + backend: - image: stackup-backend + image: stackup-core-server environment: SERVER_PORT: 38010 ports: - "38010:38010" -``` - -위 예시에서 `SERVER_PORT`는 컨테이너 내부의 Spring Boot 포트로 전달됩니다. `ports`의 왼쪽 값인 `38010`은 호스트 포트이고, 오른쪽 값인 `38010`은 컨테이너 내부 포트입니다. - -요청 흐름: - -```text -브라우저/외부 요청 - -> https://www.udangtang.site/api - -> Nginx - -> http://127.0.0.1:38010 - -> container:38010 - -> Spring Boot server.port=38010 -``` -`38010`은 Core Server의 확정 포트입니다. root compose, Nginx upstream, `SERVER_PORT`, Dockerfile `EXPOSE`를 모두 같은 값으로 맞춥니다. + realtime: + ports: + - "38020:38020" -Nginx reverse proxy를 사용할 때 `GITHUB_OAUTH_REDIRECT_URI`에는 내부 백엔드 API 포트를 넣지 않습니다. 브라우저가 실제 접근하는 프론트 callback URL을 넣습니다. + ai: + ports: + - "38030:38030" -## 로컬 실행 + postgres: + ports: + - "38040:5432" -로컬에서 backend만 실행하려면 `.env` 값을 현재 shell 환경변수로 올린 뒤 Spring Boot를 실행합니다. + rabbitmq: + ports: + - "38050:5672" + - "38051:15672" -PowerShell 예시: + minio: + ports: + - "38060:9000" + - "38061:9001" +``` -```powershell -Get-Content .env | ForEach-Object { - if ($_ -match '^\s*#|^\s*$') { return } - $pair = $_ -split '=', 2 - if ($pair.Length -eq 2) { - Set-Item -Path "Env:$($pair[0].Trim())" -Value $pair[1].Trim() - } -} +Nginx reverse proxy를 사용할 때 `GITHUB_OAUTH_REDIRECT_URI`에는 내부 backend port를 넣지 않습니다. 브라우저가 실제 접근하는 프론트엔드 callback URL을 넣습니다. -.\gradlew.bat bootRun -``` +## 보안 기준 -IntelliJ에서는 Run Configuration의 Environment variables에 `.env.example`의 키를 실제 값으로 넣습니다. +- 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를 사용합니다. ## 검증 @@ -288,16 +272,9 @@ IntelliJ에서는 Run Configuration의 Environment variables에 `.env.example` ./gradlew.bat test ``` -로그인 URL 발급 확인: - -```bash -curl -X POST http://localhost:38010/api/auth/github -``` - -배포 환경에서 Nginx를 거친 API 확인: +배포 후 헬스 체크와 OAuth URL 발급 API는 Nginx 경유 주소로 확인합니다. ```bash -curl -X POST https://www.udangtang.site/api/auth/github +curl https://your-domain/api/system/health +curl -X POST https://your-domain/api/auth/github ``` - -응답의 `authorizationUrl`에 들어간 `redirect_uri`가 GitHub OAuth App callback URL과 같은지 확인합니다. diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index c4c982e7..3ce77217 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -63,7 +63,7 @@ app: 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} @@ -106,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}