chore(deploy): CMS 클린 재동기화 + Claude provider flip

- workspace/guardia-cms 정본을 repos에 클린 재동기화(부분 auto-sync 발산 해소, 컴파일 가능 상태 회복)
- AI provider flip: db/105_ai_provider_claude.sql (cms_setting UPSERT ai.provider=claude, 멱등)
- application.yml schema-locations 에 105 등재 (104 시드 이후 실행)
- stale 프론트 번들 2종 정리(빌드 재생성분)
- Claude 키 미설정/실패 시 AiTextRouter Ollama 자동 폴백(무중단·무회귀)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-11 10:38:40 +09:00
parent e07d38c6d3
commit 106d70c054
28 changed files with 921 additions and 545 deletions

View File

@ -42,6 +42,9 @@
<!-- DB Driver -->
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><version>${postgresql.version}</version></dependency>
<!-- TOTP 2FA (RFC 6238 · SHA1 · 30s · 6자리) — UIWS(uiws build.gradle) 동일 좌표. QR 생성 위해 zxing 전이 포함. -->
<dependency><groupId>dev.samstevens.totp</groupId><artifactId>totp</artifactId><version>1.7.1</version></dependency>
<!-- JWT -->
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>${jjwt.version}</version></dependency>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>
@ -60,6 +63,9 @@
<!-- HTTP Client (Ollama / 연계 GUARDiA 호출) -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webflux</artifactId></dependency>
<!-- DuckDB (로컬 임베디드 AI 학습/피드백 저장소 — /opt/guardia-cms/data/cms_learning.duckdb) -->
<dependency><groupId>org.duckdb</groupId><artifactId>duckdb_jdbc</artifactId><version>1.1.3</version></dependency>
<!-- Test -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency>

View File

