DEV-2166 관리자 비밀번호 변경 오류 — 조사 기록 및 인수인계

작성일: 2026-08-13 관련 티켓: DEV-2166 관리자 비밀번호 변경 시 오류 수정

보안 주의: 이 문서에는 실제 비밀번호·해시값을 기록하지 않습니다. 필요한 값은 CloudWatch 로그와 DB에서 직접 확인하세요.


1. 증상

관리자 화면에서 비밀번호를 변경하면 화면에는 성공(200) 으로 표시되지만,

  1. 새 비밀번호로 로그인되지 않는다 (비밀번호가 일치하지 않습니다)
  2. 옛 비밀번호로도 로그인되지 않는다 → 계정 사용 불가
  3. admin_account.udate_pwd(비밀번호 수정날짜)가 갱신되지 않는다

증상 1·2와 증상 3은 원인이 다른 별개의 문제다. 조사 중 실제로 udate_pwd는 정상 갱신됐는데도 로그인이 안 되는 계정이 확인되었다.


2. 시스템 구조 (선행 이해 필수)

2-1. 관리자 로그인은 2단계

로그인은 admin-api가 아니라 account API에서 처리한다. CMS Login.jsx가 REACT_APP_API_ACCOUNT_BASEURL/login 을 호출한다.

[CMS] Login.jsx
   └→ [aws/account/api] admin-account-api/service/index.js  login()
        ├─ 1단계  checkLogin()  : admin_account.pwd vs SHA1(입력값) 대조   ← 예비 확인
        └─ 2단계  cognito.call("/admin/login", { account, password: member.pwd })
                    └→ [aws/account/api] cognito-api/cognito/adminCognito.js
                         └→ InitiateAuth(USER_PASSWORD_AUTH)   ← 실제 로그인 심사

핵심 두 가지

2-2. Cognito 동기화는 DB 트리거가 담당

애플리케이션 코드에는 Cognito 비밀번호를 바꾸는 호출이 없다. admin-api의 cognito 사용처는 토큰 검증(validationToken)뿐이다. 동기화는 전적으로 DB 계층에 있다.

UPDATE admin_account
  └→ trg_admin_account_update (AFTER UPDATE)          [aws/database/schema/account/triggers]
       └→ sp_admin_account_update(_sha1)              [aws/database/schema/account/procedures]
            └→ lambda_sync(arn:...:wcdAdmin-Account-Register-V4:SERVICE)
                 └→ [aws/account/application] admin_register/index.js  (type: insert|update|delete)
                      └→ admin_register/cognito/cognito.js  updatePassword(account, oldPwd, newPwd)
                           ├─ InitiateAuth(옛 비밀번호로 로그인)     ← 여기서 막히면 끝
                           └─ ChangePasswordCommand

이 구조의 두 가지 결함 — 둘 다 이번에 해소했다 (4·5번)

  1. 자가 복구 불가 — updatePassword()가 "옛 비밀번호로 Cognito 로그인 → 변경" 방식이라, 한 번 어긋나면 옛 비밀번호로도 들어갈 수 없어 이후 모든 변경 시도가 실패한다. 비밀번호 초기화를 해도 마찬가지다.
  2. 실패가 전파되지 않음 — 앱은 lambda_sync 결과를 확인하지 않는다. 동기화가 실패해도 UPDATE는 성공했으므로 화면에는 200이 뜬다.

현재 구조 (위 흐름에서 바뀐 부분)

UPDATE admin_account
  └→ trg_admin_account_update
       └→ IF NOT (OLD.pwd <=> NEW.pwd)                ← 비밀번호가 바뀐 경우에만 호출
            └→ AES_DECRYPT(NEW.pwd) 결과로 프로시저 분기
                 ├─ NULL     → sp_admin_account_update_sha1
                 └─ NOT NULL → sp_admin_account_update
                      └→ lambda_sync(...)
                           └→ setPassword(account, newPwd)
                                └─ AdminSetUserPassword(Permanent)    ← 옛 비밀번호 불필요
                           └→ 실패 시 ADMIN/SYSTEM 알람 발송
                      └→ isSuccess=false 면 SIGNAL → UPDATE 롤백

3. 원인

원인 A — udate_pwd 미갱신 (수정 완료)

