Skip to main content

API Security

Overview

This guide covers comprehensive API security implementation in the Ink platform, including authentication, authorization, rate limiting, input validation, and security best practices.

Target Audience: Backend developers and security engineers
Prerequisites: Understanding of Spring Security, JWT, and REST APIs
Estimated Time: 40-50 minutes

Prerequisites

  • Spring Security knowledge
  • REST API design principles
  • JWT authentication understanding
  • Completed Keycloak integration

Security Architecture

Installation Steps

1. Security Dependencies

<!-- filepath: /Users/jetstart/dev/jetrev/ink/pom.xml -->
<!-- ...existing code... -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j-core</artifactId>
<version>8.7.0</version>
</dependency>
</dependencies>
<!-- ...existing code... -->

2. Method Security Configuration

// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/config/MethodSecurityConfig.java
@Configuration
@EnableMethodSecurity(
prePostEnabled = true,
securedEnabled = true,
jsr250Enabled = true
)
public class MethodSecurityConfig {

@Bean
public MethodSecurityExpressionHandler methodSecurityExpressionHandler() {
DefaultMethodSecurityExpressionHandler handler =
new DefaultMethodSecurityExpressionHandler();
handler.setPermissionEvaluator(new CustomPermissionEvaluator());
return handler;
}
}

3. Rate Limiting Configuration

// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/config/RateLimitConfig.java
@Configuration
public class RateLimitConfig {

@Bean
public RateLimitInterceptor rateLimitInterceptor() {
return new RateLimitInterceptor();
}

@Bean
public WebMvcConfigurer rateLimitConfigurer(
RateLimitInterceptor rateLimitInterceptor) {
return new WebMvcConfigurer() {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(rateLimitInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/public/**");
}
};
}
}

Configuration

Controller Security Annotations

// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/controller/UserController.java
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
@Validated
public class UserController {

private final UserService userService;

@GetMapping("/me")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<UserDto> getCurrentUser() {
// ...existing code...
}

@GetMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Page<UserDto>> getAllUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
// ...existing code...
}

@PostMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<UserDto> createUser(
@Valid @RequestBody CreateUserRequest request) {
// ...existing code...
}

@PutMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or @userSecurity.isOwner(#id)")
public ResponseEntity<UserDto> updateUser(
@PathVariable Long id,
@Valid @RequestBody UpdateUserRequest request) {
// ...existing code...
}

@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
// ...existing code...
}
}

Custom Security Expression

// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/security/UserSecurity.java
@Component("userSecurity")
@RequiredArgsConstructor
public class UserSecurity {

private final UserContextHolder userContextHolder;

public boolean isOwner(Long userId) {
UserContext currentUser = userContextHolder.getCurrentUser();
return currentUser != null &&
currentUser.getUserId().equals(userId.toString());
}

public boolean canAccessResource(Long resourceId) {
UserContext currentUser = userContextHolder.getCurrentUser();
if (currentUser == null) {
return false;
}

// Check ownership or admin role
return currentUser.getRoles().contains("ADMIN") ||
isResourceOwner(currentUser.getUserId(), resourceId);
}

private boolean isResourceOwner(String userId, Long resourceId) {
// ...existing code...
return false;
}
}

Input Validation

// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/dto/CreateUserRequest.java
@Data
@Builder
public class CreateUserRequest {

@NotBlank(message = "Username is required")
@Size(min = 3, max = 50, message = "Username must be between 3 and 50 characters")
@Pattern(regexp = "^[a-zA-Z0-9_-]+$", message = "Username can only contain alphanumeric characters, hyphens, and underscores")
private String username;

@NotBlank(message = "Email is required")
@Email(message = "Email must be valid")
private String email;

@NotBlank(message = "Password is required")
@Size(min = 8, message = "Password must be at least 8 characters")
@Pattern(
regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]+$",
message = "Password must contain uppercase, lowercase, number, and special character"
)
private String password;

@NotNull(message = "Roles are required")
@Size(min = 1, message = "At least one role is required")
private List<String> roles;
}

Usage Examples

Rate Limiting Implementation

// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/security/RateLimitInterceptor.java
@Slf4j
public class RateLimitInterceptor implements HandlerInterceptor {

private final Map<String, Bucket> cache = new ConcurrentHashMap<>();

@Value("${ink.api.rate-limit.requests-per-minute:100}")
private int requestsPerMinute;

@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {

String key = getClientKey(request);
Bucket bucket = resolveBucket(key);

if (bucket.tryConsume(1)) {
return true;
}

response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.getWriter().write("Rate limit exceeded. Try again later.");
return false;
}

private Bucket resolveBucket(String key) {
return cache.computeIfAbsent(key, k -> createBucket());
}

private Bucket createBucket() {
Bandwidth limit = Bandwidth.builder()
.capacity(requestsPerMinute)
.refillGreedy(requestsPerMinute, Duration.ofMinutes(1))
.build();

return Bucket.builder()
.addLimit(limit)
.build();
}

private String getClientKey(HttpServletRequest request) {
String userId = extractUserId(request);
return userId != null ? userId : request.getRemoteAddr();
}

private String extractUserId(HttpServletRequest request) {
// ...existing code...
return null;
}
}

API Key Authentication

// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/security/ApiKeyAuthFilter.java
@Component
@RequiredArgsConstructor
public class ApiKeyAuthFilter extends OncePerRequestFilter {

private final ApiKeyService apiKeyService;

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

String apiKey = request.getHeader("X-API-Key");

if (apiKey != null && apiKeyService.isValid(apiKey)) {
Authentication auth = apiKeyService.authenticate(apiKey);
SecurityContextHolder.getContext().setAuthentication(auth);
}

filterChain.doFilter(request, response);
}
}

Audit Logging

// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/security/AuditAspect.java
@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class AuditAspect {

private final AuditLogRepository auditLogRepository;
private final UserContextHolder userContextHolder;

@Around("@annotation(auditable)")
public Object auditMethod(ProceedingJoinPoint joinPoint, Auditable auditable)
throws Throwable {

UserContext user = userContextHolder.getCurrentUser();
String action = auditable.action();

AuditLog auditLog = AuditLog.builder()
.userId(user != null ? user.getUserId() : "anonymous")
.action(action)
.timestamp(Instant.now())
.method(joinPoint.getSignature().toShortString())
.build();

try {
Object result = joinPoint.proceed();
auditLog.setStatus("SUCCESS");
return result;
} catch (Exception e) {
auditLog.setStatus("FAILURE");
auditLog.setErrorMessage(e.getMessage());
throw e;
} finally {
auditLogRepository.save(auditLog);
}
}
}

Verification

Security Testing

// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/security/SecurityTest.java
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
class SecurityTest {

@Autowired
private MockMvc mockMvc;

@Test
@WithMockUser(roles = "USER")
void testUserCanAccessOwnProfile() throws Exception {
mockMvc.perform(get("/api/users/me"))
.andExpect(status().isOk());
}

@Test
void testUnauthenticatedAccessDenied() throws Exception {
mockMvc.perform(get("/api/users"))
.andExpect(status().isUnauthorized());
}

@Test
@WithMockUser(roles = "USER")
void testUserCannotAccessAdminEndpoint() throws Exception {
mockMvc.perform(get("/api/admin/users"))
.andExpect(status().isForbidden());
}

@Test
@WithMockUser(roles = "ADMIN")
void testAdminCanAccessAllEndpoints() throws Exception {
mockMvc.perform(get("/api/admin/users"))
.andExpect(status().isOk());
}
}

Input Validation Testing

// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/validation/ValidationTest.java
@SpringBootTest
class ValidationTest {

@Autowired
private Validator validator;

@Test
void testInvalidEmail() {
CreateUserRequest request = CreateUserRequest.builder()
.username("testuser")
.email("invalid-email")
.password("Password123!")
.roles(List.of("USER"))
.build();

Set<ConstraintViolation<CreateUserRequest>> violations =
validator.validate(request);

assertThat(violations).hasSize(1);
assertThat(violations.iterator().next().getMessage())
.contains("Email must be valid");
}

@Test
void testWeakPassword() {
CreateUserRequest request = CreateUserRequest.builder()
.username("testuser")
.email("test@example.com")
.password("weak")
.roles(List.of("USER"))
.build();

Set<ConstraintViolation<CreateUserRequest>> violations =
validator.validate(request);

assertThat(violations).isNotEmpty();
}
}

Troubleshooting

Common Security Issues

Issue: 403 Forbidden despite valid token

// Debug security context
@GetMapping("/debug/security")
public ResponseEntity<Map<String, Object>> debugSecurity() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();

Map<String, Object> debug = new HashMap<>();
debug.put("authenticated", auth.isAuthenticated());
debug.put("authorities", auth.getAuthorities());
debug.put("principal", auth.getPrincipal());

return ResponseEntity.ok(debug);
}

Issue: Rate limiting not working

// Add logging to rate limiter
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
String key = getClientKey(request);
log.debug("Rate limit check for: {}", key);

Bucket bucket = resolveBucket(key);
boolean allowed = bucket.tryConsume(1);

log.debug("Rate limit result: {}, remaining: {}",
allowed, bucket.getAvailableTokens());

return allowed;
}

Code Examples

Custom Permission Evaluator

// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/security/CustomPermissionEvaluator.java
@Component
@RequiredArgsConstructor
public class CustomPermissionEvaluator implements PermissionEvaluator {

private final UserRepository userRepository;

@Override
public boolean hasPermission(Authentication authentication,
Object targetDomainObject,
Object permission) {

if (authentication == null || targetDomainObject == null) {
return false;
}

String targetType = targetDomainObject.getClass().getSimpleName();
return hasPrivilege(authentication, targetType, permission.toString());
}

@Override
public boolean hasPermission(Authentication authentication,
Serializable targetId,
String targetType,
Object permission) {

if (authentication == null || targetType == null) {
return false;
}

return hasPrivilege(authentication, targetType, permission.toString());
}

private boolean hasPrivilege(Authentication auth,
String targetType,
String permission) {
// ...existing code...
return false;
}
}

CSRF Protection for APIs

// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/config/CsrfConfig.java
@Configuration
public class CsrfConfig {

@Bean
public CsrfTokenRepository csrfTokenRepository() {
CookieCsrfTokenRepository repository =
CookieCsrfTokenRepository.withHttpOnlyFalse();
repository.setCookiePath("/");
return repository;
}
}

Best Practices

  1. Principle of Least Privilege: Grant minimum necessary permissions
  2. Defense in Depth: Layer multiple security controls
  3. Input Validation: Always validate and sanitize user input
  4. Rate Limiting: Prevent abuse with rate limits
  5. Audit Logging: Log all security-relevant events
  6. Secure Defaults: Use secure configurations by default
  7. Regular Updates: Keep dependencies updated
  8. HTTPS Only: Never transmit sensitive data over HTTP
  9. Token Expiration: Use short-lived tokens with refresh mechanism
  10. Error Handling: Don't expose sensitive information in errors

Performance Optimization

Caching Authorization Decisions

// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/security/CachedAuthorizationService.java
@Service
@RequiredArgsConstructor
public class CachedAuthorizationService {

@Cacheable(value = "userPermissions", key = "#userId")
public Set<String> getUserPermissions(String userId) {
// ...existing code...
return Set.of();
}

@CacheEvict(value = "userPermissions", key = "#userId")
public void invalidateUserPermissions(String userId) {
// Called when user permissions change
}
}

Additional Resources


Next Steps: Learn about Unit Testing to test security implementations.