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
41 changes: 29 additions & 12 deletions src/main/java/io/heapdog/core/config/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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))
Expand All @@ -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();
}

Expand Down
34 changes: 34 additions & 0 deletions src/main/java/io/heapdog/core/feature/serviceuser/ServiceUser.java
Original file line number Diff line number Diff line change
@@ -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<ServiceUserPermission> permissions;



}
Original file line number Diff line number Diff line change
@@ -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<ServiceUserCreateResponseDto>
createServiceUser(@Valid @RequestBody ServiceUserCreateRequestDto request) {
var resp = serviceUserService.createServiceUser(request);
return ResponseEntity.ok(resp);
}

}
Original file line number Diff line number Diff line change
@@ -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;

}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<ServiceUser, Long> {

Optional<ServiceUser> findByApiKey(String apiKey);

}
Original file line number Diff line number Diff line change
@@ -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();
}
}
39 changes: 39 additions & 0 deletions src/main/java/io/heapdog/core/security/ApiKeyAuthEntryPoint.java
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<ServiceUser> 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);
}
}
Loading