# 인증 / 로그인 / 보안 리팩토링 — 처리 결과

- **작성자:** gbpark (Claude 가 직접 처리)
- **작성일:** 2026-04-08
- **기반 감사 리포트:** [`2026-04-08-auth-security-audit.md`](./2026-04-08-auth-security-audit.md)
- **검증:** 백엔드 `./gradlew compileJava` + `./gradlew bootJar` ✅, 프론트 `npx tsc --noEmit` 내 변경 파일 0건 신규 에러

---

## 처리 완료 (Phase 1 + 2)

### 🔴 시급 — 보안 결함

| # | 항목 | 변경 파일 | 처리 내용 |
|---|---|---|---|
| 1 | JWT secret 환경변수화 | `application.yml`, `docker-compose.invyone.yml`, `docker/dev/.env`(신규), `docker/dev/.env.example`(신규) | `${JWT_SECRET}` 환경변수 필수, 디폴트 제거 (미지정 시 앱 기동 실패 = 의도된 동작). 새 64자 random base64 secret 생성. dev `.env` 는 syncthing 동기화로 3대 머신 자동 전파, git 추적 제외 |
| 2 | 마스터 패스워드 백도어 제거 | `AuthService.java` | `masterPassword` 필드 + `masterPassword.equals(password)` 검증 로직 완전 삭제. 어떤 환경에서도 마스터 비번 로그인 불가능 |
| 3 | MD5 → BCrypt 점진 재해시 | `PasswordHasher.java`(신규), `AuthService.java`, `auth.xml` | `PasswordHasher` 유틸이 BCrypt prefix 자동 식별 → BCrypt/MD5 양방향 검증. **로그인 성공 시 MD5 였으면 자동으로 BCrypt 로 재저장**. 신규 회원가입은 BCrypt 강제. 새 mapper 쿼리 `auth.updateUserPassword` 추가 |
| 5 | CORS 화이트리스트 | `SecurityConfig.java`, `application.yml`, `docker-compose.invyone.yml` | `setAllowedOriginPatterns("*")` → `setAllowedOrigins(env)`. `${CORS_ALLOWED_ORIGINS}` 환경변수 콤마 구분 문자열에서 화이트리스트 로드. dev 디폴트는 localhost + Tailscale IP |
| 7 | User enumeration 메시지 통일 | `AuthService.java` | "사용자가 존재하지 않습니다" / "패스워드가 일치하지 않습니다" → 모두 `"아이디 또는 비밀번호를 확인해주세요."` 통일 상수. 내부 로그용 `internalErrorReason` 은 구분 유지 |
| 8 | 로그인 락아웃 | `AuthService.java`, `auth.xml` | 기존 `LOGIN_ACCESS_LOG` 테이블 활용 (별도 저장소 없음). 로그인 시도 직전 `auth.countRecentLoginFailures` 로 최근 5분 내 실패 건수 조회 → **5건 이상이면 비번 검증 전에 차단**. 락아웃 차단도 별도 `recordLoginAttempt` 로 로그 |

### 🟡 중요 — 구조 / 일관성

| # | 항목 | 변경 파일 | 처리 내용 |
|---|---|---|---|
| 9 | useAuth.login() 죽은 코드 제거 | `useAuth.ts` | grep 으로 호출처 0건 확인. `login()` 메서드, `LoginResult` 인터페이스, return 객체의 `login` export 모두 제거. 실제 로그인은 `useLogin` 훅 단독 |
| 10 | TokenManager 단일화 | `lib/auth/tokenManager.ts`(신규), `client.ts`, `useAuth.ts` | 두 곳에 중복되어 있던 TokenManager 를 신규 모듈로 추출. 양쪽에서 import. SSR 가드 + 만료 판단 + 만료 임박 판단 모두 통일 |
| 11 | 임시 인증 fallback 제거 | `useAuth.ts` | `/auth/me` 실패 시 클라이언트에서 토큰 페이로드 디코드해 그대로 신뢰하던 두 곳의 fallback 제거. 백엔드가 401 주면 즉시 비인증 전환 + 토큰 삭제 |
| 12 | getUserInfo 중복 put 버그 | `AuthService.java` | `result.put("company_code", companyCode)` 가 두 번 박혀 있던 것 한 줄 삭제. git blame 으로 snake_case 컨버팅 라운드(b7f9e51d62) 의 단순 실수 확인 |

---

## 미처리 (Phase 3 — 별도 트랙 필요)

