작성일: 2026-08-13 관련 티켓: DEV-2166 관리자 비밀번호 변경 시 오류 수정
보안 주의: 이 문서에는 실제 비밀번호·해시값을 기록하지 않습니다. 필요한 값은 CloudWatch 로그와 DB에서 직접 확인하세요.
관리자 화면에서 비밀번호를 변경하면 화면에는 성공(200) 으로 표시되지만,
비밀번호가 일치하지 않습니다)admin_account.udate_pwd(비밀번호 수정날짜)가 갱신되지 않는다증상 1·2와 증상 3은 원인이 다른 별개의 문제다. 조사 중 실제로 udate_pwd는 정상 갱신됐는데도 로그인이 안 되는 계정이 확인되었다.
로그인은 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) ← 실제 로그인 심사
핵심 두 가지
admin_account.pwd에 들어 있는 SHA1 hex 40자 문자열 그 자체다.
checkLogin()이 member["pwd"] = storePassword.toString()으로 해시를 담아 그대로 Cognito에 넘기기 때문이다.
→ Cognito 비밀번호를 수동으로 설정할 때도 평문이 아닌 SHA1 해시 문자열을 넣어야 한다.애플리케이션 코드에는 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번)
updatePassword()가 "옛 비밀번호로 Cognito 로그인 → 변경" 방식이라, 한 번 어긋나면 옛 비밀번호로도 들어갈 수 없어 이후 모든 변경 시도가 실패한다. 비밀번호 초기화를 해도 마찬가지다.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 롤백
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() |
누락 |
DB에는 새 비밀번호가 저장되지만 Cognito에는 반영되지 않아 두 저장소가 어긋난다.
DB : 새 비밀번호 ← 1단계 통과
Cognito : 옛 비밀번호 ← 2단계에서 거부
조사한 사례에서는 Cognito 사용자가 잠금(disabled) 상태인 것이 동기화를 막고 있었다.
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 대조는 통과한 것이다.
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. |
_resetPassword()가 updatePassword 후 updateStatus(ACTIVE)를 호출하면서 트리거가 한 번 더 돈 것이다. 이때 OLD.pwd == NEW.pwd가 된다.추론(미확정): 호출 1은 Incorrect username or password, 호출 2·3은 User is disabled로 응답이 갈렸다. Cognito가 자격 증명을 먼저 검증한 뒤 잠금 여부를 본다면, 호출 2·3에서 넘긴 옛 비밀번호는 Cognito 값과 일치했다는 뜻이 된다. 즉 사고 이전부터 이미 어긋나 있었고 계정도 잠겨 있었다는 해석이다. 코드 어디에도 Cognito 사용자를 비활성화하는 로직이 없으므로(AdminDisableUser 검색 0건), 콘솔에서 수동으로 잠갔을 가능성이 높다. 확인 필요.
admin_account.pwd가 로그인 시 입력한 새 비밀번호의 SHA1과 일치 → DB 반영은 정상udate_pwd가 2년 전 값으로 남아 있는 계정 확인 → 원인 A 실증udate_pwd는 갱신됐는데 로그인은 실패하는 계정 확인 → 원인 A와 B가 별개임을 실증| # | 작업 | 저장소 | 상태 |
|---|---|---|---|
| 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 배포 + 트리거·프로시저 적용).
| 확인 항목 | 결과 |
|---|---|
| 정상 계정 비밀번호 변경 | 성공 (오탐 없음) |
| 어긋난 계정에서 이름만 수정 | 성공 — 트리거 게이팅으로 Cognito 호출 자체가 없음 |
| 어긋난 계정에서 비밀번호 변경 | 차단 + pwd·udate_pwd 롤백 |
lambda_sync 결과의 $.isSuccess 판정 |
실측 확인 (should_signal = 1) |
| 실패 알림 발송 | 실측 확인 (계정·작업·오류 수록, 발송 실패 0건) |
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은 기존과 동일하다.
대상: aws/account/application → admin_register/cognito/cognito.js
기존: InitiateAuth(옛 비밀번호 로그인) → ChangePasswordCommand
변경: AdminSetUserPasswordCommand (Permanent: true)
setPassword(account, newPassword)가 진입점이고 index.js의 update 분기에서 호출한다updatePassword()(InitiateAuth + ChangePassword)는 제거했다AccessDeniedException일 때만 기존 방식으로 넘어가는 폴백을 한동안 뒀다가, 권한 부여와 운영 확인이 끝난 뒤 걷어냈다AccessDeniedException 하나로 좁혔던 이유는, 다른 오류까지 폴백하면 실제 실패 원인이 로그에서 가려지기 때문이다pwd) 파라미터를 계속 넘기지만 새 방식에서는 사용하지 않는다.IAM 권한 — 템플릿 방식은 실패했다 (중요)
AWS::IAM::Policy로 템플릿에 넣어 배포했으나 다음 오류로 실패했다.
User: arn:aws:iam::305848146030:user/wecandeo is not authorized to
perform: iam:PutRolePolicy on resource: role wcdAccount-APP-Role
CAPABILITY_IAM은 정상이었다. 배포 주체에게 iam:PutRolePolicy가 없는 것이 원인이다.ec08c5d)만 revert(5c97ee6)하고 재배포했다.iam:PutRolePolicy를 부여하는 방안은 거부했다. 그 역할로 도는 Lambda 코드를 이미 배포할 수 있으므로, 역할에 iam:*까지 스스로 붙일 수 있게 된다.wcdAccount-CognitoPolicy에 cognito-idp:AdminSetUserPassword 액션을 추가하는 방식이다.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 권한이 유일한 차단 요소라는 근거다.
검토한 두 방식 중 A를 택하되, 그대로 쓰면 부작용이 커서 변형했다.
| 방식 | 판단 |
|---|---|
A. 프로시저에서 실패 시 SIGNAL |
채택 — 단, 비밀번호 변경 시에만 차단하도록 변형 |
| B. 앱에서 변경 후 검증 | 기각 — 비밀번호 변경 경로 3곳을 각각 고쳐야 하고, admin-api의 Cognito 사용처가 토큰 검증뿐이라 검증 수단이 마땅치 않다 |
A를 그대로 쓰면 안 되는 이유. 트리거는 admin_account의 모든 UPDATE에서 돈다. 차단을 무조건 걸면 Cognito 장애 시 이름·이메일 수정, 상태 변경, 그리고 로그인 실패 누적에 의한 NonACTIVE 보안 차단까지 막힌다.
구현 (3조각)
admin_register/tools/req-alarm.js. 동기화 실패 시 계정·작업종류·오류메시지를 담아 wcdAlarm-API-V4로 발송한다. 알림 실패는 본 처리에 영향을 주지 않는다.isSuccess를 추가했다. 기존 statusCode: 200과 body 형식은 유지해 하위 호환된다. 실패해도 200을 반환하던 탓에 프로시저가 성패를 알 수 없던 문제를 푼다.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을 쓴다. 실제 라우팅 키는 두 번째 세그먼트다.
동기화 Lambda는 "DB의 옛 비밀번호로 Cognito 로그인 → 변경" 방식이므로, DB의 값이 Cognito 값과 같아지는 순간 동기화가 되살아난다. 이를 이용한다.
_resetPassword()에 하드코딩)으로 바뀐다주의:
_canChangePassword()가 10분 내 비밀번호 변경 3회째부터LIMIT_PASSWORD로 차단한다(DynamoDB, TTL 1시간). 위 절차는 2회라 괜찮지만 여유를 두고 진행할 것.
# 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
--permanent 필수. 빼면 FORCE_CHANGE_PASSWORD 상태가 되어 다른 이유로 로그인이 막힌다.aws cognito-idp describe-user-pool --user-pool-id ... --query 'UserPool.Policies' 로 확인. 해시는 소문자+숫자만이라 대문자·특수문자 필수 정책이면 거부된다.| 구성요소 | 저장소 경로 | 배포 대상 |
|---|---|---|
| 관리자 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 |
— |
ap-northeast-2_jzcSZpBO9 (로그인 성공 로그의 idToken iss 클레임에서 확인)arn:aws:lambda:ap-northeast-2:527581947826:function:wcdAdmin-Account-Register-V4:SERVICE# 테이블 스키마 (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%'"
information_schema.TRIGGERS가 0건이라고 트리거가 없는 것이 아니다.
조회 계정 accountForLambda에 TRIGGER 권한이 없어 MySQL이 행을 숨긴다. 조사 초기에 이것 때문에 "트리거 없음"으로 오판할 뻔했다. 트리거 확인은 권한 있는 계정으로 SHOW CREATE TRIGGER를 쓸 것.
저장소의 트리거 정의가 실제 배포본과 달랐다. (해소됨)
저장소 파일은 sp_admin_account_update 하나만 무조건 호출했으나, 배포본은 암호화 방식에 따라 두 프로시저로 분기하고 있었다. 배포본을 받아 저장소를 갱신했다(bb8cbd9).
조사 당시 "프로시저 파일도 stale일 것"으로 의심했으나 아니었다. 그 의심은 "트리거가 SHA1 값을 AES판으로 보낸다"는 잘못된 전제에서 나왔고, 실제로는 트리거가 분기하므로 AES판에는 AES 값만 간다. 두 프로시저 파일 모두 배포본과 정합했다.
AES 비밀번호 계정이 아직 남아 있다. 조사 시점 38건 중 SHA1 28 / AES 10. 트리거가 두 경로를 모두 쓰므로 프로시저를 고칠 때 양쪽 다 손봐야 한다. SHA1판만 고치면 10건이 조용히 누락된다.
트리거의 @isSHA1 변수명은 의미가 반대다. 담기는 값은 AES 복호화 결과이고, NULL일 때가 SHA1이다.
저장소 프로시저 파일의 Lambda ARN이 prod 계정(527581947826)으로 하드코딩되어 있다. T에 그대로 적용하면 T의 동기화가 prod Lambda를 호출하게 된다. 적용할 환경의 ARN을 유지할 것.
로그인 10회 실패 시 계정이 자동 차단된다.
admin-account-api/service/index.js의 ERROR_VALID_AUTH 처리에서 updateStatus(account, "NonACTIVE")가 실행된다. 차단되면 _update()의 WHERE id = ? AND status = 'ACTIVE' 조건에 걸려 관리자 화면을 통한 복구도 막힌다. 조사·복구 중 로그인 시도 횟수에 주의할 것.
_update()의 조건부 SQL 조립에 잠재 버그가 있다.
${pwd && ", pwd = SHA1(?), udate_pwd = NOW()"} 는 pwd가 undefined일 때 문자열 "undefined"를 SQL에 삽입해 문법 오류를 낸다. 현재는 CMS가 FormData로 항상 pwd 필드를 보내(미입력 시 "") 발현하지 않는다. 동작 변경이라 DEV-2166 범위에서는 제외했다.
비밀번호 변경 경로 어디에도 서버 측 현 비밀번호 검증이 없다. (별도 티켓 감)
_changePassword()(changeAuth)가 받는 auth 파라미터는 현 비밀번호가 아니라 새 비밀번호다. 검증하는 것은 형식·연속문자·account/email/tel 일치 여부·변경 횟수 제한뿐이다.
현 비밀번호 검증은 _validPassword()(validAuth)라는 별도 엔드포인트에 있고, 두 API는 서버에서 연결되어 있지 않다. 순서를 지키는 것은 CMS(CHPassword.jsx가 validAuth → changeAuth 순으로 호출)뿐이라, changeAuth를 직접 호출하면 현 비밀번호 없이 변경된다. 세션 토큰이 탈취되면 계정 완전 장악으로 이어진다.
고치려면 _changePassword()가 현 비밀번호를 함께 받아 서버에서 대조하면 되지만, CMS 요청 파라미터가 바뀌는 변경이라 DEV-2166 범위에서는 제외했다.
(이 사실은 4번 작업에서 "옛 비밀번호를 없애도 되는가"를 검토하다 확인됐다. 옛 비밀번호는 어느 계층에서도 변경의 관문이 아니었고, Lambda가 받던 값도 사용자 입력이 아니라 트리거가 OLD.pwd로 자동 공급하던 것이다.)
AWS CLI 기본 프로파일에 Cognito 관리 권한이 없다.
arn:aws:iam::305848146030:user/wecandeo 는 AdminGetUser·AdminSetUserPassword 모두 AccessDeniedException이다. 콘솔 계정은 권한이 더 넓을 수 있다.