본문 바로가기
PHP

[PHP] Secure Password Hashing - password_hash()로 비밀번호 저장

by teamnova 2025. 11. 10.
728x90

개념 설명
사용자의 비밀번호를 데이터베이스에 그대로 저장해서는 안 됩니다. 만약 데이터베이스가 유출되면 모든 사용자의 계정이 탈취되기 때문입니다. 이를 방지하기 위해 비밀번호는 해시된 형태로 저장해야 합니다.

 

해싱??
임의의 길이 데이터를 고정된 길이의 데이터로 매핑하는 단방향 함수입니다.

 

해시 함수는 다음과 같은 특징을 가집니다.

1. 단방향성: 원본 데이터(비밀번호)로 해시 값을 계산하기는 쉽지만, 해시 값으로 원본 데이터를 알아내는 것은 거의 불가능해야 합니다. md5()나 sha1() 같은 오래된 해시 함수는 이 원칙이 깨졌으므로 절대 비밀번호 저장에 사용하면 안 됩니다.
2. 솔팅 : 동일한 비밀번호라도 해싱할 때마다 결과가 달라져야 합니다. 이는 '레인보우 테이블' 공격(미리 계산된 해시 값 목록을 이용한 공격)을 무력화합니다. 해싱에 사용되는 랜덤 데이터를 솔트(Salt)라고 합니다.
3. 느린 처리 속도 : 해시 함수는 의도적으로 느리게 설계되어야 합니다. 속도가 빠르면 공격자가 무차별 대입 공격을 통해 짧은 시간 안에 수많은 비밀번호를 테스트해볼 수 있기 때문입니다.

 

1. password_hash(): 비밀번호 해싱하기

 

$password: 해싱할 사용자의 원본 비밀번호.
$algo: 사용할 해싱 알고리즘입니다.

PASSWORD_DEFAULT:  PHP가 설치된 버전에 따라 알고리즘을 자동으로 선택합니다. 나중에 PHP가 업데이트되어 더 좋은 알고리즘이 나오면 자동으로 업그레이드되므로 유지보수에 매우 유리합니다.

PASSWORD_BCRYPT: Bcrypt 알고리즘을 강제로 사용.

PASSWORD_ARGON2I, PASSWORD_ARGON2ID: Argon2 알고리즘을 강제로 사용 (PHP 7.2+).

$options: 알고리즘에 대한 옵션. 주로 cost 값을 지정합니다.

cost: 해싱의 복잡도를 결정하는 값입니다. 숫자가 높을수록 해시 계산에 더 많은 시간이 걸려 보안성이 높아집니다. 기본값은 10이며, 서버 성능에 따라 10~12 사이의 값을 사용하는 것이 일반적입니다.

 

<?php
// 사용자가 회원가입 폼에서 입력한 비밀번호
$plainPassword = 'my_super_secret_password123';

// 옵션 설정 (cost를 높이면 더 안전하지만, 서버 부하가 증가함)
$options = [
    'cost' => 12,
];

// 비밀번호를 해싱합니다. PASSWORD_DEFAULT를 사용하는 것이 가장 좋습니다.
$hashedPassword = password_hash($plainPassword, PASSWORD_DEFAULT, $options);

if ($hashedPassword === false) {
    die('비밀번호 해싱에 실패했습니다.');
}

echo "원본 비밀번호: " . $plainPassword . PHP_EOL;
echo "해싱된 비밀번호: " . $hashedPassword . PHP_EOL;

// $hashedPassword를 데이터베이스의 users 테이블, password 컬럼에 저장합니다.
// (보통 VARCHAR(255) 타입의 컬럼이면 충분합니다.)

/*
실행 결과 (실행할 때마다 솔트가 달라져 결과가 매번 바뀝니다):
원본 비밀번호: my_super_secret_password123
해싱된 비밀번호: $2y$12$Kj/w9P8.t.9yLhR.j/eC.ey.U/bK7.c.9U.f/a.3.d.1.g.2.e
*/
?>

 

 

 

2. password_verify(): 해시 검증하기

 

