diff --git a/agent/main.go b/agent/main.go index 0ea3404c9..60bd7a575 100644 --- a/agent/main.go +++ b/agent/main.go @@ -3,7 +3,6 @@ package main import ( "fmt" "os" - "strings" "time" pb "github.com/utmstack/UTMStack/agent/agent" @@ -170,41 +169,6 @@ func main() { fmt.Println("TLS certificates loaded successfully!") time.Sleep(5 * time.Second) - case "check-tls-certs": - fmt.Println("Checking TLS certificates status ...") - - status := utils.GetTLSStatus( - config.IntegrationCertPath, - config.IntegrationKeyPath, - config.IntegrationCAPath, - ) - - fmt.Printf("Certificate file exists: %v\n", status.CertExists) - fmt.Printf("Private key file exists: %v\n", status.KeyExists) - fmt.Printf("CA certificate file exists: %v\n", status.CAExists) - fmt.Printf("TLS Available: %v\n", status.Available) - fmt.Printf("Certificates Valid: %v\n", status.Valid) - - if status.Error != "" { - fmt.Printf("Error: %s\n", status.Error) - } - - if status.Available { - fmt.Println("\n" + strings.Repeat("=", 50)) - details, err := utils.GetCertificateDetails(config.IntegrationCertPath) - if err == nil { - fmt.Println(details) - } - fmt.Println("TLS is ready for use!") - fmt.Println("Use: ./utmstack_agent enable-tls tcp") - } else { - fmt.Println("\n" + strings.Repeat("=", 50)) - fmt.Println("TLS is NOT available.") - fmt.Println("To enable TLS:") - fmt.Println(" Load your certificates: ./utmstack_agent load-tls-certs [ca]") - } - time.Sleep(5 * time.Second) - case "change-port": fmt.Println("Changing integration port ...") integration := os.Args[2] diff --git a/agent/modules/configuration.go b/agent/modules/configuration.go index 2274c3535..dd5bf90e6 100644 --- a/agent/modules/configuration.go +++ b/agent/modules/configuration.go @@ -69,6 +69,9 @@ func ChangeIntegrationStatus(logTyp string, proto string, isEnabled bool, tlsOpt // Handle TLS configuration if specified if len(tlsOptions) > 0 && isEnabled { if tlsOptions[0] { + if !utils.CheckIfPathExist(config.IntegrationCertPath) || !utils.CheckIfPathExist(config.IntegrationKeyPath) { + return "", fmt.Errorf("TLS certificates not found. Please load certificates first") + } // Enable TLS integration.TCP.TLSEnabled = true mod := GetModule(logTyp) diff --git a/agent/modules/syslog.go b/agent/modules/syslog.go index e2ed0d776..1efdb31aa 100644 --- a/agent/modules/syslog.go +++ b/agent/modules/syslog.go @@ -98,6 +98,12 @@ func (m *SyslogModule) GetPort(proto string) string { func (m *SyslogModule) EnablePort(proto string, enableTLS bool) error { switch proto { case "tcp": + if enableTLS { + if !utils.CheckIfPathExist(config.IntegrationCertPath) || !utils.CheckIfPathExist(config.IntegrationKeyPath) { + return fmt.Errorf("TLS certificates not found. Please load certificates first") + } + } + m.TCPListener.TLSEnabled = enableTLS go m.enableTCP() return nil diff --git a/agent/serv/service.go b/agent/serv/service.go index ea8abdbab..71b021621 100644 --- a/agent/serv/service.go +++ b/agent/serv/service.go @@ -40,19 +40,6 @@ func (p *program) run() { utils.Logger.Fatal("error getting config: %v", err) } - if utils.CheckIfPathExist(config.IntegrationCertPath) && utils.CheckIfPathExist(config.IntegrationKeyPath) { - err = utils.ValidateIntegrationCertificates(config.IntegrationCertPath, config.IntegrationKeyPath) - if err != nil { - utils.Logger.ErrorF("TLS certificates are invalid: %v", err) - utils.Logger.Info("TLS functionality will be disabled. Use 'load-tls-certs' command to fix this.") - } else { - utils.Logger.Info("TLS certificates are valid and ready for use") - } - } else { - utils.Logger.Info("No TLS certificates found. TLS functionality is disabled.") - utils.Logger.Info("Use 'load-tls-certs' command to load your certificates.") - } - db := database.GetDB() err = db.Migrate(models.Log{}) if err != nil { diff --git a/agent/utils/files.go b/agent/utils/files.go index 977b5689c..51da99255 100644 --- a/agent/utils/files.go +++ b/agent/utils/files.go @@ -157,3 +157,20 @@ func IsDirEmpty(path string) (bool, error) { } return false, err } + +func copyFile(src, dst string) error { + sourceFile, err := os.Open(src) + if err != nil { + return err + } + defer sourceFile.Close() + + destFile, err := os.Create(dst) + if err != nil { + return err + } + defer destFile.Close() + + _, err = io.Copy(destFile, sourceFile) + return err +} diff --git a/agent/utils/integration_tls.go b/agent/utils/int_tls.go similarity index 67% rename from agent/utils/integration_tls.go rename to agent/utils/int_tls.go index 741974e9a..5ca5928f3 100644 --- a/agent/utils/integration_tls.go +++ b/agent/utils/int_tls.go @@ -4,12 +4,9 @@ import ( "crypto/rsa" "crypto/tls" "crypto/x509" - "encoding/pem" "fmt" - "io" "os" "path/filepath" - "strings" "time" ) @@ -170,97 +167,3 @@ func LoadUserCertificatesWithStruct(src, dest CertificateFiles) error { return nil } - -func GetTLSStatus(certPath, keyPath, caPath string) TLSStatus { - status := TLSStatus{ - CertExists: CheckIfPathExist(certPath), - KeyExists: CheckIfPathExist(keyPath), - CAExists: CheckIfPathExist(caPath), - } - - if status.CertExists && status.KeyExists { - err := ValidateIntegrationCertificates(certPath, keyPath) - if err == nil { - status.Available = true - status.Valid = true - } else { - status.Error = err.Error() - } - } else { - status.Error = "Certificate or private key files not found" - } - - return status -} - -func GetCertificateDetails(certPath string) (string, error) { - if !CheckIfPathExist(certPath) { - return "", fmt.Errorf("certificate file not found: %s", certPath) - } - - // Parse certificate directly - certData, err := os.ReadFile(certPath) - if err != nil { - return "", fmt.Errorf("error reading certificate: %w", err) - } - - block, _ := pem.Decode(certData) - if block == nil { - return "", fmt.Errorf("failed to decode certificate PEM") - } - - cert, err := x509.ParseCertificate(block.Bytes) - if err != nil { - return "", fmt.Errorf("failed to parse certificate: %w", err) - } - - return formatCertificateDetails(cert), nil -} - -func formatCertificateDetails(cert *x509.Certificate) string { - var builder strings.Builder - - builder.WriteString(fmt.Sprintf("Subject: %s\n", cert.Subject.String())) - builder.WriteString(fmt.Sprintf("Issuer: %s\n", cert.Issuer.String())) - builder.WriteString(fmt.Sprintf("Valid from: %s\n", cert.NotBefore.Format("2006-01-02 15:04:05 UTC"))) - builder.WriteString(fmt.Sprintf("Valid until: %s\n", cert.NotAfter.Format("2006-01-02 15:04:05 UTC"))) - builder.WriteString(fmt.Sprintf("Serial Number: %s\n", cert.SerialNumber.String())) - - if len(cert.DNSNames) > 0 || len(cert.IPAddresses) > 0 { - builder.WriteString("Subject Alternative Names: ") - - for i, dns := range cert.DNSNames { - if i > 0 { - builder.WriteString(", ") - } - builder.WriteString(dns) - } - - for i, ip := range cert.IPAddresses { - if i > 0 || len(cert.DNSNames) > 0 { - builder.WriteString(", ") - } - builder.WriteString(ip.String()) - } - builder.WriteString("\n") - } - - return builder.String() -} - -func copyFile(src, dst string) error { - sourceFile, err := os.Open(src) - if err != nil { - return err - } - defer sourceFile.Close() - - destFile, err := os.Create(dst) - if err != nil { - return err - } - defer destFile.Close() - - _, err = io.Copy(destFile, sourceFile) - return err -} diff --git a/backend/.gitignore b/backend/.gitignore index 834dd8821..518edd788 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -62,6 +62,7 @@ local.properties *.orig classes/ out/ +.nvim/ ###################### # Visual Studio Code diff --git a/backend/pom.xml b/backend/pom.xml index 325396715..2109f1376 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -376,6 +376,12 @@ 3.0.5 + + commons-net + commons-net + 3.9.0 + + diff --git a/backend/src/main/java/com/park/utmstack/advice/GlobalExceptionHandler.java b/backend/src/main/java/com/park/utmstack/advice/GlobalExceptionHandler.java index 937bdde86..b84c2ea14 100644 --- a/backend/src/main/java/com/park/utmstack/advice/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/park/utmstack/advice/GlobalExceptionHandler.java @@ -4,10 +4,7 @@ import com.park.utmstack.security.TooMuchLoginAttemptsException; import com.park.utmstack.service.application_events.ApplicationEventService; import com.park.utmstack.util.ResponseUtil; -import com.park.utmstack.util.exceptions.IncidentAlertConflictException; -import com.park.utmstack.util.exceptions.NoAlertsProvidedException; -import com.park.utmstack.util.exceptions.TfaVerificationException; -import com.park.utmstack.util.exceptions.TooManyRequestsException; +import com.park.utmstack.util.exceptions.*; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; @@ -41,8 +38,9 @@ public ResponseEntity handleTooManyLoginAttempts(TooMuchLoginAttemptsExceptio return ResponseUtil.buildLockedResponse(e.getMessage()); } - @ExceptionHandler(NoSuchElementException.class) - public ResponseEntity handleNotFound(NoSuchElementException e, HttpServletRequest request) { + @ExceptionHandler({NoSuchElementException.class, + ApiKeyNotFoundException.class}) + public ResponseEntity handleNotFound(Exception e, HttpServletRequest request) { return ResponseUtil.buildNotFoundResponse(e.getMessage()); } @@ -56,8 +54,9 @@ public ResponseEntity handleNoAlertsProvided(Exception e, HttpServletRequest return ResponseUtil.buildErrorResponse(HttpStatus.BAD_REQUEST, e.getMessage()); } - @ExceptionHandler(IncidentAlertConflictException.class) - public ResponseEntity handleConflict(IncidentAlertConflictException e, HttpServletRequest request) { + @ExceptionHandler({IncidentAlertConflictException.class, + ApiKeyExistException.class}) + public ResponseEntity handleConflict(Exception e, HttpServletRequest request) { return ResponseUtil.buildErrorResponse(HttpStatus.CONFLICT, e.getMessage()); } diff --git a/backend/src/main/java/com/park/utmstack/config/Constants.java b/backend/src/main/java/com/park/utmstack/config/Constants.java index 61d6c52a9..a36fe1733 100644 --- a/backend/src/main/java/com/park/utmstack/config/Constants.java +++ b/backend/src/main/java/com/park/utmstack/config/Constants.java @@ -2,7 +2,9 @@ import com.park.utmstack.domain.index_pattern.enums.SystemIndexPattern; +import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; public final class Constants { @@ -137,6 +139,7 @@ public final class Constants { // Defines the index pattern for querying Elasticsearch statistics indexes. // ---------------------------------------------------------------------------------- public static final String STATISTICS_INDEX_PATTERN = "v11-statistics-*"; + public static final String V11_API_ACCESS_LOGS = "v11-api-access-logs-*"; // Logging public static final String TRACE_ID_KEY = "traceId"; @@ -156,6 +159,9 @@ public final class Constants { public static final String CONF_TYPE_PASSWORD = "password"; public static final String CONF_TYPE_FILE = "file"; + public static final String API_KEY_HEADER = "Utm-Api-Key"; + public static final List API_ENDPOINT_IGNORE = Collections.emptyList(); + private Constants() { } } diff --git a/backend/src/main/java/com/park/utmstack/config/OpenApiConfiguration.java b/backend/src/main/java/com/park/utmstack/config/OpenApiConfiguration.java index 46a30dd02..dd1414d6d 100644 --- a/backend/src/main/java/com/park/utmstack/config/OpenApiConfiguration.java +++ b/backend/src/main/java/com/park/utmstack/config/OpenApiConfiguration.java @@ -23,11 +23,16 @@ public OpenApiConfiguration(InfoEndpoint infoEndpoint) { @Bean public OpenAPI customOpenAPI() { final String securitySchemeBearer = "bearerAuth"; + final String securitySchemeApiInternalKey = "ApiInternalKeyAuth"; final String securitySchemeApiKey = "ApiKeyAuth"; + final String apiTitle = "UTMStack API"; String version = MapUtil.flattenToStringMap(infoEndpoint.info(), true).get("build.version"); return new OpenAPI() - .addSecurityItem(new SecurityRequirement().addList(securitySchemeBearer).addList(securitySchemeApiKey)) + .addSecurityItem(new SecurityRequirement() + .addList(securitySchemeBearer) + .addList(securitySchemeApiInternalKey) + .addList(securitySchemeApiKey)) .components(new Components() .addSecuritySchemes(securitySchemeBearer, new SecurityScheme() @@ -35,9 +40,13 @@ public OpenAPI customOpenAPI() { .type(SecurityScheme.Type.HTTP) .scheme("bearer") .bearerFormat("JWT")) - .addSecuritySchemes(securitySchemeApiKey, new SecurityScheme() + .addSecuritySchemes(securitySchemeApiInternalKey, new SecurityScheme() .name("Utm-Internal-Key") .type(SecurityScheme.Type.APIKEY) + .in(SecurityScheme.In.HEADER)) + .addSecuritySchemes(securitySchemeApiKey, new SecurityScheme() + .name(Constants.API_KEY_HEADER) + .type(SecurityScheme.Type.APIKEY) .in(SecurityScheme.In.HEADER))) .info(new Info().title(apiTitle).version(version)) .addServersItem(new Server().url("/").description("Default Server URL")); diff --git a/backend/src/main/java/com/park/utmstack/config/SecurityConfiguration.java b/backend/src/main/java/com/park/utmstack/config/SecurityConfiguration.java index 9fe019ace..f113192fd 100644 --- a/backend/src/main/java/com/park/utmstack/config/SecurityConfiguration.java +++ b/backend/src/main/java/com/park/utmstack/config/SecurityConfiguration.java @@ -1,11 +1,19 @@ package com.park.utmstack.config; +import com.park.utmstack.loggin.api_key.ApiKeyUsageLoggingService; import com.park.utmstack.loggin.filter.MdcCleanupFilter; +import com.park.utmstack.repository.UserRepository; import com.park.utmstack.security.AuthoritiesConstants; +import com.park.utmstack.security.api_key.ApiKeyConfigurer; +import com.park.utmstack.security.api_key.ApiKeyFilter; import com.park.utmstack.security.internalApiKey.InternalApiKeyConfigurer; import com.park.utmstack.security.internalApiKey.InternalApiKeyProvider; import com.park.utmstack.security.jwt.JWTConfigurer; +import com.park.utmstack.security.jwt.JWTFilter; import com.park.utmstack.security.jwt.TokenProvider; +import com.park.utmstack.service.api_key.ApiKeyService; +import lombok.RequiredArgsConstructor; +import org.apache.commons.net.util.SubnetUtils; import org.springframework.beans.factory.BeanInitializationException; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; @@ -29,8 +37,11 @@ import javax.annotation.PostConstruct; import javax.servlet.http.HttpServletResponse; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; @Configuration +@RequiredArgsConstructor @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true) @Import(SecurityProblemSupport.class) @@ -41,17 +52,7 @@ public class SecurityConfiguration extends WebSecurityConfigurerAdapter { private final TokenProvider tokenProvider; private final CorsFilter corsFilter; private final InternalApiKeyProvider internalApiKeyProvider; - - public SecurityConfiguration(AuthenticationManagerBuilder authenticationManagerBuilder, - UserDetailsService userDetailsService, - TokenProvider tokenProvider, - CorsFilter corsFilter, InternalApiKeyProvider internalApiKeyProvider) { - this.authenticationManagerBuilder = authenticationManagerBuilder; - this.userDetailsService = userDetailsService; - this.tokenProvider = tokenProvider; - this.corsFilter = corsFilter; - this.internalApiKeyProvider = internalApiKeyProvider; - } + private final ApiKeyFilter apiKeyFilter; @PostConstruct public void init() { @@ -127,7 +128,10 @@ public void configure(HttpSecurity http) throws Exception { .and() .apply(securityConfigurerAdapterForJwt()) .and() - .apply(securityConfigurerAdapterForInternalApiKey()); + .apply(securityConfigurerAdapterForInternalApiKey()) + .and() + .apply(securityConfigurerAdapterForApiKey()) ; + } @@ -138,4 +142,9 @@ private JWTConfigurer securityConfigurerAdapterForJwt() { private InternalApiKeyConfigurer securityConfigurerAdapterForInternalApiKey() { return new InternalApiKeyConfigurer(internalApiKeyProvider); } + + private ApiKeyConfigurer securityConfigurerAdapterForApiKey() { + return new ApiKeyConfigurer(apiKeyFilter); + } + } diff --git a/backend/src/main/java/com/park/utmstack/domain/User.java b/backend/src/main/java/com/park/utmstack/domain/User.java index 54d3282b4..736115273 100644 --- a/backend/src/main/java/com/park/utmstack/domain/User.java +++ b/backend/src/main/java/com/park/utmstack/domain/User.java @@ -97,7 +97,7 @@ public class User extends AbstractAuditingEntity implements Serializable { private Boolean defaultPassword; @JsonIgnore - @ManyToMany + @ManyToMany(fetch = FetchType.EAGER) @JoinTable(name = "jhi_user_authority", joinColumns = {@JoinColumn(name = "user_id", referencedColumnName = "id")}, inverseJoinColumns = {@JoinColumn(name = "authority_name", referencedColumnName = "name")}) @BatchSize(size = 20) diff --git a/backend/src/main/java/com/park/utmstack/domain/api_keys/ApiKey.java b/backend/src/main/java/com/park/utmstack/domain/api_keys/ApiKey.java new file mode 100644 index 000000000..4986a7b58 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/domain/api_keys/ApiKey.java @@ -0,0 +1,44 @@ +package com.park.utmstack.domain.api_keys; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.persistence.*; +import java.io.Serializable; +import java.time.Instant; +import java.util.UUID; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "api_keys") +public class ApiKey implements Serializable { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long userId; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String apiKey; + + @Column + private String allowedIp; + + @Column(nullable = false) + private Instant createdAt; + + private Instant generatedAt; + + @Column + private Instant expiresAt; +} diff --git a/backend/src/main/java/com/park/utmstack/domain/api_keys/ApiKeyUsageLog.java b/backend/src/main/java/com/park/utmstack/domain/api_keys/ApiKeyUsageLog.java new file mode 100644 index 000000000..54e0c8d15 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/domain/api_keys/ApiKeyUsageLog.java @@ -0,0 +1,49 @@ +package com.park.utmstack.domain.api_keys; + +import com.park.utmstack.service.dto.auditable.AuditableDTO; +import lombok.*; + +import java.util.HashMap; +import java.util.Map; + +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ApiKeyUsageLog implements AuditableDTO { + + private String id; + private String apiKeyId; + private String apiKeyName; + private String userId; + private String timestamp; + private String endpoint; + private String address; + private String errorMessage; + private String queryParams; + private String payload; + private String userAgent; + private String httpMethod; + private String statusCode; + + @Override + public Map toAuditMap() { + Map map = new HashMap<>(); + + map.put("id", id); + map.put("api_key_id", apiKeyId); + map.put("api_key_name", apiKeyName); + map.put("user_id", userId); + map.put("timestamp", timestamp != null ? timestamp : null); + map.put("endpoint", endpoint); + map.put("address", address); + map.put("error_message", errorMessage); + map.put("query_params", queryParams); + map.put("user_agent", userAgent); + map.put("http_method", httpMethod); + map.put("status_code", statusCode); + + return map; + } +} diff --git a/backend/src/main/java/com/park/utmstack/domain/application_events/enums/ApplicationEventType.java b/backend/src/main/java/com/park/utmstack/domain/application_events/enums/ApplicationEventType.java index 9df92c191..eca45ec19 100644 --- a/backend/src/main/java/com/park/utmstack/domain/application_events/enums/ApplicationEventType.java +++ b/backend/src/main/java/com/park/utmstack/domain/application_events/enums/ApplicationEventType.java @@ -42,5 +42,9 @@ public enum ApplicationEventType { ERROR, WARNING, INFO, - MODULE_ACTIVATION_ATTEMPT, MODULE_ACTIVATION_SUCCESS, UNDEFINED + MODULE_ACTIVATION_ATTEMPT, + MODULE_ACTIVATION_SUCCESS, + API_KEY_ACCESS_SUCCESS, + API_KEY_ACCESS_FAILURE, + UNDEFINED } diff --git a/backend/src/main/java/com/park/utmstack/domain/shared_types/ApplicationLayer.java b/backend/src/main/java/com/park/utmstack/domain/shared_types/ApplicationLayer.java index 16bfac1cb..ed1a2af7f 100644 --- a/backend/src/main/java/com/park/utmstack/domain/shared_types/ApplicationLayer.java +++ b/backend/src/main/java/com/park/utmstack/domain/shared_types/ApplicationLayer.java @@ -7,6 +7,7 @@ @Getter public enum ApplicationLayer { SERVICE ("SERVICE"), + API ("API"), CONTROLLER ("CONTROLLER"); private final String value; diff --git a/backend/src/main/java/com/park/utmstack/loggin/api_key/ApiKeyUsageLoggingService.java b/backend/src/main/java/com/park/utmstack/loggin/api_key/ApiKeyUsageLoggingService.java new file mode 100644 index 000000000..444f22576 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/loggin/api_key/ApiKeyUsageLoggingService.java @@ -0,0 +1,139 @@ +package com.park.utmstack.loggin.api_key; + +import com.park.utmstack.domain.api_keys.ApiKey; +import com.park.utmstack.domain.api_keys.ApiKeyUsageLog; +import com.park.utmstack.domain.application_events.enums.ApplicationEventType; +import com.park.utmstack.service.api_key.ApiKeyService; +import com.park.utmstack.service.application_events.ApplicationEventService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.util.ContentCachingRequestWrapper; +import org.springframework.web.util.ContentCachingResponseWrapper; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.UUID; + +import static org.postgresql.PGProperty.APPLICATION_NAME; + +@Service +@Slf4j +@RequiredArgsConstructor +public class ApiKeyUsageLoggingService { + + private final ApiKeyService apiKeyService; + private final ApplicationEventService applicationEventService; + private static final String LOG_USAGE_FLAG = "LOG_USAGE_DONE"; + + public void logUsage(ContentCachingRequestWrapper request, + ContentCachingResponseWrapper response, + ApiKey apiKey, + String ipAddress, + String message) { + + if (Boolean.TRUE.equals(request.getAttribute(LOG_USAGE_FLAG))) { + return; + } + + try { + String payload = extractPayload(request); + String errorText = extractErrorText(response); + int status = safeStatus(response); + + ApiKeyUsageLog usage = buildUsageLog(apiKey, ipAddress, request, status, errorText, payload); + + apiKeyService.logUsage(usage); + + ApplicationEventType eventType = (status >= 400) + ? ApplicationEventType.API_KEY_ACCESS_FAILURE + : ApplicationEventType.API_KEY_ACCESS_SUCCESS; + + String eventMessage = (status >= 400) + ? "API key access failure" + : "API key access"; + + applicationEventService.createEvent(eventMessage, eventType, usage.toAuditMap()); + + } catch (Exception e) { + log.error("Error while logging API key usage: {}", e.getMessage(), e); + } finally { + request.setAttribute(LOG_USAGE_FLAG, Boolean.TRUE); + } + } + + private int safeStatus(HttpServletResponse response) { + try { + return response.getStatus(); + } catch (Exception e) { + return 0; + } + } + + private String extractPayload(ContentCachingRequestWrapper request) { + try { + if (!"GET".equalsIgnoreCase(request.getMethod()) && !"DELETE".equalsIgnoreCase(request.getMethod())) { + byte[] content = request.getContentAsByteArray(); + return content.length > 0 ? new String(content, StandardCharsets.UTF_8) : null; + } + } catch (Exception ex) { + log.error("Error extracting payload: {}", ex.getMessage()); + } + return null; + } + + private String extractErrorText(ContentCachingResponseWrapper response) { + int statusCode = response.getStatus(); + if (statusCode >= 400) { + byte[] content = response.getContentAsByteArray(); + String responseError = content.length > 0 ? new String(content, StandardCharsets.UTF_8) : null; + String errorHeader = response.getHeader("X-" + APPLICATION_NAME + "-error"); + return StringUtils.hasText(responseError) ? responseError : errorHeader; + } + return null; + } + + private ApiKeyUsageLog buildUsageLog(ApiKey apiKey, + String ipAddress, + HttpServletRequest request, + int status, + String errorText, + String payload) { + + String id = UUID.randomUUID().toString(); + String apiKeyId = apiKey != null && apiKey.getId() != null ? apiKey.getId().toString() : null; + String apiKeyName = apiKey != null ? apiKey.getName() : null; + String userId = apiKey != null && apiKey.getUserId() != null ? apiKey.getUserId().toString() : null; + String timestamp = Instant.now().toString(); + String endpoint = request != null ? request.getRequestURI() : null; + String queryParams = request != null ? request.getQueryString() : null; + String userAgent = request != null ? request.getHeader("User-Agent") : null; + String httpMethod = request != null ? request.getMethod() : null; + String statusCode = String.valueOf(status); + + String safePayload = null; + if (payload != null) { + int PAYLOAD_MAX_LENGTH = 2000; + safePayload = payload.length() > PAYLOAD_MAX_LENGTH ? payload.substring(0, PAYLOAD_MAX_LENGTH) : payload; + } + + return ApiKeyUsageLog.builder() + .id(id) + .apiKeyId(apiKeyId) + .apiKeyName(apiKeyName) + .userId(userId) + .timestamp(timestamp) + .endpoint(endpoint) + .address(ipAddress) + .errorMessage(errorText) + .queryParams(queryParams) + .payload(safePayload) + .userAgent(userAgent) + .httpMethod(httpMethod) + .statusCode(statusCode) + .build(); + } +} diff --git a/backend/src/main/java/com/park/utmstack/repository/api_key/ApiKeyRepository.java b/backend/src/main/java/com/park/utmstack/repository/api_key/ApiKeyRepository.java new file mode 100644 index 000000000..ece32eba9 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/repository/api_key/ApiKeyRepository.java @@ -0,0 +1,29 @@ +package com.park.utmstack.repository.api_key; + +import com.park.utmstack.domain.api_keys.ApiKey; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import javax.validation.constraints.NotNull; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface ApiKeyRepository extends JpaRepository { + + Optional findByIdAndUserId(Long id, Long userId); + + Page findByUserId(Long userId, Pageable pageable); + + @Cacheable(cacheNames = "apikey", key = "#root.args[0]") + Optional findOneByApiKey(@NotNull String apiKey); + + Optional findByNameAndUserId(@NotNull String name, Long userId); + + List findAllByExpiresAtAfterAndExpiresAtLessThanEqual(Instant now, Instant fiveDaysFromNow); +} diff --git a/backend/src/main/java/com/park/utmstack/security/api_key/ApiKeyConfigurer.java b/backend/src/main/java/com/park/utmstack/security/api_key/ApiKeyConfigurer.java new file mode 100644 index 000000000..871260236 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/security/api_key/ApiKeyConfigurer.java @@ -0,0 +1,20 @@ +package com.park.utmstack.security.api_key; + +import com.park.utmstack.security.jwt.JWTFilter; +import org.springframework.security.config.annotation.SecurityConfigurerAdapter; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.DefaultSecurityFilterChain; + +public class ApiKeyConfigurer extends SecurityConfigurerAdapter { + + private final ApiKeyFilter apiKeyFilter; + + public ApiKeyConfigurer(ApiKeyFilter apiKeyFilter) { + this.apiKeyFilter = apiKeyFilter; + } + + @Override + public void configure(HttpSecurity http) throws Exception { + http.addFilterAfter(apiKeyFilter, JWTFilter.class); + } +} diff --git a/backend/src/main/java/com/park/utmstack/security/api_key/ApiKeyFilter.java b/backend/src/main/java/com/park/utmstack/security/api_key/ApiKeyFilter.java new file mode 100644 index 000000000..5e5fd9100 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/security/api_key/ApiKeyFilter.java @@ -0,0 +1,185 @@ +package com.park.utmstack.security.api_key; + + +import com.park.utmstack.config.Constants; +import com.park.utmstack.domain.api_keys.ApiKey; +import com.park.utmstack.loggin.api_key.ApiKeyUsageLoggingService; +import com.park.utmstack.repository.UserRepository; +import com.park.utmstack.service.api_key.ApiKeyService; +import com.park.utmstack.service.application_events.ApplicationEventService; +import com.park.utmstack.util.exceptions.ApiKeyInvalidAccessException; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.net.util.SubnetUtils; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +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.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.ContentCachingRequestWrapper; +import org.springframework.web.util.ContentCachingResponseWrapper; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.regex.Pattern; + +import static com.park.utmstack.config.Constants.API_ENDPOINT_IGNORE; + +@Slf4j +@Component +@AllArgsConstructor +public class ApiKeyFilter extends OncePerRequestFilter { + + private static final String LOG_USAGE_FLAG = "LOG_USAGE_DONE"; + private static final Pattern CIDR_PATTERN = Pattern.compile( + "^((25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)\\.){3}(25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)/(\\d|[1-2]\\d|3[0-2])$" + ); + + + private final UserRepository userRepository; + private final ApiKeyService apiKeyService; + private final ConcurrentMap invalidApiKeyBlackList = new ConcurrentHashMap<>(); + private final ConcurrentMap cidrCache = new ConcurrentHashMap<>(); + private final ApiKeyUsageLoggingService apiKeyUsageLoggingService; + + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + + if (API_ENDPOINT_IGNORE.contains(request.getRequestURI())) { + filterChain.doFilter(request, response); + return; + } + + if (request.getAttribute(LOG_USAGE_FLAG) != null) { + filterChain.doFilter(request, response); + return; + } + + String apiKey = request.getHeader(Constants.API_KEY_HEADER); + + if (!StringUtils.hasText(apiKey)) { + filterChain.doFilter(request, response); + return; + } + + String ipAddress = request.getRemoteAddr(); + var key = getApiKey(apiKey); + + var wrappedRequest = new ContentCachingRequestWrapper(request); + var wrappedResponse = new ContentCachingResponseWrapper(response); + + UsernamePasswordAuthenticationToken authentication; + + try { + authentication = getAuthentication(key, ipAddress); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(wrappedRequest)); + SecurityContextHolder.getContext().setAuthentication(authentication); + + } catch (ApiKeyInvalidAccessException e) { + apiKeyUsageLoggingService.logUsage(wrappedRequest, wrappedResponse, key, ipAddress, e.getMessage()); + throw e; + } + + filterChain.doFilter(wrappedRequest, wrappedResponse); + wrappedResponse.copyBodyToResponse(); + + apiKeyUsageLoggingService.logUsage(wrappedRequest, wrappedResponse, key, ipAddress, null); + } + + private ApiKey getApiKey(String apiKey) { + if (invalidApiKeyBlackList.containsKey(apiKey)) { + log.warn("Access attempt with invalid API key (cached)"); + throw new ApiKeyInvalidAccessException("Invalid API key"); + } + + return apiKeyService.findOneByApiKey(apiKey) + .orElseGet(() -> { + invalidApiKeyBlackList.put(apiKey, Boolean.TRUE); + log.warn("Access attempt with invalid API key (not found in DB)"); + throw new ApiKeyInvalidAccessException("Invalid API key"); + }); + } + + public UsernamePasswordAuthenticationToken getAuthentication(ApiKey apiKey, String remoteIpAddress) { + Objects.requireNonNull(apiKey, "API key must not be null"); + Objects.requireNonNull(remoteIpAddress, "Remote IP address must not be null"); + + if (!allowAccessToRemoteIp(apiKey.getAllowedIp(), remoteIpAddress)) { + log.warn("Access denied: IP [{}] not allowed for API key [{}]", remoteIpAddress, apiKey.getApiKey()); + throw new ApiKeyInvalidAccessException( + "Invalid IP address: " + remoteIpAddress + ". If you recognize this IP, add it to allowed IP list." + ); + } + + if (apiKey.getExpiresAt() != null && !apiKey.getExpiresAt().isAfter(Instant.now())) { + log.warn("Access denied: API key [{}] expired at {}", apiKey.getApiKey(), apiKey.getExpiresAt()); + throw new ApiKeyInvalidAccessException("API key expired at " + apiKey.getExpiresAt()); + } + + var userEntityOpt = userRepository.findById(apiKey.getUserId()); + if (userEntityOpt.isEmpty()) { + log.warn("Access denied: User [{}] not found for API key [{}]", apiKey.getUserId(), apiKey.getApiKey()); + throw new ApiKeyInvalidAccessException("User not found for API key"); + } + + var userEntity = userEntityOpt.get(); + + if (!userEntity.getActivated()) { + log.warn("Access denied: User [{}] not activated", userEntity.getLogin()); + throw new ApiKeyInvalidAccessException("User not activated"); + } + + List authorities = userEntity.getAuthorities().stream() + .map(auth -> new SimpleGrantedAuthority(auth.getName())) + .toList(); + + User principal = new User(userEntity.getLogin(), "", authorities); + + return new UsernamePasswordAuthenticationToken(principal, apiKey.getApiKey(), authorities); + } + + public boolean allowAccessToRemoteIp(String allowedIpList, String remoteIp) { + if (allowedIpList == null || allowedIpList.trim().isEmpty()) { + return true; + } + String[] whitelistIps = allowedIpList.split(","); + for (String ip : whitelistIps) { + String allowed = ip.trim(); + if (allowed.isEmpty()) { + continue; + } + if (CIDR_PATTERN.matcher(allowed).matches()) { + try { + SubnetUtils subnetUtils = cidrCache.computeIfAbsent(allowed, key -> { + SubnetUtils su = new SubnetUtils(key); + su.setInclusiveHostCount(true); + return su; + }); + if (subnetUtils.getInfo().isInRange(remoteIp)) { + return true; + } + } catch (IllegalArgumentException e) { + log.error("Invalid CIDR notation: {}", allowed); + } + } else if (allowed.equals(remoteIp)) { + return true; + } + } + return false; + } +} diff --git a/backend/src/main/java/com/park/utmstack/service/UserService.java b/backend/src/main/java/com/park/utmstack/service/UserService.java index ed1a6379b..5dacde192 100644 --- a/backend/src/main/java/com/park/utmstack/service/UserService.java +++ b/backend/src/main/java/com/park/utmstack/service/UserService.java @@ -301,6 +301,7 @@ public List getAuthorities() { public User getCurrentUserLogin() { String userLogin = SecurityUtils.getCurrentUserLogin().orElseThrow(() -> new CurrentUserLoginNotFoundException("No current user login was found")); - return userRepository.findOneWithAuthoritiesByLogin(userLogin).orElseThrow(() -> new CurrentUserLoginNotFoundException(String.format("No user with login %1$s was found", userLogin))); + return userRepository.findOneWithAuthoritiesByLogin(userLogin) + .orElseThrow(() -> new CurrentUserLoginNotFoundException(String.format("No user with login %1$s was found", userLogin))); } } diff --git a/backend/src/main/java/com/park/utmstack/service/api_key/ApiKeyService.java b/backend/src/main/java/com/park/utmstack/service/api_key/ApiKeyService.java new file mode 100644 index 000000000..cbc2784a5 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/service/api_key/ApiKeyService.java @@ -0,0 +1,195 @@ +package com.park.utmstack.service.api_key; + +import com.park.utmstack.domain.api_keys.ApiKey; +import com.park.utmstack.domain.api_keys.ApiKeyUsageLog; +import com.park.utmstack.repository.api_key.ApiKeyRepository; +import com.park.utmstack.service.UserService; +import com.park.utmstack.service.dto.api_key.ApiKeyResponseDTO; +import com.park.utmstack.service.dto.api_key.ApiKeyUpsertDTO; +import com.park.utmstack.service.elasticsearch.OpensearchClientBuilder; +import com.park.utmstack.service.mapper.ApiKeyMapper; +import com.park.utmstack.util.exceptions.ApiKeyExistException; +import com.park.utmstack.util.exceptions.ApiKeyNotFoundException; +import lombok.AllArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Optional; +import java.util.UUID; + +import static com.park.utmstack.config.Constants.V11_API_ACCESS_LOGS; + +@Service +@AllArgsConstructor +public class ApiKeyService { + + private static final String CLASSNAME = "ApiKeyService"; + private final Logger log = LoggerFactory.getLogger(ApiKeyService.class); + private final ApiKeyRepository apiKeyRepository; + private final ApiKeyMapper apiKeyMapper; + private final OpensearchClientBuilder client; + + + public ApiKeyResponseDTO createApiKey(Long userId,ApiKeyUpsertDTO dto) { + final String ctx = CLASSNAME + ".createApiKey"; + try { + apiKeyRepository.findByNameAndUserId(dto.getName(), userId) + .ifPresent(apiKey -> { + throw new ApiKeyExistException("Api key already exists"); + }); + + var apiKey = ApiKey.builder() + .userId(userId) + .name(dto.getName()) + .expiresAt(dto.getExpiresAt()) + .allowedIp(String.join(",", dto.getAllowedIp())) + .createdAt(Instant.now()) + .generatedAt(Instant.now()) + .apiKey(generateRandomKey()) + .build(); + + return apiKeyMapper.toDto(apiKeyRepository.save(apiKey)); + } catch (Exception e) { + throw new ApiKeyExistException(ctx + ": " + e.getMessage()); + } + } + + public String generateApiKey(Long userId, Long apiKeyId) { + final String ctx = CLASSNAME + ".generateApiKey"; + try { + ApiKey apiKey = apiKeyRepository.findByIdAndUserId(apiKeyId, userId) + .orElseThrow(() -> new ApiKeyNotFoundException("API key not found")); + + Instant now = Instant.now(); + Instant originalCreated = apiKey.getGeneratedAt() != null ? apiKey.getGeneratedAt() : apiKey.getCreatedAt(); + Instant originalExpires = apiKey.getExpiresAt(); + + Duration duration; + if (originalCreated != null && originalExpires != null && !originalExpires.isBefore(originalCreated)) { + duration = Duration.between(originalCreated, originalExpires); + } else { + duration = Duration.ofDays(7); + } + + String plainKey = generateRandomKey(); + apiKey.setApiKey(plainKey); + apiKey.setGeneratedAt(Instant.now()); + apiKey.setExpiresAt(now.plus(duration)); + apiKeyRepository.save(apiKey); + return plainKey; + } catch (Exception e) { + throw new RuntimeException(ctx + ": " + e.getMessage()); + } + } + + public ApiKeyResponseDTO updateApiKey(Long userId, Long apiKeyId, ApiKeyUpsertDTO dto) { + final String ctx = CLASSNAME + ".updateApiKey"; + try { + ApiKey apiKey = apiKeyRepository.findByIdAndUserId(apiKeyId, userId) + .orElseThrow(() -> new ApiKeyNotFoundException("API key not found")); + apiKey.setName(dto.getName()); + if (dto.getAllowedIp() != null) { + apiKey.setAllowedIp(String.join(",", dto.getAllowedIp())); + } else { + apiKey.setAllowedIp(null); + } + apiKey.setExpiresAt(dto.getExpiresAt()); + ApiKey updated = apiKeyRepository.save(apiKey); + return apiKeyMapper.toDto(updated); + } catch (Exception e) { + throw new RuntimeException(ctx + ": " + e.getMessage()); + } + } + + public ApiKeyResponseDTO getApiKey(Long userId, Long apiKeyId) { + final String ctx = CLASSNAME + ".getApiKey"; + try { + ApiKey apiKey = apiKeyRepository.findByIdAndUserId(apiKeyId, userId) + .orElseThrow(() -> new ApiKeyNotFoundException("API key not found")); + return apiKeyMapper.toDto(apiKey); + } catch (Exception e) { + throw new RuntimeException(ctx + ": " + e.getMessage()); + } + } + + public Page listApiKeys(Long userId, Pageable pageable) { + final String ctx = CLASSNAME + ".listApiKeys"; + try { + return apiKeyRepository.findByUserId(userId, pageable).map(apiKeyMapper::toDto); + } catch (Exception e) { + throw new RuntimeException(ctx + ": " + e.getMessage()); + } + } + + + public void deleteApiKey(Long userId, Long apiKeyId) { + final String ctx = CLASSNAME + ".deleteApiKey"; + try { + ApiKey apiKey = apiKeyRepository.findByIdAndUserId(apiKeyId, userId) + .orElseThrow(() -> new ApiKeyNotFoundException("API key not found")); + apiKeyRepository.delete(apiKey); + } catch (Exception e) { + throw new RuntimeException(ctx + ": " + e.getMessage()); + } + } + + private String generateRandomKey() { + final String ctx = CLASSNAME + ".generateRandomKey"; + try { + SecureRandom random = new SecureRandom(); + byte[] keyBytes = new byte[32]; + random.nextBytes(keyBytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(keyBytes); + } catch (Exception e) { + throw new RuntimeException(ctx + ": " + e.getMessage()); + } + } + + @Async + public void logUsage(ApiKeyUsageLog apiKeyUsageLog) { + final String ctx = CLASSNAME + ".logUsage"; + try { + client.getClient().index(V11_API_ACCESS_LOGS, apiKeyUsageLog); + } catch (Exception e) { + log.error(ctx + ": {}", e.getMessage()); + } + } + + public Optional findOneByApiKey(String apiKey) { + return apiKeyRepository.findOneByApiKey(apiKey); + } + + + /*@Scheduled(cron = "0 0 9 * * ?") + public void checkExpiringApiKeys() { + Instant fiveDaysFromNow = Instant.now().plus(5, ChronoUnit.DAYS); + Instant now = Instant.now(); + List expiringKeys = apiKeyRepository.findAllByExpiresAtAfterAndExpiresAtLessThanEqual(now, fiveDaysFromNow); + + if (!expiringKeys.isEmpty()) { + Map> expiringKeysByAccount = expiringKeys.stream() + .collect(Collectors.groupingBy(ApiKey::getUserId)); + + expiringKeysByAccount.forEach((userId, apiKeys) -> { + var principal = userRepository.findByuserIdAndAccountOwnerIsTrue(userId.toString()).orElse(null); + if (principal == null) { + return; + } + mailService.sendKeyExpirationEmail(principal, apiKeys); + + userNotificationService.createAndSendNotification(principal.getUuid(), + NotificationMessageKeyEnum.apiKey_EXPIRATION, + Map.of("names", apiKeys.stream().map(ApiKey::getName).collect(Collectors.joining(",")))); + }); + } + }*/ +} diff --git a/backend/src/main/java/com/park/utmstack/service/application_events/ApplicationEventService.java b/backend/src/main/java/com/park/utmstack/service/application_events/ApplicationEventService.java index 149e387c3..450f60d37 100644 --- a/backend/src/main/java/com/park/utmstack/service/application_events/ApplicationEventService.java +++ b/backend/src/main/java/com/park/utmstack/service/application_events/ApplicationEventService.java @@ -40,7 +40,7 @@ public void createEvent(String message, ApplicationEventType type) { .message(message).timestamp(Instant.now().toString()) .source(ApplicationEventSource.PANEL.name()).type(type.name()) .build(); - /*client.getClient().index(".utmstack-logs", applicationEvent);*/ + client.getClient().index(".utmstack-logs", applicationEvent); } catch (Exception e) { log.error(ctx + ": {}", e.getMessage()); } diff --git a/backend/src/main/java/com/park/utmstack/service/dto/api_key/ApiKeyResponseDTO.java b/backend/src/main/java/com/park/utmstack/service/dto/api_key/ApiKeyResponseDTO.java new file mode 100644 index 000000000..abfa4d02d --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/service/dto/api_key/ApiKeyResponseDTO.java @@ -0,0 +1,36 @@ +package com.park.utmstack.service.dto.api_key; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ApiKeyResponseDTO { + + @Schema(description = "Unique identifier of the API key") + private Long id; + + @Schema(description = "User-friendly API key name") + private String name; + + @Schema(description = "Allowed IP address or IP range in CIDR notation (e.g., '192.168.1.100' or '192.168.1.0/24')") + private List allowedIp; + + @Schema(description = "API key creation timestamp") + private Instant createdAt; + + @Schema(description = "API key expiration timestamp (if applicable)") + private Instant expiresAt; + + @Schema(description = "Generated At") + private Instant generatedAt; +} diff --git a/backend/src/main/java/com/park/utmstack/service/dto/api_key/ApiKeyUpsertDTO.java b/backend/src/main/java/com/park/utmstack/service/dto/api_key/ApiKeyUpsertDTO.java new file mode 100644 index 000000000..6345f2032 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/service/dto/api_key/ApiKeyUpsertDTO.java @@ -0,0 +1,28 @@ +package com.park.utmstack.service.dto.api_key; + +import com.park.utmstack.validation.api_key.ValidIPOrCIDR; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotNull; +import java.time.Instant; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ApiKeyUpsertDTO { + @NotNull + @Schema(description = "API Key name", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + @Schema(description = "Allowed IP address or IP range in CIDR notation (e.g., '192.168.1.100' or '192.168.1.0/24'). If null, no IP restrictions are applied.") + private List<@ValidIPOrCIDR String> allowedIp; + + @Schema(description = "Expiration timestamp of the API key") + private Instant expiresAt; +} diff --git a/backend/src/main/java/com/park/utmstack/service/mapper/ApiKeyMapper.java b/backend/src/main/java/com/park/utmstack/service/mapper/ApiKeyMapper.java new file mode 100644 index 000000000..0439c563b --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/service/mapper/ApiKeyMapper.java @@ -0,0 +1,31 @@ +package com.park.utmstack.service.mapper; + +import com.park.utmstack.domain.api_keys.ApiKey; +import com.park.utmstack.service.dto.api_key.ApiKeyResponseDTO; +import org.mapstruct.Mapper; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Optional; +import java.util.stream.Collectors; + +@Mapper(componentModel = "spring") +public class ApiKeyMapper { + + public ApiKeyResponseDTO toDto(ApiKey apiKey){ + return ApiKeyResponseDTO.builder() + .id(apiKey.getId()) + .name(apiKey.getName()) + .createdAt(apiKey.getCreatedAt()) + .expiresAt(apiKey.getExpiresAt()) + .allowedIp( + Optional.ofNullable(apiKey.getAllowedIp()) + .map(s -> Arrays.stream(s.split(",")) + .map(String::trim) + .filter(str -> !str.isEmpty()) + .collect(Collectors.toList())) + .orElse(Collections.emptyList()) + ) + .build(); + } +} diff --git a/backend/src/main/java/com/park/utmstack/util/exceptions/ApiKeyExistException.java b/backend/src/main/java/com/park/utmstack/util/exceptions/ApiKeyExistException.java new file mode 100644 index 000000000..20577f508 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/util/exceptions/ApiKeyExistException.java @@ -0,0 +1,7 @@ +package com.park.utmstack.util.exceptions; + +public class ApiKeyExistException extends RuntimeException { + public ApiKeyExistException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/com/park/utmstack/util/exceptions/ApiKeyInvalidAccessException.java b/backend/src/main/java/com/park/utmstack/util/exceptions/ApiKeyInvalidAccessException.java new file mode 100644 index 000000000..c5a13ead4 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/util/exceptions/ApiKeyInvalidAccessException.java @@ -0,0 +1,9 @@ +package com.park.utmstack.util.exceptions; + +import org.springframework.security.core.AuthenticationException; + +public class ApiKeyInvalidAccessException extends AuthenticationException { + public ApiKeyInvalidAccessException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/com/park/utmstack/util/exceptions/ApiKeyNotFoundException.java b/backend/src/main/java/com/park/utmstack/util/exceptions/ApiKeyNotFoundException.java new file mode 100644 index 000000000..173d8f442 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/util/exceptions/ApiKeyNotFoundException.java @@ -0,0 +1,7 @@ +package com.park.utmstack.util.exceptions; + +public class ApiKeyNotFoundException extends RuntimeException { + public ApiKeyNotFoundException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/com/park/utmstack/validation/api_key/ValidIPOrCIDR.java b/backend/src/main/java/com/park/utmstack/validation/api_key/ValidIPOrCIDR.java new file mode 100644 index 000000000..55dfe9593 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/validation/api_key/ValidIPOrCIDR.java @@ -0,0 +1,23 @@ +package com.park.utmstack.validation.api_key; + + +import javax.validation.Constraint; +import javax.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.*; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@Documented +@Constraint(validatedBy = ValidIPOrCIDRValidator.class) +@Target({FIELD, METHOD, PARAMETER, ANNOTATION_TYPE, TYPE_USE}) +@Retention(RUNTIME) +public @interface ValidIPOrCIDR { + String message() default "Invalid IP address or CIDR notation"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/backend/src/main/java/com/park/utmstack/validation/api_key/ValidIPOrCIDRValidator.java b/backend/src/main/java/com/park/utmstack/validation/api_key/ValidIPOrCIDRValidator.java new file mode 100644 index 000000000..8324094f8 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/validation/api_key/ValidIPOrCIDRValidator.java @@ -0,0 +1,37 @@ +package com.park.utmstack.validation.api_key; + + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; +import java.util.regex.Pattern; + +public class ValidIPOrCIDRValidator implements ConstraintValidator { + + private static final Pattern IPV4_PATTERN = Pattern.compile( + "^(?:(?:25[0-5]|2[0-4]\\d|[01]?\\d?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|[01]?\\d?\\d)$" + ); + + private static final Pattern IPV4_CIDR_PATTERN = Pattern.compile( + "^(?:(?:25[0-5]|2[0-4]\\d|[01]?\\d?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|[01]?\\d?\\d)/(\\d|[1-2]\\d|3[0-2])$" + ); + private static final Pattern IPV6_PATTERN = Pattern.compile( + "^(?:[\\da-fA-F]{1,4}:){7}[\\da-fA-F]{1,4}$" + ); + + private static final Pattern IPV6_CIDR_PATTERN = Pattern.compile( + "^(?:[\\da-fA-F]{1,4}:){7}[\\da-fA-F]{1,4}/(\\d|[1-9]\\d|1[01]\\d|12[0-8])$" + ); + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + // Allow null or empty values; use @NotNull/@NotEmpty to enforce non-null if needed. + if (value == null || value.trim().isEmpty()) { + return true; + } + String trimmed = value.trim(); + if (IPV4_PATTERN.matcher(trimmed).matches() || IPV4_CIDR_PATTERN.matcher(trimmed).matches()) { + return true; + } + return IPV6_PATTERN.matcher(trimmed).matches() || IPV6_CIDR_PATTERN.matcher(trimmed).matches(); + } +} diff --git a/backend/src/main/java/com/park/utmstack/web/rest/api_key/ApiKeyResource.java b/backend/src/main/java/com/park/utmstack/web/rest/api_key/ApiKeyResource.java new file mode 100644 index 000000000..aa6b2052a --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/web/rest/api_key/ApiKeyResource.java @@ -0,0 +1,184 @@ +package com.park.utmstack.web.rest.api_key; + + +import com.park.utmstack.domain.chart_builder.types.query.FilterType; +import com.park.utmstack.security.AuthoritiesConstants; +import com.park.utmstack.service.UserService; +import com.park.utmstack.service.api_key.ApiKeyService; +import com.park.utmstack.service.dto.api_key.ApiKeyResponseDTO; +import com.park.utmstack.service.dto.api_key.ApiKeyUpsertDTO; +import com.park.utmstack.service.elasticsearch.ElasticsearchService; +import com.park.utmstack.util.UtilPagination; +import com.park.utmstack.web.rest.elasticsearch.ElasticsearchResource; +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.headers.Header; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opensearch.client.opensearch.core.SearchResponse; +import org.opensearch.client.opensearch.core.search.Hit; +import org.opensearch.client.opensearch.core.search.HitsMetadata; +import org.springdoc.api.annotations.ParameterObject; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import tech.jhipster.web.util.PaginationUtil; + +import java.util.*; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/api-keys") +@PreAuthorize("hasAuthority(\"" + AuthoritiesConstants.USER + "\")") +@Slf4j +@AllArgsConstructor +@Hidden +public class ApiKeyResource { + + private final ApiKeyService apiKeyService; + private final ElasticsearchService elasticsearchService; + private final UserService userService; + + @Operation(summary = "Create API key", + description = "Creates a new API key record using the provided settings. The plain text key is not generated at creation.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "API key created successfully", + content = @Content(schema = @Schema(implementation = ApiKeyResponseDTO.class))), + @ApiResponse(responseCode = "409", description = "API key already exists", content = @Content), + @ApiResponse(responseCode = "500", description = "Internal server error", + content = @Content, headers = { + @Header(name = "X-App-Error", description = "Technical error details") + }) + }) + @PostMapping + public ResponseEntity createApiKey(@RequestBody ApiKeyUpsertDTO dto) { + Long userId = userService.getCurrentUserLogin().getId(); + ApiKeyResponseDTO responseDTO = apiKeyService.createApiKey(userId, dto); + return ResponseEntity.status(HttpStatus.CREATED).body(responseDTO); + } + + @Operation(summary = "Generate a new API key", + description = "Generates (or renews) a new random API key for the specified API key record. The plain text key is returned only once.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "API key generated successfully", + content = @Content(schema = @Schema(type = "string"))), + @ApiResponse(responseCode = "404", description = "API key not found", content = @Content), + @ApiResponse(responseCode = "500", description = "Internal server error", + content = @Content, headers = { + @Header(name = "X-App-Error", description = "Technical error details") + }) + }) + @PostMapping("/{id}/generate") + public ResponseEntity generateApiKey(@PathVariable("id") Long apiKeyId) { + Long userId = userService.getCurrentUserLogin().getId(); + String plainKey = apiKeyService.generateApiKey(userId, apiKeyId); + return ResponseEntity.ok(plainKey); + } + + @Operation(summary = "Retrieve API key", + description = "Retrieves the API key details for the specified API key record.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "API key retrieved successfully", + content = @Content(schema = @Schema(implementation = ApiKeyResponseDTO.class))), + @ApiResponse(responseCode = "404", description = "API key not found", content = @Content), + @ApiResponse(responseCode = "500", description = "Internal server error", + content = @Content, headers = { + @Header(name = "X-App-Error", description = "Technical error details") + }) + }) + @GetMapping("/{id}") + public ResponseEntity getApiKey(@PathVariable("id") Long apiKeyId) { + Long userId = userService.getCurrentUserLogin().getId(); + ApiKeyResponseDTO responseDTO = apiKeyService.getApiKey(userId, apiKeyId); + return ResponseEntity.ok(responseDTO); + } + + @Operation(summary = "List API keys", + description = "Retrieves the API key list.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "API key retrieved successfully", + content = @Content(schema = @Schema(implementation = ApiKeyResponseDTO.class))), + @ApiResponse(responseCode = "404", description = "API key not found", content = @Content), + @ApiResponse(responseCode = "500", description = "Internal server error", + content = @Content, headers = { + @Header(name = "X-App-Error", description = "Technical error details") + }) + }) + @GetMapping + public ResponseEntity> listApiKeys(@ParameterObject Pageable pageable) { + Long userId = userService.getCurrentUserLogin().getId(); + Page page = apiKeyService.listApiKeys(userId,pageable); + HttpHeaders headers = PaginationUtil.generatePaginationHttpHeaders(ServletUriComponentsBuilder.fromCurrentRequest(), page); + + return ResponseEntity.ok().headers(headers).body(page.getContent()); + } + + @Operation(summary = "Update API key", + description = "Updates mutable fields (name, allowed IPs, expiration) for the specified API key record.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "API key updated successfully", + content = @Content(schema = @Schema(implementation = ApiKeyResponseDTO.class))), + @ApiResponse(responseCode = "404", description = "API key not found", content = @Content), + @ApiResponse(responseCode = "500", description = "Internal server error", + content = @Content, headers = { + @Header(name = "X-App-Error", description = "Technical error details") + }) + }) + @PutMapping("/{id}") + public ResponseEntity updateApiKey(@PathVariable("id") Long apiKeyId, + @RequestBody ApiKeyUpsertDTO dto) { + + Long userId = userService.getCurrentUserLogin().getId(); + ApiKeyResponseDTO responseDTO = apiKeyService.updateApiKey(userId, apiKeyId, dto); + return ResponseEntity.ok(responseDTO); + + } + + @Operation(summary = "Delete API key", + description = "Deletes the specified API key record for the authenticated user.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "API key deleted successfully", content = @Content), + @ApiResponse(responseCode = "404", description = "API key not found", content = @Content), + @ApiResponse(responseCode = "500", description = "Internal server error", + content = @Content, headers = { + @Header(name = "X-App-Error", description = "Technical error details") + }) + }) + @DeleteMapping("/{id}") + public ResponseEntity deleteApiKey(@PathVariable("id") Long apiKeyId) { + + Long userId = userService.getCurrentUserLogin().getId(); + apiKeyService.deleteApiKey(userId, apiKeyId); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/usage") + public ResponseEntity> search(@RequestBody(required = false) List filters, + @RequestParam Integer top, @RequestParam String indexPattern, + @RequestParam(required = false, defaultValue = "false") boolean includeChildren, + Pageable pageable) { + + SearchResponse searchResponse = elasticsearchService.search(filters, top, indexPattern, + pageable, Map.class); + + if (Objects.isNull(searchResponse) || Objects.isNull(searchResponse.hits()) || searchResponse.hits().total().value() == 0) + return ResponseEntity.ok(Collections.emptyList()); + + HitsMetadata hits = searchResponse.hits(); + HttpHeaders headers = UtilPagination.generatePaginationHttpHeaders(Math.min(hits.total().value(), top), + pageable.getPageNumber(), pageable.getPageSize(), "/api/elasticsearch/search"); + + return ResponseEntity.ok().headers(headers).body(hits.hits().stream() + .map(Hit::source).collect(Collectors.toList())); + + } +} diff --git a/backend/src/main/resources/config/liquibase/changelog/20251017001_create_api_keys_table.xml b/backend/src/main/resources/config/liquibase/changelog/20251017001_create_api_keys_table.xml new file mode 100644 index 000000000..e73010f6a --- /dev/null +++ b/backend/src/main/resources/config/liquibase/changelog/20251017001_create_api_keys_table.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/main/resources/config/liquibase/master.xml b/backend/src/main/resources/config/liquibase/master.xml index 5201db735..8735ef517 100644 --- a/backend/src/main/resources/config/liquibase/master.xml +++ b/backend/src/main/resources/config/liquibase/master.xml @@ -253,5 +253,7 @@ + + diff --git a/frontend/src/app/app-management/api-keys/api-keys.component.html b/frontend/src/app/app-management/api-keys/api-keys.component.html new file mode 100644 index 000000000..e2a5158ff --- /dev/null +++ b/frontend/src/app/app-management/api-keys/api-keys.component.html @@ -0,0 +1,151 @@ +
+
+
+
+ API Keys +
+ + The API key is a simple encrypted string that identifies you in the application. With this key, you can access the REST API. + +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Name + + Allowed IPs + + Expires At + + Created At + ACTIONS
{{ key.name }}{{ key.allowedIp?.join(', ') || '—' }} + + + + + + + + + + + + + {{ key.expiresAt ? (key.expiresAt | date:'dd/MM/yy HH:mm':'UTC') : '—' }} + {{ key.createdAt | date:'dd/MM/yy HH:mm' :'UTC' }} + + + +
+ +
+
+ + +
+
+
+ +
+
+ + + + +
+ +
+
+
+ + + + + +
+
+ Copy it now because it will be shown only once! +
+
+ {{ maskSecrets(generatedApiKey) }} + +
+
+ + {{ 'Keep this key safeKeep this key safe' }} +
+
+
+ +
+
diff --git a/frontend/src/app/app-management/api-keys/api-keys.component.scss b/frontend/src/app/app-management/api-keys/api-keys.component.scss new file mode 100644 index 000000000..b3751c83a --- /dev/null +++ b/frontend/src/app/app-management/api-keys/api-keys.component.scss @@ -0,0 +1,7 @@ +:host{ + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + height: 100%; +} diff --git a/frontend/src/app/app-management/api-keys/api-keys.component.ts b/frontend/src/app/app-management/api-keys/api-keys.component.ts new file mode 100644 index 000000000..82d9e3450 --- /dev/null +++ b/frontend/src/app/app-management/api-keys/api-keys.component.ts @@ -0,0 +1,232 @@ +import {Component, OnInit, TemplateRef, ViewChild} from '@angular/core'; +import {NgbModal, NgbModalRef} from '@ng-bootstrap/ng-bootstrap'; +import * as moment from 'moment'; +import {UtmToastService} from '../../shared/alert/utm-toast.service'; +import { + ModalConfirmationComponent +} from '../../shared/components/utm/util/modal-confirmation/modal-confirmation.component'; +import {ITEMS_PER_PAGE} from '../../shared/constants/pagination.constants'; +import {SortEvent} from '../../shared/directives/sortable/type/sort-event'; +import {ApiKeyModalComponent} from './shared/components/api-key-modal/api-key-modal.component'; +import {ApiKeyResponse} from './shared/models/ApiKeyResponse'; +import {ApiKeysService} from './shared/service/api-keys.service'; + +@Component({ + selector: 'app-api-keys', + templateUrl: './api-keys.component.html', + styleUrls: ['./api-keys.component.scss'] +}) +export class ApiKeysComponent implements OnInit { + + generating: string[] = []; + noData = false; + apiKeys: ApiKeyResponse[] = []; + loading = false; + generatedApiKey = ''; + @ViewChild('generatedModal') generatedModal!: TemplateRef; + generatedModalRef!: NgbModalRef; + copied = false; + readonly itemsPerPage = ITEMS_PER_PAGE; + totalItems = 0; + page = 0; + size = this.itemsPerPage; + + request = { + sort: 'createdAt,desc', + page: this.page, + size: this.size + }; + + constructor( private toastService: UtmToastService, + private apiKeyService: ApiKeysService, + private modalService: NgbModal + ) {} + + ngOnInit(): void { + this.loadKeys(); + } + + loadKeys(): void { + this.loading = true; + this.apiKeyService.list(this.request).subscribe({ + next: (res) => { + this.totalItems = Number(res.headers.get('X-Total-Count')); + this.apiKeys = res.body || []; + this.noData = this.apiKeys.length === 0; + this.loading = false; + }, + error: () => { + this.loading = false; + this.apiKeys = []; + } + }); + } + + copyToClipboard(): void { + if (!this.generatedApiKey) { return; } + + if (navigator && (navigator as any).clipboard && (navigator as any).clipboard.writeText) { + (navigator as any).clipboard.writeText(this.generatedApiKey) + .then(() => this.copied = true) + .catch(err => { + console.error('Error al copiar con clipboard API', err); + this.fallbackCopy(this.generatedApiKey); + }); + } else { + this.fallbackCopy(this.generatedApiKey); + } + } + + private fallbackCopy(text: string): void { + try { + const textarea = document.createElement('textarea'); + textarea.value = text; + + textarea.style.position = 'fixed'; + textarea.style.top = '0'; + textarea.style.left = '0'; + textarea.style.opacity = '0'; + + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + + const successful = document.execCommand('copy'); + document.body.removeChild(textarea); + + if (successful) { + this.showCopiedFeedback(); + } else { + console.warn('Fallback copy failed'); + } + } catch (err) { + console.error('Error en fallback copy', err); + } + } + + private showCopiedFeedback(): void { + this.copied = true; + setTimeout(() => this.copied = false, 2000); + } + + openCreateModal(): void { + const modalRef = this.modalService.open(ApiKeyModalComponent, { centered: true }); + + modalRef.result.then((key: ApiKeyResponse) => { + if (key) { + this.generateKey(key); + } + }); + } + + editKey(key: ApiKeyResponse): void { + const modalRef = this.modalService.open(ApiKeyModalComponent, {centered: true}); + modalRef.componentInstance.apiKey = key; + + modalRef.result.then((key: ApiKeyResponse) => { + if (key) { + this.generateKey(key); + } + }); + } + + deleteKey(apiKey: ApiKeyResponse): void { + const modalRef = this.modalService.open(ModalConfirmationComponent, {centered: true}); + modalRef.componentInstance.header = `Delete API Key: ${apiKey.name}`; + modalRef.componentInstance.message = 'Are you sure you want to delete this API key?'; + modalRef.componentInstance.confirmBtnType = 'delete'; + modalRef.componentInstance.type = 'danger'; + modalRef.componentInstance.confirmBtnText = 'Delete'; + modalRef.componentInstance.confirmBtnIcon = 'icon-cross-circle'; + + modalRef.result.then(reason => { + if (reason === 'ok') { + this.delete(apiKey); + } + }); + } + + delete(apiKey: ApiKeyResponse): void { + this.apiKeyService.delete(apiKey.id).subscribe({ + next: () => { + this.toastService.showSuccess('API key deleted successfully.'); + this.loadKeys(); + }, + error: (err) => { + this.toastService.showError('Error', 'An error occurred while deleting the API key.'); + throw err; + } + }); + } + + getDaysUntilExpire(expiresAt: string): number { + if (!expiresAt) { + return -1; + } + + const today = moment().startOf('day'); + const expireDate = moment(expiresAt).startOf('day'); + return expireDate.diff(today, 'days'); + } + + onSortBy($event: SortEvent) { + this.request.sort = $event.column + ',' + $event.direction; + this.loadKeys(); + } + + maskSecrets(str: string): string { + if (!str || str.length <= 10) { + return str; + } + const prefix = str.substring(0, 10); + const maskLength = str.length - 30; + const maskedPart = '*'.repeat(maskLength); + return prefix + maskedPart; + } + + generateKey(apiKey: ApiKeyResponse): void { + this.generating.push(apiKey.id); + this.apiKeyService.generateApiKey(apiKey.id).subscribe(response => { + this.generatedApiKey = response.body ? response.body : ""; + this.generatedModalRef = this.modalService.open(this.generatedModal, {centered: true}); + const index = this.generating.indexOf(apiKey.id); + if (index > -1) { + this.generating.splice(index, 1); + } + this.loadKeys(); + }); + } + + isApiKeyExpired(expiresAt?: string | null ): boolean { + if (!expiresAt) { + return false; + } + const expirationTime = new Date(expiresAt).getTime(); + return expirationTime < Date.now(); + } + + close() { + this.generatedModalRef.close(); + this.copied = false; + this.generatedApiKey = ''; + } + + loadPage($event: number) { + this.page = $event - 1; + this.request = { + ...this.request, + page: this.page + }; + this.loadKeys(); + } + + onItemsPerPageChange($event: number) { + this.request = { + ...this.request, + size: $event, + page: 0 + }; + this.page = 0; + this.loadKeys(); + } +} diff --git a/frontend/src/app/app-management/api-keys/shared/components/api-key-modal/api-key-modal.component.html b/frontend/src/app/app-management/api-keys/shared/components/api-key-modal/api-key-modal.component.html new file mode 100644 index 000000000..7e1399bbb --- /dev/null +++ b/frontend/src/app/app-management/api-keys/shared/components/api-key-modal/api-key-modal.component.html @@ -0,0 +1,127 @@ + + +
+ +
+ {{ errorMsg }} +
+ +
+
+ + +
+ +
+ +
+ +
+ +
+
+
+ +
+ + +
+ + +
+ +
+ {{ ipInputError }} +
+ +
    +
  • + +
    +
    + + {{ ip.value }} + {{ getIpType(ip.value) }} +
    +
    + +
    +
    +
  • +
+
+ + +
+ +
+ + + +
+
+ + + diff --git a/frontend/src/app/app-management/api-keys/shared/components/api-key-modal/api-key-modal.component.scss b/frontend/src/app/app-management/api-keys/shared/components/api-key-modal/api-key-modal.component.scss new file mode 100644 index 000000000..e31427b61 --- /dev/null +++ b/frontend/src/app/app-management/api-keys/shared/components/api-key-modal/api-key-modal.component.scss @@ -0,0 +1,12 @@ +.disabled-rounded-start { + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; +} +.disabled-rounded-end { + border-top-right-radius: 0 !important; + border-bottom-right-radius: 0 !important; +} + +.mt-4 { + margin-top: 9rem !important; +} diff --git a/frontend/src/app/app-management/api-keys/shared/components/api-key-modal/api-key-modal.component.ts b/frontend/src/app/app-management/api-keys/shared/components/api-key-modal/api-key-modal.component.ts new file mode 100644 index 000000000..f2378c13d --- /dev/null +++ b/frontend/src/app/app-management/api-keys/shared/components/api-key-modal/api-key-modal.component.ts @@ -0,0 +1,139 @@ +import {HttpErrorResponse} from '@angular/common/http'; +import {Component, Input, OnInit} from '@angular/core'; +import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import {IpFormsValidators} from '../../../../../rule-management/app-rule/validators/ip.forms.validators'; +import {UtmToastService} from '../../../../../shared/alert/utm-toast.service'; +import {ApiKeyResponse} from '../../models/ApiKeyResponse'; +import { ApiKeysService } from '../../service/api-keys.service'; + +@Component({ + selector: 'app-api-key-modal', + templateUrl: './api-key-modal.component.html', + styleUrls: ['./api-key-modal.component.scss'] +}) +export class ApiKeyModalComponent implements OnInit { + + @Input() apiKey: ApiKeyResponse = null; + + apiKeyForm: FormGroup; + ipInput = ''; + loading = false; + errorMsg = ''; + isSaving: string | string[] | Set | { [p: string]: any }; + minDate = { year: new Date().getFullYear(), month: new Date().getMonth() + 1, day: new Date().getDate() }; + ipInputError = ''; + + constructor( public activeModal: NgbActiveModal, + private apiKeyService: ApiKeysService, + private fb: FormBuilder, + private toastService: UtmToastService) { + } + + ngOnInit(): void { + + const expiresAtDate = this.apiKey && this.apiKey.expiresAt ? new Date(this.apiKey.expiresAt) : null; + const expiresAtNgbDate = expiresAtDate ? { + year: expiresAtDate.getUTCFullYear(), + month: expiresAtDate.getUTCMonth() + 1, + day: expiresAtDate.getUTCDate() + } : null; + + this.apiKeyForm = this.fb.group({ + name: [ this.apiKey ? this.apiKey.name : '', Validators.required], + allowedIp: this.fb.array(this.apiKey ? this.apiKey.allowedIp : []), + expiresAt: [expiresAtNgbDate, Validators.required], + }); + } + + get allowedIp(): FormArray { + return this.apiKeyForm.get('allowedIp') as FormArray; + } + + addIp(): void { + const trimmedIp = this.ipInput.trim(); + + if (!trimmedIp) { + this.ipInputError = 'Please enter an IP address or CIDR'; // Error is assigned + return; + } + + const tempControl = this.fb.control(trimmedIp, [IpFormsValidators.ipOrCidr()]); + + if (tempControl.invalid) { + if (tempControl.hasError('invalidIp')) { + this.ipInputError = 'Invalid IP address format'; + } else if (tempControl.hasError('invalidCidr')) { + this.ipInputError = 'Invalid CIDR format'; + } + return; + } + + const isDuplicate = this.allowedIp.controls.some( + control => control.value === trimmedIp + ); + + if (isDuplicate) { + this.ipInputError = 'This IP is already added'; + return; + } + + this.allowedIp.push(this.fb.control(trimmedIp, [IpFormsValidators.ipOrCidr()])); + this.ipInput = ''; + this.ipInputError = ''; + } + + removeIp(index: number): void { + this.allowedIp.removeAt(index); + } + + create(): void { + this.errorMsg = ''; + + if (this.apiKeyForm.invalid) { + this.errorMsg = 'Name is required.'; + return; + } + + this.loading = true; + + const rawDate = this.apiKeyForm.get('expiresAt').value; + let formattedDate = rawDate; + + if (rawDate && typeof rawDate === 'object') { + formattedDate = `${rawDate.year}-${String(rawDate.month).padStart(2, '0')}-${String(rawDate.day).padStart(2, '0')}T00:00:00.000Z`; + } + + const payload = { + ...this.apiKeyForm.value, + expiresAt: formattedDate, + }; + + const save = this.apiKey ? this.apiKeyService.update(this.apiKey.id, payload) : + this.apiKeyService.create(payload); + + save.subscribe({ + next: (response) => { + this.loading = false; + this.activeModal.close(response.body as ApiKeyResponse); + }, + error: (err: HttpErrorResponse) => { + this.loading = false; + if (err.status === 409) { + this.toastService.showError('Error', 'An API key with this name already exists.'); + } else if (err.status === 500) { + this.toastService.showError('Error', 'Server error occurred while creating the API key.'); + } + } + }); + } + + getIpType(value: string): string { + if (!value) { return ''; } + if (value.includes('/')) { + return value.includes(':') ? 'IPv6 CIDR' : 'IPv4 CIDR'; + } + return value.includes(':') ? 'IPv6' : 'IPv4'; + } +} + diff --git a/frontend/src/app/app-management/api-keys/shared/models/ApiKeyResponse.ts b/frontend/src/app/app-management/api-keys/shared/models/ApiKeyResponse.ts new file mode 100644 index 000000000..3f6b890d7 --- /dev/null +++ b/frontend/src/app/app-management/api-keys/shared/models/ApiKeyResponse.ts @@ -0,0 +1,8 @@ +export interface ApiKeyResponse { + id: string; + name: string; + allowedIp: string[]; + createdAt: string; + expiresAt?: string; + generatedAt?: string; +} diff --git a/frontend/src/app/app-management/api-keys/shared/models/ApiKeyUpsert.ts b/frontend/src/app/app-management/api-keys/shared/models/ApiKeyUpsert.ts new file mode 100644 index 000000000..7ae630a43 --- /dev/null +++ b/frontend/src/app/app-management/api-keys/shared/models/ApiKeyUpsert.ts @@ -0,0 +1,6 @@ +export interface ApiKeyUpsert { + id: string; + name: string; + allowedIp?: string[]; + expiresAt?: Date; +} diff --git a/frontend/src/app/app-management/api-keys/shared/service/api-keys.service.ts b/frontend/src/app/app-management/api-keys/shared/service/api-keys.service.ts new file mode 100644 index 000000000..e239d5e4d --- /dev/null +++ b/frontend/src/app/app-management/api-keys/shared/service/api-keys.service.ts @@ -0,0 +1,117 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { SERVER_API_URL } from '../../../../app.constants'; +import { ApiKeyResponse } from '../models/ApiKeyResponse'; +import { ApiKeyUpsert } from '../models/ApiKeyUpsert'; +import {createRequestOption} from "../../../../shared/util/request-util"; + +/** + * Service for managing API keys + */ +@Injectable({ + providedIn: 'root' +}) +export class ApiKeysService { + public resourceUrl = SERVER_API_URL + 'api/api-keys'; + + constructor(private http: HttpClient) {} + + /** + * Create a new API key + */ + create(dto: ApiKeyUpsert): Observable> { + return this.http.post( + this.resourceUrl, + dto, + { observe: 'response' } + ); + } + + /** + * Generate (or renew) a plain API key for the given id + * Returns the plain text key (only once) + */ + generate(id: string): Observable> { + return this.http.post( + `${this.resourceUrl}/${id}/generate`, + {}, + { observe: 'response', responseType: 'text' } + ); + } + + /** + * Get API key by id + */ + get(id: string): Observable> { + return this.http.get( + `${this.resourceUrl}/${id}`, + { observe: 'response' } + ); + } + + /** + * List all API keys (with optional pagination) + */ + list(params?: any): Observable> { + const httpParams = createRequestOption(params); + return this.http.get( + this.resourceUrl, + { observe: 'response', params: httpParams }, + ); + } + + /** + * Update an existing API key + */ + update(id: string, dto: ApiKeyUpsert): Observable> { + return this.http.put( + `${this.resourceUrl}/${id}`, + dto, + { observe: 'response' } + ); + } + + /** + * Delete API key + */ + delete(id: string): Observable> { + return this.http.delete( + `${this.resourceUrl}/${id}`, + { observe: 'response' } + ); + } + + generateApiKey(apiKeyId: string): Observable> { + return this.http.post(`${this.resourceUrl}/${apiKeyId}/generate`, null, { + observe: 'response', + responseType: 'text' + }); + } + + /** + * Search API key usage in Elasticsearch + */ + usage(params: { + filters?: any[]; + top: number; + indexPattern: string; + includeChildren?: boolean; + page?: number; + size?: number; + }): Observable { + return this.http.get( + `${this.resourceUrl}/usage`, + { + params: { + top: params.top.toString(), + indexPattern: params.indexPattern, + includeChildren: params.includeChildren.toString() || 'false', + page: params.page.toString() || '0', + size: params.size.toString() || '10' + } + } + ); + } +} + diff --git a/frontend/src/app/app-management/app-management-routing.module.ts b/frontend/src/app/app-management/app-management-routing.module.ts index 616d6fa01..d498dae62 100644 --- a/frontend/src/app/app-management/app-management-routing.module.ts +++ b/frontend/src/app/app-management/app-management-routing.module.ts @@ -16,6 +16,7 @@ import {MenuComponent} from './menu/menu.component'; import {RolloverConfigComponent} from './rollover-config/rollover-config.component'; import {UtmApiDocComponent} from './utm-api-doc/utm-api-doc.component'; import {UtmNotificationViewComponent} from './utm-notification/components/notifications-view/utm-notification-view.component'; +import {ApiKeysComponent} from "./api-keys/api-keys.component"; const routes: Routes = [ {path: '', redirectTo: 'settings', pathMatch: 'full'}, @@ -123,7 +124,16 @@ const routes: Routes = [ data: { authorities: [ADMIN_ROLE] }, - }], + }, + { + path: 'api-keys', + component: ApiKeysComponent, + canActivate: [UserRouteAccessService], + data: { + authorities: [ADMIN_ROLE] + }, + } + ], }, ]; diff --git a/frontend/src/app/app-management/app-management.module.ts b/frontend/src/app/app-management/app-management.module.ts index 32484f1c9..5841a0f91 100644 --- a/frontend/src/app/app-management/app-management.module.ts +++ b/frontend/src/app/app-management/app-management.module.ts @@ -45,9 +45,13 @@ import {UtmApiDocComponent} from './utm-api-doc/utm-api-doc.component'; import { UtmNotificationViewComponent } from "./utm-notification/components/notifications-view/utm-notification-view.component"; +import { ApiKeysComponent } from './api-keys/api-keys.component'; +import { ApiKeyModalComponent } from './api-keys/shared/components/api-key-modal/api-key-modal.component'; @NgModule({ declarations: [ + ApiKeysComponent, + ApiKeyModalComponent, AppManagementComponent, AppManagementSidebarComponent, IndexPatternHelpComponent, @@ -84,6 +88,7 @@ import { HealthDetailComponent, MenuDeleteDialogComponent, TokenActivateComponent, + ApiKeyModalComponent, IndexDeleteComponent], imports: [ CommonModule, diff --git a/frontend/src/app/app-management/layout/app-management-sidebar/app-management-sidebar.component.html b/frontend/src/app/app-management/layout/app-management-sidebar/app-management-sidebar.component.html index 66efbcbfb..a67af0739 100644 --- a/frontend/src/app/app-management/layout/app-management-sidebar/app-management-sidebar.component.html +++ b/frontend/src/app/app-management/layout/app-management-sidebar/app-management-sidebar.component.html @@ -125,6 +125,16 @@ + + +   + API Keys + + + > { - return this.http.get(SERVER_API_URL + `api/check-credentials?password=${password}&checkUUID=${uuid}`, { + const sanitized_password = encodeURIComponent(password) + return this.http.get(SERVER_API_URL + `api/check-credentials?password=${sanitized_password}&checkUUID=${uuid}`, { observe: 'response', responseType: 'text' }); diff --git a/frontend/src/app/rule-management/app-rule/validators/ip.forms.validators.ts b/frontend/src/app/rule-management/app-rule/validators/ip.forms.validators.ts new file mode 100644 index 000000000..b82b574d6 --- /dev/null +++ b/frontend/src/app/rule-management/app-rule/validators/ip.forms.validators.ts @@ -0,0 +1,86 @@ +import {AbstractControl, ValidationErrors, ValidatorFn} from '@angular/forms'; + +export class IpFormsValidators { + + static ipOrCidr(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + if (!control.value) { + return null; + } + + const value = control.value.trim(); + + if (value.includes('/')) { + return IpFormsValidators.validateCIDR(value) ? null : { invalidCidr: true }; + } + + const isValidIPv4 = IpFormsValidators.validateIPv4(value); + const isValidIPv6 = IpFormsValidators.validateIPv6(value); + + return (isValidIPv4 || isValidIPv6) ? null : { invalidIp: true }; + }; + } + + private static validateIPv4(ip: string): boolean { + const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; + const match = ip.match(ipv4Regex); + + if (!match) { + return false; + } + + for (let i = 1; i <= 4; i++) { + const octet = parseInt(match[i], 10); + if (octet < 0 || octet > 255) { + return false; + } + } + + return true; + } + + private static validateIPv6(ip: string): boolean { + // tslint:disable-next-line:max-line-length + const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/; + + return ipv6Regex.test(ip); + } + + private static validateCIDR(cidr: string): boolean { + const parts = cidr.split('/'); + + if (parts.length !== 2) { + return false; + } + + const [ip, prefix] = parts; + const prefixNum = parseInt(prefix, 10); + + const isIPv4 = ip.includes('.') && !ip.includes(':'); + const isIPv6 = ip.includes(':'); + + if (isIPv4) { + if (!IpFormsValidators.validateIPv4(ip)) { + return false; + } + + if (isNaN(prefixNum) || prefixNum < 0 || prefixNum > 32) { + return false; + } + } else if (isIPv6) { + + if (!IpFormsValidators.validateIPv6(ip)) { + return false; + } + + if (isNaN(prefixNum) || prefixNum < 0 || prefixNum > 128) { + return false; + } + } else { + return false; + } + + return true; + } + +} diff --git a/frontend/src/app/shared/constants/date-timezone-date.const.ts b/frontend/src/app/shared/constants/date-timezone-date.const.ts index ac2068658..ebb65546d 100644 --- a/frontend/src/app/shared/constants/date-timezone-date.const.ts +++ b/frontend/src/app/shared/constants/date-timezone-date.const.ts @@ -25,6 +25,8 @@ export const TIMEZONES: Array<{ label: string; timezone: string, zone: string }> {label: 'Sydney (AEST)', timezone: 'Australia/Sydney', zone: 'Australia'}, {label: 'Melbourne (AEST)', timezone: 'Australia/Melbourne', zone: 'Australia'}, {label: 'Perth (AWST)', timezone: 'Australia/Perth', zone: 'Australia'}, + {label: 'New Zealand (NZST)', timezone: 'Pacific/Auckland', zone: 'Pacific'}, + {label: 'Fiji (FJT)', timezone: 'Pacific/Fiji', zone: 'Pacific'}, {label: 'Beijing (CST)', timezone: 'Asia/Shanghai', zone: 'Asia'}, {label: 'Tokyo (JST)', timezone: 'Asia/Tokyo', zone: 'Asia'}, {label: 'Seoul (KST)', timezone: 'Asia/Seoul', zone: 'Asia'}, @@ -37,7 +39,6 @@ export const TIMEZONES: Array<{ label: string; timezone: string, zone: string }> {label: 'Buenos Aires (ART)', timezone: 'America/Argentina/Buenos_Aires', zone: 'America'}, {label: 'São Paulo (BRT)', timezone: 'America/Sao_Paulo', zone: 'America'}, ]; - export const DATE_FORMATS: Array<{ label: string; format: string; equivalentTo: string }> = [ {label: 'Short', format: 'short', equivalentTo: 'M/d/yy, h:mm a'}, {label: 'Medium', format: 'medium', equivalentTo: 'MMM d, y, h:mm:ss a'},