Add Node auth routes for ACS login

This commit is contained in:
unknown
2026-07-14 15:57:58 +09:00
parent ce82cf5a26
commit 7c8bd37b12
8 changed files with 564 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
# ACS 로그인 404 분석 및 조치
> 작성일: 2026-07-14
> 증상: 운영 배포 URL에서 로그인 시도 시 404 발생
> 대상 URL: `https://acs.apps.bokdev.in/login`
## 1. 증상
로그인 화면은 표시되지만, 아이디/비밀번호 입력 후 로그인 버튼을 누르면 404 오류가 발생한다.
## 2. 원인
프론트엔드 로그인 화면은 `POST /api/auth/login`을 호출한다.
현재 Node 배포 서버의 `/api` 라우터에는 `healthRouter`만 연결되어 있어 `/api/auth/login` 경로가 존재하지 않았다.
즉, 404는 `/login` 화면 경로가 아니라 로그인 API 경로인 `/api/auth/login`에서 발생한 것이다.
## 3. 조치
Node 서버에 Auth API를 추가했다.
| API | 조치 내용 |
|---|---|
| `POST /api/auth/login` | 사용자 조회, bcrypt 비밀번호 검증, 세션 저장 |
| `POST /api/auth/logout` | 세션 삭제 |
| `GET /api/auth/me` | 현재 로그인 사용자 반환 |
| `POST /api/auth/change-password` | 기존 비밀번호 검증 후 새 비밀번호 저장 |
세션은 PostgreSQL 기반 `connect-pg-simple` 스토어를 사용하도록 연결했다.
## 4. 확인 결과
| 항목 | 결과 |
|---|---|
| `npm run typecheck` | 성공 |
| `npm run build:server` | 성공 |
| `npm run build` | 성공 |
첫 번째 전체 빌드는 샌드박스 안에서 esbuild 프로세스 생성이 `spawn EPERM`으로 차단되었고, 승인 후 샌드박스 밖에서 재실행하여 성공을 확인했다.
## 5. 남은 확인 사항
운영 DB에 로그인 계정이 적재되어 있어야 한다.
기존 seed 파일 기준 기본 계정은 아래와 같다.
| 계정 | 초기 비밀번호 | 역할 |
|---|---|---|
| `admin` | `ChangeMe123!` | ADMIN |
| `security` | `ChangeMe123!` | SECURITY |
| `host` | `ChangeMe123!` | HOST |
| `a` | `1` | ADMIN |
| `s` | `1` | SECURITY |
| `h` | `1` | HOST |
운영 DB에 계정이 없으면 404는 해결되지만 로그인은 401로 실패한다. 이 경우 `scripts/seed-load.py``scripts/seeds/users.csv` 기준으로 사용자 seed 적재가 필요하다.
원활한 테스트를 위해 `a/1`, `s/1`, `h/1` 단축 계정을 seed CSV에 추가했다.
## 6. 재테스트 기록
2026-07-14 사용자가 `a/1`로 운영 URL에서 다시 로그인 시도했으나 404가 재현되었다.
판단: 운영 서버가 아직 Auth API 추가 코드로 재배포되지 않은 상태다. 로컬 변경분을 원격 저장소에 push하고 Coolify 재배포가 완료된 뒤 다시 테스트해야 한다.

280
docs/ACS-test-scenarios.md Normal file
View File

