Add api key for use rest api
Test Workflow / test (push) Has been cancelled

This commit is contained in:
2026-06-16 22:25:45 +07:00
parent fb381518de
commit 38888140cd
9 changed files with 388 additions and 2 deletions
+58 -1
View File
@@ -503,4 +503,61 @@ telnet 193.46.217.94 3128 # Проверка п
private static final String SECRET_KEY = "YCPAPZ32XC-LWb8xE0b_xTcBD8NZUCHpSOfu8LUG"; private static final String SECRET_KEY = "YCPAPZ32XC-LWb8xE0b_xTcBD8NZUCHpSOfu8LUG";
Обновить сертики Обновить сертики
systemctl stop nginx && certbot renew && systemctl start nginx 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"
}
@@ -9,6 +9,7 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHt
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import ru.soune.nocopy.util.ApiKeyFilter;
import ru.soune.nocopy.util.JwtAuthFilter; import ru.soune.nocopy.util.JwtAuthFilter;
@Configuration @Configuration
@@ -18,6 +19,8 @@ public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter; private final JwtAuthFilter jwtAuthFilter;
private final ApiKeyFilter apiKeyFilter;
@Bean @Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http http
@@ -33,6 +36,7 @@ public class SecurityConfig {
.requestMatchers("/api/internal/**").permitAll() .requestMatchers("/api/internal/**").permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.addFilterBefore(apiKeyFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build(); return http.build();
@@ -28,7 +28,6 @@ import ru.soune.nocopy.entity.file.FileEntity;
import ru.soune.nocopy.entity.file.FileStatus; import ru.soune.nocopy.entity.file.FileStatus;
import ru.soune.nocopy.entity.file.UploadStatus; import ru.soune.nocopy.entity.file.UploadStatus;
import ru.soune.nocopy.entity.file.permission.PermissionType; 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.entity.user.User;
import ru.soune.nocopy.exception.*; import ru.soune.nocopy.exception.*;
import ru.soune.nocopy.handler.*; import ru.soune.nocopy.handler.*;
@@ -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<BaseResponse> createKey(@RequestAttribute("userId") Long userId,
@RequestBody Map<String, String> 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<BaseResponse> listKeys(@RequestAttribute("userId") Long userId) {
List<ApiKey> keys = apiKeyService.getUserKeys(userId);
List<Map<String, Object>> result = keys.stream()
.map(key -> Map.<String, Object>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<BaseResponse> revokeKey(@RequestAttribute("userId") Long userId,
@PathVariable Long keyId) {
apiKeyService.revoke(keyId, userId);
return ResponseEntity.ok(
BaseResponse.builder()
.messageCode(MessageCode.SUCCESS.getCode())
.build()
);
}
}
@@ -9,6 +9,7 @@ public enum MessageCode {
FILE_IS_BLOCKED(1, "File is blocked,deleted or removed"), FILE_IS_BLOCKED(1, "File is blocked,deleted or removed"),
INVALID_FIELD(2, "Invalid field"), INVALID_FIELD(2, "Invalid field"),
MAIL_VERIFIED_NULL(2, "Mail verified null"), 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"), INVALID_TOKEN(2, "Token not found or time expired"),
TOKEN_IS_ALIVE(2, "Token is alive"), TOKEN_IS_ALIVE(2, "Token is alive"),
INVALID_ACTION(2, "Invalid action"), INVALID_ACTION(2, "Invalid action"),
@@ -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());
}
}
@@ -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<ApiKey, Long> {
Optional<ApiKey> findByKeyHash(String keyHash);
List<ApiKey> findAllByUserId(Long userId);
Optional<ApiKey> findByIdAndUserId(Long id, Long userId);
}
@@ -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<Long> 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<ApiKey> 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<ApiKey> 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);
}
}
@@ -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<Long> 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);
}
}