다음 4건은 **회귀 위험 / 큰 설계 결정 / 보안 결함이 아닌 이유**로 이번 작업에서 제외했습니다.

### #4 SecurityConfig permitAll 좁히기 — 회귀 위험

- 95개 컨트롤러 중 자체 토큰 검증을 하는 건 `AuthController` 단 한 곳뿐
- `permitAll` 을 걷어내면 나머지 94개가 일제히 인증 강제됨
- 그 중 public 으로 열려 있어야 하는 게 없는지 (헬스체크, 이미지 다운로드, 외부 콜백 등) **컨트롤러별 감사 선행 필요**
- 단계적 처리 권장:
  1. 컨트롤러별 사용 패턴 감사 (1차로 token attribute 사용 여부)
  2. public 후보 (`/api/auth/login`, `/api/auth/signup`, `/api/auth/refresh`, 헬스체크 등) 명시
  3. `requestMatchers(...).permitAll().anyRequest().authenticated()` 로 변경
  4. 핵심 엔드포인트 회귀 테스트 (curl)

### #6 HttpOnly 쿠키 단일 소스 — 광범위 설계 변경

- 영향 범위: 백엔드 응답 (Set-Cookie), 프론트 토큰 핸들링 (TokenManager 변경), 미들웨어 보호 경로 확장, refresh 로직, 인터셉터, 모든 API 호출 인증 헤더 제거
- XSS 방어 효과는 명확하지만 **단일 트랙으로 처리해야 안전**
- 별도 PR 로 진행 권장

### #13 Refresh token 분리 — 큰 설계 변경

- 현재 access token 으로 새 access token 발급 (자체 갱신)
- 진짜 refresh token 도입하려면: 별도 token 발급 + 저장 (DB or Redis) + 회전 + revoke + 블랙리스트
- 토큰 탈취 시나리오 방어에는 필수지만 **#6 과 함께 묶어서 진행**하는 게 효율적

### #14 AuthService 책임 분할 — 보안 결함 아님

- 386줄 → 5~6 파일 분할 (Login / Token / Company / Signup / MenuPathResolver)
- **코드 품질 이슈**라 보안 패치 트랙에서 제외
- 보안 패치 안정화 후 별도 리팩토링 트랙으로

---

## 사용자 액션 필요 (★ 운영 적용 전 필수)

### A. DB 컬럼 길이 확인

`USER_INFO.USER_PASSWORD` 가 BCrypt 해시(60자) 가 들어갈 수 있는 길이인지 확인 필요.

```sql
-- 현재 컬럼 정보 확인
SELECT column_name, data_type, character_maximum_length
FROM information_schema.columns
WHERE table_name = 'user_info' AND column_name = 'user_password';
```

- 만약 `VARCHAR(32)` 같이 짧으면 → `ALTER TABLE USER_INFO ALTER COLUMN USER_PASSWORD TYPE VARCHAR(72);` 필요 (BCrypt 60자 + 여유)
- `VARCHAR(64)` 이상이면 OK
- 컬럼이 짧은 상태에서 점진 재해시 시도하면 update 실패 → log warn 만 남고 다음 로그인에 다시 시도. 기능은 안 깨지지만 재해시가 영영 안 됨

### B. 새 JWT secret 적용 후 강제 재로그인 발생

- secret 이 바뀌면 기존 발급된 토큰은 전부 invalid → **운영 사용자 전원 강제 재로그인**
- dev 환경에서는 즉시 적용해도 되지만, 운영 적용 시에는 사용자 공지 + 점심시간 같은 저트래픽 시간대 권장

### C. 운영 환경 도커 컴포즈 / .env 별도 작성 필요

- 현재는 **dev 도커** (`docker/dev/.env`) 만 작성
- 운영 도커 컴포즈 (`docker/prod/...`) 가 있다면 거기에도 동일한 패턴으로 `.env` + secret + CORS 화이트리스트 적용 필요
- 운영 CORS 화이트리스트: `https://v1.invyone.com,https://api.invyone.com` 식으로 변경

### D. 마스터 패스워드 의존성 확인

- 코드에서는 완전 제거됨
- 만약 어떤 운영 워크플로우가 마스터 패스워드에 의존하고 있었다면 그 워크플로우는 깨짐
- 대안: 정식 admin impersonation 엔드포인트 + 감사 로그 + 권한 체크로 분리해서 새로 만들기 (필요 시)

---

