From 38888140cdd18122d1b7767b1187723a59961a27 Mon Sep 17 00:00:00 2001 From: backdev-1 Date: Tue, 16 Jun 2026 22:25:45 +0700 Subject: [PATCH] Add api key for use rest api --- README.md | 59 +++++++++++- .../security/SecurityConfig.java | 4 + .../nocopy/controller/ApiController.java | 1 - .../nocopy/controller/ApiKeyController.java | 70 ++++++++++++++ .../java/ru/soune/nocopy/dto/MessageCode.java | 1 + .../ru/soune/nocopy/entity/api/ApiKey.java | 68 ++++++++++++++ .../repository/api/ApiKeyRepository.java | 18 ++++ .../nocopy/service/api/ApiKeyService.java | 93 +++++++++++++++++++ .../ru/soune/nocopy/util/ApiKeyFilter.java | 76 +++++++++++++++ 9 files changed, 388 insertions(+), 2 deletions(-) create mode 100644 src/main/java/ru/soune/nocopy/controller/ApiKeyController.java create mode 100644 src/main/java/ru/soune/nocopy/entity/api/ApiKey.java create mode 100644 src/main/java/ru/soune/nocopy/repository/api/ApiKeyRepository.java create mode 100644 src/main/java/ru/soune/nocopy/service/api/ApiKeyService.java create mode 100644 src/main/java/ru/soune/nocopy/util/ApiKeyFilter.java diff --git a/README.md b/README.md index 5c31126..5ecf030 100644 --- a/README.md +++ b/README.md @@ -503,4 +503,61 @@ telnet 193.46.217.94 3128 # Проверка п private static final String SECRET_KEY = "YCPAPZ32XC-LWb8xE0b_xTcBD8NZUCHpSOfu8LUG"; Обновить сертики -systemctl stop nginx && certbot renew && systemctl start nginx \ No newline at end of file +systemctl stop nginx && certbot renew && systemctl start nginx + + +Создание API ключей для внешнего доступа + +1. + +POST /api/user/api-keys +Authorization: Bearer {YOUR_JWT_TOKEN} +Content-Type: application/json + +{ + "name": "My Integration Script" +} + +{ + "messageCode": "API_KEY_CREATED", + "messageDesc": "Save this key. It will not be shown again.", + "messageBody": { + "apiKey": "ncp_dGhpcyBpc2EzcGFtcGxla2V5Zm9ydGVzdGluZw" + } +} + +2. Использование API-ключа + +GET /api/v1/files +X-API-Key: ncp_dGhpcyBpc2EzcGFtcGxla2V5Zm9ydGVzdGluZw + +curl -X GET \ + -H "X-API-Key: ncp_dGhpcyBpc2EzcGFtcGxla2V5Zm9ydGVzdGluZw" \ + https://api.nocopy.ru/api/v1/files + +3.Просмотр списка ключей +GET /api/user/api-keys +Authorization: Bearer {YOUR_JWT_TOKEN} + +{ + "messageCode": "OK", + "messageBody": [ + { + "id": 1, + "name": "My Integration Script", + "prefix": "dGhpcyBp", + "isActive": true, + "createdAt": "2024-01-15T10:30:00", + "lastUsedAt": "2024-01-16T14:22:00" + } + ] +} + +4. Отзыв API-ключа + +DELETE /api/user/api-keys/{keyId} +Authorization: Bearer {YOUR_JWT_TOKEN} + +{ + "messageCode": "API_KEY_REVOKED" +} \ No newline at end of file diff --git a/src/main/java/ru/soune/nocopy/configuration/security/SecurityConfig.java b/src/main/java/ru/soune/nocopy/configuration/security/SecurityConfig.java index 4bb5f87..c9c3271 100644 --- a/src/main/java/ru/soune/nocopy/configuration/security/SecurityConfig.java +++ b/src/main/java/ru/soune/nocopy/configuration/security/SecurityConfig.java @@ -9,6 +9,7 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHt import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import ru.soune.nocopy.util.ApiKeyFilter; import ru.soune.nocopy.util.JwtAuthFilter; @Configuration @@ -18,6 +19,8 @@ public class SecurityConfig { private final JwtAuthFilter jwtAuthFilter; + private final ApiKeyFilter apiKeyFilter; + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http @@ -33,6 +36,7 @@ public class SecurityConfig { .requestMatchers("/api/internal/**").permitAll() .anyRequest().authenticated() ) + .addFilterBefore(apiKeyFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); diff --git a/src/main/java/ru/soune/nocopy/controller/ApiController.java b/src/main/java/ru/soune/nocopy/controller/ApiController.java index f4ba07e..f3aee85 100644 --- a/src/main/java/ru/soune/nocopy/controller/ApiController.java +++ b/src/main/java/ru/soune/nocopy/controller/ApiController.java @@ -28,7 +28,6 @@ import ru.soune.nocopy.entity.file.FileEntity; import ru.soune.nocopy.entity.file.FileStatus; import ru.soune.nocopy.entity.file.UploadStatus; import ru.soune.nocopy.entity.file.permission.PermissionType; -import ru.soune.nocopy.entity.user.AuthToken; import ru.soune.nocopy.entity.user.User; import ru.soune.nocopy.exception.*; import ru.soune.nocopy.handler.*; diff --git a/src/main/java/ru/soune/nocopy/controller/ApiKeyController.java b/src/main/java/ru/soune/nocopy/controller/ApiKeyController.java new file mode 100644 index 0000000..4eb3fda --- /dev/null +++ b/src/main/java/ru/soune/nocopy/controller/ApiKeyController.java @@ -0,0 +1,70 @@ +package ru.soune.nocopy.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import ru.soune.nocopy.dto.BaseResponse; +import ru.soune.nocopy.dto.MessageCode; +import ru.soune.nocopy.entity.api.ApiKey; +import ru.soune.nocopy.service.api.ApiKeyService; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/user") +@RequiredArgsConstructor +public class ApiKeyController { + + private final ApiKeyService apiKeyService; + + @PostMapping("/api-keys") + public ResponseEntity createKey(@RequestAttribute("userId") Long userId, + @RequestBody Map body) { + String name = body.getOrDefault("name", "Unnamed key"); + String apiKey = apiKeyService.createApiKey(userId, name); + + return ResponseEntity.ok( + BaseResponse.builder() + .messageCode(MessageCode.API_KEY_CREATED.getCode()) + .messageDesc(MessageCode.API_KEY_CREATED.getDescription()) + .messageBody(Map.of("apiKey", apiKey)) + .build() + ); + } + + @GetMapping("/api-keys") + public ResponseEntity listKeys(@RequestAttribute("userId") Long userId) { + List keys = apiKeyService.getUserKeys(userId); + + List> result = keys.stream() + .map(key -> Map.of( + "id", key.getId(), + "name", key.getName(), + "prefix", key.getPrefix(), + "isActive", key.isActive(), + "createdAt", key.getCreatedAt(), + "lastUsedAt", key.getLastUsedAt() + )) + .toList(); + + return ResponseEntity.ok( + BaseResponse.builder() + .messageCode(MessageCode.SUCCESS.getCode()) + .messageBody(result) + .build() + ); + } + + @DeleteMapping("/api-keys/{keyId}") + public ResponseEntity revokeKey(@RequestAttribute("userId") Long userId, + @PathVariable Long keyId) { + apiKeyService.revoke(keyId, userId); + + return ResponseEntity.ok( + BaseResponse.builder() + .messageCode(MessageCode.SUCCESS.getCode()) + .build() + ); + } +} diff --git a/src/main/java/ru/soune/nocopy/dto/MessageCode.java b/src/main/java/ru/soune/nocopy/dto/MessageCode.java index 156f6f2..3cc1df5 100644 --- a/src/main/java/ru/soune/nocopy/dto/MessageCode.java +++ b/src/main/java/ru/soune/nocopy/dto/MessageCode.java @@ -9,6 +9,7 @@ public enum MessageCode { FILE_IS_BLOCKED(1, "File is blocked,deleted or removed"), INVALID_FIELD(2, "Invalid field"), MAIL_VERIFIED_NULL(2, "Mail verified null"), + API_KEY_CREATED(2, "Save this key. It will not be shown again"), INVALID_TOKEN(2, "Token not found or time expired"), TOKEN_IS_ALIVE(2, "Token is alive"), INVALID_ACTION(2, "Invalid action"), diff --git a/src/main/java/ru/soune/nocopy/entity/api/ApiKey.java b/src/main/java/ru/soune/nocopy/entity/api/ApiKey.java new file mode 100644 index 0000000..c71fb61 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/entity/api/ApiKey.java @@ -0,0 +1,68 @@ +package ru.soune.nocopy.entity.api; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "api_keys") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ApiKey { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "key_hash", nullable = false, unique = true, length = 64) + private String keyHash; + + @Column(nullable = false, length = 8) + private String prefix; + + @Column(length = 255) + private String name; + + @Column(name = "is_active", nullable = false) + private boolean isActive; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "expires_at") + private LocalDateTime expiresAt; + + @Column(name = "last_used_at") + private LocalDateTime lastUsedAt; + + @Builder + private ApiKey(Long userId, String keyHash, String prefix, + String name, LocalDateTime expiresAt) { + this.userId = userId; + this.keyHash = keyHash; + this.prefix = prefix; + this.name = name; + this.isActive = true; + this.createdAt = LocalDateTime.now(); + this.expiresAt = expiresAt; + } + + public void markAsUsed() { + this.lastUsedAt = LocalDateTime.now(); + } + + public void revoke() { + this.isActive = false; + } + + public boolean isValid() { + if (!isActive) return false; + if (expiresAt == null) return true; + return expiresAt.isAfter(LocalDateTime.now()); + } +} diff --git a/src/main/java/ru/soune/nocopy/repository/api/ApiKeyRepository.java b/src/main/java/ru/soune/nocopy/repository/api/ApiKeyRepository.java new file mode 100644 index 0000000..b1d0c86 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/repository/api/ApiKeyRepository.java @@ -0,0 +1,18 @@ +package ru.soune.nocopy.repository.api; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import ru.soune.nocopy.entity.api.ApiKey; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface ApiKeyRepository extends JpaRepository { + + Optional findByKeyHash(String keyHash); + + List findAllByUserId(Long userId); + + Optional findByIdAndUserId(Long id, Long userId); +} diff --git a/src/main/java/ru/soune/nocopy/service/api/ApiKeyService.java b/src/main/java/ru/soune/nocopy/service/api/ApiKeyService.java new file mode 100644 index 0000000..f560a52 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/service/api/ApiKeyService.java @@ -0,0 +1,93 @@ +package ru.soune.nocopy.service.api; + + +import lombok.RequiredArgsConstructor; +import org.apache.commons.codec.digest.DigestUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.soune.nocopy.entity.api.ApiKey; +import ru.soune.nocopy.repository.api.ApiKeyRepository; + +import java.security.SecureRandom; +import java.util.Base64; +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class ApiKeyService { + + private final ApiKeyRepository apiKeyRepository; + + @Transactional + public String createApiKey(Long userId, String name) { + String rawKey = generateRawKey(); + String hash = DigestUtils.sha256Hex(rawKey); + String prefix = rawKey.substring(0, 8); + + ApiKey apiKey = ApiKey.builder() + .userId(userId) + .keyHash(hash) + .prefix(prefix) + .name(name) + .build(); + + apiKeyRepository.save(apiKey); + + return "ncp_" + Base64.getUrlEncoder().withoutPadding() + .encodeToString(rawKey.getBytes()); + } + + @Transactional + public Optional authenticate(String clientProvidedKey) { + if (!clientProvidedKey.startsWith("ncp_")) { + return Optional.empty(); + } + + String encodedPart = clientProvidedKey.substring(4); + + String rawKey; + try { + rawKey = new String(Base64.getUrlDecoder().decode(encodedPart)); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + + String hash = DigestUtils.sha256Hex(rawKey); + + Optional apiKeyOpt = apiKeyRepository.findByKeyHash(hash); + + if (apiKeyOpt.isEmpty()) { + return Optional.empty(); + } + + ApiKey apiKey = apiKeyOpt.get(); + + if (!apiKey.isValid()) { + return Optional.empty(); + } + + apiKey.markAsUsed(); + apiKeyRepository.save(apiKey); + + return Optional.of(apiKey.getUserId()); + } + + @Transactional(readOnly = true) + public List getUserKeys(Long userId) { + return apiKeyRepository.findAllByUserId(userId); + } + + @Transactional + public void revoke(Long keyId, Long userId) { + apiKeyRepository.findByIdAndUserId(keyId, userId) + .ifPresent(ApiKey::revoke); + } + + private String generateRawKey() { + SecureRandom random = new SecureRandom(); + byte[] bytes = new byte[32]; + random.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } +} diff --git a/src/main/java/ru/soune/nocopy/util/ApiKeyFilter.java b/src/main/java/ru/soune/nocopy/util/ApiKeyFilter.java new file mode 100644 index 0000000..f1a11ed --- /dev/null +++ b/src/main/java/ru/soune/nocopy/util/ApiKeyFilter.java @@ -0,0 +1,76 @@ +package ru.soune.nocopy.util; + +import com.fasterxml.jackson.databind.ObjectMapper; +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.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import ru.soune.nocopy.dto.BaseResponse; +import ru.soune.nocopy.dto.MessageCode; +import ru.soune.nocopy.service.api.ApiKeyService; + +import java.io.IOException; +import java.util.List; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class ApiKeyFilter extends OncePerRequestFilter { + + private final ApiKeyService apiKeyService; + + private final ObjectMapper objectMapper; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { + + if (SecurityContextHolder.getContext().getAuthentication() != null) { + filterChain.doFilter(request, response); + return; + } + + String apiKey = request.getHeader("X-API-Key"); + + if (apiKey == null) { + filterChain.doFilter(request, response); + return; + } + + Optional userIdOpt = apiKeyService.authenticate(apiKey); + + if (userIdOpt.isEmpty()) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + BaseResponse error = BaseResponse.builder() + .messageCode(MessageCode.INVALID_CREDENTIALS.getCode()) + .messageDesc("Invalid or expired API key") + .build(); + response.getWriter().write(objectMapper.writeValueAsString(error)); + return; + } + + Long userId = userIdOpt.get(); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userId, + null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + request.setAttribute("userId", userId); + + filterChain.doFilter(request, response); + } +}