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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
459 changes: 459 additions & 0 deletions articles/APPKey_HOWTO.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package org.ohdsi.webapi.security.apikey;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import org.ohdsi.webapi.security.authc.WebApiAuthenticationToken;
import org.ohdsi.webapi.security.authz.User;
import org.ohdsi.webapi.security.identity.WebApiPrincipal;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.Collection;
import java.util.List;
import java.util.Optional;

/**
* Servlet filter that authenticates requests carrying an {@code X-API-KEY} header.
*
* <p>The filter runs before Spring Security's {@code BearerTokenAuthenticationFilter}.
* If the header is absent the request is passed down the chain unchanged, allowing
* normal JWT authentication to proceed. If the header is present but the key is
* invalid, a 401 response is returned immediately.
*
* <p>Successful API key authentication sets a {@link WebApiAuthenticationToken} with
* a {@code null} session ID in the {@link SecurityContextHolder}, matching the
* stateless intent of API key access.
*/
@Component
public class ApiKeyAuthFilter extends OncePerRequestFilter {

public static final String API_KEY_HEADER = "X-API-KEY";

private static final Logger log = LoggerFactory.getLogger(ApiKeyAuthFilter.class);

private final ApiKeyService apiKeyService;

public ApiKeyAuthFilter(ApiKeyService apiKeyService) {
this.apiKeyService = apiKeyService;
}

@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {

String apiKey = request.getHeader(API_KEY_HEADER);
if (apiKey == null || apiKey.isBlank()) {
// No API key header — let the JWT filter handle the request normally.
filterChain.doFilter(request, response);
return;
}

Optional<User> userOpt = apiKeyService.validate(apiKey);
if (userOpt.isEmpty()) {
log.debug("Rejected invalid API key from {}", request.getRemoteAddr());
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"message\":\"Invalid or expired API key\"}");
return;
}

WebApiPrincipal principal = new WebApiPrincipal(userOpt.get());
// Authorities are empty, consistent with the existing JWT converter behaviour.
// Per-endpoint @PreAuthorize rules evaluate permissions via the AuthorizationService.
Collection<GrantedAuthority> authorities = List.of();
// sessionId is null: API key requests are stateless and carry no session.
WebApiAuthenticationToken auth = WebApiAuthenticationToken.authenticated(principal, null, authorities);
SecurityContextHolder.getContext().setAuthentication(auth);

filterChain.doFilter(request, response);
}
}
120 changes: 120 additions & 0 deletions src/main/java/org/ohdsi/webapi/security/apikey/ApiKeyController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package org.ohdsi.webapi.security.apikey;

import org.ohdsi.webapi.security.authz.AuthorizationService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;

/**
* REST endpoints for managing personal API keys.
*
* <p>All endpoints require an authenticated principal (interactive session or an
* existing API key). The raw key is returned exactly once, at creation time.
*
* <pre>
* POST /user/apikeys – generate a new key
* GET /user/apikeys – list keys for the current user (no secrets/hashes)
* DELETE /user/apikeys/{id} – revoke (disable) a key
* </pre>
*/
@RestController
@RequestMapping("/user/apikeys")
public class ApiKeyController {

private static final Logger log = LoggerFactory.getLogger(ApiKeyController.class);

private final ApiKeyService apiKeyService;
private final AuthorizationService authorizationService;

public ApiKeyController(ApiKeyService apiKeyService, AuthorizationService authorizationService) {
this.apiKeyService = apiKeyService;
this.authorizationService = authorizationService;
}

/**
* Request body for creating an API key.
*
* @param name short label for the key (required)
* @param description optional longer description
* @param expiresInDays number of days until the key expires; null or 0 means the key never expires
*/
public record CreateRequest(String name, String description, Integer expiresInDays) {
}

/**
* Generate a new API key for the currently authenticated user.
*
* <p>The response includes the full plaintext key in {@code rawKey}.
* This is the only time it will ever be returned — store it securely.
*/
@PostMapping
@PreAuthorize("isAuthenticated()")
public ResponseEntity<ApiKeyService.ApiKeyResult> create(@RequestBody CreateRequest request) {

if (request.name() == null || request.name().isBlank()) {
return ResponseEntity.badRequest().build();
}

String login = authorizationService.getCurrentUser().login();
Instant expiresAt = (request.expiresInDays() != null && request.expiresInDays() > 0)
? Instant.now().plus(request.expiresInDays(), ChronoUnit.DAYS)
: null;
ApiKeyService.ApiKeyResult result = apiKeyService.generate(
login, request.name(), request.description(), expiresAt);

log.info("API key created via controller: identifier={}, user={}", result.keyIdentifier(), login);
return ResponseEntity.status(HttpStatus.CREATED).body(result);
}

/**
* List all API keys for the currently authenticated user.
* Key hashes and secrets are never included in the response.
*/
@GetMapping
@PreAuthorize("isAuthenticated()")
public List<ApiKeyService.ApiKeyInfo> list() {
String login = authorizationService.getCurrentUser().login();
return apiKeyService.list(login);
}

/**
* Revoke or permanently delete an API key by its public identifier.
* <ul>
* <li>Without {@code ?remove}: soft-disables the key (visible in list, cannot authenticate).</li>
* <li>With {@code ?remove}: permanently removes the record from the database.</li>
* </ul>
* Returns 404 if the key does not exist or does not belong to the caller.
*/
@DeleteMapping("/{keyIdentifier}")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Void> revoke(
@PathVariable String keyIdentifier,
@RequestParam(name = "remove", required = false) String remove) {
String login = authorizationService.getCurrentUser().login();
try {
if (remove != null) {
apiKeyService.delete(keyIdentifier, login);
} else {
apiKeyService.revoke(keyIdentifier, login);
}
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
log.debug("Revoke rejected for key identifier={}, user={}: {}", keyIdentifier, login, e.getMessage());
return ResponseEntity.notFound().build();
}
}
}
93 changes: 93 additions & 0 deletions src/main/java/org/ohdsi/webapi/security/apikey/ApiKeyEntity.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package org.ohdsi.webapi.security.apikey;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.SequenceGenerator;
import jakarta.persistence.Table;

