Arsitektur Keamanan Apache Fineract
Ringkasan Eksekutif
Apache Fineract mengimplementasikan arsitektur keamanan berlapis (defense-in-depth) yang komprehensif untuk melindungi data keuangan sensitif dan memastikan compliance dengan regulasi industri finansial. Arsitektur ini menggabungkan authentication, authorization, encryption, audit logging, dan monitoring untuk memberikan protection yang menyeluruh terhadap berbagai ancaman keamanan.
Arsitektur Keamanan Berlapis
Defense-in-Depth Model
1. Authentication Architecture
Multi-Factor Authentication (MFA) System
Core Authentication Components
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
@Bean
public AuthenticationProvider totpAuthenticationProvider() {
TOTPAuthenticationProvider provider = new TOTPAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setOneTimePasswordService(oneTimePasswordService);
return provider;
}
}
@Service
public class MultiFactorAuthenticationService {
@Autowired
private UserRepository userRepository;
@Autowired
private OneTimePasswordService otpService;
@Autowired
private SmsService smsService;
@Autowired
private EmailService emailService;
public AuthenticationResult authenticateWithMFA(String username, String password,
String otpCode, MfaMethod method) {
// Step 1: Primary authentication (password)
Authentication passwordAuth = authenticatePassword(username, password);
if (!passwordAuth.isAuthenticated()) {
return AuthenticationResult.failed("Invalid credentials");
}
User user = (User) passwordAuth.getPrincipal();
// Step 2: Check if MFA is enabled for user
if (!user.isMfaEnabled()) {
return AuthenticationResult.success(user, "Password authentication only");
}
// Step 3: Validate MFA code
boolean isOtpValid = validateMfaCode(user, otpCode, method);
if (!isOtpValid) {
// Log failed MFA attempt
auditService.logMfaFailure(user.getId(), method, "Invalid OTP code");
return AuthenticationResult.failed("Invalid MFA code");
}
// Step 4: Generate session token
String sessionToken = sessionService.createSession(user);
auditService.logMfaSuccess(user.getId(), method);
return AuthenticationResult.success(user, sessionToken, "MFA authentication successful");
}
public void enableMfaForUser(String userId, MfaMethod method) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
if (method == MfaMethod.TOTP) {
// Generate TOTP secret
String secret = totpService.generateSecret();
// Generate QR code
String qrCodeUrl = totpService.generateQrCodeUrl(user.getEmail(), secret);
// Store temporary secret (not yet activated)
user.setMfaSecret(totpService.encryptSecret(secret));
user.setMfaMethod(method);
user.setMfaEnabled(false); // Will be enabled after verification
userRepository.save(user);
// Send QR code to user
emailService.sendMfaSetupEmail(user.getEmail(), qrCodeUrl, secret);
} else if (method == MfaMethod.SMS) {
// Send SMS verification code
String verificationCode = otpService.generateCode();
otpService.storeVerificationCode(user.getPhoneNumber(), verificationCode,
Duration.ofMinutes(10));
smsService.sendMfaVerificationCode(user.getPhoneNumber(), verificationCode);
} else if (method == MfaMethod.EMAIL) {
// Send email verification code
String verificationCode = otpService.generateCode();
otpService.storeVerificationCode(user.getEmail(), verificationCode,
Duration.ofMinutes(10));
emailService.sendMfaVerificationCode(user.getEmail(), verificationCode);
}
}
public boolean verifyMfaSetup(String userId, String verificationCode, String secret) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
if (user.getMfaMethod() == MfaMethod.TOTP) {
// Verify TOTP code
String decryptedSecret = totpService.decryptSecret(user.getMfaSecret());
boolean isValid = totpService.verifyCode(decryptedSecret, verificationCode);
if (isValid) {
user.setMfaEnabled(true);
userRepository.save(user);
auditService.logMfaSetupCompleted(userId, MfaMethod.TOTP);
return true;
}
}
return false;
}
private boolean validateMfaCode(User user, String code, MfaMethod method) {
switch (method) {
case TOTP:
String secret = totpService.decryptSecret(user.getMfaSecret());
return totpService.verifyCode(secret, code);
case SMS:
return otpService.verifyCode(user.getPhoneNumber(), code);
case EMAIL:
return otpService.verifyCode(user.getEmail(), code);
default:
return false;
}
}
}
// MFA Models
public enum MfaMethod {
TOTP, SMS, EMAIL, BACKUP_CODES
}
@Data
@Builder
public class AuthenticationResult {
private boolean success;
private String token;
private String message;
private User user;
private Set<String> authorities;
private Duration expiresIn;
public static AuthenticationResult success(User user, String token, String message) {
return AuthenticationResult.builder()
.success(true)
.user(user)
.token(token)
.message(message)
.authorities(extractAuthorities(user))
.expiresIn(Duration.ofHours(24))
.build();
}
public static AuthenticationResult failed(String message) {
return AuthenticationResult.builder()
.success(false)
.message(message)
.build();
}
}
OAuth2/JWT Implementation
@Configuration
@EnableAuthorizationServer
public class OAuth2Config {
@Bean
public AuthorizationServerEndpointsConfigurer authorizationServerEndpointsConfigurer() {
return new AuthorizationServerEndpointsConfigurer()
.authenticationManager(authenticationManager)
.accessTokenConverter(jwtTokenConverter())
.tokenStore(tokenStore())
.exceptionTranslator(oauthExceptionTranslator())
.reuseRefreshTokens(false);
}
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(jwtTokenConverter());
}
@Bean
public JwtAccessTokenConverter jwtTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey(jwtSecret);
// Add custom token enhancer
TokenEnhancerChain enhancerChain = new TokenEnhancerChain();
enhancerChain.setTokenEnhancers(Arrays.asList(
new CustomTokenEnhancer(),
converter
));
return converter;
}
@Bean
public TokenEnhancer customTokenEnhancer() {
return new CustomTokenEnhancer();
}
}
public class CustomTokenEnhancer implements TokenEnhancer {
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken,
OAuth2Authentication authentication) {
User principal = (User) authentication.getPrincipal();
Map<String, Object> additionalInfo = new HashMap<>();
additionalInfo.put("user_id", principal.getId());
additionalInfo.put("tenant_id", getTenantId(authentication));
additionalInfo.put("roles", extractRoles(principal));
additionalInfo.put("permissions", extractPermissions(principal));
additionalInfo.put("office_id", principal.getOfficeId());
additionalInfo.put("issued_at", Instant.now().toEpochMilli());
((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo);
return accessToken;
}
private String getTenantId(OAuth2Authentication authentication) {
// Extract tenant from authentication or context
return authentication.getOAuth2Request().getRequestParameters()
.getOrDefault("tenant_id", "default");
}
}
@Component
public class JwtTokenValidator {
public boolean validateToken(String token, String tenantId) {
try {
Claims claims = Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
// Check expiration
Date expiration = claims.getExpiration();
if (expiration.before(new Date())) {
throw new ExpiredJwtException(null, claims, "Token expired");
}
// Check tenant
String tokenTenant = claims.get("tenant_id", String.class);
if (!tenantId.equals(tokenTenant)) {
throw new IllegalArgumentException("Invalid tenant");
}
// Check if user still exists and is active
Long userId = claims.get("user_id", Long.class);
User user = userRepository.findById(userId);
if (user == null || !user.isEnabled()) {
throw new IllegalArgumentException("User not found or disabled");
}
return true;
} catch (JwtException | IllegalArgumentException e) {
log.warn("Token validation failed: {}", e.getMessage());
return false;
}
}
}
2. Authorization Architecture
Role-Based Access Control (RBAC)
Permission System Implementation
@Entity
@Table(name = "m_permission")
public class Permission {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "code", unique = true, nullable = false)
private String code;
@Column(name = "description")
private String description;
@Column(name = "module")
private String module;
@Column(name = "operation")
private String operation;
@Column(name = "resource")
private String resource;
@ManyToMany(mappedBy = "permissions", fetch = FetchType.EAGER)
private Set<Role> roles = new HashSet<>();
// Predefined permissions
public static final String CLIENT_READ = "CLIENT_READ";
public static final String CLIENT_WRITE = "CLIENT_WRITE";
public static final String LOAN_CREATE = "LOAN_CREATE";
public static final String LOAN_APPROVE = "LOAN_APPROVE";
public static final String LOAN_DISBURSE = "LOAN_DISBURSE";
public static final String LOAN_REPAYMENT = "LOAN_REPAYMENT";
public static final String SAVINGS_READ = "SAVINGS_READ";
public static final String SAVINGS_WRITE = "SAVINGS_WRITE";
public static final String REPORT_GENERATE = "REPORT_GENERATE";
public static final String USER_MANAGE = "USER_MANAGE";
public static final String SYSTEM_CONFIG = "SYSTEM_CONFIG";
}
@Entity
@Table(name = "m_role")
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name", unique = true, nullable = false)
private String name;
@Column(name = "description")
private String description;
@Column(name = "is_system_role")
private boolean systemRole;
@Column(name = "can_be_deleted")
private boolean deletable;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "m_role_permission",
joinColumns = @JoinColumn(name = "role_id"),
inverseJoinColumns = @JoinColumn(name = "permission_id"))
private Set<Permission> permissions = new HashSet<>();
@ManyToMany(mappedBy = "roles")
private Set<User> users = new HashSet<>();
// Predefined roles
public static final String SUPER_ADMIN = "Super Admin";
public static final String ADMIN = "Admin";
public static final String LOAN_OFFICER = "Loan Officer";
public static final String TELLER = "Teller";
public static final String MANAGER = "Manager";
public static final String AUDITOR = "Auditor";
public static final String SELF_SERVICE_USER = "Self Service User";
}
@Entity
@Table(name = "m_appuser_role")
public class UserRole {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "appuser_id", nullable = false)
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "role_id", nullable = false)
private Role role;
@Column(name = "start_date")
private LocalDate startDate;
@Column(name = "end_date")
private LocalDate endDate;
@Column(name = "is_active")
private boolean active = true;
@Column(name = "created_by")
private Long createdBy;
@Column(name = "created_date")
private LocalDateTime createdDate;
}
Authorization Service Implementation
@Service
public class AuthorizationService {
@Autowired
private UserRepository userRepository;
@Autowired
private RoleRepository roleRepository;
@Autowired
private PermissionRepository permissionRepository;
@Autowired
private AuditService auditService;
public boolean hasPermission(String username, String permission) {
User user = userRepository.findByEmail(username);
if (user == null || !user.isEnabled()) {
return false;
}
// Check direct permissions
Set<Permission> userPermissions = getUserPermissions(user.getId());
boolean hasDirectPermission = userPermissions.stream()
.anyMatch(p -> p.getCode().equals(permission));
if (hasDirectPermission) {
return true;
}
// Check role-based permissions
Set<Role> userRoles = getUserRoles(user.getId());
return userRoles.stream()
.anyMatch(role -> role.getPermissions().stream()
.anyMatch(p -> p.getCode().equals(permission)));
}
public boolean hasAnyPermission(String username, List<String> permissions) {
return permissions.stream()
.anyMatch(permission -> hasPermission(username, permission));
}
public boolean hasAllPermissions(String username, List<String> permissions) {
return permissions.stream()
.allMatch(permission -> hasPermission(username, permission));
}
public Set<Permission> getUserPermissions(Long userId) {
Set<Permission> permissions = new HashSet<>();
// Add direct permissions
permissions.addAll(userRepository.getUserPermissions(userId));
// Add role-based permissions
Set<Role> roles = getUserRoles(userId);
roles.forEach(role -> permissions.addAll(role.getPermissions()));
return permissions;
}
public void assignRoleToUser(Long userId, Long roleId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
Role role = roleRepository.findById(roleId)
.orElseThrow(() -> new RoleNotFoundException(roleId));
// Check if assignment already exists
Optional<UserRole> existingAssignment = userRoleRepository
.findByUserIdAndRoleIdAndActive(userId, roleId);
if (existingAssignment.isPresent()) {
throw new RoleAssignmentException("Role already assigned to user");
}
// Create new assignment
UserRole userRole = UserRole.builder()
.user(user)
.role(role)
.startDate(LocalDate.now())
.active(true)
.createdBy(getCurrentUserId())
.createdDate(LocalDateTime.now())
.build();
userRoleRepository.save(userRole);
// Audit log
auditService.logRoleAssignment(userId, roleId, "ASSIGNED");
}
public void revokeRoleFromUser(Long userId, Long roleId) {
Optional<UserRole> userRole = userRoleRepository
.findByUserIdAndRoleIdAndActive(userId, roleId);
if (userRole.isPresent()) {
UserRole assignment = userRole.get();
assignment.setActive(false);
assignment.setEndDate(LocalDate.now());
userRoleRepository.save(assignment);
// Audit log
auditService.logRoleAssignment(userId, roleId, "REVOKED");
}
}
}
Method-Level Security
@RestController
@RequestMapping("/api/v1/loans")
@PreAuthorize("hasAuthority('READ_WRITE_FINERACT_API')")
public class LoansApiResource {
@GetMapping
@PreAuthorize("hasPermission('LOAN', 'READ')")
public ResponseEntity<List<LoanAccountData>> retrieveAll(
@RequestParam Map<String, String> parameters) {
// Implementation
}
@PostMapping
@PreAuthorize("hasPermission('LOAN', 'CREATE')")
public ResponseEntity<CommandProcessingResult> create(
@RequestBody String apiRequestBodyAsJson) {
// Implementation
}
@PostMapping("/{loanId}")
@PreAuthorize("hasPermission(#loanId, 'LOAN', 'UPDATE')")
public ResponseEntity<CommandProcessingResult> stateTransitions(
@PathParam("loanId") Long loanId,
@RequestParam("command") String commandParam,
@RequestBody String apiRequestBodyAsJson) {
// Implementation
}
}
@Component
public class MethodSecurityExpressionHandler extends DefaultMethodSecurityExpressionHandler {
private final PermissionEvaluator permissionEvaluator = new FineractPermissionEvaluator();
private final RoleHierarchy roleHierarchy = new RoleHierarchyImpl();
@Override
public MethodSecurityExpressionOperations createSecurityExpressionRoot(
MethodInvocation methodInvocation, Authentication authentication) {
FineractMethodSecurityExpressionRoot root = new FineractMethodSecurityExpressionRoot(authentication);
root.setPermissionEvaluator(permissionEvaluator);
root.setRoleHierarchy(roleHierarchy);
root.setTrustResolver(authenticationTrustResolver());
root.setRoleVoter(new RoleVoter());
root.setDefaultRolePrefix("ROLE_");
return root;
}
}
public class FineractMethodSecurityExpressionRoot extends MethodSecurityExpressionOperations {
public boolean hasPermission(Long entityId, String entityType, String operation) {
// Check specific entity permission
return permissionService.hasEntityPermission(entityId, entityType, operation, getPrincipal());
}
public boolean hasTenantPermission(String permission) {
// Check tenant-specific permission
return tenantService.hasPermission(getTenantId(), permission, getPrincipal());
}
public boolean hasRoleInOffice(String role, Long officeId) {
// Check if user has role in specific office
return officeService.hasUserRoleInOffice(getPrincipal().getId(), role, officeId);
}
public boolean hasLoanAccess(Long loanId) {
// Check if user has access to specific loan
return loanService.canUserAccessLoan(getPrincipal().getId(), loanId);
}
public boolean hasClientAccess(Long clientId) {
// Check if user has access to specific client
return clientService.canUserAccessClient(getPrincipal().getId(), clientId);
}
}
Attribute-Based Access Control (ABAC)
@Component
public class AttributeBasedAccessControl {
@Autowired
private UserRepository userRepository;
@Autowired
private OfficeService officeService;
public boolean evaluateAccessPolicy(String username, String resource,
String action, Map<String, Object> attributes) {
User user = userRepository.findByEmail(username);
if (user == null || !user.isEnabled()) {
return false;
}
// Get user context
UserContext userContext = buildUserContext(user);
// Evaluate different policy types
switch (resource) {
case "LOAN":
return evaluateLoanAccessPolicy(userContext, action, attributes);
case "CLIENT":
return evaluateClientAccessPolicy(userContext, action, attributes);
case "SAVINGS_ACCOUNT":
return evaluateSavingsAccessPolicy(userContext, action, attributes);
case "REPORT":
return evaluateReportAccessPolicy(userContext, action, attributes);
default:
return false;
}
}
private boolean evaluateLoanAccessPolicy(UserContext userContext, String action,
Map<String, Object> attributes) {
Long loanId = (Long) attributes.get("loanId");
Loan loan = loanRepository.findById(loanId);
if (loan == null) {
return false;
}
switch (action) {
case "READ":
// Can read if loan belongs to user's office hierarchy
return officeService.isInOfficeHierarchy(userContext.getOfficeId(),
loan.getOffice().getId());
case "UPDATE":
case "DELETE":
// Can modify if loan is in user's office and user has proper role
return isInLoanOffice(userContext, loan) &&
hasLoanManagementRole(userContext);
case "DISBURSE":
// Can disburse only if loan is approved and user has disbursement permission
return isInLoanOffice(userContext, loan) &&
loan.getStatus() == LoanStatus.APPROVED &&
hasPermission(userContext, "LOAN_DISBURSE");
case "APPROVE":
// Can approve only if user is manager or above in loan office
return isInLoanOffice(userContext, loan) &&
hasMinimumRole(userContext, Role.MANAGER);
default:
return false;
}
}
private boolean evaluateClientAccessPolicy(UserContext userContext, String action,
Map<String, Object> attributes) {
Long clientId = (Long) attributes.get("clientId");
Client client = clientRepository.findById(clientId);
if (client == null) {
return false;
}
switch (action) {
case "READ":
// Can read clients in office hierarchy
return officeService.isInOfficeHierarchy(userContext.getOfficeId(),
client.getOffice().getId());
case "UPDATE":
case "DELETE":
// Can modify only in same office
return userContext.getOfficeId().equals(client.getOffice().getId()) &&
hasClientManagementRole(userContext);
case "CREATE_LOAN":
case "CREATE_SAVINGS":
// Can create products only for clients in office
return userContext.getOfficeId().equals(client.getOffice().getId()) &&
hasProductCreationRole(userContext);
default:
return false;
}
}
private UserContext buildUserContext(User user) {
return UserContext.builder()
.userId(user.getId())
.officeId(user.getOfficeId())
.roles(getUserRoles(user.getId()))
.permissions(getUserPermissions(user.getId()))
.isSystemAdmin(user.isSystemAdmin())
.build();
}
}
3. Data Security dan Encryption
Encryption at Rest Implementation
@Service
public class EncryptionService {
private final String encryptionKey;
private final AESEncryptionService aesEncryptionService;
private final RSAEncryptionService rsaEncryptionService;
public EncryptionService(@Value("${app.encryption.key}") String encryptionKey) {
this.encryptionKey = encryptionKey;
this.aesEncryptionService = new AESEncryptionService(encryptionKey);
this.rsaEncryptionService = new RSAEncryptionService();
}
public String encryptSensitiveData(String plainText, EncryptionType type) {
switch (type) {
case AES:
return aesEncryptionService.encrypt(plainText);
case RSA:
return rsaEncryptionService.encrypt(plainText);
case AES_RSA:
return encryptWithRSAKey(plainText);
default:
throw new IllegalArgumentException("Unsupported encryption type");
}
}
public String decryptSensitiveData(String cipherText, EncryptionType type) {
switch (type) {
case AES:
return aesEncryptionService.decrypt(cipherText);
case RSA:
return rsaEncryptionService.decrypt(cipherText);
case AES_RSA:
return decryptWithRSAKey(cipherText);
default:
throw new IllegalArgumentException("Unsupported encryption type");
}
}
// Hybrid encryption for highly sensitive data
private String encryptWithRSAKey(String plainText) {
// Generate AES key
String aesKey = AESEncryptionService.generateKey();
// Encrypt data with AES
String encryptedData = aesEncryptionService.encrypt(plainText, aesKey);
// Encrypt AES key with RSA public key
String encryptedKey = rsaEncryptionService.encrypt(aesKey);
// Combine encrypted key and encrypted data
return encryptedKey + ":" + encryptedData;
}
private String decryptWithRSAKey(String cipherText) {
String[] parts = cipherText.split(":");
String encryptedKey = parts[0];
String encryptedData = parts[1];
// Decrypt AES key
String aesKey = rsaEncryptionService.decrypt(encryptedKey);
// Decrypt data with AES key
return aesEncryptionService.decrypt(encryptedData, aesKey);
}
}
@Entity
@EntityListeners(AuditingEntityListener.class)
@Table(name = "m_client_sensitive_data")
public class ClientSensitiveData {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "client_id", nullable = false)
private Long clientId;
@Convert(converter = EncryptedStringConverter.class)
@Column(name = "national_id_encrypted", columnDefinition = "LONGTEXT")
private String nationalId;
@Convert(converter = EncryptedStringConverter.class)
@Column(name = "bank_account_encrypted", columnDefinition = "LONGTEXT")
private String bankAccount;
@Convert(converter = EncryptedStringConverter.class)
@Column(name = "passport_number_encrypted", columnDefinition = "LONGTEXT")
private String passportNumber;
@Convert(converter = EncryptedStringConverter.class)
@Column(name = "driving_license_encrypted", columnDefinition = "LONGTEXT")
private String drivingLicense;
@Column(name = "data_classification", nullable = false)
private DataClassification classification;
@Column(name = "created_at", nullable = false)
@CreatedDate
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
@LastModifiedDate
private LocalDateTime updatedAt;
}
@Converter
public class EncryptedStringConverter implements AttributeConverter<String, String> {
@Autowired
private EncryptionService encryptionService;
@Override
public String convertToDatabaseColumn(String attribute) {
if (attribute == null) {
return null;
}
return encryptionService.encryptSensitiveData(attribute, EncryptionType.AES_RSA);
}
@Override
public String convertToEntityAttribute(String dbData) {
if (dbData == null) {
return null;
}
return encryptionService.decryptSensitiveData(dbData, EncryptionType.AES_RSA);
}
}
Database Encryption
@Configuration
public class DatabaseEncryptionConfig {
@Bean
public DataSource encryptedDataSource(DataSource dataSource) {
return new EncryptedDataSourceProxy(dataSource);
}
@Bean
public EntityManagerFactory entityManagerFactory(DataSource dataSource) {
LocalContainerEntityManagerFactoryBean factoryBean = new LocalContainerEntityManagerFactoryBean();
factoryBean.setDataSource(dataSource);
factoryBean.setPackagesToScan("org.apache.fineract.**.domain");
factoryBean.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
Properties jpaProperties = new Properties();
jpaProperties.put("hibernate.dialect", "org.hibernate.dialect.MySQL8Dialect");
jpaProperties.put("hibernate.hbm2ddl.auto", "none");
jpaProperties.put("hibernate.show_sql", false);
jpaProperties.put("hibernate.format_sql", false);
// Enable Transparent Data Encryption (TDE)
jpaProperties.put("hibernate.connection.provider_disables_autocommit", true);
jpaProperties.put("hibernate.jdbc.batch_size", 25);
factoryBean.setJpaProperties(jpaProperties);
return factoryBean.getObject();
}
}
public class EncryptedDataSourceProxy implements DataSource {
private final DataSource delegate;
private final EncryptionService encryptionService;
public EncryptedDataSourceProxy(DataSource delegate) {
this.delegate = delegate;
this.encryptionService = new EncryptionService(System.getenv("DB_ENCRYPTION_KEY"));
}
@Override
public Connection getConnection() throws SQLException {
return new EncryptedConnectionProxy(delegate.getConnection(), encryptionService);
}
@Override
public Connection getConnection(String username, String password) throws SQLException {
return new EncryptedConnectionProxy(delegate.getConnection(username, password), encryptionService);
}
}
public class EncryptedConnectionProxy implements Connection {
private final Connection delegate;
private final EncryptionService encryptionService;
public EncryptedConnectionProxy(Connection delegate, EncryptionService encryptionService) {
this.delegate = delegate;
this.encryptionService = encryptionService;
}
@Override
public PreparedStatement prepareStatement(String sql) throws SQLException {
PreparedStatement statement = delegate.prepareStatement(sql);
// Auto-encrypt sensitive parameters
return new EncryptingPreparedStatement(statement, encryptionService);
}
// Other Connection method implementations...
}
4. API Security
API Authentication dan Rate Limiting
@Configuration
@EnableWebFluxSecurity
public class ApiSecurityConfig {
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
return http
.authorizeExchange(exchanges -> exchanges
.pathMatchers("/api/v1/auth/**").permitAll()
.pathMatchers("/api/v1/self/**").hasAuthority("READ_SELF_SERVICE_DATA")
.pathMatchers(HttpMethod.POST, "/api/v1/loans/**")
.hasAuthority("CREATE_LOAN")
.pathMatchers(HttpMethod.PUT, "/api/v1/loans/**")
.hasAuthority("UPDATE_LOAN")
.pathMatchers("/api/v1/**")
.hasAuthority("READ_WRITE_FINERACT_API")
.anyExchange().authenticated()
)
.httpBasic(Customizer.withDefaults())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.addFilterBefore(rateLimitingFilter(), SecurityWebFiltersOrder.AUTHORIZATION)
.addFilterBefore(requestValidationFilter(), SecurityWebFiltersOrder.AUTHENTICATION)
.build();
}
@Bean
public RateLimitingFilter rateLimitingFilter() {
return new RateLimitingFilter();
}
@Bean
public RequestValidationFilter requestValidationFilter() {
return new RequestValidationFilter();
}
}
@Component
public class RateLimitingFilter implements WebFilter {
private final Map<String, RateLimitTracker> rateLimitCache = new ConcurrentHashMap<>();
private final int maxRequests = 1000;
private final Duration timeWindow = Duration.ofMinutes(1);
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
String clientIp = getClientIP(exchange);
String path = exchange.getRequest().getURI().getPath();
String key = clientIp + ":" + path;
return rateLimitCache.compute(key, (k, tracker) -> {
if (tracker == null || tracker.isExpired()) {
tracker = new RateLimitTracker();
}
if (tracker.incrementAndGet() > maxRequests) {
// Rate limit exceeded
exchange.getResponse().setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
return Mono.error(new RateLimitExceededException("Rate limit exceeded"));
}
return chain.filter(exchange);
});
}
private String getClientIP(ServerWebExchange exchange) {
String xForwardedFor = exchange.getRequest().getHeaders()
.getFirst("X-Forwarded-For");
if (xForwardedFor != null) {
return xForwardedFor.split(",")[0].trim();
}
String xRealIp = exchange.getRequest().getHeaders()
.getFirst("X-Real-IP");
if (xRealIp != null) {
return xRealIp;
}
return exchange.getRequest().getRemoteAddress()
.getAddress().getHostAddress();
}
}
Input Validation dan Sanitization
@Component
public class InputValidationFilter implements Filter {
private final Set<String> forbiddenPatterns = Set.of(
"<script", "javascript:", "vbscript:", "onload=", "onerror=",
"SELECT ", "INSERT ", "UPDATE ", "DELETE ", "DROP ", "UNION ",
"../", "..\\\\", "/etc/passwd", "boot.ini"
);
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
// Validate request
ValidationResult validation = validateRequest(httpRequest);
if (!validation.isValid()) {
httpResponse.sendError(HttpStatus.BAD_REQUEST.value(),
"Request contains invalid or dangerous content");
return;
}
// Wrap request and response with sanitizing wrappers
HttpServletRequest sanitizedRequest = new SanitizingHttpServletRequest(httpRequest);
HttpServletResponse sanitizedResponse = new SanitizingHttpServletResponse(httpResponse);
chain.doFilter(sanitizedRequest, sanitizedResponse);
}
private ValidationResult validateRequest(HttpServletRequest request) {
// Check for XSS patterns
Enumeration<String> parameterNames = request.getParameterNames();
while (parameterNames.hasMoreElements()) {
String paramName = parameterNames.nextElement();
String paramValue = request.getParameter(paramName);
if (containsForbiddenPatterns(paramValue)) {
return ValidationResult.invalid("Parameter " + paramName +
" contains forbidden content");
}
}
// Check request body
if (request instanceof HttpServletRequestWrapper) {
try {
String body = extractRequestBody((HttpServletRequestWrapper) request);
if (containsForbiddenPatterns(body)) {
return ValidationResult.invalid("Request body contains forbidden content");
}
} catch (IOException e) {
// Continue with other validations
}
}
return ValidationResult.valid();
}
private boolean containsForbiddenPatterns(String content) {
if (content == null || content.isEmpty()) {
return false;
}
String lowerContent = content.toLowerCase();
return forbiddenPatterns.stream()
.anyMatch(pattern -> lowerContent.contains(pattern.toLowerCase()));
}
}
public class SanitizingHttpServletRequestWrapper extends HttpServletRequestWrapper {
private final Set<String> parametersToSanitize = Set.of(
"firstName", "lastName", "email", "address", "description", "notes",
"clientName", "groupName", "productName", "accountName"
);
public SanitizingHttpServletRequestWrapper(HttpServletRequest request) {
super(request);
}
@Override
public String getParameter(String name) {
String value = super.getParameter(name);
if (parametersToSanitize.contains(name)) {
return sanitizeString(value);
}
return value;
}
@Override
public String[] getParameterValues(String name) {
String[] values = super.getParameterValues(name);
if (values != null && parametersToSanitize.contains(name)) {
return Arrays.stream(values)
.map(this::sanitizeString)
.toArray(String[]::new);
}
return values;
}
private String sanitizeString(String input) {
if (input == null) {
return null;
}
return input
.replace("<", "<")
.replace(">", ">")
.replace("\"", """)
.replace("'", "'")
.replace("/", "/");
}
}
5. Audit Logging dan Compliance
Comprehensive Audit System
@Entity
@Table(name = "m_audit_log")
@EntityListeners(AuditingEntityListener.class)
public class AuditLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "timestamp", nullable = false)
@CreatedDate
private LocalDateTime timestamp;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "username")
private String username;
@Column(name = "tenant_id", nullable = false)
private String tenantId;
@Column(name = "action", nullable = false)
private AuditAction action;
@Column(name = "entity_type", nullable = false)
private String entityType;
@Column(name = "entity_id")
private Long entityId;
@Column(name = "resource_identifier")
private String resourceIdentifier;
@Column(name = "ip_address")
private String ipAddress;
@Column(name = "user_agent")
private String userAgent;
@Column(name = "session_id")
private String sessionId;
@Column(name = "request_method")
private String requestMethod;
@Column(name = "request_uri")
private String requestUri;
@Column(name = "status_code")
private Integer statusCode;
@Column(name = "duration_ms")
private Long durationMs;
@Column(name = "old_values", columnDefinition = "TEXT")
private String oldValues;
@Column(name = "new_values", columnDefinition = "TEXT")
private String newValues;
@Column(name = "success", nullable = false)
private boolean success = true;
@Column(name = "error_message")
private String errorMessage;
@Column(name = "correlation_id")
private String correlationId;
@Column(name = "risk_score")
private Integer riskScore;
}
@Service
public class AuditService {
@Autowired
private AuditLogRepository auditLogRepository;
@Autowired
private UserService userService;
@Async
public void logAccess(String username, String action, String resource,
String method, String uri, String ipAddress,
String userAgent, int statusCode, long duration) {
AuditLog auditLog = AuditLog.builder()
.timestamp(LocalDateTime.now())
.userId(getCurrentUserId(username))
.username(username)
.tenantId(getCurrentTenantId())
.action(AuditAction.ACCESS)
.entityType(resource)
.requestMethod(method)
.requestUri(uri)
.ipAddress(ipAddress)
.userAgent(userAgent)
.statusCode(statusCode)
.durationMs(duration)
.success(statusCode < 400)
.correlationId(generateCorrelationId())
.build();
auditLogRepository.save(auditLog);
}
@Async
public void logDataChange(String username, String action, String entityType,
Long entityId, String oldValues, String newValues) {
AuditLog auditLog = AuditLog.builder()
.timestamp(LocalDateTime.now())
.userId(getCurrentUserId(username))
.username(username)
.tenantId(getCurrentTenantId())
.action(AuditAction.valueOf(action))
.entityType(entityType)
.entityId(entityId)
.oldValues(oldValues)
.newValues(newValues)
.success(true)
.correlationId(generateCorrelationId())
.riskScore(calculateRiskScore(action, entityType, oldValues, newValues))
.build();
auditLogRepository.save(auditLog);
// Trigger real-time monitoring for high-risk operations
if (auditLog.getRiskScore() > 70) {
alertSecurityTeam(auditLog);
}
}
@Async
public void logSecurityEvent(String username, SecurityEventType eventType,
String details, String ipAddress, String userAgent) {
AuditLog auditLog = AuditLog.builder()
.timestamp(LocalDateTime.now())
.userId(getCurrentUserId(username))
.username(username)
.tenantId(getCurrentTenantId())
.action(AuditAction.SECURITY_EVENT)
.entityType(eventType.name())
.ipAddress(ipAddress)
.userAgent(userAgent)
.errorMessage(details)
.success(false)
.correlationId(generateCorrelationId())
.riskScore(100) // High risk
.build();
auditLogRepository.save(auditLog);
// Immediate security alert
alertSecurityTeam(auditLog);
}
@EventListener
public void handleLoanEvent(LoanEvent event) {
if (event instanceof LoanDisbursedEvent) {
logDataChange(getCurrentUsername(), "LOAN_DISBURSEMENT",
"LOAN", event.getLoan().getId(), null,
serializeLoanData(event.getLoan()));
} else if (event instanceof LoanApprovedEvent) {
logDataChange(getCurrentUsername(), "LOAN_APPROVAL",
"LOAN", event.getLoan().getId(),
serializeLoanStatus(event.getOldStatus()),
serializeLoanStatus(event.getNewStatus()));
}
}
public Page<AuditLog> getAuditLogs(AuditLogSearchCriteria criteria) {
return auditLogRepository.findByCriteria(
criteria.getStartDate(),
criteria.getEndDate(),
criteria.getUserId(),
criteria.getAction(),
criteria.getEntityType(),
criteria.getEntityId(),
criteria.getTenantId(),
PageRequest.of(criteria.getPage(), criteria.getSize())
);
}
}
6. Security Monitoring dan Alerting
Real-time Security Monitoring
@Component
public class SecurityMonitoringService {
@Autowired
private AuditLogRepository auditLogRepository;
@Autowired
private NotificationService notificationService;
@Scheduled(fixedRate = 60000) // Every minute
public void monitorSecurityEvents() {
checkFailedLoginAttempts();
checkMultipleLoanDisbursals();
checkUnusualDataAccess();
checkSystemConfigurationChanges();
checkHighRiskTransactions();
}
private void checkFailedLoginAttempts() {
LocalDateTime threshold = LocalDateTime.now().minusMinutes(15);
List<User> suspiciousUsers = userRepository.findUsersWithFailedLogins(threshold, 5);
for (User user : suspiciousUsers) {
SecurityAlert alert = SecurityAlert.builder()
.type(SecurityAlertType.FAILED_LOGINS)
.severity(SecurityAlertSeverity.MEDIUM)
.title("Multiple Failed Login Attempts")
.description(String.format("User %s has %d failed login attempts in 15 minutes",
user.getEmail(), user.getFailedLoginAttempts()))
.userId(user.getId())
.timestamp(Instant.now())
.build();
notificationService.sendSecurityAlert(alert);
}
}
private void checkMultipleLoanDisbursals() {
LocalDateTime threshold = LocalDateTime.now().minusHours(1);
List<User> activeDisbursers = userRepository
.findUsersWithHighDisbursalVolume(threshold, 1000000.0); // $1M threshold
for (User user : activeDisbursers) {
SecurityAlert alert = SecurityAlert.builder()
.type(SecurityAlertType.HIGH_VOLUME_DISBURSEMENT)
.severity(SecurityAlertSeverity.HIGH)
.title("High Volume Loan Disbursement")
.description(String.format("User %s has disbursed loans totaling $%.2f in the last hour",
user.getEmail(), user.getDisbursementVolume()))
.userId(user.getId())
.timestamp(Instant.now())
.build();
notificationService.sendSecurityAlert(alert);
}
}
private void checkUnusualDataAccess() {
// Check for after-hours access
LocalDateTime now = LocalDateTime.now();
if (now.getHour() < 6 || now.getHour() > 22) {
List<User> afterHoursUsers = userRepository.findUsersActiveAfterHours(now.minusHours(4));
for (User user : afterHoursUsers) {
SecurityAlert alert = SecurityAlert.builder()
.type(SecurityAlertType.AFTER_HOURS_ACCESS)
.severity(SecurityAlertSeverity.MEDIUM)
.title("After Hours Data Access")
.description(String.format("User %s accessed system outside business hours",
user.getEmail()))
.userId(user.getId())
.timestamp(Instant.now())
.build();
notificationService.sendSecurityAlert(alert);
}
}
}
private void checkSystemConfigurationChanges() {
LocalDateTime threshold = LocalDateTime.now().minusHours(24);
List<AuditLog> configChanges = auditLogRepository
.findConfigurationChanges(threshold);
for (AuditLog change : configChanges) {
if (change.getRiskScore() > 80) {
SecurityAlert alert = SecurityAlert.builder()
.type(SecurityAlertType.CRITICAL_CONFIG_CHANGE)
.severity(SecurityAlertSeverity.CRITICAL)
.title("Critical System Configuration Change")
.description(String.format("Critical config change by %s: %s",
change.getUsername(), change.getEntityType()))
.userId(change.getUserId())
.timestamp(change.getTimestamp().toInstant(ZoneOffset.UTC))
.build();
notificationService.sendSecurityAlert(alert);
}
}
}
}
@Entity
@Table(name = "m_security_alert")
public class SecurityAlert {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "type", nullable = false)
private SecurityAlertType type;
@Column(name = "severity", nullable = false)
private SecurityAlertSeverity severity;
@Column(name = "title", nullable = false)
private String title;
@Column(name = "description", nullable = false, columnDefinition = "TEXT")
private String description;
@Column(name = "user_id")
private Long userId;
@Column(name = "tenant_id")
private String tenantId;
@Column(name = "timestamp", nullable = false)
private Instant timestamp;
@Column(name = "acknowledged")
private boolean acknowledged = false;
@Column(name = "acknowledged_by")
private Long acknowledgedBy;
@Column(name = "acknowledged_at")
private Instant acknowledgedAt;
@Column(name = "resolved")
private boolean resolved = false;
@Column(name = "resolved_by")
private Long resolvedBy;
@Column(name = "resolved_at")
private Instant resolvedAt;
}
7. Compliance dan Regulatory Requirements
SOX Compliance Implementation
@Component
public class SoxComplianceService {
@Autowired
private AuditLogRepository auditLogRepository;
@Autowired
private UserService userService;
public ComplianceReport generateSOXComplianceReport(LocalDate startDate, LocalDate endDate) {
ComplianceReport report = new ComplianceReport();
report.setPeriod(startDate, endDate);
report.setReportType(ComplianceReportType.SOX);
// Control 1: User Access Reviews
report.addControlReview("User Access Review",
generateUserAccessReview(startDate, endDate));
// Control 2: Change Management
report.addControlReview("Change Management",
generateChangeManagementReport(startDate, endDate));
// Control 3: Segregation of Duties
report.addControlReview("Segregation of Duties",
generateSegregationOfDutiesReport());
// Control 4: System Security Monitoring
report.addControlReview("System Security Monitoring",
generateSecurityMonitoringReport(startDate, endDate));
// Control 5: Data Integrity Controls
report.addControlReview("Data Integrity Controls",
generateDataIntegrityReport(startDate, endDate));
return report;
}
private UserAccessReview generateUserAccessReview(LocalDate startDate, LocalDate endDate) {
UserAccessReview review = new UserAccessReview();
// Review user access levels
List<User> allUsers = userRepository.findAllActive();
for (User user : allUsers) {
UserAccessReviewItem item = new UserAccessReviewItem();
item.setUserId(user.getId());
item.setUsername(user.getEmail());
item.setRole(getPrimaryRole(user));
item.setLastLogin(user.getLastLogin());
item.setAccessRights(getUserAccessRights(user));
item.setComplianceStatus(evaluateComplianceStatus(user));
review.addItem(item);
}
review.setTotalUsers(allUsers.size());
review.setCompliantUsers((int) review.getItems().stream()
.filter(item -> item.getComplianceStatus() == ComplianceStatus.COMPLIANT)
.count());
review.setNonCompliantUsers(review.getTotalUsers() - review.getCompliantUsers());
return review;
}
private SegregationOfDutiesReport generateSegregationOfDutiesReport() {
SegregationOfDutiesReport report = new SegregationOfDutiesReport();
// Check for conflicts
List<User> allUsers = userRepository.findAllActive();
for (User user : allUsers) {
Set<String> permissions = getUserPermissions(user.getId());
// Check for approval + payment processing conflict
if (hasPermission(permissions, "LOAN_APPROVE") &&
hasPermission(permissions, "LOAN_DISBURSE")) {
SegregationConflict conflict = new SegregationConflict();
conflict.setUserId(user.getId());
conflict.setUsername(user.getEmail());
conflict.setConflictType("APPROVAL_PAYMENT");
conflict.setDescription("User has both loan approval and disbursement rights");
conflict.setSeverity(Severity.HIGH);
report.addConflict(conflict);
}
// Check for setup + transaction processing conflict
if (hasPermission(permissions, "PRODUCT_SETUP") &&
hasPermission(permissions, "TRANSACTION_PROCESSING")) {
SegregationConflict conflict = new SegregationConflict();
conflict.setUserId(user.getId());
conflict.setUsername(user.getEmail());
conflict.setConflictType("SETUP_PROCESSING");
conflict.setDescription("User has both product setup and transaction processing rights");
conflict.setSeverity(Severity.MEDIUM);
report.addConflict(conflict);
}
}
return report;
}
}
Kesimpulan
Arsitektur keamanan Apache Fineract menyediakan protection yang komprehensif dan berlapis untuk memastikan confidentiality, integrity, dan availability dari data keuangan:
Kelebihan Arsitektur Keamanan:
- Multi-Factor Authentication: Strong user authentication dengan multiple methods
- Role-Based Access Control: Granular permission system dengan hierarchical roles
- Attribute-Based Access Control: Context-aware authorization untuk complex scenarios
- Data Encryption: Comprehensive encryption untuk data at rest dan in transit
- Audit Logging: Complete audit trail untuk compliance dan forensics
- Real-time Monitoring: Continuous security monitoring dengan automated alerting
Layered Security Approach:
- Network Security: Firewall, DDoS protection, WAF
- Application Security: Authentication, authorization, input validation
- Data Security: Encryption, access controls, backup security
- Infrastructure Security: Container security, OS security, runtime protection
- Compliance: SOX, regulatory requirements, audit trails
Security Best Practices:
- Defense in Depth: Multiple security layers untuk comprehensive protection
- Least Privilege: Minimal access rights untuk users dan services
- Zero Trust: Verify every access request regardless of source
- Security by Design: Security considerations integrated from development phase
- Continuous Monitoring: Real-time monitoring dan automated response
- Regular Audits: Periodic security assessments dan penetration testing
Compliance Features:
- SOX Compliance: Comprehensive controls untuk financial reporting
- Audit Trail: Complete logging dari semua system activities
- Data Retention: Proper data lifecycle management
- Access Reviews: Regular review dari user access rights
- Change Management: Controlled deployment processes
- Incident Response: Structured incident handling procedures
Arsitektur ini memastikan bahwa Apache Fineract memenuhi estándares keamanan industri finansial sambil mempertahankan usability dan performance yang tinggi.
Dokumentasi ini menjelaskan implementasi keamanan secara detail. Specific requirements dapat disesuaikan berdasarkan regulatory landscape dan organizational security policies.