admin-api/repository/admin/acount.js _update() 의 SQL에 컬럼이 누락되어 있었다. admin_account.udate_pwd는 datetime DEFAULT NULL 로 ON UPDATE CURRENT_TIMESTAMP 속성이 없어, 쿼리에서 직접 지정해야 한다.

경로 호출 함수 수정 전 udate_pwd
비밀번호 초기화 (resetAuth) _updatePassword() 갱신됨
내 비밀번호 변경 (changeAuth) _updatePassword() 갱신됨
관리자 상세 수정 (update) _update() 누락

원인 B — Cognito 동기화 끊김 (근본 원인, 해소 완료 → 4·5번)

DB에는 새 비밀번호가 저장되지만 Cognito에는 반영되지 않아 두 저장소가 어긋난다.

DB      : 새 비밀번호   ← 1단계 통과
Cognito : 옛 비밀번호   ← 2단계에서 거부

조사한 사례에서는 Cognito 사용자가 잠금(disabled) 상태인 것이 동기화를 막고 있었다.


4. 조사 근거

4-1. wcdAdminAccount-API-V4 (로그인 API)

INFO   NotAuthorizedException: Incorrect username or password.
DEBUG  ❗️ Cognito 관리자 로그인 결과: {"data":{},"errorInfo":{"code":"Error","message":"Incorrect username or password."}}
ERROR  관리자 계정 로그인: { code: 'ERROR_VALID_AUTH', ... }

Cognito 호출까지 도달했다는 것 자체가 DB 1단계를 통과했다는 증거다. login()은 checkLogin() 성공 후에야 params["id"]를 채우고 Cognito로 넘어가므로, 로그에 id가 찍혀 있으면 DB 대조는 통과한 것이다.

4-2. wcdAdmin-Account-Register-V4 (동기화 Lambda)

한 계정에 대해 짧은 시간 내 3회 호출, 3회 모두 실패했다.

호출 트리거를 부른 작업 Cognito 응답
1 비밀번호 초기화 (_updatePassword) Incorrect username or password.
2 상태 ACTIVE 변경 (_updateStatus) User is disabled.
3 관리자 화면에서 비밀번호 변경 (_update) User is disabled.

추론(미확정): 호출 1은 Incorrect username or password, 호출 2·3은 User is disabled로 응답이 갈렸다. Cognito가 자격 증명을 먼저 검증한 뒤 잠금 여부를 본다면, 호출 2·3에서 넘긴 옛 비밀번호는 Cognito 값과 일치했다는 뜻이 된다. 즉 사고 이전부터 이미 어긋나 있었고 계정도 잠겨 있었다는 해석이다. 코드 어디에도 Cognito 사용자를 비활성화하는 로직이 없으므로(AdminDisableUser 검색 0건), 콘솔에서 수동으로 잠갔을 가능성이 높다. 확인 필요.

4-3. DB (TestDB)


5. 조치 현황

# 작업 저장소 상태
1 _update()에 udate_pwd = NOW() 추가 aws/admin/api 완료 (7637586)
2 어긋난 계정 복구 — 진행 중 (Cognito 잠금 해제 완료, 비밀번호 일치 작업 남음)
3 다른 관리자 계정 상태 점검 — 미착수
4 동기화를 AdminSetUserPassword 방식으로 변경 aws/account/application 완료·배포·검증 (3c8bf1b)
5 동기화 실패 전파 aws/account/application, aws/database/schema 완료·배포·검증 — A안 변형 + 실패 알림
6 Lambda 역할에 AdminSetUserPassword 권한 부여 — 완료 — 템플릿 방식 폐기(5c97ee6), 콘솔에서 직접 부여

T 환경 기준이다. prod 적용은 별도로 진행해야 한다 (Lambda 배포 + 트리거·프로시저 적용).

검증 결과 (T)

확인 항목 결과
정상 계정 비밀번호 변경 성공 (오탐 없음)
어긋난 계정에서 이름만 수정 성공 — 트리거 게이팅으로 Cognito 호출 자체가 없음
어긋난 계정에서 비밀번호 변경 차단 + pwd·udate_pwd 롤백
lambda_sync 결과의 $.isSuccess 판정 실측 확인 (should_signal = 1)
실패 알림 발송 실측 확인 (계정·작업·오류 수록, 발송 실패 0건)