로그인 시 입력한 비밀번호와 DB에 저장된 해시 값이 일치하는지 비교합니다.


주의: 절대 if (password_hash($input, ...) == $dbHash) 와 같이 비교하면 안 됩니다. password_hash()는 매번 다른 해시를 생성하므로 항상 false가 나옵니다. 또한, == 연산자는 타이밍 공격(Timing Attack)에 취약할 수 있습니다. 반드시 password_verify()를 사용해야 합니다.


문법:
password_verify(string $password, string $hash): bool
$password: 사용자가 로그인 시 입력한 원본 비밀번호.
$hash: 데이터베이스에 저장되어 있던 해시 값.

 

// --- 데이터베이스에서 가져온 정보라고 가정 ---
$userId = 1;
$username = 'john_doe';
// 이전 예제에서 생성되어 DB에 저장된 해시 값
$hashedPasswordFromDB = '$2y$12$Kj/w9P8.t.9yLhR.j/eC.ey.U/bK7.c.9U.f/a.3.d.1.g.2.e';


// --- 사용자가 로그인 폼에 입력한 정보 ---
$inputPassword = 'my_super_secret_password123';


// password_verify() 함수로 비밀번호를 검증합니다.
// 이 함수는 $hashedPasswordFromDB에 포함된 솔트와 알고리즘 정보를 자동으로 추출하여 비교합니다.
if (password_verify($inputPassword, $hashedPasswordFromDB)) {
    echo "로그인 성공! 환영합니다, {$username}님." . PHP_EOL;
    // 여기에 세션 생성 등 로그인 성공 로직을 구현합니다.
} else {
    echo "로그인 실패: 비밀번호가 일치하지 않습니다." . PHP_EOL;
}


// 실패 테스트
$wrongPassword = 'wrong_password';
if (!password_verify($wrongPassword, $hashedPasswordFromDB)) {
    echo "잘못된 비밀번호('{$wrongPassword}')는 검증에 실패했습니다." . PHP_EOL;
}

 

3.password_needs_rehash(): 리해싱 필요 여부 확인

 

시간이 지나면 기존의 cost 값은 너무 낮아져 보안에 취약해질 수 있습니다. 또는 PHP가 업데이트되어 PASSWORD_DEFAULT가 더 강력한 새 알고리즘(예: Bcrypt -> Argon2)을 가리킬 수도 있습니다.
이때 사용자가 로그인할 때마다 해시를 검증하고, 필요하다면 더 강력한 설정으로 다시 해싱하여 DB를 업데이트하는 것이 좋습니다. password_needs_rehash() 함수가 이 작업을 도와줍니다.


password_needs_rehash(string $hash, string|int|null $algo, array $options = []): bool

 

// DB에 저장된 오래된 해시 (예: cost가 10이었던 시절의 해시)
$oldHashFromDB = '$2y$10$abcdefghijklmnopqrstuv.abcdefghijklmnopqrstuv.abcdefghijkl';
$inputPassword = 'my_password'; // 사용자가 입력한 비밀번호

// 1. 먼저 비밀번호가 유효한지 검증
if (password_verify($inputPassword, $oldHashFromDB)) {
    echo "로그인 성공!" . PHP_EOL;

    // 2. 현재 권장 설정과 다른지 확인 (예: cost를 12로 올리고 싶을 때)
    $newOptions = ['cost' => 12];
    if (password_needs_rehash($oldHashFromDB, PASSWORD_DEFAULT, $newOptions)) {
        // 리해싱이 필요하다면, 새로운 해시를 생성
        $newHash = password_hash($inputPassword, PASSWORD_DEFAULT, $newOptions);

        // DB의 비밀번호를 새로운 해시로 업데이트
        // updateUserPasswordInDatabase($userId, $newHash);
        echo "비밀번호 해시가 최신 알고리즘으로 안전하게 업데이트되었습니다." . PHP_EOL;
        echo "새로운 해시: " . $newHash . PHP_EOL;
    }

} else {
    echo "로그인 실패." . PHP_EOL;
}