@@ -0,0 +1,280 @@
# ACS 테스트 시나리오
> 작성일: 2026-07-14
> 대상: `C:\ai-dev\workspace\acs`
> 목적: ACS 수동 테스트를 위한 업무 흐름별 시나리오, 기대 결과, 결함 기록 기준 정리
## 1. 테스트 전 확인
### 1.1 실행 대상
현재 ACS 저장소에는 Java/Spring 백엔드와 Node 서버 골격이 함께 존재한다.
| 구분 | 확인 내용 |
|---|---|
| Java 백엔드 | `backend/src/main/java/com/itcenter/acs/controller` 기준 주요 ACS API 존재 |
| Node 서버 | 운영 배포 기준 health/db/static frontend 및 Auth API 연결됨 |
오늘 운영 배포 테스트는 Node 배포 서버 기준으로 진행한다. 단, 방문 신청/승인/출입/리포트 API는 Java 백엔드 기능을 Node API로 이관하는 과정에 있으므로, 각 기능별 API 미구현 여부도 함께 기록한다.
### 1.2 기본 접속 정보
오늘 테스트의 기본 URL은 운영 배포 주소인 `https://acs.apps.bokdev.in`을 기준으로 한다.
| 항목 | 운영 배포 기준 |
|---|---|
| ACS 웹 | `https://acs.apps.bokdev.in` |
| 로그인 | `https://acs.apps.bokdev.in/login` |
| 키오스크 | `https://acs.apps.bokdev.in/kiosk` |
| 공개 출입증 | `https://acs.apps.bokdev.in/pass/:token` |
| Health check | `https://acs.apps.bokdev.in/healthz` |
| DB check | `https://acs.apps.bokdev.in/db` |
로컬 개발 테스트가 필요할 때만 아래 주소를 보조로 사용한다.
| 항목 | 로컬 개발 기준 |
|---|---|
| Java 백엔드 | `http://localhost:8080` |
| Vite 프론트엔드 | `http://localhost:5173` |
| 로컬 로그인 | `http://localhost:5173/login` |
### 1.3 기본 계정
| 계정 | 초기 비밀번호 | 역할 |
|---|---|---|
| `admin` | `ChangeMe123!` | ADMIN |
| `security` | `ChangeMe123!` | SECURITY |
| `host` | `ChangeMe123!` | HOST |
| `a` | `1` | ADMIN |
| `s` | `1` | SECURITY |
| `h` | `1` | HOST |
`a/s/h` 계정은 원활한 테스트용 단축 계정이며, 비밀번호 변경 강제 없이 로그인되도록 seed 기준을 둔다. 운영 DB에 해당 seed가 아직 적재되지 않았다면 로그인은 실패할 수 있다.
### 1.4 공통 판정 기준
| 판정 | 기준 |
|---|---|
| PASS | 화면 표시, API 응답, DB/상태 변화가 기대 결과와 일치 |
| FAIL | 기능 오류, 권한 오류, 화면 오류, 데이터 불일치, 예외 발생 |
| BLOCKED | 선행 환경 또는 데이터 문제로 테스트 불가 |
| N/A | 현재 범위에서 제외 |
## 2. Smoke Test
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| SMK-01 | 애플리케이션 기동 | `https://acs.apps.bokdev.in/healthz`, `/db`, `/login` 접속 | health/db 정상, 로그인 화면 표시 |
| SMK-02 | 로그인 | 우선 `a/1`로 로그인 후 필요 시 `s/1`, `h/1` 확인 | 세션 생성, 역할별 접근 메뉴 표시 |
| SMK-03 | 현재 사용자 확인 | 로그인 후 `/api/auth/me` 호출 또는 새로고침 | 현재 사용자 정보 유지 |
| SMK-04 | 로그아웃 | 로그아웃 실행 후 보호 화면 접근 | 로그인 화면으로 이동 |
| SMK-05 | 새로고침 유지 | 로그인 상태에서 대시보드 새로고침 | 세션 유지 또는 만료 시 로그인 이동 |
## 3. 권한 테스트
| ID | 역할 | 확인 화면/API | 기대 결과 |
|---|---|---|---|
| AUT-01 | 비로그인 | `/dashboard`, `/visit-requests`, `/access` | `/login`으로 이동 |
| AUT-02 | HOST | `/visit-requests`, `/visit-requests/new`, `/access` | 접근 가능 |
| AUT-03 | HOST | `/approvals`, `/blacklist`, `/reports`, `/audit`, `/deliveries` | 권한 없음 표시 또는 접근 차단 |
| AUT-04 | SECURITY | `/access`, `/reports` | 접근 가능 |
| AUT-05 | SECURITY | `/approvals`, `/blacklist`, `/audit`, `/deliveries` | 권한 없음 표시 또는 접근 차단 |
| AUT-06 | ADMIN | 전체 관리 화면 | 접근 가능 |
## 4. 방문 신청 테스트
### 4.1 정상 등록
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| VIS-01 | 단일 방문 신청 | HOST 로그인 → 출입 신청 신규 작성 → 필수값 입력 → 저장 | 신청 목록에 `PENDING` 상태로 표시 |
| VIS-02 | 여러 서버실 선택 | 신규 신청에서 서버실 복수 선택 → 저장 | 선택한 서버실 기준 신청/QR 대상이 의도대로 생성 |
| VIS-03 | 선택 입력값 저장 | 회사, 차량번호, 작업명, 현장감시자2 정보 입력 | 목록/상세/승인 화면에 입력값 유지 |
| VIS-04 | 구역 목록 | 신청 화면 진입 | 출입 구역 목록 정상 로딩 |
### 4.2 입력 검증
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| VIS-05 | 방문자명 누락 | 방문자명 없이 저장 | 저장 차단, 안내 메시지 표시 |
| VIS-06 | 연락처 누락 | 연락처 없이 저장 | 저장 차단, 안내 메시지 표시 |
| VIS-07 | 방문 목적 누락 | 목적 없이 저장 | 저장 차단, 안내 메시지 표시 |
| VIS-08 | 과거 일자 입력 | 과거 출입 일시 입력 | 저장 차단 |
| VIS-09 | 퇴실 시간이 입실보다 빠름 | `visitTo < visitFrom` 입력 | 저장 차단 |
| VIS-10 | 특수문자/한글 입력 | 한글 이름, 회사명, 목적 입력 | 깨짐 없이 저장/표시 |
### 4.3 취소
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| VIS-11 | 신청 취소 | PENDING 또는 APPROVED 신청 취소 | 상태가 `CANCELLED`로 변경되고 출입 불가 |
| VIS-12 | 취소 후 승인 시도 | 취소된 신청을 승인 API 또는 화면에서 처리 시도 | 처리 불가 |
## 5. 승인/반려 테스트
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| APR-01 | 승인 대기 목록 | ADMIN 로그인 → 승인 대기 화면 진입 | PENDING 신청만 표시 |
| APR-02 | 승인 | 신청 1건 승인 | 상태 `APPROVED`, `qrToken` 발급, 출입증 접근 가능 |
| APR-03 | 반려 | 신청 1건 반려 및 사유 입력 | 상태 `REJECTED`, 출입 불가 |
| APR-04 | 중복 승인 | 이미 승인된 건 다시 승인 시도 | 중복 처리 차단 |
| APR-05 | 중복 반려 | 이미 반려된 건 다시 반려 시도 | 중복 처리 차단 |
| APR-06 | 승인/반려 감사 로그 | 승인 또는 반려 후 감사 로그 확인 | action과 대상 ID 기록 |
## 6. 출입증/QR 테스트
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| PAS-01 | 내부 배지 화면 | 승인된 건의 `/badge/:id` 접속 | 방문자, 회사, 구역, 기간, QR 표시 |
| PAS-02 | 내부 QR 이미지 | `/api/passes/{id}/qr.png` 호출 | PNG 이미지 응답 |
| PAS-03 | 공개 출입증 | 승인 건의 `/pass/:token` 접속 | 로그인 없이 출입증 표시 |
| PAS-04 | 공개 QR 이미지 | `/api/public/passes/{token}/qr.png` 호출 | PNG 이미지 응답 |
| PAS-05 | 잘못된 토큰 | 임의 token으로 공개 출입증 접속 | 오류 안내 또는 접근 차단 |
| PAS-06 | 만료/취소/반려 건 | 각 상태의 token 또는 id로 출입증 접근 | 출입 불가 상태가 명확히 표시 |
## 7. 출입 콘솔 테스트
### 7.1 입장
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| ACC-01 | 이름 검색 | SECURITY 또는 ADMIN 로그인 → 출입 콘솔 → 방문자명 검색 | 승인된 방문자 후보 표시 |
| ACC-02 | 정상 입장 | 승인된 방문자를 입장 처리 | `IN` 이벤트 기록, 게이트 오픈 성공 메시지 |
| ACC-03 | 재실 현황 반영 | 입장 직후 재실 목록 확인 | 해당 방문자가 재실중으로 표시 |
| ACC-04 | 중복 입장 차단 | 이미 입장한 방문자 재입장 시도 | 중복 입장 오류 |
### 7.2 퇴장
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| ACC-05 | 정상 퇴장 | 재실중 방문자 퇴장 처리 | `OUT` 이벤트 기록, 재실 목록에서 제거 |
| ACC-06 | 입장 없는 퇴장 차단 | 입장 기록 없는 방문자 퇴장 시도 | 퇴장 불가 오류 |
| ACC-07 | 당일 재입장 차단 여부 | 입장→퇴장 완료 후 동일 방문자 재입장 시도 | 정책에 따라 차단 또는 허용. 현재 문서 기준은 차단 여부 확인 필요 |
| ACC-08 | 금일 출입기록 | 입장/퇴장 후 오늘 출입기록 확인 | 입장/퇴장 시각과 재실 여부 일치 |
### 7.3 기간/상태 검증
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| ACC-09 | 방문 시작 전 입장 | `visitFrom` 이전 입장 시도 | 입장 차단 |
| ACC-10 | 방문 종료 후 입장 | `visitTo` 이후 입장 시도 | 상태 만료 또는 입장 차단 |
| ACC-11 | PENDING 입장 | 미승인 신청 입장 시도 | 입장 차단 |
| ACC-12 | REJECTED 입장 | 반려 신청 입장 시도 | 입장 차단 |
| ACC-13 | CANCELLED 입장 | 취소 신청 입장 시도 | 입장 차단 |
## 8. 공개 키오스크 테스트
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| KIO-01 | 키오스크 접근 | 비로그인 상태로 `/kiosk` 접속 | 키오스크 화면 표시 |
| KIO-02 | QR 스캔 준비 | HTTPS 또는 localhost에서 카메라 권한 허용 | 카메라 스캔 동작 |
| KIO-03 | QR 입장 | 승인된 공개 QR 스캔 후 입장 | 입장 성공, 재실 상태 반영 |
| KIO-04 | QR 퇴장 | 동일 QR로 퇴장 | 퇴장 성공, 재실 상태 해제 |
| KIO-05 | 잘못된 QR | 임의 QR 또는 만료 QR 스캔 | 오류 안내 |
| KIO-06 | 카메라 미지원 | HTTP 일반 IP 또는 권한 거부 상태에서 접속 | 대체 안내 또는 오류가 명확히 표시 |
## 9. 블랙리스트 테스트
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| BLK-01 | 등록 | ADMIN 로그인 → 블랙리스트 등록 | 목록에 active 상태로 표시 |
| BLK-02 | 매칭 입장 차단 | 블랙리스트와 이름/연락처가 일치하는 승인 방문자 입장 시도 | 403 또는 차단 메시지 |
| BLK-03 | 삭제 | 블랙리스트 항목 삭제 | 목록에서 제거 또는 inactive 처리 |
| BLK-04 | 삭제 후 입장 | 삭제된 블랙리스트 대상 입장 시도 | 다른 조건이 정상이면 입장 가능 |
| BLK-05 | 감사 로그 | 등록/삭제 후 감사 로그 확인 | `BLACKLIST_ADD`, `BLACKLIST_REMOVE` 기록 |
## 10. 리포트/통계 테스트
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| RPT-01 | 대시보드 통계 | 신청/승인/입장 후 대시보드 확인 | 오늘 방문, 승인 대기, 승인, 재실 수치 일치 |
| RPT-02 | 리포트 다운로드 | SECURITY 또는 ADMIN → 리포트 기간 선택 → 다운로드 | `visits_{from}_{to}.xlsx` 다운로드 |
| RPT-03 | 엑셀 내용 | 다운로드 파일 열기 | 방문자, 회사, 구역, 기간, 상태, 출입 기록 확인 가능 |
| RPT-04 | 빈 기간 | 데이터 없는 기간 다운로드 | 빈 파일 또는 안내가 정상 처리 |
| RPT-05 | 권한 검증 | HOST로 리포트 접근 | 접근 차단 |
## 11. 발송함 테스트
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| DLV-01 | 승인 후 발송 기록 | 방문 승인 후 ADMIN → 발송함 확인 | 출입증 발송 기록 생성 |
| DLV-02 | 성공/실패 필터 | 상태 필터 변경 | 해당 상태의 발송 기록만 표시 |
| DLV-03 | 재시도 | 실패 발송 건 재시도 | attempts 증가, 상태/오류 갱신 |
| DLV-04 | 발송 실패 영향 | 발송 Provider 오류 유도 후 승인 | 승인은 유지되고 발송 실패만 기록 |
## 12. 엑셀 업로드 테스트
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| XLS-01 | 정상 업로드 | 양식에 맞는 엑셀 파일 업로드 | 성공 건수 표시, 신청 생성 |
| XLS-02 | 일부 오류 | 일부 행 필수값 누락 | 성공/실패 건수와 오류 행 표시 |
| XLS-03 | 전체 오류 | 잘못된 양식 업로드 | 생성 없이 오류 표시 |
| XLS-04 | 대량 업로드 | 다수 행 업로드 | 타임아웃 없이 처리, 성공 건수 일치 |
## 13. 보안/세션 테스트
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| SEC-01 | CSRF | state-changing API를 CSRF 헤더 없이 호출 | 차단 또는 정책대로 처리 |
| SEC-02 | 세션 만료 | 세션 삭제 후 보호 API 호출 | 401 및 로그인 이동 |
| SEC-03 | 역할 우회 | HOST 세션으로 ADMIN API 직접 호출 | 403 또는 접근 차단 |
| SEC-04 | 공개 API 범위 | 비로그인 상태에서 공개 출입증 API 호출 | 공개 token API만 허용 |
| SEC-05 | 민감정보 노출 | 공개 출입증 화면 확인 | 불필요한 내부 정보 미노출 |
## 14. UI/브라우저 테스트
| ID | 시나리오 | 절차 | 기대 결과 |
|---|---|---|---|
| UI-01 | 데스크톱 레이아웃 | 1920x1080에서 주요 화면 확인 | 텍스트 겹침 없음 |
| UI-02 | 노트북 레이아웃 | 1366x768에서 주요 화면 확인 | 테이블/버튼 사용 가능 |
| UI-03 | 모바일 공개 출입증 | 모바일 폭에서 `/pass/:token` 확인 | QR과 방문 정보가 잘림 없이 표시 |
| UI-04 | 모바일 키오스크 | 모바일 폭에서 `/kiosk` 확인 | 스캔/입장/퇴장 조작 가능 |
| UI-05 | 한글 표시 | 모든 주요 화면 확인 | 한글 깨짐 없음 |
## 15. 회귀 테스트 묶음
오늘 전체 테스트 시간이 부족하면 아래 순서만 우선 수행한다.
1. `a/1`, `s/1`, `h/1` 로그인
2. HOST 방문 신청
3. ADMIN 승인
4. 공개 출입증 및 QR 확인
5. SECURITY 출입 콘솔 입장
6. 재실 현황 확인
7. 퇴장
8. 리포트 다운로드
9. 블랙리스트 등록 후 입장 차단
10. 감사 로그와 발송함 확인
## 16. 결함 기록 양식
```md
### BUG-YYYYMMDD-번호
- 상태: OPEN / FIXED / RETEST / CLOSED
- 심각도: Critical / Major / Minor / Trivial
- 발견 화면:
- 계정/역할:
- 재현 절차:
- 기대 결과:
- 실제 결과:
- 첨부:
- 관련 API:
- 비고:
```
## 17. 테스트 결과 기록표
| ID | 결과 | 담당 | 일시 | 비고 |
|---|---|---|---|
| SMK-01 | | | | |
| SMK-02 | | | | |
| VIS-01 | | | | |
| APR-02 | | | | |
| PAS-03 | | | | |
| ACC-02 | | | | |
| ACC-05 | | | | |
| KIO-03 | | | | |
| BLK-02 | | | | |
| RPT-02 | | | | |
| DLV-01 | | | | |