1번 수정 내용

  const sql = `UPDATE admin_account
                SET name = ?
-                 ${pwd && ", pwd = SHA1(?)"}
+                 ${pwd && ", pwd = SHA1(?), udate_pwd = NOW()"}
                  , email = ?
                  , tel = ?
                  , partner_v_skip_alarm = ?
                WHERE id = ? AND status = 'ACTIVE'`;

NOW()는 바인딩 파라미터를 쓰지 않아 기존 파라미터 순서에 영향이 없다. 비밀번호 미변경 시 생성되는 SQL은 기존과 동일하다.


6. 조치 상세

4번 — 동기화 방식 개선 (완료·배포·검증)

대상: aws/account/application → admin_register/cognito/cognito.js

기존: InitiateAuth(옛 비밀번호 로그인) → ChangePasswordCommand
변경: AdminSetUserPasswordCommand (Permanent: true)

IAM 권한 — 템플릿 방식은 실패했다 (중요)

AWS::IAM::Policy로 템플릿에 넣어 배포했으나 다음 오류로 실패했다.

User: arn:aws:iam::305848146030:user/wecandeo is not authorized to
perform: iam:PutRolePolicy on resource: role wcdAccount-APP-Role

AWS::Lambda::Version 논리명을 올려야 반영된다

프로시저가 부르는 대상이 :SERVICE 별칭이라, 논리명을 안 바꾸면 코드가 배포돼도 별칭이 옛 버전을 계속 가리킨다. 논리명의 숫자와 실제 Lambda 버전 번호는 무관하다.

역할의 기존 권한 (devel 계정 기준, 조사 시점)

