From f8ec3e71e51b9b3792d49f366c0cc88e42f0cc01 Mon Sep 17 00:00:00 2001 From: Caroline Nordbrandt Date: Mon, 27 Apr 2026 01:22:39 +0200 Subject: [PATCH 01/11] Add `ssn_hash` column to Staff and EmploymentForm; update schema migration --- .../cyberwatch/features/form/model/EmploymentForm.java | 6 +++--- .../org/example/cyberwatch/features/staff/model/Staff.java | 3 +++ src/main/resources/db/migration/V12__add_ssn_hash.sql | 4 ++++ 3 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 src/main/resources/db/migration/V12__add_ssn_hash.sql diff --git a/src/main/java/org/example/cyberwatch/features/form/model/EmploymentForm.java b/src/main/java/org/example/cyberwatch/features/form/model/EmploymentForm.java index f8def19..5ac4861 100644 --- a/src/main/java/org/example/cyberwatch/features/form/model/EmploymentForm.java +++ b/src/main/java/org/example/cyberwatch/features/form/model/EmploymentForm.java @@ -14,9 +14,6 @@ import java.time.LocalDateTime; -//Could it be an idea that the EmployeeForm needs to be approved by an Manager, like a signature on a paper form? -// Then we could have a status field in the EmployeeForm with the following states: -//DRAFT -> SUBMITTED -> APPROVED -> COMPLETED -> REJECTED @Getter @Setter @Entity @@ -31,6 +28,9 @@ public class EmploymentForm { @NotBlank(message = "Social security number cannot be blank") private String socialSecurityNumber; + @Column(name = "ssn_hash", length = 64) + private String ssnHash; + @Column(name = "first_name") @NotBlank(message = "First name cannot be blank") @Size(min = 2, max = 50, message = "First name must be between 2 and 50 characters") diff --git a/src/main/java/org/example/cyberwatch/features/staff/model/Staff.java b/src/main/java/org/example/cyberwatch/features/staff/model/Staff.java index 7a3d4e9..625c415 100644 --- a/src/main/java/org/example/cyberwatch/features/staff/model/Staff.java +++ b/src/main/java/org/example/cyberwatch/features/staff/model/Staff.java @@ -23,6 +23,9 @@ public class Staff { @NotBlank(message = "Social security number cannot be blank") private String socialSecurityNumber; + @Column(name = "ssn_hash", length = 64) + private String ssnHash; + @Column(name = "first_name") @NotBlank(message = "First name cannot be blank") @Size(min = 2, max = 50, message = "First name must be between 2 and 50 characters") diff --git a/src/main/resources/db/migration/V12__add_ssn_hash.sql b/src/main/resources/db/migration/V12__add_ssn_hash.sql new file mode 100644 index 0000000..a195063 --- /dev/null +++ b/src/main/resources/db/migration/V12__add_ssn_hash.sql @@ -0,0 +1,4 @@ +ALTER TABLE staff + ADD COLUMN ssn_hash VARCHAR(64); +ALTER TABLE employment_forms + ADD COLUMN ssn_hash VARCHAR(64); \ No newline at end of file From 1952210409e869b58faf26b08d868c7a0bab641a Mon Sep 17 00:00:00 2001 From: Caroline Nordbrandt Date: Mon, 27 Apr 2026 01:57:41 +0200 Subject: [PATCH 02/11] Enhance SSN security: Add `ssn_salt` column, update constraints and indexes; include `commons-codec` dependency for hashing --- pom.xml | 7 +++++++ .../features/form/model/EmploymentForm.java | 7 +++++-- .../cyberwatch/features/staff/model/Staff.java | 7 +++++-- .../resources/db/migration/V12__add_ssn_hash.sql | 12 +++++++++++- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/pom.xml b/pom.xml index 34ea79c..3c83e34 100644 --- a/pom.xml +++ b/pom.xml @@ -169,6 +169,13 @@ 3.20.0 compile + + + commons-codec + commons-codec + 1.20.0 + compile + diff --git a/src/main/java/org/example/cyberwatch/features/form/model/EmploymentForm.java b/src/main/java/org/example/cyberwatch/features/form/model/EmploymentForm.java index 5ac4861..cc08c42 100644 --- a/src/main/java/org/example/cyberwatch/features/form/model/EmploymentForm.java +++ b/src/main/java/org/example/cyberwatch/features/form/model/EmploymentForm.java @@ -24,13 +24,16 @@ public class EmploymentForm { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(name = "social_security_number", nullable = false, unique = true) + @Column(name = "social_security_number", nullable = false) @NotBlank(message = "Social security number cannot be blank") private String socialSecurityNumber; - @Column(name = "ssn_hash", length = 64) + @Column(name = "ssn_hash", length = 64, unique = true) private String ssnHash; + @Column(name = "ssn_salt", length = 32) + private String ssnSalt; + @Column(name = "first_name") @NotBlank(message = "First name cannot be blank") @Size(min = 2, max = 50, message = "First name must be between 2 and 50 characters") diff --git a/src/main/java/org/example/cyberwatch/features/staff/model/Staff.java b/src/main/java/org/example/cyberwatch/features/staff/model/Staff.java index 625c415..0579894 100644 --- a/src/main/java/org/example/cyberwatch/features/staff/model/Staff.java +++ b/src/main/java/org/example/cyberwatch/features/staff/model/Staff.java @@ -19,13 +19,16 @@ public class Staff { @Column(name = "employee_id") private Long id; - @Column(name = "social_security_number", nullable = false, unique = true) + @Column(name = "social_security_number", nullable = false) @NotBlank(message = "Social security number cannot be blank") private String socialSecurityNumber; - @Column(name = "ssn_hash", length = 64) + @Column(name = "ssn_hash", length = 64, unique = true) private String ssnHash; + @Column(name = "ssn_salt", length = 32) + private String ssnSalt; + @Column(name = "first_name") @NotBlank(message = "First name cannot be blank") @Size(min = 2, max = 50, message = "First name must be between 2 and 50 characters") diff --git a/src/main/resources/db/migration/V12__add_ssn_hash.sql b/src/main/resources/db/migration/V12__add_ssn_hash.sql index a195063..dfe1a05 100644 --- a/src/main/resources/db/migration/V12__add_ssn_hash.sql +++ b/src/main/resources/db/migration/V12__add_ssn_hash.sql @@ -1,4 +1,14 @@ ALTER TABLE staff ADD COLUMN ssn_hash VARCHAR(64); +ADD COLUMN ssn_salt VARCHAR(32); + ALTER TABLE employment_forms - ADD COLUMN ssn_hash VARCHAR(64); \ No newline at end of file + ADD COLUMN ssn_hash VARCHAR(64); +ADD COLUMN ssn_salt VARCHAR(32); + +CREATE UNIQUE INDEX idx_staff_ssn_hash ON staff (ssn_hash); +CREATE UNIQUE INDEX idx_form_ssn_hash ON employment_form (ssn_hash); + +--Uniqueness sköts via ssn_hash istället +ALTER TABLE staff DROP CONSTRAINT IF EXISTS staff_social_security_number_key; +ALTER TABLE employment_form DROP CONSTRAINT IF EXISTS employment_form_social_security_number_key; \ No newline at end of file From 45baaf95adda1992431d8b743b99e6b73b936c27 Mon Sep 17 00:00:00 2001 From: Caroline Nordbrandt Date: Mon, 27 Apr 2026 04:41:32 +0200 Subject: [PATCH 03/11] Refactor SSN handling: remove `ssn_salt` fields, add HMAC-based hashing with `hmacKey` for search, update encryption configuration and schema migration --- .../cyberwatch/config/EncryptionConfig.java | 12 +++++++----- .../config/security/EncryptionService.java | 16 +++++++++++++++- .../features/form/model/EmploymentForm.java | 3 --- .../cyberwatch/features/staff/model/Staff.java | 3 --- src/main/resources/application.properties | 5 +++-- .../resources/db/migration/V12__add_ssn_hash.sql | 2 -- 6 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/main/java/org/example/cyberwatch/config/EncryptionConfig.java b/src/main/java/org/example/cyberwatch/config/EncryptionConfig.java index e914265..5938112 100644 --- a/src/main/java/org/example/cyberwatch/config/EncryptionConfig.java +++ b/src/main/java/org/example/cyberwatch/config/EncryptionConfig.java @@ -9,14 +9,16 @@ @Configuration public class EncryptionConfig { - @Value("${app.encryption.key}") - private String encryptionKey; + @Value("${app.encryption.password}") + private String encryptionPass; - @Value("${app.encryption.salt}") - private String salt; + @Value("${app.encryption.pepper}") + private String pepper; + //Skapar en AES-256 krypteringsnyckel, pass + pepper = key, + //delux lägger till ett randomiserat salt-värde(Nonce/IV) vid varje anrop av encrypt @Bean public TextEncryptor textEncryptor() { - return Encryptors.text(encryptionKey, salt); + return Encryptors.delux(encryptionPass, pepper); } } diff --git a/src/main/java/org/example/cyberwatch/config/security/EncryptionService.java b/src/main/java/org/example/cyberwatch/config/security/EncryptionService.java index f614c89..e6d9558 100644 --- a/src/main/java/org/example/cyberwatch/config/security/EncryptionService.java +++ b/src/main/java/org/example/cyberwatch/config/security/EncryptionService.java @@ -1,15 +1,20 @@ package org.example.cyberwatch.config.security; import lombok.RequiredArgsConstructor; +import org.apache.commons.codec.digest.HmacAlgorithms; +import org.apache.commons.codec.digest.HmacUtils; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.crypto.encrypt.TextEncryptor; import org.springframework.stereotype.Service; - @Service @RequiredArgsConstructor public class EncryptionService { private final TextEncryptor textEncryptor; + @Value("${app.encryption.hmac-key}") // En separat fast nyckel för sökning + private String hmacKey; + public String encrypt(String data) { return textEncryptor.encrypt(data); } @@ -18,6 +23,15 @@ public String decrypt(String data) { return textEncryptor.decrypt(data); } + /** + * Skapar ett Blind Index (deterministisk hash). + * Används för att jämföra om personnummer redan finns i databasen. + */ + public String hmac(String data) { + if (data == null) return null; + return new HmacUtils(HmacAlgorithms.HMAC_SHA_256, hmacKey).hmacHex(data); + } + public String maskLastFour(String encrypted) { if (encrypted == null || encrypted.isBlank()) { return "****"; diff --git a/src/main/java/org/example/cyberwatch/features/form/model/EmploymentForm.java b/src/main/java/org/example/cyberwatch/features/form/model/EmploymentForm.java index cc08c42..56a626e 100644 --- a/src/main/java/org/example/cyberwatch/features/form/model/EmploymentForm.java +++ b/src/main/java/org/example/cyberwatch/features/form/model/EmploymentForm.java @@ -31,9 +31,6 @@ public class EmploymentForm { @Column(name = "ssn_hash", length = 64, unique = true) private String ssnHash; - @Column(name = "ssn_salt", length = 32) - private String ssnSalt; - @Column(name = "first_name") @NotBlank(message = "First name cannot be blank") @Size(min = 2, max = 50, message = "First name must be between 2 and 50 characters") diff --git a/src/main/java/org/example/cyberwatch/features/staff/model/Staff.java b/src/main/java/org/example/cyberwatch/features/staff/model/Staff.java index 0579894..ed23ed0 100644 --- a/src/main/java/org/example/cyberwatch/features/staff/model/Staff.java +++ b/src/main/java/org/example/cyberwatch/features/staff/model/Staff.java @@ -26,9 +26,6 @@ public class Staff { @Column(name = "ssn_hash", length = 64, unique = true) private String ssnHash; - @Column(name = "ssn_salt", length = 32) - private String ssnSalt; - @Column(name = "first_name") @NotBlank(message = "First name cannot be blank") @Size(min = 2, max = 50, message = "First name must be between 2 and 50 characters") diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 405af20..254d7f4 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -17,8 +17,9 @@ app.s3.allowed-origins=http://localhost:8080 spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID} spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET} spring.security.oauth2.client.registration.google.scope=email,profile -app.encryption.key=${ENCRYPTION_KEY} -app.encryption.salt=${ENCRYPTION_SALT} +app.encryption.password=${ENCRYPTION_PASSWORD} +app.encryption.pepper=${ENCRYPTION_PEPPER} +app.encryption.hmac-key=${APP_ENCRYPTION_HMAC_KEY} management.endpoints.web.exposure.include=logfile management.endpoint.logfile.enabled=true diff --git a/src/main/resources/db/migration/V12__add_ssn_hash.sql b/src/main/resources/db/migration/V12__add_ssn_hash.sql index dfe1a05..3aef3c2 100644 --- a/src/main/resources/db/migration/V12__add_ssn_hash.sql +++ b/src/main/resources/db/migration/V12__add_ssn_hash.sql @@ -1,10 +1,8 @@ ALTER TABLE staff ADD COLUMN ssn_hash VARCHAR(64); -ADD COLUMN ssn_salt VARCHAR(32); ALTER TABLE employment_forms ADD COLUMN ssn_hash VARCHAR(64); -ADD COLUMN ssn_salt VARCHAR(32); CREATE UNIQUE INDEX idx_staff_ssn_hash ON staff (ssn_hash); CREATE UNIQUE INDEX idx_form_ssn_hash ON employment_form (ssn_hash); From 3b2aafc081f3af48fe359bf7f9921119b7739e15 Mon Sep 17 00:00:00 2001 From: Caroline Nordbrandt Date: Mon, 27 Apr 2026 10:57:03 +0200 Subject: [PATCH 04/11] Refactor SSN handling: simplify masking logic, improve role-based SSN access, and update schema constraints --- .../config/security/EncryptionService.java | 6 ++-- .../features/staff/service/StaffService.java | 29 ++++++++++++------- .../db/migration/V12__add_ssn_hash.sql | 6 ++-- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/main/java/org/example/cyberwatch/config/security/EncryptionService.java b/src/main/java/org/example/cyberwatch/config/security/EncryptionService.java index e6d9558..e6965b4 100644 --- a/src/main/java/org/example/cyberwatch/config/security/EncryptionService.java +++ b/src/main/java/org/example/cyberwatch/config/security/EncryptionService.java @@ -32,11 +32,11 @@ public String hmac(String data) { return new HmacUtils(HmacAlgorithms.HMAC_SHA_256, hmacKey).hmacHex(data); } - public String maskLastFour(String encrypted) { - if (encrypted == null || encrypted.isBlank()) { + public String maskLastFour(String plainText) { + if (plainText == null || plainText.isBlank()) { return "****"; } - String decrypted = textEncryptor.decrypt(encrypted); + String decrypted = textEncryptor.decrypt(plainText); if (decrypted.length() <= 4) { return "****"; } diff --git a/src/main/java/org/example/cyberwatch/features/staff/service/StaffService.java b/src/main/java/org/example/cyberwatch/features/staff/service/StaffService.java index 5783727..0f982db 100644 --- a/src/main/java/org/example/cyberwatch/features/staff/service/StaffService.java +++ b/src/main/java/org/example/cyberwatch/features/staff/service/StaffService.java @@ -50,7 +50,7 @@ public StaffDTO getUserStaff(Staff user) { if (user == null) { throw new IllegalArgumentException("Staff cannot be null"); } - return toMaskedDto(user); + return toDtoWithSsnPolicy(user, user); } @PreAuthorize("hasAnyRole('HR', 'ADMIN')") @@ -89,6 +89,7 @@ public void deleteStaff(Long staffId) { logger.info("Staff {} deleted", staffId); } + public List getStaffByRoleOrDepartment(Role role, Department department, Staff requester) { List staffList; @@ -101,7 +102,12 @@ public List getStaffByRoleOrDepartment(Role role, Department departmen } return staffList.stream() - .map(s -> toDtoWithSsnPolicy(s, requester)) + .map(s -> { + StaffDTO dto = staffMapper.toDto(s); + // Vi skippar dekryptering helt för listor! + dto.setSocialSecurityNumber("********-****"); + return dto; + }) .toList(); } @@ -130,17 +136,20 @@ private StaffDTO toDtoWithSsnPolicy(Staff staff, Staff requester) { StaffDTO dto = staffMapper.toDto(staff); String rawEncryptedSsn = staff.getSocialSecurityNumber(); - if (requester != null && (requester.getRole() == Role.ADMIN || requester.getRole() == Role.HR)) { - dto.setSocialSecurityNumber(encryptionService.decrypt(rawEncryptedSsn)); + String plainText = encryptionService.decrypt(rawEncryptedSsn); + + boolean isPrivileged = requester != null && ( + requester.getRole() == Role.ADMIN || + requester.getRole() == Role.HR || + Objects.equals(requester.getId(), staff.getId()) + ); + + if (isPrivileged) { + dto.setSocialSecurityNumber(plainText); } else { - dto.setSocialSecurityNumber(encryptionService.maskLastFour(rawEncryptedSsn)); + dto.setSocialSecurityNumber(encryptionService.maskLastFour(plainText)); } return dto; } - private StaffDTO toMaskedDto(Staff staff) { - StaffDTO dto = staffMapper.toDto(staff); - dto.setSocialSecurityNumber(encryptionService.maskLastFour(staff.getSocialSecurityNumber())); - return dto; - } } \ No newline at end of file diff --git a/src/main/resources/db/migration/V12__add_ssn_hash.sql b/src/main/resources/db/migration/V12__add_ssn_hash.sql index 3aef3c2..da8b1b4 100644 --- a/src/main/resources/db/migration/V12__add_ssn_hash.sql +++ b/src/main/resources/db/migration/V12__add_ssn_hash.sql @@ -1,12 +1,12 @@ ALTER TABLE staff ADD COLUMN ssn_hash VARCHAR(64); -ALTER TABLE employment_forms +ALTER TABLE employment_form ADD COLUMN ssn_hash VARCHAR(64); CREATE UNIQUE INDEX idx_staff_ssn_hash ON staff (ssn_hash); CREATE UNIQUE INDEX idx_form_ssn_hash ON employment_form (ssn_hash); --Uniqueness sköts via ssn_hash istället -ALTER TABLE staff DROP CONSTRAINT IF EXISTS staff_social_security_number_key; -ALTER TABLE employment_form DROP CONSTRAINT IF EXISTS employment_form_social_security_number_key; \ No newline at end of file +ALTER TABLE staff DROP CONSTRAINT IF EXISTS uc_staff_social_security_number; +ALTER TABLE employment_form DROP CONSTRAINT IF EXISTS uc_employmentform_social_security_number; From fd849ecf6ff217651799cdcf2a152be909b7c3b5 Mon Sep 17 00:00:00 2001 From: Caroline Nordbrandt Date: Mon, 27 Apr 2026 12:28:45 +0200 Subject: [PATCH 05/11] Refactor EmploymentForm and Staff services: implement SSN hashing, update validation logic, and streamline encryption handling --- .../form/mapper/EmploymentMapper.java | 6 ++-- .../repository/EmploymentFormRepository.java | 1 + .../form/service/EmploymentFormService.java | 35 +++++++------------ .../features/staff/model/StaffMapper.java | 2 ++ .../features/staff/model/UpdateStaffDTO.java | 4 +++ .../staff/repository/StaffRepository.java | 2 ++ .../features/staff/service/StaffService.java | 18 ++++++++++ 7 files changed, 44 insertions(+), 24 deletions(-) diff --git a/src/main/java/org/example/cyberwatch/features/form/mapper/EmploymentMapper.java b/src/main/java/org/example/cyberwatch/features/form/mapper/EmploymentMapper.java index 164a54e..129735d 100644 --- a/src/main/java/org/example/cyberwatch/features/form/mapper/EmploymentMapper.java +++ b/src/main/java/org/example/cyberwatch/features/form/mapper/EmploymentMapper.java @@ -34,7 +34,7 @@ public EmploymentFormDTO toDTO(EmploymentForm entity) { public EmploymentForm toEntity(CreateEmploymentDTO dto) { if (dto == null) return null; EmploymentForm entity = new EmploymentForm(); - entity.setSocialSecurityNumber(dto.getSocialSecurityNumber()); + // Note: socialSecurityNumber and ssnHash are handled separately in service layer for encryption/hashing entity.setFirstName(dto.getFirstName()); entity.setLastName(dto.getLastName()); entity.setEmail(dto.getEmail()); @@ -49,7 +49,7 @@ public EmploymentForm toEntity(CreateEmploymentDTO dto) { public void updateEntity(UpdateEmploymentDTO dto, EmploymentForm entity) { if (dto == null || entity == null) return; - entity.setSocialSecurityNumber(dto.getSocialSecurityNumber()); + // Note: socialSecurityNumber and ssnHash are handled separately in service layer for encryption/hashing entity.setFirstName(dto.getFirstName()); entity.setLastName(dto.getLastName()); entity.setEmail(dto.getEmail()); @@ -61,7 +61,9 @@ public void updateEntity(UpdateEmploymentDTO dto, EmploymentForm entity) { public Staff formToStaff(EmploymentForm form) { if (form == null) return null; Staff staff = new Staff(); + // Copy already-encrypted SSN and its hash from approved form staff.setSocialSecurityNumber(form.getSocialSecurityNumber()); + staff.setSsnHash(form.getSsnHash()); staff.setFirstName(form.getFirstName()); staff.setLastName(form.getLastName()); staff.setEmail(form.getEmail()); diff --git a/src/main/java/org/example/cyberwatch/features/form/repository/EmploymentFormRepository.java b/src/main/java/org/example/cyberwatch/features/form/repository/EmploymentFormRepository.java index dcf002b..d715f23 100644 --- a/src/main/java/org/example/cyberwatch/features/form/repository/EmploymentFormRepository.java +++ b/src/main/java/org/example/cyberwatch/features/form/repository/EmploymentFormRepository.java @@ -11,5 +11,6 @@ public interface EmploymentFormRepository extends JpaRepository { List findByStatus(ApprovalStatus status); + boolean existsBySsnHash(String ssnHash); boolean existsBySocialSecurityNumber(String socialSecurityNumber); } diff --git a/src/main/java/org/example/cyberwatch/features/form/service/EmploymentFormService.java b/src/main/java/org/example/cyberwatch/features/form/service/EmploymentFormService.java index 483be5b..4637375 100644 --- a/src/main/java/org/example/cyberwatch/features/form/service/EmploymentFormService.java +++ b/src/main/java/org/example/cyberwatch/features/form/service/EmploymentFormService.java @@ -55,14 +55,12 @@ public EmploymentFormDTO createForm(CreateEmploymentDTO form, Staff hrStaff) { validateSsnNotExists(form.getSocialSecurityNumber()); - //NOTE: Set HR based on logged in HR-staff EmploymentForm formEntity = employmentMapper.toEntity(form); - // Set default status to PENDING formEntity.setStatus(ApprovalStatus.PENDING); formEntity.setCreatedBy(hrStaff); - formEntity.setSocialSecurityNumber( - encryptionService.encrypt(form.getSocialSecurityNumber()) - ); + String encryptedSsn = encryptionService.encrypt(form.getSocialSecurityNumber()); + formEntity.setSocialSecurityNumber(encryptedSsn); + formEntity.setSsnHash(encryptionService.hmac(form.getSocialSecurityNumber())); EmploymentForm savedForm = employmentFormRepository.save(formEntity); logger.info("New employment form created with ID: {} by HR staffId={}", savedForm.getId(), hrStaff.getId()); @@ -124,6 +122,7 @@ public EmploymentFormDTO updateFormBeforeApproval(Long formId, UpdateEmploymentD if (!existingSsnPlain.equals(newSsnPlain)) { validateSsnNotExists(newSsnPlain); existingForm.setSocialSecurityNumber(encryptionService.encrypt(newSsnPlain)); + existingForm.setSsnHash(encryptionService.hmac(newSsnPlain)); } employmentMapper.updateEntity(updatedForm, existingForm); @@ -235,31 +234,23 @@ private String generateSecurePassword() { } private void validateSsnNotExists(String ssn) { + String ssnHash = encryptionService.hmac(ssn); - // TODO: Replace with SSN hash lookup when database schema is updated - // Current workaround: encrypt before searching (requires double encryption in createForm which is costly) - // Check employment forms by decrypting and comparing - List existingForms = employmentFormRepository.findAll(); - for (EmploymentForm form : existingForms) { - String decryptedSsn = encryptionService.decrypt(form.getSocialSecurityNumber()); - if (decryptedSsn.equals(ssn)) { - throw new IllegalStateException("An application with this SSN already exists."); - } + // Kontrollera EmploymentForm + if (employmentFormRepository.existsBySsnHash(ssnHash)) { + throw new IllegalStateException("An application with this SSN already exists."); } - // Check staff by decrypting and comparing - List existingStaff = staffRepository.findAll(); - for (Staff staff : existingStaff) { - String decryptedSsn = encryptionService.decrypt(staff.getSocialSecurityNumber()); - if (decryptedSsn.equals(ssn)) { - throw new IllegalStateException("An employee with this SSN already exists."); - } + // Kontrollera Staff + if (staffRepository.existsBySsnHash(ssnHash)) { + throw new IllegalStateException("An employee with this SSN already exists."); } } private EmploymentFormDTO toSafeDto(EmploymentForm form) { EmploymentFormDTO dto = employmentMapper.toDTO(form); - dto.setSocialSecurityNumber(encryptionService.maskLastFour(form.getSocialSecurityNumber())); + String decrypted = encryptionService.decrypt(form.getSocialSecurityNumber()); + dto.setSocialSecurityNumber(encryptionService.maskLastFour(decrypted)); return dto; } diff --git a/src/main/java/org/example/cyberwatch/features/staff/model/StaffMapper.java b/src/main/java/org/example/cyberwatch/features/staff/model/StaffMapper.java index e53ac62..fe83be0 100644 --- a/src/main/java/org/example/cyberwatch/features/staff/model/StaffMapper.java +++ b/src/main/java/org/example/cyberwatch/features/staff/model/StaffMapper.java @@ -24,6 +24,8 @@ public StaffDTO toDto(Staff entity) { public void updateEntity(UpdateStaffDTO dto, Staff entity) { if (dto == null || entity == null) return; + + // Note: socialSecurityNumber is handled separately in StaffService for encryption/hashing entity.setFirstName(dto.getFirstName()); entity.setLastName(dto.getLastName()); entity.setEmail(dto.getEmail()); diff --git a/src/main/java/org/example/cyberwatch/features/staff/model/UpdateStaffDTO.java b/src/main/java/org/example/cyberwatch/features/staff/model/UpdateStaffDTO.java index d23506f..53182a1 100644 --- a/src/main/java/org/example/cyberwatch/features/staff/model/UpdateStaffDTO.java +++ b/src/main/java/org/example/cyberwatch/features/staff/model/UpdateStaffDTO.java @@ -22,6 +22,10 @@ public class UpdateStaffDTO { @Size(min = 2, max = 50, message = "Last name must be between 2 and 50 characters") private String lastName; + @NotBlank(message = "Social security number cannot be blank") + @Pattern(regexp = "^\\d{8}-\\d{4}$", message = "Social security number must match format YYYYMMDD-NNNN") + private String socialSecurityNumber; + @Email(message = "Email should be valid") @NotBlank(message = "Email cannot be blank") private String email; diff --git a/src/main/java/org/example/cyberwatch/features/staff/repository/StaffRepository.java b/src/main/java/org/example/cyberwatch/features/staff/repository/StaffRepository.java index 27054d5..9448b21 100644 --- a/src/main/java/org/example/cyberwatch/features/staff/repository/StaffRepository.java +++ b/src/main/java/org/example/cyberwatch/features/staff/repository/StaffRepository.java @@ -13,6 +13,8 @@ public interface StaffRepository extends JpaRepository { boolean existsBySocialSecurityNumber(String socialSecurityNumber); + boolean existsBySsnHash(String ssnHash); + Optional findByEmail(String email); List findByRole(Role role); diff --git a/src/main/java/org/example/cyberwatch/features/staff/service/StaffService.java b/src/main/java/org/example/cyberwatch/features/staff/service/StaffService.java index 0f982db..3acd799 100644 --- a/src/main/java/org/example/cyberwatch/features/staff/service/StaffService.java +++ b/src/main/java/org/example/cyberwatch/features/staff/service/StaffService.java @@ -71,6 +71,16 @@ public StaffDTO updateStaff(Long staffId, UpdateStaffDTO dto, Staff requester) { }); } + // Hantera SSN separat för kryptering och hashning + String existingSsnPlain = encryptionService.decrypt(existingStaff.getSocialSecurityNumber()); + String newSsnPlain = dto.getSocialSecurityNumber(); + + if (!existingSsnPlain.equals(newSsnPlain)) { + validateSsnNotExists(newSsnPlain); + existingStaff.setSocialSecurityNumber(encryptionService.encrypt(newSsnPlain)); + existingStaff.setSsnHash(encryptionService.hmac(newSsnPlain)); + } + staffMapper.updateEntity(dto, existingStaff); Staff savedStaff = staffRepository.save(existingStaff); @@ -130,6 +140,14 @@ public StaffDTO updateStatus(Long staffId, String status, Staff requester) { return toDtoWithSsnPolicy(savedStaff, requester); // Returnerar maskat SSN efter statusuppdatering } + private void validateSsnNotExists(String ssn) { + String ssnHash = encryptionService.hmac(ssn); + + // Kontrollera om SSN redan finns i Staff + if (staffRepository.existsBySsnHash(ssnHash)) { + throw new IllegalStateException("An employee with this SSN already exists."); + } + } // ADMIN/HR får se hela, andra får se maskat. private StaffDTO toDtoWithSsnPolicy(Staff staff, Staff requester) { From 3c7ef074fb123fc3e5441daa7b05b063c2955261 Mon Sep 17 00:00:00 2001 From: Caroline Nordbrandt Date: Mon, 27 Apr 2026 14:51:56 +0200 Subject: [PATCH 06/11] Refactor encryption handling: update encryption properties, implement HMAC-based SSN hashing, and enhance SSN access control in services --- .../config/security/EncryptionService.java | 5 +- .../features/staff/service/StaffService.java | 7 +-- .../service/EmploymentFormServiceTest.java | 36 +++++++------- .../staff/service/StaffServiceTest.java | 48 +++++++++++++------ .../resources/application-test.properties | 5 +- 5 files changed, 57 insertions(+), 44 deletions(-) diff --git a/src/main/java/org/example/cyberwatch/config/security/EncryptionService.java b/src/main/java/org/example/cyberwatch/config/security/EncryptionService.java index e6965b4..cad9ab5 100644 --- a/src/main/java/org/example/cyberwatch/config/security/EncryptionService.java +++ b/src/main/java/org/example/cyberwatch/config/security/EncryptionService.java @@ -36,10 +36,9 @@ public String maskLastFour(String plainText) { if (plainText == null || plainText.isBlank()) { return "****"; } - String decrypted = textEncryptor.decrypt(plainText); - if (decrypted.length() <= 4) { + if (plainText.length() <= 4) { return "****"; } - return decrypted.substring(0, decrypted.length() - 4) + "****"; + return plainText.substring(0, plainText.length() - 4) + "****"; } } diff --git a/src/main/java/org/example/cyberwatch/features/staff/service/StaffService.java b/src/main/java/org/example/cyberwatch/features/staff/service/StaffService.java index 3acd799..92e7fa1 100644 --- a/src/main/java/org/example/cyberwatch/features/staff/service/StaffService.java +++ b/src/main/java/org/example/cyberwatch/features/staff/service/StaffService.java @@ -112,12 +112,7 @@ public List getStaffByRoleOrDepartment(Role role, Department departmen } return staffList.stream() - .map(s -> { - StaffDTO dto = staffMapper.toDto(s); - // Vi skippar dekryptering helt för listor! - dto.setSocialSecurityNumber("********-****"); - return dto; - }) + .map(s -> toDtoWithSsnPolicy(s, requester)) .toList(); } diff --git a/src/test/java/org/example/cyberwatch/features/form/service/EmploymentFormServiceTest.java b/src/test/java/org/example/cyberwatch/features/form/service/EmploymentFormServiceTest.java index b8601b0..60bccce 100644 --- a/src/test/java/org/example/cyberwatch/features/form/service/EmploymentFormServiceTest.java +++ b/src/test/java/org/example/cyberwatch/features/form/service/EmploymentFormServiceTest.java @@ -24,7 +24,6 @@ import org.springframework.security.crypto.password.PasswordEncoder; import tools.jackson.databind.ObjectMapper; -import java.util.List; import java.util.Optional; import static org.junit.jupiter.api.Assertions.*; @@ -66,6 +65,7 @@ void createForm_Success() { // Arrange String rawSsn = "19900101-1234"; String encryptedSsn = "krypterat-ssn"; + String ssnHash = "abc-123-hash"; CreateEmploymentDTO dto = new CreateEmploymentDTO( rawSsn, "Alice", "Andersson", @@ -73,9 +73,12 @@ void createForm_Success() { ); Staff hrStaff = createHrStaff(); EmploymentForm entity = new EmploymentForm(); + entity.setId(1L); + + when(encryptionService.hmac(rawSsn)).thenReturn(ssnHash); + when(formRepository.existsBySsnHash(ssnHash)).thenReturn(false); + when(staffRepository.existsBySsnHash(ssnHash)).thenReturn(false); - when(formRepository.findAll()).thenReturn(List.of()); - when(staffRepository.findAll()).thenReturn(List.of()); when(encryptionService.encrypt(rawSsn)).thenReturn(encryptedSsn); when(mapper.toEntity(dto)).thenReturn(entity); when(formRepository.save(any(EmploymentForm.class))).thenReturn(entity); @@ -176,22 +179,22 @@ void approveAndFinalize_Success() throws Exception { @DisplayName("Should throw exception if SSN already exists in form repository") void createForm_DuplicateSsnInForms_ThrowsException() { // Arrange - Staff creator = new Staff(); - creator.setEmail("owner@cyberwatch.local"); + String rawSsn = "19900101-1234"; + String ssnHash = "existing-staff-hash"; CreateEmploymentDTO dto = new CreateEmploymentDTO( - "19900101-1234", "Alice", "Andersson", + rawSsn, "Alice", "Andersson", "alice@test.com", "070", Role.HR, Department.BACKEND, null, null, null ); - EmploymentForm existing = new EmploymentForm(); - existing.setSocialSecurityNumber("krypterat-ssn"); + Staff creator = createHrStaff(); - when(formRepository.findAll()).thenReturn(List.of(existing)); - when(encryptionService.decrypt("krypterat-ssn")).thenReturn("19900101-1234"); + when(encryptionService.hmac(rawSsn)).thenReturn(ssnHash); + when(formRepository.existsBySsnHash(ssnHash)).thenReturn(true); // Finns redan i forms // Act & Assert assertThrows(IllegalStateException.class, () -> service.createForm(dto, creator) ); + verify(formRepository, never()).save(any()); } @@ -199,20 +202,17 @@ void createForm_DuplicateSsnInForms_ThrowsException() { @DisplayName("Should throw exception if SSN already exists in staff repository") void createForm_DuplicateSsnInStaff_ThrowsException() { // Arrange - Staff creator = new Staff(); String rawSsn = "19900101-1234"; - + String ssnHash = "existing-staff-hash"; CreateEmploymentDTO dto = new CreateEmploymentDTO( rawSsn, "Alice", "Andersson", "alice@test.com", "070", Role.HR, Department.BACKEND, null, null, null ); + Staff creator = createHrStaff(); - Staff existingStaff = new Staff(); - existingStaff.setSocialSecurityNumber("krypterat-ssn"); - - when(formRepository.findAll()).thenReturn(List.of());//no match - when(staffRepository.findAll()).thenReturn(List.of(existingStaff)); - when(encryptionService.decrypt("krypterat-ssn")).thenReturn("19900101-1234"); + when(encryptionService.hmac(rawSsn)).thenReturn(ssnHash); + when(formRepository.existsBySsnHash(ssnHash)).thenReturn(false); // Finns inte i forms + when(staffRepository.existsBySsnHash(ssnHash)).thenReturn(true); // Finns i staff // Act & Assert assertThrows(IllegalStateException.class, () -> diff --git a/src/test/java/org/example/cyberwatch/features/staff/service/StaffServiceTest.java b/src/test/java/org/example/cyberwatch/features/staff/service/StaffServiceTest.java index d3a3cc7..d8a339e 100644 --- a/src/test/java/org/example/cyberwatch/features/staff/service/StaffServiceTest.java +++ b/src/test/java/org/example/cyberwatch/features/staff/service/StaffServiceTest.java @@ -86,16 +86,18 @@ void updateStatus_ShouldUpdateAndReturnStaffDTO() { @Test @DisplayName("Should return masked SSN for non-admin") void getStaffById_NonAdminOrHr_ReturnsMaskedSsn() { - newStaff.setRole(Role.CONSULTANT); + Staff requester = new Staff(); + requester.setId(99L); + requester.setRole(Role.CONSULTANT); + when(staffRepository.findById(1L)).thenReturn(Optional.of(newStaff)); when(staffMapper.toDto(newStaff)).thenReturn(newStaffDTO); - when(encryptionService.maskLastFour("krypterat-ssn")).thenReturn("19900101-****"); + when(encryptionService.decrypt("krypterat-ssn")).thenReturn("19900101-1234"); + when(encryptionService.maskLastFour("19900101-1234")).thenReturn("19900101-****"); - StaffDTO result = staffService.getStaffById(1L, newStaff); + StaffDTO result = staffService.getStaffById(1L, requester); assertThat(result.getSocialSecurityNumber()).isEqualTo("19900101-****"); - verify(encryptionService).maskLastFour("krypterat-ssn"); - verify(encryptionService, never()).decrypt(any()); } @Test @@ -133,10 +135,13 @@ void getStaffById_NullId() { @DisplayName("Should update and return DTO when valid input") void updateStaff_Valid() { newStaff.setRole(Role.ADMIN); - UpdateStaffDTO dto = new UpdateStaffDTO("Anna", "Nilsson", + String rawSsn = "19900101-1234"; + UpdateStaffDTO dto = new UpdateStaffDTO("Anna", "Nilsson", rawSsn, "anna@cyberwatch.se", "0701234567", Role.HR, Department.BACKEND); when(staffRepository.findById(1L)).thenReturn(Optional.of(newStaff)); + when(encryptionService.decrypt("krypterat-ssn")).thenReturn(rawSsn); + when(staffRepository.save(newStaff)).thenReturn(newStaff); when(staffMapper.toDto(newStaff)).thenReturn(newStaffDTO); @@ -179,26 +184,34 @@ void deleteStaff_NotFound() { @Test @DisplayName("Should filter by role and mask SSN for non-admin or hr") void getStaffByRoleOrDepartment_FilterByRole_MaskedSsn() { - newStaff.setRole(Role.CEO); + Staff requester = new Staff(); + requester.setRole(Role.CONSULTANT); + when(staffRepository.findByRole(Role.HR)).thenReturn(List.of(newStaff)); when(staffMapper.toDto(newStaff)).thenReturn(newStaffDTO); - when(encryptionService.maskLastFour("krypterat-ssn")).thenReturn("19900101-****"); - List result = staffService.getStaffByRoleOrDepartment(Role.HR, null, newStaff); + // FIX: Nu behövs dessa även i list-vyn + when(encryptionService.decrypt("krypterat-ssn")).thenReturn("19900101-1234"); + when(encryptionService.maskLastFour("19900101-1234")).thenReturn("19900101-****"); + + List result = staffService.getStaffByRoleOrDepartment(Role.HR, null, requester); assertThat(result).hasSize(1); - verify(staffRepository, times(1)).findByRole(Role.HR); - verify(staffRepository, never()).findByDepartment(any()); + assertThat(result.getFirst().getSocialSecurityNumber()).isEqualTo("19900101-****"); } @Test @DisplayName("Should filter by role and decrypt SSN for admin") void getStaffByRoleOrDepartment_FilterByRole_DecryptedSsn() { + Staff requester = new Staff(); + requester.setRole(Role.ADMIN); + when(staffRepository.findByRole(Role.HR)).thenReturn(List.of(newStaff)); when(staffMapper.toDto(newStaff)).thenReturn(newStaffDTO); + when(encryptionService.decrypt("krypterat-ssn")).thenReturn("19900101-1234"); - List result = staffService.getStaffByRoleOrDepartment(Role.HR, null, newStaff); + List result = staffService.getStaffByRoleOrDepartment(Role.HR, null, requester); assertThat(result).hasSize(1); assertThat(result.getFirst().getSocialSecurityNumber()).isEqualTo("19900101-1234"); @@ -237,13 +250,18 @@ void getStaffByRoleOrDepartment_NoFilter() { @Test @DisplayName("Should return all staff when no filter with masked last four") void getStaffByRoleOrDepartment_NoFilter_MaskedFour() { - newStaff.setRole(Role.PROJECT_MANAGER); + Staff requester = new Staff(); + requester.setRole(Role.PROJECT_MANAGER); when(staffRepository.findAll()).thenReturn(List.of(newStaff)); when(staffMapper.toDto(newStaff)).thenReturn(newStaffDTO); - when(encryptionService.maskLastFour("krypterat-ssn")).thenReturn("19900101-****"); - List result = staffService.getStaffByRoleOrDepartment(null, null, newStaff); + + when(encryptionService.decrypt("krypterat-ssn")).thenReturn("19900101-1234"); + when(encryptionService.maskLastFour("19900101-1234")).thenReturn("19900101-****"); + + List result = staffService.getStaffByRoleOrDepartment(null, null, requester); assertThat(result).hasSize(1); + assertThat(result.getFirst().getSocialSecurityNumber()).isEqualTo("19900101-****"); verify(staffRepository).findAll(); } } diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index 47e7380..ee349f6 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -1,5 +1,6 @@ -ENCRYPTION_KEY=testencryptionkey1234567890123456 -ENCRYPTION_SALT=0123456789abcdef +ENCRYPTION_PASSWORD=testencryptionkey1234567890123456 +ENCRYPTION_PEPPER=0123456789abcdef +app.encryption.hmac-key=0123456789abcdef0123456789abcdef S3_ENDPOINT=http://localhost:9002 S3_BUCKET=test-bucket S3_ACCESS_KEY=minioadmin From 3c5a541e6de33a85c4e758381a7e8ea3f3046b3d Mon Sep 17 00:00:00 2001 From: Caroline Nordbrandt Date: Mon, 27 Apr 2026 16:02:14 +0200 Subject: [PATCH 07/11] Enhance SSN display and access control: implement dynamic SSN view toggling, update EmploymentForm and Staff services to apply SSN policy based on user roles, and improve styling for SSN elements --- .../controller/EmploymentFormController.java | 9 ++- .../form/service/EmploymentFormService.java | 38 +++++---- src/main/resources/static/css/styles.css | 57 ++++++++++++++ src/main/resources/static/js/employment.js | 77 +++++++++++++++++-- 4 files changed, 154 insertions(+), 27 deletions(-) diff --git a/src/main/java/org/example/cyberwatch/features/form/controller/EmploymentFormController.java b/src/main/java/org/example/cyberwatch/features/form/controller/EmploymentFormController.java index 3a907c4..efb4757 100644 --- a/src/main/java/org/example/cyberwatch/features/form/controller/EmploymentFormController.java +++ b/src/main/java/org/example/cyberwatch/features/form/controller/EmploymentFormController.java @@ -56,14 +56,15 @@ public ResponseEntity updateForm( //Show list of pending forms @GetMapping - public ResponseEntity> getForms(@RequestParam(required = false) ApprovalStatus status) { - return ResponseEntity.ok(employmentFormService.getFormsByFilterApproval(status)); + public ResponseEntity> getForms(@RequestParam(required = false) ApprovalStatus status, + @AuthenticationPrincipal Staff staff) { + return ResponseEntity.ok(employmentFormService.getFormsByFilterApproval(status, staff)); } //Get a form by id @GetMapping("{id}") - public ResponseEntity getFormById(@PathVariable Long id) { - return ResponseEntity.ok(employmentFormService.getFormById(id)); + public ResponseEntity getFormById(@PathVariable Long id, @AuthenticationPrincipal Staff staff) { + return ResponseEntity.ok(employmentFormService.getFormById(id, staff)); } // Reject a form with a reason diff --git a/src/main/java/org/example/cyberwatch/features/form/service/EmploymentFormService.java b/src/main/java/org/example/cyberwatch/features/form/service/EmploymentFormService.java index 4637375..0165200 100644 --- a/src/main/java/org/example/cyberwatch/features/form/service/EmploymentFormService.java +++ b/src/main/java/org/example/cyberwatch/features/form/service/EmploymentFormService.java @@ -65,30 +65,29 @@ public EmploymentFormDTO createForm(CreateEmploymentDTO form, Staff hrStaff) { EmploymentForm savedForm = employmentFormRepository.save(formEntity); logger.info("New employment form created with ID: {} by HR staffId={}", savedForm.getId(), hrStaff.getId()); - return toSafeDto(savedForm); + return applySsnPolicy(savedForm, hrStaff); } @PreAuthorize("hasAnyRole('HR', 'CEO', 'CTO', 'ADMIN')") - public List getFormsByFilterApproval(ApprovalStatus status) { - // Om status är angiven (t.ex. PENDING eller APPROVED), filtrera på den + public List getFormsByFilterApproval(ApprovalStatus status, Staff requester) { + List forms; if (status != null) { - return employmentFormRepository.findByStatus(status) - .stream() - .map(employmentMapper::toDTO) - .toList(); + forms = employmentFormRepository.findByStatus(status); + } else { + forms = employmentFormRepository.findAll(); } - return getAllForms(); + // Använd din nya policy-metod på varje element i listan! + return forms.stream() + .map(form -> applySsnPolicy(form, requester)) + .toList(); } - private List getAllForms() { - return employmentMapper.toDTOList(employmentFormRepository.findAll()); - } // Get a single form by ID @PreAuthorize("hasAnyRole('HR', 'CEO', 'CTO', 'ADMIN')") - public EmploymentFormDTO getFormById(Long formId) { - return toSafeDto(findFormById(formId)); + public EmploymentFormDTO getFormById(Long formId, Staff requester) { + return applySsnPolicy(findFormById(formId), requester); } // Update form before approval (only PENDING forms can be updated) @@ -128,7 +127,7 @@ public EmploymentFormDTO updateFormBeforeApproval(Long formId, UpdateEmploymentD employmentMapper.updateEntity(updatedForm, existingForm); logger.info("Form {} updated by staffId={}", formId, loggedInHr.getId()); - return toSafeDto(employmentFormRepository.save(existingForm)); + return applySsnPolicy(employmentFormRepository.save(existingForm), loggedInHr); } // Reject a form (only PENDING forms can be rejected, and only by management) @@ -247,10 +246,17 @@ private void validateSsnNotExists(String ssn) { } } - private EmploymentFormDTO toSafeDto(EmploymentForm form) { + private EmploymentFormDTO applySsnPolicy(EmploymentForm form, Staff requester) { EmploymentFormDTO dto = employmentMapper.toDTO(form); String decrypted = encryptionService.decrypt(form.getSocialSecurityNumber()); - dto.setSocialSecurityNumber(encryptionService.maskLastFour(decrypted)); + + // Om requester är ADMIN/HR/CEO/CTO -> visa allt, annars maskera + if (requester.getRole() == Role.ADMIN || requester.getRole() == Role.HR || + requester.getRole() == Role.CEO || requester.getRole() == Role.CTO) { + dto.setSocialSecurityNumber(decrypted); + } else { + dto.setSocialSecurityNumber(encryptionService.maskLastFour(decrypted)); + } return dto; } diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css index 295cdad..d28d446 100644 --- a/src/main/resources/static/css/styles.css +++ b/src/main/resources/static/css/styles.css @@ -215,6 +215,63 @@ input, select, textarea { border: 1px solid var(--border); } +/* SSN style */ +.ssn-wrapper { + display: inline-flex; + align-items: center; + gap: 12px; + padding: 4px 10px; + background: #f1f5f9; + border-radius: 8px; +} + +.ssn-display-row { + display: flex; + align-items: center; /* Centrerar "SSN:" vertikalt med boxen */ + gap: 8px; /* Avståndet mellan texten och den grå boxen */ + margin-top: 0.5rem; +} + +.ssn-label { + font-weight: 700; /* Samma fetstil som "Status:" */ + color: #1e293b; + min-width: 45px; /* Valfritt: ger alla etiketter samma startpunkt om du vill */ +} + +.ssn-placeholder { + color: #94a3b8; + letter-spacing: 2px; + font-weight: bold; +} + +.ssn-value { + font-family: 'Courier New', monospace; + color: #1e293b; + font-size: 0.95rem; +} + +.btn-ssn-action { + background: white; + border: 1px solid #cbd5e0; + border-radius: 6px; + padding: 4px 12px; + font-size: 12px; + font-weight: 600; + color: #475569; + cursor: pointer; + text-transform: uppercase; +} + +.btn-ssn-action:hover { + background: #f8fafc; + border-color: #94a3b8; +} + +.btn-ssn-action.active { + background: #f1f5f9; + border-color: #94a3b8; +} + /* Login */ .login-body { background: radial-gradient(circle at center, #065f46, #042f24); diff --git a/src/main/resources/static/js/employment.js b/src/main/resources/static/js/employment.js index d816875..d908640 100644 --- a/src/main/resources/static/js/employment.js +++ b/src/main/resources/static/js/employment.js @@ -20,6 +20,20 @@ async function initEmploymentPage() { } } +function getInitialSsnHtml(id, type) { + return ` +
+ SSN: +
+ ••••••-•••• + +
+
+ `; +} + function setupEmploymentPageButtons() { const openBtn = document.getElementById("openCreateEmploymentBtn"); const cancelBtn = document.getElementById("cancelCreateEmploymentBtn"); @@ -120,17 +134,64 @@ function renderEmploymentStaffList() {
-

ID: ${staff.id}

Email: ${escapeHtml(staff.email || "-")}

-

Telefon: ${escapeHtml(staff.phoneNumber || "-")}

+

Phone: ${escapeHtml(staff.phoneNumber || "-")}

Department: ${escapeHtml(staff.department || "-")}

-

Personnummer: ${escapeHtml(staff.socialSecurityNumber || "-")}

Status: ${escapeHtml(staff.status || "OFFLINE")}

+ +
+ ${getInitialSsnHtml(staff.id, 'staff')} +
`).join(""); } +async function toggleSsnView(id, type) { + // Bestäm prefix och endpoint baserat på typen + const prefix = (type === 'staff') ? 'ssn' : 'form-ssn'; + const endpoint = (type === 'staff') ? 'staff' : 'forms'; + + const container = document.getElementById(`${prefix}-container-${id}`); + if (!container) return; + + container.innerHTML = "Laddar..."; + + try { + const res = await apiFetch(`/${endpoint}/${id}`); + if (!res.ok) throw new Error("Behörighet saknas"); + + const data = await res.json(); + + container.innerHTML = ` +
+ SSN: +
+ ${escapeHtml(data.socialSecurityNumber)} + +
+
+`; + + // Maskera automatiskt efter 10 sekunder + setTimeout(() => resetSsnView(id, type), 10000); + + } catch (e) { + container.innerHTML = "Kunde inte hämta data."; + console.error(e); + } +} + +function resetSsnView(id, type) { + const prefix = (type === 'staff') ? 'ssn' : 'form-ssn'; + const container = document.getElementById(`${prefix}-container-${id}`); + if (container) { + container.innerHTML = getInitialSsnHtml(id, type); + } +} + async function submitEmploymentForm(e) { e.preventDefault(); @@ -200,11 +261,13 @@ async function loadPendingEmploymentForms() {

Email: ${escapeHtml(form.email)}

-

Telefon: ${escapeHtml(form.phoneNumber)}

-

Roll: ${escapeHtml(form.role)}

+

Phone: ${escapeHtml(form.phoneNumber)}

+

Role: ${escapeHtml(form.role)}

Department: ${escapeHtml(form.department)}

-

Personnummer: ${escapeHtml(form.socialSecurityNumber)}

-
+
+ ${getInitialSsnHtml(form.id, 'form')} +
+
${canApprove ? ` From a48d23937c9b414ec3183d436af76f45f78b4d15 Mon Sep 17 00:00:00 2001 From: Caroline Nordbrandt Date: Mon, 27 Apr 2026 16:29:18 +0200 Subject: [PATCH 08/11] Seed database with initial admin users, additional staff for testing, and pending employment forms; update DataInitializer with enhanced staff and employment form creation logic. --- .../cyberwatch/config/DataInitializer.java | 64 +++++++++++++++++-- 1 file changed, 57 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/example/cyberwatch/config/DataInitializer.java b/src/main/java/org/example/cyberwatch/config/DataInitializer.java index faef873..81732a7 100644 --- a/src/main/java/org/example/cyberwatch/config/DataInitializer.java +++ b/src/main/java/org/example/cyberwatch/config/DataInitializer.java @@ -1,25 +1,32 @@ package org.example.cyberwatch.config; import org.example.cyberwatch.config.security.EncryptionService; +import org.example.cyberwatch.features.form.model.EmploymentForm; +import org.example.cyberwatch.features.form.repository.EmploymentFormRepository; import org.example.cyberwatch.features.staff.model.Staff; import org.example.cyberwatch.features.staff.repository.StaffRepository; +import org.example.cyberwatch.shared.model.enums.ApprovalStatus; import org.example.cyberwatch.shared.model.enums.Department; import org.example.cyberwatch.shared.model.enums.Role; import org.springframework.boot.CommandLineRunner; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; +import java.time.LocalDateTime; import java.util.List; @Component public class DataInitializer implements CommandLineRunner { private final StaffRepository staffRepository; + private final EmploymentFormRepository employmentFormRepository; private final EncryptionService encryptionService; private final PasswordEncoder passwordEncoder; - public DataInitializer(StaffRepository staffRepository, EncryptionService encryptionService, PasswordEncoder passwordEncoder) { + public DataInitializer(StaffRepository staffRepository, EmploymentFormRepository employmentFormRepository, + EncryptionService encryptionService, PasswordEncoder passwordEncoder) { this.staffRepository = staffRepository; + this.employmentFormRepository = employmentFormRepository; this.encryptionService = encryptionService; this.passwordEncoder = passwordEncoder; } @@ -30,26 +37,69 @@ public void run(String... args) { return; } + // Skapa admin-användare + List admins = staffRepository.saveAll(List.of( + createStaff("19900101-0101", "Eric", "Thilen", "ericthilen2003@gmail.com", "0701111111", Role.ADMIN, Department.BACKEND, "testPass123", "ONLINE"), + createStaff("19900505-0505", "Caroline", "Nordbrandt", "nordbrandtcaroline@gmail.com", "0702222222", Role.ADMIN, Department.DEVOPS, "testPass1234", "ONLINE"), + createStaff("19900707-0707", "Alice", "Wersen", "alicewersen@hotmail.com", "0703333333", Role.ADMIN, Department.HR, "testPass12345", "OFFLINE"), + createStaff("19900909-0909", "Younes", "Lamia", "younescool94@gmail.com", "0704444444", Role.ADMIN, Department.FRONTEND, "testPass1234", "OFFLINE") + )); + + // Fler dummies för att testa filter och scroll staffRepository.saveAll(List.of( - createStaff("19900101-0101", "Eric", "Thilen", "ericthilen2003@gmail.com", "0701111111", Role.ADMIN, Department.BACKEND, "testPass123"), - createStaff("19900505-0505", "Caroline", "Nordbrandt", "nordbrandtcaroline@gmail.com", "0702222222", Role.ADMIN, Department.DEVOPS, "testPass1234"), - createStaff("19900707-0707", "Alice", "Wersen", "alicewersen@hotmail.com", "0703333333", Role.ADMIN, Department.HR, "testPass12345"), - createStaff("19900909-0909", "Younes", "Lamia", "younescool94@gmail.com", "0704444444", Role.ADMIN, Department.FRONTEND, "testPass1234") + createStaff("19850212-1212", "Johan", "Andersson", "johan.a@cyberwatch.se", "0705555555", Role.CTO, Department.BACKEND, "pass123", "BUSY"), + createStaff("19920314-3412", "Sofia", "Lindgren", "sofia.l@cyberwatch.se", "0706666666", Role.HR, Department.HR, "pass123", "OFFLINE"), + createStaff("19881120-5678", "Marcus", "Ek", "marcus.ek@cyberwatch.se", "0707777777", Role.CEO, Department.MANAGEMENT, "pass123", "AWAY"), + createStaff("19950606-9999", "Linnea", "Berg", "linnea.b@cyberwatch.se", "0708888888", Role.CONSULTANT, Department.FRONTEND, "pass123", "OFFLINE"), + createStaff("19910825-4433", "Niklas", "Sjöberg", "niklas.s@cyberwatch.se", "0709999999", Role.CONSULTANT, Department.DEVOPS, "pass123", "AWAY"), + createStaff("19931201-1010", "Elena", "Popova", "elena.p@cyberwatch.se", "0701010101", Role.CONSULTANT, Department.BACKEND, "pass123", "OFFLINE"), + createStaff("19870412-2233", "Mikael", "Vesterberg", "mikael.v@cyberwatch.se", "0721112233", Role.PROJECT_MANAGER, Department.DEVOPS, "testPass123", "BUSY"), + createStaff("19941030-4455", "Sara", "Lundin", "sara.l@cyberwatch.se", "0734445566", Role.CONSULTANT, Department.BACKEND, "testPass123", "BUSY"), + createStaff("19910228-6677", "David", "Holm", "david.h@cyberwatch.se", "0767778899", Role.HR, Department.HR, "testPass123", "ONLINE"), + createStaff("19890515-8899", "Emma", "Sjölin", "emma.s@cyberwatch.se", "0708889900", Role.PROJECT_MANAGER, Department.FRONTEND, "testPass123", "ONLINE"), + createStaff("19960820-1122", "Lucas", "Karlsson", "lucas.k@cyberwatch.se", "0791112233", Role.CONSULTANT, Department.BACKEND, "testPass123", "OFFLINE") + )); + + // Skapa 3 pending employment forms + Staff hrStaff = admins.get(0); // Eric (Admin/HR) + employmentFormRepository.saveAll(List.of( + createEmploymentForm("19800315-2525", "Peter", "Bergström", "peter.bergstrom@cyberwatch.se", "0715151515", Role.CONSULTANT, Department.BACKEND, hrStaff), + createEmploymentForm("19870723-3636", "Victoria", "Ström", "victoria.strom@cyberwatch.se", "0726262626", Role.PROJECT_MANAGER, Department.FRONTEND, hrStaff), + createEmploymentForm("19920411-4747", "Martin", "Nordin", "martin.nordin@cyberwatch.se", "0737373737", Role.CONSULTANT, Department.DEVOPS, hrStaff) )); } - private Staff createStaff(String ssn, String firstName, String lastName, String email, String phone, Role role, Department department, String rawPassword) { + private Staff createStaff(String ssn, String firstName, String lastName, String email, String phone, + Role role, Department department, String rawPassword, String status) { Staff staff = new Staff(); staff.setSocialSecurityNumber(encryptionService.encrypt(ssn)); + staff.setSsnHash(encryptionService.hmac(ssn)); staff.setFirstName(firstName); staff.setLastName(lastName); staff.setEmail(email); staff.setPhoneNumber(phone); staff.setRole(role); staff.setDepartment(department); - staff.setStatus("OFFLINE"); + staff.setStatus(status); staff.setPassword(passwordEncoder.encode(rawPassword)); return staff; } + + private EmploymentForm createEmploymentForm(String ssn, String firstName, String lastName, String email, + String phone, Role role, Department department, Staff createdBy) { + EmploymentForm form = new EmploymentForm(); + form.setSocialSecurityNumber(encryptionService.encrypt(ssn)); + form.setSsnHash(encryptionService.hmac(ssn)); + form.setFirstName(firstName); + form.setLastName(lastName); + form.setEmail(email); + form.setPhoneNumber(phone); + form.setRole(role); + form.setDepartment(department); + form.setStatus(ApprovalStatus.PENDING); + form.setCreatedDate(LocalDateTime.now()); + form.setCreatedBy(createdBy); + return form; + } } From 91e5c806380330bdc3a4ca87c9d2191f4571be88 Mon Sep 17 00:00:00 2001 From: Caroline Nordbrandt Date: Mon, 27 Apr 2026 17:42:40 +0200 Subject: [PATCH 09/11] Refactor SSN handling and access control: update regex for SSN validation, add non-null constraints for `ssn_hash`, remove role-based SSN masking policy, and streamline encryption handling; update database seeding with ticket creation. --- .../cyberwatch/config/DataInitializer.java | 43 ++++++++++++++++--- .../controller/EmploymentFormController.java | 9 ++-- .../form/dto/CreateEmploymentDTO.java | 3 +- .../features/form/model/EmploymentForm.java | 3 +- .../form/service/EmploymentFormService.java | 23 ++++------ .../features/staff/model/Staff.java | 3 +- .../features/staff/model/UpdateStaffDTO.java | 4 +- .../db/migration/V12__add_ssn_hash.sql | 5 +-- .../resources/application-test.properties | 4 +- 9 files changed, 60 insertions(+), 37 deletions(-) diff --git a/src/main/java/org/example/cyberwatch/config/DataInitializer.java b/src/main/java/org/example/cyberwatch/config/DataInitializer.java index 81732a7..5430096 100644 --- a/src/main/java/org/example/cyberwatch/config/DataInitializer.java +++ b/src/main/java/org/example/cyberwatch/config/DataInitializer.java @@ -5,14 +5,13 @@ import org.example.cyberwatch.features.form.repository.EmploymentFormRepository; import org.example.cyberwatch.features.staff.model.Staff; import org.example.cyberwatch.features.staff.repository.StaffRepository; -import org.example.cyberwatch.shared.model.enums.ApprovalStatus; -import org.example.cyberwatch.shared.model.enums.Department; -import org.example.cyberwatch.shared.model.enums.Role; +import org.example.cyberwatch.features.ticket.model.Ticket; +import org.example.cyberwatch.features.ticket.repository.TicketRepository; +import org.example.cyberwatch.shared.model.enums.*; import org.springframework.boot.CommandLineRunner; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; -import java.time.LocalDateTime; import java.util.List; @Component @@ -20,13 +19,15 @@ public class DataInitializer implements CommandLineRunner { private final StaffRepository staffRepository; private final EmploymentFormRepository employmentFormRepository; + private final TicketRepository ticketRepository; private final EncryptionService encryptionService; private final PasswordEncoder passwordEncoder; public DataInitializer(StaffRepository staffRepository, EmploymentFormRepository employmentFormRepository, - EncryptionService encryptionService, PasswordEncoder passwordEncoder) { + TicketRepository ticketRepository, EncryptionService encryptionService, PasswordEncoder passwordEncoder) { this.staffRepository = staffRepository; this.employmentFormRepository = employmentFormRepository; + this.ticketRepository = ticketRepository; this.encryptionService = encryptionService; this.passwordEncoder = passwordEncoder; } @@ -61,12 +62,27 @@ public void run(String... args) { )); // Skapa 3 pending employment forms - Staff hrStaff = admins.get(0); // Eric (Admin/HR) + Staff hrStaff = admins.getFirst(); // Eric (Admin) employmentFormRepository.saveAll(List.of( createEmploymentForm("19800315-2525", "Peter", "Bergström", "peter.bergstrom@cyberwatch.se", "0715151515", Role.CONSULTANT, Department.BACKEND, hrStaff), createEmploymentForm("19870723-3636", "Victoria", "Ström", "victoria.strom@cyberwatch.se", "0726262626", Role.PROJECT_MANAGER, Department.FRONTEND, hrStaff), createEmploymentForm("19920411-4747", "Martin", "Nordin", "martin.nordin@cyberwatch.se", "0737373737", Role.CONSULTANT, Department.DEVOPS, hrStaff) )); + + // Skapa 10 tickets + List allStaff = staffRepository.findAll(); + ticketRepository.saveAll(List.of( + createTicket("TICKET-1001", "Fix login bug on mobile", "Login button not responding on iOS 17", Status.IN_PROGRESS, Priority.HIGH, IssueType.SOFTWARE, allStaff.get(4), List.of(allStaff.get(7))), + createTicket("TICKET-1002", "Add dark mode feature", "Implement dark theme for better accessibility", Status.SUBMITTED, Priority.MEDIUM, IssueType.SOFTWARE, allStaff.get(5), List.of(allStaff.get(9))), + createTicket("TICKET-1003", "Database optimization", "Query performance issues in staff list endpoint", Status.IN_PROGRESS, Priority.HIGH, IssueType.SOFTWARE, allStaff.get(6), List.of()), + createTicket("TICKET-1004", "Update API documentation", "Document new endpoints for ticket filtering", Status.SUBMITTED, Priority.LOW, IssueType.OTHER, allStaff.get(7), List.of()), + createTicket("TICKET-1005", "Memory leak in S3 upload", "Application consuming too much memory during large file uploads", Status.RESOLVED, Priority.HIGH, IssueType.SOFTWARE, allStaff.get(8), List.of(allStaff.get(5))), + createTicket("TICKET-1006", "Enhance error messages", "Make error messages more user-friendly", Status.SUBMITTED, Priority.MEDIUM, IssueType.SOFTWARE, allStaff.get(9), List.of(allStaff.get(8))), + createTicket("TICKET-1007", "SSL certificate expiring", "Renew SSL certificate before expiration", Status.IN_PROGRESS, Priority.CRITICAL, IssueType.SECURITY, allStaff.get(4), List.of(allStaff.get(6))), + createTicket("TICKET-1008", "Add password recovery", "Implement forgot password functionality", Status.SUBMITTED, Priority.MEDIUM, IssueType.SOFTWARE, allStaff.get(10), List.of(allStaff.get(7))), + createTicket("TICKET-1009", "Network latency issues", "Reported high latency in VPN connection", Status.CLOSED, Priority.LOW, IssueType.NETWORK, allStaff.get(5), List.of()), + createTicket("TICKET-1010", "Update security headers", "Add missing security headers to HTTP responses", Status.IN_PROGRESS, Priority.HIGH, IssueType.SECURITY, allStaff.get(6), List.of(allStaff.get(4))) + )); } private Staff createStaff(String ssn, String firstName, String lastName, String email, String phone, @@ -97,9 +113,22 @@ private EmploymentForm createEmploymentForm(String ssn, String firstName, String form.setRole(role); form.setDepartment(department); form.setStatus(ApprovalStatus.PENDING); - form.setCreatedDate(LocalDateTime.now()); form.setCreatedBy(createdBy); return form; } + + private Ticket createTicket(String ticketCode, String title, String description, Status status, Priority priority, IssueType issueType, + Staff createdBy, List assignedStaff) { + Ticket ticket = new Ticket(); + ticket.setTicketCode(ticketCode); + ticket.setTitle(title); + ticket.setDescription(description); + ticket.setStatus(status); + ticket.setPriority(priority); + ticket.setIssueType(issueType); + ticket.setCreatedBy(createdBy); + ticket.setAssignedStaff(assignedStaff); + return ticket; + } } diff --git a/src/main/java/org/example/cyberwatch/features/form/controller/EmploymentFormController.java b/src/main/java/org/example/cyberwatch/features/form/controller/EmploymentFormController.java index efb4757..3a907c4 100644 --- a/src/main/java/org/example/cyberwatch/features/form/controller/EmploymentFormController.java +++ b/src/main/java/org/example/cyberwatch/features/form/controller/EmploymentFormController.java @@ -56,15 +56,14 @@ public ResponseEntity updateForm( //Show list of pending forms @GetMapping - public ResponseEntity> getForms(@RequestParam(required = false) ApprovalStatus status, - @AuthenticationPrincipal Staff staff) { - return ResponseEntity.ok(employmentFormService.getFormsByFilterApproval(status, staff)); + public ResponseEntity> getForms(@RequestParam(required = false) ApprovalStatus status) { + return ResponseEntity.ok(employmentFormService.getFormsByFilterApproval(status)); } //Get a form by id @GetMapping("{id}") - public ResponseEntity getFormById(@PathVariable Long id, @AuthenticationPrincipal Staff staff) { - return ResponseEntity.ok(employmentFormService.getFormById(id, staff)); + public ResponseEntity getFormById(@PathVariable Long id) { + return ResponseEntity.ok(employmentFormService.getFormById(id)); } // Reject a form with a reason diff --git a/src/main/java/org/example/cyberwatch/features/form/dto/CreateEmploymentDTO.java b/src/main/java/org/example/cyberwatch/features/form/dto/CreateEmploymentDTO.java index 92a984b..40b16f2 100644 --- a/src/main/java/org/example/cyberwatch/features/form/dto/CreateEmploymentDTO.java +++ b/src/main/java/org/example/cyberwatch/features/form/dto/CreateEmploymentDTO.java @@ -18,7 +18,8 @@ public class CreateEmploymentDTO { @NotBlank(message = "Social security number cannot be blank") - @Pattern(regexp = "\\d{6}-\\d{4}|\\d{8}-\\d{4}", message = "Social security number must be in format YYMMDD-NNNN or YYYYMMDD-NNNN") + @Pattern(regexp = "^(19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])-\\d{4}$", + message = "Social security number must match format YYYYMMDD-NNNN") private String socialSecurityNumber; @NotBlank(message = "First name cannot be blank") diff --git a/src/main/java/org/example/cyberwatch/features/form/model/EmploymentForm.java b/src/main/java/org/example/cyberwatch/features/form/model/EmploymentForm.java index 56a626e..9877bdf 100644 --- a/src/main/java/org/example/cyberwatch/features/form/model/EmploymentForm.java +++ b/src/main/java/org/example/cyberwatch/features/form/model/EmploymentForm.java @@ -28,7 +28,8 @@ public class EmploymentForm { @NotBlank(message = "Social security number cannot be blank") private String socialSecurityNumber; - @Column(name = "ssn_hash", length = 64, unique = true) + @Column(name = "ssn_hash", length = 64, nullable = false, unique = true) + @NotBlank(message = "SSN hash cannot be blank") private String ssnHash; @Column(name = "first_name") diff --git a/src/main/java/org/example/cyberwatch/features/form/service/EmploymentFormService.java b/src/main/java/org/example/cyberwatch/features/form/service/EmploymentFormService.java index 0165200..2a01246 100644 --- a/src/main/java/org/example/cyberwatch/features/form/service/EmploymentFormService.java +++ b/src/main/java/org/example/cyberwatch/features/form/service/EmploymentFormService.java @@ -65,11 +65,11 @@ public EmploymentFormDTO createForm(CreateEmploymentDTO form, Staff hrStaff) { EmploymentForm savedForm = employmentFormRepository.save(formEntity); logger.info("New employment form created with ID: {} by HR staffId={}", savedForm.getId(), hrStaff.getId()); - return applySsnPolicy(savedForm, hrStaff); + return applySsnPolicy(savedForm); } @PreAuthorize("hasAnyRole('HR', 'CEO', 'CTO', 'ADMIN')") - public List getFormsByFilterApproval(ApprovalStatus status, Staff requester) { + public List getFormsByFilterApproval(ApprovalStatus status) { List forms; if (status != null) { forms = employmentFormRepository.findByStatus(status); @@ -79,15 +79,15 @@ public List getFormsByFilterApproval(ApprovalStatus status, S // Använd din nya policy-metod på varje element i listan! return forms.stream() - .map(form -> applySsnPolicy(form, requester)) + .map(this::applySsnPolicy) .toList(); } // Get a single form by ID @PreAuthorize("hasAnyRole('HR', 'CEO', 'CTO', 'ADMIN')") - public EmploymentFormDTO getFormById(Long formId, Staff requester) { - return applySsnPolicy(findFormById(formId), requester); + public EmploymentFormDTO getFormById(Long formId) { + return applySsnPolicy(findFormById(formId)); } // Update form before approval (only PENDING forms can be updated) @@ -127,7 +127,7 @@ public EmploymentFormDTO updateFormBeforeApproval(Long formId, UpdateEmploymentD employmentMapper.updateEntity(updatedForm, existingForm); logger.info("Form {} updated by staffId={}", formId, loggedInHr.getId()); - return applySsnPolicy(employmentFormRepository.save(existingForm), loggedInHr); + return applySsnPolicy(employmentFormRepository.save(existingForm)); } // Reject a form (only PENDING forms can be rejected, and only by management) @@ -246,17 +246,10 @@ private void validateSsnNotExists(String ssn) { } } - private EmploymentFormDTO applySsnPolicy(EmploymentForm form, Staff requester) { + private EmploymentFormDTO applySsnPolicy(EmploymentForm form) { EmploymentFormDTO dto = employmentMapper.toDTO(form); String decrypted = encryptionService.decrypt(form.getSocialSecurityNumber()); - - // Om requester är ADMIN/HR/CEO/CTO -> visa allt, annars maskera - if (requester.getRole() == Role.ADMIN || requester.getRole() == Role.HR || - requester.getRole() == Role.CEO || requester.getRole() == Role.CTO) { - dto.setSocialSecurityNumber(decrypted); - } else { - dto.setSocialSecurityNumber(encryptionService.maskLastFour(decrypted)); - } + dto.setSocialSecurityNumber(decrypted); return dto; } diff --git a/src/main/java/org/example/cyberwatch/features/staff/model/Staff.java b/src/main/java/org/example/cyberwatch/features/staff/model/Staff.java index ed23ed0..51a0936 100644 --- a/src/main/java/org/example/cyberwatch/features/staff/model/Staff.java +++ b/src/main/java/org/example/cyberwatch/features/staff/model/Staff.java @@ -23,7 +23,8 @@ public class Staff { @NotBlank(message = "Social security number cannot be blank") private String socialSecurityNumber; - @Column(name = "ssn_hash", length = 64, unique = true) + @Column(name = "ssn_hash", length = 64, nullable = false, unique = true) + @NotBlank(message = "SSN hash cannot be blank") private String ssnHash; @Column(name = "first_name") diff --git a/src/main/java/org/example/cyberwatch/features/staff/model/UpdateStaffDTO.java b/src/main/java/org/example/cyberwatch/features/staff/model/UpdateStaffDTO.java index 53182a1..06ae79f 100644 --- a/src/main/java/org/example/cyberwatch/features/staff/model/UpdateStaffDTO.java +++ b/src/main/java/org/example/cyberwatch/features/staff/model/UpdateStaffDTO.java @@ -22,8 +22,8 @@ public class UpdateStaffDTO { @Size(min = 2, max = 50, message = "Last name must be between 2 and 50 characters") private String lastName; - @NotBlank(message = "Social security number cannot be blank") - @Pattern(regexp = "^\\d{8}-\\d{4}$", message = "Social security number must match format YYYYMMDD-NNNN") + @Pattern(regexp = "^(19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])-\\d{4}$", + message = "Social security number must match format YYYYMMDD-NNNN") private String socialSecurityNumber; @Email(message = "Email should be valid") diff --git a/src/main/resources/db/migration/V12__add_ssn_hash.sql b/src/main/resources/db/migration/V12__add_ssn_hash.sql index da8b1b4..2d9970b 100644 --- a/src/main/resources/db/migration/V12__add_ssn_hash.sql +++ b/src/main/resources/db/migration/V12__add_ssn_hash.sql @@ -1,8 +1,7 @@ ALTER TABLE staff - ADD COLUMN ssn_hash VARCHAR(64); - + ADD COLUMN ssn_hash VARCHAR(64) NOT NULL; ALTER TABLE employment_form - ADD COLUMN ssn_hash VARCHAR(64); + ADD COLUMN ssn_hash VARCHAR(64) NOT NULL; CREATE UNIQUE INDEX idx_staff_ssn_hash ON staff (ssn_hash); CREATE UNIQUE INDEX idx_form_ssn_hash ON employment_form (ssn_hash); diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index ee349f6..d078224 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -1,5 +1,5 @@ -ENCRYPTION_PASSWORD=testencryptionkey1234567890123456 -ENCRYPTION_PEPPER=0123456789abcdef +app.encryption.password=testencryptionkey1234567890123456 +app.encryption.pepper=0123456789abcdef app.encryption.hmac-key=0123456789abcdef0123456789abcdef S3_ENDPOINT=http://localhost:9002 S3_BUCKET=test-bucket From 8d87555da7549db985418c2c1bdc853f1444409e Mon Sep 17 00:00:00 2001 From: Caroline Nordbrandt Date: Mon, 27 Apr 2026 21:59:15 +0200 Subject: [PATCH 10/11] Enhance encryption handling: update documentation for AES-256 key generation, clarify encryption and decryption processes, and add unique index for SSN hash in database --- .../example/cyberwatch/config/EncryptionConfig.java | 12 ++++++++++-- .../config/security/EncryptionService.java | 10 +++++++++- .../resources/db/migration/V12__add_ssn_hash.sql | 1 + 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/example/cyberwatch/config/EncryptionConfig.java b/src/main/java/org/example/cyberwatch/config/EncryptionConfig.java index 5938112..4bd29dd 100644 --- a/src/main/java/org/example/cyberwatch/config/EncryptionConfig.java +++ b/src/main/java/org/example/cyberwatch/config/EncryptionConfig.java @@ -15,8 +15,16 @@ public class EncryptionConfig { @Value("${app.encryption.pepper}") private String pepper; - //Skapar en AES-256 krypteringsnyckel, pass + pepper = key, - //delux lägger till ett randomiserat salt-värde(Nonce/IV) vid varje anrop av encrypt + /** + * pass + pepper = key, delux kör dem genom en nyckelderivationsfunktion (PBKDF2) + * för att skapa den faktiska AES-nyckeln som vi sedan använder för att kunna låsa upp/låsa personnummer + * delux lägger till ett randomiserat salt-värde(Nonce/IV) vid varje anrop av encrypt + * delux-objektet lever i minnet under hela applikationens körning - Spring hanterar livscykeln som en singleton Bean, + * så det randomiserade saltet är unikt per kryptering och gör att samma input ger olika output varje gång + * Det håller internt koll på: + * encryptionPass + pepper → för att kunna kryptera/dekryptera och sätter in/plockar ut IV(saltet) vid encrypt/decrypt + * Logiken för att generera ett nytt slumpmässigt IV/salt vid varje encrypt()-anrop + */ @Bean public TextEncryptor textEncryptor() { return Encryptors.delux(encryptionPass, pepper); diff --git a/src/main/java/org/example/cyberwatch/config/security/EncryptionService.java b/src/main/java/org/example/cyberwatch/config/security/EncryptionService.java index cad9ab5..da37c63 100644 --- a/src/main/java/org/example/cyberwatch/config/security/EncryptionService.java +++ b/src/main/java/org/example/cyberwatch/config/security/EncryptionService.java @@ -15,16 +15,24 @@ public class EncryptionService { @Value("${app.encryption.hmac-key}") // En separat fast nyckel för sökning private String hmacKey; + /** + * Delegerar till delux som genererar ett unikt IV, krypterar med AES-256 nycken + * och returnerar IV + krypterad data som en enda sträng, det är denna som sedan lagras i databasen. + */ public String encrypt(String data) { return textEncryptor.encrypt(data); } + /** + * Delegerar till delux som plockar ut IV från strängen och dekrypterar + * med AES-256 för att återge klartexten. + */ public String decrypt(String data) { return textEncryptor.decrypt(data); } /** - * Skapar ett Blind Index (deterministisk hash). + * Skapar ett "Blind Index" (deterministisk hash, sökbar). * Används för att jämföra om personnummer redan finns i databasen. */ public String hmac(String data) { diff --git a/src/main/resources/db/migration/V12__add_ssn_hash.sql b/src/main/resources/db/migration/V12__add_ssn_hash.sql index 2d9970b..fe4b54f 100644 --- a/src/main/resources/db/migration/V12__add_ssn_hash.sql +++ b/src/main/resources/db/migration/V12__add_ssn_hash.sql @@ -3,6 +3,7 @@ ALTER TABLE staff ALTER TABLE employment_form ADD COLUMN ssn_hash VARCHAR(64) NOT NULL; +--För att snabbt och enkelt kunna söka och jämföra i databasen CREATE UNIQUE INDEX idx_staff_ssn_hash ON staff (ssn_hash); CREATE UNIQUE INDEX idx_form_ssn_hash ON employment_form (ssn_hash); From 0a0178dff45d98b82b597cfe478d8981e79ddc3f Mon Sep 17 00:00:00 2001 From: Caroline Nordbrandt Date: Tue, 28 Apr 2026 08:46:05 +0200 Subject: [PATCH 11/11] Enhance encryption validation: implement fail-fast checks for encryption and HMAC keys to ensure strong security at startup --- .../cyberwatch/config/EncryptionConfig.java | 38 +++++++++++++++++++ .../config/security/EncryptionService.java | 23 +++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/main/java/org/example/cyberwatch/config/EncryptionConfig.java b/src/main/java/org/example/cyberwatch/config/EncryptionConfig.java index 4bd29dd..95bf5a7 100644 --- a/src/main/java/org/example/cyberwatch/config/EncryptionConfig.java +++ b/src/main/java/org/example/cyberwatch/config/EncryptionConfig.java @@ -1,5 +1,6 @@ package org.example.cyberwatch.config; +import jakarta.annotation.PostConstruct; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -15,6 +16,41 @@ public class EncryptionConfig { @Value("${app.encryption.pepper}") private String pepper; + //Vi implementerade fail-fast validation för alla krypteringsnycklar. Applikationen startar INTE om någon nyckel är tom eller för svag. + @PostConstruct + public void validateEncryptionKeys() { + if (encryptionPass == null || encryptionPass.isBlank()) { + throw new IllegalArgumentException( + "CRITICAL: app.encryption.password is missing or blank. " + + "AES-256 encryption disabled. Set a strong password in environment variables." + ); + } + + if (pepper == null || pepper.isBlank()) { + throw new IllegalArgumentException( + "CRITICAL: app.encryption.pepper is missing or blank. " + + "Key derivation weakened. Set a strong pepper value in environment variables." + ); + } + + // Minsta entropy-krav för password: 32 tecken + if (encryptionPass.length() < 32) { + throw new IllegalArgumentException( + "CRITICAL: app.encryption.password is too short (" + encryptionPass.length() + " chars). " + + "Requires minimum 32 characters for AES-256 security. " + + "Use a strong random password like: openssl rand -base64 24" + ); + } + + // Minsta entropy-krav för pepper: 16 tecken + if (pepper.length() < 16) { + throw new IllegalArgumentException( + "CRITICAL: app.encryption.pepper is too short (" + pepper.length() + " chars). " + + "Requires minimum 16 characters. Use: openssl rand -hex 8" + ); + } + } + /** * pass + pepper = key, delux kör dem genom en nyckelderivationsfunktion (PBKDF2) * för att skapa den faktiska AES-nyckeln som vi sedan använder för att kunna låsa upp/låsa personnummer @@ -24,6 +60,8 @@ public class EncryptionConfig { * Det håller internt koll på: * encryptionPass + pepper → för att kunna kryptera/dekryptera och sätter in/plockar ut IV(saltet) vid encrypt/decrypt * Logiken för att generera ett nytt slumpmässigt IV/salt vid varje encrypt()-anrop + * + * Säkerhet: Om password eller pepper är tom/svag kastas IllegalArgumentException vid startup (@PostConstruct) */ @Bean public TextEncryptor textEncryptor() { diff --git a/src/main/java/org/example/cyberwatch/config/security/EncryptionService.java b/src/main/java/org/example/cyberwatch/config/security/EncryptionService.java index da37c63..e2c26c0 100644 --- a/src/main/java/org/example/cyberwatch/config/security/EncryptionService.java +++ b/src/main/java/org/example/cyberwatch/config/security/EncryptionService.java @@ -1,11 +1,13 @@ package org.example.cyberwatch.config.security; +import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import org.apache.commons.codec.digest.HmacAlgorithms; import org.apache.commons.codec.digest.HmacUtils; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.crypto.encrypt.TextEncryptor; import org.springframework.stereotype.Service; + @Service @RequiredArgsConstructor public class EncryptionService { @@ -15,6 +17,27 @@ public class EncryptionService { @Value("${app.encryption.hmac-key}") // En separat fast nyckel för sökning private String hmacKey; + //Fail-fas validering. Detta garanterar att blind indexet alltid är starkt och predictable duplicates är omöjliga. + @PostConstruct + public void validateHmacKey() { + if (hmacKey == null || hmacKey.isBlank()) { + throw new IllegalArgumentException( + "CRITICAL: app.encryption.hmac-key is missing or blank. " + + "Blind index security compromised. Set a strong, non-empty key in environment variables." + ); + } + + // Minsta entropy-krav: 16 tecken (128 bits) + if (hmacKey.length() < 16) { + throw new IllegalArgumentException( + "CRITICAL: app.encryption.hmac-key is too short (" + hmacKey.length() + " chars). " + + "Requires minimum 16 characters for adequate entropy. " + + "Use a strong random key like: " + + "openssl rand -hex 16" + ); + } + } + /** * Delegerar till delux som genererar ett unikt IV, krypterar med AES-256 nycken * och returnerar IV + krypterad data som en enda sträng, det är denna som sedan lagras i databasen.