diff --git a/src/main/java/io/heapdog/core/config/SecurityConfig.java b/src/main/java/io/heapdog/core/config/SecurityConfig.java index 5cff878..2638c1e 100644 --- a/src/main/java/io/heapdog/core/config/SecurityConfig.java +++ b/src/main/java/io/heapdog/core/config/SecurityConfig.java @@ -1,13 +1,17 @@ package io.heapdog.core.config; +import io.heapdog.core.feature.auth.JwtAuthenticationService; +import io.heapdog.core.security.ApiKeyAuthEntryPoint; +import io.heapdog.core.security.ApiKeyAuthenticationFilter; +import io.heapdog.core.security.jwt.JwtAuthenticationEntryPoint; import io.heapdog.core.security.jwt.JwtAuthenticationFilter; -import jakarta.servlet.http.HttpServletResponse; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; import org.springframework.security.access.hierarchicalroles.RoleHierarchy; @@ -36,10 +40,30 @@ @AllArgsConstructor @Slf4j public class SecurityConfig { + + @Order(1) + @Bean + SecurityFilterChain apiKeySecurityFilterChain(HttpSecurity http, + AuthenticationManager authenticationManager, + ApiKeyAuthEntryPoint apiKeyAuthEntryPoint) throws Exception { + return http + // FIX: Restrict this chain to only match /internal/** URLs + .securityMatcher("/internal/**") + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) + .addFilterBefore(new ApiKeyAuthenticationFilter(authenticationManager, apiKeyAuthEntryPoint), UsernamePasswordAuthenticationFilter.class) + .exceptionHandling(ex -> ex.authenticationEntryPoint(apiKeyAuthEntryPoint)) + .build(); + } + + + @Order(2) @Bean SecurityFilterChain securityFilterChain(HttpSecurity http, - JwtAuthenticationFilter jwtAuthenticationFilter - ) throws Exception { + JwtAuthenticationService jwtAuthenticationService, + JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint + ) throws Exception { return http .csrf(AbstractHttpConfigurer::disable) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) @@ -50,15 +74,8 @@ SecurityFilterChain securityFilterChain(HttpSecurity http, auth.anyRequest().authenticated(); }) .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)) - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) - .exceptionHandling(exception -> { - exception.authenticationEntryPoint((request, response, authException) -> { - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"); - }); - exception.accessDeniedHandler((request, response, accessDeniedException) -> { - response.sendError(HttpServletResponse.SC_FORBIDDEN, "Forbidden"); - }); - }) + .addFilterBefore(new JwtAuthenticationFilter(jwtAuthenticationService, jwtAuthenticationEntryPoint), UsernamePasswordAuthenticationFilter.class) + .exceptionHandling(exception -> exception.authenticationEntryPoint(jwtAuthenticationEntryPoint)) .build(); } diff --git a/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUser.java b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUser.java new file mode 100644 index 0000000..d3ac9ea --- /dev/null +++ b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUser.java @@ -0,0 +1,34 @@ +package io.heapdog.core.feature.serviceuser; + + +import io.heapdog.core.shared.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.util.Set; + +@Builder +@Entity +@Table(name = "heapdog_service_user") +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class ServiceUser extends BaseEntity { + + private String name; + private String apiKey; + private boolean enabled; + + @ElementCollection(targetClass = ServiceUserPermission.class, fetch = FetchType.EAGER) + @CollectionTable( + name = "heapdog_service_user_permission", + joinColumns = @JoinColumn(name = "service_user_id") + ) + @Column(name = "permission") + @Enumerated(EnumType.STRING) + private Set permissions; + + + +} diff --git a/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserController.java b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserController.java new file mode 100644 index 0000000..3ddeb11 --- /dev/null +++ b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserController.java @@ -0,0 +1,24 @@ +package io.heapdog.core.feature.serviceuser; + + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/service-users") +@RequiredArgsConstructor +public class ServiceUserController { + + private final ServiceUserService serviceUserService; + + + @PostMapping + ResponseEntity + createServiceUser(@Valid @RequestBody ServiceUserCreateRequestDto request) { + var resp = serviceUserService.createServiceUser(request); + return ResponseEntity.ok(resp); + } + +} diff --git a/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserCreateRequestDto.java b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserCreateRequestDto.java new file mode 100644 index 0000000..5dc3038 --- /dev/null +++ b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserCreateRequestDto.java @@ -0,0 +1,14 @@ +package io.heapdog.core.feature.serviceuser; + +import jakarta.validation.constraints.NotEmpty; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class ServiceUserCreateRequestDto { + + @NotEmpty + private String name; + +} diff --git a/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserCreateResponseDto.java b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserCreateResponseDto.java new file mode 100644 index 0000000..1ef85bd --- /dev/null +++ b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserCreateResponseDto.java @@ -0,0 +1,15 @@ +package io.heapdog.core.feature.serviceuser; + + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class ServiceUserCreateResponseDto { + + private Long id; + private String name; + private String apiKey; + private Boolean enabled; +} diff --git a/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserPermission.java b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserPermission.java new file mode 100644 index 0000000..386eabe --- /dev/null +++ b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserPermission.java @@ -0,0 +1,18 @@ +package io.heapdog.core.feature.serviceuser; + +public enum ServiceUserPermission { + + READ_HEAPDOG_USER("read:heapdog_user"), + WRITE_HEAPDOG_USER("write:heapdog_user"), + READ_NOTIFICATION("read:notification"); + + private final String permission; + + ServiceUserPermission(String permission) { + this.permission = permission; + } + + public String getPermission() { + return permission; + } +} diff --git a/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserRepository.java b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserRepository.java new file mode 100644 index 0000000..5586bb9 --- /dev/null +++ b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserRepository.java @@ -0,0 +1,11 @@ +package io.heapdog.core.feature.serviceuser; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ServiceUserRepository extends JpaRepository { + + Optional findByApiKey(String apiKey); + +} diff --git a/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserService.java b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserService.java new file mode 100644 index 0000000..d608e19 --- /dev/null +++ b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserService.java @@ -0,0 +1,29 @@ +package io.heapdog.core.feature.serviceuser; + +import io.heapdog.core.shared.util.OtpGenerator; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ServiceUserService { + + private final ServiceUserRepository serviceUserRepository; + + @PreAuthorize("hasRole('ADMIN')") + ServiceUserCreateResponseDto createServiceUser(ServiceUserCreateRequestDto request) { + ServiceUser serviceUser = ServiceUser.builder() + .name(request.getName()) + .apiKey(String.format("svc-%s", OtpGenerator.generateOtp(32))) + .enabled(true) + .build(); + var saved = serviceUserRepository.save(serviceUser); + return ServiceUserCreateResponseDto.builder() + .id(saved.getId()) + .name(saved.getName()) + .apiKey(saved.getApiKey()) + .enabled(saved.isEnabled()) + .build(); + } +} diff --git a/src/main/java/io/heapdog/core/security/ApiKeyAuthEntryPoint.java b/src/main/java/io/heapdog/core/security/ApiKeyAuthEntryPoint.java new file mode 100644 index 0000000..896111e --- /dev/null +++ b/src/main/java/io/heapdog/core/security/ApiKeyAuthEntryPoint.java @@ -0,0 +1,39 @@ +package io.heapdog.core.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import io.heapdog.core.shared.ApiError; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.time.Instant; + +@Component +@RequiredArgsConstructor +public class ApiKeyAuthEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + + var error = ApiError.builder() + .timestamp(Instant.now()) + .status(HttpServletResponse.SC_UNAUTHORIZED) + .error("Unauthorized") + .message(authException.getMessage()) + .code("UNAUTHORIZED") + .path(request.getRequestURI()) + .build(); + response.getWriter().write(objectMapper.writeValueAsString(error)); + } +} diff --git a/src/main/java/io/heapdog/core/security/ApiKeyAuthenticationFilter.java b/src/main/java/io/heapdog/core/security/ApiKeyAuthenticationFilter.java new file mode 100644 index 0000000..ca8ba71 --- /dev/null +++ b/src/main/java/io/heapdog/core/security/ApiKeyAuthenticationFilter.java @@ -0,0 +1,46 @@ +package io.heapdog.core.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@RequiredArgsConstructor +@Slf4j +public class ApiKeyAuthenticationFilter extends OncePerRequestFilter { + + private final AuthenticationManager authenticationManager; + private final ApiKeyAuthEntryPoint entryPoint; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + + String apiKey = request.getHeader("X-API-KEY"); + + if (apiKey == null || apiKey.isEmpty()) { + entryPoint.commence(request, response, new BadCredentialsException("Missing X-API-KEY header")); + return; + } + + try { + Authentication authResult = authenticationManager.authenticate(ApiKeyAuthenticationToken.unauthenticated(apiKey)); + SecurityContextHolder.getContext().setAuthentication(authResult); + + filterChain.doFilter(request, response); + + } catch (AuthenticationException ex) { + SecurityContextHolder.clearContext(); + entryPoint.commence(request, response, ex); + } + } +} diff --git a/src/main/java/io/heapdog/core/security/ApiKeyAuthenticationProvider.java b/src/main/java/io/heapdog/core/security/ApiKeyAuthenticationProvider.java new file mode 100644 index 0000000..e2699ef --- /dev/null +++ b/src/main/java/io/heapdog/core/security/ApiKeyAuthenticationProvider.java @@ -0,0 +1,54 @@ +package io.heapdog.core.security; + +import io.heapdog.core.feature.serviceuser.ServiceUser; +import io.heapdog.core.feature.serviceuser.ServiceUserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class ApiKeyAuthenticationProvider implements AuthenticationProvider { + + private final ServiceUserRepository repository; + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + ApiKeyAuthenticationToken token = (ApiKeyAuthenticationToken) authentication; + String apiKey = token.getCredentials().toString(); + Optional user = repository.findByApiKey(apiKey); + if (user.isPresent()) { + ServiceUser serviceUser = user.get(); + if (!serviceUser.isEnabled()) { + throw new BadCredentialsException("API Key is disabled"); + } + return ApiKeyAuthenticationToken.authenticated( + serviceUser, + serviceUser.getPermissions().stream() + .map(permission -> (GrantedAuthority) permission::name) + .toList() + ); + } else { + throw new BadCredentialsException("Invalid API Key"); + } +// return repository.findByApiKey(apiKey) +// .map(serviceUser -> ApiKeyAuthenticationToken.authenticated( +// serviceUser, +// serviceUser.getPermissions().stream() +// .map(permission -> (GrantedAuthority) permission::name) +// .toList() +// )) +// .orElseThrow(() -> new BadCredentialsException("Invalid API Key")); + } + + @Override + public boolean supports(Class authentication) { + return ApiKeyAuthenticationToken.class.isAssignableFrom(authentication); + } +} diff --git a/src/main/java/io/heapdog/core/security/ApiKeyAuthenticationToken.java b/src/main/java/io/heapdog/core/security/ApiKeyAuthenticationToken.java new file mode 100644 index 0000000..e4004c8 --- /dev/null +++ b/src/main/java/io/heapdog/core/security/ApiKeyAuthenticationToken.java @@ -0,0 +1,36 @@ +package io.heapdog.core.security; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; + +public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken { + private final String apiKey; + private final Object principal; + + public ApiKeyAuthenticationToken(Object principal, String apiKey, boolean authenticated, Collection authorities) { + super(authorities); + this.principal = principal; + this.apiKey = apiKey; + setAuthenticated(authenticated); + } + + public static ApiKeyAuthenticationToken authenticated(Object principal, Collection authorities) { + return new ApiKeyAuthenticationToken(principal, null, true, authorities); + } + + public static ApiKeyAuthenticationToken unauthenticated(String apiKey) { + return new ApiKeyAuthenticationToken(null, apiKey, false, null); + } + + @Override + public Object getCredentials() { + return this.apiKey; + } + + @Override + public Object getPrincipal() { + return this.principal; + } +} diff --git a/src/main/java/io/heapdog/core/security/jwt/JwtAuthenticationEntryPoint.java b/src/main/java/io/heapdog/core/security/jwt/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..5b5d455 --- /dev/null +++ b/src/main/java/io/heapdog/core/security/jwt/JwtAuthenticationEntryPoint.java @@ -0,0 +1,28 @@ +package io.heapdog.core.security.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.AllArgsConstructor; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.Map; + +@Component +@AllArgsConstructor +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + response.setContentType("application/json"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + var errorResponse = Map.of("message", authException.getMessage(), "path", request.getRequestURI()); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/java/io/heapdog/core/security/jwt/JwtAuthenticationFilter.java b/src/main/java/io/heapdog/core/security/jwt/JwtAuthenticationFilter.java index 9ff7c40..8260235 100644 --- a/src/main/java/io/heapdog/core/security/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/io/heapdog/core/security/jwt/JwtAuthenticationFilter.java @@ -9,18 +9,15 @@ import org.springframework.lang.NonNull; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; -import org.springframework.web.servlet.HandlerExceptionResolver; import java.io.IOException; @AllArgsConstructor -@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtAuthenticationService jwtAuthenticationService; - private final HandlerExceptionResolver handlerExceptionResolver; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; @Override protected void doFilterInternal(@NonNull HttpServletRequest request, @@ -29,17 +26,19 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, String authHeader = request.getHeader("authorization"); - if (authHeader != null && authHeader.startsWith("Bearer")) { - String token = authHeader.substring(7); - try { - Authentication authentication = jwtAuthenticationService.verifyToken(token); - SecurityContextHolder.getContext().setAuthentication(authentication); - } catch (JwtValidationFailedException e) { - handlerExceptionResolver.resolveException(request, response, null, e); - return; - } + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; } - filterChain.doFilter(request, response); + String token = authHeader.substring(7); + try { + Authentication authentication = jwtAuthenticationService.verifyToken(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (JwtValidationFailedException e) { + jwtAuthenticationEntryPoint.commence(request, response, e); + return; + } + filterChain.doFilter(request, response); } } diff --git a/src/main/java/io/heapdog/core/shared/GlobalControllerAdvice.java b/src/main/java/io/heapdog/core/shared/GlobalControllerAdvice.java index 97f6317..cf7c972 100644 --- a/src/main/java/io/heapdog/core/shared/GlobalControllerAdvice.java +++ b/src/main/java/io/heapdog/core/shared/GlobalControllerAdvice.java @@ -9,6 +9,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.DisabledException; import org.springframework.security.authorization.AuthorizationDeniedException; @@ -253,4 +254,20 @@ ResponseEntity handleIllegalArgumentException(IllegalArgumentException .build(); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } + + @SkipApiWrap + @ExceptionHandler(exception = HttpMessageNotReadableException.class) + ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException ex, HttpServletRequest request) { + log.warn(ex.toString()); + ApiError errorResponse = ApiError + .builder() + .timestamp(Instant.now()) + .status(HttpStatus.BAD_REQUEST.value()) + .error(HttpStatus.BAD_REQUEST.getReasonPhrase()) + .code("MALFORMED_REQUEST") + .message("Malformed request body") + .path(request.getRequestURI()) + .build(); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } } diff --git a/src/main/resources/db/migration/V20251211222452__create_service_user_table.sql b/src/main/resources/db/migration/V20251211222452__create_service_user_table.sql new file mode 100644 index 0000000..66c7aba --- /dev/null +++ b/src/main/resources/db/migration/V20251211222452__create_service_user_table.sql @@ -0,0 +1,28 @@ +CREATE TABLE heapdog_service_user +( + id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP WITHOUT TIME ZONE, + created_by_id BIGINT, + updated_by_id BIGINT, + is_deleted BOOLEAN NOT NULL, + name VARCHAR(255), + api_key VARCHAR(255), + enabled BOOLEAN NOT NULL, + CONSTRAINT pk_heapdog_service_user PRIMARY KEY (id) +); + +CREATE TABLE heapdog_service_user_permission +( + service_user_id BIGINT NOT NULL, + permission VARCHAR(255) +); + +ALTER TABLE heapdog_service_user + ADD CONSTRAINT FK_HEAPDOG_SERVICE_USER_ON_CREATED_BY FOREIGN KEY (created_by_id) REFERENCES heapdog_user (id); + +ALTER TABLE heapdog_service_user + ADD CONSTRAINT FK_HEAPDOG_SERVICE_USER_ON_UPDATED_BY FOREIGN KEY (updated_by_id) REFERENCES heapdog_user (id); + +ALTER TABLE heapdog_service_user_permission + ADD CONSTRAINT fk_heapdog_service_user_permission_on_service_user FOREIGN KEY (service_user_id) REFERENCES heapdog_service_user (id); \ No newline at end of file