Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 85 additions & 101 deletions routes/auth/forgot-password/verify.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,108 +82,92 @@ const verifyAndResetSchema = z.object({
* $ref: '#/components/schemas/ErrorResponse'
*/
router.post('/', validate(verifyAndResetSchema), async (req, res) => {
try {
// ★ 수정 1: 변수를 try 밖으로 꺼내서, catch에서도 쓸 수 있게 함
const { email, code, newPassword } = req.body;

// 1. 이메일로 user_id 조회 (USER_TABLE)
const userQuery = {
TableName: 'USER_TABLE',
IndexName: 'EmailIndex',
KeyConditionExpression: 'email = :email',
ExpressionAttributeValues: { ':email': email },
};
const { Items: users } = await dynamoDB.send(new QueryCommand(userQuery));
const user = users && users.length > 0 ? users[0] : null;

if (!user) {
logger.warn('코드 검증 실패: 사용자를 찾을 수 없음', { email });
throw new ServerError(ERROR_CODES.USER_NOT_FOUND, 404);
}

const userId = user.user_id;

// 2. 새 비밀번호 해싱
const newPasswordHash = await bcrypt.hash(newPassword, BCRYPT_SALT_ROUNDS);
const now = new Date().toISOString();

// 3. 트랜잭션: 코드 검증/소비(Update) + 비밀번호 변경(Update)
// DynamoDB는 '읽고 나서 수정' 사이에 데이터가 변할 수 있으므로,
// ConditionExpression을 사용해 '코드가 유효할 때만' 업데이트하도록 트랜잭션을 구성합니다.
const transactParams = {

try {
// 1. 유저 조회 (EmailIndex)
const userQuery = {
TableName: 'USER_TABLE',
IndexName: 'EmailIndex',
KeyConditionExpression: 'email = :email',
ExpressionAttributeValues: { ':email': email },
};
const { Items: users } = await dynamoDB.send(new QueryCommand(userQuery));
const user = users && users.length > 0 ? users[0] : null;

if (!user) {
throw new ServerError(ERROR_CODES.USER_NOT_FOUND, 404);
}

const userId = user.user_id;

// 2. 비밀번호 해싱
const newPasswordHash = await bcrypt.hash(newPassword, BCRYPT_SALT_ROUNDS);
const now = new Date().toISOString();

// 3. 트랜잭션 실행
const transactParams = {
TransactItems: [
{ // 3-1. 인증 코드 검증 및 소비 처리
Update: {
TableName: 'AUTH_DATA_TABLE',
Key: {
user_id: userId,
sort_key: `RESET#${code}`
},
// ★ 수정됨: consumed -> #c 로 변경
UpdateExpression: 'set #c = :consumed, verified_at = :now',
// ★ 수정됨: 조건식에서도 #c 사용
ConditionExpression: '#c = :not_consumed AND expires_at > :now',
// ★ 추가됨: 별명 정의
ExpressionAttributeNames: {
'#c': 'consumed'
},
ExpressionAttributeValues: {
':consumed': true,
':now': now,
':not_consumed': false,
}
}
{
// 3-1. 인증 코드 검증 & 사용 처리
Update: {
TableName: 'AUTH_DATA_TABLE',
Key: {
user_id: userId,
sort_key: `RESET#${code}`
},
// 예약어 이슈 해결 (#c)
UpdateExpression: 'set #c = :true, verified_at = :now',
ConditionExpression: '#c = :false AND expires_at > :now',
ExpressionAttributeNames: {
'#c': 'consumed'
},
ExpressionAttributeValues: {
':true': true,
':false': false,
':now': now,
},
},
{ // 3-2. 비밀번호 변경
Update: {
TableName: 'AUTH_DATA_TABLE',
Key: {
user_id: userId,
sort_key: 'PASSWORD_ITEM'
},
UpdateExpression: 'set password_hash = :hash, updated_at = :now',
ExpressionAttributeValues: {
':hash': newPasswordHash,
':now': now
}
}
}
]
};

await dynamoDB.send(new TransactWriteCommand(transactParams));

logger.info('비밀번호 재설정 성공', { userId });
res.status(200).json({ message: '비밀번호가 성공적으로 재설정되었습니다.' });

} catch (error) {
// 1. TransactionCanceledException 처리 (조건 불만족)
if (error.name === 'TransactionCanceledException') {
// 상세 이유를 로그에 출력
// CancellationReasons는 배열 형태로 실패 원인을 알려줌
logger.warn('비밀번호 재설정 트랜잭션 취소됨', {
reasons: JSON.stringify(error.CancellationReasons)
});
return res.status(400).json({
error: "INVALID_VERIFICATION_CODE",
message: "인증 코드가 틀렸거나, 만료되었거나, 이미 사용되었습니다."
});
}

if (ServerError.isServerError(error)) {
return res.status(error.statusCode).json(error.toJSON());
},
{
// 3-2. 비밀번호 변경
Update: {
TableName: 'AUTH_DATA_TABLE',
Key: {
user_id: userId,
sort_key: 'PASSWORD_ITEM'
},
UpdateExpression: 'set password_hash = :hash, updated_at = :now',
ExpressionAttributeValues: {
':hash': newPasswordHash,
':now': now,
},
},
},
],
};
await dynamoDB.send(new TransactWriteCommand(transactParams));

logger.info('비밀번호 재설정 성공', { userId });
res.status(200).json({ message: '비밀번호가 성공적으로 재설정되었습니다.' });

} catch (error) {
// 트랜잭션 실패 (조건 불만족 = 코드가 틀렸거나, 만료됐거나, 이미 사용됨)
if (error.name === 'TransactionCanceledException') {
// ★ 수정 2: 이제 email 변수를 안전하게 사용 가능
logger.warn('비밀번호 재설정 실패: 코드 검증 조건 불만족 (코드 불일치/만료/재사용)', { email });
return res.status(400).json(new ServerError(ERROR_CODES.INVALID_VERIFICATION_CODE, 400).toJSON());
}

if (ServerError.isServerError(error)) {
return res.status(error.statusCode).json(error.toJSON());
}
logger.error('비밀번호 재설정 중 오류', { error: error.message });
res.status(500).json(new ServerError(ERROR_CODES.UNEXPECTED_ERROR, 500).toJSON());
}

// 2. 기타 에러 (여기가 중요! 상세 내용을 클라이언트로 보냄)
logger.error('비밀번호 재설정 중 오류', { error: error.message });

// 개발 중에만 이렇게 상세 내용을 봅니다.
res.status(500).json({
error: "Internal Server Error",
message: "서버 내부 오류가 발생했습니다.",
details: error.message, // ★ 진짜 에러 메시지 (영어)
stack: error.stack // ★ 에러 위치
});
}
});

module.exports = router;
});

module.exports = router;