feat: sensitive fields removal + phone encryption + click-to-reveal + audit log
This commit is contained in:
@@ -8,6 +8,7 @@ import com.srsystem.dto.EmployeeSearchResult;
|
||||
import com.srsystem.dto.EmployeeSummary;
|
||||
import com.srsystem.dto.PortfolioResponse;
|
||||
import com.srsystem.dto.UpdateAchievementsRequest;
|
||||
import com.srsystem.entity.AuditLog;
|
||||
import com.srsystem.entity.DispatchHistory;
|
||||
import com.srsystem.entity.Employee;
|
||||
import com.srsystem.entity.EmployeeEmergencyContact;
|
||||
@@ -16,6 +17,7 @@ import com.srsystem.entity.enums.DispatchStatus;
|
||||
import com.srsystem.entity.enums.EmpStatus;
|
||||
import com.srsystem.entity.enums.RoleCode;
|
||||
import com.srsystem.util.ExcelParser;
|
||||
import com.srsystem.repository.AuditLogRepository;
|
||||
import com.srsystem.repository.EquipmentAssignmentRepository;
|
||||
import com.srsystem.service.DispatchHistoryService;
|
||||
import com.srsystem.service.EmployeeService;
|
||||
@@ -53,6 +55,7 @@ public class EmployeeController {
|
||||
private final DispatchHistoryService dispatchHistoryService;
|
||||
private final PortfolioService portfolioService;
|
||||
private final EquipmentAssignmentRepository equipmentAssignmentRepository;
|
||||
private final AuditLogRepository auditLogRepository;
|
||||
|
||||
@Operation(summary = "직원 목록 조회", description = "페이징된 직원 목록을 조회합니다. 상태 필터 옵션 포함")
|
||||
@PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')")
|
||||
@@ -254,6 +257,29 @@ public class EmployeeController {
|
||||
return ResponseEntity.ok(ApiResponse.ok("프로필 이미지가 저장되었습니다", employeeService.update(empId, emp)));
|
||||
}
|
||||
|
||||
@Operation(summary = "전화번호 열람 요청", description = "사유를 입력하면 전화번호를 반환합니다 (감사 로그 기록)")
|
||||
@PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')")
|
||||
@PostMapping("/{empId}/request-phone")
|
||||
public ResponseEntity<ApiResponse<Map<String, String>>> requestPhone(
|
||||
@PathVariable Long empId, @RequestBody Map<String, String> body,
|
||||
@CurrentUser Employee currentUser,
|
||||
jakarta.servlet.http.HttpServletRequest request) {
|
||||
Employee target = employeeService.findById(empId);
|
||||
if (target.getPhone() == null) {
|
||||
return ResponseEntity.ok(ApiResponse.ok("등록된 전화번호가 없습니다", Map.of("phone", "")));
|
||||
}
|
||||
auditLogRepository.save(AuditLog.builder()
|
||||
.action("VIEW_PHONE")
|
||||
.targetType("EMPLOYEE")
|
||||
.targetId(empId)
|
||||
.actorId(currentUser.getEmpId())
|
||||
.reason(body.getOrDefault("reason", ""))
|
||||
.ipAddress(request.getRemoteAddr())
|
||||
.createdAt(java.time.LocalDateTime.now())
|
||||
.build());
|
||||
return ResponseEntity.ok(ApiResponse.ok(Map.of("phone", target.getPhone())));
|
||||
}
|
||||
|
||||
@Operation(summary = "비밀번호 변경", description = "현재 사용자의 비밀번호를 변경합니다")
|
||||
@PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')")
|
||||
@PutMapping("/me/password")
|
||||
@@ -293,11 +319,7 @@ public class EmployeeController {
|
||||
emp.setEmpStatus(EmpStatus.valueOf(row.getOrDefault("재직상태", "ACTIVE").toUpperCase()));
|
||||
emp.setDispatchStatus(DispatchStatus.valueOf(row.getOrDefault("파견상태", "WAITING").toUpperCase()));
|
||||
emp.setPassword("batch1234!");
|
||||
if (row.containsKey("생년월일")) emp.setBirthDate(java.time.LocalDate.parse(row.get("생년월일")));
|
||||
if (row.containsKey("학력")) emp.setEducation(row.get("학력"));
|
||||
if (row.containsKey("학교명")) emp.setSchoolName(row.get("학교명"));
|
||||
if (row.containsKey("직급")) emp.setPosition(row.get("직급"));
|
||||
if (row.containsKey("학과")) emp.setMajor(row.get("학과"));
|
||||
if (row.containsKey("입사일")) emp.setJoinedDate(java.time.LocalDate.parse(row.get("입사일")));
|
||||
return emp;
|
||||
},
|
||||
@@ -336,7 +358,7 @@ public class EmployeeController {
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@GetMapping("/batch/template")
|
||||
public ResponseEntity<byte[]> downloadTemplate() throws Exception {
|
||||
String[] headers = {"로그인ID", "이름", "이메일", "전화번호", "권한", "재직상태", "파견상태", "생년월일", "학력", "학교명", "직급", "학과", "입사일"};
|
||||
String[] headers = {"로그인ID", "이름", "이메일", "전화번호", "권한", "재직상태", "파견상태", "직급", "입사일"};
|
||||
Map<Integer, String[]> dropdowns = Map.of(
|
||||
4, new String[]{"ADMIN", "MANAGER", "EMPLOYEE"},
|
||||
5, new String[]{"ACTIVE", "ON_LEAVE", "RESIGNED"},
|
||||
|
||||
@@ -10,18 +10,10 @@ public record EmployeeSummary(
|
||||
Long empId,
|
||||
String name,
|
||||
String email,
|
||||
String phone,
|
||||
RoleCode roleCode,
|
||||
EmpStatus empStatus,
|
||||
DispatchStatus dispatchStatus,
|
||||
LocalDate joinedDate,
|
||||
LocalDate leftDate,
|
||||
LocalDate birthDate,
|
||||
String education,
|
||||
String major,
|
||||
LocalDate graduationDate,
|
||||
String residence,
|
||||
String profileImage,
|
||||
String schoolName,
|
||||
String position
|
||||
String profileImage
|
||||
) {}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.srsystem.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "tb_audit_log")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class AuditLog {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sq_audit_logs")
|
||||
@SequenceGenerator(name = "sq_audit_logs", sequenceName = "sq_audit_logs", allocationSize = 50)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "action", nullable = false, length = 50)
|
||||
private String action;
|
||||
|
||||
@Column(name = "target_type", length = 50)
|
||||
private String targetType;
|
||||
|
||||
@Column(name = "target_id")
|
||||
private Long targetId;
|
||||
|
||||
@Column(name = "actor_id")
|
||||
private Long actorId;
|
||||
|
||||
@Column(name = "reason", length = 500)
|
||||
private String reason;
|
||||
|
||||
@Column(name = "ip_address", length = 50)
|
||||
private String ipAddress;
|
||||
|
||||
@Column(name = "created_at")
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package com.srsystem.entity;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.srsystem.entity.enums.DispatchStatus;
|
||||
import com.srsystem.entity.enums.EmpStatus;
|
||||
import com.srsystem.entity.enums.RoleCode;
|
||||
import com.srsystem.util.PhoneEncryptionConverter;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.SQLDelete;
|
||||
@@ -41,7 +41,8 @@ public class Employee extends BaseEntity {
|
||||
@Column(name = "email", length = 255)
|
||||
private String email;
|
||||
|
||||
@Column(name = "phone", length = 20)
|
||||
@Convert(converter = PhoneEncryptionConverter.class)
|
||||
@Column(name = "phone", length = 255)
|
||||
private String phone;
|
||||
|
||||
@Builder.Default
|
||||
@@ -65,18 +66,6 @@ public class Employee extends BaseEntity {
|
||||
@Column(name = "left_date")
|
||||
private LocalDate leftDate;
|
||||
|
||||
@Column(name = "birth_date")
|
||||
private LocalDate birthDate;
|
||||
|
||||
@Column(name = "education", length = 20)
|
||||
private String education;
|
||||
|
||||
@Column(name = "major", length = 100)
|
||||
private String major;
|
||||
|
||||
@Column(name = "school_name", length = 200)
|
||||
private String schoolName;
|
||||
|
||||
@Column(name = "manager_id")
|
||||
private Long managerId;
|
||||
|
||||
@@ -86,12 +75,6 @@ public class Employee extends BaseEntity {
|
||||
@Column(name = "position", length = 50)
|
||||
private String position;
|
||||
|
||||
@Column(name = "graduation_date")
|
||||
private LocalDate graduationDate;
|
||||
|
||||
@Column(name = "residence", length = 255)
|
||||
private String residence;
|
||||
|
||||
@Column(name = "profile_image", length = 500)
|
||||
private String profileImage;
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.srsystem.repository;
|
||||
|
||||
import com.srsystem.entity.AuditLog;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {
|
||||
}
|
||||
@@ -104,13 +104,7 @@ public class EmployeeService {
|
||||
employee.setEmail(updateRequest.getEmail());
|
||||
employee.setPhone(updateRequest.getPhone());
|
||||
employee.setJoinedDate(updateRequest.getJoinedDate());
|
||||
employee.setBirthDate(updateRequest.getBirthDate());
|
||||
employee.setEducation(updateRequest.getEducation());
|
||||
employee.setMajor(updateRequest.getMajor());
|
||||
employee.setGraduationDate(updateRequest.getGraduationDate());
|
||||
employee.setResidence(updateRequest.getResidence());
|
||||
employee.setProfileImage(updateRequest.getProfileImage());
|
||||
employee.setSchoolName(updateRequest.getSchoolName());
|
||||
employee.setPosition(updateRequest.getPosition());
|
||||
employee.setManagerId(updateRequest.getManagerId());
|
||||
employee.setBackupManagerId(updateRequest.getBackupManagerId());
|
||||
@@ -168,20 +162,12 @@ public class EmployeeService {
|
||||
employee.getEmpId(),
|
||||
employee.getName(),
|
||||
employee.getEmail(),
|
||||
employee.getPhone(),
|
||||
employee.getRoleCode(),
|
||||
employee.getEmpStatus(),
|
||||
employee.getDispatchStatus(),
|
||||
employee.getJoinedDate(),
|
||||
employee.getLeftDate(),
|
||||
employee.getBirthDate(),
|
||||
employee.getEducation(),
|
||||
employee.getMajor(),
|
||||
employee.getGraduationDate(),
|
||||
employee.getResidence(),
|
||||
employee.getProfileImage(),
|
||||
employee.getSchoolName(),
|
||||
employee.getPosition()
|
||||
employee.getProfileImage()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.srsystem.util;
|
||||
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
|
||||
@Component
|
||||
@Converter
|
||||
public class PhoneEncryptionConverter implements AttributeConverter<String, String> {
|
||||
|
||||
private static final String AES = "AES";
|
||||
private static final String AES_GCM = "AES/GCM/NoPadding";
|
||||
private static final int GCM_IV_LENGTH = 12;
|
||||
private static final int GCM_TAG_LENGTH = 128;
|
||||
|
||||
private static SecretKey secretKey;
|
||||
private static final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
public PhoneEncryptionConverter(@Value("${app.encryption.phone-key}") String key) {
|
||||
byte[] keyBytes = key.getBytes();
|
||||
if (keyBytes.length < 16) {
|
||||
byte[] padded = new byte[16];
|
||||
System.arraycopy(keyBytes, 0, padded, 0, Math.min(keyBytes.length, 16));
|
||||
keyBytes = padded;
|
||||
}
|
||||
secretKey = new SecretKeySpec(keyBytes, AES);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String convertToDatabaseColumn(String plaintext) {
|
||||
if (plaintext == null) return null;
|
||||
try {
|
||||
byte[] iv = new byte[GCM_IV_LENGTH];
|
||||
secureRandom.nextBytes(iv);
|
||||
Cipher cipher = Cipher.getInstance(AES_GCM);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
|
||||
byte[] ciphertext = cipher.doFinal(plaintext.getBytes());
|
||||
byte[] combined = new byte[GCM_IV_LENGTH + ciphertext.length];
|
||||
System.arraycopy(iv, 0, combined, 0, GCM_IV_LENGTH);
|
||||
System.arraycopy(ciphertext, 0, combined, GCM_IV_LENGTH, ciphertext.length);
|
||||
return Base64.getEncoder().encodeToString(combined);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Phone encryption failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String convertToEntityAttribute(String encrypted) {
|
||||
if (encrypted == null) return null;
|
||||
try {
|
||||
byte[] combined = Base64.getDecoder().decode(encrypted);
|
||||
byte[] iv = new byte[GCM_IV_LENGTH];
|
||||
byte[] ciphertext = new byte[combined.length - GCM_IV_LENGTH];
|
||||
System.arraycopy(combined, 0, iv, 0, GCM_IV_LENGTH);
|
||||
System.arraycopy(combined, GCM_IV_LENGTH, ciphertext, 0, ciphertext.length);
|
||||
Cipher cipher = Cipher.getInstance(AES_GCM);
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
|
||||
return new String(cipher.doFinal(ciphertext));
|
||||
} catch (Exception e) {
|
||||
return encrypted;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user