import org.ohdsi.webapi.security.authz.UserEntity;

import java.time.Instant;

@Entity(name = "ApiKey")
@Table(name = "sec_api_key")
public class ApiKeyEntity {

@Id
@Column(name = "id")
@SequenceGenerator(name = "sec_api_key_seq", sequenceName = "sec_api_key_sequence", allocationSize = 1)
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sec_api_key_seq")
private Long id;

/**
* Public, indexed portion of the key used for O(1) database lookup.
* This is NOT a secret — it is stored as plain text.
*/
@Column(name = "key_identifier", unique = true, nullable = false, length = 64)
private String keyIdentifier;

/**
* BCrypt hash of the secret portion of the key.
* The plaintext secret is never stored.
*/
@Column(name = "key_hash", nullable = false, length = 255)
private String keyHash;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private UserEntity user;

@Column(name = "name", nullable = false, length = 255)
private String name;

@Column(name = "description", length = 1000)
private String description;

@Column(name = "created_at", nullable = false)
private Instant createdAt;

@Column(name = "expires_at")
private Instant expiresAt;

@Column(name = "disabled", nullable = false)
private boolean disabled = false;

@Column(name = "last_used_at")
private Instant lastUsedAt;

public Long getId() { return id; }
public void setId(Long id) { this.id = id; }

public String getKeyIdentifier() { return keyIdentifier; }
public void setKeyIdentifier(String keyIdentifier) { this.keyIdentifier = keyIdentifier; }

public String getKeyHash() { return keyHash; }
public void setKeyHash(String keyHash) { this.keyHash = keyHash; }

public UserEntity getUser() { return user; }
public void setUser(UserEntity user) { this.user = user; }

public String getName() { return name; }
public void setName(String name) { this.name = name; }

public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }

public Instant getCreatedAt() { return createdAt; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }

public Instant getExpiresAt() { return expiresAt; }
public void setExpiresAt(Instant expiresAt) { this.expiresAt = expiresAt; }

public boolean isDisabled() { return disabled; }
public void setDisabled(boolean disabled) { this.disabled = disabled; }

public Instant getLastUsedAt() { return lastUsedAt; }
public void setLastUsedAt(Instant lastUsedAt) { this.lastUsedAt = lastUsedAt; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package org.ohdsi.webapi.security.apikey;

import org.ohdsi.webapi.security.authz.UserEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
public interface ApiKeyRepository extends JpaRepository<ApiKeyEntity, Long> {

/**
* O(1) lookup by the public identifier portion of the key.
* The identifier column has a UNIQUE index, so this is an indexed point lookup.
*/
Optional<ApiKeyEntity> findByKeyIdentifier(String keyIdentifier);

List<ApiKeyEntity> findByUser(UserEntity user);
}
Loading
Loading