Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,17 @@ 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'
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'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
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.*;

import java.util.List;

@RestController
@RequestMapping("/api/api-keys")
@RequiredArgsConstructor
public class ApiKeyController {

private final ApiKeyService apiKeyService;

@PostMapping
public ApiKeyCreateResponse createApiKey(Authentication authentication, @Valid @RequestBody ApiKeyCreateRequest request) {
return apiKeyService.createApiKey(authentication, request);
}

@GetMapping
public List<ApiKeyResponse> 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);
}
}
51 changes: 51 additions & 0 deletions src/main/java/com/tokenledgercloud/api/domain/apikey/ApiKey.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

ํด๋ž˜์Šค ๋ ˆ๋ฒจ์˜ @Setter๋Š” ๊ฐ์ฒด์˜ ๋ฌด๋ถ„๋ณ„ํ•œ ์ƒํƒœ ๋ณ€๊ฒฝ์„ ํ—ˆ์šฉํ•˜์—ฌ ์บก์Аํ™”๋ฅผ ์ €ํ•ดํ•˜๊ณ  ์œ ์ง€๋ณด์ˆ˜์„ฑ์„ ๋–จ์–ด๋œจ๋ฆด ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ•„์š”ํ•œ ํ•„๋“œ์—๋งŒ @Setter๋ฅผ ๋ถ™์ด๊ฑฐ๋‚˜, ์˜๋ฏธ ์žˆ๋Š” ๋น„์ฆˆ๋‹ˆ์Šค ๋ฉ”์„œ๋“œ(์˜ˆ: deactivate())๋ฅผ ํ†ตํ•ด ์ƒํƒœ๋ฅผ ๋ณ€๊ฒฝํ•˜๋Š” ๊ฒƒ์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.

@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ApiKey {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@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)
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();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
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<ApiKey, Long> {
Optional<ApiKey> findByHashedKey(String hashedKey);
Optional<ApiKey> findByHashedKeyAndIsActiveTrue(String hashedKey);
List<ApiKey> findByMemberId(Long memberId);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

API ํ‚ค ๊ฐœ์ˆ˜ ์ œํ•œ ํ™•์ธ์„ ์œ„ํ•ด ํšจ์œจ์ ์ธ count ์ฟผ๋ฆฌ ๋ฉ”์„œ๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.

    List<ApiKey> findByMemberId(Long memberId);
    long countByMemberId(Long memberId);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.tokenledgercloud.api.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
20 changes: 20 additions & 0 deletions src/main/java/com/tokenledgercloud/api/dto/ApiKeyResponse.java
Original file line number Diff line number Diff line change
@@ -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 displayKey;
private String name;
private LocalDateTime createdAt;
private boolean isActive;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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 {
Expand All @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Comment on lines +47 to +50
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

๋ชจ๋“  ์˜ˆ์™ธ(Exception)๋ฅผ ํฌ๊ด„์ ์œผ๋กœ ์žก์•„ ์•„๋ฌด๋Ÿฐ ์ฒ˜๋ฆฌ๋„ ํ•˜์ง€ ์•Š๋Š” ๊ฒƒ์€ ์˜ˆ์ƒ์น˜ ๋ชปํ•œ ์‹œ์Šคํ…œ ์˜ค๋ฅ˜๋ฅผ ์ˆจ๊ธธ ์ˆ˜ ์žˆ์–ด ์œ„ํ—˜ํ•ฉ๋‹ˆ๋‹ค. ์ธ์ฆ ์‹คํŒจ(์˜ˆ: IllegalArgumentException)์™€ ์‹œ์Šคํ…œ ์˜ค๋ฅ˜๋ฅผ ๊ตฌ๋ถ„ํ•˜์—ฌ ์ฒ˜๋ฆฌํ•˜๊ณ , ์ตœ์†Œํ•œ ๋””๋ฒ„๊น…์„ ์œ„ํ•œ ๋กœ๊ทธ๋ฅผ ๋‚จ๊ธฐ๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.

            } catch (IllegalArgumentException e) {
                logger.debug("Invalid API key provided: " + e.getMessage());
            } catch (Exception e) {
                logger.error("Unexpected error during API key authentication", e);
            }

}

filterChain.doFilter(request, response);
}

private String resolveApiKey(HttpServletRequest request) {
return request.getHeader("X-API-KEY");
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Loading