@ -56,6 +56,12 @@ public class AdminController {
return ApiResponse.ok(userService.resetPassword(id, req.password()));
}
/** 관리자 OTP 초기화(사용자 관리 화면 버튼) — OTP 시크릿 NULL → 다음 로그인 시 QR 재등록 유도. */
@PostMapping("/users/{id}/otp-reset")
public ApiResponse<UserDto> resetOtp(@PathVariable Long id) {
return ApiResponse.ok(userService.resetOtp(id));
}
@DeleteMapping("/users/{id}")
public ApiResponse<Void> deleteUser(@PathVariable Long id, Authentication auth) {
String currentUsername = auth != null ? auth.getName() : null;

View File

@ -85,6 +85,17 @@ public class AdminUserService {
return UserDto.from(user);
}
/**
* 관리자 OTP 초기화(사용자 관리 화면 버튼). 시크릿 폐기 + otp_enabled=false
* 대상 사용자는 다음 로그인부터 OTP_SETUP(QR 재등록) 플로우를 탄다. 시크릿은 응답/로그 미노출.
*/
public UserDto resetOtp(Long id) {
CmsUser user = require(id);
mapper.resetOtp(id);
auditService.log("USER_OTP_RESET", user.getUsername(), "OTP 초기화(재등록 유도)");
return UserDto.from(user);
}
public void delete(Long id, String currentUsername) {
CmsUser user = require(id);
if (user.getUsername().equals(currentUsername)) {

View File

@ -31,5 +31,8 @@ public interface AdminUserMapper {
/** 활성 SUPERADMIN 계정 수 (마지막 관리자 삭제/강등 방지용). */
int countSuperAdmins();
/** 관리자 OTP 초기화: 시크릿 폐기 + otp_enabled=false → 다음 로그인 시 QR 재등록 유도. */
int resetOtp(@Param("id") Long id);
int countByUsername(@Param("username") String username);
}

View File

@ -1,5 +1,6 @@
package com.zioinfo.cms.ai;
import com.zioinfo.cms.ai.service.AiTextRouter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@ -23,7 +24,8 @@ import java.util.regex.Pattern;
@RequiredArgsConstructor
public class AiService {
private final OllamaClient ollama;
private final OllamaClient ollama; // 비전(vision) 경로 전용 + 가용성 유지
private final AiTextRouter aiRouter; // 텍스트 생성은 provider 라우팅(ClaudeOllama) 경유
// 욕설/스팸 폴백 사전 (규칙기반)
private static final Set<String> PROFANITY = Set.of(
@ -41,7 +43,7 @@ public class AiService {
"당신은 e-커머스 콘텐츠 작가입니다. 유형: %s, 주제: %s, 톤: %s. "
+ "한국어로 자연스러운 마케팅 초안을 200자 내외로 작성하세요. 설명 없이 본문만 출력.",
kind, topic, tone == null ? "친근함" : tone);
String out = ollama.generate(prompt);
String out = aiRouter.generateText(prompt);
if (out != null && !out.isBlank()) {
return out;
}
@ -85,7 +87,7 @@ public class AiService {
"다음 콘텐츠의 SEO를 개선하세요. 제목: %s\\n본문: %s\\n"
+ "'META: ...\\nKEYWORDS: a,b,c\\nSCORE: 0~100' 형식으로 한국어 출력.",
title, truncate(body, 800));
String out = ollama.generate(prompt);
String out = aiRouter.generateText(prompt);
if (out != null && !out.isBlank()) {
result.put("metaDescription", firstNonBlank(extractLine(out, "META:"), truncate(body, 150)));
String kw = extractLine(out, "KEYWORDS:");
@ -108,7 +110,7 @@ public class AiService {
String prompt = String.format(
"다음 텍스트를 %s 로케일 언어로 자연스럽게 번역하세요. 번역문만 출력:\\n%s",
targetLocale, truncate(text, 1500));
String out = ollama.generate(prompt);
String out = aiRouter.generateText(prompt);
if (out != null && !out.isBlank()) {
return out;
}
@ -136,7 +138,7 @@ public class AiService {
String joined = truncate(String.join("\\n- ", reviews), 1500);
String prompt = "다음 리뷰들을 한 문장으로 요약하고 감성(POSITIVE/NEGATIVE/NEUTRAL)을 판정하세요. "
+ "'SUMMARY: ...\\nSENTIMENT: ...' 형식:\\n- " + joined;
String out = ollama.generate(prompt);
String out = aiRouter.generateText(prompt);
if (out != null && !out.isBlank()) {
result.put("summary", firstNonBlank(extractLine(out, "SUMMARY:"), out));
String s = extractLine(out, "SENTIMENT:").toUpperCase();
@ -162,7 +164,7 @@ public class AiService {
public List<String> searchKeywords(String naturalQuery) {
if (naturalQuery == null || naturalQuery.isBlank()) return List.of();
String prompt = "다음 검색 의도에서 핵심 키워드 3~5개를 쉼표로만 출력:\\n" + naturalQuery;
String out = ollama.generate(prompt);
String out = aiRouter.generateText(prompt);
if (out != null && !out.isBlank()) {
String line = out.replaceAll("[\\n\\r]", " ").trim();
List<String> kws = new ArrayList<>();
@ -190,7 +192,7 @@ public class AiService {
}
String prompt = "다음 UGC가 욕설/스팸/부적절한지 판정하세요. "
+ "'DECISION: APPROVE|REJECT|REVIEW\\nRISK: 0~1\\nREASON: ...' 형식 한국어:\\n" + truncate(text, 800);
String out = ollama.generate(prompt);
String out = aiRouter.generateText(prompt);
if (out != null && !out.isBlank()) {
String d = extractLine(out, "DECISION:").toUpperCase();
String decision = d.contains("REJECT") ? "REJECT" : d.contains("REVIEW") ? "REVIEW" : "APPROVE";

View File

@ -34,17 +34,28 @@ public class OllamaClient {
this.visionModel = visionModel;
}
/** 프롬프트로 텍스트 생성. 실패 시 빈 문자열 반환(예외 없음). */
@SuppressWarnings("unchecked")
/** 프롬프트로 텍스트 생성(기본 텍스트 모델). 실패 시 빈 문자열 반환(예외 없음). */
public String generate(String prompt) {
return generate(prompt, textModel);
}
/**
* 지정 모델로 텍스트 생성. 실패 문자열 반환(예외 없음).
*
* <p>{@code AiTextRouter}·{@code AiConfigService} provider(qwen3/deepseek/glm/ollama) 소형 모델을
* 지정 호출하기 위한 진입점. model 비었으면 서버 기본 텍스트 모델로 폴백한다.
*/
@SuppressWarnings("unchecked")
public String generate(String prompt, String model) {
String useModel = (model == null || model.isBlank()) ? textModel : model.trim();
try {
Map<String, Object> body = Map.of("model", textModel, "prompt", prompt, "stream", false);
Map<String, Object> body = Map.of("model", useModel, "prompt", prompt, "stream", false);
Map<String, Object> res = builder.baseUrl(ollamaUrl).build()
.post().uri("/api/generate")
.bodyValue(body)
.retrieve()
.bodyToMono(Map.class)
.timeout(Duration.ofSeconds(30))
.timeout(Duration.ofSeconds(120))
.map(m -> (Map<String, Object>) m)
.block();
if (res == null) return "";
@ -70,7 +81,7 @@ public class OllamaClient {
.bodyValue(body)
.retrieve()
.bodyToMono(Map.class)
.timeout(Duration.ofSeconds(45))
.timeout(Duration.ofSeconds(120))
.map(m -> (Map<String, Object>) m)
.block();
if (res == null) return "";

View File

@ -1,22 +1,52 @@
package com.zioinfo.cms.auth;
import com.zioinfo.cms.auth.dto.ChangePasswordRequest;
import com.zioinfo.cms.auth.dto.OtpConfirmRequest;
import com.zioinfo.cms.auth.dto.OtpSetupResponse;
import com.zioinfo.cms.auth.dto.OtpVerifyRequest;
import com.zioinfo.cms.common.ApiResponse;
import com.zioinfo.cms.uiws.auth.OtpAuthService;
import com.zioinfo.cms.uiws.auth.TwoFactorService;
import com.zioinfo.cms.uiws.common.UiwsApiException;
import com.zioinfo.cms.uiws.common.UiwsErrorCode;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* CMS 인증 컨트롤러.
* - /login: 2FA off { twofa:"false", token }, OTP on 이면 { twofa:"true", verifyToken, verifyMethod, secret?, qrImage? }.
* - /verify: (UIWS 이메일 2FA 이식) verify-token + 인증코드 access/refresh 발급.
* - /verify-otp: (TOTP 이식) verify-token + 6자리 access/refresh 발급(최초 로그인이면 등록 확정).
* 기존 클라이언트(2FA off) token 응답 보존 회귀 0.
*/
@RestController
@RequestMapping("/api/cms/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
private final TwoFactorService twoFactorService;
private final OtpAuthService otpAuthService;
private final JwtUtil jwtUtil;
@PostMapping("/login")
public ApiResponse<Map<String, String>> login(@RequestBody LoginRequest req) {
String token = authService.login(req.username(), req.password());
return ApiResponse.ok(Map.of("token", token, "type", "Bearer"));
return ApiResponse.ok(authService.login(req.username(), req.password()));
}
/** UIWS 이메일 2FA 이식: 2차 인증 코드 검증 → access/refresh 발급. */
@PostMapping("/verify")
public ApiResponse<Map<String, String>> verify(@RequestBody VerifyRequest req) {
return ApiResponse.ok(twoFactorService.verify(req.verifyToken(), req.code()));
}
/** TOTP 이식: 로그인 2단계 6자리 검증 → access/refresh 발급(최초 로그인이면 등록 확정). */
@PostMapping("/verify-otp")
public ApiResponse<Map<String, String>> verifyOtp(@Valid @RequestBody OtpVerifyRequest req) {
return ApiResponse.ok(otpAuthService.verifyOtp(req.verifyToken(), req.code()));
}
@GetMapping("/me")
@ -25,5 +55,50 @@ public class AuthController {
return ApiResponse.ok(authService.me(token));
}
record LoginRequest(String username, String password) {}
// 마이페이지: OTP 등록/재설정/해제 + 비밀번호 변경 (본인, access 토큰 필요)
/** 마이페이지 OTP 등록/재설정 시작 → { secret, otpAuthUri, qrImage }(이 응답에서만 시크릿/QR 노출). */
@PostMapping("/otp/setup")
public ApiResponse<OtpSetupResponse> otpSetup(@RequestHeader("Authorization") String header) {
return ApiResponse.ok(otpAuthService.setup(requireUser(header)));
}
/** 마이페이지 OTP 확인·활성화(앱 6자리 코드). */
@PostMapping("/otp/confirm")
public ApiResponse<Map<String, String>> otpConfirm(@RequestHeader("Authorization") String header,
@Valid @RequestBody OtpConfirmRequest req) {
otpAuthService.confirm(requireUser(header), req.code());
return ApiResponse.ok(Map.of("result", "ok"));
}
/** 마이페이지 OTP 해제. */
@PostMapping("/otp/disable")
public ApiResponse<Map<String, String>> otpDisable(@RequestHeader("Authorization") String header) {
otpAuthService.disable(requireUser(header));
return ApiResponse.ok(Map.of("result", "ok"));
}
/** 마이페이지 비밀번호 변경(현재 비번 검증 + BCrypt). 새 비번은 응답/로그 미포함. */
@PostMapping("/change-password")
public ApiResponse<Map<String, String>> changePassword(@RequestHeader("Authorization") String header,
@Valid @RequestBody ChangePasswordRequest req) {
authService.changePassword(requireUser(header), req);
return ApiResponse.ok(Map.of("result", "ok"));
}
/**
* Authorization 헤더에서 인증 사용자를 해석한다. verify-token(2fa 단계용) 거부.
* (/api/cms/auth/** permitAll 이라 필터 인증이 없으므로 여기서 access 토큰을 명시 검증.)
*/
private String requireUser(String header) {
String token = header == null ? "" : header.replace("Bearer ", "").trim();
if (token.isEmpty() || jwtUtil.isVerifyToken(token) || !jwtUtil.isValid(token)) {
throw new UiwsApiException(UiwsErrorCode.UNAUTHORIZED);
}
return jwtUtil.getUsername(token);
}
record LoginRequest(String username, String password) {}
record VerifyRequest(String verifyToken, String code) {}
}

View File

@ -1,30 +1,102 @@
package com.zioinfo.cms.auth;
import com.zioinfo.cms.admin.AuditService;
import com.zioinfo.cms.auth.dto.AuthHelperResult;
import com.zioinfo.cms.auth.dto.ChangePasswordRequest;
import com.zioinfo.cms.auth.dto.FindIdRequest;
import com.zioinfo.cms.auth.dto.FindIdResponse;
import com.zioinfo.cms.auth.dto.ResetPasswordRequest;
import com.zioinfo.cms.auth.dto.SignupRequest;
import com.zioinfo.cms.auth.mapper.UserMapper;
import com.zioinfo.cms.uiws.auth.OtpAuthService;
import com.zioinfo.cms.uiws.auth.TwoFactorService;
import com.zioinfo.cms.uiws.common.UiwsApiException;
import com.zioinfo.cms.uiws.common.UiwsErrorCode;
import com.zioinfo.cms.uiws.common.mail.MailSender;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.util.HashMap;
import java.util.Map;
/**
* CMS 인증 서비스.
* - 기존 단일 JWT 로그인 보존(2FA off 또는 미설정 회귀 0).
* - UIWS 2FA 레이어(이메일 코드) 추가: 2FA on 이면 1차 통과 verify-token + 이메일코드 발급.
* 실패 누적 max-login-fail 계정 잠금.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {
private static final SecureRandom RANDOM = new SecureRandom();
/** 임시 비밀번호 문자셋(혼동 문자 0/O/1/l/I 제외). */
private static final String TMP_PW_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789@#$%";
private final UserMapper userMapper;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
private final TwoFactorService twoFactorService;
private final OtpAuthService otpAuthService;
private final MailSender mailSender;
private final AuditService auditService;
public String login(String username, String password) {
/**
* 1차 로그인. 2FA 활성 verify-token + 이메일코드 흐름으로 분기,
* 비활성 기존처럼 access 토큰 즉시 발급.
*
* @return 2FA off: { twofa:"false", token, type }
* 2FA on : { twofa:"true", verifyToken, step:"EMAIL", maskedEmail }
*/
public Map<String, String> login(String username, String password) {
CmsUser user = userMapper.findByUsername(username);
// 잠금 우선 차단(존재하는 계정에 한해 잠금 메시지 존재 여부 누설 최소화)
if (user != null && twoFactorService.isLocked(user)) {
throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED);
}
if (user == null || !user.isActive()) {
throw new RuntimeException("ERR-AUTH-001: 존재하지 않거나 비활성 계정");
}
// 회원가입 승인 게이트(로그인 보조 이식): 미승인 계정은 비번 일치 전에 차단.
if (Boolean.FALSE.equals(user.getApproved())) {
throw new RuntimeException("ERR-AUTH-003: 승인 대기 중인 계정입니다. 관리자 승인 후 이용 가능합니다.");
}
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
// 2FA(OTP 또는 이메일) 활성 실패 누적/잠금. 비활성 기존 동작(메시지만) 유지.
if (otpAuthService.isEnabled() || twoFactorService.isEnabled()) {
twoFactorService.recordLoginFailure(username);
CmsUser after = userMapper.findByUsername(username);
if (after != null && Boolean.TRUE.equals(after.getLocked())) {
throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED);
}
}
throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치");
}
return jwtUtil.generate(username, user.getRole());
// 비밀번호 검증 통과 2단계 우선순위: OTP > 이메일코드 > 단일 로그인
if (otpAuthService.isEnabled()) {
// { twofa:true, verifyToken, verifyMethod:(OTP|OTP_SETUP), secret?, otpAuthUri?, qrImage? }
return otpAuthService.beginOtp(user);
}
if (twoFactorService.isEnabled()) {
Map<String, String> step1 = twoFactorService.beginTwoFactor(user);
return Map.of(
"twofa", "true",
"verifyToken", step1.get("verifyToken"),
"step", step1.get("step"),
"maskedEmail", step1.getOrDefault("maskedEmail", ""));
}
// 2FA 비활성 기존 단일 로그인 흐름(회귀 0)
userMapper.resetLoginFail(username);
String token = jwtUtil.generate(username, user.getRole());
return Map.of("twofa", "false", "token", token, "type", "Bearer");
}
public Map<String, Object> me(String token) {
@ -37,4 +109,121 @@ public class AuthService {
m.put("displayName", u != null ? u.getDisplayName() : username);
return m;
}
// 로그인 보조 이식: 회원가입 / 아이디찾기 / 비밀번호 초기화
/**
* 운영자 회원가입(승인 대기). username/email 중복 검사 approved=false·role=VIEWER INSERT.
* 비밀번호는 BCrypt 저장. 활성/승인 전까지 로그인 차단(AuthService.login 승인 게이트).
*/
@Transactional
public AuthHelperResult signup(SignupRequest req) {
if (req.username() == null || req.username().isBlank()
|| req.password() == null || req.password().length() < 4
|| req.email() == null || req.email().isBlank()) {
return new AuthHelperResult(false, "아이디·비밀번호(4자 이상)·이메일은 필수입니다.");
}
if (userMapper.countByUsername(req.username()) > 0) {
return new AuthHelperResult(false, "이미 사용 중인 아이디입니다.");
}
if (userMapper.countByEmail(req.email()) > 0) {
return new AuthHelperResult(false, "이미 등록된 이메일입니다.");
}
CmsUser u = new CmsUser();
u.setUsername(req.username());
u.setPasswordHash(passwordEncoder.encode(req.password()));
u.setDisplayName(req.displayName() != null && !req.displayName().isBlank()
? req.displayName() : req.username());
u.setEmail(req.email());
userMapper.signup(u);
log.info("[auth-helper] signup pending approval: username={}", req.username());
return new AuthHelperResult(true, "가입 신청이 접수되었습니다. 관리자 승인 후 로그인할 수 있습니다.");
}
/**
* 아이디 찾기: 표시명+이메일 동시 일치 운영자 1건 조회. username 부분 마스킹 반환.
* 미발견 found=false(원문 username 절대 미노출).
*/
public FindIdResponse findId(FindIdRequest req) {
if (req.displayName() == null || req.displayName().isBlank()
|| req.email() == null || req.email().isBlank()) {
return new FindIdResponse(false, "");
}
CmsUser u = userMapper.findByDisplayNameAndEmail(req.displayName(), req.email());
if (u == null) {
return new FindIdResponse(false, "");
}
return new FindIdResponse(true, maskUsername(u.getUsername()));
}
/**
* 비밀번호 초기화: username+email 일치 검증 임시비번 생성·BCrypt 저장·잠금/실패카운트 해제.
* 임시비번은 메일(미설정 LogMailSender 로그)로만 전달. API 응답·로그 메시지에 비번 미노출.
* 대상 미존재여도 success=true(계정 열거 방지).
*/
@Transactional
public AuthHelperResult resetPassword(ResetPasswordRequest req) {
final String okMsg = "임시 비밀번호를 등록된 이메일로 발송했습니다. 메일을 확인하세요.";
if (req.username() == null || req.username().isBlank()
|| req.email() == null || req.email().isBlank()) {
return new AuthHelperResult(false, "아이디와 이메일을 모두 입력하세요.");
}
CmsUser u = userMapper.findByUsernameAndEmail(req.username(), req.email());
if (u == null) {
// 존재 여부 누설 방지 동일 성공 메시지 반환(실제 발송 없음).
log.info("[auth-helper] reset-password no match (suppressed): username={}", req.username());
return new AuthHelperResult(true, okMsg);
}
String tempPw = generateTempPassword();
userMapper.updatePasswordHash(req.username(), passwordEncoder.encode(tempPw));
String subject = "[GUARDiA CMS] 임시 비밀번호 안내";
String body = String.format(
"안녕하세요 %s 님,\n임시 비밀번호는 [%s] 입니다.\n로그인 후 즉시 비밀번호를 변경하세요.",
u.getDisplayName() != null ? u.getDisplayName() : u.getUsername(), tempPw);
// 메일 본문에만 임시비번 포함. mailSender 미설정 환경은 LogMailSender 폴백(서버 로그).
mailSender.send(u.getEmail(), subject, body);
log.info("[auth-helper] reset-password issued temp pw (sent via mail/log): username={}", req.username());
return new AuthHelperResult(true, okMsg);
}
/**
* 마이페이지 비밀번호 변경(현재 비밀번호 검증 + BCrypt 저장). UIWS changePassword 미러.
* 현재 비번 불일치 PASSWORD_MISMATCH, 기존과 동일 PASSWORD_SAME_AS_OLD.
* 비밀번호 평문은 로그/응답/감사에 절대 기록하지 않는다(보안 불변규칙).
*/
@Transactional
public void changePassword(String username, ChangePasswordRequest req) {
CmsUser user = userMapper.findByUsername(username);
if (user == null) {
throw new UiwsApiException(UiwsErrorCode.USER_NOT_FOUND);
}
if (!passwordEncoder.matches(req.currentPassword(), user.getPasswordHash())) {
throw new UiwsApiException(UiwsErrorCode.PASSWORD_MISMATCH);
}
if (passwordEncoder.matches(req.newPassword(), user.getPasswordHash())) {
throw new UiwsApiException(UiwsErrorCode.PASSWORD_SAME_AS_OLD);
}
userMapper.updatePasswordHash(username, passwordEncoder.encode(req.newPassword()));
auditService.log(username, "PASSWORD_CHANGE", username, "본인 비밀번호 변경");
}
private static String generateTempPassword() {
StringBuilder sb = new StringBuilder(10);
for (int i = 0; i < 10; i++) {
sb.append(TMP_PW_CHARS.charAt(RANDOM.nextInt(TMP_PW_CHARS.length())));
}
return sb.toString();
}
/** username 부분 마스킹: 앞 2자만 노출(예: admin → ad***). 2자 이하는 첫 글자만. */
private static String maskUsername(String username) {
if (username == null || username.isBlank()) {
return "";
}
if (username.length() <= 2) {
return username.charAt(0) + "*";
}
return username.substring(0, 2) + "*".repeat(Math.max(1, username.length() - 2));
}
}

View File

@ -18,4 +18,24 @@ public class CmsUser {
private String role;
private boolean active;
private LocalDateTime createdAt;
// UIWS 2FA 이식 컬럼 (cms_user ALTER, 91_uiws_port.sql)
/** 운영자 이메일(2FA 인증코드 발송 대상). 기존 cms_user 미보유 → ALTER 로 추가. */
private String email;
/** 이메일 인증코드(6자리). 발급 후 verify 단계에서 검증. API 응답에는 절대 미포함. */
private String emailVerifyCode;
/** 인증코드 만료시각. */
private LocalDateTime emailVerifyExpire;
/** 로그인 실패 누적 횟수(기본 0). max-login-fail 도달 시 locked. */
private Integer loginFailCount;
/** 계정 잠금 여부(기본 false). */
private Boolean locked;
/** TOTP 시크릿(UIWS OTP 경로). 등록 확정 전 보류 시크릿도 여기 저장. API 응답에 절대 미포함. */
private String otpSecret;
/** OTP 등록 확정 여부(기본 false). 최초 로그인 verify 성공 시 true 로 확정(멱등). */
private Boolean otpEnabled;
// 로그인 보조 이식(회원가입 승인 게이트) cms_user.approved
/** 회원가입 승인 여부(기본 true). 신규 가입자는 false → SUPERADMIN 승인 전 로그인 차단. */
private Boolean approved;
}

View File

@ -26,7 +26,10 @@ public class JwtFilter extends OncePerRequestFilter {
String header = req.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
if (jwtUtil.isValid(token)) {
// 보안(UIWS 2FA): purpose=2fa verify-token access 토큰이 아니다.
// 동일 서명키라 isValid() 통과하므로 별도 차단하지 않으면 2차 인증 API 접근(2FA 우회) 가능.
// verify-token 인증 컨텍스트를 세우지 않고 무시한다(/api/cms/auth/verify 에서만 사용).
if (jwtUtil.isValid(token) && !jwtUtil.isVerifyToken(token)) {
String username = jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token);
var auth = new UsernamePasswordAuthenticationToken(

View File

@ -34,6 +34,47 @@ public class JwtUtil {
.compact();
}
/**
* UIWS 2FA 이식: 1차 로그인 통과 발급하는 단기 verify-token.
* purpose=2fa 클레임으로 access 토큰과 구분(verify-token 으로는 API 접근 불가).
*/
public String generateVerifyToken(String username, long validitySeconds) {
return Jwts.builder()
.subject(username)
.claim("purpose", "2fa")
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + validitySeconds * 1000L))
.signWith(key())
.compact();
}
/** verify-token 검증 후 username 반환. 유효하지 않거나 purpose!=2fa 면 null. */
public String parseVerifyTokenUsername(String token) {
try {
Claims c = parse(token);
if (!"2fa".equals(c.get("purpose", String.class))) {
return null;
}
return c.getSubject();
} catch (JwtException | IllegalArgumentException e) {
log.debug("verify-token 검증 실패: {}", e.getMessage());
return null;
}
}
/**
* 보안: 토큰이 2FA verify-token(purpose=2fa)인지 판별.
* JwtFilter access 토큰만 인증 컨텍스트로 인정하도록 verify-token 걸러내는 사용.
* (2차 코드 검증 verify-token 으로 보호 API 접근 = 2FA 완전 우회 반드시 차단.)
*/
public boolean isVerifyToken(String token) {
try {
return "2fa".equals(parse(token).get("purpose", String.class));
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
public Claims parse(String token) {
return Jwts.parser().verifyWith(key()).build()
.parseSignedClaims(token).getPayload();

View File

@ -3,6 +3,9 @@ package com.zioinfo.cms.auth.mapper;
import com.zioinfo.cms.auth.CmsUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
import java.time.LocalDateTime;
@Mapper
public interface UserMapper {
@ -10,4 +13,63 @@ public interface UserMapper {
CmsUser findByUsername(@Param("username") String username);
int insert(CmsUser user);
// UIWS 2FA 이식: 실패 카운트/잠금/인증코드 갱신 (멱등 UPDATE)
/** 로그인 성공 시 실패 카운트 초기화. */
int resetLoginFail(@Param("username") String username);
/** 로그인 실패 누적(+1) 및 임계 도달 시 잠금. */
int incrementLoginFail(@Param("username") String username, @Param("maxFail") int maxFail);
/** 1차 통과 시 이메일 인증코드/만료 저장(verify 단계에서 검증). */
int saveEmailCode(@Param("username") String username,
@Param("code") String code,
@Param("expire") LocalDateTime expire);
/** 2차 검증 성공 시 코드 폐기. */
int clearEmailCode(@Param("username") String username);
/** 관리자 잠금 해제(실패 카운트/잠금 초기화). */
int unlock(@Param("username") String username);
// 로그인 보조 이식: 회원가입 / 아이디찾기 / 비밀번호 초기화
/** username 존재 여부(회원가입 중복 검사). */
int countByUsername(@Param("username") String username);
/** email 존재 여부(회원가입 중복 검사 — 운영자 계정 한정). */
int countByEmail(@Param("email") String email);
/**
* 회원가입(승인 대기). approved=false·is_active=true·role=VIEWER 고정.
* 관리자 화면에서 활성/승인 전까지 로그인 차단.
*/
int signup(CmsUser user);
/** 아이디찾기: 표시명(display_name)+이메일 일치 운영자 1건. */
CmsUser findByDisplayNameAndEmail(@Param("displayName") String displayName,
@Param("email") String email);
/** 비밀번호 초기화 대상 검증: username+email 동시 일치 운영자 1건. */
CmsUser findByUsernameAndEmail(@Param("username") String username,
@Param("email") String email);
/** 임시 비밀번호 적용 + 잠금/실패카운트 해제(초기화 시). */
int updatePasswordHash(@Param("username") String username,
@Param("passwordHash") String passwordHash);
// TOTP(OTP 2차 인증) 이식 (멱등 UPDATE, @Update XML 미정의 신규 메서드)
/** 보류 시크릿 저장(등록 확정 전). otp_enabled 은 건드리지 않는다(verify/confirm 에서 확정). */
@Update("UPDATE cms_user SET otp_secret = #{secret} WHERE username = #{username}")
int updateOtpSecret(@Param("username") String username, @Param("secret") String secret);
/** 등록 확정: otp_enabled=true (시크릿은 유지). */
@Update("UPDATE cms_user SET otp_enabled = true WHERE username = #{username}")
int enableOtp(@Param("username") String username);
/** 해제/초기화: 시크릿 폐기 + otp_enabled=false. (마이페이지 해제) */
@Update("UPDATE cms_user SET otp_secret = NULL, otp_enabled = false WHERE username = #{username}")
int disableOtp(@Param("username") String username);
}

View File

@ -45,6 +45,9 @@ public class SecurityConfig {
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/cms/auth/**").permitAll()
// 로그인 보조 이식(회원가입/아이디찾기/비번초기화) 로그인 무인증.
// 회원가입은 승인대기로 INSERT, 비번초기화는 임시비번을 메일/로그로만 전달(응답 무노출).
.requestMatchers("/api/auth/signup", "/api/auth/find-id", "/api/auth/reset-password").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api/cms/docs/**", "/api/cms/swagger/**").permitAll()
@ -62,6 +65,12 @@ public class SecurityConfig {
.requestMatchers("/api/admin/audit").hasAnyRole("SUPERADMIN", "EDITOR")
.requestMatchers("/api/admin/**").hasRole("SUPERADMIN")
// UIWS PublicLookup(가입화면 부서/회사 셀렉트박스용 공개 조회) 로그인 GET 무인증.
// 조회 전용(부서명/회사명만) 민감정보·자격증명 미포함.
.requestMatchers(HttpMethod.GET, "/api/public/**").permitAll()
// UIWS system(시스템관리·권한관리) 이식 SUPERADMIN 전용.
.requestMatchers("/api/system/**").hasRole("SUPERADMIN")
// 콘텐츠/리소스 작성·수정·삭제 AUTHOR 이상 (게시 승인/발행은 서비스 메서드에서 EDITOR+ 추가 가드)
.requestMatchers(HttpMethod.POST, "/api/cms/**").hasAnyRole("SUPERADMIN", "EDITOR", "AUTHOR")
.requestMatchers(HttpMethod.PUT, "/api/cms/**").hasAnyRole("SUPERADMIN", "EDITOR", "AUTHOR")

View File

@ -9,10 +9,19 @@ spring:
username: ${DB_USER:cms_user}
password: ${DB_PASS:cms_pass2026}
driver-class-name: org.postgresql.Driver
hikari:
# 공유 PostgreSQL max_connections 포화 방지(형제 솔루션 다수 동거) — 풀 캡 3.
maximum-pool-size: ${DB_POOL_MAX:3}
sql:
init:
mode: ${SQL_INIT_MODE:never}
schema-locations: classpath:db/schema.sql
# UIWS 이식: 91_uiws_port.sql(신규 tb_uiws_* + cms_user 2FA ALTER, 전부 멱등)만 부팅 시 적용.
# 기존 schema.sql 은 deploy_server 가 psql 로 별도 적용(비멱등 cms_setting/cms_i18n 시드 충돌 회피).
# 91_uiws_port.sql 단독은 완전 멱등 → mode:always 안전. continue-on-error 로 부분 실패 격리.
mode: ${SQL_INIT_MODE:always}
# 91=업무모듈+2FA ALTER, 92=system(시스템관리·권한관리) 이식, 93=OTP otp_enabled ALTER, 104=AI 플랫폼 설정 시드. 전부 멱등.
# 105_ai_provider_claude.sql: AI provider flip → claude (멱등 UPSERT, 104 이후 실행).
schema-locations: classpath:db/91_uiws_port.sql,classpath:db/92_uiws_system.sql,classpath:db/93_auth_otp.sql,classpath:db/104_seed_ai_config.sql,classpath:db/105_ai_provider_claude.sql
continue-on-error: true
servlet:
multipart:
max-file-size: 20MB
@ -38,14 +47,41 @@ guardia:
bi-url: ${BI_URL:http://localhost:8006}
# 보안 불변 규칙: Ollama 온프레미스만 허용. 외부 AI API 절대 금지.
ollama-url: ${OLLAMA_URL:http://localhost:11434}
ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3}
ollama-vision-model: ${OLLAMA_VISION_MODEL:llava}
ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3.2:1b}
ollama-vision-model: ${OLLAMA_VISION_MODEL:moondream}
# 로컬 임베디드 DuckDB — AI 피드백/추론 학습 저장소(솔루션별 격리). 서버 기본 경로.
duckdb:
path: ${CMS_DUCKDB_PATH:/opt/guardia-cms/data/cms_learning.duckdb}
# 중앙 guardia-rag 연동 (최신 AI 기법 배선 — 별개 레이어, 미가용 시 CMS 로컬 폴백).
# 보안 불변: 온프레미스(서버 내부 루프백) 전용. 외부 엔드포인트 지정 금지.
rag:
base-url: ${RAG_URL:http://127.0.0.1:8020}
timeout-ms: ${RAG_TIMEOUT_MS:120000}
enabled: ${RAG_ENABLED:true}
solution: cms
crypto:
secret: ${CMS_CRYPTO_SECRET:guardia-cms-aes-256-gcm-master-key-2026-zioinfo}
jwt:
secret: ${JWT_SECRET:guardia-cms-jwt-secret-2026-minimum-256bit-key-zioinfo}
expiration: 86400000
# UIWS 이식 모듈 설정 (com.zioinfo.cms.uiws.config.UiwsProperties = prefix cms.uiws)
cms:
uiws:
auth:
# 2FA 레이어 on/off. 기본 on. off 면 기존 단일 JWT 로그인 흐름 유지(회귀 0).
twofa-enabled: ${CMS_TWOFA_ENABLED:true}
# TOTP(OTP 2차 인증) on/off. 기본 on. 활성 시 로그인 2단계가 OTP(등록/검증) 경로 사용(이메일코드보다 우선).
otp-enabled: ${CMS_OTP_ENABLED:true}
verify-token-validity-seconds: 300 # 1차 통과 후 verify-token 5분
email-code-validity-seconds: 300 # 이메일 인증코드 5분
max-login-fail: 5 # 실패 5회 시 계정 잠금
upload:
upload-dir: ${CMS_UIWS_UPLOAD_DIR:./uploads/uiws}
mail:
# 메일 발송 모드. log=LogMailSender 폴백(외부 호출 0, 코드는 로그에만). smtp 운영 시 별도 빈.
mode: ${CMS_MAIL_MODE:log}
logging:
level:
com.zioinfo.cms: DEBUG

View File

@ -0,0 +1,34 @@
-- =====================================================================
-- GUARDiA CMS — 105. AI provider flip → claude (멱등 UPSERT)
-- 대상 DB : cms_db (테이블 cms_setting — schema.sql 에서 생성, PK=key)
-- 적용 : application.yml spring.sql.init.mode=always + schema-locations 에 본 파일 등재
-- (104_seed_ai_config.sql 이후 실행 — cms_setting 테이블 선행 생성 보장).
-- mode:always 재실행 안전(ON CONFLICT DO UPDATE — 멱등).
-- 설명 : 런타임 AI provider 를 claude 로 전환(활성화 스위치).
-- 104 시드는 provider 기본값을 'ollama' 로만 INSERT(ON CONFLICT DO NOTHING)하므로,
-- 서버에 ai.provider 행이 'ollama' 인 상태에서 Claude 경로가 실효되지 않는다.
-- 본 파일이 UPSERT 로 provider='claude' 를 강제 → AiConfigService.isClaudeActive()
-- 3조건(provider=claude · ANTHROPIC_API_KEY 설정 · ai.enabled=true) 충족.
-- 폴백 : Claude 키 미설정/실패 시 AiTextRouter 가 Ollama 로 자동 폴백(무중단·무회귀).
-- provider=claude 는 '활성화'일 뿐 강제 아님 — 키 없으면 기존 Ollama 동작 유지.
-- 보안 : Claude API 키/시크릿/IP/비밀번호 일절 미포함(키는 서버 env ANTHROPIC_API_KEY 로만).
-- 근거 : _workspace/portall/{03_claude_switch_delta.md,04_server_check.md}
-- (cms 설정테이블 컬럼 key/value — crm 의 setting_key/setting_value 와 상이).
-- =====================================================================
SET client_encoding = 'UTF8';
-- cms_setting 은 schema.sql/104 에서 선행 생성됨. 안전을 위해 존재 보장(멱등, schema.sql 정의와 동일).
CREATE TABLE IF NOT EXISTS cms_setting (
key VARCHAR(200) PRIMARY KEY,
value TEXT,
updated_at TIMESTAMP DEFAULT NOW()
);
-- 행 없으면 INSERT, 있으면(ollama 등) UPDATE — 멱등 flip.
INSERT INTO cms_setting (key, value, updated_at)
VALUES ('ai.provider', 'claude', NOW())
ON CONFLICT (key)
DO UPDATE SET value = 'claude', updated_at = NOW();
-- end 105_ai_provider_claude.sql

View File

@ -53,4 +53,9 @@
SELECT COUNT(*) FROM cms_user WHERE username = #{username}
</select>
<!-- 관리자 OTP 초기화: 시크릿 폐기 + otp_enabled=false (다음 로그인 시 QR 재등록 유도) -->
<update id="resetOtp">
UPDATE cms_user SET otp_secret = NULL, otp_enabled = false WHERE id = #{id}
</update>
</mapper>

View File

@ -11,10 +11,21 @@
<result property="role" column="role"/>
<result property="active" column="is_active"/>
<result property="createdAt" column="created_at"/>
<!-- UIWS 2FA 이식 컬럼 (cms_user ALTER, 91_uiws_port.sql) -->
<result property="email" column="email"/>
<result property="emailVerifyCode" column="email_verify_code"/>
<result property="emailVerifyExpire" column="email_verify_expire"/>
<result property="loginFailCount" column="login_fail_count"/>
<result property="locked" column="locked"/>
<result property="otpSecret" column="otp_secret"/>
<result property="otpEnabled" column="otp_enabled"/>
<!-- 로그인 보조 이식: 회원가입 승인 게이트 -->
<result property="approved" column="approved"/>
</resultMap>
<select id="findByUsername" resultMap="userMap">
SELECT id, username, password_hash, display_name, role, is_active, created_at
SELECT id, username, password_hash, display_name, role, is_active, created_at,
email, email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, otp_enabled, approved
FROM cms_user
WHERE username = #{username}
</select>
@ -25,4 +36,73 @@
VALUES (#{username}, #{passwordHash}, #{displayName}, #{role}, #{active})
</insert>
<!-- ── UIWS 2FA 이식: 실패 카운트/잠금/인증코드 갱신 (멱등 UPDATE) ─────────────── -->
<update id="resetLoginFail">
UPDATE cms_user SET login_fail_count = 0 WHERE username = #{username}
</update>
<update id="incrementLoginFail">
UPDATE cms_user
SET login_fail_count = COALESCE(login_fail_count, 0) + 1,
locked = (COALESCE(login_fail_count, 0) + 1 >= #{maxFail})
WHERE username = #{username}
</update>
<update id="saveEmailCode">
UPDATE cms_user
SET email_verify_code = #{code}, email_verify_expire = #{expire}, login_fail_count = 0
WHERE username = #{username}
</update>
<update id="clearEmailCode">
UPDATE cms_user SET email_verify_code = NULL, email_verify_expire = NULL WHERE username = #{username}
</update>
<update id="unlock">
UPDATE cms_user SET locked = false, login_fail_count = 0 WHERE username = #{username}
</update>
<!-- ── 로그인 보조 이식: 회원가입 / 아이디찾기 / 비밀번호 초기화 ─────────────── -->
<select id="countByUsername" resultType="int">
SELECT COUNT(*) FROM cms_user WHERE username = #{username}
</select>
<select id="countByEmail" resultType="int">
SELECT COUNT(*) FROM cms_user WHERE email = #{email}
</select>
<!-- 회원가입(승인 대기): role=VIEWER·is_active=true·approved=false·실패카운트0·미잠금 고정 -->
<insert id="signup" parameterType="com.zioinfo.cms.auth.CmsUser"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO cms_user (username, password_hash, display_name, role, email,
is_active, approved, login_fail_count, locked)
VALUES (#{username}, #{passwordHash}, #{displayName}, 'VIEWER', #{email},
true, false, 0, false)
</insert>
<select id="findByDisplayNameAndEmail" resultMap="userMap">
SELECT id, username, password_hash, display_name, role, is_active, created_at,
email, email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, otp_enabled, approved
FROM cms_user
WHERE display_name = #{displayName} AND email = #{email}
ORDER BY id
LIMIT 1
</select>
<select id="findByUsernameAndEmail" resultMap="userMap">
SELECT id, username, password_hash, display_name, role, is_active, created_at,
email, email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, otp_enabled, approved
FROM cms_user
WHERE username = #{username} AND email = #{email}
</select>
<!-- 임시비번 적용 + 잠금/실패카운트 해제(초기화 시) -->
<update id="updatePasswordHash">
UPDATE cms_user
SET password_hash = #{passwordHash}, locked = false, login_fail_count = 0
WHERE username = #{username}
</update>
</mapper>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -3,9 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<title>GUARDiA CMS — AI 콘텐츠 관리</title>
<script type="module" crossorigin src="/assets/index-qhjCbNFY.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DzayOpa6.css">
<script type="module" crossorigin src="/assets/index-lCwctxBX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index--ySdlt3w.css">
</head>
<body>
<div id="root"></div>

View File

@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<title>GUARDiA CMS — AI 콘텐츠 관리</title>
</head>
<body>

View File

@ -19,9 +19,27 @@ import Channels from './pages/Channels'
import Members from './pages/Members'
import Analytics from './pages/Analytics'
import AiTools from './pages/AiTools'
import AiTechniques from './pages/AiTechniques'
import WiseAiPage from './pages/WiseAiPage'
import UserManagement from './pages/UserManagement'
import AuditLog from './pages/AuditLog'
import SystemSettings from './pages/SystemSettings'
import AiPlatformSettings from './pages/AiPlatformSettings'
import MobileApp from './pages/MobileApp'
import MyPage from './pages/MyPage'
// UIWS 이식 업무 모듈(업무일지·일정·쪽지·통계) — 기존 라우트 보존, 신규 메뉴만 추가
import UiwsLayout from './components/uiws/UiwsLayout'
import WorklogList from './pages/uiws/WorklogList'
import ScheduleCalendar from './pages/uiws/ScheduleCalendar'
import MessageBox from './pages/uiws/MessageBox'
import StatsPivot from './pages/uiws/StatsPivot'
// UIWS 시스템관리(권한) — RBAC/공통코드/메뉴/부서/거래처 (SUPERADMIN 전용)
import RoleManagement from './pages/uiws/system/RoleManagement'
import RoleMenuPermission from './pages/uiws/system/RoleMenuPermission'
import CodeManagement from './pages/uiws/system/CodeManagement'
import MenuManagement from './pages/uiws/system/MenuManagement'
import DeptManagement from './pages/uiws/system/DeptManagement'
import CompanyManagement from './pages/uiws/system/CompanyManagement'
const qc = new QueryClient()
@ -33,6 +51,8 @@ export default function App() {
<Route path="/login" element={<Login />} />
<Route element={<Layout />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/wise-ai" element={<WiseAiPage />} />
<Route path="/mypage" element={<MyPage />} />
<Route path="/contents" element={<Contents />} />
<Route path="/contents/new" element={<ContentEditor />} />
<Route path="/contents/:id" element={<ContentEditor />} />
@ -49,6 +69,7 @@ export default function App() {
<Route path="/members" element={<Members />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/ai-tools" element={<AiTools />} />
<Route path="/ai-techniques" element={<AiTechniques />} />
<Route path="/admin/users" element={
<ProtectedRoute min="SUPERADMIN"><UserManagement /></ProtectedRoute>
} />
@ -58,6 +79,36 @@ export default function App() {
<Route path="/admin/settings" element={
<ProtectedRoute min="EDITOR"><SystemSettings /></ProtectedRoute>
} />
<Route path="/admin/ai-platform" element={
<ProtectedRoute min="SUPERADMIN"><AiPlatformSettings /></ProtectedRoute>
} />
<Route path="/admin/mobile-app" element={<MobileApp />} />
{/* 업무(UIWS 이식) — 인증 사용자 전체(Viewer+). UiwsLayout = .uiws-scope 다크/라이트 토큰 */}
<Route path="/uiws/worklog" element={<UiwsLayout><WorklogList /></UiwsLayout>} />
<Route path="/uiws/schedule" element={<UiwsLayout><ScheduleCalendar /></UiwsLayout>} />
<Route path="/uiws/message" element={<UiwsLayout><MessageBox /></UiwsLayout>} />
<Route path="/uiws/stats" element={<UiwsLayout><StatsPivot /></UiwsLayout>} />
{/* 시스템관리(권한) — UIWS RBAC/공통코드/메뉴/부서/거래처. /api/system/** = SUPERADMIN 전용 */}
<Route path="/uiws/system/roles" element={
<ProtectedRoute min="SUPERADMIN"><UiwsLayout><RoleManagement /></UiwsLayout></ProtectedRoute>
} />
<Route path="/uiws/system/role-menus" element={
<ProtectedRoute min="SUPERADMIN"><UiwsLayout><RoleMenuPermission /></UiwsLayout></ProtectedRoute>
} />
<Route path="/uiws/system/codes" element={
<ProtectedRoute min="SUPERADMIN"><UiwsLayout><CodeManagement /></UiwsLayout></ProtectedRoute>
} />
<Route path="/uiws/system/menus" element={
<ProtectedRoute min="SUPERADMIN"><UiwsLayout><MenuManagement /></UiwsLayout></ProtectedRoute>
} />
<Route path="/uiws/system/depts" element={
<ProtectedRoute min="SUPERADMIN"><UiwsLayout><DeptManagement /></UiwsLayout></ProtectedRoute>
} />
<Route path="/uiws/system/companies" element={
<ProtectedRoute min="SUPERADMIN"><UiwsLayout><CompanyManagement /></UiwsLayout></ProtectedRoute>
} />
</Route>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>

View File

@ -35,6 +35,15 @@ export const login = (username: string, password: string) =>
api.post('/api/cms/auth/login', { username, password })
export const getMe = () => api.get('/api/cms/auth/me')
// ── 2FA / OTP / 계정 보안 (access 토큰 필요, 마이페이지) ────────────────────
// OTP 등록/재설정/해제 + 비밀번호 변경. 응답은 { success, message, data } 봉투(raw).
// 보안 불변: setup 응답(secret/qrImage)은 화면 표시용만 — 로그/저장 절대 금지.
export const otpSetup = () => api.post('/api/cms/auth/otp/setup')
export const otpConfirm = (code: string) => api.post('/api/cms/auth/otp/confirm', { code })
export const otpDisable = () => api.post('/api/cms/auth/otp/disable')
export const changePassword = (currentPassword: string, newPassword: string) =>
api.post('/api/cms/auth/change-password', { currentPassword, newPassword })
// ── Dashboard ─────────────────────────────────────────────────────────
export const getDashboardSummary = () => unwrap(api.get('/api/cms/dashboard/summary'))
export const getDashboardWorkflow = () => unwrap(api.get('/api/cms/dashboard/workflow'))
@ -188,6 +197,16 @@ export const aiReviewSummary = (reviews: string[]) => unwrap(api.post('/api/cms/
export const aiSearchKeywords = (query: string) => unwrap(api.post('/api/cms/ai/search-keywords', { query }))
export const aiModerate = (text: string) => unwrap(api.post('/api/cms/ai/moderate', { text }))
// ── RAG (중앙 guardia-rag 최신 AI 기법 배선 — 별개 레이어, 폴백 degraded) ──
export const getRagToggles = () => unwrap(api.get('/api/cms/rag/toggles'))
export const updateRagToggle = (key: string, value: string) =>
unwrap(api.put(`/api/cms/rag/toggles/${encodeURIComponent(key)}`, { value }))
export const ragContentDraft = (kind: string, topic: string, tone: string) =>
unwrap(api.post('/api/cms/rag/content-draft', { kind, topic, tone }))
export const ragModerate = (text: string) => unwrap(api.post('/api/cms/rag/moderate', { text }))
export const ragFeedback = (d: { answerId?: string; query?: string; answer?: string; verdict: 'up' | 'down'; correction?: string }) =>
unwrap(api.post('/api/cms/rag/feedback', d))
// ── Integration ───────────────────────────────────────────────────────
export const getIntegrationStatus = () => unwrap(api.get('/api/cms/integration/status'))
export const getMallProducts = () => unwrap(api.get('/api/cms/integration/mall/products'))
@ -202,6 +221,8 @@ export const updateUserRole = (id: number, role: string) => api.put(`/api/admin/
export const updateUserActive = (id: number, active: boolean) => api.put(`/api/admin/users/${id}/active`, { active })
export const resetPassword = (id: number, password: string) => api.put(`/api/admin/users/${id}/password`, { password })
export const deleteUser = (id: number) => api.delete(`/api/admin/users/${id}`)
// 관리자 OTP 초기화 — 대상 사용자 OTP 해제(다음 로그인 시 재등록). 시크릿 미조회.
export const adminOtpReset = (id: number) => api.post(`/api/admin/users/${id}/otp-reset`)
export const getAuditLogs = (action = '', actor = '', limit = 100) =>
api.get(`/api/admin/audit${qs({ action, actor, limit })}`)

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useNavigate, Link } from 'react-router-dom'
import { LogOut, UserCircle } from 'lucide-react'
import { getAiStatus } from '../api/client'
@ -30,10 +30,10 @@ export default function Header() {
<span className={`w-1.5 h-1.5 rounded-full ${ollama ? 'bg-accent' : 'bg-slate-500'}`} />
AI {ollama === null ? '확인 중' : ollama ? '온라인' : '폴백'}
</span>
<span className="flex items-center gap-1.5 text-sm text-slate-300">
<Link to="/mypage" className="flex items-center gap-1.5 text-sm text-slate-300 hover:text-brand" title="마이페이지">
<UserCircle size={18} /> {user}
{role && <span className="text-[11px] text-slate-500">({role})</span>}
</span>
</Link>
<button onClick={logout} className="flex items-center gap-1.5 text-sm text-slate-400 hover:text-brand">
<LogOut size={16} />
</button>

View File

@ -4,8 +4,11 @@ import {
LayoutDashboard, FileText, Image, Package, Megaphone, ListTree, Palette,
Languages, Search, FileInput, MessageSquareWarning, Share2, UsersRound,
Sparkles, BarChart3, ShieldCheck, ScrollText, Settings, FileStack,
BookOpen, CalendarDays, Mail, Smartphone, Cpu,
KeyRound, ListChecks, Hash, SquareMenu, Network, Building2, BrainCircuit,
} from 'lucide-react'
import { currentRole, hasRole } from './rbac'
import ThemeToggle from './uiws/ThemeToggle'
interface Link { to: string; label: string; icon: any; min?: string }
@ -29,11 +32,33 @@ const engageLinks: Link[] = [
{ to: '/members', label: '회원', icon: UsersRound },
{ to: '/analytics', label: '콘텐츠 분석', icon: BarChart3 },
{ to: '/ai-tools', label: 'AI 도구', icon: Sparkles },
{ to: '/ai-techniques', label: 'AI 기법 (RAG)', icon: Cpu },
]
const adminLinks: Link[] = [
{ to: '/admin/users', label: '사용자/권한', icon: ShieldCheck, min: 'SUPERADMIN' },
{ to: '/admin/audit', label: '감사 로그', icon: ScrollText, min: 'EDITOR' },
{ to: '/admin/settings', label: '시스템 설정', icon: Settings, min: 'EDITOR' },
{ to: '/admin/ai-platform', label: 'AI 플랫폼', icon: Cpu, min: 'SUPERADMIN' },
]
// 업무(UIWS 이식) — 업무일지·일정·쪽지·통계 (인증 사용자 전체)
const uiwsLinks: Link[] = [
{ to: '/uiws/worklog', label: '업무일지', icon: BookOpen },
{ to: '/uiws/schedule', label: '일정', icon: CalendarDays },
{ to: '/uiws/message', label: '쪽지', icon: Mail },
{ to: '/uiws/stats', label: '업무통계', icon: BarChart3 },
]
// 시스템관리(권한) — UIWS RBAC/공통코드/메뉴/부서/거래처 (SUPERADMIN 전용)
const systemLinks: Link[] = [
{ to: '/uiws/system/roles', label: '권한 관리', icon: KeyRound, min: 'SUPERADMIN' },
{ to: '/uiws/system/role-menus', label: '역할-메뉴 권한', icon: ListChecks, min: 'SUPERADMIN' },
{ to: '/uiws/system/codes', label: '공통코드', icon: Hash, min: 'SUPERADMIN' },
{ to: '/uiws/system/menus', label: '메뉴 관리', icon: SquareMenu, min: 'SUPERADMIN' },
{ to: '/uiws/system/depts', label: '부서 관리', icon: Network, min: 'SUPERADMIN' },
{ to: '/uiws/system/companies', label: '거래처 관리', icon: Building2, min: 'SUPERADMIN' },
]
// 통합 메신저 앱(읽기전용 QR 설치) — 인증 사용자 전체
const appLinks: Link[] = [
{ to: '/admin/mobile-app', label: '모바일 앱 설치', icon: Smartphone },
]
const linkClass = ({ isActive }: { isActive: boolean }) =>
@ -72,6 +97,7 @@ export default function Sidebar() {
}, [])
const visibleAdmin = adminLinks.filter(l => hasRole(l.min || 'VIEWER', role))
const visibleSystem = systemLinks.filter(l => hasRole(l.min || 'VIEWER', role))
return (
<aside className="w-60 bg-panel border-r border-edge flex flex-col">
@ -83,16 +109,37 @@ export default function Sidebar() {
</div>
</div>
<nav className="flex-1 py-2 overflow-auto">
<NavLink to="/wise-ai" className={linkClass}>
<BrainCircuit size={17} />
<span className="flex flex-col leading-tight">
<span>WISE AI</span>
<span className="text-[10px] text-slate-500">Enterprise AI for Trusted Knowledge</span>
</span>
</NavLink>
<Section links={contentLinks} />
<Section title="사이트" links={siteLinks} />
<Section title="참여 · 분석" links={engageLinks} />
<div className="border-t border-edge mt-3">
<Section title="업무(UIWS)" links={uiwsLinks} />
</div>
{visibleSystem.length > 0 && (
<div className="border-t border-edge mt-3">
<Section title="시스템관리(권한)" links={visibleSystem} />
</div>
)}
<div className="border-t border-edge mt-3">
<Section title="모바일" links={appLinks} />
</div>
{visibleAdmin.length > 0 && (
<div className="border-t border-edge mt-3">
<Section title="관리자" links={visibleAdmin} />
</div>
)}
</nav>
<div className="p-4 text-[11px] text-slate-500 border-t border-edge">
<div className="px-4 pt-3 border-t border-edge">
<ThemeToggle />
</div>
<div className="p-4 text-[11px] text-slate-500">
Ollama · CMS
</div>
</aside>

View File

@ -2,9 +2,18 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
// UIWS 이식: 모듈 스코프 테마 토큰(다크/라이트). 전역 CMS 다크 셸은 index.css 유지 — 덮어쓰지 않음.
import './theme/theme.css'
import { ThemeProvider } from './theme/ThemeContext'
// 초기 테마 적용(FOUC 방지) — ThemeProvider 마운트 전에 data-theme 세팅(uiws-scope 화면에만 영향)
const saved = localStorage.getItem('cms_theme')
document.documentElement.setAttribute('data-theme', saved === 'light' ? 'light' : 'dark')
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider>
<App />
</ThemeProvider>
</React.StrictMode>
)

View File

@ -1,23 +1,44 @@
import { useState } from 'react'
import { useState } from 'react'
import { ShieldCheck } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { login, getMe } from '../api/client'
import { verify2fa, verifyOtp } from '../api/uiws'
import AuthHelperModals, { type AuthHelperKind } from './AuthHelperModals'
/**
* CMS UIWS 2FA + Authenticator(OTP) .
* 1단계: 아이디/ /api/cms/auth/login.
* - twofa="false": { token } ( 0).
* - twofa="true": verifyMethod 2 .
* · OTP : Authenticator 6 /verify-otp
* · OTP_SETUP : QR(qrImage)+(secret) 6 /verify-otp
* · EMAIL(): 6 /verify ()
* 2단계: verify-token + access getMe /dashboard.
* 보안: OTP secret/QR· / .
*/
type VerifyMethod = 'OTP' | 'OTP_SETUP' | 'EMAIL'
export default function Login() {
const [step, setStep] = useState<'login' | 'verify'>('login')
const [username, setUsername] = useState('admin')
const [password, setPassword] = useState('')
const [code, setCode] = useState('')
const [verifyToken, setVerifyToken] = useState('')
const [verifyMethod, setVerifyMethod] = useState<VerifyMethod>('EMAIL')
const [maskedEmail, setMaskedEmail] = useState('')
const [qrImage, setQrImage] = useState('') // OTP_SETUP 등록 순간만 존재
const [secret, setSecret] = useState('') // OTP_SETUP 등록 순간만 존재
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const [helper, setHelper] = useState<AuthHelperKind | null>(null)
const nav = useNavigate()
const submit = async (e: React.FormEvent) => {
e.preventDefault()
setErr(''); setBusy(true)
try {
const res = await login(username, password)
const token = res.data?.data?.token
if (!token) throw new Error('no token')
const isOtp = verifyMethod === 'OTP' || verifyMethod === 'OTP_SETUP'
const isSetup = verifyMethod === 'OTP_SETUP'
// 토큰 저장 + 역할 선조회 후 대시보드 진입(공통)
const finishLogin = async (token: string) => {
localStorage.setItem('cms_token', token)
// 역할 선조회 (RBAC 메뉴 즉시 반영)
try {
const me = await getMe()
const d = me.data?.data || {}
@ -25,6 +46,27 @@ export default function Login() {
if (d.username) localStorage.setItem('cms_user', d.username)
} catch { /* noop */ }
nav('/dashboard')
}
const submitLogin = async (e: React.FormEvent) => {
e.preventDefault()
setErr(''); setBusy(true)
try {
const res = await login(username, password)
const d = res.data?.data || {}
if (d.twofa === 'true') {
setVerifyToken(d.verifyToken || '')
setVerifyMethod((d.verifyMethod as VerifyMethod) || 'EMAIL')
setMaskedEmail(d.maskedEmail || '')
setQrImage(d.qrImage || '')
setSecret(d.secret || '')
setCode('')
setStep('verify')
} else {
const token = d.token
if (!token) throw new Error('no token')
await finishLogin(token)
}
} catch {
setErr('로그인 실패 — 아이디/비밀번호를 확인하세요.')
} finally {
@ -32,26 +74,119 @@ export default function Login() {
}
}
const submitVerify = async (e: React.FormEvent) => {
e.preventDefault()
setErr(''); setBusy(true)
try {
// OTP·OTP_SETUP 은 /verify-otp, 이메일은 /verify(하위호환)
const res = isOtp
? await verifyOtp(verifyToken, code.trim())
: await verify2fa(verifyToken, code.trim())
const token = res.data?.data?.token
if (!token) throw new Error('no token')
await finishLogin(token)
} catch {
setErr('인증 실패 — 코드가 올바르지 않거나 만료되었습니다.')
} finally {
setBusy(false)
}
}
const backToLogin = () => {
setStep('login'); setCode(''); setErr('')
setQrImage(''); setSecret('') // 시크릿 잔존 방지
}
return (
<div className="min-h-screen flex items-center justify-center bg-ink">
<form onSubmit={submit} className="w-[360px] bg-panel border border-edge rounded-2xl p-8">
<form onSubmit={step === 'login' ? submitLogin : submitVerify}
className="w-[360px] bg-panel border border-edge rounded-2xl p-8">
<div className="flex items-center gap-2 justify-center mb-6">
<img src="/login.png" alt="GUARDiA" style={{ height: 36, width: 'auto' }} onError={e => { (e.target as HTMLImageElement).style.display = 'none' }} />
<img src="/login.png" alt="GUARDiA" style={{ height: 36, width: 'auto' }}
onError={e => { (e.target as HTMLImageElement).style.display = 'none' }} />
<span className="text-xl font-bold">GUARDiA CMS</span>
</div>
{step === 'login' ? (
<>
<p className="text-center text-sm text-slate-400 mb-6">AI </p>
<label className="block text-xs text-slate-400 mb-1"></label>
<input value={username} onChange={e => setUsername(e.target.value)}
<input value={username} onChange={e => setUsername(e.target.value)} autoComplete="username"
className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" />
<label className="block text-xs text-slate-400 mb-1"></label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)}
<input type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="current-password"
className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" />
{err && <p className="text-rose-400 text-xs mb-3">{err}</p>}
<button disabled={busy} className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90 disabled:opacity-60">
{busy ? '로그인 중…' : '로그인'}
</button>
<p className="text-center text-[11px] text-slate-500 mt-4">admin / editor / author · admin123</p>
{/* 로그인 보조 3링크(UIWS auth 패턴 이식) */}
<div className="flex items-center justify-center gap-2 text-[11px] text-slate-400 mt-3">
<button type="button" onClick={() => setHelper('signup')} className="hover:text-brand"></button>
<span className="text-slate-600">|</span>
<button type="button" onClick={() => setHelper('findId')} className="hover:text-brand"> </button>
<span className="text-slate-600">|</span>
<button type="button" onClick={() => setHelper('reset')} className="hover:text-brand"> </button>
</div>
</>
) : (
<>
<div className="flex items-center gap-2 justify-center mb-4">
<ShieldCheck size={20} className="text-brand" />
<span className="text-lg font-bold">2 </span>
</div>
{isOtp ? (
isSetup ? (
<>
<p className="text-center text-sm text-slate-400 mb-3">
. QR을 Authenticator (Google·Microsoft)
6 .
</p>
{qrImage && (
<div className="flex justify-center mb-3">
<img src={qrImage} alt="OTP QR" width={180} height={180}
style={{ background: '#fff', padding: 8, borderRadius: 8 }} />
</div>
)}
{secret && (
<div className="mb-4">
<div className="text-[11px] text-slate-500 mb-1">QR </div>
<code className="block text-xs text-accent bg-ink border border-edge rounded-md px-2 py-1.5 break-all select-all">
{secret}
</code>
</div>
)}
</>
) : (
<p className="text-center text-sm text-slate-400 mb-6">
Authenticator 6 .
</p>
)
) : (
<p className="text-center text-xs text-slate-400 mb-4">
6 {maskedEmail ? ` (${maskedEmail})` : ''}.
</p>
)}
<label className="block text-xs text-slate-400 mb-1"> </label>
<input value={code} onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
inputMode="numeric" autoComplete="one-time-code" maxLength={6} autoFocus placeholder="000000"
className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm tracking-[0.4em] text-center focus:border-brand outline-none" />
{err && <p className="text-rose-400 text-xs mb-3">{err}</p>}
<button disabled={busy || code.length !== 6}
className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90 disabled:opacity-60">
{busy ? '인증 중…' : isSetup ? '등록하고 로그인' : '인증 확인'}
</button>
<button type="button" onClick={backToLogin}
className="w-full py-2 mt-2 rounded-lg text-slate-400 text-xs hover:text-slate-200">
</button>
</>
)}
</form>
<AuthHelperModals kind={helper} onClose={() => setHelper(null)} />
</div>
)
}

View File

@ -1,8 +1,9 @@
import { useEffect, useState } from 'react'
import { Plus, Trash2, KeyRound, Power, ShieldCheck } from 'lucide-react'
import { Plus, Trash2, KeyRound, Power, ShieldCheck, RefreshCw } from 'lucide-react'
import { CMS_ROLES } from '../components/rbac'
import {
getUsers, createUser, updateUserRole, updateUserActive, resetPassword, deleteUser,
adminOtpReset,
} from '../api/client'
interface User { id: number; username: string; displayName?: string; role: string; active: boolean; createdAt: string | null }
@ -35,6 +36,10 @@ export default function UserManagement() {
const changeRole = (u: User, role: string) => wrap(() => updateUserRole(u.id, role))
const toggleActive = (u: User) => wrap(() => updateUserActive(u.id, !u.active))
const doReset = (u: User) => { const pw = window.prompt(`'${u.username}' 의 새 비밀번호`); if (pw) wrap(() => resetPassword(u.id, pw)) }
const otpReset = (u: User) => {
if (!window.confirm(`'${u.username}' 사용자의 OTP를 초기화하시겠습니까?\n초기화하면 다음 로그인 시 재등록해야 합니다.`)) return
wrap(() => adminOtpReset(u.id))
}
const remove = (u: User) => { if (window.confirm(`'${u.username}' 삭제?`)) wrap(() => deleteUser(u.id)) }
return (
@ -81,6 +86,7 @@ export default function UserManagement() {
<td className="px-4"><div className="flex items-center justify-end gap-3">
<button onClick={() => toggleActive(u)} title={u.active ? '비활성화' : '활성화'} className="text-slate-400 hover:text-brand"><Power size={16} /></button>
<button onClick={() => doReset(u)} title="비밀번호 재설정" className="text-slate-400 hover:text-brand"><KeyRound size={16} /></button>
<button onClick={() => otpReset(u)} title="OTP 초기화" className="text-slate-400 hover:text-brand"><RefreshCw size={16} /></button>
<button onClick={() => remove(u)} title="삭제" className="text-rose-400 hover:text-rose-300"><Trash2 size={16} /></button>
</div></td>
</tr>