View File

@@ -2,3 +2,6 @@ username,full_name,email,department,password,must_change_password,roles
admin,관리자,admin@itcenter.local,IT운영팀,ChangeMe123!,true,ADMIN admin,관리자,admin@itcenter.local,IT운영팀,ChangeMe123!,true,ADMIN
security,보안담당,security@itcenter.local,보안팀,ChangeMe123!,true,SECURITY security,보안담당,security@itcenter.local,보안팀,ChangeMe123!,true,SECURITY
host,홍길동,host@itcenter.local,개발1팀,ChangeMe123!,true,HOST host,홍길동,host@itcenter.local,개발1팀,ChangeMe123!,true,HOST
a,관리자,a@itcenter.local,IT운영팀,1,false,ADMIN
s,보안담당,s@itcenter.local,보안팀,1,false,SECURITY
h,홍길동,h@itcenter.local,개발1팀,1,false,HOST
1 username full_name email department password must_change_password roles
2 admin 관리자 admin@itcenter.local IT운영팀 ChangeMe123! true ADMIN
3 security 보안담당 security@itcenter.local 보안팀 ChangeMe123! true SECURITY
4 host 홍길동 host@itcenter.local 개발1팀 ChangeMe123! true HOST
5 a 관리자 a@itcenter.local IT운영팀 1 false ADMIN
6 s 보안담당 s@itcenter.local 보안팀 1 false SECURITY
7 h 홍길동 h@itcenter.local 개발1팀 1 false HOST

