This commit is contained in:
@@ -504,3 +504,60 @@ telnet 193.46.217.94 3128 # Проверка п
|
|||||||
|
|
||||||
Обновить сертики
|
Обновить сертики
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user