종류 이름 내용
인라인 wcdAccount-CognitoPolicy cognito-idp:AdminDeleteUser on userpool/*
관리형 wcdAPPDefault-Policy Lambda 기본 실행 권한 등

기존 정책을 템플릿으로 가져오지 않고 새 이름(wcdAdminAccount-SetPasswordPolicy)으로 추가했다. 같은 이름을 템플릿이 관리하면 CloudFormation이 정책을 통째로 덮어써 AdminDeleteUser 권한이 사라진다.

참고: SignUp·InitiateAuth·ChangePassword는 IAM 권한이 필요 없는 API다(ClientId/AccessToken으로 인증). 역할에 Cognito 권한이 AdminDeleteUser 하나뿐인데도 기존 코드가 동작한 이유이며, IAM 권한이 유일한 차단 요소라는 근거다.

5번 — 실패 전파 (완료)

검토한 두 방식 중 A를 택하되, 그대로 쓰면 부작용이 커서 변형했다.

방식 판단
A. 프로시저에서 실패 시 SIGNAL 채택 — 단, 비밀번호 변경 시에만 차단하도록 변형
B. 앱에서 변경 후 검증 기각 — 비밀번호 변경 경로 3곳을 각각 고쳐야 하고, admin-api의 Cognito 사용처가 토큰 검증뿐이라 검증 수단이 마땅치 않다

A를 그대로 쓰면 안 되는 이유. 트리거는 admin_account의 모든 UPDATE에서 돈다. 차단을 무조건 걸면 Cognito 장애 시 이름·이메일 수정, 상태 변경, 그리고 로그인 실패 누적에 의한 NonACTIVE 보안 차단까지 막힌다.

구현 (3조각)

  1. 실패 알림 — admin_register/tools/req-alarm.js. 동기화 실패 시 계정·작업종류·오류메시지를 담아 wcdAlarm-API-V4로 발송한다. 알림 실패는 본 처리에 영향을 주지 않는다.
  2. 판정 플래그 — Lambda 응답 최상위에 isSuccess를 추가했다. 기존 statusCode: 200과 body 형식은 유지해 하위 호환된다. 실패해도 200을 반환하던 탓에 프로시저가 성패를 알 수 없던 문제를 푼다.
  3. 차단 — 프로시저에서 lambda_sync 결과를 판정해 실패면 SIGNAL로 UPDATE를 롤백한다. AES/SHA1 두 프로시저 모두에 적용했다.
IF NOT (pwd <=> newPwd) AND JSON_UNQUOTE(JSON_EXTRACT(@result, '$.isSuccess')) = 'false' THEN
	SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'admin_account cognito sync failed';
END IF;

isSuccess가 없으면(구버전 Lambda) 판정할 수 없으므로 기존과 동일하게 통과시킨다 — 프로시저가 Lambda보다 먼저 배포돼도 비밀번호 변경이 막히지 않는다.

추가로, 트리거에서도 걸러낸다. IF NOT (OLD.pwd <=> NEW.pwd)로 감싸 비밀번호가 바뀐 경우에만 프로시저를 호출한다. update 동기화는 비밀번호만 반영하므로 나머지 UPDATE의 호출은 같은 값을 다시 쓰는 낭비였고, Lambda 호출 자체가 실패하면 무관한 수정까지 롤백되던 위험도 함께 사라진다. 프로시저의 동일 조건은 다른 호출 경로 대비 방어용으로 남겼다.

알람 대상 주의. proxy의 첫 세그먼트는 라우팅이 아니라 수신자 조회(admin_alarm.service_type)에 쓰인다. 등록되지 않은 값을 쓰면 알람이 조용히 사라진다. 등록값은 UPLOAD/PACKAGE/ENCODE/DEPLOY/BATCH/ANALYTICS/ALARM/API/VIDEOPACK/ADMIN/BRAND/SUBTITLE/MIGRATION/BILLING이며 ADMIN/SYSTEM을 쓴다. 실제 라우팅 키는 두 번째 세그먼트다.


7. 어긋난 계정 복구 절차

방법 1 — AWS 권한 없이 관리자 화면만으로 (권장)

동기화 Lambda는 "DB의 옛 비밀번호로 Cognito 로그인 → 변경" 방식이므로, DB의 값이 Cognito 값과 같아지는 순간 동기화가 되살아난다. 이를 이용한다.

  1. 대상 계정의 Cognito 잠금을 먼저 해제한다 (잠겨 있으면 어떤 방법도 불가)
  2. 관리자 화면에서 비밀번호 초기화 실행 → DB가 초기화 기본값(_resetPassword()에 하드코딩)으로 바뀐다
  3. 그 기본값으로 로그인 시도
  4. 로그인된 상태에서 원하는 비밀번호로 변경 → 이번엔 옛 값이 Cognito와 일치하므로 동기화 성공

주의: _canChangePassword()가 10분 내 비밀번호 변경 3회째부터 LIMIT_PASSWORD로 차단한다(DynamoDB, TTL 1시간). 위 절차는 2회라 괜찮지만 여유를 두고 진행할 것.

방법 2 — Cognito에 직접 설정

# 1) DB에 저장된 해시 확인
mysql84 --login-path=v4TAccountDB account -e "SELECT account, CONVERT(pwd USING utf8mb4) AS cognito_password FROM admin_account WHERE account = '<계정>'"

# 2) 그 해시를 Cognito 비밀번호로 영구 설정
aws cognito-idp admin-set-user-password \
  --user-pool-id ap-northeast-2_jzcSZpBO9 \
  --username <계정> \
  --password <위에서 확인한 SHA1 해시 40자> \
  --permanent \
  --region ap-northeast-2

8. 참고 정보

저장소 / 배포 대상

구성요소 저장소 경로 배포 대상
관리자 API aws/admin/api wcdAdmin-API-V4
로그인 API aws/account/api (admin-account-api) wcdAdminAccount-API-V4
Cognito 인증 aws/account/api (cognito-api) —
동기화 Lambda aws/account/application (admin_register) wcdAdmin-Account-Register-V4
트리거/프로시저 aws/database/schema/account RDS
관리자 CMS aws/admin/cms —

식별자

조사에 사용한 명령

# 테이블 스키마 (udate_pwd 자동 갱신 속성 없음을 확인)
mysql84 --login-path=v4TAccountDB account -e "SHOW CREATE TABLE admin_account\G"

# 계정 상태 및 비밀번호 해시 대조
mysql84 --login-path=v4TAccountDB account -e "SELECT id, account, status, CONVERT(pwd USING utf8mb4) AS stored_pwd, cdate, udate_pwd FROM admin_account WHERE account = '<계정>'\G"

# 트리거/프로시저 존재 확인
mysql84 --login-path=v4TAccountDB account -e "SELECT ROUTINE_NAME FROM information_schema.ROUTINES WHERE ROUTINE_NAME LIKE '%admin_account%'"

9. 함정 / 주의사항