View File

@@ -18,6 +18,9 @@ export const env = {
sessionSecret: process.env.SESSION_SECRET ?? 'dev-only-change-me', sessionSecret: process.env.SESSION_SECRET ?? 'dev-only-change-me',
publicBaseUrl: process.env.ACS_PUBLIC_BASE_URL ?? 'http://localhost:3000', publicBaseUrl: process.env.ACS_PUBLIC_BASE_URL ?? 'http://localhost:3000',
smsProvider: process.env.ACS_SMS_PROVIDER ?? 'dev', smsProvider: process.env.ACS_SMS_PROVIDER ?? 'dev',
cookieSecure:
(process.env.ACS_COOKIE_SECURE ?? '').toLowerCase() === 'true' ||
(process.env.ACS_PUBLIC_BASE_URL ?? '').startsWith('https://'),
}; };
export function requireDatabaseUrl(): string { export function requireDatabaseUrl(): string {

View File

@@ -1,17 +1,39 @@
import path from 'node:path'; import path from 'node:path';
import express from 'express'; import express from 'express';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import session from 'express-session';
import connectPgSimple from 'connect-pg-simple';
import { env } from './config/env.js'; import { env } from './config/env.js';
import { apiRouter } from './routes/index.js'; import { apiRouter } from './routes/index.js';
import { platformStatusRouter } from './routes/health.js'; import { platformStatusRouter } from './routes/health.js';
import { errorHandler } from './http/errors.js'; import { errorHandler } from './http/errors.js';
import { getPool } from './db/pool.js';
const app = express(); const app = express();
const frontendDist = path.resolve(process.cwd(), 'frontend', 'dist'); const frontendDist = path.resolve(process.cwd(), 'frontend', 'dist');
const PgSession = connectPgSimple(session);
app.disable('x-powered-by'); app.disable('x-powered-by');
app.set('trust proxy', 1);
app.use(express.json({ limit: '1mb' })); app.use(express.json({ limit: '1mb' }));
app.use(cookieParser()); app.use(cookieParser());
app.use(session({
name: 'acs.sid',
secret: env.sessionSecret,
resave: false,
saveUninitialized: false,
store: new PgSession({
pool: getPool(),
tableName: 'user_sessions',
createTableIfMissing: true,
}),
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: env.cookieSecure,
maxAge: 1000 * 60 * 60 * 8,
},
}));
app.use(platformStatusRouter); app.use(platformStatusRouter);
app.use('/api', apiRouter); app.use('/api', apiRouter);

182
server/routes/auth.ts Normal file
View File

@@ -0,0 +1,182 @@
import bcrypt from 'bcryptjs';
import type { NextFunction, Request, Response } from 'express';
import { Router } from 'express';
import { ok } from '../http/apiResponse.js';
import { ApiError } from '../http/errors.js';
import { query } from '../db/pool.js';
interface UserRow {
id: string;
username: string;
password_hash: string;
full_name: string;
email: string | null;
department: string | null;
must_change_password: boolean;
enabled: boolean;
locked: boolean;
roles: string[];
}
interface CurrentUser {
id: number;
username: string;
fullName: string;
department?: string;
email?: string;
roles: string[];
mustChangePassword: boolean;
}
const router = Router();
function asyncRoute(
handler: (req: Request, res: Response, next: NextFunction) => Promise<void>,
) {
return (req: Request, res: Response, next: NextFunction) => {
handler(req, res, next).catch(next);
};
}
async function findUserByUsername(username: string): Promise<UserRow | undefined> {
const result = await query<UserRow>(
`
SELECT
u.id,
u.username,
u.password_hash,
u.full_name,
u.email,
u.department,
u.must_change_password,
u.enabled,
u.locked,
COALESCE(array_agg(ur.role) FILTER (WHERE ur.role IS NOT NULL), '{}') AS roles
FROM users u
LEFT JOIN user_roles ur ON ur.user_id = u.id
WHERE u.username = $1
GROUP BY u.id
`,
[username],
);
return result.rows[0];
}
async function findUserById(id: number): Promise<UserRow | undefined> {
const result = await query<UserRow>(
`
SELECT
u.id,
u.username,
u.password_hash,
u.full_name,
u.email,
u.department,
u.must_change_password,
u.enabled,
u.locked,
COALESCE(array_agg(ur.role) FILTER (WHERE ur.role IS NOT NULL), '{}') AS roles
FROM users u
LEFT JOIN user_roles ur ON ur.user_id = u.id
WHERE u.id = $1
GROUP BY u.id
`,
[id],
);
return result.rows[0];
}
function toCurrentUser(user: UserRow): CurrentUser {
return {
id: Number(user.id),
username: user.username,
fullName: user.full_name,
department: user.department ?? undefined,
email: user.email ?? undefined,
roles: user.roles,
mustChangePassword: user.must_change_password,
};
}
async function requireCurrentUser(req: Request): Promise<UserRow> {
const userId = req.session.userId;
if (!userId) {
throw new ApiError(401, '로그인이 필요합니다.');
}
const user = await findUserById(userId);
if (!user || !user.enabled || user.locked) {
req.session.userId = undefined;
throw new ApiError(401, '로그인이 필요합니다.');
}
return user;
}
router.post('/login', asyncRoute(async (req, res) => {
const username = String(req.body?.username ?? '').trim();
const password = String(req.body?.password ?? '');
if (!username || !password) {
throw new ApiError(400, '아이디와 비밀번호를 입력하세요.');
}
const user = await findUserByUsername(username);
const matches = user ? await bcrypt.compare(password, user.password_hash) : false;
if (!user || !matches || !user.enabled || user.locked) {
throw new ApiError(401, '아이디 또는 비밀번호가 올바르지 않습니다.');
}
req.session.userId = Number(user.id);
ok(res, toCurrentUser(user));
}));
router.post('/logout', (req, res, next) => {
req.session.destroy((error) => {
if (error) {
next(error);
return;
}
res.clearCookie('connect.sid');
ok(res, '로그아웃되었습니다.');
});
});
router.get('/me', asyncRoute(async (req, res) => {
const user = await requireCurrentUser(req);
ok(res, toCurrentUser(user));
}));
router.post('/change-password', asyncRoute(async (req, res) => {
const user = await requireCurrentUser(req);
const oldPassword = String(req.body?.oldPassword ?? '');
const newPassword = String(req.body?.newPassword ?? '');
if (newPassword.length < 8) {
throw new ApiError(400, '새 비밀번호는 8자 이상이어야 합니다.');
}
if (!(await bcrypt.compare(oldPassword, user.password_hash))) {
throw new ApiError(400, '현재 비밀번호가 올바르지 않습니다.');
}
if (await bcrypt.compare(newPassword, user.password_hash)) {
throw new ApiError(400, '기존 비밀번호와 다른 비밀번호를 입력하세요.');
}
const passwordHash = await bcrypt.hash(newPassword, 12);
await query(
`
UPDATE users
SET password_hash = $1,
must_change_password = FALSE,
updated_at = now()
WHERE id = $2
`,
[passwordHash, Number(user.id)],
);
ok(res, '비밀번호가 변경되었습니다.');
}));
export const authRouter = router;

View File

@@ -1,6 +1,8 @@
import { Router } from 'express'; import { Router } from 'express';
import { authRouter } from './auth.js';
import { healthRouter } from './health.js'; import { healthRouter } from './health.js';
export const apiRouter = Router(); export const apiRouter = Router();
apiRouter.use(healthRouter); apiRouter.use(healthRouter);
apiRouter.use('/auth', authRouter);

7
server/types/session.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
import 'express-session';
declare module 'express-session' {
interface SessionData {
userId?: number;
}
}