feat: sensitive fields removal + phone encryption + click-to-reveal + audit log

This commit is contained in:
pray4skylark
2026-06-10 16:23:39 +09:00
parent 969efe3fc3
commit cae0225f80
13 changed files with 313 additions and 257 deletions
@@ -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;
}
}
}