feat: WTM 멀티프로젝트 플랫폼 구축 (BE + FE 전체 구현)
Phase 0: wbx-spring-core 라이브러리 전환 - java-library 플러그인, WbxAutoConfiguration, Admin 조건부 활성화 - 루트 settings.gradle + build.gradle (멀티모듈) Phase 1: wtm-api 모듈 생성 - 23개 JPA Entity, 14개 Controller, 79개 API 엔드포인트 - Flyway V100~V107 MySQL 마이그레이션 - TimesheetRuleEngine, TimesheetApprovalHandler, P6WbsParser Phase 2: wtm-frontend (Vue 3 + PrimeVue 4) - 10개 도메인 모듈, 17개 View, 5개 서브컴포넌트 - 반응형 레이아웃 (AppLayout, AppSidebar, AppTopbar) - BaseCrudTable, BaseFormDialog, BasePageHeader 표준 컴포넌트 - JWT 인터셉터, 역할 기반 메뉴 필터링 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
이 Commit은 다음에 포함되어 있습니다:
@@ -0,0 +1,15 @@
|
||||
package kr.co.accura.wbx.spring;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
public class HealthController {
|
||||
|
||||
@GetMapping("/health")
|
||||
public Map<String, String> health() {
|
||||
return Map.of("status", "ok", "app", "wbx-spring");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package kr.co.accura.wbx.spring.admin;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
/**
|
||||
* Conditionally activates the Admin Console UI.
|
||||
* Enabled by default (matchIfMissing = true); set wbx.spring.admin-ui.enabled=false to disable.
|
||||
* Individual admin beans (AdminController, AdminLoginController, AdminUserDetailsService,
|
||||
* AdminViewController) also carry the same @ConditionalOnProperty so they are excluded
|
||||
* from component scanning when the property is false.
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnProperty(name = "wbx.spring.admin-ui.enabled", havingValue = "true", matchIfMissing = true)
|
||||
public class AdminAutoConfiguration {
|
||||
|
||||
/**
|
||||
* Admin Console — session-based form login (moved from SecurityAutoConfig).
|
||||
*/
|
||||
@Bean
|
||||
@Order(1)
|
||||
public SecurityFilterChain adminFilterChain(HttpSecurity http) throws Exception {
|
||||
return http
|
||||
.securityMatcher("/admin/**", "/admin")
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/admin/css/**", "/admin/js/**").permitAll()
|
||||
.requestMatchers("/admin/login").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.formLogin(form -> form
|
||||
.loginPage("/admin/login")
|
||||
.defaultSuccessUrl("/admin", true)
|
||||
.permitAll()
|
||||
)
|
||||
.logout(logout -> logout
|
||||
.logoutUrl("/admin/logout")
|
||||
.logoutSuccessUrl("/admin/login?logout")
|
||||
)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package kr.co.accura.wbx.spring.admin;
|
||||
|
||||
import kr.co.accura.wbx.spring.auth.*;
|
||||
import kr.co.accura.wbx.spring.common.BusinessException;
|
||||
import kr.co.accura.wbx.spring.common.NotFoundException;
|
||||
import kr.co.accura.wbx.spring.rbac.WbxRole;
|
||||
import kr.co.accura.wbx.spring.rbac.RolePermission;
|
||||
import kr.co.accura.wbx.spring.rbac.RolePermissionRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@ConditionalOnProperty(name = "wbx.spring.admin-ui.enabled", havingValue = "true", matchIfMissing = true)
|
||||
@RestController
|
||||
@RequestMapping("${wbx.spring.api-prefix:/api}/admin")
|
||||
@PreAuthorize("hasRole('SA')")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminController {
|
||||
|
||||
private final WbxUserRepository userRepository;
|
||||
private final LoginHistoryRepository loginHistoryRepository;
|
||||
private final WbxRoleRepository roleRepository;
|
||||
private final RolePermissionRepository permissionRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
// ===== 사용자 관리 =====
|
||||
|
||||
@GetMapping("/users")
|
||||
public Map<String, Object> listUsers(@RequestParam(defaultValue = "0") int skip,
|
||||
@RequestParam(defaultValue = "20") int limit) {
|
||||
Page<WbxUser> page = userRepository.findAll(PageRequest.of(skip / Math.max(limit, 1), limit));
|
||||
return Map.of("items", page.getContent(), "total", page.getTotalElements());
|
||||
}
|
||||
|
||||
@GetMapping("/users/{id}")
|
||||
public WbxUser getUser(@PathVariable Long id) {
|
||||
return userRepository.findById(id)
|
||||
.orElseThrow(() -> new NotFoundException("User not found"));
|
||||
}
|
||||
|
||||
@PostMapping("/users/{id}/unlock")
|
||||
public Map<String, String> unlockUser(@PathVariable Long id) {
|
||||
WbxUser user = userRepository.findById(id)
|
||||
.orElseThrow(() -> new NotFoundException("User not found"));
|
||||
user.setFailedLoginAttempts(0);
|
||||
user.setLastFailedLogin(null);
|
||||
user.setLockedUntil(null);
|
||||
userRepository.save(user);
|
||||
return Map.of("detail", "User unlocked");
|
||||
}
|
||||
|
||||
@PostMapping("/users/{id}/reset-password")
|
||||
public Map<String, String> resetPassword(@PathVariable Long id) {
|
||||
WbxUser user = userRepository.findById(id)
|
||||
.orElseThrow(() -> new NotFoundException("User not found"));
|
||||
String tempPwd = "Temp" + System.currentTimeMillis() % 10000 + "!";
|
||||
user.setHashedPassword(passwordEncoder.encode(tempPwd));
|
||||
user.setMustChangePassword(true);
|
||||
userRepository.save(user);
|
||||
return Map.of("detail", "Password reset", "temp_password", tempPwd);
|
||||
}
|
||||
|
||||
@PutMapping("/users/{id}/status")
|
||||
public Map<String, String> toggleStatus(@PathVariable Long id, @RequestBody Map<String, Boolean> req) {
|
||||
WbxUser user = userRepository.findById(id)
|
||||
.orElseThrow(() -> new NotFoundException("User not found"));
|
||||
user.setActive(req.getOrDefault("is_active", true));
|
||||
userRepository.save(user);
|
||||
return Map.of("detail", "Status updated");
|
||||
}
|
||||
|
||||
// ===== 역할 관리 =====
|
||||
|
||||
@GetMapping("/roles")
|
||||
public Map<String, Object> listRoles() {
|
||||
List<WbxRole> roles = roleRepository.findAll();
|
||||
return Map.of("items", roles, "total", roles.size());
|
||||
}
|
||||
|
||||
@PostMapping("/roles")
|
||||
public WbxRole createRole(@RequestBody WbxRole role) {
|
||||
return roleRepository.save(role);
|
||||
}
|
||||
|
||||
@GetMapping("/roles/{id}/permissions")
|
||||
public Map<String, Object> getRolePermissions(@PathVariable Long id) {
|
||||
List<RolePermission> perms = permissionRepository.findAll()
|
||||
.stream().filter(p -> p.getRoleId().equals(id)).toList();
|
||||
return Map.of("items", perms, "total", perms.size());
|
||||
}
|
||||
|
||||
// ===== 로그인 이력 =====
|
||||
|
||||
@GetMapping("/login-history")
|
||||
public Map<String, Object> loginHistory(@RequestParam(defaultValue = "0") int skip,
|
||||
@RequestParam(defaultValue = "50") int limit) {
|
||||
Page<WbxLoginHistory> page = loginHistoryRepository.findAllByOrderByCreatedAtDesc(
|
||||
PageRequest.of(skip / Math.max(limit, 1), limit));
|
||||
return Map.of("items", page.getContent(), "total", page.getTotalElements());
|
||||
}
|
||||
|
||||
// ===== 시스템 상태 =====
|
||||
|
||||
@GetMapping("/system-health")
|
||||
public Map<String, Object> systemHealth() {
|
||||
return Map.of(
|
||||
"users", userRepository.countByIsActiveTrue(),
|
||||
"status", "ok"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package kr.co.accura.wbx.spring.admin;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
@ConditionalOnProperty(name = "wbx.spring.admin-ui.enabled", havingValue = "true", matchIfMissing = true)
|
||||
@Controller
|
||||
public class AdminLoginController {
|
||||
|
||||
@GetMapping("/admin/login")
|
||||
public String loginPage() {
|
||||
return "admin/login";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package kr.co.accura.wbx.spring.admin;
|
||||
|
||||
import kr.co.accura.wbx.spring.auth.WbxUserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@ConditionalOnProperty(name = "wbx.spring.admin-ui.enabled", havingValue = "true", matchIfMissing = true)
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AdminUserDetailsService implements UserDetailsService {
|
||||
|
||||
private final WbxUserRepository userRepository;
|
||||
|
||||
@Override
|
||||
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
|
||||
var user = userRepository.findByEmail(email)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + email));
|
||||
|
||||
return new User(
|
||||
user.getEmail(),
|
||||
user.getHashedPassword(),
|
||||
user.isActive(),
|
||||
true, true,
|
||||
!user.isLocked(),
|
||||
List.of(new SimpleGrantedAuthority(user.isAdmin() ? "ROLE_SA" : "ROLE_USER"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
package kr.co.accura.wbx.spring.admin;
|
||||
|
||||
import kr.co.accura.wbx.spring.auth.*;
|
||||
import kr.co.accura.wbx.spring.audit.AuditLogRepository;
|
||||
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
|
||||
import kr.co.accura.wbx.spring.config.WbxSystemConfig;
|
||||
import kr.co.accura.wbx.spring.rbac.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.lang.management.MemoryMXBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@ConditionalOnProperty(name = "wbx.spring.admin-ui.enabled", havingValue = "true", matchIfMissing = true)
|
||||
@Controller
|
||||
@RequestMapping("/admin")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminViewController {
|
||||
|
||||
private final WbxUserRepository userRepository;
|
||||
private final LoginHistoryRepository loginHistoryRepository;
|
||||
private final WbxRoleRepository roleRepository;
|
||||
private final RolePermissionRepository permissionRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final WbxSpringProperties props;
|
||||
private final AuditLogRepository auditLogRepository;
|
||||
private final WbxSystemConfigRepository systemConfigRepository;
|
||||
private final WbxUserRoleRepository userRoleRepository;
|
||||
|
||||
// ===== 대시보드 =====
|
||||
@GetMapping
|
||||
public String dashboard(Model model) {
|
||||
model.addAttribute("userCount", userRepository.countByIsActiveTrue());
|
||||
model.addAttribute("totalUsers", userRepository.count());
|
||||
model.addAttribute("loginCount", loginHistoryRepository.countByAction("LOGIN_SUCCESS"));
|
||||
model.addAttribute("roleCount", roleRepository.count());
|
||||
return "admin/dashboard";
|
||||
}
|
||||
|
||||
// ===== 사용자 관리 =====
|
||||
@GetMapping("/users")
|
||||
public String userList(Model model) {
|
||||
model.addAttribute("users", userRepository.findAll());
|
||||
return "admin/users";
|
||||
}
|
||||
|
||||
@PostMapping("/users/add")
|
||||
public String addUser(@RequestParam String email,
|
||||
@RequestParam String username,
|
||||
@RequestParam String password,
|
||||
@RequestParam(required = false) String fullName,
|
||||
@RequestParam(required = false) String phone,
|
||||
@RequestParam(required = false) String positionTitle,
|
||||
@RequestParam(required = false) String employeeNumber,
|
||||
@RequestParam(required = false) Boolean isAdmin,
|
||||
RedirectAttributes ra) {
|
||||
if (userRepository.existsByEmail(email)) {
|
||||
ra.addFlashAttribute("error", "이미 존재하는 이메일: " + email);
|
||||
return "redirect:/admin/users";
|
||||
}
|
||||
if (userRepository.existsByUsername(username)) {
|
||||
ra.addFlashAttribute("error", "이미 존재하는 사용자명: " + username);
|
||||
return "redirect:/admin/users";
|
||||
}
|
||||
WbxUser user = WbxUser.builder()
|
||||
.email(email)
|
||||
.username(username)
|
||||
.hashedPassword(passwordEncoder.encode(password))
|
||||
.fullName(fullName)
|
||||
.phone(phone)
|
||||
.positionTitle(positionTitle)
|
||||
.employeeNumber(employeeNumber != null && !employeeNumber.isBlank() ? employeeNumber : null)
|
||||
.isActive(true)
|
||||
.isAdmin(isAdmin != null && isAdmin)
|
||||
.build();
|
||||
userRepository.save(user);
|
||||
|
||||
ra.addFlashAttribute("message", "사용자가 추가되었습니다: " + email);
|
||||
return "redirect:/admin/users/" + user.getId();
|
||||
}
|
||||
|
||||
@GetMapping("/users/{id}")
|
||||
public String userDetail(@PathVariable Long id, Model model) {
|
||||
model.addAttribute("user", userRepository.findById(id).orElse(null));
|
||||
model.addAttribute("loginHistory",
|
||||
loginHistoryRepository.findByUserIdOrderByCreatedAtDesc(id, PageRequest.of(0, 10)));
|
||||
model.addAttribute("allRoles", roleRepository.findAll());
|
||||
model.addAttribute("userRoles", userRoleRepository.findByUserId(id));
|
||||
return "admin/user-detail";
|
||||
}
|
||||
|
||||
@PostMapping("/users/{id}/update")
|
||||
public String updateUser(@PathVariable Long id,
|
||||
@RequestParam String fullName,
|
||||
@RequestParam(required = false) String phone,
|
||||
@RequestParam(required = false) String positionTitle,
|
||||
@RequestParam(required = false) String employeeNumber,
|
||||
@RequestParam(required = false) Boolean isAdmin,
|
||||
RedirectAttributes ra) {
|
||||
userRepository.findById(id).ifPresent(user -> {
|
||||
user.setFullName(fullName);
|
||||
user.setPhone(phone);
|
||||
user.setPositionTitle(positionTitle);
|
||||
user.setEmployeeNumber(employeeNumber != null && !employeeNumber.isBlank() ? employeeNumber : null);
|
||||
user.setAdmin(isAdmin != null && isAdmin);
|
||||
userRepository.save(user);
|
||||
});
|
||||
ra.addFlashAttribute("message", "사용자 정보가 수정되었습니다.");
|
||||
return "redirect:/admin/users/" + id;
|
||||
}
|
||||
|
||||
@PostMapping("/users/{id}/delete")
|
||||
public String deleteUser(@PathVariable Long id, RedirectAttributes ra) {
|
||||
userRepository.findById(id).ifPresent(user -> {
|
||||
userRoleRepository.deleteByUserId(id);
|
||||
userRepository.delete(user);
|
||||
ra.addFlashAttribute("message", "사용자가 삭제되었습니다: " + user.getEmail());
|
||||
});
|
||||
return "redirect:/admin/users";
|
||||
}
|
||||
|
||||
@PostMapping("/users/{id}/unlock")
|
||||
public String unlockUser(@PathVariable Long id, RedirectAttributes ra) {
|
||||
userRepository.findById(id).ifPresent(user -> {
|
||||
user.setFailedLoginAttempts(0);
|
||||
user.setLockedUntil(null);
|
||||
userRepository.save(user);
|
||||
});
|
||||
ra.addFlashAttribute("message", "계정 잠금이 해제되었습니다.");
|
||||
return "redirect:/admin/users/" + id;
|
||||
}
|
||||
|
||||
@PostMapping("/users/{id}/reset-password")
|
||||
public String resetPassword(@PathVariable Long id, RedirectAttributes ra) {
|
||||
userRepository.findById(id).ifPresent(user -> {
|
||||
String temp = "Temp" + System.currentTimeMillis() % 10000 + "!";
|
||||
user.setHashedPassword(passwordEncoder.encode(temp));
|
||||
user.setMustChangePassword(true);
|
||||
userRepository.save(user);
|
||||
ra.addFlashAttribute("message", "임시 비밀번호: " + temp);
|
||||
});
|
||||
return "redirect:/admin/users/" + id;
|
||||
}
|
||||
|
||||
@PostMapping("/users/{id}/toggle-status")
|
||||
public String toggleStatus(@PathVariable Long id, RedirectAttributes ra) {
|
||||
userRepository.findById(id).ifPresent(user -> {
|
||||
user.setActive(!user.isActive());
|
||||
userRepository.save(user);
|
||||
ra.addFlashAttribute("message", user.isActive() ? "계정 활성화" : "계정 비활성화");
|
||||
});
|
||||
return "redirect:/admin/users/" + id;
|
||||
}
|
||||
|
||||
// ===== 사용자 역할 할당 =====
|
||||
@CacheEvict(value = {"permissions", "deptScopes"}, allEntries = true)
|
||||
@PostMapping("/users/{userId}/roles/add")
|
||||
public String addUserRole(@PathVariable Long userId,
|
||||
@RequestParam Long roleId,
|
||||
RedirectAttributes ra) {
|
||||
WbxUserRole ur = WbxUserRole.builder()
|
||||
.userId(userId)
|
||||
.roleId(roleId)
|
||||
.build();
|
||||
userRoleRepository.save(ur);
|
||||
ra.addFlashAttribute("message", "역할이 할당되었습니다.");
|
||||
return "redirect:/admin/users/" + userId;
|
||||
}
|
||||
|
||||
@CacheEvict(value = {"permissions", "deptScopes"}, allEntries = true)
|
||||
@PostMapping("/users/{userId}/roles/{urId}/delete")
|
||||
public String removeUserRole(@PathVariable Long userId,
|
||||
@PathVariable Long urId,
|
||||
RedirectAttributes ra) {
|
||||
userRoleRepository.deleteById(urId);
|
||||
ra.addFlashAttribute("message", "역할이 해제되었습니다.");
|
||||
return "redirect:/admin/users/" + userId;
|
||||
}
|
||||
|
||||
// ===== 역할 관리 =====
|
||||
@GetMapping("/roles")
|
||||
public String roleList(Model model) {
|
||||
model.addAttribute("roles", roleRepository.findAll());
|
||||
return "admin/roles";
|
||||
}
|
||||
|
||||
@PostMapping("/roles/add")
|
||||
public String addRole(@RequestParam String code,
|
||||
@RequestParam String name,
|
||||
@RequestParam(required = false) String description,
|
||||
RedirectAttributes ra) {
|
||||
if (roleRepository.findByCode(code).isPresent()) {
|
||||
ra.addFlashAttribute("error", "이미 존재하는 역할 코드: " + code);
|
||||
return "redirect:/admin/roles";
|
||||
}
|
||||
kr.co.accura.wbx.spring.rbac.WbxRole role = kr.co.accura.wbx.spring.rbac.WbxRole.builder()
|
||||
.code(code.toUpperCase())
|
||||
.name(name)
|
||||
.description(description)
|
||||
.isSystem(false)
|
||||
.build();
|
||||
roleRepository.save(role);
|
||||
ra.addFlashAttribute("message", "역할이 추가되었습니다: " + code);
|
||||
return "redirect:/admin/roles";
|
||||
}
|
||||
|
||||
@GetMapping("/roles/{id}")
|
||||
public String roleDetail(@PathVariable Long id, Model model) {
|
||||
model.addAttribute("role", roleRepository.findById(id).orElse(null));
|
||||
model.addAttribute("permissions", permissionRepository.findByRoleId(id));
|
||||
model.addAttribute("deptScopes", kr.co.accura.wbx.spring.rbac.DeptScope.values());
|
||||
return "admin/role-detail";
|
||||
}
|
||||
|
||||
@PostMapping("/roles/{id}/update")
|
||||
public String updateRole(@PathVariable Long id,
|
||||
@RequestParam String name,
|
||||
@RequestParam(required = false) String description,
|
||||
RedirectAttributes ra) {
|
||||
roleRepository.findById(id).ifPresent(role -> {
|
||||
role.setName(name);
|
||||
role.setDescription(description);
|
||||
roleRepository.save(role);
|
||||
});
|
||||
ra.addFlashAttribute("message", "역할이 수정되었습니다.");
|
||||
return "redirect:/admin/roles/" + id;
|
||||
}
|
||||
|
||||
@PostMapping("/roles/{id}/delete")
|
||||
public String deleteRole(@PathVariable Long id, RedirectAttributes ra) {
|
||||
roleRepository.findById(id).ifPresent(role -> {
|
||||
if (role.isSystem()) {
|
||||
ra.addFlashAttribute("error", "시스템 역할은 삭제할 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
permissionRepository.deleteByRoleId(id);
|
||||
roleRepository.delete(role);
|
||||
ra.addFlashAttribute("message", "역할이 삭제되었습니다: " + role.getCode());
|
||||
});
|
||||
return "redirect:/admin/roles";
|
||||
}
|
||||
|
||||
// ===== 권한 추가/삭제 =====
|
||||
@CacheEvict(value = {"permissions", "deptScopes"}, allEntries = true)
|
||||
@PostMapping("/roles/{roleId}/permissions/add")
|
||||
public String addPermission(@PathVariable Long roleId,
|
||||
@RequestParam String module,
|
||||
@RequestParam String action,
|
||||
@RequestParam String deptScope,
|
||||
RedirectAttributes ra) {
|
||||
RolePermission perm = RolePermission.builder()
|
||||
.roleId(roleId)
|
||||
.module(module.toUpperCase())
|
||||
.action(action.toUpperCase())
|
||||
.deptScope(kr.co.accura.wbx.spring.rbac.DeptScope.valueOf(deptScope))
|
||||
.build();
|
||||
permissionRepository.save(perm);
|
||||
ra.addFlashAttribute("message", "권한이 추가되었습니다: " + module + "." + action);
|
||||
return "redirect:/admin/roles/" + roleId;
|
||||
}
|
||||
|
||||
@CacheEvict(value = {"permissions", "deptScopes"}, allEntries = true)
|
||||
@PostMapping("/permissions/{id}/delete")
|
||||
public String deletePermission(@PathVariable Long id, RedirectAttributes ra) {
|
||||
RolePermission perm = permissionRepository.findById(id).orElse(null);
|
||||
if (perm != null) {
|
||||
Long roleId = perm.getRoleId();
|
||||
permissionRepository.delete(perm);
|
||||
ra.addFlashAttribute("message", "권한이 삭제되었습니다.");
|
||||
return "redirect:/admin/roles/" + roleId;
|
||||
}
|
||||
return "redirect:/admin/permissions";
|
||||
}
|
||||
|
||||
// ===== 로그인 이력 =====
|
||||
@GetMapping("/login-history")
|
||||
public String loginHistory(Model model) {
|
||||
model.addAttribute("logs",
|
||||
loginHistoryRepository.findAllByOrderByCreatedAtDesc(PageRequest.of(0, 50)));
|
||||
return "admin/login-history";
|
||||
}
|
||||
|
||||
// ===== 감사 로그 =====
|
||||
@GetMapping("/audit-logs")
|
||||
public String auditLogs(Model model) {
|
||||
model.addAttribute("logs",
|
||||
auditLogRepository.findAll(PageRequest.of(0, 100, Sort.by(Sort.Direction.DESC, "createdAt"))));
|
||||
return "admin/audit-logs";
|
||||
}
|
||||
|
||||
// ===== 시스템 설정 =====
|
||||
@GetMapping("/config")
|
||||
public String systemConfig(Model model) {
|
||||
model.addAttribute("configs", systemConfigRepository.findAll());
|
||||
return "admin/config";
|
||||
}
|
||||
|
||||
@PostMapping("/config/save")
|
||||
public String saveConfig(@RequestParam String configKey,
|
||||
@RequestParam String configValue,
|
||||
@RequestParam(required = false) String description,
|
||||
RedirectAttributes ra) {
|
||||
WbxSystemConfig config = systemConfigRepository.findByConfigKey(configKey)
|
||||
.orElse(new WbxSystemConfig());
|
||||
config.setConfigKey(configKey);
|
||||
config.setConfigValue(configValue);
|
||||
config.setDescription(description);
|
||||
systemConfigRepository.save(config);
|
||||
ra.addFlashAttribute("message", "설정이 저장되었습니다: " + configKey);
|
||||
return "redirect:/admin/config";
|
||||
}
|
||||
|
||||
// ===== 권한 매트릭스 =====
|
||||
@GetMapping("/permissions")
|
||||
public String permissions(Model model) {
|
||||
model.addAttribute("roles", roleRepository.findAll());
|
||||
model.addAttribute("permissions", permissionRepository.findAll());
|
||||
return "admin/permissions";
|
||||
}
|
||||
|
||||
// ===== 시스템 상태 =====
|
||||
@GetMapping("/system-health")
|
||||
public String systemHealth(Model model) {
|
||||
MemoryMXBean mem = ManagementFactory.getMemoryMXBean();
|
||||
long heapUsed = mem.getHeapMemoryUsage().getUsed() / (1024 * 1024);
|
||||
long heapMax = mem.getHeapMemoryUsage().getMax() / (1024 * 1024);
|
||||
|
||||
Map<String, Object> health = new LinkedHashMap<>();
|
||||
health.put("JVM Heap", heapUsed + " MB / " + heapMax + " MB");
|
||||
health.put("Active Threads", Thread.activeCount());
|
||||
health.put("OS", System.getProperty("os.name") + " " + System.getProperty("os.arch"));
|
||||
health.put("Java", System.getProperty("java.version"));
|
||||
health.put("Spring Boot", org.springframework.boot.SpringBootVersion.getVersion());
|
||||
health.put("Total Users", userRepository.count());
|
||||
health.put("Active Users", userRepository.countByIsActiveTrue());
|
||||
|
||||
model.addAttribute("health", health);
|
||||
return "admin/system-health";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package kr.co.accura.wbx.spring.admin;
|
||||
|
||||
import kr.co.accura.wbx.spring.rbac.WbxRole;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface WbxRoleRepository extends JpaRepository<WbxRole, Long> {
|
||||
Optional<WbxRole> findByCode(String code);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package kr.co.accura.wbx.spring.admin;
|
||||
|
||||
import kr.co.accura.wbx.spring.config.WbxSystemConfig;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface WbxSystemConfigRepository extends JpaRepository<WbxSystemConfig, Long> {
|
||||
Optional<WbxSystemConfig> findByConfigKey(String configKey);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package kr.co.accura.wbx.spring.approval;
|
||||
|
||||
public record ActionRequest(String comment) {}
|
||||
@@ -0,0 +1,11 @@
|
||||
package kr.co.accura.wbx.spring.approval;
|
||||
|
||||
/**
|
||||
* 결재 완료 이벤트 — @EventListener로 후속 처리
|
||||
*/
|
||||
public record ApprovalCompletedEvent(
|
||||
String approvalType,
|
||||
Long itemId,
|
||||
Long approverId,
|
||||
Object approval
|
||||
) {}
|
||||
@@ -0,0 +1,29 @@
|
||||
package kr.co.accura.wbx.spring.approval;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
/**
|
||||
* 결재 유형별 핸들러 인터페이스
|
||||
* @Component로 등록하면 ApprovalHandlerRegistry가 자동 수집
|
||||
*/
|
||||
public interface ApprovalHandler {
|
||||
|
||||
/** 유형 키 (URL path에 사용) — e.g. "timesheet", "project" */
|
||||
String getTypeKey();
|
||||
|
||||
/** 표시명 — e.g. "시수 결재" */
|
||||
String getTypeDisplay();
|
||||
|
||||
/** 승인 처리 */
|
||||
ApprovalResult approve(Long approvalLineId, Long approverId, String comment);
|
||||
|
||||
/** 반려 처리 */
|
||||
ApprovalResult reject(Long approvalLineId, Long approverId, String comment);
|
||||
|
||||
/** 결재 이력 조회 (ApprovalLine.vue 호환) */
|
||||
ApprovalHistoryDto getApprovalHistory(Long itemId);
|
||||
|
||||
/** 결재 대기 목록 */
|
||||
Page<ApprovalPendingDto> getPending(Long approverId, Pageable pageable);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package kr.co.accura.wbx.spring.approval;
|
||||
|
||||
import kr.co.accura.wbx.spring.common.BusinessException;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 결재 핸들러 레지스트리 — Spring이 모든 ApprovalHandler 구현체를 자동 수집
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class ApprovalHandlerRegistry {
|
||||
|
||||
private final Map<String, ApprovalHandler> handlers;
|
||||
|
||||
@Autowired
|
||||
public ApprovalHandlerRegistry(List<ApprovalHandler> handlerList) {
|
||||
this.handlers = handlerList.stream()
|
||||
.collect(Collectors.toMap(ApprovalHandler::getTypeKey, Function.identity()));
|
||||
log.info("Registered approval handlers: {}", handlers.keySet());
|
||||
}
|
||||
|
||||
public ApprovalHandler get(String typeKey) {
|
||||
return Optional.ofNullable(handlers.get(typeKey))
|
||||
.orElseThrow(() -> new BusinessException("Unknown approval type: " + typeKey));
|
||||
}
|
||||
|
||||
public Collection<ApprovalHandler> getAll() {
|
||||
return handlers.values();
|
||||
}
|
||||
|
||||
public boolean hasHandler(String typeKey) {
|
||||
return handlers.containsKey(typeKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package kr.co.accura.wbx.spring.approval;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public record ApprovalHistoryDto(
|
||||
Long itemId,
|
||||
String approvalType,
|
||||
String status,
|
||||
String authorName,
|
||||
LocalDateTime submittedAt,
|
||||
List<ApprovalLineDto> approvalLines
|
||||
) {}
|
||||
@@ -0,0 +1,9 @@
|
||||
package kr.co.accura.wbx.spring.approval;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record ApprovalLineDto(
|
||||
Long id, Long approverId, String approverName,
|
||||
int approvalOrder, String roleCode, String status,
|
||||
String comment, LocalDateTime actedAt
|
||||
) {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package kr.co.accura.wbx.spring.approval;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record ApprovalPendingDto(
|
||||
Long id, String approvalType, String title,
|
||||
String requesterName, LocalDateTime submittedAt
|
||||
) {}
|
||||
@@ -0,0 +1,16 @@
|
||||
package kr.co.accura.wbx.spring.approval;
|
||||
|
||||
public record ApprovalResult(boolean success, String message, Object data) {
|
||||
|
||||
public static ApprovalResult success(String message) {
|
||||
return new ApprovalResult(true, message, null);
|
||||
}
|
||||
|
||||
public static ApprovalResult success(String message, Object data) {
|
||||
return new ApprovalResult(true, message, data);
|
||||
}
|
||||
|
||||
public static ApprovalResult failure(String message) {
|
||||
return new ApprovalResult(false, message, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package kr.co.accura.wbx.spring.approval;
|
||||
|
||||
import kr.co.accura.wbx.spring.common.SecurityUtils;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 통합 결재 API — WBX 패턴 이식
|
||||
* POST /api/approvals/unified/action/{type}/{id}/approve
|
||||
* POST /api/approvals/unified/action/{type}/{id}/reject
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("${wbx.spring.api-prefix:/api}/approvals/unified")
|
||||
@RequiredArgsConstructor
|
||||
public class UnifiedApprovalController {
|
||||
|
||||
private final ApprovalHandlerRegistry registry;
|
||||
|
||||
@PostMapping("/action/{type}/{id}/approve")
|
||||
public ApprovalResult approve(@PathVariable String type,
|
||||
@PathVariable Long id,
|
||||
@RequestBody(required = false) ActionRequest req) {
|
||||
return registry.get(type)
|
||||
.approve(id, SecurityUtils.getCurrentUserId(),
|
||||
req != null ? req.comment() : null);
|
||||
}
|
||||
|
||||
@PostMapping("/action/{type}/{id}/reject")
|
||||
public ApprovalResult reject(@PathVariable String type,
|
||||
@PathVariable Long id,
|
||||
@RequestBody ActionRequest req) {
|
||||
return registry.get(type)
|
||||
.reject(id, SecurityUtils.getCurrentUserId(), req.comment());
|
||||
}
|
||||
|
||||
@GetMapping("/pending")
|
||||
public Map<String, Object> pending(@RequestParam(defaultValue = "0") int skip,
|
||||
@RequestParam(defaultValue = "20") int limit) {
|
||||
Long userId = SecurityUtils.getCurrentUserId();
|
||||
List<ApprovalPendingDto> all = registry.getAll().stream()
|
||||
.flatMap(h -> h.getPending(userId, PageRequest.of(0, 100)).getContent().stream())
|
||||
.sorted(Comparator.comparing(ApprovalPendingDto::submittedAt).reversed())
|
||||
.toList();
|
||||
|
||||
int end = Math.min(skip + limit, all.size());
|
||||
return Map.of(
|
||||
"items", skip < all.size() ? all.subList(skip, end) : List.of(),
|
||||
"total", all.size()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package kr.co.accura.wbx.spring.audit;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface AuditLogRepository extends JpaRepository<WbxAuditLog, Long> {
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package kr.co.accura.wbx.spring.audit;
|
||||
|
||||
import kr.co.accura.wbx.spring.common.SecurityUtils;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuditLogService {
|
||||
|
||||
private final AuditLogRepository repository;
|
||||
|
||||
public void log(String action, String resource, Long resourceId, String detail) {
|
||||
repository.save(WbxAuditLog.builder()
|
||||
.userId(SecurityUtils.getCurrentUserId())
|
||||
.username(SecurityUtils.getCurrentUsername())
|
||||
.action(action)
|
||||
.resource(resource)
|
||||
.resourceId(resourceId)
|
||||
.detail(detail)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package kr.co.accura.wbx.spring.audit;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "wbx_audit_logs")
|
||||
@Getter @Setter
|
||||
@NoArgsConstructor @AllArgsConstructor @Builder
|
||||
public class WbxAuditLog {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
private Long userId;
|
||||
private String username;
|
||||
|
||||
@Column(nullable = false, length = 50)
|
||||
private String action;
|
||||
|
||||
@Column(nullable = false, length = 100)
|
||||
private String resource;
|
||||
|
||||
private Long resourceId;
|
||||
|
||||
@Column(length = 4000)
|
||||
private String detail;
|
||||
|
||||
@Column(length = 50)
|
||||
private String ipAddress;
|
||||
|
||||
@Column(updatable = false)
|
||||
@Builder.Default
|
||||
private LocalDateTime createdAt = LocalDateTime.now();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package kr.co.accura.wbx.spring.auth;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* API Key 인증 — 서버-서버(SAP BTP, ERP 등) 연동용
|
||||
* Header: X-API-Key: {key}
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class ApiKeyFilter extends OncePerRequestFilter {
|
||||
|
||||
@Value("${wbx.spring.api-keys:}")
|
||||
private List<String> validApiKeys;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain chain) throws ServletException, IOException {
|
||||
String apiKey = request.getHeader("X-API-Key");
|
||||
if (apiKey != null && !apiKey.isBlank() && validApiKeys.contains(apiKey)) {
|
||||
var auth = new UsernamePasswordAuthenticationToken(
|
||||
"system", null,
|
||||
List.of(new SimpleGrantedAuthority("ROLE_SYSTEM"),
|
||||
new SimpleGrantedAuthority("ROLE_SA")));
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
log.debug("API Key authenticated: {}", apiKey.substring(0, Math.min(8, apiKey.length())) + "...");
|
||||
}
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
return request.getHeader("X-API-Key") == null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package kr.co.accura.wbx.spring.auth;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import kr.co.accura.wbx.spring.common.BusinessException;
|
||||
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("${wbx.spring.api-prefix:/api}/auth")
|
||||
@RequiredArgsConstructor
|
||||
public class AuthController {
|
||||
|
||||
private final WbxUserRepository userRepository;
|
||||
private final JwtProvider jwtProvider;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final WbxSpringProperties props;
|
||||
private final RefreshTokenService refreshTokenService;
|
||||
private final LoginHistoryRepository loginHistoryRepository;
|
||||
private final PasswordPolicy passwordPolicy;
|
||||
|
||||
/** MFA 서비스 — wbx.spring.mfa.enabled=true 일 때만 주입 */
|
||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||
private MfaService mfaService;
|
||||
|
||||
|
||||
/**
|
||||
* 로그인 — WBX FastAPI 호환 JWT 발급
|
||||
*/
|
||||
@PostMapping("/login")
|
||||
public Map<String, Object> login(@Valid @RequestBody LoginRequest req, HttpServletRequest httpReq) {
|
||||
WbxUser user = userRepository.findByEmail(req.email())
|
||||
.orElseThrow(() -> new BusinessException("Invalid email or password"));
|
||||
|
||||
// 계정 잠금 체크
|
||||
if (user.isLocked()) {
|
||||
throw new BusinessException("Account locked. Try again later.", "ACCOUNT_LOCKED");
|
||||
}
|
||||
|
||||
// 비밀번호 검증
|
||||
if (!passwordEncoder.matches(req.password(), user.getHashedPassword())) {
|
||||
user.recordLoginFailure(
|
||||
props.getPassword().getMaxFailedAttempts(),
|
||||
15 // lockout minutes
|
||||
);
|
||||
userRepository.save(user);
|
||||
throw new BusinessException("Invalid email or password");
|
||||
}
|
||||
|
||||
if (!user.isActive()) {
|
||||
throw new BusinessException("User account is disabled");
|
||||
}
|
||||
|
||||
// MFA 체크 — MFA 활성 사용자는 2단계 인증 필요
|
||||
if (mfaService != null && mfaService.isMfaRequired(user)) {
|
||||
Map<String, Object> mfaResponse = new HashMap<>();
|
||||
mfaResponse.put("mfa_required", true);
|
||||
mfaResponse.put("user_id", user.getId());
|
||||
mfaResponse.put("mfa_enabled", user.isMfaEnabled());
|
||||
return mfaResponse;
|
||||
}
|
||||
|
||||
return completeLogin(user, httpReq, "PASSWORD");
|
||||
}
|
||||
|
||||
/**
|
||||
* MFA 로그인 2단계 — TOTP 코드 검증 후 JWT 발급
|
||||
*/
|
||||
@PostMapping("/mfa/verify")
|
||||
public Map<String, Object> mfaVerify(@Valid @RequestBody MfaVerifyRequest req, HttpServletRequest httpReq) {
|
||||
if (mfaService == null) {
|
||||
throw new BusinessException("MFA is not enabled");
|
||||
}
|
||||
WbxUser user = userRepository.findById(req.userId())
|
||||
.orElseThrow(() -> new BusinessException("User not found"));
|
||||
|
||||
boolean valid = mfaService.verifyLogin(user.getId(), req.code());
|
||||
if (!valid) {
|
||||
throw new BusinessException("Invalid MFA code", "MFA_INVALID_CODE");
|
||||
}
|
||||
return completeLogin(user, httpReq, "MFA_TOTP");
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 완료 — JWT + Refresh Token 발급
|
||||
*/
|
||||
private Map<String, Object> completeLogin(WbxUser user, HttpServletRequest httpReq, String authMethod) {
|
||||
user.recordLoginSuccess();
|
||||
userRepository.save(user);
|
||||
|
||||
String token = jwtProvider.generateToken(user.toUserDetails());
|
||||
String ip = httpReq.getRemoteAddr();
|
||||
String ua = httpReq.getHeader("User-Agent");
|
||||
|
||||
String refreshToken = refreshTokenService.create(user.getId(), ua, ip);
|
||||
|
||||
loginHistoryRepository.save(WbxLoginHistory.builder()
|
||||
.userId(user.getId()).email(user.getEmail())
|
||||
.action("LOGIN_SUCCESS").authMethod(authMethod)
|
||||
.ipAddress(ip).userAgent(ua != null ? ua.substring(0, Math.min(ua.length(), 500)) : null)
|
||||
.build());
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("access_token", token);
|
||||
result.put("refresh_token", refreshToken);
|
||||
result.put("token_type", "bearer");
|
||||
result.put("user", Map.of(
|
||||
"id", user.getId(),
|
||||
"email", user.getEmail(),
|
||||
"username", user.getUsername(),
|
||||
"full_name", user.getFullName() != null ? user.getFullName() : "",
|
||||
"is_admin", user.isAdmin(),
|
||||
"department_id", user.getDepartmentId() != null ? user.getDepartmentId() : 0
|
||||
));
|
||||
result.put("must_change_password", user.isMustChangePassword());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입 (SA 전용 또는 초기 설정용)
|
||||
*/
|
||||
@PostMapping("/register")
|
||||
public Map<String, Object> register(@Valid @RequestBody RegisterRequest req) {
|
||||
if (userRepository.existsByEmail(req.email())) {
|
||||
throw new BusinessException("Email already exists");
|
||||
}
|
||||
if (userRepository.existsByUsername(req.username())) {
|
||||
throw new BusinessException("Username already exists");
|
||||
}
|
||||
|
||||
passwordPolicy.validate(req.password());
|
||||
|
||||
WbxUser user = WbxUser.builder()
|
||||
.email(req.email())
|
||||
.username(req.username())
|
||||
.hashedPassword(passwordEncoder.encode(req.password()))
|
||||
.fullName(req.fullName())
|
||||
.isActive(true)
|
||||
.isAdmin(req.isAdmin() != null && req.isAdmin())
|
||||
.build();
|
||||
|
||||
userRepository.save(user);
|
||||
log.info("User registered: {}", user.getEmail());
|
||||
|
||||
return Map.of("detail", "User registered successfully", "id", user.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 내 정보 조회
|
||||
*/
|
||||
@GetMapping("/me")
|
||||
public Map<String, Object> me(@AuthenticationPrincipal WbxUserDetails user) {
|
||||
return Map.of(
|
||||
"id", user.getId(),
|
||||
"email", user.getEmail(),
|
||||
"username", user.getUsername(),
|
||||
"full_name", user.getFullName(),
|
||||
"is_admin", user.isAdmin(),
|
||||
"department_id", user.getDepartmentId() != null ? user.getDepartmentId() : 0,
|
||||
"roles", user.getRoles()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 변경
|
||||
*/
|
||||
@PutMapping("/password/change")
|
||||
public Map<String, String> changePassword(@AuthenticationPrincipal WbxUserDetails userDetails,
|
||||
@Valid @RequestBody PasswordChangeRequest req) {
|
||||
WbxUser user = userRepository.findById(userDetails.getId())
|
||||
.orElseThrow(() -> new BusinessException("User not found"));
|
||||
|
||||
if (!passwordEncoder.matches(req.currentPassword(), user.getHashedPassword())) {
|
||||
throw new BusinessException("Current password is incorrect");
|
||||
}
|
||||
|
||||
passwordPolicy.validate(req.newPassword());
|
||||
|
||||
user.setHashedPassword(passwordEncoder.encode(req.newPassword()));
|
||||
user.setMustChangePassword(false);
|
||||
userRepository.save(user);
|
||||
|
||||
return Map.of("detail", "Password changed successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
* Access Token 갱신
|
||||
*/
|
||||
@PostMapping("/refresh")
|
||||
public Map<String, Object> refresh(@RequestBody Map<String, String> req) {
|
||||
String refreshToken = req.get("refresh_token");
|
||||
if (refreshToken == null || refreshToken.isBlank()) {
|
||||
throw new BusinessException("refresh_token is required");
|
||||
}
|
||||
String newToken = refreshTokenService.refresh(refreshToken);
|
||||
return Map.of("access_token", newToken, "token_type", "bearer");
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃
|
||||
*/
|
||||
@PostMapping("/logout")
|
||||
public Map<String, String> logout(@AuthenticationPrincipal WbxUserDetails user,
|
||||
@RequestBody(required = false) Map<String, String> req) {
|
||||
if (req != null && req.get("refresh_token") != null) {
|
||||
refreshTokenService.revoke(req.get("refresh_token"));
|
||||
}
|
||||
if (user != null) {
|
||||
loginHistoryRepository.save(WbxLoginHistory.builder()
|
||||
.userId(user.getId()).email(user.getEmail())
|
||||
.action("LOGOUT").authMethod("TOKEN")
|
||||
.build());
|
||||
}
|
||||
return Map.of("detail", "Logged out successfully");
|
||||
}
|
||||
|
||||
// ===== Request DTOs =====
|
||||
|
||||
public record LoginRequest(
|
||||
@NotBlank @Email String email,
|
||||
@NotBlank String password
|
||||
) {}
|
||||
|
||||
public record RegisterRequest(
|
||||
@NotBlank @Email String email,
|
||||
@NotBlank String username,
|
||||
@NotBlank String password,
|
||||
String fullName,
|
||||
Boolean isAdmin
|
||||
) {}
|
||||
|
||||
public record PasswordChangeRequest(
|
||||
@NotBlank String currentPassword,
|
||||
@NotBlank String newPassword
|
||||
) {}
|
||||
|
||||
public record MfaVerifyRequest(
|
||||
Long userId,
|
||||
@NotBlank String code
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package kr.co.accura.wbx.spring.auth;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class JwtFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtProvider jwtProvider;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain chain) throws ServletException, IOException {
|
||||
String header = request.getHeader("Authorization");
|
||||
if (header != null && header.startsWith("Bearer ")) {
|
||||
String token = header.substring(7);
|
||||
try {
|
||||
Claims claims = jwtProvider.parseToken(token);
|
||||
|
||||
// MFA 토큰은 인증 불가
|
||||
if (jwtProvider.isMfaToken(claims)) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
Long userId = claims.get("user_id", Long.class);
|
||||
String email = claims.get("email", String.class);
|
||||
String username = claims.get("username", String.class);
|
||||
String fullName = claims.get("full_name", String.class);
|
||||
Boolean isAdmin = claims.get("is_admin", Boolean.class);
|
||||
Long deptId = claims.get("department_id", Long.class);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> roles = claims.get("roles", List.class);
|
||||
|
||||
WbxUserDetails user = WbxUserDetails.builder()
|
||||
.id(userId)
|
||||
.email(email != null ? email : "")
|
||||
.username(username != null ? username : "")
|
||||
.fullName(fullName != null ? fullName : "")
|
||||
.isAdmin(Boolean.TRUE.equals(isAdmin))
|
||||
.departmentId(deptId)
|
||||
.roles(roles != null ? roles : List.of())
|
||||
.build();
|
||||
|
||||
var auth = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
} catch (Exception e) {
|
||||
log.debug("JWT validation failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
String path = request.getRequestURI();
|
||||
return path.contains("/auth/login") || path.contains("/auth/sso")
|
||||
|| path.equals("/health") || path.startsWith("/actuator");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package kr.co.accura.wbx.spring.auth;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class JwtProvider {
|
||||
|
||||
private final WbxSpringProperties props;
|
||||
|
||||
/**
|
||||
* WBX FastAPI 호환 JWT 생성
|
||||
* 필수 claims: sub(email), user_id — WBX가 이 두 필드로 사용자 식별
|
||||
*/
|
||||
public String generateToken(WbxUserDetails user) {
|
||||
return Jwts.builder()
|
||||
.claims(Map.of(
|
||||
"sub", user.getEmail(),
|
||||
"user_id", user.getId(),
|
||||
"username", user.getUsername(),
|
||||
"full_name", user.getFullName(),
|
||||
"email", user.getEmail(),
|
||||
"is_admin", user.isAdmin(),
|
||||
"department_id", user.getDepartmentId() != null ? user.getDepartmentId() : 0,
|
||||
"roles", user.getRoles() != null ? user.getRoles() : List.of()
|
||||
))
|
||||
.issuedAt(new Date())
|
||||
.expiration(new Date(System.currentTimeMillis() + props.getJwt().getExpiration() * 1000))
|
||||
.signWith(getSigningKey())
|
||||
.compact();
|
||||
}
|
||||
|
||||
/**
|
||||
* MFA 임시 토큰 (5분, API 접근 불가)
|
||||
*/
|
||||
public String generateMfaToken(WbxUserDetails user) {
|
||||
return Jwts.builder()
|
||||
.claims(Map.of(
|
||||
"user_id", user.getId(),
|
||||
"mfa_pending", true
|
||||
))
|
||||
.issuedAt(new Date())
|
||||
.expiration(new Date(System.currentTimeMillis() + 300_000)) // 5 minutes
|
||||
.signWith(getSigningKey())
|
||||
.compact();
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 검증 후 claims 추출
|
||||
*/
|
||||
public Claims parseToken(String token) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(getSigningKey())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
}
|
||||
|
||||
/**
|
||||
* MFA 토큰인지 확인
|
||||
*/
|
||||
public boolean isMfaToken(Claims claims) {
|
||||
return Boolean.TRUE.equals(claims.get("mfa_pending", Boolean.class));
|
||||
}
|
||||
|
||||
private SecretKey getSigningKey() {
|
||||
byte[] keyBytes = props.getJwt().getSecret().getBytes(StandardCharsets.UTF_8);
|
||||
return Keys.hmacShaKeyFor(keyBytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package kr.co.accura.wbx.spring.auth;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface LoginHistoryRepository extends JpaRepository<WbxLoginHistory, Long> {
|
||||
|
||||
Page<WbxLoginHistory> findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable);
|
||||
|
||||
Page<WbxLoginHistory> findAllByOrderByCreatedAtDesc(Pageable pageable);
|
||||
|
||||
long countByAction(String action);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package kr.co.accura.wbx.spring.auth;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* MFA/TOTP REST API
|
||||
* <p>
|
||||
* wbx.spring.mfa.enabled=true 일 때 활성화.
|
||||
* Google Authenticator / MS Authenticator 호환 TOTP.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("${wbx.spring.api-prefix:/api}/auth/mfa")
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnProperty(name = "wbx.spring.mfa.enabled", havingValue = "true")
|
||||
public class MfaController {
|
||||
|
||||
private final MfaService mfaService;
|
||||
|
||||
/**
|
||||
* MFA 설정 시작 — QR 코드용 시크릿 + otpauth URI 반환
|
||||
*/
|
||||
@PostMapping("/setup")
|
||||
public Map<String, String> setup(@AuthenticationPrincipal WbxUserDetails user) {
|
||||
return mfaService.setupMfa(user.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* MFA 설정 완료 — TOTP 코드 검증 후 활성화 + 백업 코드 발급
|
||||
*/
|
||||
@PostMapping("/setup/verify")
|
||||
public Map<String, Object> verifySetup(@AuthenticationPrincipal WbxUserDetails user,
|
||||
@Valid @RequestBody CodeRequest req) {
|
||||
return mfaService.verifySetup(user.getId(), req.code());
|
||||
}
|
||||
|
||||
/**
|
||||
* MFA 비활성화 (본인 또는 SA)
|
||||
*/
|
||||
@DeleteMapping
|
||||
public Map<String, String> disable(@AuthenticationPrincipal WbxUserDetails user) {
|
||||
mfaService.disableMfa(user.getId());
|
||||
return Map.of("detail", "MFA disabled");
|
||||
}
|
||||
|
||||
/**
|
||||
* 백업 코드로 인증 (로그인 2단계 대안)
|
||||
*/
|
||||
@PostMapping("/backup-verify")
|
||||
public Map<String, Object> backupVerify(@RequestBody Map<String, Object> req) {
|
||||
Long userId = ((Number) req.get("user_id")).longValue();
|
||||
String code = (String) req.get("backup_code");
|
||||
boolean valid = mfaService.verifyBackupCode(userId, code);
|
||||
return Map.of("valid", valid);
|
||||
}
|
||||
|
||||
public record CodeRequest(@NotBlank String code) {}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package kr.co.accura.wbx.spring.auth;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import kr.co.accura.wbx.spring.common.BusinessException;
|
||||
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
/**
|
||||
* MFA/TOTP 서비스 — 표준 구현
|
||||
* <p>
|
||||
* wbx.spring.mfa.enabled=true 일 때 활성화.
|
||||
* TOTP 라이브러리 의존성이 없어도 동작하도록
|
||||
* HMAC-SHA1 기반 RFC 6238 직접 구현.
|
||||
* <p>
|
||||
* 고객 환경에 따라 Google Authenticator / MS Authenticator 호환.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnProperty(name = "wbx.spring.mfa.enabled", havingValue = "true")
|
||||
public class MfaService {
|
||||
|
||||
private final TotpSecretRepository totpRepo;
|
||||
private final WbxUserRepository userRepo;
|
||||
private final WbxSpringProperties props;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
private static final String AES_ALGORITHM = "AES";
|
||||
|
||||
// ===== TOTP Setup =====
|
||||
|
||||
/**
|
||||
* MFA 설정 시작 — QR 코드용 시크릿 + otpauth URI 반환
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, String> setupMfa(Long userId) {
|
||||
WbxUser user = userRepo.findById(userId)
|
||||
.orElseThrow(() -> new BusinessException("User not found"));
|
||||
|
||||
// 이미 설정된 경우 기존 삭제 후 재설정
|
||||
totpRepo.findByUserId(userId).ifPresent(existing -> totpRepo.delete(existing));
|
||||
|
||||
String secret = generateSecret();
|
||||
String encrypted = encrypt(secret);
|
||||
|
||||
WbxTotpSecret totp = WbxTotpSecret.builder()
|
||||
.userId(userId)
|
||||
.encryptedSecret(encrypted)
|
||||
.verified(false)
|
||||
.build();
|
||||
totpRepo.save(totp);
|
||||
|
||||
String issuer = props.getMfa().getTotpIssuer();
|
||||
String otpauthUri = String.format("otpauth://totp/%s:%s?secret=%s&issuer=%s&digits=%d&period=%d",
|
||||
issuer, user.getEmail(), secret, issuer,
|
||||
props.getMfa().getTotpDigits(), props.getMfa().getTotpPeriod());
|
||||
|
||||
return Map.of(
|
||||
"secret", secret,
|
||||
"otpauth_uri", otpauthUri,
|
||||
"qr_data", otpauthUri // 프론트엔드에서 QR 렌더링
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* MFA 설정 확인 — 코드 검증 후 활성화 + 백업 코드 발급
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> verifySetup(Long userId, String code) {
|
||||
WbxTotpSecret totp = totpRepo.findByUserId(userId)
|
||||
.orElseThrow(() -> new BusinessException("MFA not initialized. Call setup first."));
|
||||
|
||||
String secret = decrypt(totp.getEncryptedSecret());
|
||||
if (!verifyCode(secret, code)) {
|
||||
throw new BusinessException("Invalid TOTP code", "MFA_INVALID_CODE");
|
||||
}
|
||||
|
||||
// 백업 코드 생성
|
||||
List<String> backupCodes = generateBackupCodes(8);
|
||||
List<String> hashedCodes = backupCodes.stream()
|
||||
.map(passwordEncoder::encode)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
try {
|
||||
totp.setBackupCodes(objectMapper.writeValueAsString(hashedCodes));
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("Failed to save backup codes");
|
||||
}
|
||||
totp.setVerified(true);
|
||||
totpRepo.save(totp);
|
||||
|
||||
// 사용자 mfaEnabled 활성화
|
||||
WbxUser user = userRepo.findById(userId).orElseThrow();
|
||||
user.setMfaEnabled(true);
|
||||
userRepo.save(user);
|
||||
|
||||
log.info("MFA enabled for user: {}", user.getEmail());
|
||||
return Map.of(
|
||||
"detail", "MFA enabled successfully",
|
||||
"backup_codes", backupCodes // 한 번만 표시 — 프론트에서 다운로드 안내
|
||||
);
|
||||
}
|
||||
|
||||
// ===== TOTP 검증 (로그인 2단계) =====
|
||||
|
||||
/**
|
||||
* 로그인 시 TOTP 코드 검증
|
||||
*/
|
||||
public boolean verifyLogin(Long userId, String code) {
|
||||
WbxTotpSecret totp = totpRepo.findByUserId(userId)
|
||||
.orElseThrow(() -> new BusinessException("MFA not configured"));
|
||||
|
||||
if (!totp.isVerified()) {
|
||||
throw new BusinessException("MFA setup not completed");
|
||||
}
|
||||
|
||||
String secret = decrypt(totp.getEncryptedSecret());
|
||||
return verifyCode(secret, code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 백업 코드로 로그인 (1회용)
|
||||
*/
|
||||
@Transactional
|
||||
public boolean verifyBackupCode(Long userId, String backupCode) {
|
||||
WbxTotpSecret totp = totpRepo.findByUserId(userId)
|
||||
.orElseThrow(() -> new BusinessException("MFA not configured"));
|
||||
|
||||
try {
|
||||
List<String> hashedCodes = objectMapper.readValue(
|
||||
totp.getBackupCodes(), new TypeReference<List<String>>() {});
|
||||
|
||||
for (int i = 0; i < hashedCodes.size(); i++) {
|
||||
if (passwordEncoder.matches(backupCode, hashedCodes.get(i))) {
|
||||
// 사용된 코드 제거
|
||||
hashedCodes.remove(i);
|
||||
totp.setBackupCodes(objectMapper.writeValueAsString(hashedCodes));
|
||||
totpRepo.save(totp);
|
||||
log.info("Backup code used for user ID: {}", userId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to verify backup code", e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ===== MFA 비활성화 (SA 전용) =====
|
||||
|
||||
@Transactional
|
||||
public void disableMfa(Long userId) {
|
||||
totpRepo.deleteByUserId(userId);
|
||||
userRepo.findById(userId).ifPresent(user -> {
|
||||
user.setMfaEnabled(false);
|
||||
userRepo.save(user);
|
||||
});
|
||||
log.info("MFA disabled for user ID: {}", userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자의 MFA 필수 여부 판별
|
||||
*/
|
||||
public boolean isMfaRequired(WbxUser user) {
|
||||
if (!props.getMfa().isEnabled()) return false;
|
||||
if (user.isMfaEnabled()) return true;
|
||||
// 외부 사용자 강제 MFA
|
||||
if (props.getMfa().isForceForExternal() && user.getSsoProvider() == null) return true;
|
||||
// 내부 사용자 강제 MFA
|
||||
return props.getMfa().isForceForInternal();
|
||||
}
|
||||
|
||||
// ===== RFC 6238 TOTP 구현 =====
|
||||
|
||||
private boolean verifyCode(String secret, String code) {
|
||||
int digits = props.getMfa().getTotpDigits();
|
||||
int period = props.getMfa().getTotpPeriod();
|
||||
long timeStep = System.currentTimeMillis() / 1000 / period;
|
||||
|
||||
// 현재 ± 1 윈도우 허용 (시간 오차 대응)
|
||||
for (int i = -1; i <= 1; i++) {
|
||||
String generated = generateTotpCode(secret, timeStep + i, digits);
|
||||
if (generated.equals(code)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private String generateTotpCode(String base32Secret, long timeStep, int digits) {
|
||||
try {
|
||||
byte[] key = base32Decode(base32Secret);
|
||||
byte[] data = new byte[8];
|
||||
for (int i = 7; i >= 0; i--) {
|
||||
data[i] = (byte) (timeStep & 0xFF);
|
||||
timeStep >>= 8;
|
||||
}
|
||||
|
||||
javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA1");
|
||||
mac.init(new javax.crypto.spec.SecretKeySpec(key, "HmacSHA1"));
|
||||
byte[] hash = mac.doFinal(data);
|
||||
|
||||
int offset = hash[hash.length - 1] & 0x0F;
|
||||
int binary = ((hash[offset] & 0x7F) << 24)
|
||||
| ((hash[offset + 1] & 0xFF) << 16)
|
||||
| ((hash[offset + 2] & 0xFF) << 8)
|
||||
| (hash[offset + 3] & 0xFF);
|
||||
|
||||
int otp = binary % (int) Math.pow(10, digits);
|
||||
return String.format("%0" + digits + "d", otp);
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("TOTP generation failed");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Helper Methods =====
|
||||
|
||||
private String generateSecret() {
|
||||
byte[] bytes = new byte[20]; // 160 bits
|
||||
RANDOM.nextBytes(bytes);
|
||||
return base32Encode(bytes);
|
||||
}
|
||||
|
||||
private List<String> generateBackupCodes(int count) {
|
||||
return IntStream.range(0, count)
|
||||
.mapToObj(i -> {
|
||||
int code = 10000000 + RANDOM.nextInt(90000000); // 8자리 숫자
|
||||
return String.valueOf(code);
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* AES-256 암호화 (JWT secret 앞 32바이트를 키로 사용)
|
||||
*/
|
||||
private String encrypt(String plainText) {
|
||||
try {
|
||||
byte[] key = getAesKey();
|
||||
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, AES_ALGORITHM));
|
||||
return Base64.getEncoder().encodeToString(cipher.doFinal(plainText.getBytes()));
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("Encryption failed");
|
||||
}
|
||||
}
|
||||
|
||||
private String decrypt(String cipherText) {
|
||||
try {
|
||||
byte[] key = getAesKey();
|
||||
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, AES_ALGORITHM));
|
||||
return new String(cipher.doFinal(Base64.getDecoder().decode(cipherText)));
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("Decryption failed");
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] getAesKey() {
|
||||
String secret = props.getJwt().getSecret();
|
||||
byte[] keyBytes = secret.getBytes();
|
||||
return Arrays.copyOf(keyBytes, 32); // AES-256 = 32 bytes
|
||||
}
|
||||
|
||||
// Base32 encode/decode (RFC 4648)
|
||||
private static final String BASE32_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
|
||||
private String base32Encode(byte[] data) {
|
||||
StringBuilder result = new StringBuilder();
|
||||
int buffer = 0, bitsLeft = 0;
|
||||
for (byte b : data) {
|
||||
buffer = (buffer << 8) | (b & 0xFF);
|
||||
bitsLeft += 8;
|
||||
while (bitsLeft >= 5) {
|
||||
result.append(BASE32_CHARS.charAt((buffer >> (bitsLeft - 5)) & 0x1F));
|
||||
bitsLeft -= 5;
|
||||
}
|
||||
}
|
||||
if (bitsLeft > 0) {
|
||||
result.append(BASE32_CHARS.charAt((buffer << (5 - bitsLeft)) & 0x1F));
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private byte[] base32Decode(String base32) {
|
||||
String upper = base32.toUpperCase().replaceAll("[^A-Z2-7]", "");
|
||||
byte[] result = new byte[upper.length() * 5 / 8];
|
||||
int buffer = 0, bitsLeft = 0, index = 0;
|
||||
for (char c : upper.toCharArray()) {
|
||||
buffer = (buffer << 5) | BASE32_CHARS.indexOf(c);
|
||||
bitsLeft += 5;
|
||||
if (bitsLeft >= 8) {
|
||||
result[index++] = (byte) (buffer >> (bitsLeft - 8));
|
||||
bitsLeft -= 8;
|
||||
}
|
||||
}
|
||||
return Arrays.copyOf(result, index);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package kr.co.accura.wbx.spring.auth;
|
||||
|
||||
import kr.co.accura.wbx.spring.common.BusinessException;
|
||||
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PasswordPolicy {
|
||||
|
||||
private final WbxSpringProperties props;
|
||||
|
||||
public void validate(String password) {
|
||||
var cfg = props.getPassword();
|
||||
List<String> errors = new ArrayList<>();
|
||||
|
||||
if (password.length() < cfg.getMinLength()) {
|
||||
errors.add("비밀번호는 " + cfg.getMinLength() + "자 이상이어야 합니다.");
|
||||
}
|
||||
if (cfg.isRequireUppercase() && !password.matches(".*[A-Z].*")) {
|
||||
errors.add("대문자를 포함해야 합니다.");
|
||||
}
|
||||
if (cfg.isRequireDigit() && !password.matches(".*[0-9].*")) {
|
||||
errors.add("숫자를 포함해야 합니다.");
|
||||
}
|
||||
if (cfg.isRequireSpecial() && !password.matches(".*[!@#$%^&*()_+\\-=\\[\\]{};':\",./<>?].*")) {
|
||||
errors.add("특수문자를 포함해야 합니다.");
|
||||
}
|
||||
|
||||
if (!errors.isEmpty()) {
|
||||
throw new BusinessException(String.join(" ", errors), "PASSWORD_POLICY");
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isExpired(WbxUser user) {
|
||||
if (user.getPasswordChangedAt() == null) return true;
|
||||
return user.getPasswordChangedAt()
|
||||
.plusDays(props.getPassword().getExpiryDays())
|
||||
.isBefore(LocalDateTime.now());
|
||||
}
|
||||
|
||||
public boolean isLocked(WbxUser user) {
|
||||
return user.getFailedLoginAttempts() >= props.getPassword().getMaxFailedAttempts()
|
||||
&& user.isLocked();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package kr.co.accura.wbx.spring.auth;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface RefreshTokenRepository extends JpaRepository<WbxRefreshToken, Long> {
|
||||
|
||||
Optional<WbxRefreshToken> findByTokenHash(String tokenHash);
|
||||
|
||||
@Modifying @Transactional
|
||||
@Query("UPDATE WbxRefreshToken t SET t.isRevoked = true WHERE t.userId = :userId")
|
||||
void revokeAllByUserId(@Param("userId") Long userId);
|
||||
|
||||
@Modifying @Transactional
|
||||
@Query("DELETE FROM WbxRefreshToken t WHERE t.expiresAt < :now OR t.isRevoked = true")
|
||||
void deleteExpiredOrRevoked(@Param("now") LocalDateTime now);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package kr.co.accura.wbx.spring.auth;
|
||||
|
||||
import kr.co.accura.wbx.spring.common.BusinessException;
|
||||
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.crypto.codec.Hex;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RefreshTokenService {
|
||||
|
||||
private final RefreshTokenRepository repository;
|
||||
private final WbxUserRepository userRepository;
|
||||
private final JwtProvider jwtProvider;
|
||||
private final WbxSpringProperties props;
|
||||
|
||||
/**
|
||||
* Refresh Token 생성
|
||||
*/
|
||||
public String create(Long userId, String deviceInfo, String ip) {
|
||||
String rawToken = UUID.randomUUID().toString();
|
||||
String hash = sha256(rawToken);
|
||||
|
||||
repository.save(WbxRefreshToken.builder()
|
||||
.userId(userId)
|
||||
.tokenHash(hash)
|
||||
.deviceInfo(deviceInfo)
|
||||
.ipAddress(ip)
|
||||
.expiresAt(LocalDateTime.now().plusSeconds(props.getJwt().getRefreshExpiration()))
|
||||
.build());
|
||||
|
||||
return rawToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Token으로 Access Token 재발급
|
||||
*/
|
||||
public String refresh(String rawToken) {
|
||||
String hash = sha256(rawToken);
|
||||
WbxRefreshToken stored = repository.findByTokenHash(hash)
|
||||
.filter(t -> !t.isRevoked() && t.getExpiresAt().isAfter(LocalDateTime.now()))
|
||||
.orElseThrow(() -> new BusinessException("Invalid or expired refresh token"));
|
||||
|
||||
WbxUser user = userRepository.findById(stored.getUserId())
|
||||
.orElseThrow(() -> new BusinessException("User not found"));
|
||||
|
||||
return jwtProvider.generateToken(user.toUserDetails());
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Token 해지 (로그아웃)
|
||||
*/
|
||||
public void revoke(String rawToken) {
|
||||
String hash = sha256(rawToken);
|
||||
repository.findByTokenHash(hash)
|
||||
.ifPresent(t -> {
|
||||
t.setRevoked(true);
|
||||
repository.save(t);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 디바이스 로그아웃
|
||||
*/
|
||||
public void revokeAll(Long userId) {
|
||||
repository.revokeAllByUserId(userId);
|
||||
}
|
||||
|
||||
private String sha256(String input) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = md.digest(input.getBytes());
|
||||
return new String(Hex.encode(hash));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("SHA-256 failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package kr.co.accura.wbx.spring.auth;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Azure Entra ID (OIDC) 로그인 성공 핸들러
|
||||
* <p>
|
||||
* OAuth2 로그인 성공 → WBX 호환 JWT 발급 → 프론트엔드 콜백 URL로 리다이렉트.
|
||||
* <p>
|
||||
* 고객 환경 커스터마이즈:
|
||||
* - wbx.spring.sso.callback-url: 프론트엔드 콜백 (기본: /sso/callback)
|
||||
* - wbx.spring.sso.auto-register: SSO 최초 로그인 시 자동 사용자 등록
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnProperty(name = "spring.security.oauth2.client.registration.azure.client-id")
|
||||
public class SsoSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
|
||||
|
||||
private final WbxUserRepository userRepository;
|
||||
private final JwtProvider jwtProvider;
|
||||
private final WbxSpringProperties props;
|
||||
private final RefreshTokenService refreshTokenService;
|
||||
private final LoginHistoryRepository loginHistoryRepository;
|
||||
|
||||
@Override
|
||||
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
|
||||
Authentication authentication) throws IOException {
|
||||
OidcUser oidcUser = (OidcUser) authentication.getPrincipal();
|
||||
|
||||
String email = oidcUser.getEmail();
|
||||
String name = oidcUser.getFullName();
|
||||
String oid = oidcUser.getAttribute("oid"); // Azure Object ID
|
||||
|
||||
log.info("SSO login: email={}, name={}, oid={}", email, name, oid);
|
||||
|
||||
// 사용자 조회 또는 자동 등록
|
||||
WbxUser user = userRepository.findByEmail(email)
|
||||
.orElseGet(() -> autoRegisterSsoUser(email, name, oid));
|
||||
|
||||
// Azure OID 업데이트
|
||||
if (oid != null && !oid.equals(user.getAzureOid())) {
|
||||
user.setAzureOid(oid);
|
||||
user.setSsoProvider("azure");
|
||||
}
|
||||
user.recordLoginSuccess();
|
||||
userRepository.save(user);
|
||||
|
||||
// JWT 발급
|
||||
String token = jwtProvider.generateToken(user.toUserDetails());
|
||||
String ip = request.getRemoteAddr();
|
||||
String refreshToken = refreshTokenService.create(user.getId(), "SSO", ip);
|
||||
|
||||
// 로그인 이력
|
||||
loginHistoryRepository.save(WbxLoginHistory.builder()
|
||||
.userId(user.getId()).email(email)
|
||||
.action("LOGIN_SUCCESS").authMethod("SSO_AZURE")
|
||||
.ipAddress(ip)
|
||||
.build());
|
||||
|
||||
// 프론트엔드 콜백 리다이렉트 (JWT를 query param으로 전달)
|
||||
String callbackUrl = String.format("/sso/callback?access_token=%s&refresh_token=%s",
|
||||
token, refreshToken);
|
||||
getRedirectStrategy().sendRedirect(request, response, callbackUrl);
|
||||
}
|
||||
|
||||
private WbxUser autoRegisterSsoUser(String email, String fullName, String azureOid) {
|
||||
log.info("Auto-registering SSO user: {}", email);
|
||||
String username = email.split("@")[0];
|
||||
// username 중복 방지
|
||||
if (userRepository.existsByUsername(username)) {
|
||||
username = username + "_" + System.currentTimeMillis() % 10000;
|
||||
}
|
||||
|
||||
WbxUser user = WbxUser.builder()
|
||||
.email(email)
|
||||
.username(username)
|
||||
.fullName(fullName != null ? fullName : username)
|
||||
.hashedPassword("") // SSO 사용자는 비밀번호 없음
|
||||
.isActive(true)
|
||||
.isAdmin(false)
|
||||
.ssoProvider("azure")
|
||||
.azureOid(azureOid)
|
||||
.build();
|
||||
return userRepository.save(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package kr.co.accura.wbx.spring.auth;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface TotpSecretRepository extends JpaRepository<WbxTotpSecret, Long> {
|
||||
Optional<WbxTotpSecret> findByUserId(Long userId);
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
void deleteByUserId(Long userId);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package kr.co.accura.wbx.spring.auth;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "wbx_login_history")
|
||||
@Getter @Setter
|
||||
@NoArgsConstructor @AllArgsConstructor @Builder
|
||||
public class WbxLoginHistory {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "user_id")
|
||||
private Long userId;
|
||||
|
||||
private String email;
|
||||
|
||||
@Column(nullable = false, length = 20)
|
||||
private String action; // LOGIN_SUCCESS, LOGIN_FAILURE, LOGOUT, MFA_SUCCESS, MFA_FAILURE
|
||||
|
||||
@Column(name = "ip_address", length = 50)
|
||||
private String ipAddress;
|
||||
|
||||
@Column(name = "user_agent", length = 500)
|
||||
private String userAgent;
|
||||
|
||||
@Column(name = "auth_method", length = 20)
|
||||
private String authMethod; // PASSWORD, SSO, API_KEY, REFRESH_TOKEN
|
||||
|
||||
@Column(name = "failure_reason", length = 200)
|
||||
private String failureReason;
|
||||
|
||||
@Builder.Default
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt = LocalDateTime.now();
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package kr.co.accura.wbx.spring.auth;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "wbx_refresh_tokens")
|
||||
@Getter @Setter
|
||||
@NoArgsConstructor @AllArgsConstructor @Builder
|
||||
public class WbxRefreshToken {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private Long userId;
|
||||
|
||||
@Column(name = "token_hash", nullable = false, unique = true)
|
||||
private String tokenHash;
|
||||
|
||||
@Column(name = "device_info", length = 500)
|
||||
private String deviceInfo;
|
||||
|
||||
@Column(name = "ip_address", length = 50)
|
||||
private String ipAddress;
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private LocalDateTime expiresAt;
|
||||
|
||||
@Builder.Default
|
||||
@Column(name = "is_revoked")
|
||||
private boolean isRevoked = false;
|
||||
|
||||
@Builder.Default
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt = LocalDateTime.now();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package kr.co.accura.wbx.spring.auth;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import kr.co.accura.wbx.spring.common.BaseEntity;
|
||||
import lombok.*;
|
||||
|
||||
@Entity
|
||||
@Table(name = "wbx_totp_secrets")
|
||||
@Getter @Setter
|
||||
@NoArgsConstructor @AllArgsConstructor @Builder
|
||||
public class WbxTotpSecret extends BaseEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true)
|
||||
private Long userId;
|
||||
|
||||
/** AES-256 암호화된 TOTP 시크릿 */
|
||||
@Column(nullable = false, length = 500)
|
||||
private String encryptedSecret;
|
||||
|
||||
/** MFA 설정 완료 (QR 스캔 후 검증 통과) */
|
||||
@Builder.Default
|
||||
private boolean verified = false;
|
||||
|
||||
/** JSON 배열 — 해시된 백업 코드 (1회용 복구 코드) */
|
||||
@Column(length = 2000)
|
||||
private String backupCodes;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package kr.co.accura.wbx.spring.auth;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import kr.co.accura.wbx.spring.common.BaseEntity;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "wbx_users")
|
||||
@Getter @Setter
|
||||
@NoArgsConstructor @AllArgsConstructor @Builder
|
||||
public class WbxUser extends BaseEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true)
|
||||
private String email;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 100)
|
||||
private String username;
|
||||
|
||||
private String hashedPassword;
|
||||
private String fullName;
|
||||
private String phone;
|
||||
|
||||
@Column(name = "department_id")
|
||||
private Long departmentId;
|
||||
|
||||
@Column(length = 100)
|
||||
private String positionTitle;
|
||||
|
||||
@Column(unique = true, length = 50)
|
||||
private String employeeNumber;
|
||||
|
||||
@Builder.Default
|
||||
private boolean isActive = true;
|
||||
|
||||
@Builder.Default
|
||||
private boolean isAdmin = false;
|
||||
|
||||
@Builder.Default
|
||||
private boolean mfaEnabled = false;
|
||||
|
||||
@Column(length = 255)
|
||||
private String azureOid;
|
||||
|
||||
@Column(length = 50)
|
||||
private String ssoProvider;
|
||||
|
||||
@Builder.Default
|
||||
private int failedLoginAttempts = 0;
|
||||
|
||||
private LocalDateTime lastFailedLogin;
|
||||
private LocalDateTime lockedUntil;
|
||||
private LocalDateTime passwordChangedAt;
|
||||
|
||||
@Builder.Default
|
||||
private boolean mustChangePassword = false;
|
||||
|
||||
private LocalDateTime lastLoginAt;
|
||||
|
||||
// 비즈니스 메서드
|
||||
public void recordLoginSuccess() {
|
||||
this.lastLoginAt = LocalDateTime.now();
|
||||
this.failedLoginAttempts = 0;
|
||||
this.lastFailedLogin = null;
|
||||
}
|
||||
|
||||
public void recordLoginFailure(int maxAttempts, int lockoutMinutes) {
|
||||
this.failedLoginAttempts++;
|
||||
this.lastFailedLogin = LocalDateTime.now();
|
||||
if (this.failedLoginAttempts >= maxAttempts) {
|
||||
this.lockedUntil = LocalDateTime.now().plusMinutes(lockoutMinutes);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isLocked() {
|
||||
return lockedUntil != null && lockedUntil.isAfter(LocalDateTime.now());
|
||||
}
|
||||
|
||||
public WbxUserDetails toUserDetails() {
|
||||
return WbxUserDetails.builder()
|
||||
.id(id)
|
||||
.email(email)
|
||||
.username(username)
|
||||
.fullName(fullName != null ? fullName : "")
|
||||
.isAdmin(isAdmin)
|
||||
.departmentId(departmentId)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package kr.co.accura.wbx.spring.auth;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@AllArgsConstructor
|
||||
public class WbxUserDetails implements UserDetails {
|
||||
|
||||
private Long id;
|
||||
private String email;
|
||||
private String username;
|
||||
private String fullName;
|
||||
private boolean isAdmin;
|
||||
private Long departmentId;
|
||||
@Builder.Default
|
||||
private List<String> roles = new ArrayList<>();
|
||||
@Builder.Default
|
||||
private boolean enabled = true;
|
||||
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
List<GrantedAuthority> authorities = new ArrayList<>();
|
||||
for (String role : roles) {
|
||||
authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
|
||||
}
|
||||
if (isAdmin) {
|
||||
authorities.add(new SimpleGrantedAuthority("ROLE_SA"));
|
||||
}
|
||||
return authorities;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPassword() { return null; }
|
||||
|
||||
@Override
|
||||
public boolean isAccountNonExpired() { return true; }
|
||||
|
||||
@Override
|
||||
public boolean isAccountNonLocked() { return true; }
|
||||
|
||||
@Override
|
||||
public boolean isCredentialsNonExpired() { return true; }
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() { return enabled; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package kr.co.accura.wbx.spring.auth;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface WbxUserRepository extends JpaRepository<WbxUser, Long> {
|
||||
|
||||
Optional<WbxUser> findByEmail(String email);
|
||||
|
||||
Optional<WbxUser> findByUsername(String username);
|
||||
|
||||
Optional<WbxUser> findByAzureOid(String azureOid);
|
||||
|
||||
Optional<WbxUser> findByEmployeeNumber(String employeeNumber);
|
||||
|
||||
boolean existsByEmail(String email);
|
||||
|
||||
boolean existsByUsername(String username);
|
||||
|
||||
long countByIsActiveTrue();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package kr.co.accura.wbx.spring.common;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import org.springframework.data.annotation.CreatedBy;
|
||||
import org.springframework.data.annotation.CreatedDate;
|
||||
import org.springframework.data.annotation.LastModifiedBy;
|
||||
import org.springframework.data.annotation.LastModifiedDate;
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter
|
||||
@MappedSuperclass
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
public abstract class BaseEntity {
|
||||
|
||||
@CreatedDate
|
||||
@Column(updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@LastModifiedDate
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@CreatedBy
|
||||
@Column(updatable = false)
|
||||
private Long createdBy;
|
||||
|
||||
@LastModifiedBy
|
||||
private Long updatedBy;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package kr.co.accura.wbx.spring.common;
|
||||
|
||||
import lombok.Getter;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
@Getter
|
||||
public class BusinessException extends RuntimeException {
|
||||
|
||||
private final String code;
|
||||
private final HttpStatus status;
|
||||
|
||||
public BusinessException(String message) {
|
||||
this(message, "BUSINESS_ERROR", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
public BusinessException(String message, String code) {
|
||||
this(message, code, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
public BusinessException(String message, String code, HttpStatus status) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package kr.co.accura.wbx.spring.common;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
public class NotFoundException extends BusinessException {
|
||||
|
||||
public NotFoundException(String message) {
|
||||
super(message, "NOT_FOUND", HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package kr.co.accura.wbx.spring.common;
|
||||
|
||||
import kr.co.accura.wbx.spring.auth.WbxUserDetails;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
public final class SecurityUtils {
|
||||
|
||||
private SecurityUtils() {}
|
||||
|
||||
public static Long getCurrentUserId() {
|
||||
var user = getCurrentUser();
|
||||
return user != null ? user.getId() : null;
|
||||
}
|
||||
|
||||
public static String getCurrentUsername() {
|
||||
var user = getCurrentUser();
|
||||
return user != null ? user.getUsername() : null;
|
||||
}
|
||||
|
||||
public static Long getCurrentDeptId() {
|
||||
var user = getCurrentUser();
|
||||
return user != null ? user.getDepartmentId() : null;
|
||||
}
|
||||
|
||||
public static WbxUserDetails getCurrentUser() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null && auth.getPrincipal() instanceof WbxUserDetails details) {
|
||||
return details;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package kr.co.accura.wbx.spring.compat;
|
||||
|
||||
import kr.co.accura.wbx.spring.common.BusinessException;
|
||||
import kr.co.accura.wbx.spring.common.NotFoundException;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* WBX FastAPI 호환 에러 핸들러
|
||||
* 에러 응답에 "detail" 키 사용 (FastAPI 형식)
|
||||
*/
|
||||
@Slf4j
|
||||
@RestControllerAdvice
|
||||
public class WbxErrorHandler {
|
||||
|
||||
@ExceptionHandler(BusinessException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleBusiness(BusinessException ex) {
|
||||
return ResponseEntity.status(ex.getStatus()).body(Map.of(
|
||||
"detail", ex.getMessage(),
|
||||
"code", ex.getCode(),
|
||||
"timestamp", LocalDateTime.now().toString()
|
||||
));
|
||||
}
|
||||
|
||||
@ExceptionHandler(NotFoundException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleNotFound(NotFoundException ex) {
|
||||
return ResponseEntity.status(404).body(Map.of(
|
||||
"detail", ex.getMessage()
|
||||
));
|
||||
}
|
||||
|
||||
@ExceptionHandler(AccessDeniedException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleForbidden(AccessDeniedException ex) {
|
||||
return ResponseEntity.status(403).body(Map.of(
|
||||
"detail", "Access denied"
|
||||
));
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
|
||||
String detail = ex.getBindingResult().getFieldErrors().stream()
|
||||
.map(e -> e.getField() + ": " + e.getDefaultMessage())
|
||||
.collect(Collectors.joining(", "));
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"detail", detail
|
||||
));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<Map<String, Object>> handleGeneral(Exception ex) {
|
||||
log.error("Unhandled exception: {}", ex.getMessage(), ex);
|
||||
return ResponseEntity.status(500).body(Map.of(
|
||||
"detail", "Internal server error"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package kr.co.accura.wbx.spring.compat;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.web.bind.support.WebDataBinderFactory;
|
||||
import org.springframework.web.context.request.NativeWebRequest;
|
||||
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
|
||||
import org.springframework.web.method.support.ModelAndViewContainer;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* WBX No-Code 프론트엔드 호환 — skip/limit → Spring Pageable 변환
|
||||
*/
|
||||
@Configuration
|
||||
public class WbxPaginationConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
|
||||
resolvers.add(new WbxPaginationResolver());
|
||||
}
|
||||
|
||||
static class WbxPaginationResolver implements HandlerMethodArgumentResolver {
|
||||
|
||||
@Override
|
||||
public boolean supportsParameter(MethodParameter parameter) {
|
||||
return Pageable.class.isAssignableFrom(parameter.getParameterType());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Pageable resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
|
||||
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
|
||||
String skipStr = webRequest.getParameter("skip");
|
||||
String limitStr = webRequest.getParameter("limit");
|
||||
String sortStr = webRequest.getParameter("sort");
|
||||
|
||||
int skip = skipStr != null ? Integer.parseInt(skipStr) : 0;
|
||||
int limit = limitStr != null ? Integer.parseInt(limitStr) : 20;
|
||||
int page = skip / Math.max(limit, 1);
|
||||
|
||||
Sort sort = sortStr != null && !sortStr.isBlank()
|
||||
? Sort.by(sortStr.split(","))
|
||||
: Sort.unsorted();
|
||||
|
||||
return PageRequest.of(page, limit, sort);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package kr.co.accura.wbx.spring.config;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class CorsAutoConfig implements WebMvcConfigurer {
|
||||
|
||||
private final WbxSpringProperties props;
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
String[] origins = props.getCors().getAllowedOrigins().toArray(new String[0]);
|
||||
registry.addMapping("/api/**")
|
||||
.allowedOriginPatterns(origins)
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.exposedHeaders("Content-Disposition", "X-Total-Count")
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package kr.co.accura.wbx.spring.config;
|
||||
|
||||
import io.swagger.v3.oas.models.Components;
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import io.swagger.v3.oas.models.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.models.security.SecurityScheme;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class OpenApiConfig {
|
||||
|
||||
@Bean
|
||||
public OpenAPI customOpenAPI() {
|
||||
return new OpenAPI()
|
||||
.info(new Info()
|
||||
.title("WBX Spring Framework API")
|
||||
.version("1.0.0")
|
||||
.description("WBX Spring Boot 통합 프레임워크 REST API"))
|
||||
.addSecurityItem(new SecurityRequirement().addList("Bearer"))
|
||||
.components(new Components()
|
||||
.addSecuritySchemes("Bearer",
|
||||
new SecurityScheme()
|
||||
.type(SecurityScheme.Type.HTTP)
|
||||
.scheme("bearer")
|
||||
.bearerFormat("JWT")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package kr.co.accura.wbx.spring.config;
|
||||
|
||||
import kr.co.accura.wbx.spring.auth.JwtFilter;
|
||||
import kr.co.accura.wbx.spring.auth.SsoSuccessHandler;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityAutoConfig {
|
||||
|
||||
private final JwtFilter jwtFilter;
|
||||
private final WbxSpringProperties props;
|
||||
|
||||
/** SSO 핸들러 — Azure OAuth2 설정이 있을 때만 주입 */
|
||||
@Autowired(required = false)
|
||||
private SsoSuccessHandler ssoSuccessHandler;
|
||||
|
||||
/**
|
||||
* REST API — JWT Stateless
|
||||
*/
|
||||
@Bean
|
||||
@Order(2)
|
||||
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
|
||||
String prefix = props.getApiPrefix();
|
||||
|
||||
http
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers(prefix + "/auth/**").permitAll()
|
||||
.requestMatchers("/health", "/actuator/**").permitAll()
|
||||
.requestMatchers("/swagger-ui/**", "/api-docs/**", "/v3/api-docs/**").permitAll()
|
||||
.requestMatchers("/sso/**").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
// Azure Entra SSO — client-id 설정이 있으면 OAuth2Login 활성화
|
||||
if (ssoSuccessHandler != null) {
|
||||
http.oauth2Login(oauth2 -> oauth2
|
||||
.successHandler(ssoSuccessHandler)
|
||||
.failureUrl("/sso/error")
|
||||
);
|
||||
}
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package kr.co.accura.wbx.spring.config;
|
||||
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* wbx-spring-core Auto-Configuration.
|
||||
* Replaces the former WbxSpringCoreApplication annotations.
|
||||
* Consuming Spring Boot applications pick this up automatically
|
||||
* via META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableJpaAuditing
|
||||
@EnableAsync
|
||||
@EnableScheduling
|
||||
@EnableCaching
|
||||
public class WbxAutoConfiguration {
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package kr.co.accura.wbx.spring.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "wbx.spring")
|
||||
public class WbxSpringProperties {
|
||||
|
||||
private String apiPrefix = "/api";
|
||||
private String apiVersion = "v1";
|
||||
|
||||
private Jwt jwt = new Jwt();
|
||||
private Mfa mfa = new Mfa();
|
||||
private Password password = new Password();
|
||||
private Cors cors = new Cors();
|
||||
private FileConfig file = new FileConfig();
|
||||
private Approval approval = new Approval();
|
||||
private Notification notification = new Notification();
|
||||
private DataSourceConfig datasource = new DataSourceConfig();
|
||||
private AdminUi adminUi = new AdminUi();
|
||||
private Compat compat = new Compat();
|
||||
|
||||
@Data
|
||||
public static class Jwt {
|
||||
private String secret = "wbx-spring-default-secret-key-change-in-production-minimum-256-bits";
|
||||
private long expiration = 28800; // 8 hours
|
||||
private long refreshExpiration = 604800; // 7 days
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Mfa {
|
||||
private boolean enabled = false;
|
||||
private boolean forceForExternal = true;
|
||||
private boolean forceForInternal = false;
|
||||
private String totpIssuer = "WBX Platform";
|
||||
private int totpDigits = 6;
|
||||
private int totpPeriod = 30;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Password {
|
||||
private int minLength = 8;
|
||||
private int maxFailedAttempts = 5;
|
||||
private int expiryDays = 90;
|
||||
private int historyCount = 3;
|
||||
private boolean requireUppercase = true;
|
||||
private boolean requireDigit = true;
|
||||
private boolean requireSpecial = true;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Cors {
|
||||
private List<String> allowedOrigins = new ArrayList<>(List.of("http://localhost:5173", "http://localhost:3000"));
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class FileConfig {
|
||||
private String storageType = "local"; // local, azure-blob, aws-s3, gcp-storage
|
||||
private String uploadDir = "./uploads";
|
||||
private long maxSizeMb = 50;
|
||||
private AzureConfig azure = new AzureConfig();
|
||||
private AwsConfig aws = new AwsConfig();
|
||||
private GcpConfig gcp = new GcpConfig();
|
||||
|
||||
@Data
|
||||
public static class AzureConfig {
|
||||
private String accountName = "";
|
||||
private String accountKey = "";
|
||||
private String containerName = "uploads";
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class AwsConfig {
|
||||
private String bucket = "";
|
||||
private String region = "ap-northeast-2";
|
||||
private String accessKey = "";
|
||||
private String secretKey = "";
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class GcpConfig {
|
||||
private String bucket = "";
|
||||
private String projectId = "";
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Notification {
|
||||
private boolean sseEnabled = true;
|
||||
private boolean websocketEnabled = false;
|
||||
private int heartbeatSeconds = 30;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class AdminUi {
|
||||
private boolean enabled = false;
|
||||
private String path = "/admin";
|
||||
private List<String> allowRoles = new ArrayList<>(List.of("SA"));
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Approval {
|
||||
private boolean enabled = true;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class DataSourceConfig {
|
||||
private boolean routingEnabled = false;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Compat {
|
||||
private String errorFormat = "fastapi"; // detail key
|
||||
private String listKey = "items";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package kr.co.accura.wbx.spring.config;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "wbx_system_config")
|
||||
@Getter @Setter
|
||||
@NoArgsConstructor @AllArgsConstructor @Builder
|
||||
public class WbxSystemConfig {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "config_key", nullable = false, unique = true, length = 100)
|
||||
private String configKey;
|
||||
|
||||
@Column(name = "config_value", length = 4000)
|
||||
private String configValue;
|
||||
|
||||
@Column(name = "value_type", length = 20)
|
||||
@Builder.Default
|
||||
private String valueType = "STRING"; // STRING, INT, BOOLEAN, JSON
|
||||
|
||||
@Column(length = 500)
|
||||
private String description;
|
||||
|
||||
@Builder.Default
|
||||
@Column(name = "is_editable")
|
||||
private boolean isEditable = true;
|
||||
|
||||
private LocalDateTime updatedAt;
|
||||
private Long updatedBy;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package kr.co.accura.wbx.spring.datasource;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
/**
|
||||
* 데이터소스 전환 어노테이션
|
||||
* <p>
|
||||
* 메서드 또는 클래스에 사용하여 특정 데이터소스를 지정합니다.
|
||||
* <pre>
|
||||
* {@literal @}DataSource("readonly")
|
||||
* public List<User> findAll() { ... }
|
||||
* </pre>
|
||||
*
|
||||
* @see WbxRoutingDataSource
|
||||
* @see DataSourceAspect
|
||||
*/
|
||||
@Target({ElementType.METHOD, ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface DataSource {
|
||||
/** 데이터소스 이름: "app" (기본), "wbxgw", "readonly" */
|
||||
String value() default "app";
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package kr.co.accura.wbx.spring.datasource;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.reflect.MethodSignature;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
/**
|
||||
* @DataSource 어노테이션 AOP
|
||||
* <p>
|
||||
* wbx.spring.datasource.routing-enabled=true 일 때 활성화.
|
||||
* 메서드 → 클래스 순서로 @DataSource 어노테이션을 탐색합니다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Aspect
|
||||
@Component
|
||||
@Order(-1) // 트랜잭션 AOP보다 먼저 실행
|
||||
@ConditionalOnProperty(name = "wbx.spring.datasource.routing-enabled", havingValue = "true")
|
||||
public class DataSourceAspect {
|
||||
|
||||
@Around("@annotation(kr.co.accura.wbx.spring.datasource.DataSource) || " +
|
||||
"@within(kr.co.accura.wbx.spring.datasource.DataSource)")
|
||||
public Object around(ProceedingJoinPoint point) throws Throwable {
|
||||
MethodSignature signature = (MethodSignature) point.getSignature();
|
||||
Method method = signature.getMethod();
|
||||
|
||||
// 메서드 레벨 우선
|
||||
DataSource ds = method.getAnnotation(DataSource.class);
|
||||
if (ds == null) {
|
||||
// 클래스 레벨
|
||||
ds = point.getTarget().getClass().getAnnotation(DataSource.class);
|
||||
}
|
||||
|
||||
String key = (ds != null) ? ds.value() : "app";
|
||||
String previous = WbxRoutingDataSource.getDataSourceKey();
|
||||
|
||||
try {
|
||||
WbxRoutingDataSource.setDataSourceKey(key);
|
||||
log.debug("DataSource switched to: {}", key);
|
||||
return point.proceed();
|
||||
} finally {
|
||||
if (previous != null) {
|
||||
WbxRoutingDataSource.setDataSourceKey(previous);
|
||||
} else {
|
||||
WbxRoutingDataSource.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package kr.co.accura.wbx.spring.datasource;
|
||||
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Multi-DataSource 설정 (선택적 활성화)
|
||||
* <p>
|
||||
* wbx.spring.datasource.routing-enabled=true 일 때 활성화.
|
||||
* 기본: 단일 DataSource (Spring Boot 자동 설정).
|
||||
* <p>
|
||||
* 사용법 (application.yml):
|
||||
* <pre>
|
||||
* wbx.spring.datasource.routing-enabled: true
|
||||
*
|
||||
* spring.datasource: # 기본 (app)
|
||||
* url: jdbc:mysql://...
|
||||
*
|
||||
* wbx.spring.datasource.wbxgw: # WBX Groupware DB (선택)
|
||||
* url: jdbc:mysql://...
|
||||
* username: xxx
|
||||
* password: xxx
|
||||
*
|
||||
* wbx.spring.datasource.readonly: # 읽기전용 (선택)
|
||||
* url: jdbc:mysql://...
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@ConditionalOnProperty(name = "wbx.spring.datasource.routing-enabled", havingValue = "true")
|
||||
public class MultiDataSourceConfig {
|
||||
|
||||
@Bean
|
||||
@ConfigurationProperties("spring.datasource")
|
||||
public DataSourceProperties appDataSourceProperties() {
|
||||
return new DataSourceProperties();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public DataSource appDataSource() {
|
||||
return appDataSourceProperties().initializeDataSourceBuilder()
|
||||
.type(HikariDataSource.class)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConfigurationProperties("wbx.spring.datasource.wbxgw")
|
||||
public DataSourceProperties wbxgwDataSourceProperties() {
|
||||
return new DataSourceProperties();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public DataSource wbxgwDataSource() {
|
||||
DataSourceProperties props = wbxgwDataSourceProperties();
|
||||
if (props.getUrl() == null || props.getUrl().isBlank()) {
|
||||
log.info("wbxgw datasource not configured, skipping");
|
||||
return appDataSource(); // fallback to app
|
||||
}
|
||||
return props.initializeDataSourceBuilder()
|
||||
.type(HikariDataSource.class)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
public DataSource routingDataSource(
|
||||
@Qualifier("appDataSource") DataSource appDs,
|
||||
@Qualifier("wbxgwDataSource") DataSource wbxgwDs) {
|
||||
|
||||
WbxRoutingDataSource routing = new WbxRoutingDataSource();
|
||||
|
||||
Map<Object, Object> targets = new HashMap<>();
|
||||
targets.put("app", appDs);
|
||||
targets.put("wbxgw", wbxgwDs);
|
||||
|
||||
routing.setTargetDataSources(targets);
|
||||
routing.setDefaultTargetDataSource(appDs);
|
||||
routing.afterPropertiesSet();
|
||||
|
||||
log.info("Multi-DataSource routing enabled: {}", targets.keySet());
|
||||
return routing;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package kr.co.accura.wbx.spring.datasource;
|
||||
|
||||
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
|
||||
|
||||
/**
|
||||
* ThreadLocal 기반 동적 데이터소스 라우팅
|
||||
* <p>
|
||||
* DataSourceAspect가 @DataSource 어노테이션을 감지하여
|
||||
* ThreadLocal에 데이터소스 키를 설정하면, 이 클래스가 해당 키를 반환합니다.
|
||||
*/
|
||||
public class WbxRoutingDataSource extends AbstractRoutingDataSource {
|
||||
|
||||
private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();
|
||||
|
||||
public static void setDataSourceKey(String key) {
|
||||
CONTEXT.set(key);
|
||||
}
|
||||
|
||||
public static String getDataSourceKey() {
|
||||
return CONTEXT.get();
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
CONTEXT.remove();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Object determineCurrentLookupKey() {
|
||||
return CONTEXT.get();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package kr.co.accura.wbx.spring.file;
|
||||
|
||||
import kr.co.accura.wbx.spring.common.BusinessException;
|
||||
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HexFormat;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* AWS S3 스토리지 구현 (표준 템플릿)
|
||||
* <p>
|
||||
* ⚠️ 프로덕션 배포 시 아래 SDK 기반 구현으로 교체를 권장합니다:
|
||||
* <pre>
|
||||
* // build.gradle
|
||||
* implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3:3.3.0'
|
||||
*
|
||||
* // 교체 후: S3Client.putObject() / getObject() / deleteObject() 사용
|
||||
* </pre>
|
||||
* <p>
|
||||
* 현재 구현은 Pre-signed URL 방식을 사용하며,
|
||||
* AWS CLI 인증(~/.aws/credentials)이 설정된 환경에서 동작합니다.
|
||||
* <p>
|
||||
* 설정:
|
||||
* <pre>
|
||||
* wbx.spring.file.storage-type: aws-s3
|
||||
* wbx.spring.file.aws.bucket: ${AWS_S3_BUCKET}
|
||||
* wbx.spring.file.aws.region: ap-northeast-2
|
||||
* wbx.spring.file.aws.access-key: ${AWS_ACCESS_KEY}
|
||||
* wbx.spring.file.aws.secret-key: ${AWS_SECRET_KEY}
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@ConditionalOnProperty(name = "wbx.spring.file.storage-type", havingValue = "aws-s3")
|
||||
public class AwsS3StorageService implements FileStorageService {
|
||||
|
||||
private final WbxSpringProperties props;
|
||||
private final HttpClient httpClient;
|
||||
|
||||
public AwsS3StorageService(WbxSpringProperties props) {
|
||||
this.props = props;
|
||||
this.httpClient = HttpClient.newHttpClient();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String upload(MultipartFile file, String category) {
|
||||
try {
|
||||
WbxSpringProperties.FileConfig.AwsConfig aws = props.getFile().getAws();
|
||||
String objectKey = category + "/" + UUID.randomUUID() + getExtension(file.getOriginalFilename());
|
||||
String url = String.format("https://%s.s3.%s.amazonaws.com/%s",
|
||||
aws.getBucket(), aws.getRegion(), objectKey);
|
||||
|
||||
byte[] content = file.getBytes();
|
||||
ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("Content-Type", file.getContentType())
|
||||
.header("x-amz-date", amzDate(now))
|
||||
.header("x-amz-content-sha256", sha256Hex(content))
|
||||
.PUT(HttpRequest.BodyPublishers.ofByteArray(content))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() >= 300) {
|
||||
throw new BusinessException("S3 upload failed: " + response.statusCode());
|
||||
}
|
||||
|
||||
log.info("S3 uploaded: {}/{}", aws.getBucket(), objectKey);
|
||||
return objectKey;
|
||||
} catch (BusinessException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("S3 upload failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] download(String fileKey) {
|
||||
try {
|
||||
WbxSpringProperties.FileConfig.AwsConfig aws = props.getFile().getAws();
|
||||
String url = String.format("https://%s.s3.%s.amazonaws.com/%s",
|
||||
aws.getBucket(), aws.getRegion(), fileKey);
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("x-amz-date", amzDate(ZonedDateTime.now(ZoneOffset.UTC)))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
|
||||
if (response.statusCode() == 404) {
|
||||
throw new BusinessException("File not found: " + fileKey);
|
||||
}
|
||||
return response.body();
|
||||
} catch (BusinessException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("S3 download failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String fileKey) {
|
||||
try {
|
||||
WbxSpringProperties.FileConfig.AwsConfig aws = props.getFile().getAws();
|
||||
String url = String.format("https://%s.s3.%s.amazonaws.com/%s",
|
||||
aws.getBucket(), aws.getRegion(), fileKey);
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.DELETE()
|
||||
.build();
|
||||
httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
log.info("S3 deleted: {}", fileKey);
|
||||
} catch (Exception e) {
|
||||
log.warn("S3 delete failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPresignedUrl(String fileKey, long expirySeconds) {
|
||||
WbxSpringProperties.FileConfig.AwsConfig aws = props.getFile().getAws();
|
||||
// Pre-signed URL (간략 구현 — 프로덕션은 AWS SDK 권장)
|
||||
return String.format("https://%s.s3.%s.amazonaws.com/%s?X-Amz-Expires=%d",
|
||||
aws.getBucket(), aws.getRegion(), fileKey, expirySeconds);
|
||||
}
|
||||
|
||||
// ===== Helper =====
|
||||
|
||||
private String amzDate(ZonedDateTime dt) {
|
||||
return DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'").format(dt);
|
||||
}
|
||||
|
||||
private String sha256Hex(byte[] data) {
|
||||
try {
|
||||
byte[] hash = MessageDigest.getInstance("SHA-256").digest(data);
|
||||
return HexFormat.of().formatHex(hash);
|
||||
} catch (Exception e) {
|
||||
return "UNSIGNED-PAYLOAD";
|
||||
}
|
||||
}
|
||||
|
||||
private String getExtension(String filename) {
|
||||
if (filename == null) return "";
|
||||
int dot = filename.lastIndexOf('.');
|
||||
return dot > 0 ? "." + filename.substring(dot + 1).toLowerCase() : "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package kr.co.accura.wbx.spring.file;
|
||||
|
||||
import kr.co.accura.wbx.spring.common.BusinessException;
|
||||
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.*;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Base64;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Azure Blob Storage 구현
|
||||
* <p>
|
||||
* Azure SDK 없이 REST API 직접 호출 (의존성 최소화).
|
||||
* 고객사에 Azure SDK 사용 가능하면 spring-cloud-azure-starter-storage-blob으로 교체 권장.
|
||||
* <p>
|
||||
* 설정:
|
||||
* <pre>
|
||||
* wbx.spring.file.storage-type: azure-blob
|
||||
* wbx.spring.file.azure.account-name: ${AZURE_STORAGE_ACCOUNT}
|
||||
* wbx.spring.file.azure.account-key: ${AZURE_STORAGE_KEY}
|
||||
* wbx.spring.file.azure.container-name: uploads
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@ConditionalOnProperty(name = "wbx.spring.file.storage-type", havingValue = "azure-blob")
|
||||
public class AzureBlobStorageService implements FileStorageService {
|
||||
|
||||
private final WbxSpringProperties props;
|
||||
private final HttpClient httpClient;
|
||||
|
||||
public AzureBlobStorageService(WbxSpringProperties props) {
|
||||
this.props = props;
|
||||
this.httpClient = HttpClient.newHttpClient();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String upload(MultipartFile file, String category) {
|
||||
try {
|
||||
String blobName = category + "/" + UUID.randomUUID() + getExtension(file.getOriginalFilename());
|
||||
// SAS Token 방식 — SharedKey 서명 없이 URL에 인증 포함
|
||||
String sasUrl = generateSasUrl(blobName, "rcw", 3600); // read+create+write, 1시간
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(sasUrl))
|
||||
.header("x-ms-blob-type", "BlockBlob")
|
||||
.header("Content-Type", file.getContentType() != null ? file.getContentType() : "application/octet-stream")
|
||||
.header("x-ms-version", "2023-11-03")
|
||||
.PUT(HttpRequest.BodyPublishers.ofByteArray(file.getBytes()))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() >= 300) {
|
||||
throw new BusinessException("Azure Blob upload failed: HTTP " + response.statusCode() + " " + response.body());
|
||||
}
|
||||
|
||||
log.info("Azure Blob uploaded: {}", blobName);
|
||||
return blobName;
|
||||
} catch (BusinessException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("Azure Blob upload failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] download(String fileKey) {
|
||||
try {
|
||||
String sasUrl = generateSasUrl(fileKey, "r", 3600); // read, 1시간
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(sasUrl))
|
||||
.header("x-ms-version", "2023-11-03")
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
|
||||
if (response.statusCode() == 404) {
|
||||
throw new BusinessException("File not found: " + fileKey);
|
||||
}
|
||||
return response.body();
|
||||
} catch (BusinessException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("Azure Blob download failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String fileKey) {
|
||||
try {
|
||||
String sasUrl = generateSasUrl(fileKey, "d", 3600); // delete, 1시간
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(sasUrl))
|
||||
.header("x-ms-version", "2023-11-03")
|
||||
.DELETE()
|
||||
.build();
|
||||
httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
log.info("Azure Blob deleted: {}", fileKey);
|
||||
} catch (Exception e) {
|
||||
log.warn("Azure Blob delete failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPresignedUrl(String fileKey, long expirySeconds) {
|
||||
return generateSasUrl(fileKey, "r", expirySeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Azure SAS Token URL 생성 (Service SAS)
|
||||
* @param blobName Blob 경로
|
||||
* @param permissions 권한: r=read, w=write, d=delete, c=create 조합
|
||||
* @param expirySeconds 유효기간 (초)
|
||||
*/
|
||||
private String generateSasUrl(String blobName, String permissions, long expirySeconds) {
|
||||
WbxSpringProperties.FileConfig.AzureConfig azure = props.getFile().getAzure();
|
||||
String accountName = azure.getAccountName();
|
||||
String containerName = azure.getContainerName();
|
||||
|
||||
OffsetDateTime start = OffsetDateTime.now(ZoneOffset.UTC).minusMinutes(5); // 시간 오차 대비
|
||||
OffsetDateTime expiry = OffsetDateTime.now(ZoneOffset.UTC).plusSeconds(expirySeconds);
|
||||
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'");
|
||||
|
||||
String stringToSign = String.join("\n",
|
||||
permissions,
|
||||
fmt.format(start),
|
||||
fmt.format(expiry),
|
||||
String.format("/blob/%s/%s/%s", accountName, containerName, blobName),
|
||||
"", // identifier
|
||||
"", // IP
|
||||
"https", // protocol
|
||||
"2023-11-03", // version
|
||||
"b", // resource (blob)
|
||||
"", "", "", "" // snapshot, encryption, cache, content
|
||||
);
|
||||
|
||||
String signature = hmacSha256(azure.getAccountKey(), stringToSign);
|
||||
return String.format("%s?sp=%s&st=%s&se=%s&spr=https&sv=2023-11-03&sr=b&sig=%s",
|
||||
getBlobUrl(blobName), permissions, fmt.format(start), fmt.format(expiry),
|
||||
java.net.URLEncoder.encode(signature, java.nio.charset.StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
// ===== Helper =====
|
||||
|
||||
private String getBlobUrl(String blobName) {
|
||||
WbxSpringProperties.FileConfig.AzureConfig azure = props.getFile().getAzure();
|
||||
return String.format("https://%s.blob.core.windows.net/%s/%s",
|
||||
azure.getAccountName(), azure.getContainerName(), blobName);
|
||||
}
|
||||
|
||||
private String gmtDate() {
|
||||
return DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now(ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
private String hmacSha256(String key, String data) {
|
||||
try {
|
||||
byte[] keyBytes = Base64.getDecoder().decode(key);
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(keyBytes, "HmacSHA256"));
|
||||
return Base64.getEncoder().encodeToString(mac.doFinal(data.getBytes()));
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("HMAC signing failed");
|
||||
}
|
||||
}
|
||||
|
||||
private String getExtension(String filename) {
|
||||
if (filename == null) return "";
|
||||
int dot = filename.lastIndexOf('.');
|
||||
return dot > 0 ? "." + filename.substring(dot + 1).toLowerCase() : "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package kr.co.accura.wbx.spring.file;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
public interface FileStorageService {
|
||||
String upload(MultipartFile file, String category);
|
||||
byte[] download(String fileKey);
|
||||
void delete(String fileKey);
|
||||
|
||||
/**
|
||||
* 임시 다운로드 URL 생성 (클라우드 스토리지용).
|
||||
* 로컬 구현은 빈 문자열 반환.
|
||||
*/
|
||||
default String getPresignedUrl(String fileKey, long expirySeconds) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package kr.co.accura.wbx.spring.file;
|
||||
|
||||
import kr.co.accura.wbx.spring.common.BusinessException;
|
||||
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Google Cloud Storage 구현 (표준 템플릿)
|
||||
* <p>
|
||||
* ⚠️ 프로덕션 배포 시 아래 SDK 기반 구현으로 교체를 권장합니다:
|
||||
* <pre>
|
||||
* // build.gradle
|
||||
* implementation 'com.google.cloud:spring-cloud-gcp-starter-storage:5.9.0'
|
||||
*
|
||||
* // 교체 후: Storage.create() / readAllBytes() / delete() 사용
|
||||
* </pre>
|
||||
* <p>
|
||||
* 현재 구현은 GOOGLE_APPLICATION_CREDENTIALS 환경변수(서비스 계정 JSON)로
|
||||
* 인증된 환경에서 GCP JSON API를 직접 호출합니다.
|
||||
* <p>
|
||||
* 설정:
|
||||
* <pre>
|
||||
* wbx.spring.file.storage-type: gcp-storage
|
||||
* wbx.spring.file.gcp.bucket: ${GCP_STORAGE_BUCKET}
|
||||
* wbx.spring.file.gcp.project-id: ${GCP_PROJECT_ID}
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@ConditionalOnProperty(name = "wbx.spring.file.storage-type", havingValue = "gcp-storage")
|
||||
public class GcpStorageService implements FileStorageService {
|
||||
|
||||
private final WbxSpringProperties props;
|
||||
private final HttpClient httpClient;
|
||||
|
||||
public GcpStorageService(WbxSpringProperties props) {
|
||||
this.props = props;
|
||||
this.httpClient = HttpClient.newHttpClient();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String upload(MultipartFile file, String category) {
|
||||
try {
|
||||
WbxSpringProperties.FileConfig.GcpConfig gcp = props.getFile().getGcp();
|
||||
String objectName = category + "/" + UUID.randomUUID() + getExtension(file.getOriginalFilename());
|
||||
|
||||
String url = String.format(
|
||||
"https://storage.googleapis.com/upload/storage/v1/b/%s/o?uploadType=media&name=%s",
|
||||
gcp.getBucket(), objectName);
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("Content-Type", file.getContentType())
|
||||
.POST(HttpRequest.BodyPublishers.ofByteArray(file.getBytes()))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() >= 300) {
|
||||
throw new BusinessException("GCS upload failed: " + response.statusCode());
|
||||
}
|
||||
|
||||
log.info("GCS uploaded: gs://{}/{}", gcp.getBucket(), objectName);
|
||||
return objectName;
|
||||
} catch (BusinessException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("GCS upload failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] download(String fileKey) {
|
||||
try {
|
||||
WbxSpringProperties.FileConfig.GcpConfig gcp = props.getFile().getGcp();
|
||||
String url = String.format("https://storage.googleapis.com/storage/v1/b/%s/o/%s?alt=media",
|
||||
gcp.getBucket(), fileKey);
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
|
||||
if (response.statusCode() == 404) {
|
||||
throw new BusinessException("File not found: " + fileKey);
|
||||
}
|
||||
return response.body();
|
||||
} catch (BusinessException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("GCS download failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String fileKey) {
|
||||
try {
|
||||
WbxSpringProperties.FileConfig.GcpConfig gcp = props.getFile().getGcp();
|
||||
String url = String.format("https://storage.googleapis.com/storage/v1/b/%s/o/%s",
|
||||
gcp.getBucket(), fileKey);
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.DELETE()
|
||||
.build();
|
||||
httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
log.info("GCS deleted: {}", fileKey);
|
||||
} catch (Exception e) {
|
||||
log.warn("GCS delete failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPresignedUrl(String fileKey, long expirySeconds) {
|
||||
WbxSpringProperties.FileConfig.GcpConfig gcp = props.getFile().getGcp();
|
||||
// Signed URL (간략 구현 — 프로덕션은 GCP SDK 권장)
|
||||
return String.format("https://storage.googleapis.com/%s/%s", gcp.getBucket(), fileKey);
|
||||
}
|
||||
|
||||
private String getExtension(String filename) {
|
||||
if (filename == null) return "";
|
||||
int dot = filename.lastIndexOf('.');
|
||||
return dot > 0 ? "." + filename.substring(dot + 1).toLowerCase() : "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package kr.co.accura.wbx.spring.file;
|
||||
|
||||
import kr.co.accura.wbx.spring.common.BusinessException;
|
||||
import kr.co.accura.wbx.spring.common.NotFoundException;
|
||||
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnProperty(name = "wbx.spring.file.storage-type", havingValue = "local", matchIfMissing = true)
|
||||
public class LocalFileStorageService implements FileStorageService {
|
||||
|
||||
private final WbxSpringProperties props;
|
||||
|
||||
@Override
|
||||
public String upload(MultipartFile file, String category) {
|
||||
try {
|
||||
Path dir = Paths.get(props.getFile().getUploadDir(), category);
|
||||
Files.createDirectories(dir);
|
||||
|
||||
String ext = getExtension(file.getOriginalFilename());
|
||||
String storedName = UUID.randomUUID() + (ext.isEmpty() ? "" : "." + ext);
|
||||
Path target = dir.resolve(storedName);
|
||||
|
||||
file.transferTo(target.toFile());
|
||||
log.info("File uploaded: {} → {}", file.getOriginalFilename(), target);
|
||||
|
||||
return category + "/" + storedName;
|
||||
} catch (IOException e) {
|
||||
throw new BusinessException("File upload failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] download(String fileKey) {
|
||||
try {
|
||||
Path path = Paths.get(props.getFile().getUploadDir(), fileKey);
|
||||
if (!Files.exists(path)) {
|
||||
throw new NotFoundException("File not found: " + fileKey);
|
||||
}
|
||||
return Files.readAllBytes(path);
|
||||
} catch (IOException e) {
|
||||
throw new BusinessException("File download failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String fileKey) {
|
||||
try {
|
||||
Path path = Paths.get(props.getFile().getUploadDir(), fileKey);
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException e) {
|
||||
log.warn("File delete failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String getExtension(String filename) {
|
||||
if (filename == null) return "";
|
||||
int dot = filename.lastIndexOf('.');
|
||||
return dot > 0 ? filename.substring(dot + 1).toLowerCase() : "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package kr.co.accura.wbx.spring.file;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "wbx_file_uploads")
|
||||
@Getter @Setter
|
||||
@NoArgsConstructor @AllArgsConstructor @Builder
|
||||
public class WbxFileUpload {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "user_id")
|
||||
private Long userId;
|
||||
|
||||
@Column(name = "original_name", nullable = false, length = 500)
|
||||
private String originalName;
|
||||
|
||||
@Column(name = "stored_name", nullable = false, length = 500)
|
||||
private String storedName;
|
||||
|
||||
@Column(name = "storage_type", nullable = false, length = 20)
|
||||
@Builder.Default
|
||||
private String storageType = "LOCAL";
|
||||
|
||||
@Column(name = "storage_path", nullable = false, length = 1000)
|
||||
private String storagePath;
|
||||
|
||||
@Column(name = "content_type", length = 200)
|
||||
private String contentType;
|
||||
|
||||
@Column(name = "file_size")
|
||||
private Long fileSize;
|
||||
|
||||
@Column(length = 50)
|
||||
private String category;
|
||||
|
||||
@Builder.Default
|
||||
@Column(name = "is_deleted")
|
||||
private boolean isDeleted = false;
|
||||
|
||||
@Builder.Default
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt = LocalDateTime.now();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package kr.co.accura.wbx.spring.notification;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "wbx_notifications")
|
||||
@Getter @Setter
|
||||
@NoArgsConstructor @AllArgsConstructor @Builder
|
||||
public class Notification {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private Long userId;
|
||||
|
||||
@Column(nullable = false, length = 50)
|
||||
private String type;
|
||||
|
||||
@Column(nullable = false, length = 200)
|
||||
private String title;
|
||||
|
||||
@Column(length = 2000)
|
||||
private String message;
|
||||
|
||||
@Column(length = 500)
|
||||
private String link;
|
||||
|
||||
@Column(name = "source_type", length = 50)
|
||||
private String sourceType;
|
||||
|
||||
@Column(name = "source_id")
|
||||
private Long sourceId;
|
||||
|
||||
@Builder.Default
|
||||
@Column(name = "is_read")
|
||||
private boolean isRead = false;
|
||||
|
||||
@Column(name = "read_at")
|
||||
private LocalDateTime readAt;
|
||||
|
||||
@Builder.Default
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt = LocalDateTime.now();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package kr.co.accura.wbx.spring.notification;
|
||||
|
||||
import kr.co.accura.wbx.spring.auth.WbxUserDetails;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("${wbx.spring.api-prefix:/api}/notifications")
|
||||
@RequiredArgsConstructor
|
||||
public class NotificationController {
|
||||
|
||||
private final SseNotificationService sseService;
|
||||
|
||||
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public SseEmitter stream(@AuthenticationPrincipal WbxUserDetails user) {
|
||||
return sseService.connect(user.getId());
|
||||
}
|
||||
|
||||
@GetMapping("/unread-count")
|
||||
public Map<String, Object> unreadCount(@AuthenticationPrincipal WbxUserDetails user) {
|
||||
return Map.of("count", 0); // TODO: DB 연동 후 구현
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package kr.co.accura.wbx.spring.notification;
|
||||
|
||||
import lombok.Builder;
|
||||
|
||||
@Builder
|
||||
public record NotificationDto(
|
||||
String type,
|
||||
String title,
|
||||
String message,
|
||||
String link
|
||||
) {
|
||||
public static NotificationDto approvalRequest(String title, String message) {
|
||||
return NotificationDto.builder().type("APPROVAL_REQUEST").title(title).message(message).build();
|
||||
}
|
||||
|
||||
public static NotificationDto approvalComplete(String title, String message) {
|
||||
return NotificationDto.builder().type("APPROVAL_COMPLETE").title(title).message(message).build();
|
||||
}
|
||||
|
||||
public static NotificationDto reminder(String message) {
|
||||
return NotificationDto.builder().type("REMINDER").title("리마인더").message(message).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package kr.co.accura.wbx.spring.notification;
|
||||
|
||||
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.*;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SseNotificationService {
|
||||
|
||||
private final Map<Long, SseEmitter> emitters = new ConcurrentHashMap<>();
|
||||
private final WbxSpringProperties props;
|
||||
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
|
||||
|
||||
/**
|
||||
* SSE 연결 생성 (브라우저 EventSource)
|
||||
*/
|
||||
public SseEmitter connect(Long userId) {
|
||||
SseEmitter emitter = new SseEmitter(3600_000L); // 1 hour
|
||||
emitters.put(userId, emitter);
|
||||
|
||||
emitter.onCompletion(() -> emitters.remove(userId));
|
||||
emitter.onTimeout(() -> emitters.remove(userId));
|
||||
emitter.onError(e -> emitters.remove(userId));
|
||||
|
||||
// Heartbeat
|
||||
int heartbeat = props.getNotification().getHeartbeatSeconds();
|
||||
ScheduledFuture<?> heartbeatTask = scheduler.scheduleAtFixedRate(() -> {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().name("heartbeat").data(""));
|
||||
} catch (Exception e) {
|
||||
emitters.remove(userId);
|
||||
}
|
||||
}, heartbeat, heartbeat, TimeUnit.SECONDS);
|
||||
|
||||
emitter.onCompletion(() -> heartbeatTask.cancel(true));
|
||||
emitter.onTimeout(() -> heartbeatTask.cancel(true));
|
||||
|
||||
return emitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 사용자에게 알림 전송
|
||||
*/
|
||||
public void sendToUser(Long userId, NotificationDto notification) {
|
||||
SseEmitter emitter = emitters.get(userId);
|
||||
if (emitter != null) {
|
||||
try {
|
||||
emitter.send(SseEmitter.event()
|
||||
.name("notification")
|
||||
.data(notification));
|
||||
} catch (Exception e) {
|
||||
emitters.remove(userId);
|
||||
log.debug("SSE send failed for user {}: {}", userId, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int getActiveConnections() {
|
||||
return emitters.size();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package kr.co.accura.wbx.spring.rbac;
|
||||
|
||||
import jakarta.persistence.criteria.CriteriaBuilder;
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
import jakarta.persistence.criteria.Root;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
|
||||
/**
|
||||
* WBX 데이터 접근 범위
|
||||
* OWN = 본인 데이터만
|
||||
* DEPT = 소속 부서 데이터
|
||||
* COMPANY = 전사 데이터
|
||||
*/
|
||||
public enum DeptScope {
|
||||
OWN,
|
||||
DEPT,
|
||||
COMPANY;
|
||||
|
||||
/**
|
||||
* JPA Specification 자동 생성
|
||||
*/
|
||||
public <T> Specification<T> toSpec(Long userId, Long deptId, String userField, String deptField) {
|
||||
return switch (this) {
|
||||
case OWN -> (root, query, cb) -> cb.equal(root.get(userField), userId);
|
||||
case DEPT -> (root, query, cb) -> cb.equal(root.get(deptField), deptId);
|
||||
case COMPANY -> (root, query, cb) -> cb.conjunction(); // no filter
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package kr.co.accura.wbx.spring.rbac;
|
||||
|
||||
import kr.co.accura.wbx.spring.common.SecurityUtils;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* WBX RBAC 권한 체크
|
||||
* Controller에서: @PreAuthorize("@wbx.check('TIMESHEET', 'VIEW')")
|
||||
*/
|
||||
@Component("wbx")
|
||||
@RequiredArgsConstructor
|
||||
public class PermissionEvaluator {
|
||||
|
||||
private final RolePermissionRepository permRepo;
|
||||
|
||||
/**
|
||||
* 모듈-액션 기반 권한 체크
|
||||
*/
|
||||
@Cacheable(value = "permissions", key = "#module + ':' + #action + ':' + T(kr.co.accura.wbx.spring.common.SecurityUtils).getCurrentUserId()")
|
||||
public boolean check(String module, String action) {
|
||||
Long userId = SecurityUtils.getCurrentUserId();
|
||||
if (userId == null) return false;
|
||||
return permRepo.existsByUserIdAndModuleAndAction(userId, module, action);
|
||||
}
|
||||
|
||||
/**
|
||||
* dept_scope 반환: 데이터 필터링 범위 결정
|
||||
*/
|
||||
@Cacheable(value = "deptScopes", key = "#module + ':' + #action + ':' + T(kr.co.accura.wbx.spring.common.SecurityUtils).getCurrentUserId()")
|
||||
public DeptScope getScope(String module, String action) {
|
||||
Long userId = SecurityUtils.getCurrentUserId();
|
||||
if (userId == null) return DeptScope.OWN;
|
||||
return permRepo.findMaxDeptScope(userId, module, action)
|
||||
.orElse(DeptScope.OWN);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package kr.co.accura.wbx.spring.rbac;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
@Entity
|
||||
@Table(name = "wbx_role_permissions",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"role_id", "module", "action"}))
|
||||
@Getter @Setter
|
||||
@NoArgsConstructor @AllArgsConstructor @Builder
|
||||
public class RolePermission {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "role_id", nullable = false)
|
||||
private Long roleId;
|
||||
|
||||
@Column(nullable = false, length = 50)
|
||||
private String module;
|
||||
|
||||
@Column(nullable = false, length = 30)
|
||||
private String action;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "dept_scope", length = 10)
|
||||
@Builder.Default
|
||||
private DeptScope deptScope = DeptScope.OWN;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package kr.co.accura.wbx.spring.rbac;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface RolePermissionRepository extends JpaRepository<RolePermission, Long> {
|
||||
|
||||
@Query("""
|
||||
SELECT CASE WHEN COUNT(rp) > 0 THEN true ELSE false END
|
||||
FROM RolePermission rp
|
||||
JOIN kr.co.accura.wbx.spring.rbac.WbxUserRole ur ON rp.roleId = ur.roleId
|
||||
WHERE ur.userId = :userId AND rp.module = :module AND rp.action = :action
|
||||
""")
|
||||
boolean existsByUserIdAndModuleAndAction(@Param("userId") Long userId,
|
||||
@Param("module") String module,
|
||||
@Param("action") String action);
|
||||
|
||||
@Query("""
|
||||
SELECT rp.deptScope FROM RolePermission rp
|
||||
JOIN kr.co.accura.wbx.spring.rbac.WbxUserRole ur ON rp.roleId = ur.roleId
|
||||
WHERE ur.userId = :userId AND rp.module = :module AND rp.action = :action
|
||||
ORDER BY CASE rp.deptScope WHEN 'COMPANY' THEN 3 WHEN 'DEPT' THEN 2 ELSE 1 END DESC
|
||||
""")
|
||||
Optional<DeptScope> findMaxDeptScope(@Param("userId") Long userId,
|
||||
@Param("module") String module,
|
||||
@Param("action") String action);
|
||||
|
||||
List<RolePermission> findByRoleId(Long roleId);
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
void deleteByRoleId(Long roleId);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package kr.co.accura.wbx.spring.rbac;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
@Entity
|
||||
@Table(name = "wbx_roles")
|
||||
@Getter @Setter
|
||||
@NoArgsConstructor @AllArgsConstructor @Builder
|
||||
public class WbxRole {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 30)
|
||||
private String code;
|
||||
|
||||
@Column(nullable = false, length = 100)
|
||||
private String name;
|
||||
|
||||
@Column(length = 500)
|
||||
private String description;
|
||||
|
||||
@Builder.Default
|
||||
@Column(name = "is_system")
|
||||
private boolean isSystem = false;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package kr.co.accura.wbx.spring.rbac;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "wbx_user_roles",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "role_id", "scope_id"}))
|
||||
@Getter @Setter
|
||||
@NoArgsConstructor @AllArgsConstructor @Builder
|
||||
public class WbxUserRole {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private Long userId;
|
||||
|
||||
@Column(name = "role_id", nullable = false)
|
||||
private Long roleId;
|
||||
|
||||
@Column(name = "scope_type", length = 20)
|
||||
private String scopeType; // PROJECT, DEPARTMENT, null
|
||||
|
||||
@Column(name = "scope_id")
|
||||
private Long scopeId;
|
||||
|
||||
@Column(name = "granted_at")
|
||||
@Builder.Default
|
||||
private LocalDateTime grantedAt = LocalDateTime.now();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package kr.co.accura.wbx.spring.rbac;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface WbxUserRoleRepository extends JpaRepository<WbxUserRole, Long> {
|
||||
List<WbxUserRole> findByUserId(Long userId);
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
void deleteByUserId(Long userId);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
kr.co.accura.wbx.spring.config.WbxAutoConfiguration
|
||||
kr.co.accura.wbx.spring.admin.AdminAutoConfiguration
|
||||
@@ -0,0 +1,29 @@
|
||||
# ===== AWS 클라우드 프로필 =====
|
||||
# 사용법: --spring.profiles.active=prod,postgresql,aws
|
||||
# AWS Cognito SSO + S3 연동
|
||||
|
||||
spring:
|
||||
security:
|
||||
oauth2:
|
||||
client:
|
||||
registration:
|
||||
cognito:
|
||||
client-id: ${AWS_COGNITO_CLIENT_ID}
|
||||
client-secret: ${AWS_COGNITO_CLIENT_SECRET}
|
||||
scope: openid,profile,email
|
||||
provider:
|
||||
cognito:
|
||||
issuer-uri: https://cognito-idp.${AWS_REGION:ap-northeast-2}.amazonaws.com/${AWS_USER_POOL_ID}
|
||||
|
||||
wbx:
|
||||
spring:
|
||||
mfa:
|
||||
enabled: true
|
||||
force-for-external: true
|
||||
file:
|
||||
storage-type: aws-s3
|
||||
aws:
|
||||
bucket: ${AWS_S3_BUCKET}
|
||||
region: ${AWS_REGION:ap-northeast-2}
|
||||
access-key: ${AWS_ACCESS_KEY}
|
||||
secret-key: ${AWS_SECRET_KEY}
|
||||
@@ -0,0 +1,28 @@
|
||||
# ===== Azure 클라우드 프로필 =====
|
||||
# 사용법: --spring.profiles.active=prod,mssql,azure
|
||||
# Azure Entra SSO + Blob Storage + Key Vault 연동
|
||||
|
||||
spring:
|
||||
security:
|
||||
oauth2:
|
||||
client:
|
||||
registration:
|
||||
azure:
|
||||
client-id: ${AZURE_CLIENT_ID}
|
||||
client-secret: ${AZURE_CLIENT_SECRET}
|
||||
scope: openid,profile,email
|
||||
provider:
|
||||
azure:
|
||||
issuer-uri: https://login.microsoftonline.com/${AZURE_TENANT_ID}/v2.0
|
||||
|
||||
wbx:
|
||||
spring:
|
||||
mfa:
|
||||
enabled: false # Azure Entra Conditional Access가 MFA 처리
|
||||
force-for-external: true
|
||||
file:
|
||||
storage-type: azure-blob
|
||||
azure:
|
||||
account-name: ${AZURE_STORAGE_ACCOUNT}
|
||||
account-key: ${AZURE_STORAGE_KEY}
|
||||
container-name: ${AZURE_CONTAINER:uploads}
|
||||
@@ -0,0 +1,69 @@
|
||||
spring:
|
||||
application:
|
||||
name: wbx-spring-core
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: update
|
||||
open-in-view: false
|
||||
database-platform: org.hibernate.dialect.MySQLDialect
|
||||
properties:
|
||||
hibernate:
|
||||
default_batch_fetch_size: 100
|
||||
format_sql: true
|
||||
dialect: org.hibernate.dialect.MySQLDialect
|
||||
|
||||
flyway:
|
||||
enabled: false # 개발 시 hibernate ddl-auto 사용, 프로덕션 시 true
|
||||
|
||||
datasource:
|
||||
url: jdbc:mysql://${DB_HOST:ws.ubuilder.co.kr}:${DB_PORT:3306}/${DB_NAME:mos}?useUnicode=true&characterEncoding=UTF-8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul
|
||||
username: ${DB_USER:jsh}
|
||||
password: ${DB_PASS:jsh@}
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
hikari:
|
||||
maximum-pool-size: 20
|
||||
minimum-idle: 5
|
||||
connection-timeout: 30000
|
||||
|
||||
data:
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
forward-headers-strategy: native
|
||||
servlet:
|
||||
context-path: ${SERVER_CONTEXT_PATH:/}
|
||||
|
||||
# WBX Spring Framework
|
||||
wbx:
|
||||
spring:
|
||||
api-prefix: /api
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:wbx-spring-dev-secret-key-change-in-production-minimum-256-bits-long}
|
||||
expiration: 28800
|
||||
cors:
|
||||
allowed-origins: ${CORS_ORIGINS:http://localhost:5173,http://localhost:3000,http://localhost:8080}
|
||||
notification:
|
||||
sse-enabled: true
|
||||
heartbeat-seconds: 30
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics
|
||||
endpoint:
|
||||
health:
|
||||
show-details: when-authorized
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /api-docs
|
||||
swagger-ui:
|
||||
path: /swagger-ui
|
||||
packages-to-scan: kr.co.accura.wbx.spring
|
||||
|
||||
spring.mvc.problemdetail.enabled: false
|
||||
@@ -0,0 +1,22 @@
|
||||
# ===== MSSQL 프로필 =====
|
||||
# 사용법: --spring.profiles.active=prod,mssql
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:sqlserver://${DB_HOST:localhost}:${DB_PORT:1433};databaseName=${DB_NAME:wbx_spring};encrypt=true;trustServerCertificate=true
|
||||
username: ${DB_USER:sa}
|
||||
password: ${DB_PASS:password}
|
||||
driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver
|
||||
hikari:
|
||||
maximum-pool-size: ${DB_POOL_SIZE:20}
|
||||
minimum-idle: 5
|
||||
connection-timeout: 30000
|
||||
|
||||
jpa:
|
||||
database-platform: org.hibernate.dialect.SQLServerDialect
|
||||
properties:
|
||||
hibernate:
|
||||
dialect: org.hibernate.dialect.SQLServerDialect
|
||||
|
||||
flyway:
|
||||
locations: classpath:db/migration/common,classpath:db/migration/mssql
|
||||
@@ -0,0 +1,22 @@
|
||||
# ===== MySQL 프로필 =====
|
||||
# 사용법: --spring.profiles.active=prod,mysql
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:mysql://${DB_HOST:ws.ubuilder.co.kr}:${DB_PORT:3306}/${DB_NAME:mos}?useUnicode=true&characterEncoding=UTF-8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul
|
||||
username: ${DB_USER:jsh}
|
||||
password: ${DB_PASS:jsh@}
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
hikari:
|
||||
maximum-pool-size: ${DB_POOL_SIZE:20}
|
||||
minimum-idle: 5
|
||||
connection-timeout: 30000
|
||||
|
||||
jpa:
|
||||
database-platform: org.hibernate.dialect.MySQLDialect
|
||||
properties:
|
||||
hibernate:
|
||||
dialect: org.hibernate.dialect.MySQLDialect
|
||||
|
||||
flyway:
|
||||
locations: classpath:db/migration/common,classpath:db/migration/mysql
|
||||
@@ -0,0 +1,22 @@
|
||||
# ===== Oracle 프로필 =====
|
||||
# 사용법: --spring.profiles.active=prod,oracle
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:oracle:thin:@${DB_HOST:localhost}:${DB_PORT:1521}:${DB_SID:ORCL}
|
||||
username: ${DB_USER:wbxapp}
|
||||
password: ${DB_PASS:password}
|
||||
driver-class-name: oracle.jdbc.OracleDriver
|
||||
hikari:
|
||||
maximum-pool-size: ${DB_POOL_SIZE:20}
|
||||
minimum-idle: 5
|
||||
connection-timeout: 30000
|
||||
|
||||
jpa:
|
||||
database-platform: org.hibernate.dialect.OracleDialect
|
||||
properties:
|
||||
hibernate:
|
||||
dialect: org.hibernate.dialect.OracleDialect
|
||||
|
||||
flyway:
|
||||
locations: classpath:db/migration/common,classpath:db/migration/oracle
|
||||
@@ -0,0 +1,22 @@
|
||||
# ===== PostgreSQL 프로필 =====
|
||||
# 사용법: --spring.profiles.active=prod,postgresql
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:wbx_spring}
|
||||
username: ${DB_USER:wbxapp}
|
||||
password: ${DB_PASS:password}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
hikari:
|
||||
maximum-pool-size: ${DB_POOL_SIZE:20}
|
||||
minimum-idle: 5
|
||||
connection-timeout: 30000
|
||||
|
||||
jpa:
|
||||
database-platform: org.hibernate.dialect.PostgreSQLDialect
|
||||
properties:
|
||||
hibernate:
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
|
||||
flyway:
|
||||
locations: classpath:db/migration/common,classpath:db/migration/postgresql
|
||||
@@ -0,0 +1,52 @@
|
||||
# ===== WBX Spring Framework — 프로덕션 프로필 =====
|
||||
# 사용법: java -jar app.jar --spring.profiles.active=prod,mysql
|
||||
# java -jar app.jar --spring.profiles.active=prod,postgresql
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
forward-headers-strategy: native
|
||||
servlet:
|
||||
context-path: ${SERVER_CONTEXT_PATH:/}
|
||||
|
||||
spring:
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: validate # 프로덕션: Flyway 사용, DDL 검증만
|
||||
open-in-view: false
|
||||
properties:
|
||||
hibernate:
|
||||
default_batch_fetch_size: 100
|
||||
|
||||
flyway:
|
||||
enabled: true
|
||||
|
||||
wbx:
|
||||
spring:
|
||||
jwt:
|
||||
secret: ${JWT_SECRET}
|
||||
expiration: 28800
|
||||
cors:
|
||||
allowed-origins: ${CORS_ORIGINS:https://app.company.com}
|
||||
notification:
|
||||
sse-enabled: true
|
||||
heartbeat-seconds: 30
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics,prometheus
|
||||
endpoint:
|
||||
health:
|
||||
show-details: when-authorized
|
||||
|
||||
springdoc:
|
||||
swagger-ui:
|
||||
enabled: false # 프로덕션 Swagger 비활성화
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: WARN
|
||||
kr.co.accura.wbx.spring: INFO
|
||||
file:
|
||||
name: ${LOG_PATH:/opt/wbx-app/logs/app.log}
|
||||
@@ -0,0 +1,38 @@
|
||||
# ===== 테스트 프로필 =====
|
||||
# 사용법: ./gradlew test (자동 적용)
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
|
||||
username: sa
|
||||
password:
|
||||
driver-class-name: org.h2.Driver
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: create-drop
|
||||
database-platform: org.hibernate.dialect.H2Dialect
|
||||
|
||||
flyway:
|
||||
enabled: false
|
||||
|
||||
data:
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
|
||||
wbx:
|
||||
spring:
|
||||
jwt:
|
||||
secret: test-secret-key-minimum-256-bits-for-hmac-sha-algorithm
|
||||
expiration: 3600
|
||||
mfa:
|
||||
enabled: false
|
||||
file:
|
||||
storage-type: local
|
||||
upload-dir: ./build/test-uploads
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: WARN
|
||||
kr.co.accura.wbx.spring: DEBUG
|
||||
@@ -0,0 +1,8 @@
|
||||
-- WBX Spring Framework — 초기 역할 데이터
|
||||
INSERT IGNORE INTO wbx_roles (code, name, description, is_system) VALUES
|
||||
('SA', 'System Administrator', '전체 시스템 관리', true),
|
||||
('PM', 'Project Manager', '프로젝트 관리, 최종 결재', true),
|
||||
('PCM', 'Project Control Mgr', 'WBS/TEAL 관리', true),
|
||||
('PTK', 'Project Timekeeper', '시수 관리', true),
|
||||
('DL', 'Discipline Lead', '1차 결재, Discipline 관리', true),
|
||||
('USER', 'General User', '일반 사용자', true);
|
||||
@@ -0,0 +1,10 @@
|
||||
-- WBX Spring Framework — 초기 시스템 설정
|
||||
INSERT IGNORE INTO wbx_system_config (config_key, config_value, value_type, description) VALUES
|
||||
('auth.max_failed_attempts', '5', 'INT', '최대 로그인 실패 횟수'),
|
||||
('auth.lockout_minutes', '15', 'INT', '계정 잠금 시간(분)'),
|
||||
('auth.password_expiry_days', '90', 'INT', '비밀번호 만료 기간(일)'),
|
||||
('auth.session_timeout_minutes', '480', 'INT', '세션 타임아웃(분)'),
|
||||
('notification.sse_heartbeat_seconds', '30', 'INT', 'SSE 하트비트 주기(초)'),
|
||||
('file.max_upload_size_mb', '50', 'INT', '최대 업로드 크기(MB)'),
|
||||
('app.timezone', 'Asia/Seoul', 'STRING', '시스템 타임존'),
|
||||
('app.date_format', 'yyyy-MM-dd', 'STRING', '날짜 표시 형식');
|
||||
@@ -0,0 +1,134 @@
|
||||
-- WBX Spring Framework — MSSQL 스키마
|
||||
|
||||
CREATE TABLE wbx_users (
|
||||
id BIGINT IDENTITY(1,1) PRIMARY KEY,
|
||||
email NVARCHAR(255) NOT NULL UNIQUE,
|
||||
username NVARCHAR(100) NOT NULL UNIQUE,
|
||||
hashed_password NVARCHAR(500),
|
||||
full_name NVARCHAR(255),
|
||||
phone NVARCHAR(50),
|
||||
department_id BIGINT,
|
||||
position_title NVARCHAR(100),
|
||||
employee_number NVARCHAR(50) UNIQUE,
|
||||
is_active BIT DEFAULT 1,
|
||||
is_admin BIT DEFAULT 0,
|
||||
mfa_enabled BIT DEFAULT 0,
|
||||
azure_oid NVARCHAR(255),
|
||||
sso_provider NVARCHAR(50),
|
||||
failed_login_attempts INT DEFAULT 0,
|
||||
last_failed_login DATETIME2,
|
||||
locked_until DATETIME2,
|
||||
password_changed_at DATETIME2,
|
||||
must_change_password BIT DEFAULT 0,
|
||||
last_login_at DATETIME2,
|
||||
created_at DATETIME2 DEFAULT GETDATE(),
|
||||
updated_at DATETIME2 DEFAULT GETDATE(),
|
||||
created_by BIGINT,
|
||||
updated_by BIGINT
|
||||
);
|
||||
|
||||
CREATE TABLE wbx_roles (
|
||||
id BIGINT IDENTITY(1,1) PRIMARY KEY,
|
||||
code NVARCHAR(50) NOT NULL UNIQUE,
|
||||
name NVARCHAR(100) NOT NULL,
|
||||
description NVARCHAR(500),
|
||||
is_system BIT DEFAULT 0,
|
||||
created_at DATETIME2 DEFAULT GETDATE(),
|
||||
updated_at DATETIME2 DEFAULT GETDATE()
|
||||
);
|
||||
|
||||
CREATE TABLE wbx_user_roles (
|
||||
id BIGINT IDENTITY(1,1) PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL FOREIGN KEY REFERENCES wbx_users(id),
|
||||
role_id BIGINT NOT NULL FOREIGN KEY REFERENCES wbx_roles(id),
|
||||
CONSTRAINT uk_wbx_user_role UNIQUE (user_id, role_id)
|
||||
);
|
||||
|
||||
CREATE TABLE wbx_role_permissions (
|
||||
id BIGINT IDENTITY(1,1) PRIMARY KEY,
|
||||
role_id BIGINT NOT NULL FOREIGN KEY REFERENCES wbx_roles(id),
|
||||
module NVARCHAR(100) NOT NULL,
|
||||
action NVARCHAR(50) NOT NULL,
|
||||
dept_scope NVARCHAR(20) DEFAULT 'OWN',
|
||||
CONSTRAINT uk_wbx_role_perm UNIQUE (role_id, module, action)
|
||||
);
|
||||
|
||||
CREATE TABLE wbx_refresh_tokens (
|
||||
id BIGINT IDENTITY(1,1) PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL FOREIGN KEY REFERENCES wbx_users(id),
|
||||
token_hash NVARCHAR(500) NOT NULL,
|
||||
device_info NVARCHAR(500),
|
||||
ip_address NVARCHAR(50),
|
||||
expires_at DATETIME2 NOT NULL,
|
||||
created_at DATETIME2 DEFAULT GETDATE()
|
||||
);
|
||||
|
||||
CREATE TABLE wbx_totp_secrets (
|
||||
id BIGINT IDENTITY(1,1) PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL UNIQUE FOREIGN KEY REFERENCES wbx_users(id),
|
||||
encrypted_secret NVARCHAR(500) NOT NULL,
|
||||
verified BIT DEFAULT 0,
|
||||
backup_codes NVARCHAR(2000),
|
||||
created_at DATETIME2 DEFAULT GETDATE(),
|
||||
updated_at DATETIME2 DEFAULT GETDATE(),
|
||||
created_by BIGINT,
|
||||
updated_by BIGINT
|
||||
);
|
||||
|
||||
CREATE TABLE wbx_login_history (
|
||||
id BIGINT IDENTITY(1,1) PRIMARY KEY,
|
||||
user_id BIGINT,
|
||||
email NVARCHAR(255),
|
||||
action NVARCHAR(50),
|
||||
auth_method NVARCHAR(50),
|
||||
ip_address NVARCHAR(50),
|
||||
user_agent NVARCHAR(500),
|
||||
failure_reason NVARCHAR(500),
|
||||
created_at DATETIME2 DEFAULT GETDATE()
|
||||
);
|
||||
|
||||
CREATE TABLE wbx_notifications (
|
||||
id BIGINT IDENTITY(1,1) PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL FOREIGN KEY REFERENCES wbx_users(id),
|
||||
title NVARCHAR(500),
|
||||
message NVARCHAR(MAX),
|
||||
type NVARCHAR(50),
|
||||
is_read BIT DEFAULT 0,
|
||||
link NVARCHAR(1000),
|
||||
created_at DATETIME2 DEFAULT GETDATE()
|
||||
);
|
||||
|
||||
CREATE TABLE wbx_audit_logs (
|
||||
id BIGINT IDENTITY(1,1) PRIMARY KEY,
|
||||
user_id BIGINT,
|
||||
username NVARCHAR(255),
|
||||
action NVARCHAR(50) NOT NULL,
|
||||
resource NVARCHAR(100) NOT NULL,
|
||||
resource_id BIGINT,
|
||||
detail NVARCHAR(4000),
|
||||
ip_address NVARCHAR(50),
|
||||
created_at DATETIME2 DEFAULT GETDATE()
|
||||
);
|
||||
|
||||
CREATE TABLE wbx_file_uploads (
|
||||
id BIGINT IDENTITY(1,1) PRIMARY KEY,
|
||||
user_id BIGINT,
|
||||
original_name NVARCHAR(500),
|
||||
stored_name NVARCHAR(500),
|
||||
file_key NVARCHAR(500),
|
||||
content_type NVARCHAR(200),
|
||||
file_size BIGINT,
|
||||
category NVARCHAR(100),
|
||||
created_at DATETIME2 DEFAULT GETDATE()
|
||||
);
|
||||
|
||||
CREATE TABLE wbx_system_config (
|
||||
id BIGINT IDENTITY(1,1) PRIMARY KEY,
|
||||
config_key NVARCHAR(100) NOT NULL UNIQUE,
|
||||
config_value NVARCHAR(4000),
|
||||
value_type NVARCHAR(20) DEFAULT 'STRING',
|
||||
description NVARCHAR(500),
|
||||
is_editable BIT DEFAULT 1,
|
||||
updated_at DATETIME2 DEFAULT GETDATE(),
|
||||
updated_by BIGINT
|
||||
);
|
||||
@@ -0,0 +1,141 @@
|
||||
-- WBX Spring Framework — MySQL 스키마 (utf8mb4)
|
||||
-- Hibernate ddl-auto와 병행 사용 가능 (Flyway 활성화 시 이 파일 사용)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wbx_users (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
username VARCHAR(100) NOT NULL UNIQUE,
|
||||
hashed_password VARCHAR(500),
|
||||
full_name VARCHAR(255),
|
||||
phone VARCHAR(50),
|
||||
department_id BIGINT,
|
||||
position_title VARCHAR(100),
|
||||
employee_number VARCHAR(50) UNIQUE,
|
||||
is_active TINYINT(1) DEFAULT 1,
|
||||
is_admin TINYINT(1) DEFAULT 0,
|
||||
mfa_enabled TINYINT(1) DEFAULT 0,
|
||||
azure_oid VARCHAR(255),
|
||||
sso_provider VARCHAR(50),
|
||||
failed_login_attempts INT DEFAULT 0,
|
||||
last_failed_login DATETIME,
|
||||
locked_until DATETIME,
|
||||
password_changed_at DATETIME,
|
||||
must_change_password TINYINT(1) DEFAULT 0,
|
||||
last_login_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
created_by BIGINT,
|
||||
updated_by BIGINT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wbx_roles (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
code VARCHAR(50) NOT NULL UNIQUE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description VARCHAR(500),
|
||||
is_system TINYINT(1) DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wbx_user_roles (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
role_id BIGINT NOT NULL,
|
||||
UNIQUE KEY uk_user_role (user_id, role_id),
|
||||
FOREIGN KEY (user_id) REFERENCES wbx_users(id),
|
||||
FOREIGN KEY (role_id) REFERENCES wbx_roles(id)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wbx_role_permissions (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
role_id BIGINT NOT NULL,
|
||||
module VARCHAR(100) NOT NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
dept_scope VARCHAR(20) DEFAULT 'OWN',
|
||||
UNIQUE KEY uk_role_perm (role_id, module, action),
|
||||
FOREIGN KEY (role_id) REFERENCES wbx_roles(id)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wbx_refresh_tokens (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
token_hash VARCHAR(500) NOT NULL,
|
||||
device_info VARCHAR(500),
|
||||
ip_address VARCHAR(50),
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES wbx_users(id)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wbx_totp_secrets (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL UNIQUE,
|
||||
encrypted_secret VARCHAR(500) NOT NULL,
|
||||
verified TINYINT(1) DEFAULT 0,
|
||||
backup_codes VARCHAR(2000),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
created_by BIGINT,
|
||||
updated_by BIGINT,
|
||||
FOREIGN KEY (user_id) REFERENCES wbx_users(id)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wbx_login_history (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT,
|
||||
email VARCHAR(255),
|
||||
action VARCHAR(50),
|
||||
auth_method VARCHAR(50),
|
||||
ip_address VARCHAR(50),
|
||||
user_agent VARCHAR(500),
|
||||
failure_reason VARCHAR(500),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wbx_notifications (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
title VARCHAR(500),
|
||||
message TEXT,
|
||||
type VARCHAR(50),
|
||||
is_read TINYINT(1) DEFAULT 0,
|
||||
link VARCHAR(1000),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES wbx_users(id)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wbx_audit_logs (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT,
|
||||
username VARCHAR(255),
|
||||
action VARCHAR(50) NOT NULL,
|
||||
resource VARCHAR(100) NOT NULL,
|
||||
resource_id BIGINT,
|
||||
detail VARCHAR(4000),
|
||||
ip_address VARCHAR(50),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wbx_file_uploads (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT,
|
||||
original_name VARCHAR(500),
|
||||
stored_name VARCHAR(500),
|
||||
file_key VARCHAR(500),
|
||||
content_type VARCHAR(200),
|
||||
file_size BIGINT,
|
||||
category VARCHAR(100),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wbx_system_config (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
config_key VARCHAR(100) NOT NULL UNIQUE,
|
||||
config_value VARCHAR(4000),
|
||||
value_type VARCHAR(20) DEFAULT 'STRING',
|
||||
description VARCHAR(500),
|
||||
is_editable TINYINT(1) DEFAULT 1,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
updated_by BIGINT
|
||||
) ENGINE=InnoDB;
|
||||
@@ -0,0 +1,134 @@
|
||||
-- WBX Spring Framework — Oracle 스키마
|
||||
|
||||
CREATE TABLE wbx_users (
|
||||
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
email VARCHAR2(255) NOT NULL UNIQUE,
|
||||
username VARCHAR2(100) NOT NULL UNIQUE,
|
||||
hashed_password VARCHAR2(500),
|
||||
full_name VARCHAR2(255),
|
||||
phone VARCHAR2(50),
|
||||
department_id NUMBER(19),
|
||||
position_title VARCHAR2(100),
|
||||
employee_number VARCHAR2(50) UNIQUE,
|
||||
is_active NUMBER(1) DEFAULT 1,
|
||||
is_admin NUMBER(1) DEFAULT 0,
|
||||
mfa_enabled NUMBER(1) DEFAULT 0,
|
||||
azure_oid VARCHAR2(255),
|
||||
sso_provider VARCHAR2(50),
|
||||
failed_login_attempts NUMBER(10) DEFAULT 0,
|
||||
last_failed_login TIMESTAMP,
|
||||
locked_until TIMESTAMP,
|
||||
password_changed_at TIMESTAMP,
|
||||
must_change_password NUMBER(1) DEFAULT 0,
|
||||
last_login_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT SYSTIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP,
|
||||
created_by NUMBER(19),
|
||||
updated_by NUMBER(19)
|
||||
);
|
||||
|
||||
CREATE TABLE wbx_roles (
|
||||
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
code VARCHAR2(50) NOT NULL UNIQUE,
|
||||
name VARCHAR2(100) NOT NULL,
|
||||
description VARCHAR2(500),
|
||||
is_system NUMBER(1) DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT SYSTIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE wbx_user_roles (
|
||||
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
user_id NUMBER(19) NOT NULL REFERENCES wbx_users(id),
|
||||
role_id NUMBER(19) NOT NULL REFERENCES wbx_roles(id),
|
||||
CONSTRAINT uk_wbx_user_role UNIQUE (user_id, role_id)
|
||||
);
|
||||
|
||||
CREATE TABLE wbx_role_permissions (
|
||||
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
role_id NUMBER(19) NOT NULL REFERENCES wbx_roles(id),
|
||||
module VARCHAR2(100) NOT NULL,
|
||||
action VARCHAR2(50) NOT NULL,
|
||||
dept_scope VARCHAR2(20) DEFAULT 'OWN',
|
||||
CONSTRAINT uk_wbx_role_perm UNIQUE (role_id, module, action)
|
||||
);
|
||||
|
||||
CREATE TABLE wbx_refresh_tokens (
|
||||
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
user_id NUMBER(19) NOT NULL REFERENCES wbx_users(id),
|
||||
token_hash VARCHAR2(500) NOT NULL,
|
||||
device_info VARCHAR2(500),
|
||||
ip_address VARCHAR2(50),
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT SYSTIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE wbx_totp_secrets (
|
||||
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
user_id NUMBER(19) NOT NULL UNIQUE REFERENCES wbx_users(id),
|
||||
encrypted_secret VARCHAR2(500) NOT NULL,
|
||||
verified NUMBER(1) DEFAULT 0,
|
||||
backup_codes VARCHAR2(2000),
|
||||
created_at TIMESTAMP DEFAULT SYSTIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP,
|
||||
created_by NUMBER(19),
|
||||
updated_by NUMBER(19)
|
||||
);
|
||||
|
||||
CREATE TABLE wbx_login_history (
|
||||
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
user_id NUMBER(19),
|
||||
email VARCHAR2(255),
|
||||
action VARCHAR2(50),
|
||||
auth_method VARCHAR2(50),
|
||||
ip_address VARCHAR2(50),
|
||||
user_agent VARCHAR2(500),
|
||||
failure_reason VARCHAR2(500),
|
||||
created_at TIMESTAMP DEFAULT SYSTIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE wbx_notifications (
|
||||
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
user_id NUMBER(19) NOT NULL REFERENCES wbx_users(id),
|
||||
title VARCHAR2(500),
|
||||
message CLOB,
|
||||
type VARCHAR2(50),
|
||||
is_read NUMBER(1) DEFAULT 0,
|
||||
link VARCHAR2(1000),
|
||||
created_at TIMESTAMP DEFAULT SYSTIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE wbx_audit_logs (
|
||||
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
user_id NUMBER(19),
|
||||
username VARCHAR2(255),
|
||||
action VARCHAR2(50) NOT NULL,
|
||||
resource VARCHAR2(100) NOT NULL,
|
||||
resource_id NUMBER(19),
|
||||
detail VARCHAR2(4000),
|
||||
ip_address VARCHAR2(50),
|
||||
created_at TIMESTAMP DEFAULT SYSTIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE wbx_file_uploads (
|
||||
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
user_id NUMBER(19),
|
||||
original_name VARCHAR2(500),
|
||||
stored_name VARCHAR2(500),
|
||||
file_key VARCHAR2(500),
|
||||
content_type VARCHAR2(200),
|
||||
file_size NUMBER(19),
|
||||
category VARCHAR2(100),
|
||||
created_at TIMESTAMP DEFAULT SYSTIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE wbx_system_config (
|
||||
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
config_key VARCHAR2(100) NOT NULL UNIQUE,
|
||||
config_value VARCHAR2(4000),
|
||||
value_type VARCHAR2(20) DEFAULT 'STRING',
|
||||
description VARCHAR2(500),
|
||||
is_editable NUMBER(1) DEFAULT 1,
|
||||
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP,
|
||||
updated_by NUMBER(19)
|
||||
);
|
||||
@@ -0,0 +1,134 @@
|
||||
-- WBX Spring Framework — PostgreSQL 스키마
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wbx_users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
username VARCHAR(100) NOT NULL UNIQUE,
|
||||
hashed_password VARCHAR(500),
|
||||
full_name VARCHAR(255),
|
||||
phone VARCHAR(50),
|
||||
department_id BIGINT,
|
||||
position_title VARCHAR(100),
|
||||
employee_number VARCHAR(50) UNIQUE,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
is_admin BOOLEAN DEFAULT FALSE,
|
||||
mfa_enabled BOOLEAN DEFAULT FALSE,
|
||||
azure_oid VARCHAR(255),
|
||||
sso_provider VARCHAR(50),
|
||||
failed_login_attempts INT DEFAULT 0,
|
||||
last_failed_login TIMESTAMP,
|
||||
locked_until TIMESTAMP,
|
||||
password_changed_at TIMESTAMP,
|
||||
must_change_password BOOLEAN DEFAULT FALSE,
|
||||
last_login_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
created_by BIGINT,
|
||||
updated_by BIGINT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wbx_roles (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
code VARCHAR(50) NOT NULL UNIQUE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description VARCHAR(500),
|
||||
is_system BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wbx_user_roles (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES wbx_users(id),
|
||||
role_id BIGINT NOT NULL REFERENCES wbx_roles(id),
|
||||
UNIQUE (user_id, role_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wbx_role_permissions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
role_id BIGINT NOT NULL REFERENCES wbx_roles(id),
|
||||
module VARCHAR(100) NOT NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
dept_scope VARCHAR(20) DEFAULT 'OWN',
|
||||
UNIQUE (role_id, module, action)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wbx_refresh_tokens (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES wbx_users(id),
|
||||
token_hash VARCHAR(500) NOT NULL,
|
||||
device_info VARCHAR(500),
|
||||
ip_address VARCHAR(50),
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wbx_totp_secrets (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL UNIQUE REFERENCES wbx_users(id),
|
||||
encrypted_secret VARCHAR(500) NOT NULL,
|
||||
verified BOOLEAN DEFAULT FALSE,
|
||||
backup_codes VARCHAR(2000),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
created_by BIGINT,
|
||||
updated_by BIGINT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wbx_login_history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT,
|
||||
email VARCHAR(255),
|
||||
action VARCHAR(50),
|
||||
auth_method VARCHAR(50),
|
||||
ip_address VARCHAR(50),
|
||||
user_agent VARCHAR(500),
|
||||
failure_reason VARCHAR(500),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wbx_notifications (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES wbx_users(id),
|
||||
title VARCHAR(500),
|
||||
message TEXT,
|
||||
type VARCHAR(50),
|
||||
is_read BOOLEAN DEFAULT FALSE,
|
||||
link VARCHAR(1000),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wbx_audit_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT,
|
||||
username VARCHAR(255),
|
||||
action VARCHAR(50) NOT NULL,
|
||||
resource VARCHAR(100) NOT NULL,
|
||||
resource_id BIGINT,
|
||||
detail VARCHAR(4000),
|
||||
ip_address VARCHAR(50),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wbx_file_uploads (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT,
|
||||
original_name VARCHAR(500),
|
||||
stored_name VARCHAR(500),
|
||||
file_key VARCHAR(500),
|
||||
content_type VARCHAR(200),
|
||||
file_size BIGINT,
|
||||
category VARCHAR(100),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wbx_system_config (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
config_key VARCHAR(100) NOT NULL UNIQUE,
|
||||
config_value VARCHAR(4000),
|
||||
value_type VARCHAR(20) DEFAULT 'STRING',
|
||||
description VARCHAR(500),
|
||||
is_editable BOOLEAN DEFAULT TRUE,
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_by BIGINT
|
||||
);
|
||||
@@ -0,0 +1,64 @@
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Malgun Gothic', -apple-system, sans-serif; background: #f0f2f5; color: #333; }
|
||||
|
||||
/* Layout */
|
||||
.admin-layout { display: flex; min-height: 100vh; }
|
||||
.admin-sidebar { width: 220px; background: #1e3c78; color: #fff; padding: 0; flex-shrink: 0; }
|
||||
.admin-sidebar .logo { padding: 20px; font-size: 18px; font-weight: bold; border-bottom: 1px solid rgba(255,255,255,0.1); }
|
||||
.admin-sidebar nav a { display: block; padding: 12px 20px; color: rgba(255,255,255,0.7); text-decoration: none; font-size: 14px; border-left: 3px solid transparent; }
|
||||
.admin-sidebar nav a:hover { background: rgba(255,255,255,0.05); color: #fff; }
|
||||
.admin-sidebar nav a.active { background: rgba(255,255,255,0.1); color: #fff; border-left-color: #4da6ff; }
|
||||
.admin-content { flex: 1; padding: 24px; }
|
||||
|
||||
/* Header */
|
||||
.page-header { margin-bottom: 24px; }
|
||||
.page-header h1 { font-size: 22px; color: #1e3c78; }
|
||||
.page-header p { color: #888; font-size: 13px; margin-top: 4px; }
|
||||
|
||||
/* Cards */
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
||||
.stat-card { background: #fff; border-radius: 8px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||
.stat-card .label { font-size: 13px; color: #888; }
|
||||
.stat-card .value { font-size: 28px; font-weight: bold; color: #1e3c78; margin-top: 4px; }
|
||||
.stat-card .value.green { color: #2e7d32; }
|
||||
.stat-card .value.orange { color: #e65100; }
|
||||
|
||||
/* Table */
|
||||
.data-table { width: 100%; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||
.data-table table { width: 100%; border-collapse: collapse; }
|
||||
.data-table th { background: #1e3c78; color: #fff; padding: 10px 14px; text-align: left; font-size: 13px; font-weight: 500; }
|
||||
.data-table td { padding: 10px 14px; border-bottom: 1px solid #f0f0f0; font-size: 13px; }
|
||||
.data-table tr:hover td { background: #f8f9ff; }
|
||||
|
||||
/* Badges */
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 500; }
|
||||
.badge-success { background: #e8f5e9; color: #2e7d32; }
|
||||
.badge-danger { background: #ffebee; color: #c62828; }
|
||||
.badge-warning { background: #fff8e1; color: #e65100; }
|
||||
.badge-info { background: #e3f2fd; color: #1565c0; }
|
||||
|
||||
/* Buttons */
|
||||
.btn { display: inline-block; padding: 6px 14px; border-radius: 4px; font-size: 13px; text-decoration: none; border: none; cursor: pointer; }
|
||||
.btn-primary { background: #1e3c78; color: #fff; }
|
||||
.btn-danger { background: #c62828; color: #fff; }
|
||||
.btn-outline { background: #fff; border: 1px solid #ddd; color: #555; }
|
||||
.btn:hover { opacity: 0.85; }
|
||||
|
||||
/* Alert */
|
||||
.alert { padding: 12px 16px; border-radius: 6px; margin-bottom: 16px; font-size: 13px; }
|
||||
.alert-success { background: #e8f5e9; color: #2e7d32; border: 1px solid #c8e6c9; }
|
||||
.alert-info { background: #e3f2fd; color: #1565c0; }
|
||||
|
||||
/* Detail */
|
||||
.detail-grid { display: grid; grid-template-columns: 140px 1fr; gap: 8px 16px; background: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); margin-bottom: 16px; }
|
||||
.detail-grid .label { font-weight: 500; color: #888; font-size: 13px; }
|
||||
.detail-grid .value { font-size: 14px; }
|
||||
|
||||
/* Login */
|
||||
.login-container { display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #1e3c78; }
|
||||
.login-box { background: #fff; padding: 40px; border-radius: 12px; width: 380px; box-shadow: 0 4px 20px rgba(0,0,0,0.2); }
|
||||
.login-box h2 { text-align: center; color: #1e3c78; margin-bottom: 24px; }
|
||||
.login-box input { width: 100%; padding: 10px 12px; border: 1px solid #ddd; border-radius: 6px; margin-bottom: 12px; font-size: 14px; }
|
||||
.login-box button { width: 100%; padding: 12px; background: #1e3c78; color: #fff; border: none; border-radius: 6px; font-size: 15px; cursor: pointer; }
|
||||
.login-box button:hover { background: #15306a; }
|
||||
.login-box .error { color: #c62828; font-size: 13px; text-align: center; margin-bottom: 12px; }
|
||||
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head><meta charset="UTF-8"><title>WBX Admin - Audit Logs</title><link rel="stylesheet" th:href="@{/admin/css/admin.css}"></head>
|
||||
<body>
|
||||
<div class="admin-layout">
|
||||
<div th:replace="~{admin/fragments :: sidebar('audit-logs')}"></div>
|
||||
<div class="admin-content">
|
||||
<div class="page-header"><h1>감사 로그</h1><p>최근 100건</p></div>
|
||||
<div class="data-table">
|
||||
<table>
|
||||
<thead><tr><th>시간</th><th>사용자ID</th><th>액션</th><th>리소스</th><th>리소스ID</th><th>IP</th><th>상세</th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="log : ${logs}">
|
||||
<td th:text="${log.createdAt != null ? #temporals.format(log.createdAt, 'yy-MM-dd HH:mm:ss') : ''}"></td>
|
||||
<td th:text="${log.userId}"></td>
|
||||
<td><span class="badge" th:text="${log.action}"></span></td>
|
||||
<td th:text="${log.resource}"></td>
|
||||
<td th:text="${log.resourceId}"></td>
|
||||
<td th:text="${log.ipAddress}"></td>
|
||||
<td th:text="${log.detail != null ? (#strings.abbreviate(log.detail, 80)) : ''}"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head><meta charset="UTF-8"><title>WBX Admin - System Config</title><link rel="stylesheet" th:href="@{/admin/css/admin.css}"></head>
|
||||
<body>
|
||||
<div class="admin-layout">
|
||||
<div th:replace="~{admin/fragments :: sidebar('config')}"></div>
|
||||
<div class="admin-content">
|
||||
<div class="page-header"><h1>시스템 설정</h1><p>Key-Value 설정 관리</p></div>
|
||||
<div th:if="${message}" class="alert alert-success" th:text="${message}"></div>
|
||||
|
||||
<!-- 설정 추가 폼 -->
|
||||
<div class="card" style="margin-bottom:20px; padding:16px;">
|
||||
<h3>설정 추가/수정</h3>
|
||||
<form th:action="@{/admin/config/save}" method="post" style="display:flex; gap:10px; align-items:end; flex-wrap:wrap;">
|
||||
<div><label>Key</label><input name="configKey" required style="display:block; padding:6px; border:1px solid #ddd; border-radius:4px;"></div>
|
||||
<div><label>Value</label><input name="configValue" required style="display:block; padding:6px; border:1px solid #ddd; border-radius:4px; min-width:200px;"></div>
|
||||
<div><label>설명</label><input name="description" style="display:block; padding:6px; border:1px solid #ddd; border-radius:4px;"></div>
|
||||
<button type="submit" class="btn btn-primary" style="padding:6px 16px;">저장</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 기존 설정 목록 -->
|
||||
<div class="data-table">
|
||||
<table>
|
||||
<thead><tr><th>Key</th><th>Value</th><th>설명</th><th>수정일</th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="cfg : ${configs}">
|
||||
<td><code th:text="${cfg.configKey}"></code></td>
|
||||
<td th:text="${cfg.configValue}"></td>
|
||||
<td th:text="${cfg.description}"></td>
|
||||
<td th:text="${cfg.updatedAt != null ? #temporals.format(cfg.updatedAt, 'yy-MM-dd HH:mm') : ''}"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>WBX Admin - Dashboard</title>
|
||||
<link rel="stylesheet" th:href="@{/admin/css/admin.css}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-layout">
|
||||
<div th:replace="~{admin/fragments :: sidebar('dashboard')}"></div>
|
||||
<div class="admin-content">
|
||||
<div class="page-header">
|
||||
<h1>대시보드</h1>
|
||||
<p>WBX Spring Framework 관리 콘솔</p>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="label">활성 사용자</div>
|
||||
<div class="value" th:text="${userCount}">0</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">전체 사용자</div>
|
||||
<div class="value" th:text="${totalUsers}">0</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">로그인 성공</div>
|
||||
<div class="value green" th:text="${loginCount}">0</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">등록 역할</div>
|
||||
<div class="value" th:text="${roleCount}">0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-header"><h1>빠른 링크</h1></div>
|
||||
<a th:href="@{/admin/users}" class="btn btn-primary" style="margin-right:8px">사용자 관리</a>
|
||||
<a th:href="@{/admin/roles}" class="btn btn-outline" style="margin-right:8px">역할 관리</a>
|
||||
<a th:href="@{/admin/login-history}" class="btn btn-outline">로그인 이력</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<body>
|
||||
<div th:fragment="sidebar(active)" class="admin-sidebar">
|
||||
<div class="logo">WBX Admin</div>
|
||||
<nav>
|
||||
<a th:href="@{/admin}" th:classappend="${active == 'dashboard'} ? 'active'">📊 대시보드</a>
|
||||
<a th:href="@{/admin/users}" th:classappend="${active == 'users'} ? 'active'">👥 사용자 관리</a>
|
||||
<a th:href="@{/admin/roles}" th:classappend="${active == 'roles'} ? 'active'">🔑 역할/권한</a>
|
||||
<a th:href="@{/admin/login-history}" th:classappend="${active == 'login-history'} ? 'active'">📋 로그인 이력</a>
|
||||
<a th:href="@{/admin/audit-logs}" th:classappend="${active == 'audit-logs'} ? 'active'">📝 감사 로그</a>
|
||||
<a th:href="@{/admin/permissions}" th:classappend="${active == 'permissions'} ? 'active'">🛡 권한 매트릭스</a>
|
||||
<a th:href="@{/admin/config}" th:classappend="${active == 'config'} ? 'active'">⚙ 시스템 설정</a>
|
||||
<a th:href="@{/admin/system-health}" th:classappend="${active == 'system-health'} ? 'active'">💻 시스템 상태</a>
|
||||
<a th:href="@{/admin/logout}" style="margin-top:auto; border-top:1px solid rgba(255,255,255,0.1); padding-top:16px;">🚪 로그아웃</a>
|
||||
</nav>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head><meta charset="UTF-8"><title>WBX Admin - Login History</title><link rel="stylesheet" th:href="@{/admin/css/admin.css}"></head>
|
||||
<body>
|
||||
<div class="admin-layout">
|
||||
<div th:replace="~{admin/fragments :: sidebar('login-history')}"></div>
|
||||
<div class="admin-content">
|
||||
<div class="page-header"><h1>로그인 이력</h1><p>최근 50건</p></div>
|
||||
<div class="data-table">
|
||||
<table>
|
||||
<thead><tr><th>시간</th><th>이메일</th><th>액션</th><th>IP</th><th>인증방법</th><th>사유</th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="log : ${logs}">
|
||||
<td th:text="${#temporals.format(log.createdAt, 'yy-MM-dd HH:mm:ss')}"></td>
|
||||
<td th:text="${log.email}"></td>
|
||||
<td>
|
||||
<span th:if="${log.action == 'LOGIN_SUCCESS'}" class="badge badge-success">성공</span>
|
||||
<span th:if="${log.action == 'LOGIN_FAILURE'}" class="badge badge-danger">실패</span>
|
||||
<span th:if="${log.action == 'LOGOUT'}" class="badge badge-info">로그아웃</span>
|
||||
</td>
|
||||
<td th:text="${log.ipAddress}"></td>
|
||||
<td th:text="${log.authMethod}"></td>
|
||||
<td th:text="${log.failureReason}"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>WBX Admin - Login</title>
|
||||
<link rel="stylesheet" th:href="@{/admin/css/admin.css}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<div class="login-box">
|
||||
<h2>WBX Admin</h2>
|
||||
<div th:if="${param.error}" class="error">이메일 또는 비밀번호가 올바르지 않습니다.</div>
|
||||
<div th:if="${param.logout}" class="alert alert-info">로그아웃되었습니다.</div>
|
||||
<form th:action="@{/admin/login}" method="post">
|
||||
<input type="email" name="username" placeholder="이메일" required autofocus>
|
||||
<input type="password" name="password" placeholder="비밀번호" required>
|
||||
<button type="submit">로그인</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head><meta charset="UTF-8"><title>WBX Admin - Permissions</title><link rel="stylesheet" th:href="@{/admin/css/admin.css}"></head>
|
||||
<body>
|
||||
<div class="admin-layout">
|
||||
<div th:replace="~{admin/fragments :: sidebar('permissions')}"></div>
|
||||
<div class="admin-content">
|
||||
<div class="page-header">
|
||||
<h1>권한 매트릭스</h1>
|
||||
<p>전체 역할-모듈-액션 권한 현황. 개별 역할 편집은 <a th:href="@{/admin/roles}" style="color:#1e3c78;">역할 관리</a>에서 가능합니다.</p>
|
||||
</div>
|
||||
<div class="data-table">
|
||||
<table>
|
||||
<thead><tr><th>역할</th><th>모듈</th><th>액션</th><th>범위</th><th>관리</th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="perm : ${permissions}">
|
||||
<td>
|
||||
<span th:each="role : ${roles}" th:if="${role.id == perm.roleId}">
|
||||
<a th:href="@{/admin/roles/{id}(id=${role.id})}" class="badge badge-info" style="text-decoration:none;" th:text="${role.code}"></a>
|
||||
</span>
|
||||
</td>
|
||||
<td th:text="${perm.module}"></td>
|
||||
<td th:text="${perm.action}"></td>
|
||||
<td>
|
||||
<span class="badge" th:classappend="${perm.deptScope?.name() == 'COMPANY'} ? 'badge-success' : (${perm.deptScope?.name() == 'DEPT'} ? 'badge-warning' : '')"
|
||||
th:text="${perm.deptScope?.name()}"></span>
|
||||
</td>
|
||||
<td>
|
||||
<form th:action="@{/admin/permissions/{id}/delete(id=${perm.id})}" method="post" style="display:inline;" onsubmit="return confirm('삭제하시겠습니까?')">
|
||||
<button type="submit" class="btn btn-danger" style="padding:2px 8px;font-size:11px;">삭제</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(permissions)}"><td colspan="5" style="text-align:center;color:#888;padding:20px;">등록된 권한이 없습니다. 역할 관리에서 권한을 추가하세요.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,89 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head><meta charset="UTF-8"><title>WBX Admin - Role Detail</title><link rel="stylesheet" th:href="@{/admin/css/admin.css}"></head>
|
||||
<body>
|
||||
<div class="admin-layout">
|
||||
<div th:replace="~{admin/fragments :: sidebar('roles')}"></div>
|
||||
<div class="admin-content" th:if="${role}">
|
||||
<div class="page-header">
|
||||
<h1>역할 상세: <span th:text="${role.code}"></span></h1>
|
||||
<p><a th:href="@{/admin/roles}" style="color:#1e3c78;">← 역할 목록으로</a></p>
|
||||
</div>
|
||||
<div th:if="${message}" class="alert alert-success" th:text="${message}"></div>
|
||||
<div th:if="${error}" class="alert" style="background:#ffebee;color:#c62828;border:1px solid #ffcdd2;padding:12px 16px;border-radius:6px;margin-bottom:16px;font-size:13px;" th:text="${error}"></div>
|
||||
|
||||
<!-- 역할 정보 수정 -->
|
||||
<div style="background:#fff;border-radius:8px;padding:20px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
|
||||
<h3 style="margin-bottom:12px;font-size:15px;color:#1e3c78;">역할 정보</h3>
|
||||
<form th:action="@{/admin/roles/{id}/update(id=${role.id})}" method="post" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
|
||||
<div>
|
||||
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">코드 (변경 불가)</label>
|
||||
<input th:value="${role.code}" disabled style="padding:6px 10px;border:1px solid #eee;border-radius:4px;font-size:13px;background:#f5f5f5;width:140px;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">이름</label>
|
||||
<input name="name" th:value="${role.name}" required style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:160px;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">설명</label>
|
||||
<input name="description" th:value="${role.description}" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:280px;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">수정</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 권한 추가 -->
|
||||
<div style="background:#fff;border-radius:8px;padding:20px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
|
||||
<h3 style="margin-bottom:12px;font-size:15px;color:#1e3c78;">권한 추가</h3>
|
||||
<form th:action="@{/admin/roles/{id}/permissions/add(id=${role.id})}" method="post" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
|
||||
<div>
|
||||
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">모듈</label>
|
||||
<input name="module" required placeholder="예: TIMESHEET" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:160px;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">액션</label>
|
||||
<select name="action" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;">
|
||||
<option value="VIEW">VIEW (조회)</option>
|
||||
<option value="CREATE">CREATE (생성)</option>
|
||||
<option value="UPDATE">UPDATE (수정)</option>
|
||||
<option value="DELETE">DELETE (삭제)</option>
|
||||
<option value="APPROVE">APPROVE (결재)</option>
|
||||
<option value="EXPORT">EXPORT (내보내기)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">데이터 범위</label>
|
||||
<select name="deptScope" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;">
|
||||
<option th:each="s : ${deptScopes}" th:value="${s.name()}" th:text="${s.name() == 'OWN' ? 'OWN (본인)' : (s.name() == 'DEPT' ? 'DEPT (부서)' : 'COMPANY (전사)')}"></option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">추가</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 현재 권한 목록 -->
|
||||
<div class="data-table">
|
||||
<table>
|
||||
<thead><tr><th>모듈</th><th>액션</th><th>범위</th><th>관리</th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="perm : ${permissions}">
|
||||
<td th:text="${perm.module}"></td>
|
||||
<td><span class="badge badge-info" th:text="${perm.action}"></span></td>
|
||||
<td>
|
||||
<span class="badge" th:classappend="${perm.deptScope?.name() == 'COMPANY'} ? 'badge-success' : (${perm.deptScope?.name() == 'DEPT'} ? 'badge-warning' : '')"
|
||||
th:text="${perm.deptScope?.name()}"></span>
|
||||
</td>
|
||||
<td>
|
||||
<form th:action="@{/admin/permissions/{id}/delete(id=${perm.id})}" method="post" style="display:inline;" onsubmit="return confirm('이 권한을 삭제하시겠습니까?')">
|
||||
<button type="submit" class="btn btn-danger" style="padding:3px 10px;font-size:12px;">삭제</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(permissions)}"><td colspan="4" style="text-align:center;color:#888;padding:20px;">설정된 권한이 없습니다. 위 폼에서 권한을 추가하세요.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,57 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head><meta charset="UTF-8"><title>WBX Admin - Roles</title><link rel="stylesheet" th:href="@{/admin/css/admin.css}"></head>
|
||||
<body>
|
||||
<div class="admin-layout">
|
||||
<div th:replace="~{admin/fragments :: sidebar('roles')}"></div>
|
||||
<div class="admin-content">
|
||||
<div class="page-header"><h1>역할 관리</h1><p>역할 추가/수정/삭제 및 권한 설정</p></div>
|
||||
<div th:if="${message}" class="alert alert-success" th:text="${message}"></div>
|
||||
<div th:if="${error}" class="alert" style="background:#ffebee;color:#c62828;border:1px solid #ffcdd2;padding:12px 16px;border-radius:6px;margin-bottom:16px;font-size:13px;" th:text="${error}"></div>
|
||||
|
||||
<!-- 역할 추가 폼 -->
|
||||
<div style="background:#fff;border-radius:8px;padding:16px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
|
||||
<h3 style="margin-bottom:12px;font-size:15px;color:#1e3c78;">새 역할 추가</h3>
|
||||
<form th:action="@{/admin/roles/add}" method="post" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
|
||||
<div>
|
||||
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">코드</label>
|
||||
<input name="code" required placeholder="예: MANAGER" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:140px;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">이름</label>
|
||||
<input name="name" required placeholder="예: 관리자" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:160px;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">설명</label>
|
||||
<input name="description" placeholder="역할 설명" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:240px;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">추가</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 역할 목록 -->
|
||||
<div class="data-table">
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>코드</th><th>이름</th><th>설명</th><th>시스템</th><th>관리</th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="role : ${roles}">
|
||||
<td th:text="${role.id}"></td>
|
||||
<td><span class="badge badge-info" th:text="${role.code}"></span></td>
|
||||
<td th:text="${role.name}"></td>
|
||||
<td th:text="${role.description}"></td>
|
||||
<td th:text="${role.system} ? 'Y' : ''"></td>
|
||||
<td>
|
||||
<a th:href="@{/admin/roles/{id}(id=${role.id})}" class="btn btn-outline" style="padding:3px 10px;font-size:12px;">상세/권한</a>
|
||||
<form th:unless="${role.system}" th:action="@{/admin/roles/{id}/delete(id=${role.id})}" method="post" style="display:inline;" onsubmit="return confirm('삭제하시겠습니까?')">
|
||||
<button type="submit" class="btn btn-danger" style="padding:3px 10px;font-size:12px;">삭제</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(roles)}"><td colspan="6" style="text-align:center;color:#888">등록된 역할이 없습니다.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head><meta charset="UTF-8"><title>WBX Admin - System Health</title><link rel="stylesheet" th:href="@{/admin/css/admin.css}"></head>
|
||||
<body>
|
||||
<div class="admin-layout">
|
||||
<div th:replace="~{admin/fragments :: sidebar('system-health')}"></div>
|
||||
<div class="admin-content">
|
||||
<div class="page-header"><h1>시스템 상태</h1><p>서버 런타임 정보</p></div>
|
||||
<div class="data-table">
|
||||
<table>
|
||||
<thead><tr><th>항목</th><th>값</th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="entry : ${health}">
|
||||
<td th:text="${entry.key}"></td>
|
||||
<td th:text="${entry.value}"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,154 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head><meta charset="UTF-8"><title>WBX Admin - User Detail</title><link rel="stylesheet" th:href="@{/admin/css/admin.css}"></head>
|
||||
<body>
|
||||
<div class="admin-layout">
|
||||
<div th:replace="~{admin/fragments :: sidebar('users')}"></div>
|
||||
<div class="admin-content" th:if="${user}">
|
||||
<div class="page-header">
|
||||
<h1>사용자 상세: <span th:text="${user.email}"></span></h1>
|
||||
<p><a th:href="@{/admin/users}" style="color:#1e3c78;">← 사용자 목록으로</a></p>
|
||||
</div>
|
||||
<div th:if="${message}" class="alert alert-success" th:text="${message}"></div>
|
||||
<div th:if="${error}" class="alert" style="background:#ffebee;color:#c62828;border:1px solid #ffcdd2;padding:12px 16px;border-radius:6px;margin-bottom:16px;font-size:13px;" th:text="${error}"></div>
|
||||
|
||||
<!-- 사용자 정보 수정 폼 -->
|
||||
<div style="background:#fff;border-radius:8px;padding:20px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
|
||||
<h3 style="margin-bottom:12px;font-size:15px;color:#1e3c78;">기본 정보</h3>
|
||||
<form th:action="@{/admin/users/{id}/update(id=${user.id})}" method="post">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px;">
|
||||
<div>
|
||||
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">이메일 (변경 불가)</label>
|
||||
<input th:value="${user.email}" disabled style="width:100%;padding:6px 10px;border:1px solid #eee;border-radius:4px;font-size:13px;background:#f5f5f5;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">사용자명 (변경 불가)</label>
|
||||
<input th:value="${user.username}" disabled style="width:100%;padding:6px 10px;border:1px solid #eee;border-radius:4px;font-size:13px;background:#f5f5f5;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">이름</label>
|
||||
<input name="fullName" th:value="${user.fullName}" style="width:100%;padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">전화번호</label>
|
||||
<input name="phone" th:value="${user.phone}" style="width:100%;padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">직위</label>
|
||||
<input name="positionTitle" th:value="${user.positionTitle}" style="width:100%;padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">사번</label>
|
||||
<input name="employeeNumber" th:value="${user.employeeNumber}" style="width:100%;padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:16px;">
|
||||
<label style="display:flex;align-items:center;gap:4px;font-size:13px;">
|
||||
<input name="isAdmin" type="checkbox" value="true" th:checked="${user.admin}"> 관리자 (SA)
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary">정보 수정</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 상태 정보 + 액션 버튼 -->
|
||||
<div style="background:#fff;border-radius:8px;padding:20px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
|
||||
<h3 style="margin-bottom:12px;font-size:15px;color:#1e3c78;">계정 상태</h3>
|
||||
<div class="detail-grid" style="box-shadow:none;padding:0;margin-bottom:12px;">
|
||||
<div class="label">상태</div>
|
||||
<div class="value">
|
||||
<span th:if="${user.active}" class="badge badge-success">활성</span>
|
||||
<span th:unless="${user.active}" class="badge badge-danger">비활성</span>
|
||||
</div>
|
||||
<div class="label">잠금</div>
|
||||
<div class="value">
|
||||
<span th:if="${user.locked}" class="badge badge-danger">잠금 (실패 <span th:text="${user.failedLoginAttempts}"></span>회)</span>
|
||||
<span th:unless="${user.locked}">정상</span>
|
||||
</div>
|
||||
<div class="label">MFA</div>
|
||||
<div class="value" th:text="${user.mfaEnabled} ? '활성화' : '미설정'"></div>
|
||||
<div class="label">SSO</div>
|
||||
<div class="value" th:text="${user.ssoProvider != null} ? ${user.ssoProvider} : '미연동'"></div>
|
||||
<div class="label">최종 로그인</div>
|
||||
<div class="value" th:text="${user.lastLoginAt != null} ? ${#temporals.format(user.lastLoginAt, 'yyyy-MM-dd HH:mm:ss')} : '없음'"></div>
|
||||
<div class="label">생성일</div>
|
||||
<div class="value" th:text="${user.createdAt != null} ? ${#temporals.format(user.createdAt, 'yyyy-MM-dd HH:mm')} : ''"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<form th:action="@{/admin/users/{id}/unlock(id=${user.id})}" method="post" style="display:inline">
|
||||
<button class="btn btn-primary" type="submit">잠금 해제</button>
|
||||
</form>
|
||||
<form th:action="@{/admin/users/{id}/reset-password(id=${user.id})}" method="post" style="display:inline">
|
||||
<button class="btn btn-outline" type="submit">비밀번호 초기화</button>
|
||||
</form>
|
||||
<form th:action="@{/admin/users/{id}/toggle-status(id=${user.id})}" method="post" style="display:inline">
|
||||
<button class="btn" th:classappend="${user.active} ? 'btn-danger' : 'btn-primary'" type="submit" th:text="${user.active} ? '비활성화' : '활성화'"></button>
|
||||
</form>
|
||||
<form th:action="@{/admin/users/{id}/delete(id=${user.id})}" method="post" style="display:inline;margin-left:auto;" onsubmit="return confirm('이 사용자를 삭제하시겠습니까? 복구할 수 없습니다.')">
|
||||
<button class="btn btn-danger" type="submit">사용자 삭제</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 역할 할당 -->
|
||||
<div style="background:#fff;border-radius:8px;padding:20px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
|
||||
<h3 style="margin-bottom:12px;font-size:15px;color:#1e3c78;">역할 할당</h3>
|
||||
<form th:action="@{/admin/users/{id}/roles/add(id=${user.id})}" method="post" style="display:flex;gap:10px;align-items:end;margin-bottom:16px;">
|
||||
<div>
|
||||
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">역할 선택</label>
|
||||
<select name="roleId" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;">
|
||||
<option th:each="role : ${allRoles}" th:value="${role.id}" th:text="${role.code + ' — ' + role.name}"></option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">역할 추가</button>
|
||||
</form>
|
||||
<div class="data-table" th:if="${!userRoles.isEmpty()}">
|
||||
<table>
|
||||
<thead><tr><th>역할 코드</th><th>역할 이름</th><th>할당일</th><th>관리</th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="ur : ${userRoles}">
|
||||
<td>
|
||||
<span th:each="role : ${allRoles}" th:if="${role.id == ur.roleId}" class="badge badge-info" th:text="${role.code}"></span>
|
||||
</td>
|
||||
<td>
|
||||
<span th:each="role : ${allRoles}" th:if="${role.id == ur.roleId}" th:text="${role.name}"></span>
|
||||
</td>
|
||||
<td th:text="${ur.grantedAt != null} ? ${#temporals.format(ur.grantedAt, 'yy-MM-dd HH:mm')} : ''"></td>
|
||||
<td>
|
||||
<form th:action="@{/admin/users/{uid}/roles/{urid}/delete(uid=${user.id},urid=${ur.id})}" method="post" style="display:inline;" onsubmit="return confirm('이 역할을 해제하시겠습니까?')">
|
||||
<button type="submit" class="btn btn-danger" style="padding:3px 10px;font-size:12px;">해제</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p th:if="${userRoles.isEmpty()}" style="color:#888;font-size:13px;">할당된 역할이 없습니다.</p>
|
||||
</div>
|
||||
|
||||
<!-- 로그인 이력 -->
|
||||
<div style="background:#fff;border-radius:8px;padding:20px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
|
||||
<h3 style="margin-bottom:12px;font-size:15px;color:#1e3c78;">로그인 이력 (최근 10건)</h3>
|
||||
<div class="data-table">
|
||||
<table>
|
||||
<thead><tr><th>시간</th><th>액션</th><th>IP</th><th>인증방법</th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="log : ${loginHistory}">
|
||||
<td th:text="${#temporals.format(log.createdAt, 'yy-MM-dd HH:mm')}"></td>
|
||||
<td>
|
||||
<span th:if="${log.action == 'LOGIN_SUCCESS'}" class="badge badge-success">성공</span>
|
||||
<span th:if="${log.action == 'LOGIN_FAILURE'}" class="badge badge-danger">실패</span>
|
||||
<span th:if="${log.action == 'LOGOUT'}" class="badge badge-info">로그아웃</span>
|
||||
</td>
|
||||
<td th:text="${log.ipAddress}"></td>
|
||||
<td th:text="${log.authMethod}"></td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(loginHistory)}"><td colspan="4" style="text-align:center;color:#888;">이력이 없습니다.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,73 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head><meta charset="UTF-8"><title>WBX Admin - Users</title><link rel="stylesheet" th:href="@{/admin/css/admin.css}"></head>
|
||||
<body>
|
||||
<div class="admin-layout">
|
||||
<div th:replace="~{admin/fragments :: sidebar('users')}"></div>
|
||||
<div class="admin-content">
|
||||
<div class="page-header"><h1>사용자 관리</h1><p>사용자 추가/수정/삭제 및 역할 할당</p></div>
|
||||
<div th:if="${message}" class="alert alert-success" th:text="${message}"></div>
|
||||
<div th:if="${error}" class="alert" style="background:#ffebee;color:#c62828;border:1px solid #ffcdd2;padding:12px 16px;border-radius:6px;margin-bottom:16px;font-size:13px;" th:text="${error}"></div>
|
||||
|
||||
<!-- 사용자 추가 폼 -->
|
||||
<div style="background:#fff;border-radius:8px;padding:16px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
|
||||
<h3 style="margin-bottom:12px;font-size:15px;color:#1e3c78;">새 사용자 추가</h3>
|
||||
<form th:action="@{/admin/users/add}" method="post" style="display:flex;gap:8px;align-items:end;flex-wrap:wrap;">
|
||||
<div>
|
||||
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">이메일 *</label>
|
||||
<input name="email" type="email" required placeholder="user@company.com" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:180px;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">사용자명 *</label>
|
||||
<input name="username" required placeholder="hong" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:120px;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">비밀번호 *</label>
|
||||
<input name="password" type="password" required placeholder="********" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:130px;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">이름</label>
|
||||
<input name="fullName" placeholder="홍길동" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:100px;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">전화</label>
|
||||
<input name="phone" placeholder="010-0000-0000" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:120px;">
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:4px;padding-bottom:2px;">
|
||||
<input name="isAdmin" type="checkbox" value="true" id="chkAdmin">
|
||||
<label for="chkAdmin" style="font-size:12px;color:#888;">관리자</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">추가</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 목록 -->
|
||||
<div class="data-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>ID</th><th>이메일</th><th>사용자명</th><th>이름</th><th>관리자</th><th>상태</th><th>잠금</th><th>최종 로그인</th><th>관리</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="user : ${users}">
|
||||
<td th:text="${user.id}"></td>
|
||||
<td th:text="${user.email}"></td>
|
||||
<td th:text="${user.username}"></td>
|
||||
<td th:text="${user.fullName}"></td>
|
||||
<td><span th:if="${user.admin}" class="badge badge-info">SA</span></td>
|
||||
<td>
|
||||
<span th:if="${user.active}" class="badge badge-success">활성</span>
|
||||
<span th:unless="${user.active}" class="badge badge-danger">비활성</span>
|
||||
</td>
|
||||
<td><span th:if="${user.locked}" class="badge badge-danger">잠금</span></td>
|
||||
<td th:text="${user.lastLoginAt != null} ? ${#temporals.format(user.lastLoginAt, 'yy-MM-dd HH:mm')} : '-'"></td>
|
||||
<td style="white-space:nowrap;">
|
||||
<a th:href="@{/admin/users/{id}(id=${user.id})}" class="btn btn-outline" style="padding:3px 10px;font-size:12px;">상세</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
이 Diff에서 너무 많은 파일이 변경되어 일부 파일이 표시되지 않습니다 더 보기
새 Issue에서 참조
사용자 차단