diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 80a77c62..ba246e78 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -9,7 +9,7 @@
- [ ] sre: 시스템 관리 및 IT 인프라 자동화 작업
### 반영 브랜치
-
+
### ✨ 변경 사항
diff --git a/.github/workflows/deployment-to-dev.yml b/.github/workflows/deploy-to-dev.yml
similarity index 94%
rename from .github/workflows/deployment-to-dev.yml
rename to .github/workflows/deploy-to-dev.yml
index 02f5b49a..fabf2485 100644
--- a/.github/workflows/deployment-to-dev.yml
+++ b/.github/workflows/deploy-to-dev.yml
@@ -1,8 +1,8 @@
-name: Deployment to Development Server
+name: Deploy to Development Server
on:
push:
- branches: [develop]
+ branches: [dev]
env:
AWS_IAM_ROLE_TO_ASSUME: ${{ secrets.DEV_AWS_IAM_ROLE_TO_ASSUME }}
@@ -168,7 +168,7 @@ jobs:
{
"author": { "name": "${{ github.actor }}" },
"title": "Deployment Succeeded",
- "description": "Branch: `${{ github.ref_name }}`\nWorkflow: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
+ "description": "Branch: `${{ github.ref_name }}`\nWorkflow: [View on GitHub](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})",
"color": 10478271,
"fields": [
{ "name": "Service", "value": "`${{ steps.extract-name.outputs.service_name }}`", "inline": true },
@@ -189,7 +189,7 @@ jobs:
{
"author": { "name": "${{ github.actor }}" },
"title": "Deployment Failed",
- "description": "Branch: `${{ github.ref_name }}`\nWorkflow: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\n\n**Action**: App Runner will automatically attempt to roll back to the last known good configuration.",
+ "description": "Branch: `${{ github.ref_name }}`\nWorkflow: [View on GitHub](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})\n\n**Action**: App Runner will automatically attempt to roll back to the last known good configuration.",
"color": 13458524,
"fields": [
{ "name": "Service", "value": "`${{ steps.extract-name.outputs.service_name }}`", "inline": true },
diff --git a/.github/workflows/deployment-to-prod.yml b/.github/workflows/deploy-to-prod.yml
similarity index 95%
rename from .github/workflows/deployment-to-prod.yml
rename to .github/workflows/deploy-to-prod.yml
index 0a9e72a0..5ff5d716 100644
--- a/.github/workflows/deployment-to-prod.yml
+++ b/.github/workflows/deploy-to-prod.yml
@@ -1,4 +1,4 @@
-name: Deployment to Production Server
+name: Deploy to Production Server
on:
push:
@@ -145,7 +145,7 @@ jobs:
{
"author": { "name": "${{ github.actor }}" },
"title": "Deployment Succeeded",
- "description": "Branch: `${{ github.ref_name }}`\nWorkflow: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
+ "description": "Branch: `${{ github.ref_name }}`\nWorkflow: [View on GitHub](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})",
"color": 10478271,
"fields": [
{ "name": "Cluster", "value": "`${{ env.ECS_CLUSTER }}`", "inline": true },
@@ -188,7 +188,7 @@ jobs:
{
"author": { "name": "${{ github.actor }}" },
"title": "Deployment Failed",
- "description": "Branch: `${{ github.ref_name }}`\nWorkflow: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\n\n**Action**: Attempted to roll back to the previous stable task definition.",
+ "description": "Branch: `${{ github.ref_name }}`\nWorkflow: [View on GitHub](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})\n\n**Action**: Attempted to roll back to the previous stable task definition.",
"color": 13458524,
"fields": [
{ "name": "Service", "value": "`${{ env.ECS_SERVICE }}`", "inline": true },
diff --git a/.github/workflows/deployemnt-to-stg.yml b/.github/workflows/deploy-to-stg.yml
similarity index 95%
rename from .github/workflows/deployemnt-to-stg.yml
rename to .github/workflows/deploy-to-stg.yml
index 64514061..347d7040 100644
--- a/.github/workflows/deployemnt-to-stg.yml
+++ b/.github/workflows/deploy-to-stg.yml
@@ -1,4 +1,4 @@
-name: Deployment to Staging Server
+name: Deploy to Staging Server
on:
push:
@@ -145,7 +145,7 @@ jobs:
{
"author": { "name": "${{ github.actor }}" },
"title": "Deployment Succeeded",
- "description": "Branch: `${{ github.ref_name }}`\nWorkflow: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
+ "description": "Branch: `${{ github.ref_name }}`\nWorkflow: [View on GitHub](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})",
"color": 10478271,
"fields": [
{ "name": "Cluster", "value": "`${{ env.ECS_CLUSTER }}`", "inline": true },
@@ -188,7 +188,7 @@ jobs:
{
"author": { "name": "${{ github.actor }}" },
"title": "Deployment Failed",
- "description": "Branch: `${{ github.ref_name }}`\nWorkflow: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\n\n**Action**: Attempted to roll back to the previous stable task definition.",
+ "description": "Branch: `${{ github.ref_name }}`\nWorkflow: [View on GitHub](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})\n\n**Action**: Attempted to roll back to the previous stable task definition.",
"color": 13458524,
"fields": [
{ "name": "Service", "value": "`${{ env.ECS_SERVICE }}`", "inline": true },
diff --git a/.github/workflows/notify-assigned-issue.yml b/.github/workflows/notify-assigned-issue.yml
index 7d304cf2..37f83d79 100644
--- a/.github/workflows/notify-assigned-issue.yml
+++ b/.github/workflows/notify-assigned-issue.yml
@@ -25,7 +25,7 @@ jobs:
{
"title": "${{ github.event.issue.title }}",
"color": 10478271,
- "description": "${{ github.event.issue.html_url }}",
+ "description": "[View on GitHub](${{ github.event.issue.html_url }})",
"fields": [
{
"name": "Issue Number",
diff --git a/.github/workflows/notify-on-comment.yml b/.github/workflows/notify-on-comment.yml
new file mode 100644
index 00000000..484cbc1e
--- /dev/null
+++ b/.github/workflows/notify-on-comment.yml
@@ -0,0 +1,67 @@
+name: Notify on Comment
+
+on:
+ issue_comment:
+ types: [created]
+
+env:
+ DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
+
+permissions:
+ contents: read
+
+jobs:
+ notify-on-comment:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Send PR Comment Notification
+ if: github.event.issue.pull_request
+ uses: Ilshidur/action-discord@0.3.2
+ with:
+ args: "A new comment has been added to the pull request 💬"
+ env:
+ DISCORD_WEBHOOK: ${{ env.DISCORD_WEBHOOK }}
+ DISCORD_EMBEDS: |
+ [
+ {
+ "author": {
+ "name": "${{ github.event.comment.user.login }}"
+ },
+ "title": "Comment on PR #${{ github.event.issue.number }}: ${{ github.event.issue.title }}",
+ "color": 10478271,
+ "description": "${{ github.event.comment.body }}\n\n[View Comment](${{ github.event.comment.html_url }})",
+ "fields": [
+ {
+ "name": "Pull Request",
+ "value": "[Link](${{ github.event.issue.html_url }})",
+ "inline": true
+ }
+ ]
+ }
+ ]
+
+ - name: Send Issue Comment Notification
+ if: ${{ !github.event.issue.pull_request }}
+ uses: Ilshidur/action-discord@0.3.2
+ with:
+ args: "A new comment has been added to the issue 💬"
+ env:
+ DISCORD_WEBHOOK: ${{ env.DISCORD_WEBHOOK }}
+ DISCORD_EMBEDS: |
+ [
+ {
+ "author": {
+ "name": "${{ github.event.comment.user.login }}"
+ },
+ "title": "Comment on Issue #${{ github.event.issue.number }}: ${{ github.event.issue.title }}",
+ "color": 10478271,
+ "description": "${{ github.event.comment.body }}\n\n[View Comment](${{ github.event.comment.html_url }})",
+ "fields": [
+ {
+ "name": "Issue",
+ "value": "[Link](${{ github.event.issue.html_url }})",
+ "inline": true
+ }
+ ]
+ }
+ ]
diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml
index e5791ffa..2916aaa9 100644
--- a/.github/workflows/validate-pr.yml
+++ b/.github/workflows/validate-pr.yml
@@ -27,7 +27,8 @@ jobs:
--health-retries 5
steps:
- - uses: actions/checkout@v4
+ - name: Checkout
+ uses: actions/checkout@v4
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
@@ -71,7 +72,7 @@ jobs:
},
"title": "#${{ github.event.pull_request.number }}: ${{ github.event.pull_request.title }}",
"color": 10478271,
- "description": "${{ github.event.pull_request.html_url }}",
+ "description": "[View on GitHub](${{ github.event.pull_request.html_url }})",
"fields": [
{
"name": "Base Branch",
@@ -102,7 +103,7 @@ jobs:
},
"title": "#${{ github.event.pull_request.number }}: ${{ github.event.pull_request.title }}",
"color": 13458524,
- "description": "${{ github.event.pull_request.html_url }}",
+ "description": "[View on GitHub](${{ github.event.pull_request.html_url }})",
"fields": [
{
"name": "Base Branch",
diff --git a/build.gradle b/build.gradle
index bf9f7fd1..66a36107 100644
--- a/build.gradle
+++ b/build.gradle
@@ -54,6 +54,7 @@ repositories {
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
+ testImplementation 'org.springframework.security:spring-security-test'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-log4j2'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
@@ -137,7 +138,8 @@ jacocoTestCoverageVerification {
classDirectories.setFrom(files(classDirectories.files.collect {
fileTree(dir: it, excludes: [
'**/*Application.class',
- '**/config/*Config.class'
+ '**/config/*Config.class',
+ '**/GlobalExceptionHandler.class'
])
}))
}
diff --git a/checkstyle/naver-checkstyle-rules.xml b/checkstyle/naver-checkstyle-rules.xml
index e298381c..80632859 100644
--- a/checkstyle/naver-checkstyle-rules.xml
+++ b/checkstyle/naver-checkstyle-rules.xml
@@ -426,7 +426,7 @@ The following rules in the Naver coding convention cannot be checked by this con
-
-
+
+
diff --git a/src/main/java/com/und/server/ServerApplication.java b/src/main/java/com/und/server/ServerApplication.java
index 8508ee81..e0d37e45 100644
--- a/src/main/java/com/und/server/ServerApplication.java
+++ b/src/main/java/com/und/server/ServerApplication.java
@@ -4,9 +4,11 @@
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.cloud.openfeign.EnableFeignClients;
+import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableFeignClients
+@EnableScheduling
@ConfigurationPropertiesScan
public class ServerApplication {
diff --git a/src/main/java/com/und/server/config/ObservabilityUserConfig.java b/src/main/java/com/und/server/auth/config/ObservabilityUserConfig.java
similarity index 55%
rename from src/main/java/com/und/server/config/ObservabilityUserConfig.java
rename to src/main/java/com/und/server/auth/config/ObservabilityUserConfig.java
index 2a9ffa44..96cb45cd 100644
--- a/src/main/java/com/und/server/config/ObservabilityUserConfig.java
+++ b/src/main/java/com/und/server/auth/config/ObservabilityUserConfig.java
@@ -1,4 +1,4 @@
-package com.und.server.config;
+package com.und.server.auth.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
@@ -10,14 +10,19 @@
@Configuration
public class ObservabilityUserConfig {
- @Value("${observability.prometheus.username}")
- private String prometheusUsername;
+ private final String prometheusUsername;
+ private final String prometheusPassword;
- @Value("${observability.prometheus.password}")
- private String prometheusPassword;
+ public ObservabilityUserConfig(
+ @Value("${observability.prometheus.username}") final String prometheusUsername,
+ @Value("${observability.prometheus.password}") final String prometheusPassword
+ ) {
+ this.prometheusUsername = prometheusUsername;
+ this.prometheusPassword = prometheusPassword;
+ }
@Bean
- public InMemoryUserDetailsManager prometheusUserDetails(PasswordEncoder passwordEncoder) {
+ public InMemoryUserDetailsManager prometheusUserDetails(final PasswordEncoder passwordEncoder) {
return new InMemoryUserDetailsManager(User.builder()
.username(prometheusUsername)
.password(passwordEncoder.encode(prometheusPassword))
diff --git a/src/main/java/com/und/server/config/SecurityConfig.java b/src/main/java/com/und/server/auth/config/SecurityConfig.java
similarity index 69%
rename from src/main/java/com/und/server/config/SecurityConfig.java
rename to src/main/java/com/und/server/auth/config/SecurityConfig.java
index b5fac196..7340d0bd 100644
--- a/src/main/java/com/und/server/config/SecurityConfig.java
+++ b/src/main/java/com/und/server/auth/config/SecurityConfig.java
@@ -1,4 +1,4 @@
-package com.und.server.config;
+package com.und.server.auth.config;
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;
import org.springframework.context.annotation.Bean;
@@ -15,8 +15,9 @@
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
-import com.und.server.security.CustomAuthenticationEntryPoint;
-import com.und.server.security.JwtAuthenticationFilter;
+import com.und.server.auth.filter.CustomAuthenticationEntryPoint;
+import com.und.server.auth.filter.JwtAuthenticationFilter;
+import com.und.server.common.util.ProfileManager;
import lombok.RequiredArgsConstructor;
@@ -27,6 +28,7 @@ public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
+ private final ProfileManager profileManager;
@Bean
public PasswordEncoder passwordEncoder() {
@@ -35,7 +37,7 @@ public PasswordEncoder passwordEncoder() {
@Bean
@Order(1)
- public SecurityFilterChain actuatorSecurityFilterChain(HttpSecurity http) throws Exception {
+ public SecurityFilterChain actuatorSecurityFilterChain(final HttpSecurity http) throws Exception {
return http
.securityMatcher(EndpointRequest.toAnyEndpoint())
.authorizeHttpRequests(authorize -> authorize
@@ -51,21 +53,27 @@ public SecurityFilterChain actuatorSecurityFilterChain(HttpSecurity http) throws
@Bean
@Order(2)
- public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception {
+ public SecurityFilterChain apiSecurityFilterChain(final HttpSecurity http) throws Exception {
return http
+ .authorizeHttpRequests(authorize -> {
+ authorize
+ .requestMatchers(HttpMethod.POST, "/v*/auth/**").permitAll()
+ .requestMatchers("/error").permitAll();
+
+ if (!profileManager.isProdOrStgProfile()) {
+ authorize
+ .requestMatchers(HttpMethod.POST, "/v*/test/access").permitAll()
+ .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll();
+ }
+
+ authorize.anyRequest().authenticated();
+ })
+ .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
+ .exceptionHandling(handler -> handler.authenticationEntryPoint(customAuthenticationEntryPoint))
+ .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
- .sessionManagement(sessionManagement -> sessionManagement
- .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
- .authorizeHttpRequests(authorize -> authorize
- // FIXME: Remove "/v*/test/access" when deleting TestController
- .requestMatchers(HttpMethod.POST, "/v*/auth/**", "/v*/test/access").permitAll()
- .requestMatchers("/error").permitAll()
- .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
- .anyRequest().authenticated())
- .exceptionHandling(handler -> handler.authenticationEntryPoint(customAuthenticationEntryPoint))
- .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
diff --git a/src/main/java/com/und/server/auth/controller/AuthController.java b/src/main/java/com/und/server/auth/controller/AuthController.java
new file mode 100644
index 00000000..4b1f4cff
--- /dev/null
+++ b/src/main/java/com/und/server/auth/controller/AuthController.java
@@ -0,0 +1,63 @@
+package com.und.server.auth.controller;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.und.server.auth.dto.request.AuthRequest;
+import com.und.server.auth.dto.request.NonceRequest;
+import com.und.server.auth.dto.request.RefreshTokenRequest;
+import com.und.server.auth.dto.response.AuthResponse;
+import com.und.server.auth.dto.response.NonceResponse;
+import com.und.server.auth.filter.AuthMember;
+import com.und.server.auth.service.AuthService;
+
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/v1/auth")
+public class AuthController {
+
+ private final AuthService authService;
+
+ @PostMapping("/nonce")
+ @ApiResponse(responseCode = "201", description = "Nonce created")
+ public ResponseEntity generateNonce(@RequestBody @Valid final NonceRequest nonceRequest) {
+ final NonceResponse nonceResponse = authService.generateNonce(nonceRequest);
+
+ return ResponseEntity.status(HttpStatus.CREATED).body(nonceResponse);
+ }
+
+ @PostMapping("/login")
+ public ResponseEntity login(@RequestBody @Valid final AuthRequest authRequest) {
+ final AuthResponse authResponse = authService.login(authRequest);
+
+ return ResponseEntity.status(HttpStatus.OK).body(authResponse);
+ }
+
+ @PostMapping("/tokens")
+ @ApiResponse(responseCode = "201", description = "Tokens reissued")
+ public ResponseEntity reissueTokens(
+ @RequestBody @Valid final RefreshTokenRequest refreshTokenRequest
+ ) {
+ final AuthResponse authResponse = authService.reissueTokens(refreshTokenRequest);
+
+ return ResponseEntity.status(HttpStatus.CREATED).body(authResponse);
+ }
+
+ @DeleteMapping("/logout")
+ @ApiResponse(responseCode = "204", description = "Logout successful")
+ public ResponseEntity logout(@Parameter(hidden = true) @AuthMember final Long memberId) {
+ authService.logout(memberId);
+ return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
+ }
+
+}
diff --git a/src/main/java/com/und/server/dto/OidcPublicKey.java b/src/main/java/com/und/server/auth/dto/OidcPublicKey.java
similarity index 63%
rename from src/main/java/com/und/server/dto/OidcPublicKey.java
rename to src/main/java/com/und/server/auth/dto/OidcPublicKey.java
index c542f9cd..f5178eef 100644
--- a/src/main/java/com/und/server/dto/OidcPublicKey.java
+++ b/src/main/java/com/und/server/auth/dto/OidcPublicKey.java
@@ -1,26 +1,24 @@
-package com.und.server.dto;
-
-import com.fasterxml.jackson.annotation.JsonProperty;
+package com.und.server.auth.dto;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "OIDC Public Key Details")
public record OidcPublicKey(
@Schema(description = "Key ID", example = "a1b2c3d4e5")
- @JsonProperty("kid") String kid,
+ String kid,
@Schema(description = "Key Type", example = "RSA")
- @JsonProperty("kty") String kty,
+ String kty,
@Schema(description = "Algorithm", example = "RS256")
- @JsonProperty("alg") String alg,
+ String alg,
@Schema(description = "Usage", example = "sig")
- @JsonProperty("use") String use,
+ String use,
@Schema(description = "Modulus", example = "q8zZ0b_MNaLd6Ny8wd4...")
- @JsonProperty("n") String n,
+ String n,
@Schema(description = "Exponent", example = "AQAB")
- @JsonProperty("e") String e
+ String e
) { }
diff --git a/src/main/java/com/und/server/dto/OidcPublicKeys.java b/src/main/java/com/und/server/auth/dto/OidcPublicKeys.java
similarity index 56%
rename from src/main/java/com/und/server/dto/OidcPublicKeys.java
rename to src/main/java/com/und/server/auth/dto/OidcPublicKeys.java
index 069b3173..3d48a124 100644
--- a/src/main/java/com/und/server/dto/OidcPublicKeys.java
+++ b/src/main/java/com/und/server/auth/dto/OidcPublicKeys.java
@@ -1,22 +1,21 @@
-package com.und.server.dto;
+package com.und.server.auth.dto;
import java.util.List;
-import com.fasterxml.jackson.annotation.JsonProperty;
-import com.und.server.exception.ServerErrorResult;
-import com.und.server.exception.ServerException;
+import com.und.server.auth.exception.AuthErrorResult;
+import com.und.server.common.exception.ServerException;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "A list of OIDC Public Keys")
public record OidcPublicKeys(
@Schema(description = "List of public keys", example = "[...]")
- @JsonProperty("keys") List keys
+ List keys
) {
public OidcPublicKey matchingKey(final String kid, final String alg) {
return keys.stream()
.filter(key -> key.kid().equals(kid) && key.alg().equals(alg))
.findAny()
- .orElseThrow(() -> new ServerException(ServerErrorResult.PUBLIC_KEY_NOT_FOUND));
+ .orElseThrow(() -> new ServerException(AuthErrorResult.PUBLIC_KEY_NOT_FOUND));
}
}
diff --git a/src/main/java/com/und/server/dto/AuthRequest.java b/src/main/java/com/und/server/auth/dto/request/AuthRequest.java
similarity index 50%
rename from src/main/java/com/und/server/dto/AuthRequest.java
rename to src/main/java/com/und/server/auth/dto/request/AuthRequest.java
index 8ae29d50..b43b11a5 100644
--- a/src/main/java/com/und/server/dto/AuthRequest.java
+++ b/src/main/java/com/und/server/auth/dto/request/AuthRequest.java
@@ -1,15 +1,13 @@
-package com.und.server.dto;
-
-import com.fasterxml.jackson.annotation.JsonProperty;
+package com.und.server.auth.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
-import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.NotBlank;
@Schema(description = "Request for Authentication with ID Token")
public record AuthRequest(
@Schema(description = "OAuth provider name", example = "kakao")
- @NotNull(message = "Provider must not be null") @JsonProperty("provider") String provider,
+ @NotBlank(message = "Provider name must not be blank") String provider,
@Schema(description = "ID Token from the OAuth provider", example = "eyJhbGciOiJIUzI1Ni...")
- @NotNull(message = "ID Token must not be null") @JsonProperty("id_token") String idToken
+ @NotBlank(message = "ID Token must not be blank") String idToken
) { }
diff --git a/src/main/java/com/und/server/auth/dto/request/NonceRequest.java b/src/main/java/com/und/server/auth/dto/request/NonceRequest.java
new file mode 100644
index 00000000..423d01ea
--- /dev/null
+++ b/src/main/java/com/und/server/auth/dto/request/NonceRequest.java
@@ -0,0 +1,10 @@
+package com.und.server.auth.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+
+@Schema(description = "Request for issuing a Nonce")
+public record NonceRequest(
+ @Schema(description = "OAuth provider name", example = "kakao")
+ @NotBlank(message = "Provider name must not be blank") String provider
+) { }
diff --git a/src/main/java/com/und/server/auth/dto/request/RefreshTokenRequest.java b/src/main/java/com/und/server/auth/dto/request/RefreshTokenRequest.java
new file mode 100644
index 00000000..8db3d3e2
--- /dev/null
+++ b/src/main/java/com/und/server/auth/dto/request/RefreshTokenRequest.java
@@ -0,0 +1,13 @@
+package com.und.server.auth.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+
+@Schema(description = "Request to Reissue Tokens")
+public record RefreshTokenRequest(
+ @Schema(description = "Expired Access Token", example = "eyJhbGciOiJIUzI1Ni...")
+ @NotBlank(message = "Access Token must not be blank") String accessToken,
+
+ @Schema(description = "Valid Refresh Token", example = "a1b2c3d4-e5f6-78...")
+ @NotBlank(message = "Refresh Token must not be blank") String refreshToken
+) { }
diff --git a/src/main/java/com/und/server/dto/AuthResponse.java b/src/main/java/com/und/server/auth/dto/response/AuthResponse.java
similarity index 60%
rename from src/main/java/com/und/server/dto/AuthResponse.java
rename to src/main/java/com/und/server/auth/dto/response/AuthResponse.java
index 6a22433c..3e6cf4a3 100644
--- a/src/main/java/com/und/server/dto/AuthResponse.java
+++ b/src/main/java/com/und/server/auth/dto/response/AuthResponse.java
@@ -1,23 +1,21 @@
-package com.und.server.dto;
-
-import com.fasterxml.jackson.annotation.JsonProperty;
+package com.und.server.auth.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "Authentication Token Response")
public record AuthResponse(
@Schema(description = "Token type", example = "Bearer")
- @JsonProperty("token_type") String tokenType,
+ String tokenType,
@Schema(description = "Access Token for API authentication", example = "eyJhbGciOiJIUzI1Ni...")
- @JsonProperty("access_token") String accessToken,
+ String accessToken,
@Schema(description = "Access Token expiration time in seconds", example = "3600")
- @JsonProperty("access_token_expires_in") Integer accessTokenExpiresIn,
+ Integer accessTokenExpiresIn,
@Schema(description = "Refresh Token for renewing the Access Token", example = "a1b2c3d4-e5f6-78...")
- @JsonProperty("refresh_token") String refreshToken,
+ String refreshToken,
@Schema(description = "Refresh Token expiration time in seconds", example = "604800")
- @JsonProperty("refresh_token_expires_in") Integer refreshTokenExpiresIn
+ Integer refreshTokenExpiresIn
) { }
diff --git a/src/main/java/com/und/server/auth/dto/response/NonceResponse.java b/src/main/java/com/und/server/auth/dto/response/NonceResponse.java
new file mode 100644
index 00000000..06bcaca9
--- /dev/null
+++ b/src/main/java/com/und/server/auth/dto/response/NonceResponse.java
@@ -0,0 +1,9 @@
+package com.und.server.auth.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "Response with a Nonce")
+public record NonceResponse(
+ @Schema(description = "A unique and single-use string for security", example = "a1b2c3d4-e5f6-78...")
+ String nonce
+) { }
diff --git a/src/main/java/com/und/server/entity/Nonce.java b/src/main/java/com/und/server/auth/entity/Nonce.java
similarity index 85%
rename from src/main/java/com/und/server/entity/Nonce.java
rename to src/main/java/com/und/server/auth/entity/Nonce.java
index d71395a9..7d9c40bc 100644
--- a/src/main/java/com/und/server/entity/Nonce.java
+++ b/src/main/java/com/und/server/auth/entity/Nonce.java
@@ -1,9 +1,9 @@
-package com.und.server.entity;
+package com.und.server.auth.entity;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
-import com.und.server.oauth.Provider;
+import com.und.server.auth.oauth.Provider;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
diff --git a/src/main/java/com/und/server/entity/RefreshToken.java b/src/main/java/com/und/server/auth/entity/RefreshToken.java
similarity index 74%
rename from src/main/java/com/und/server/entity/RefreshToken.java
rename to src/main/java/com/und/server/auth/entity/RefreshToken.java
index 3ac3197b..6ea48f9a 100644
--- a/src/main/java/com/und/server/entity/RefreshToken.java
+++ b/src/main/java/com/und/server/auth/entity/RefreshToken.java
@@ -1,4 +1,4 @@
-package com.und.server.entity;
+package com.und.server.auth.entity;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
@@ -13,12 +13,12 @@
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
-@RedisHash(value = "refreshToken", timeToLive = 604800) // Valid for 7 days
+@RedisHash(value = "refreshToken", timeToLive = 1209600) // Valid for 14 days
public class RefreshToken {
@Id
private Long memberId;
- private String refreshToken;
+ private String value;
}
diff --git a/src/main/java/com/und/server/exception/ServerErrorResult.java b/src/main/java/com/und/server/auth/exception/AuthErrorResult.java
similarity index 65%
rename from src/main/java/com/und/server/exception/ServerErrorResult.java
rename to src/main/java/com/und/server/auth/exception/AuthErrorResult.java
index 943a9cc7..26f154e5 100644
--- a/src/main/java/com/und/server/exception/ServerErrorResult.java
+++ b/src/main/java/com/und/server/auth/exception/AuthErrorResult.java
@@ -1,27 +1,29 @@
-package com.und.server.exception;
+package com.und.server.auth.exception;
import org.springframework.http.HttpStatus;
+import com.und.server.common.exception.ErrorResult;
+
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
-public enum ServerErrorResult {
+public enum AuthErrorResult implements ErrorResult {
- INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "Invalid Parameter"),
INVALID_NONCE(HttpStatus.BAD_REQUEST, "Invalid nonce"),
INVALID_PROVIDER(HttpStatus.BAD_REQUEST, "Invalid Provider"),
+ INVALID_PROVIDER_ID(HttpStatus.BAD_REQUEST, "Invalid Provider ID"),
PUBLIC_KEY_NOT_FOUND(HttpStatus.BAD_REQUEST, "Public Key Not Found"),
INVALID_PUBLIC_KEY(HttpStatus.BAD_REQUEST, "Invalid Public Key"),
EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "Expired Token"),
+ NOT_EXPIRED_TOKEN(HttpStatus.BAD_REQUEST, "Not Expired Token"),
MALFORMED_TOKEN(HttpStatus.BAD_REQUEST, "Malformed Token"),
INVALID_TOKEN_SIGNATURE(HttpStatus.UNAUTHORIZED, "Invalid Token Signature"),
+ UNSUPPORTED_TOKEN(HttpStatus.BAD_REQUEST, "Unsupported Token"),
+ WEAK_TOKEN_KEY(HttpStatus.INTERNAL_SERVER_ERROR, "Token Key is Weak"),
INVALID_TOKEN(HttpStatus.BAD_REQUEST, "Invalid Token"),
- UNAUTHORIZED_ACCESS(HttpStatus.UNAUTHORIZED, "Unauthorized Access"),
- // FIXME: Remove MEMBER_NOT_FOUND when deleting TestController
- MEMBER_NOT_FOUND(HttpStatus.UNAUTHORIZED, "Member Not Found"),
- UNKNOWN_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "Unknown Exception");
+ UNAUTHORIZED_ACCESS(HttpStatus.UNAUTHORIZED, "Unauthorized Access");
private final HttpStatus httpStatus;
private final String message;
diff --git a/src/main/java/com/und/server/auth/filter/AuthMember.java b/src/main/java/com/und/server/auth/filter/AuthMember.java
new file mode 100644
index 00000000..0552c2da
--- /dev/null
+++ b/src/main/java/com/und/server/auth/filter/AuthMember.java
@@ -0,0 +1,11 @@
+package com.und.server.auth.filter;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Target(ElementType.PARAMETER)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface AuthMember {
+}
diff --git a/src/main/java/com/und/server/auth/filter/AuthMemberArgumentResolver.java b/src/main/java/com/und/server/auth/filter/AuthMemberArgumentResolver.java
new file mode 100644
index 00000000..23035c38
--- /dev/null
+++ b/src/main/java/com/und/server/auth/filter/AuthMemberArgumentResolver.java
@@ -0,0 +1,34 @@
+package com.und.server.auth.filter;
+
+import org.springframework.core.MethodParameter;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Component;
+import org.springframework.web.bind.support.WebDataBinderFactory;
+import org.springframework.web.context.request.NativeWebRequest;
+import org.springframework.web.method.support.HandlerMethodArgumentResolver;
+import org.springframework.web.method.support.ModelAndViewContainer;
+
+import com.und.server.auth.exception.AuthErrorResult;
+import com.und.server.common.exception.ServerException;
+
+@Component
+public class AuthMemberArgumentResolver implements HandlerMethodArgumentResolver {
+
+ @Override
+ public boolean supportsParameter(final MethodParameter parameter) {
+ return parameter.hasParameterAnnotation(AuthMember.class)
+ && parameter.getParameterType().equals(Long.class);
+ }
+
+ @Override
+ public Object resolveArgument(final MethodParameter parameter, final ModelAndViewContainer mavContainer,
+ final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) {
+ final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+ if (authentication == null || !(authentication.getPrincipal() instanceof final Long memberId)) {
+ throw new ServerException(AuthErrorResult.UNAUTHORIZED_ACCESS);
+ }
+ return memberId;
+ }
+
+}
diff --git a/src/main/java/com/und/server/security/CustomAuthenticationEntryPoint.java b/src/main/java/com/und/server/auth/filter/CustomAuthenticationEntryPoint.java
similarity index 66%
rename from src/main/java/com/und/server/security/CustomAuthenticationEntryPoint.java
rename to src/main/java/com/und/server/auth/filter/CustomAuthenticationEntryPoint.java
index 41ed9ae5..e82bdf86 100644
--- a/src/main/java/com/und/server/security/CustomAuthenticationEntryPoint.java
+++ b/src/main/java/com/und/server/auth/filter/CustomAuthenticationEntryPoint.java
@@ -1,4 +1,4 @@
-package com.und.server.security;
+package com.und.server.auth.filter;
import java.io.IOException;
@@ -6,7 +6,7 @@
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
-import com.und.server.exception.ServerErrorResult;
+import com.und.server.auth.exception.AuthErrorResult;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -20,10 +20,10 @@ public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint
@Override
public void commence(
- HttpServletRequest request,
- HttpServletResponse response,
- AuthenticationException authException
+ final HttpServletRequest request,
+ final HttpServletResponse response,
+ final AuthenticationException authException
) throws IOException {
- errorResponseWriter.sendErrorResponse(response, ServerErrorResult.UNAUTHORIZED_ACCESS);
+ errorResponseWriter.sendErrorResponse(response, AuthErrorResult.UNAUTHORIZED_ACCESS);
}
}
diff --git a/src/main/java/com/und/server/security/JwtAuthenticationFilter.java b/src/main/java/com/und/server/auth/filter/JwtAuthenticationFilter.java
similarity index 74%
rename from src/main/java/com/und/server/security/JwtAuthenticationFilter.java
rename to src/main/java/com/und/server/auth/filter/JwtAuthenticationFilter.java
index 8102e7b9..89eac499 100644
--- a/src/main/java/com/und/server/security/JwtAuthenticationFilter.java
+++ b/src/main/java/com/und/server/auth/filter/JwtAuthenticationFilter.java
@@ -1,4 +1,4 @@
-package com.und.server.security;
+package com.und.server.auth.filter;
import java.io.IOException;
import java.util.List;
@@ -9,9 +9,9 @@
import org.springframework.util.AntPathMatcher;
import org.springframework.web.filter.OncePerRequestFilter;
-import com.und.server.exception.ServerErrorResult;
-import com.und.server.exception.ServerException;
-import com.und.server.jwt.JwtProvider;
+import com.und.server.auth.exception.AuthErrorResult;
+import com.und.server.auth.jwt.JwtProvider;
+import com.und.server.common.exception.ServerException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
@@ -29,20 +29,22 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final List permissivePaths = List.of("/v*/auth/tokens");
@Override
- protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
- throws ServletException, IOException {
-
+ protected void doFilterInternal(
+ final HttpServletRequest request,
+ final HttpServletResponse response,
+ final FilterChain filterChain
+ ) throws ServletException, IOException {
final String token = resolveToken(request);
-
if (token != null) {
try {
final Authentication authentication = jwtProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
- } catch (ServerException e) {
+ } catch (final ServerException e) {
final boolean isPermissivePath = permissivePaths.stream().anyMatch(
- pattern -> pathMatcher.match(pattern, request.getServletPath()));
+ pattern -> pathMatcher.match(pattern, request.getServletPath())
+ );
- if (e.getErrorResult() != ServerErrorResult.EXPIRED_TOKEN || !isPermissivePath) {
+ if (e.getErrorResult() != AuthErrorResult.EXPIRED_TOKEN || !isPermissivePath) {
errorResponseWriter.sendErrorResponse(response, e.getErrorResult());
return;
}
@@ -52,11 +54,12 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
filterChain.doFilter(request, response);
}
- private String resolveToken(HttpServletRequest request) {
+ private String resolveToken(final HttpServletRequest request) {
final String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
+
return null;
}
diff --git a/src/main/java/com/und/server/security/SecurityErrorResponseWriter.java b/src/main/java/com/und/server/auth/filter/SecurityErrorResponseWriter.java
similarity index 71%
rename from src/main/java/com/und/server/security/SecurityErrorResponseWriter.java
rename to src/main/java/com/und/server/auth/filter/SecurityErrorResponseWriter.java
index c0af1d3d..47a5829c 100644
--- a/src/main/java/com/und/server/security/SecurityErrorResponseWriter.java
+++ b/src/main/java/com/und/server/auth/filter/SecurityErrorResponseWriter.java
@@ -1,4 +1,4 @@
-package com.und.server.security;
+package com.und.server.auth.filter;
import java.io.IOException;
@@ -6,8 +6,8 @@
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.databind.ObjectMapper;
-import com.und.server.dto.ErrorResponse;
-import com.und.server.exception.ServerErrorResult;
+import com.und.server.common.dto.response.ErrorResponse;
+import com.und.server.common.exception.ErrorResult;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
@@ -18,7 +18,10 @@ public class SecurityErrorResponseWriter {
private final ObjectMapper objectMapper;
- public void sendErrorResponse(HttpServletResponse response, ServerErrorResult errorResult) throws IOException {
+ public void sendErrorResponse(
+ final HttpServletResponse response,
+ final ErrorResult errorResult
+ ) throws IOException {
response.setStatus(errorResult.getHttpStatus().value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
diff --git a/src/main/java/com/und/server/jwt/JwtProperties.java b/src/main/java/com/und/server/auth/jwt/JwtProperties.java
similarity index 92%
rename from src/main/java/com/und/server/jwt/JwtProperties.java
rename to src/main/java/com/und/server/auth/jwt/JwtProperties.java
index 38bf8eac..cba68bcd 100644
--- a/src/main/java/com/und/server/jwt/JwtProperties.java
+++ b/src/main/java/com/und/server/auth/jwt/JwtProperties.java
@@ -1,4 +1,4 @@
-package com.und.server.jwt;
+package com.und.server.auth.jwt;
import javax.crypto.SecretKey;
diff --git a/src/main/java/com/und/server/auth/jwt/JwtProvider.java b/src/main/java/com/und/server/auth/jwt/JwtProvider.java
new file mode 100644
index 00000000..20ee0f42
--- /dev/null
+++ b/src/main/java/com/und/server/auth/jwt/JwtProvider.java
@@ -0,0 +1,181 @@
+package com.und.server.auth.jwt;
+
+import java.nio.charset.StandardCharsets;
+import java.security.PublicKey;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.stereotype.Component;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.und.server.auth.exception.AuthErrorResult;
+import com.und.server.common.exception.ServerException;
+import com.und.server.common.util.ProfileManager;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.ExpiredJwtException;
+import io.jsonwebtoken.JwtException;
+import io.jsonwebtoken.JwtParserBuilder;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.MalformedJwtException;
+import io.jsonwebtoken.UnsupportedJwtException;
+import io.jsonwebtoken.io.Decoders;
+import io.jsonwebtoken.security.SignatureException;
+import io.jsonwebtoken.security.WeakKeyException;
+import lombok.RequiredArgsConstructor;
+
+@Component
+@RequiredArgsConstructor
+public class JwtProvider {
+
+ private final JwtProperties jwtProperties;
+ private final ProfileManager profileManager;
+
+ public Map getDecodedHeader(final String token) {
+ try {
+ final String decodedHeader = decodeBase64UrlPart(token.split("\\.")[0]);
+ return new ObjectMapper().readValue(decodedHeader, new TypeReference<>() { });
+ } catch (final Exception e) {
+ throw new ServerException(AuthErrorResult.INVALID_TOKEN, e);
+ }
+ }
+
+ public String extractNonce(final String idToken) {
+ try {
+ final String payloadJson = decodeBase64UrlPart(idToken.split("\\.")[1]);
+ final Map claims = new ObjectMapper().readValue(payloadJson, new TypeReference<>() { });
+ return (String) claims.get("nonce");
+ } catch (final Exception e) {
+ throw new ServerException(AuthErrorResult.INVALID_TOKEN, e);
+ }
+ }
+
+ public String parseOidcIdToken(
+ final String token,
+ final String iss,
+ final String aud,
+ final PublicKey publicKey
+ ) {
+ final JwtParserBuilder builder = Jwts.parser()
+ .verifyWith(publicKey)
+ .requireIssuer(iss)
+ .requireAudience(aud);
+ final Claims claims = parseClaims(token, builder);
+
+ return getValidSubject(claims);
+ }
+
+ public String generateAccessToken(final Long memberId) {
+ final LocalDateTime now = LocalDateTime.now();
+ final Date issuedAt = toDate(now);
+ final Date expiration = toDate(now.plusSeconds(jwtProperties.accessTokenExpireTime()));
+
+ return Jwts.builder()
+ .subject(memberId.toString())
+ .issuer(jwtProperties.issuer())
+ .issuedAt(issuedAt)
+ .expiration(expiration)
+ .signWith(jwtProperties.secretKey())
+ .compact();
+ }
+
+ public Authentication getAuthentication(final String token) {
+ final Long memberId = getMemberIdFromToken(token);
+ final List authorities = Collections.emptyList();
+
+ return new UsernamePasswordAuthenticationToken(memberId, token, authorities);
+ }
+
+ public Long getMemberIdFromToken(final String token) {
+ final Claims claims = parseClaims(token, getAccessTokenParserBuilder());
+ return getMemberIdFromClaims(claims);
+ }
+
+ private Claims parseClaims(final String token, final JwtParserBuilder builder) {
+ try {
+ return parseToken(token, builder);
+ } catch (final ExpiredJwtException e) {
+ throw new ServerException(AuthErrorResult.EXPIRED_TOKEN, e);
+ }
+ }
+
+ public ParsedTokenInfo parseTokenForReissue(final String token) {
+ try {
+ final Claims claims = parseToken(token, getAccessTokenParserBuilder());
+ return new ParsedTokenInfo(getMemberIdFromClaims(claims), false);
+ } catch (final ExpiredJwtException e) {
+ // If the token is expired, we can still extract the member ID.
+ return new ParsedTokenInfo(getMemberIdFromClaims(e.getClaims()), true);
+ }
+ }
+
+ private Claims parseToken(final String token, final JwtParserBuilder builder) {
+ try {
+ return builder.build()
+ .parseSignedClaims(token)
+ .getPayload();
+ } catch (final ExpiredJwtException e) {
+ // This must be re-thrown for parseTokenForReissue to work correctly.
+ throw e;
+ } catch (final JwtException e) {
+ // For prod or stg environments, return a generic error to avoid leaking details.
+ if (profileManager.isProdOrStgProfile()) {
+ throw new ServerException(AuthErrorResult.UNAUTHORIZED_ACCESS, e);
+ }
+
+ // For non-production environments, provide detailed error messages.
+ if (e instanceof MalformedJwtException) {
+ throw new ServerException(AuthErrorResult.MALFORMED_TOKEN, e);
+ } else if (e instanceof UnsupportedJwtException) {
+ throw new ServerException(AuthErrorResult.UNSUPPORTED_TOKEN, e);
+ } else if (e instanceof WeakKeyException) {
+ throw new ServerException(AuthErrorResult.WEAK_TOKEN_KEY, e);
+ } else if (e instanceof SignatureException) {
+ throw new ServerException(AuthErrorResult.INVALID_TOKEN_SIGNATURE, e);
+ } else {
+ // Fallback for any other JWT-related exceptions.
+ throw new ServerException(AuthErrorResult.INVALID_TOKEN, e);
+ }
+ }
+ }
+
+ private JwtParserBuilder getAccessTokenParserBuilder() {
+ return Jwts.parser()
+ .verifyWith(jwtProperties.secretKey());
+ }
+
+ private String decodeBase64UrlPart(final String encodedPart) {
+ return new String(Decoders.BASE64URL.decode(encodedPart), StandardCharsets.UTF_8);
+ }
+
+ private Date toDate(final LocalDateTime dateTime) {
+ return Date.from(dateTime.atZone(ZoneId.systemDefault()).toInstant());
+ }
+
+ private Long getMemberIdFromClaims(final Claims claims) {
+ final String subject = getValidSubject(claims);
+ try {
+ return Long.valueOf(subject);
+ } catch (final NumberFormatException e) {
+ // The subject was not a valid Long, which is unexpected for our tokens.
+ throw new ServerException(AuthErrorResult.INVALID_TOKEN, e);
+ }
+ }
+
+ private String getValidSubject(final Claims claims) {
+ final String subject = claims.getSubject();
+ if (subject == null) {
+ throw new ServerException(AuthErrorResult.INVALID_TOKEN);
+ }
+ return subject;
+ }
+
+}
diff --git a/src/main/java/com/und/server/auth/jwt/ParsedTokenInfo.java b/src/main/java/com/und/server/auth/jwt/ParsedTokenInfo.java
new file mode 100644
index 00000000..b141d1bd
--- /dev/null
+++ b/src/main/java/com/und/server/auth/jwt/ParsedTokenInfo.java
@@ -0,0 +1,6 @@
+package com.und.server.auth.jwt;
+
+public record ParsedTokenInfo(
+ Long memberId,
+ boolean isExpired
+) { }
diff --git a/src/main/java/com/und/server/auth/oauth/AppleClient.java b/src/main/java/com/und/server/auth/oauth/AppleClient.java
new file mode 100644
index 00000000..e7e6d77a
--- /dev/null
+++ b/src/main/java/com/und/server/auth/oauth/AppleClient.java
@@ -0,0 +1,17 @@
+package com.und.server.auth.oauth;
+
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.web.bind.annotation.GetMapping;
+
+import com.und.server.auth.dto.OidcPublicKeys;
+
+@FeignClient(name = "AppleClient", url = "${oauth.apple.base-url}")
+public interface AppleClient extends OidcClient {
+
+ @Override
+ @Cacheable(cacheNames = "OidcApple", cacheManager = "oidcCacheManager")
+ @GetMapping("${oauth.apple.public-key-url}")
+ OidcPublicKeys getOidcPublicKeys();
+
+}
diff --git a/src/main/java/com/und/server/auth/oauth/AppleProvider.java b/src/main/java/com/und/server/auth/oauth/AppleProvider.java
new file mode 100644
index 00000000..94a38349
--- /dev/null
+++ b/src/main/java/com/und/server/auth/oauth/AppleProvider.java
@@ -0,0 +1,45 @@
+package com.und.server.auth.oauth;
+
+import java.security.PublicKey;
+import java.util.Map;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import com.und.server.auth.dto.OidcPublicKeys;
+import com.und.server.auth.jwt.JwtProvider;
+
+@Component
+public class AppleProvider implements OidcProvider {
+
+ private final JwtProvider jwtProvider;
+ private final PublicKeyProvider publicKeyProvider;
+ private final String appleBaseUrl;
+ private final String appleAppId;
+
+ public AppleProvider(
+ final JwtProvider jwtProvider,
+ final PublicKeyProvider publicKeyProvider,
+ @Value("${oauth.apple.base-url}") final String appleBaseUrl,
+ @Value("${oauth.apple.app-id}") final String appleAppId
+ ) {
+ this.jwtProvider = jwtProvider;
+ this.publicKeyProvider = publicKeyProvider;
+ this.appleBaseUrl = appleBaseUrl;
+ this.appleAppId = appleAppId;
+ }
+
+ @Override
+ public String getProviderId(final String token, final OidcPublicKeys oidcPublicKeys) {
+ final Map decodedHeader = jwtProvider.getDecodedHeader(token);
+ final PublicKey publicKey = publicKeyProvider.generatePublicKey(decodedHeader, oidcPublicKeys);
+
+ return jwtProvider.parseOidcIdToken(
+ token,
+ appleBaseUrl,
+ appleAppId,
+ publicKey
+ );
+ }
+
+}
diff --git a/src/main/java/com/und/server/oauth/KakaoClient.java b/src/main/java/com/und/server/auth/oauth/KakaoClient.java
similarity index 84%
rename from src/main/java/com/und/server/oauth/KakaoClient.java
rename to src/main/java/com/und/server/auth/oauth/KakaoClient.java
index 47892227..04fa05be 100644
--- a/src/main/java/com/und/server/oauth/KakaoClient.java
+++ b/src/main/java/com/und/server/auth/oauth/KakaoClient.java
@@ -1,10 +1,10 @@
-package com.und.server.oauth;
+package com.und.server.auth.oauth;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
-import com.und.server.dto.OidcPublicKeys;
+import com.und.server.auth.dto.OidcPublicKeys;
@FeignClient(name = "KakaoClient", url = "${oauth.kakao.base-url}")
public interface KakaoClient extends OidcClient {
diff --git a/src/main/java/com/und/server/oauth/KakaoProvider.java b/src/main/java/com/und/server/auth/oauth/KakaoProvider.java
similarity index 69%
rename from src/main/java/com/und/server/oauth/KakaoProvider.java
rename to src/main/java/com/und/server/auth/oauth/KakaoProvider.java
index 03aa673d..7fb84265 100644
--- a/src/main/java/com/und/server/oauth/KakaoProvider.java
+++ b/src/main/java/com/und/server/auth/oauth/KakaoProvider.java
@@ -1,4 +1,4 @@
-package com.und.server.oauth;
+package com.und.server.auth.oauth;
import java.security.PublicKey;
import java.util.Map;
@@ -6,8 +6,8 @@
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
-import com.und.server.dto.OidcPublicKeys;
-import com.und.server.jwt.JwtProvider;
+import com.und.server.auth.dto.OidcPublicKeys;
+import com.und.server.auth.jwt.JwtProvider;
@Component
public class KakaoProvider implements OidcProvider {
@@ -18,10 +18,10 @@ public class KakaoProvider implements OidcProvider {
private final String kakaoAppKey;
public KakaoProvider(
- JwtProvider jwtProvider,
- PublicKeyProvider publicKeyProvider,
- @Value("${oauth.kakao.base-url}") String kakaoBaseUrl,
- @Value("${oauth.kakao.app-key}") String kakaoAppKey
+ final JwtProvider jwtProvider,
+ final PublicKeyProvider publicKeyProvider,
+ @Value("${oauth.kakao.base-url}") final String kakaoBaseUrl,
+ @Value("${oauth.kakao.app-key}") final String kakaoAppKey
) {
this.jwtProvider = jwtProvider;
this.publicKeyProvider = publicKeyProvider;
@@ -30,7 +30,7 @@ public KakaoProvider(
}
@Override
- public IdTokenPayload getIdTokenPayload(String token, OidcPublicKeys oidcPublicKeys) {
+ public String getProviderId(final String token, final OidcPublicKeys oidcPublicKeys) {
final Map decodedHeader = jwtProvider.getDecodedHeader(token);
final PublicKey publicKey = publicKeyProvider.generatePublicKey(decodedHeader, oidcPublicKeys);
diff --git a/src/main/java/com/und/server/auth/oauth/OidcClient.java b/src/main/java/com/und/server/auth/oauth/OidcClient.java
new file mode 100644
index 00000000..508b8129
--- /dev/null
+++ b/src/main/java/com/und/server/auth/oauth/OidcClient.java
@@ -0,0 +1,9 @@
+package com.und.server.auth.oauth;
+
+import com.und.server.auth.dto.OidcPublicKeys;
+
+public interface OidcClient {
+
+ OidcPublicKeys getOidcPublicKeys();
+
+}
diff --git a/src/main/java/com/und/server/auth/oauth/OidcClientFactory.java b/src/main/java/com/und/server/auth/oauth/OidcClientFactory.java
new file mode 100644
index 00000000..73a67905
--- /dev/null
+++ b/src/main/java/com/und/server/auth/oauth/OidcClientFactory.java
@@ -0,0 +1,31 @@
+package com.und.server.auth.oauth;
+
+import java.util.EnumMap;
+import java.util.Map;
+import java.util.Optional;
+
+import org.springframework.stereotype.Component;
+
+import com.und.server.auth.exception.AuthErrorResult;
+import com.und.server.common.exception.ServerException;
+
+@Component
+public class OidcClientFactory {
+
+ private final Map oidcClients;
+
+ public OidcClientFactory(
+ final KakaoClient kakaoClient,
+ final AppleClient appleClient
+ ) {
+ oidcClients = new EnumMap<>(Provider.class);
+ oidcClients.put(Provider.KAKAO, kakaoClient);
+ oidcClients.put(Provider.APPLE, appleClient);
+ }
+
+ public OidcClient getOidcClient(final Provider provider) {
+ return Optional.ofNullable(oidcClients.get(provider))
+ .orElseThrow(() -> new ServerException(AuthErrorResult.INVALID_PROVIDER));
+ }
+
+}
diff --git a/src/main/java/com/und/server/auth/oauth/OidcProvider.java b/src/main/java/com/und/server/auth/oauth/OidcProvider.java
new file mode 100644
index 00000000..86a376fd
--- /dev/null
+++ b/src/main/java/com/und/server/auth/oauth/OidcProvider.java
@@ -0,0 +1,9 @@
+package com.und.server.auth.oauth;
+
+import com.und.server.auth.dto.OidcPublicKeys;
+
+public interface OidcProvider {
+
+ String getProviderId(final String token, final OidcPublicKeys oidcPublicKeys);
+
+}
diff --git a/src/main/java/com/und/server/auth/oauth/OidcProviderFactory.java b/src/main/java/com/und/server/auth/oauth/OidcProviderFactory.java
new file mode 100644
index 00000000..a2a8f7cf
--- /dev/null
+++ b/src/main/java/com/und/server/auth/oauth/OidcProviderFactory.java
@@ -0,0 +1,40 @@
+package com.und.server.auth.oauth;
+
+import java.util.EnumMap;
+import java.util.Map;
+import java.util.Optional;
+
+import org.springframework.stereotype.Component;
+
+import com.und.server.auth.dto.OidcPublicKeys;
+import com.und.server.auth.exception.AuthErrorResult;
+import com.und.server.common.exception.ServerException;
+
+@Component
+public class OidcProviderFactory {
+
+ private final Map oidcProviders;
+
+ public OidcProviderFactory(
+ final KakaoProvider kakaoProvider,
+ final AppleProvider appleProvider
+ ) {
+ this.oidcProviders = new EnumMap<>(Provider.class);
+ oidcProviders.put(Provider.KAKAO, kakaoProvider);
+ oidcProviders.put(Provider.APPLE, appleProvider);
+ }
+
+ public String getProviderId(
+ final Provider provider,
+ final String token,
+ final OidcPublicKeys oidcPublicKeys
+ ) {
+ return getOidcProvider(provider).getProviderId(token, oidcPublicKeys);
+ }
+
+ private OidcProvider getOidcProvider(final Provider provider) {
+ return Optional.ofNullable(oidcProviders.get(provider))
+ .orElseThrow(() -> new ServerException(AuthErrorResult.INVALID_PROVIDER));
+ }
+
+}
diff --git a/src/main/java/com/und/server/oauth/Provider.java b/src/main/java/com/und/server/auth/oauth/Provider.java
similarity index 68%
rename from src/main/java/com/und/server/oauth/Provider.java
rename to src/main/java/com/und/server/auth/oauth/Provider.java
index 9da19f45..7e14b5cf 100644
--- a/src/main/java/com/und/server/oauth/Provider.java
+++ b/src/main/java/com/und/server/auth/oauth/Provider.java
@@ -1,4 +1,4 @@
-package com.und.server.oauth;
+package com.und.server.auth.oauth;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@@ -7,7 +7,8 @@
@RequiredArgsConstructor
public enum Provider {
- KAKAO("kakao");
+ KAKAO("kakao"),
+ APPLE("apple");
private final String name;
diff --git a/src/main/java/com/und/server/oauth/PublicKeyProvider.java b/src/main/java/com/und/server/auth/oauth/PublicKeyProvider.java
similarity index 57%
rename from src/main/java/com/und/server/oauth/PublicKeyProvider.java
rename to src/main/java/com/und/server/auth/oauth/PublicKeyProvider.java
index 256e9c46..b5bf5e06 100644
--- a/src/main/java/com/und/server/oauth/PublicKeyProvider.java
+++ b/src/main/java/com/und/server/auth/oauth/PublicKeyProvider.java
@@ -1,4 +1,4 @@
-package com.und.server.oauth;
+package com.und.server.auth.oauth;
import java.math.BigInteger;
import java.security.KeyFactory;
@@ -11,15 +11,15 @@
import org.springframework.stereotype.Component;
-import com.und.server.dto.OidcPublicKey;
-import com.und.server.dto.OidcPublicKeys;
-import com.und.server.exception.ServerErrorResult;
-import com.und.server.exception.ServerException;
+import com.und.server.auth.dto.OidcPublicKey;
+import com.und.server.auth.dto.OidcPublicKeys;
+import com.und.server.auth.exception.AuthErrorResult;
+import com.und.server.common.exception.ServerException;
@Component
public class PublicKeyProvider {
- public PublicKey generatePublicKey(Map decodedHeader, OidcPublicKeys oidcPublicKeys) {
+ public PublicKey generatePublicKey(final Map decodedHeader, final OidcPublicKeys oidcPublicKeys) {
final OidcPublicKey matchingKey = oidcPublicKeys
.matchingKey(decodedHeader.get("kid"), decodedHeader.get("alg"));
@@ -28,16 +28,16 @@ public PublicKey generatePublicKey(Map decodedHeader, OidcPublic
private PublicKey getPublicKey(final OidcPublicKey matchingKey) {
try {
- byte[] modulusBytes = Base64.getUrlDecoder().decode(matchingKey.n());
- byte[] exponentBytes = Base64.getUrlDecoder().decode(matchingKey.e());
+ final byte[] modulusBytes = Base64.getUrlDecoder().decode(matchingKey.n());
+ final byte[] exponentBytes = Base64.getUrlDecoder().decode(matchingKey.e());
final BigInteger modulus = new BigInteger(1, modulusBytes);
final BigInteger exponent = new BigInteger(1, exponentBytes);
final RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(modulus, exponent);
return KeyFactory.getInstance(matchingKey.kty()).generatePublic(publicKeySpec);
- } catch (IllegalArgumentException | NoSuchAlgorithmException | InvalidKeySpecException e) {
- throw new ServerException(ServerErrorResult.INVALID_PUBLIC_KEY, e);
+ } catch (final IllegalArgumentException | NoSuchAlgorithmException | InvalidKeySpecException e) {
+ throw new ServerException(AuthErrorResult.INVALID_PUBLIC_KEY, e);
}
}
diff --git a/src/main/java/com/und/server/repository/NonceRepository.java b/src/main/java/com/und/server/auth/repository/NonceRepository.java
similarity index 71%
rename from src/main/java/com/und/server/repository/NonceRepository.java
rename to src/main/java/com/und/server/auth/repository/NonceRepository.java
index dc13cde0..59ccf35c 100644
--- a/src/main/java/com/und/server/repository/NonceRepository.java
+++ b/src/main/java/com/und/server/auth/repository/NonceRepository.java
@@ -1,9 +1,9 @@
-package com.und.server.repository;
+package com.und.server.auth.repository;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
-import com.und.server.entity.Nonce;
+import com.und.server.auth.entity.Nonce;
@Repository
public interface NonceRepository extends CrudRepository { }
diff --git a/src/main/java/com/und/server/repository/RefreshTokenRepository.java b/src/main/java/com/und/server/auth/repository/RefreshTokenRepository.java
similarity index 70%
rename from src/main/java/com/und/server/repository/RefreshTokenRepository.java
rename to src/main/java/com/und/server/auth/repository/RefreshTokenRepository.java
index 209237f7..e2d19341 100644
--- a/src/main/java/com/und/server/repository/RefreshTokenRepository.java
+++ b/src/main/java/com/und/server/auth/repository/RefreshTokenRepository.java
@@ -1,9 +1,9 @@
-package com.und.server.repository;
+package com.und.server.auth.repository;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
-import com.und.server.entity.RefreshToken;
+import com.und.server.auth.entity.RefreshToken;
@Repository
public interface RefreshTokenRepository extends CrudRepository { }
diff --git a/src/main/java/com/und/server/auth/service/AuthService.java b/src/main/java/com/und/server/auth/service/AuthService.java
new file mode 100644
index 00000000..d268448e
--- /dev/null
+++ b/src/main/java/com/und/server/auth/service/AuthService.java
@@ -0,0 +1,153 @@
+package com.und.server.auth.service;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.und.server.auth.dto.OidcPublicKeys;
+import com.und.server.auth.dto.request.AuthRequest;
+import com.und.server.auth.dto.request.NonceRequest;
+import com.und.server.auth.dto.request.RefreshTokenRequest;
+import com.und.server.auth.dto.response.AuthResponse;
+import com.und.server.auth.dto.response.NonceResponse;
+import com.und.server.auth.exception.AuthErrorResult;
+import com.und.server.auth.jwt.JwtProperties;
+import com.und.server.auth.jwt.JwtProvider;
+import com.und.server.auth.jwt.ParsedTokenInfo;
+import com.und.server.auth.oauth.OidcClient;
+import com.und.server.auth.oauth.OidcClientFactory;
+import com.und.server.auth.oauth.OidcProviderFactory;
+import com.und.server.auth.oauth.Provider;
+import com.und.server.common.dto.request.TestAuthRequest;
+import com.und.server.common.exception.ServerException;
+import com.und.server.common.util.ProfileManager;
+import com.und.server.member.entity.Member;
+import com.und.server.member.exception.MemberErrorResult;
+import com.und.server.member.service.MemberService;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class AuthService {
+
+ private final MemberService memberService;
+ private final OidcClientFactory oidcClientFactory;
+ private final OidcProviderFactory oidcProviderFactory;
+ private final JwtProvider jwtProvider;
+ private final JwtProperties jwtProperties;
+ private final NonceService nonceService;
+ private final RefreshTokenService refreshTokenService;
+ private final ProfileManager profileManager;
+
+ @Transactional
+ public AuthResponse issueTokensForTest(final TestAuthRequest request) {
+ final Provider provider = convertToProvider(request.provider());
+ final String providerId = request.providerId();
+ final Member member = memberService.findOrCreateMember(provider, providerId);
+
+ return issueTokens(member.getId());
+ }
+
+ @Transactional
+ public NonceResponse generateNonce(final NonceRequest nonceRequest) {
+ final String nonce = nonceService.generateNonceValue();
+ final Provider provider = convertToProvider(nonceRequest.provider());
+
+ nonceService.saveNonce(nonce, provider);
+
+ return new NonceResponse(nonce);
+ }
+
+ @Transactional
+ public AuthResponse login(final AuthRequest authRequest) {
+ final Provider provider = convertToProvider(authRequest.provider());
+ final String idToken = authRequest.idToken();
+
+ verifyIdTokenNonce(provider, idToken);
+ final String providerId = getProviderIdFromIdToken(provider, idToken);
+ final Member member = memberService.findOrCreateMember(provider, providerId);
+
+ return issueTokens(member.getId());
+ }
+
+ @Transactional
+ public AuthResponse reissueTokens(final RefreshTokenRequest refreshTokenRequest) {
+ final String accessToken = refreshTokenRequest.accessToken();
+ final String providedRefreshToken = refreshTokenRequest.refreshToken();
+
+ final Long memberId = getMemberIdForReissue(accessToken);
+
+ try {
+ memberService.checkMemberExists(memberId);
+ } catch (final ServerException e) {
+ if (e.getErrorResult() == MemberErrorResult.MEMBER_NOT_FOUND) {
+ // The member ID is not null, but the member doesn't exist.
+ // This is a security concern, so delete the orphaned refresh token.
+ refreshTokenService.deleteRefreshToken(memberId);
+ }
+ // For both MEMBER_NOT_FOUND and INVALID_MEMBER_ID, treat it as an invalid token situation.
+ throw new ServerException(AuthErrorResult.INVALID_TOKEN, e);
+ }
+
+ refreshTokenService.verifyRefreshToken(memberId, providedRefreshToken);
+
+ return issueTokens(memberId);
+ }
+
+ @Transactional
+ public void logout(final Long memberId) {
+ refreshTokenService.deleteRefreshToken(memberId);
+ }
+
+ private Provider convertToProvider(final String providerName) {
+ try {
+ return Provider.valueOf(providerName.toUpperCase());
+ } catch (final IllegalArgumentException e) {
+ throw new ServerException(AuthErrorResult.INVALID_PROVIDER);
+ }
+ }
+
+ private void verifyIdTokenNonce(final Provider provider, final String idToken) {
+ final String nonce = jwtProvider.extractNonce(idToken);
+ nonceService.verifyNonce(nonce, provider);
+ }
+
+ private String getProviderIdFromIdToken(final Provider provider, final String idToken) {
+ final OidcClient oidcClient = oidcClientFactory.getOidcClient(provider);
+ final OidcPublicKeys oidcPublicKeys = oidcClient.getOidcPublicKeys();
+
+ return oidcProviderFactory.getProviderId(provider, idToken, oidcPublicKeys);
+ }
+
+ private AuthResponse issueTokens(final Long memberId) {
+ final String accessToken = jwtProvider.generateAccessToken(memberId);
+ final String refreshToken = refreshTokenService.generateRefreshToken();
+ refreshTokenService.saveRefreshToken(memberId, refreshToken);
+
+ return new AuthResponse(
+ jwtProperties.type(),
+ accessToken,
+ jwtProperties.accessTokenExpireTime(),
+ refreshToken,
+ jwtProperties.refreshTokenExpireTime());
+ }
+
+ private Long getMemberIdForReissue(final String accessToken) {
+ final ParsedTokenInfo tokenInfo = jwtProvider.parseTokenForReissue(accessToken);
+ final Long memberId = tokenInfo.memberId();
+
+ if (!tokenInfo.isExpired()) {
+ // An attempt to reissue with a non-expired token may be a security risk.
+ // For security, delete the refresh token.
+ refreshTokenService.deleteRefreshToken(memberId);
+ if (profileManager.isProdOrStgProfile()) {
+ throw new ServerException(AuthErrorResult.INVALID_TOKEN);
+ }
+ throw new ServerException(AuthErrorResult.NOT_EXPIRED_TOKEN);
+ }
+
+ return memberId;
+ }
+
+}
diff --git a/src/main/java/com/und/server/auth/service/NonceService.java b/src/main/java/com/und/server/auth/service/NonceService.java
new file mode 100644
index 00000000..7cfd44d6
--- /dev/null
+++ b/src/main/java/com/und/server/auth/service/NonceService.java
@@ -0,0 +1,64 @@
+package com.und.server.auth.service;
+
+import java.util.UUID;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.und.server.auth.entity.Nonce;
+import com.und.server.auth.exception.AuthErrorResult;
+import com.und.server.auth.oauth.Provider;
+import com.und.server.auth.repository.NonceRepository;
+import com.und.server.common.exception.ServerException;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class NonceService {
+
+ private final NonceRepository nonceRepository;
+
+ public String generateNonceValue() {
+ return UUID.randomUUID().toString();
+ }
+
+ @Transactional
+ public void verifyNonce(final String value, final Provider provider) {
+ validateNonceValue(value);
+ validateProvider(provider);
+
+ nonceRepository.findById(value)
+ .filter(n -> n.getProvider() == provider)
+ .orElseThrow(() -> new ServerException(AuthErrorResult.INVALID_NONCE));
+
+ nonceRepository.deleteById(value);
+ }
+
+ @Transactional
+ public void saveNonce(final String value, final Provider provider) {
+ validateNonceValue(value);
+ validateProvider(provider);
+
+ final Nonce nonce = Nonce.builder()
+ .value(value)
+ .provider(provider)
+ .build();
+
+ nonceRepository.save(nonce);
+ }
+
+ private void validateNonceValue(final String value) {
+ if (value == null) {
+ throw new ServerException(AuthErrorResult.INVALID_NONCE);
+ }
+ }
+
+ private void validateProvider(final Provider provider) {
+ if (provider == null) {
+ throw new ServerException(AuthErrorResult.INVALID_PROVIDER);
+ }
+ }
+
+}
diff --git a/src/main/java/com/und/server/auth/service/RefreshTokenService.java b/src/main/java/com/und/server/auth/service/RefreshTokenService.java
new file mode 100644
index 00000000..24604e32
--- /dev/null
+++ b/src/main/java/com/und/server/auth/service/RefreshTokenService.java
@@ -0,0 +1,73 @@
+package com.und.server.auth.service;
+
+import java.util.UUID;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.und.server.auth.entity.RefreshToken;
+import com.und.server.auth.exception.AuthErrorResult;
+import com.und.server.auth.repository.RefreshTokenRepository;
+import com.und.server.common.exception.ServerException;
+import com.und.server.member.exception.MemberErrorResult;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class RefreshTokenService {
+
+ private final RefreshTokenRepository refreshTokenRepository;
+
+ public String generateRefreshToken() {
+ return UUID.randomUUID().toString();
+ }
+
+ @Transactional
+ public void verifyRefreshToken(final Long memberId, final String providedToken) {
+ validateMemberIdIsNotNull(memberId);
+ validateTokenValueIsNotNull(providedToken);
+
+ refreshTokenRepository.findById(memberId)
+ .map(RefreshToken::getValue)
+ .filter(savedToken -> providedToken.equals(savedToken))
+ .orElseThrow(() -> {
+ deleteRefreshToken(memberId);
+ return new ServerException(AuthErrorResult.INVALID_TOKEN);
+ });
+ }
+
+ @Transactional
+ public void saveRefreshToken(final Long memberId, final String value) {
+ validateMemberIdIsNotNull(memberId);
+ validateTokenValueIsNotNull(value);
+
+ final RefreshToken token = RefreshToken.builder()
+ .memberId(memberId)
+ .value(value)
+ .build();
+
+ refreshTokenRepository.save(token);
+ }
+
+ @Transactional
+ public void deleteRefreshToken(final Long memberId) {
+ validateMemberIdIsNotNull(memberId);
+
+ refreshTokenRepository.deleteById(memberId);
+ }
+
+ private void validateMemberIdIsNotNull(final Long memberId) {
+ if (memberId == null) {
+ throw new ServerException(MemberErrorResult.INVALID_MEMBER_ID);
+ }
+ }
+
+ private void validateTokenValueIsNotNull(final String token) {
+ if (token == null) {
+ throw new ServerException(AuthErrorResult.INVALID_TOKEN);
+ }
+ }
+
+}
diff --git a/src/main/java/com/und/server/common/config/AsyncConfig.java b/src/main/java/com/und/server/common/config/AsyncConfig.java
new file mode 100644
index 00000000..490e15a0
--- /dev/null
+++ b/src/main/java/com/und/server/common/config/AsyncConfig.java
@@ -0,0 +1,32 @@
+package com.und.server.common.config;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.ThreadPoolExecutor;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Primary;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+@Configuration
+@EnableAsync
+public class AsyncConfig {
+
+ @Bean
+ @Primary
+ public Executor taskExecutor() {
+ ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+ executor.setCorePoolSize(2);
+ executor.setMaxPoolSize(4);
+ executor.setQueueCapacity(10);
+ executor.setThreadNamePrefix("async-");
+ executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
+ executor.setKeepAliveSeconds(60);
+ executor.setWaitForTasksToCompleteOnShutdown(true);
+ executor.setAwaitTerminationSeconds(30);
+ executor.initialize();
+ return executor;
+ }
+
+}
diff --git a/src/main/java/com/und/server/config/RedisConfig.java b/src/main/java/com/und/server/common/config/RedisConfig.java
similarity index 65%
rename from src/main/java/com/und/server/config/RedisConfig.java
rename to src/main/java/com/und/server/common/config/RedisConfig.java
index 58f15971..0ab297f2 100644
--- a/src/main/java/com/und/server/config/RedisConfig.java
+++ b/src/main/java/com/und/server/common/config/RedisConfig.java
@@ -1,4 +1,4 @@
-package com.und.server.config;
+package com.und.server.common.config;
import java.time.Duration;
@@ -10,6 +10,7 @@
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisKeyValueAdapter;
+import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
@@ -24,7 +25,7 @@
public class RedisConfig {
@Bean
- public CacheManager oidcCacheManager(RedisConnectionFactory redisConnectionFactory) {
+ public CacheManager oidcCacheManager(final RedisConnectionFactory redisConnectionFactory) {
final RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(
RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())
@@ -37,4 +38,19 @@ public CacheManager oidcCacheManager(RedisConnectionFactory redisConnectionFacto
return RedisCacheManager.builder(redisConnectionFactory).cacheDefaults(config).build();
}
+ @Bean
+ public RedisTemplate redisTemplate(final RedisConnectionFactory redisConnectionFactory) {
+ RedisTemplate redisTemplate = new RedisTemplate<>();
+ redisTemplate.setConnectionFactory(redisConnectionFactory);
+
+ redisTemplate.setKeySerializer(new StringRedisSerializer());
+ redisTemplate.setHashKeySerializer(new StringRedisSerializer());
+
+ redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
+ redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
+
+ redisTemplate.afterPropertiesSet();
+ return redisTemplate;
+ }
+
}
diff --git a/src/main/java/com/und/server/config/SwaggerConfig.java b/src/main/java/com/und/server/common/config/SwaggerConfig.java
similarity index 97%
rename from src/main/java/com/und/server/config/SwaggerConfig.java
rename to src/main/java/com/und/server/common/config/SwaggerConfig.java
index 5736849d..ee232a35 100644
--- a/src/main/java/com/und/server/config/SwaggerConfig.java
+++ b/src/main/java/com/und/server/common/config/SwaggerConfig.java
@@ -1,4 +1,4 @@
-package com.und.server.config;
+package com.und.server.common.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
diff --git a/src/main/java/com/und/server/common/config/TimeConfig.java b/src/main/java/com/und/server/common/config/TimeConfig.java
new file mode 100644
index 00000000..c3772226
--- /dev/null
+++ b/src/main/java/com/und/server/common/config/TimeConfig.java
@@ -0,0 +1,16 @@
+package com.und.server.common.config;
+
+import java.time.Clock;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class TimeConfig {
+
+ @Bean
+ public Clock clock() {
+ return Clock.systemUTC();
+ }
+
+}
diff --git a/src/main/java/com/und/server/common/config/WebMvcConfig.java b/src/main/java/com/und/server/common/config/WebMvcConfig.java
new file mode 100644
index 00000000..bc9205fa
--- /dev/null
+++ b/src/main/java/com/und/server/common/config/WebMvcConfig.java
@@ -0,0 +1,22 @@
+package com.und.server.common.config;
+
+import java.util.List;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.method.support.HandlerMethodArgumentResolver;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+import com.und.server.auth.filter.AuthMemberArgumentResolver;
+
+import lombok.RequiredArgsConstructor;
+
+@Configuration
+@RequiredArgsConstructor
+public class WebMvcConfig implements WebMvcConfigurer {
+ private final AuthMemberArgumentResolver authMemberArgumentResolver;
+
+ @Override
+ public void addArgumentResolvers(final List resolvers) {
+ resolvers.add(authMemberArgumentResolver);
+ }
+}
diff --git a/src/main/java/com/und/server/common/controller/TestController.java b/src/main/java/com/und/server/common/controller/TestController.java
new file mode 100644
index 00000000..de8928dd
--- /dev/null
+++ b/src/main/java/com/und/server/common/controller/TestController.java
@@ -0,0 +1,72 @@
+package com.und.server.common.controller;
+
+
+import java.util.List;
+
+import org.springframework.context.annotation.Profile;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+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.RestController;
+
+import com.und.server.auth.dto.response.AuthResponse;
+import com.und.server.auth.filter.AuthMember;
+import com.und.server.auth.service.AuthService;
+import com.und.server.common.dto.request.TestAuthRequest;
+import com.und.server.common.dto.response.TestHelloResponse;
+import com.und.server.member.dto.response.MemberResponse;
+import com.und.server.member.entity.Member;
+import com.und.server.member.service.MemberService;
+import com.und.server.terms.dto.response.TermsAgreementResponse;
+import com.und.server.terms.service.TermsService;
+
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+
+// This controller is for testing/development and is disabled in prod/stg via @Profile.
+@Profile("!prod & !stg")
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/v1/test")
+public class TestController {
+
+ private final AuthService authService;
+ private final MemberService memberService;
+ private final TermsService termsService;
+
+ @PostMapping("/access")
+ @ApiResponse(responseCode = "201", description = "Access token created")
+ public ResponseEntity loginWithoutProviderId(@RequestBody @Valid final TestAuthRequest request) {
+ final AuthResponse response = authService.issueTokensForTest(request);
+ return ResponseEntity.status(HttpStatus.CREATED).body(response);
+ }
+
+ @GetMapping("/hello")
+ public ResponseEntity greet(@Parameter(hidden = true) @AuthMember final Long memberId) {
+ final Member member = memberService.findMemberById(memberId);
+ final String nickname = member.getNickname();
+ final TestHelloResponse response = new TestHelloResponse("Hello, " + nickname + "!");
+
+ return ResponseEntity.status(HttpStatus.OK).body(response);
+ }
+
+ @GetMapping("/members")
+ public ResponseEntity> getMemberList() {
+ final List members = memberService.getMemberList();
+
+ return ResponseEntity.status(HttpStatus.OK).body(members);
+ }
+
+ @GetMapping("/terms")
+ public ResponseEntity> getTermsList() {
+ final List terms = termsService.getTermsList();
+
+ return ResponseEntity.status(HttpStatus.OK).body(terms);
+ }
+
+}
diff --git a/src/main/java/com/und/server/common/dto/request/TestAuthRequest.java b/src/main/java/com/und/server/common/dto/request/TestAuthRequest.java
new file mode 100644
index 00000000..525a3f3b
--- /dev/null
+++ b/src/main/java/com/und/server/common/dto/request/TestAuthRequest.java
@@ -0,0 +1,15 @@
+package com.und.server.common.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+
+@Schema(description = "Request for issuing test tokens")
+public record TestAuthRequest(
+ @Schema(description = "OAuth provider name", example = "kakao")
+ @NotBlank(message = "Provider name must not be blank")
+ String provider,
+
+ @Schema(description = "Unique ID from the provider", example = "123456789")
+ @NotBlank(message = "Provider ID must not be blank")
+ String providerId
+) { }
diff --git a/src/main/java/com/und/server/common/dto/response/ErrorResponse.java b/src/main/java/com/und/server/common/dto/response/ErrorResponse.java
new file mode 100644
index 00000000..6b71a23a
--- /dev/null
+++ b/src/main/java/com/und/server/common/dto/response/ErrorResponse.java
@@ -0,0 +1,12 @@
+package com.und.server.common.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "API Error Response")
+public record ErrorResponse(
+ @Schema(description = "Error Code", example = "UNAUTHORIZED_ACCESS")
+ String code,
+
+ @Schema(description = "Error Message", example = "Unauthorized Access")
+ Object message
+) { }
diff --git a/src/main/java/com/und/server/dto/TestHelloResponse.java b/src/main/java/com/und/server/common/dto/response/TestHelloResponse.java
similarity index 68%
rename from src/main/java/com/und/server/dto/TestHelloResponse.java
rename to src/main/java/com/und/server/common/dto/response/TestHelloResponse.java
index 2076026b..0bff920c 100644
--- a/src/main/java/com/und/server/dto/TestHelloResponse.java
+++ b/src/main/java/com/und/server/common/dto/response/TestHelloResponse.java
@@ -1,12 +1,9 @@
-package com.und.server.dto;
-
-import com.fasterxml.jackson.annotation.JsonProperty;
+package com.und.server.common.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "Response for the test hello endpoint")
public record TestHelloResponse(
@Schema(description = "Greeting message", example = "Hello, Chori!")
- @JsonProperty("message")
String message
) { }
diff --git a/src/main/java/com/und/server/common/entity/BaseTimeEntity.java b/src/main/java/com/und/server/common/entity/BaseTimeEntity.java
new file mode 100644
index 00000000..664bc5cf
--- /dev/null
+++ b/src/main/java/com/und/server/common/entity/BaseTimeEntity.java
@@ -0,0 +1,24 @@
+package com.und.server.common.entity;
+
+import java.time.LocalDateTime;
+
+import org.hibernate.annotations.CreationTimestamp;
+import org.hibernate.annotations.UpdateTimestamp;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.MappedSuperclass;
+import lombok.Getter;
+
+@Getter
+@MappedSuperclass
+public class BaseTimeEntity {
+
+ @CreationTimestamp
+ @Column(nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+
+ @UpdateTimestamp
+ @Column(nullable = false)
+ private LocalDateTime updatedAt;
+
+}
diff --git a/src/main/java/com/und/server/common/exception/CommonErrorResult.java b/src/main/java/com/und/server/common/exception/CommonErrorResult.java
new file mode 100644
index 00000000..1fa032fa
--- /dev/null
+++ b/src/main/java/com/und/server/common/exception/CommonErrorResult.java
@@ -0,0 +1,19 @@
+package com.und.server.common.exception;
+
+import org.springframework.http.HttpStatus;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public enum CommonErrorResult implements ErrorResult {
+
+ INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "Invalid Parameter"),
+ DATA_INTEGRITY_VIOLATION(HttpStatus.CONFLICT, "Data Integrity Violation"),
+ UNKNOWN_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "Unknown Exception");
+
+ private final HttpStatus httpStatus;
+ private final String message;
+
+}
diff --git a/src/main/java/com/und/server/common/exception/ErrorResult.java b/src/main/java/com/und/server/common/exception/ErrorResult.java
new file mode 100644
index 00000000..d3d03a3b
--- /dev/null
+++ b/src/main/java/com/und/server/common/exception/ErrorResult.java
@@ -0,0 +1,15 @@
+package com.und.server.common.exception;
+
+import java.io.Serializable;
+
+import org.springframework.http.HttpStatus;
+
+public interface ErrorResult extends Serializable {
+
+ String name();
+
+ HttpStatus getHttpStatus();
+
+ String getMessage();
+
+}
diff --git a/src/main/java/com/und/server/exception/GlobalExceptionHandler.java b/src/main/java/com/und/server/common/exception/GlobalExceptionHandler.java
similarity index 54%
rename from src/main/java/com/und/server/exception/GlobalExceptionHandler.java
rename to src/main/java/com/und/server/common/exception/GlobalExceptionHandler.java
index 15886e15..0434f5aa 100644
--- a/src/main/java/com/und/server/exception/GlobalExceptionHandler.java
+++ b/src/main/java/com/und/server/common/exception/GlobalExceptionHandler.java
@@ -1,9 +1,10 @@
-package com.und.server.exception;
+package com.und.server.common.exception;
import java.util.List;
-import java.util.stream.Collectors;
+import org.hibernate.exception.ConstraintViolationException;
import org.springframework.context.support.DefaultMessageSourceResolvable;
+import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
@@ -13,7 +14,8 @@
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
-import com.und.server.dto.ErrorResponse;
+import com.und.server.common.dto.response.ErrorResponse;
+import com.und.server.member.exception.MemberErrorResult;
import io.swagger.v3.oas.annotations.Hidden;
import lombok.extern.slf4j.Slf4j;
@@ -23,7 +25,7 @@
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
- private ResponseEntity