## 신규 / 변경 파일 목록

**신규:**
- `backend-spring/src/main/java/com/erp/security/PasswordHasher.java`
- `frontend/lib/auth/tokenManager.ts`
- `docker/dev/.env` (gitignored, syncthing 동기화)
- `docker/dev/.env.example` (git 추적)
- `notes/gbpark/2026-04-08-auth-security-fix.md` (이 문서)

**수정:**
- `backend-spring/src/main/java/com/erp/security/SecurityConfig.java`
- `backend-spring/src/main/java/com/erp/service/AuthService.java`
- `backend-spring/src/main/resources/application.yml`
- `backend-spring/src/main/resources/mapper/auth.xml`
- `docker/dev/docker-compose.invyone.yml`
- `frontend/hooks/useAuth.ts`
- `frontend/lib/api/client.ts`

---

## 검증 결과

| 검증 | 명령 | 결과 |
|---|---|---|
| 백엔드 컴파일 | `JWT_SECRET=dummy ./gradlew compileJava` | ✅ BUILD SUCCESSFUL (8s) |
| 백엔드 패키징 | `JWT_SECRET=dummy ./gradlew bootJar` | ✅ BUILD SUCCESSFUL (5s) |
| 프론트 타입 | `npx tsc --noEmit` | ✅ 내 변경 파일 0건 신규 에러 (기존 에러는 모두 변경 전부터 존재 — git stash 로 비교 확인 완료) |

런타임 검증 (curl 로그인 → 락아웃 → 재해시 확인) 은 **사용자 환경 (사무실 도커)** 에서 진행 권장.

```bash
# 1. 도커 재기동 (새 .env 로드)
cd ~/invyone
docker compose -f docker/dev/docker-compose.invyone.yml up -d --force-recreate backend-spring

# 2. 정상 로그인 확인 (test_dev DB 의 기존 MD5 사용자로)
curl -i -X POST http://localhost:8083/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"user_id":"...","password":"..."}'
# → 200 + token 응답이면 OK

# 3. 잘못된 비번 5회 → 6회째 락아웃 메시지 확인
for i in 1 2 3 4 5 6; do
  curl -s -X POST http://localhost:8083/api/auth/login \
    -H 'Content-Type: application/json' \
    -d '{"user_id":"testuser","password":"wrong"}' | jq .
  echo "---"
done
# → 6회차에 "로그인 실패가 너무 많습니다. 5분 후 다시 시도해주세요." 응답

# 4. 점진 재해시 확인 — 정상 로그인 1회 후 DB 의 USER_PASSWORD 컬럼이 $2a$/$2b$ 로 시작하는지
psql -h 211.115.91.141 -p 11134 -U postgres -d test_dev -c \
  "SELECT user_id, user_password FROM user_info WHERE user_id = '...';"
```

---

## 정책 결정 사항

| 항목 | 값 | 변경 시 |
|---|---|---|
| 락아웃 윈도우 | 5분 | `AuthService.LOCKOUT_WINDOW_MINUTES` 상수 |
| 락아웃 한계 | 5회 실패 | `AuthService.LOCKOUT_MAX_FAILURES` 상수 |
| BCrypt cost | Spring 디폴트 (10) | `SecurityConfig.passwordEncoder()` 에서 `new BCryptPasswordEncoder(12)` 식으로 변경 가능 |
| JWT 만료 | 24시간 | `JWT_EXPIRATION` 환경변수 |
| 통일 메시지 | "아이디 또는 비밀번호를 확인해주세요." | `AuthService.LOGIN_FAIL_MESSAGE` 상수 |

---

## 종합

- **시급 보안 결함 6/8 처리** (#1, #2, #3, #5, #7, #8) — 미처리는 #4 SecurityConfig (회귀 위험), #6 HttpOnly (광범위 변경)
- **구조 결함 4/6 처리** (#9, #10, #11, #12) — 미처리는 #13 Refresh token (큰 설계), #14 AuthService 분할 (보안 아님)
- **백엔드 빌드 통과**, 프론트 타입 회귀 0건
- **운영 적용 전 사용자 액션 4건** (DB 컬럼 길이 확인, 강제 재로그인 공지, 운영 .env 작성, 마스터 패스워드 의존성 확인)

다음 단계로 #6 HttpOnly 쿠키 + #13 Refresh token 을 묶어서 별도 트랙으로 진행하면 인증 보안 모델이 거의 완성됩니다.
