From 17fb43e630f71ba71436152f5f2022f95c237b72 Mon Sep 17 00:00:00 2001 From: gim-yejong Date: Thu, 14 May 2026 02:08:09 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20API=20=ED=82=A4=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=EB=B0=8F?= =?UTF-8?q?=20Flyway=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 + .../api/controller/ApiKeyController.java | 33 ++++++++ .../api/domain/apikey/ApiKey.java | 48 +++++++++++ .../api/domain/apikey/ApiKeyRepository.java | 10 +++ .../api/dto/ApiKeyCreateRequest.java | 12 +++ .../api/dto/ApiKeyResponse.java | 20 +++++ .../api/service/ApiKeyService.java | 80 +++++++++++++++++++ src/main/resources/application.yml | 5 +- src/main/resources/db/migration/V1__init.sql | 34 ++++++++ .../migration/V2__create_api_keys_table.sql | 13 +++ 10 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/tokenledgercloud/api/controller/ApiKeyController.java create mode 100644 src/main/java/com/tokenledgercloud/api/domain/apikey/ApiKey.java create mode 100644 src/main/java/com/tokenledgercloud/api/domain/apikey/ApiKeyRepository.java create mode 100644 src/main/java/com/tokenledgercloud/api/dto/ApiKeyCreateRequest.java create mode 100644 src/main/java/com/tokenledgercloud/api/dto/ApiKeyResponse.java create mode 100644 src/main/java/com/tokenledgercloud/api/service/ApiKeyService.java create mode 100644 src/main/resources/db/migration/V1__init.sql create mode 100644 src/main/resources/db/migration/V2__create_api_keys_table.sql diff --git a/build.gradle b/build.gradle index 4228a64..3e63474 100644 --- a/build.gradle +++ b/build.gradle @@ -41,6 +41,10 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5' + // Flyway + implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-mysql' + compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' diff --git a/src/main/java/com/tokenledgercloud/api/controller/ApiKeyController.java b/src/main/java/com/tokenledgercloud/api/controller/ApiKeyController.java new file mode 100644 index 0000000..995d1c5 --- /dev/null +++ b/src/main/java/com/tokenledgercloud/api/controller/ApiKeyController.java @@ -0,0 +1,33 @@ +package com.tokenledgercloud.api.controller; + +import com.tokenledgercloud.api.dto.ApiKeyCreateRequest; +import com.tokenledgercloud.api.dto.ApiKeyResponse; +import com.tokenledgercloud.api.service.ApiKeyService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/api-keys") +@RequiredArgsConstructor +public class ApiKeyController { + + private final ApiKeyService apiKeyService; + + @PostMapping + public ApiKeyResponse createApiKey(Authentication authentication, @RequestBody ApiKeyCreateRequest request) { + return apiKeyService.createApiKey(authentication, request); + } + + @GetMapping + public List getMyApiKeys(Authentication authentication) { + return apiKeyService.getMyApiKeys(authentication); + } + + @DeleteMapping("/{id}") + public void deleteApiKey(Authentication authentication, @PathVariable Long id) { + apiKeyService.deleteApiKey(authentication, id); + } +} diff --git a/src/main/java/com/tokenledgercloud/api/domain/apikey/ApiKey.java b/src/main/java/com/tokenledgercloud/api/domain/apikey/ApiKey.java new file mode 100644 index 0000000..1b688f8 --- /dev/null +++ b/src/main/java/com/tokenledgercloud/api/domain/apikey/ApiKey.java @@ -0,0 +1,48 @@ +package com.tokenledgercloud.api.domain.apikey; + +import com.tokenledgercloud.api.domain.member.Member; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "api_keys") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ApiKey { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "api_key", nullable = false, unique = true) + private String apiKey; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Column(name = "name") + private String name; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "last_used_at") + private LocalDateTime lastUsedAt; + + @Column(name = "is_active") + @Builder.Default + private boolean isActive = true; + + @PrePersist + public void prePersist() { + if (this.createdAt == null) { + this.createdAt = LocalDateTime.now(); + } + } +} diff --git a/src/main/java/com/tokenledgercloud/api/domain/apikey/ApiKeyRepository.java b/src/main/java/com/tokenledgercloud/api/domain/apikey/ApiKeyRepository.java new file mode 100644 index 0000000..42e490f --- /dev/null +++ b/src/main/java/com/tokenledgercloud/api/domain/apikey/ApiKeyRepository.java @@ -0,0 +1,10 @@ +package com.tokenledgercloud.api.domain.apikey; + +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; +import java.util.Optional; + +public interface ApiKeyRepository extends JpaRepository { + Optional findByApiKey(String apiKey); + List findByMemberId(Long memberId); +} diff --git a/src/main/java/com/tokenledgercloud/api/dto/ApiKeyCreateRequest.java b/src/main/java/com/tokenledgercloud/api/dto/ApiKeyCreateRequest.java new file mode 100644 index 0000000..ccdd579 --- /dev/null +++ b/src/main/java/com/tokenledgercloud/api/dto/ApiKeyCreateRequest.java @@ -0,0 +1,12 @@ +package com.tokenledgercloud.api.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class ApiKeyCreateRequest { + private String name; +} diff --git a/src/main/java/com/tokenledgercloud/api/dto/ApiKeyResponse.java b/src/main/java/com/tokenledgercloud/api/dto/ApiKeyResponse.java new file mode 100644 index 0000000..447f5da --- /dev/null +++ b/src/main/java/com/tokenledgercloud/api/dto/ApiKeyResponse.java @@ -0,0 +1,20 @@ +package com.tokenledgercloud.api.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ApiKeyResponse { + private Long id; + private String apiKey; + private String name; + private LocalDateTime createdAt; + private boolean isActive; +} diff --git a/src/main/java/com/tokenledgercloud/api/service/ApiKeyService.java b/src/main/java/com/tokenledgercloud/api/service/ApiKeyService.java new file mode 100644 index 0000000..dcac33a --- /dev/null +++ b/src/main/java/com/tokenledgercloud/api/service/ApiKeyService.java @@ -0,0 +1,80 @@ +package com.tokenledgercloud.api.service; + +import com.tokenledgercloud.api.domain.apikey.ApiKey; +import com.tokenledgercloud.api.domain.apikey.ApiKeyRepository; +import com.tokenledgercloud.api.domain.member.Member; +import com.tokenledgercloud.api.domain.member.MemberRepository; +import com.tokenledgercloud.api.dto.ApiKeyCreateRequest; +import com.tokenledgercloud.api.dto.ApiKeyResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ApiKeyService { + + private final ApiKeyRepository apiKeyRepository; + private final MemberRepository memberRepository; + + @Transactional + public ApiKeyResponse createApiKey(Authentication authentication, ApiKeyCreateRequest request) { + Member member = getMember(authentication); + + String generatedKey = "tk-" + UUID.randomUUID().toString().replace("-", ""); + + ApiKey apiKey = ApiKey.builder() + .apiKey(generatedKey) + .member(member) + .name(request.getName()) + .isActive(true) + .build(); + + apiKeyRepository.save(apiKey); + + return toResponse(apiKey); + } + + @Transactional(readOnly = true) + public List getMyApiKeys(Authentication authentication) { + Member member = getMember(authentication); + return apiKeyRepository.findByMemberId(member.getId()).stream() + .map(this::toResponse) + .collect(Collectors.toList()); + } + + @Transactional + public void deleteApiKey(Authentication authentication, Long apiKeyId) { + Member member = getMember(authentication); + ApiKey apiKey = apiKeyRepository.findById(apiKeyId) + .orElseThrow(() -> new IllegalArgumentException("API Key not found")); + + if (!apiKey.getMember().getId().equals(member.getId())) { + throw new IllegalArgumentException("Unauthorized to delete this API Key"); + } + + apiKeyRepository.delete(apiKey); + } + + private Member getMember(Authentication authentication) { + String identifier = authentication.getName(); + return memberRepository.findByEmail(identifier) + .or(() -> memberRepository.findByProviderId(identifier)) + .orElseThrow(() -> new IllegalStateException("Member not found")); + } + + private ApiKeyResponse toResponse(ApiKey apiKey) { + return ApiKeyResponse.builder() + .id(apiKey.getId()) + .apiKey(apiKey.getApiKey()) + .name(apiKey.getName()) + .createdAt(apiKey.getCreatedAt()) + .isActive(apiKey.isActive()) + .build(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8d42aef..4db7cd7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -57,8 +57,11 @@ spring: enabled: true # 웹 브라우저에서 DB를 볼 수 있게 해줍니다 (localhost:8080/h2-console) jpa: hibernate: - ddl-auto: update + ddl-auto: validate show-sql: true # 실행되는 SQL 쿼리를 터미널에 보여줍니다 + flyway: + enabled: true + baseline-on-migrate: true jwt: secret: ${JWT_SECRET} diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql new file mode 100644 index 0000000..2596a93 --- /dev/null +++ b/src/main/resources/db/migration/V1__init.sql @@ -0,0 +1,34 @@ +CREATE TABLE members ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) UNIQUE, + name VARCHAR(255) NOT NULL, + password VARCHAR(255), + role VARCHAR(50) NOT NULL, + provider VARCHAR(50) NOT NULL, + provider_id VARCHAR(255) +); + +CREATE TABLE usage_logs ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + event_id VARCHAR(255) NOT NULL, + idempotency_key VARCHAR(255) NOT NULL UNIQUE, + project_id BIGINT, + application_id BIGINT, + user_id BIGINT, + model_id VARCHAR(255) NOT NULL, + input_tokens BIGINT NOT NULL, + output_tokens BIGINT NOT NULL, + total_tokens BIGINT NOT NULL, + total_cost DECIMAL(18, 8) NOT NULL, + currency_code VARCHAR(10) NOT NULL, + status VARCHAR(30) NOT NULL, + started_at DATETIME NOT NULL, + finished_at DATETIME, + latency_ms BIGINT, + error_code VARCHAR(255), + error_message VARCHAR(1000), + created_at DATETIME NOT NULL +); + +CREATE INDEX idx_usage_project_started ON usage_logs (project_id, started_at); +CREATE INDEX idx_usage_model_started ON usage_logs (model_id, started_at); diff --git a/src/main/resources/db/migration/V2__create_api_keys_table.sql b/src/main/resources/db/migration/V2__create_api_keys_table.sql new file mode 100644 index 0000000..c9b008c --- /dev/null +++ b/src/main/resources/db/migration/V2__create_api_keys_table.sql @@ -0,0 +1,13 @@ +CREATE TABLE api_keys ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + api_key VARCHAR(255) NOT NULL UNIQUE, + member_id BIGINT NOT NULL, + name VARCHAR(255), + created_at DATETIME NOT NULL, + last_used_at DATETIME, + is_active BOOLEAN DEFAULT TRUE, + CONSTRAINT fk_api_keys_member FOREIGN KEY (member_id) REFERENCES members(id) +); + +CREATE INDEX idx_api_keys_member ON api_keys (member_id); +CREATE INDEX idx_api_keys_value ON api_keys (api_key); From 2d929406663dfa592ef128913643bc4e963276f0 Mon Sep 17 00:00:00 2001 From: gim-yejong Date: Mon, 18 May 2026 20:29:05 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20API=20Key=20=EB=B0=9C=EA=B8=89=20?= =?UTF-8?q?=EB=B0=8F=20X-API-KEY=20=ED=97=A4=EB=8D=94=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20=EC=9D=B8=EC=A6=9D=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 + .../api/controller/ApiKeyController.java | 9 +- .../api/domain/apikey/ApiKey.java | 7 +- .../api/domain/apikey/ApiKeyRepository.java | 3 +- .../api/dto/ApiKeyCreateRequest.java | 4 + .../api/dto/ApiKeyCreateResponse.java | 21 +++ .../api/dto/ApiKeyResponse.java | 2 +- .../api/global/config/SecurityConfig.java | 6 +- .../security/ApiKeyAuthenticationFilter.java | 59 ++++++++ .../api/global/util/HashingUtil.java | 19 +++ .../api/service/ApiKeyService.java | 60 ++++++-- src/main/resources/application.yml | 2 +- .../migration/V2__create_api_keys_table.sql | 5 +- .../api/controller/ApiKeyControllerTest.java | 93 ++++++++++++ .../api/service/ApiKeyServiceTest.java | 134 ++++++++++++++++++ 15 files changed, 409 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/tokenledgercloud/api/dto/ApiKeyCreateResponse.java create mode 100644 src/main/java/com/tokenledgercloud/api/global/security/ApiKeyAuthenticationFilter.java create mode 100644 src/main/java/com/tokenledgercloud/api/global/util/HashingUtil.java create mode 100644 src/test/java/com/tokenledgercloud/api/controller/ApiKeyControllerTest.java create mode 100644 src/test/java/com/tokenledgercloud/api/service/ApiKeyServiceTest.java diff --git a/build.gradle b/build.gradle index 3e63474..c6ab6a8 100644 --- a/build.gradle +++ b/build.gradle @@ -50,6 +50,8 @@ dependencies { runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.boot:spring-boot-test-autoconfigure' + testImplementation 'org.springframework.security:spring-security-test' testCompileOnly 'org.projectlombok:lombok' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testAnnotationProcessor 'org.projectlombok:lombok' diff --git a/src/main/java/com/tokenledgercloud/api/controller/ApiKeyController.java b/src/main/java/com/tokenledgercloud/api/controller/ApiKeyController.java index 995d1c5..0681cce 100644 --- a/src/main/java/com/tokenledgercloud/api/controller/ApiKeyController.java +++ b/src/main/java/com/tokenledgercloud/api/controller/ApiKeyController.java @@ -1,8 +1,10 @@ package com.tokenledgercloud.api.controller; import com.tokenledgercloud.api.dto.ApiKeyCreateRequest; +import com.tokenledgercloud.api.dto.ApiKeyCreateResponse; import com.tokenledgercloud.api.dto.ApiKeyResponse; import com.tokenledgercloud.api.service.ApiKeyService; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; @@ -17,7 +19,7 @@ public class ApiKeyController { private final ApiKeyService apiKeyService; @PostMapping - public ApiKeyResponse createApiKey(Authentication authentication, @RequestBody ApiKeyCreateRequest request) { + public ApiKeyCreateResponse createApiKey(Authentication authentication, @Valid @RequestBody ApiKeyCreateRequest request) { return apiKeyService.createApiKey(authentication, request); } @@ -26,6 +28,11 @@ public List getMyApiKeys(Authentication authentication) { return apiKeyService.getMyApiKeys(authentication); } + @PatchMapping("/{id}/deactivate") + public void deactivateApiKey(Authentication authentication, @PathVariable Long id) { + apiKeyService.deactivateApiKey(authentication, id); + } + @DeleteMapping("/{id}") public void deleteApiKey(Authentication authentication, @PathVariable Long id) { apiKeyService.deleteApiKey(authentication, id); diff --git a/src/main/java/com/tokenledgercloud/api/domain/apikey/ApiKey.java b/src/main/java/com/tokenledgercloud/api/domain/apikey/ApiKey.java index 1b688f8..a739f7a 100644 --- a/src/main/java/com/tokenledgercloud/api/domain/apikey/ApiKey.java +++ b/src/main/java/com/tokenledgercloud/api/domain/apikey/ApiKey.java @@ -19,8 +19,11 @@ public class ApiKey { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(name = "api_key", nullable = false, unique = true) - private String apiKey; + @Column(name = "hashed_key", nullable = false, unique = true) + private String hashedKey; + + @Column(name = "display_key", nullable = false) + private String displayKey; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) diff --git a/src/main/java/com/tokenledgercloud/api/domain/apikey/ApiKeyRepository.java b/src/main/java/com/tokenledgercloud/api/domain/apikey/ApiKeyRepository.java index 42e490f..33c666a 100644 --- a/src/main/java/com/tokenledgercloud/api/domain/apikey/ApiKeyRepository.java +++ b/src/main/java/com/tokenledgercloud/api/domain/apikey/ApiKeyRepository.java @@ -5,6 +5,7 @@ import java.util.Optional; public interface ApiKeyRepository extends JpaRepository { - Optional findByApiKey(String apiKey); + Optional findByHashedKey(String hashedKey); + Optional findByHashedKeyAndIsActiveTrue(String hashedKey); List findByMemberId(Long memberId); } diff --git a/src/main/java/com/tokenledgercloud/api/dto/ApiKeyCreateRequest.java b/src/main/java/com/tokenledgercloud/api/dto/ApiKeyCreateRequest.java index ccdd579..11c5d88 100644 --- a/src/main/java/com/tokenledgercloud/api/dto/ApiKeyCreateRequest.java +++ b/src/main/java/com/tokenledgercloud/api/dto/ApiKeyCreateRequest.java @@ -1,5 +1,7 @@ package com.tokenledgercloud.api.dto; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -8,5 +10,7 @@ @Setter @NoArgsConstructor public class ApiKeyCreateRequest { + @NotBlank(message = "API Key name is required") + @Size(max = 50, message = "API Key name must be less than 50 characters") private String name; } diff --git a/src/main/java/com/tokenledgercloud/api/dto/ApiKeyCreateResponse.java b/src/main/java/com/tokenledgercloud/api/dto/ApiKeyCreateResponse.java new file mode 100644 index 0000000..be46f04 --- /dev/null +++ b/src/main/java/com/tokenledgercloud/api/dto/ApiKeyCreateResponse.java @@ -0,0 +1,21 @@ +package com.tokenledgercloud.api.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ApiKeyCreateResponse { + private Long id; + private String rawKey; + private String displayKey; + private String name; + private LocalDateTime createdAt; + private boolean isActive; +} diff --git a/src/main/java/com/tokenledgercloud/api/dto/ApiKeyResponse.java b/src/main/java/com/tokenledgercloud/api/dto/ApiKeyResponse.java index 447f5da..836c330 100644 --- a/src/main/java/com/tokenledgercloud/api/dto/ApiKeyResponse.java +++ b/src/main/java/com/tokenledgercloud/api/dto/ApiKeyResponse.java @@ -13,7 +13,7 @@ @Builder public class ApiKeyResponse { private Long id; - private String apiKey; + private String displayKey; private String name; private LocalDateTime createdAt; private boolean isActive; diff --git a/src/main/java/com/tokenledgercloud/api/global/config/SecurityConfig.java b/src/main/java/com/tokenledgercloud/api/global/config/SecurityConfig.java index 1dc3e0b..1cf77fc 100644 --- a/src/main/java/com/tokenledgercloud/api/global/config/SecurityConfig.java +++ b/src/main/java/com/tokenledgercloud/api/global/config/SecurityConfig.java @@ -14,7 +14,9 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import com.tokenledgercloud.api.service.CustomOAuth2UserService; +import com.tokenledgercloud.api.service.ApiKeyService; import com.tokenledgercloud.api.global.security.JwtAuthenticationFilter; +import com.tokenledgercloud.api.global.security.ApiKeyAuthenticationFilter; import com.tokenledgercloud.api.global.security.JwtTokenProvider; import com.tokenledgercloud.api.global.security.OAuth2SuccessHandler; @@ -28,6 +30,7 @@ public class SecurityConfig { private final CustomOAuth2UserService customOAuth2UserService; private final JwtTokenProvider jwtTokenProvider; private final OAuth2SuccessHandler oAuth2SuccessHandler; + private final ApiKeyService apiKeyService; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @@ -51,7 +54,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService)) .successHandler(oAuth2SuccessHandler) ) - .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class); + .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(new ApiKeyAuthenticationFilter(apiKeyService), JwtAuthenticationFilter.class); return http.build(); } diff --git a/src/main/java/com/tokenledgercloud/api/global/security/ApiKeyAuthenticationFilter.java b/src/main/java/com/tokenledgercloud/api/global/security/ApiKeyAuthenticationFilter.java new file mode 100644 index 0000000..5f63513 --- /dev/null +++ b/src/main/java/com/tokenledgercloud/api/global/security/ApiKeyAuthenticationFilter.java @@ -0,0 +1,59 @@ +package com.tokenledgercloud.api.global.security; + +import com.tokenledgercloud.api.domain.member.Member; +import com.tokenledgercloud.api.service.ApiKeyService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; + +@RequiredArgsConstructor +public class ApiKeyAuthenticationFilter extends OncePerRequestFilter { + + private final ApiKeyService apiKeyService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String apiKey = resolveApiKey(request); + + if (StringUtils.hasText(apiKey)) { + try { + Member member = apiKeyService.verifyApiKey(apiKey); + + UserDetails userDetails = new User( + member.getEmail(), + "", + Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + member.getRole().name())) + ); + + Authentication authentication = new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (Exception e) { + // Invalid API key - we can either throw error or just let it pass to be caught by security chain + // Here we just don't set the authentication + } + } + + filterChain.doFilter(request, response); + } + + private String resolveApiKey(HttpServletRequest request) { + return request.getHeader("X-API-KEY"); + } +} diff --git a/src/main/java/com/tokenledgercloud/api/global/util/HashingUtil.java b/src/main/java/com/tokenledgercloud/api/global/util/HashingUtil.java new file mode 100644 index 0000000..ccb36bb --- /dev/null +++ b/src/main/java/com/tokenledgercloud/api/global/util/HashingUtil.java @@ -0,0 +1,19 @@ +package com.tokenledgercloud.api.global.util; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +public class HashingUtil { + + public static String hash(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] encodedhash = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(encodedhash); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 algorithm not found", e); + } + } +} diff --git a/src/main/java/com/tokenledgercloud/api/service/ApiKeyService.java b/src/main/java/com/tokenledgercloud/api/service/ApiKeyService.java index dcac33a..600dfb0 100644 --- a/src/main/java/com/tokenledgercloud/api/service/ApiKeyService.java +++ b/src/main/java/com/tokenledgercloud/api/service/ApiKeyService.java @@ -5,12 +5,15 @@ import com.tokenledgercloud.api.domain.member.Member; import com.tokenledgercloud.api.domain.member.MemberRepository; import com.tokenledgercloud.api.dto.ApiKeyCreateRequest; +import com.tokenledgercloud.api.dto.ApiKeyCreateResponse; import com.tokenledgercloud.api.dto.ApiKeyResponse; +import com.tokenledgercloud.api.global.util.HashingUtil; import lombok.RequiredArgsConstructor; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.List; import java.util.UUID; import java.util.stream.Collectors; @@ -22,14 +25,24 @@ public class ApiKeyService { private final ApiKeyRepository apiKeyRepository; private final MemberRepository memberRepository; + private static final int MAX_API_KEYS_PER_MEMBER = 5; + @Transactional - public ApiKeyResponse createApiKey(Authentication authentication, ApiKeyCreateRequest request) { + public ApiKeyCreateResponse createApiKey(Authentication authentication, ApiKeyCreateRequest request) { Member member = getMember(authentication); - String generatedKey = "tk-" + UUID.randomUUID().toString().replace("-", ""); + long currentKeysCount = apiKeyRepository.findByMemberId(member.getId()).size(); + if (currentKeysCount >= MAX_API_KEYS_PER_MEMBER) { + throw new IllegalStateException("Maximum number of API keys reached (" + MAX_API_KEYS_PER_MEMBER + ")"); + } + + String rawKey = "tk-" + UUID.randomUUID().toString().replace("-", ""); + String hashedKey = HashingUtil.hash(rawKey); + String displayKey = rawKey.substring(0, 7) + "..." + rawKey.substring(rawKey.length() - 4); ApiKey apiKey = ApiKey.builder() - .apiKey(generatedKey) + .hashedKey(hashedKey) + .displayKey(displayKey) .member(member) .name(request.getName()) .isActive(true) @@ -37,7 +50,17 @@ public ApiKeyResponse createApiKey(Authentication authentication, ApiKeyCreateRe apiKeyRepository.save(apiKey); - return toResponse(apiKey); + return toCreateResponse(apiKey, rawKey); + } + + @Transactional + public Member verifyApiKey(String rawKey) { + String hashedKey = HashingUtil.hash(rawKey); + ApiKey apiKey = apiKeyRepository.findByHashedKeyAndIsActiveTrue(hashedKey) + .orElseThrow(() -> new IllegalArgumentException("Invalid or inactive API Key")); + + apiKey.setLastUsedAt(LocalDateTime.now()); + return apiKey.getMember(); } @Transactional(readOnly = true) @@ -48,17 +71,27 @@ public List getMyApiKeys(Authentication authentication) { .collect(Collectors.toList()); } + @Transactional + public void deactivateApiKey(Authentication authentication, Long apiKeyId) { + ApiKey apiKey = getMyApiKey(authentication, apiKeyId); + apiKey.setActive(false); + } + @Transactional public void deleteApiKey(Authentication authentication, Long apiKeyId) { + ApiKey apiKey = getMyApiKey(authentication, apiKeyId); + apiKeyRepository.delete(apiKey); + } + + private ApiKey getMyApiKey(Authentication authentication, Long apiKeyId) { Member member = getMember(authentication); ApiKey apiKey = apiKeyRepository.findById(apiKeyId) .orElseThrow(() -> new IllegalArgumentException("API Key not found")); if (!apiKey.getMember().getId().equals(member.getId())) { - throw new IllegalArgumentException("Unauthorized to delete this API Key"); + throw new IllegalArgumentException("Unauthorized to access this API Key"); } - - apiKeyRepository.delete(apiKey); + return apiKey; } private Member getMember(Authentication authentication) { @@ -71,7 +104,18 @@ private Member getMember(Authentication authentication) { private ApiKeyResponse toResponse(ApiKey apiKey) { return ApiKeyResponse.builder() .id(apiKey.getId()) - .apiKey(apiKey.getApiKey()) + .displayKey(apiKey.getDisplayKey()) + .name(apiKey.getName()) + .createdAt(apiKey.getCreatedAt()) + .isActive(apiKey.isActive()) + .build(); + } + + private ApiKeyCreateResponse toCreateResponse(ApiKey apiKey, String rawKey) { + return ApiKeyCreateResponse.builder() + .id(apiKey.getId()) + .rawKey(rawKey) + .displayKey(apiKey.getDisplayKey()) .name(apiKey.getName()) .createdAt(apiKey.getCreatedAt()) .isActive(apiKey.isActive()) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4db7cd7..bad1a64 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -57,7 +57,7 @@ spring: enabled: true # 웹 브라우저에서 DB를 볼 수 있게 해줍니다 (localhost:8080/h2-console) jpa: hibernate: - ddl-auto: validate + ddl-auto: update show-sql: true # 실행되는 SQL 쿼리를 터미널에 보여줍니다 flyway: enabled: true diff --git a/src/main/resources/db/migration/V2__create_api_keys_table.sql b/src/main/resources/db/migration/V2__create_api_keys_table.sql index c9b008c..0b63a0e 100644 --- a/src/main/resources/db/migration/V2__create_api_keys_table.sql +++ b/src/main/resources/db/migration/V2__create_api_keys_table.sql @@ -1,6 +1,7 @@ CREATE TABLE api_keys ( id BIGINT AUTO_INCREMENT PRIMARY KEY, - api_key VARCHAR(255) NOT NULL UNIQUE, + hashed_key VARCHAR(255) NOT NULL UNIQUE, + display_key VARCHAR(255) NOT NULL, member_id BIGINT NOT NULL, name VARCHAR(255), created_at DATETIME NOT NULL, @@ -10,4 +11,4 @@ CREATE TABLE api_keys ( ); CREATE INDEX idx_api_keys_member ON api_keys (member_id); -CREATE INDEX idx_api_keys_value ON api_keys (api_key); +CREATE INDEX idx_api_keys_hashed ON api_keys (hashed_key); diff --git a/src/test/java/com/tokenledgercloud/api/controller/ApiKeyControllerTest.java b/src/test/java/com/tokenledgercloud/api/controller/ApiKeyControllerTest.java new file mode 100644 index 0000000..a0d5d52 --- /dev/null +++ b/src/test/java/com/tokenledgercloud/api/controller/ApiKeyControllerTest.java @@ -0,0 +1,93 @@ +package com.tokenledgercloud.api.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.tokenledgercloud.api.dto.ApiKeyCreateRequest; +import com.tokenledgercloud.api.dto.ApiKeyCreateResponse; +import com.tokenledgercloud.api.dto.ApiKeyResponse; +import com.tokenledgercloud.api.service.ApiKeyService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class ApiKeyControllerTest { + + private MockMvc mockMvc; + + @Mock + private ApiKeyService apiKeyService; + + @InjectMocks + private ApiKeyController apiKeyController; + + private ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(apiKeyController).build(); + } + + @Test + @DisplayName("API 키 생성 API 호출 성공") + void createApiKey_ApiSuccess() throws Exception { + // given + ApiKeyCreateRequest request = new ApiKeyCreateRequest(); + request.setName("New Key"); + + ApiKeyCreateResponse response = ApiKeyCreateResponse.builder() + .id(1L) + .rawKey("tk-123456789") + .displayKey("tk-123...6789") + .name("New Key") + .createdAt(LocalDateTime.now()) + .isActive(true) + .build(); + + given(apiKeyService.createApiKey(any(), any())).willReturn(response); + + // when & then + mockMvc.perform(post("/api/api-keys") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rawKey").value("tk-123456789")) + .andExpect(jsonPath("$.displayKey").value("tk-123...6789")); + } + + @Test + @DisplayName("내 API 키 목록 조회 API 호출 성공") + void getMyApiKeys_ApiSuccess() throws Exception { + // given + ApiKeyResponse response = ApiKeyResponse.builder() + .id(1L) + .displayKey("tk-abc...def") + .name("My Key") + .isActive(true) + .build(); + + given(apiKeyService.getMyApiKeys(any())).willReturn(List.of(response)); + + // when & then + mockMvc.perform(get("/api/api-keys")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].displayKey").value("tk-abc...def")) + .andExpect(jsonPath("$[0].name").value("My Key")); + } +} diff --git a/src/test/java/com/tokenledgercloud/api/service/ApiKeyServiceTest.java b/src/test/java/com/tokenledgercloud/api/service/ApiKeyServiceTest.java new file mode 100644 index 0000000..e6a8ab0 --- /dev/null +++ b/src/test/java/com/tokenledgercloud/api/service/ApiKeyServiceTest.java @@ -0,0 +1,134 @@ +package com.tokenledgercloud.api.service; + +import com.tokenledgercloud.api.domain.apikey.ApiKey; +import com.tokenledgercloud.api.domain.apikey.ApiKeyRepository; +import com.tokenledgercloud.api.domain.member.Member; +import com.tokenledgercloud.api.domain.member.MemberRepository; +import com.tokenledgercloud.api.domain.member.Role; +import com.tokenledgercloud.api.dto.ApiKeyCreateRequest; +import com.tokenledgercloud.api.dto.ApiKeyCreateResponse; +import com.tokenledgercloud.api.global.util.HashingUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.Authentication; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +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.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ApiKeyServiceTest { + + @InjectMocks + private ApiKeyService apiKeyService; + + @Mock + private ApiKeyRepository apiKeyRepository; + + @Mock + private MemberRepository memberRepository; + + private Member testMember; + + @BeforeEach + void setUp() { + testMember = Member.builder() + .id(1L) + .email("test@example.com") + .name("Tester") + .role(Role.USER) + .provider("google") + .build(); + } + + @Test + @DisplayName("API 키 생성 성공") + void createApiKey_Success() { + // given + Authentication authentication = mock(Authentication.class); + given(authentication.getName()).willReturn("test@example.com"); + given(memberRepository.findByEmail("test@example.com")).willReturn(Optional.of(testMember)); + given(apiKeyRepository.findByMemberId(testMember.getId())).willReturn(new ArrayList<>()); + + ApiKeyCreateRequest request = new ApiKeyCreateRequest(); + request.setName("My Test Key"); + + // when + ApiKeyCreateResponse response = apiKeyService.createApiKey(authentication, request); + + // then + assertThat(response.getRawKey()).startsWith("tk-"); + assertThat(response.getDisplayKey()).contains("..."); + assertThat(response.getName()).isEqualTo("My Test Key"); + verify(apiKeyRepository).save(any(ApiKey.class)); + } + + @Test + @DisplayName("API 키 생성 실패 - 최대 개수(5개) 초과") + void createApiKey_LimitExceeded() { + // given + Authentication authentication = mock(Authentication.class); + given(authentication.getName()).willReturn("test@example.com"); + given(memberRepository.findByEmail("test@example.com")).willReturn(Optional.of(testMember)); + + List existingKeys = new ArrayList<>(); + for (int i = 0; i < 5; i++) existingKeys.add(new ApiKey()); + given(apiKeyRepository.findByMemberId(testMember.getId())).willReturn(existingKeys); + + ApiKeyCreateRequest request = new ApiKeyCreateRequest(); + request.setName("Sixth Key"); + + // when & then + assertThatThrownBy(() -> apiKeyService.createApiKey(authentication, request)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Maximum number of API keys reached"); + } + + @Test + @DisplayName("API 키 검증 성공") + void verifyApiKey_Success() { + // given + String rawKey = "tk-valid-key-example-123456789"; + String hashedKey = HashingUtil.hash(rawKey); + ApiKey apiKey = ApiKey.builder() + .hashedKey(hashedKey) + .member(testMember) + .isActive(true) + .build(); + + given(apiKeyRepository.findByHashedKeyAndIsActiveTrue(hashedKey)).willReturn(Optional.of(apiKey)); + + // when + Member result = apiKeyService.verifyApiKey(rawKey); + + // then + assertThat(result.getEmail()).isEqualTo(testMember.getEmail()); + assertThat(apiKey.getLastUsedAt()).isNotNull(); + } + + @Test + @DisplayName("API 키 검증 실패 - 잘못된 키") + void verifyApiKey_Invalid() { + // given + String rawKey = "tk-wrong-key"; + String hashedKey = HashingUtil.hash(rawKey); + given(apiKeyRepository.findByHashedKeyAndIsActiveTrue(hashedKey)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> apiKeyService.verifyApiKey(rawKey)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid or inactive API Key"); + } +}