#5 Golang bcrypt로 비밀번호 해싱 및 검증하기

2019. 12. 30. 22:38 개발 이야기/Golang

 

지난번에 

#3 Golang JWT 토큰으로 인증 구현하기

 

JWT 토큰 구현을 예를 들면서 비밀번호 해쉬를 해서 디비에 저장하고

 

sql 문 where 절을 이용해서 인증하는 함수를 구현했었는데요

 

좀 더 효율적인 bcrypt를 이용해서 비밀번호를 해싱하고 검증하는 

 

로직을 구현해 보도록 하겠습니다

 

bcrypt는 뭘까요 ?


bcrypt 는 Blowfish 암호를 기반으로 Niels Provos 와 David Mazières가 설계하고 1999 년 USENIX 에서 발표 한 암호 해싱 기능 입니다 . [1] 레인보우 테이블 공격 으로부터 보호하기 위해 소금 을 통합하는 것 외에도 bcrypt는 시간이 지남에 따라 적응하는 기능입니다. 반복 횟수를 늘려 속도를 늦출 수 있으므로 계산 성능이 향상 되어도 무차별 대입 검색 공격에 대한 내성이 유지 됩니다.

bcrypt 기능은 OpenBSD [2] 및 SUSE Linux 와 같은 일부 Linux 배포판을 포함한 기타 시스템 의 기본 비밀번호 해시 알고리즘입니다 . [삼]

bcrypt 는 값 비싼 키 설정 단계에서 블록 암호로 유명합니다. 표준 상태의 하위 키로 시작한 다음이 상태를 사용하여 키의 일부를 사용하여 블록 암호화를 수행하고 해당 암호화 결과 (해싱에서 더 정확한)를 사용하여 일부 하위 키를 대체합니다. 그런 다음이 수정 된 상태를 사용하여 키의 다른 부분을 암호화하고 결과를 사용하여 더 많은 하위 키를 바꿉니다. 이 방식으로 모든 하위 키가 설정 될 때까지 점진적으로 수정 된 상태를 사용하여 키를 해시하고 상태 비트를 교체합니다.

Provos와 Mazières는이를 활용하여 더 발전했습니다. 그들은 결과 암호 "Eksblowfish"( "고가의 키 스케줄 Blowfish")를 더빙하여 Blowfish의 새로운 키 설정 알고리즘을 개발했습니다. 키 설정은 솔트 및 암호를 사용하여 모든 하위 키를 설정하는 표준 Blowfish 키 설정의 수정 된 형태로 시작합니다. 그런 다음 소금과 암호를 키로 사용하여 표준 복어 키잉 알고리즘이 적용되는 여러 라운드가 있으며, 각 라운드는 이전 라운드의 하위 키 상태로 시작합니다. 이론적으로 이것은 표준 복어 키 일정보다 강력하지는 않지만 키 변경 횟수는 구성 할 수 있습니다. 따라서 이 프로세스는 임의로 느리게 만들어 해시 나 솔트에 대한 무차별 대입 공격을 방지하는 데 도움이됩니다.

https://en.wikipedia.org/wiki/Bcrypt


 

위키디피아에서 가져온 bcrypt의 해설인데요

 

간략하게 설명하자면 레인보우 테이블 공격을 막을 수 있도록 salt 값을 넣어서 hash 하는 것!~! 입니다.

 

레인보우 테이블은 문자열이 항상 같은 문제점을 이용해

 

존재하는 모든 해시값을 이용해 계정의 비밀번호를 찾아내는 공격인데요

 

요즘같이 컴퓨팅 파워가 엄청나게 발전한 시대에 해시 암호화도 안전하지 않게 되었는데요

 

이런 공격들을 방지하고 비밀번호를 찾아내는 시간을 길~~ 고 느리게 만들어

 

해커가 포기하도록 만드는 (Work Factor 증가)

방법 중 하나입니다

 

우선 위키디피아에 쓰여 있듯!

C, C ++, C #, Go, Java, JavaScript, Elixir, Perl, PHP, Python, Ruby 및 기타 언어에 대한 bcrypt 구현이 있습니다.

 

다양한 언어로 작성이 되어있는데요

 

Go 언어로 구현해보도록 하겠습니다.

 

 

#3 Golang JWT 토큰으로 인증 구현하기

 

JWT 토큰으로 인증 구현하기에서는 제가 수동으로 salt값을 섞어 주었는데요

 

func (user *User) LoginUser() bool {
    db, err := ConnectToDb()
    if err != nil {
        log.Println(err)
    }
    defer db.Close()

    _ = db.QueryRow(
        "SELECT user_no, user_name "+
            "FROM user_info "+
            "WHERE user_id = $1 AND user_pw = $2 AND is_enabled = 1",
        user.Email, sha512hash(user.Pw)).Scan(&user.UserNo, &user.Name)

    if user.UserNo != 0 {
        return true
    } else {
        return false
    }
}

 

이런 식으로 해쉬 함수를 이용했었지만 

 

bcrypt 모듈을 이용해서 해시를 만들어보겠습니다.

 

아주아주 간단한데요

먼저 bcrypt를 살펴보겠습니다.

package bcrypt // import "golang.org/x/crypto/bcrypt"

// The code is a port of Provos and Mazières's C implementation.
import (
	"crypto/rand"
	"crypto/subtle"
	"errors"
	"fmt"
	"io"
	"strconv"

	"golang.org/x/crypto/blowfish"
)

const (
	MinCost     int = 4  // the minimum allowable cost as passed in to GenerateFromPassword
	MaxCost     int = 31 // the maximum allowable cost as passed in to GenerateFromPassword
	DefaultCost int = 10 // the cost that will actually be set if a cost below MinCost is passed into GenerateFromPassword
)

..............

// GenerateFromPassword returns the bcrypt hash of the password at the given
// cost. If the cost given is less than MinCost, the cost will be set to
// DefaultCost, instead. Use CompareHashAndPassword, as defined in this package,
// to compare the returned hashed password with its cleartext version.
func GenerateFromPassword(password []byte, cost int) ([]byte, error) {
	p, err := newFromPassword(password, cost)
	if err != nil {
		return nil, err
	}
	return p.Hash(), nil
}

// CompareHashAndPassword compares a bcrypt hashed password with its possible
// plaintext equivalent. Returns nil on success, or an error on failure.
func CompareHashAndPassword(hashedPassword, password []byte) error {
	p, err := newFromHash(hashedPassword)
	if err != nil {
		return err
	}

	otherHash, err := bcrypt(password, p.cost, p.salt)
	if err != nil {
		return err
	}

	otherP := &hashed{otherHash, p.salt, p.cost, p.major, p.minor}
	if subtle.ConstantTimeCompare(p.Hash(), otherP.Hash()) == 1 {
		return nil
	}

	return ErrMismatchedHashAndPassword
}

 

여기서 맨 위에 보이는 MinCost, MaxCost, DefaultCost는 무엇을 의미할까요?


[10] 나머지 해시 문자열에는 비용 매개 변수, 128 비트 솔트 ( 22 자로 인코딩 된 Radix-64 ) 및 결과 해시 값의 184 비트 ( 31 자로 인코딩 된 Radix-64 )가 포함됩니다. [11] Radix-64 인코딩은 유닉스 / 암호 알파벳을 사용하며 '표준' Base-64 가 아닙니다 . [12] [13] 비용 파라미터 토굴 알고리즘에 입력되고, 이들의 전력 등의 키 확장 반복 횟수를 지정.

예를 들어, 섀도 비밀번호 레코드 $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy는 비용 매개 변수 10을 지정하여 2 10 개의 키 확장 라운드를 나타냅니다 . 소금은 N9qo8uLOickgx2ZMRZoMye결과 해시입니다 IjZAgcfl7p92ldGxad68LJZdL17lhWy. 표준 관행에 따라 사용자 비밀번호 자체는 저장되지 않습니다.


위키디피아의 번역된 것을 보면 10을 지정하면 2^10 개의 확장 라운드를 나타낸다고 하는데요

 

이렇게만 보면 가장 큰 수인 31이 더 좋아 보일 수 있지만

 

31을 사용하면 많은 사용자가 접속할 경우 부하가 생길 수 있다고 합니다

 

그래서 저는 DefaultCost인 10을 이용해서 구현해보겠습니다

 

보시다시피 제가 딱 두 개의 함수를 복붙 해놨는데요 바로 

GenerateFromPassword()CompareHashAndPassword()입니다.

 

함수 명에서 알 수 있듯 hash를 만드는 애와 비교하는 애! 

 

ㅋㅋ 

 

이렇습니다.

 

바로 이용해보겠습니다.

 

func (user *User) RegistUser() error {
    db, err := ConnectToDb()
    if err != nil {
        return fmt.Errorf("db connect error")
    }
    defer db.Close()

    pwHash, _ := bcrypt.GenerateFromPassword([]byte(user.Pw), bcrypt.DefaultCost)

    // 트랜잭션 시작
    tx, err := db.Begin()
    if err != nil {
        return fmt.Errorf("registry error")
    }
    defer tx.Rollback()
    // var userNo string
    err = tx.QueryRow("INSERT INTO "+
        "user_info(user_id, user_name, user_pw) "+
        "VALUES ($1, $2, $3) RETURNING user_no",
        user.Email, user.Name, string(pwHash)).Scan(&user.UserNo)
    if err != nil {
        return fmt.Errorf("registry error")
    }
......................
    //  트랜젝션으로 한번에 인서트
    err = tx.Commit()
    if err != nil {
        return fmt.Errorf("registry tx error")
    }
    return nil
}

 

 pwHash, _ := bcrypt.GenerateFromPassword([]byte(user.Pw), bcrypt.DefaultCost)

이렇게 하면 []byte 자료형의 해시가 리턴되는데요

 

이 리턴 값을 string으로 변환 후 디비에 넣어줍니다 

 

그럼 아래 그림과 같이 저장이 됩니다!

 

 

이렇게 저장되는데 리눅스를 자주 다루어 보신 분들은 아시겠지만

 

저 문자열들은 의미가 있는데요

 

bcrypt를 이용해 해시한 문자열은 어떤 의미를 가지고 있는지 확인해보겠습니다.


섀도 암호 파일 의 해시 문자열에서 접두사 "$ 2a $"또는 "$ 2b $"(또는 "$ 2y $")는 해시 문자열이 모듈 식 암호화 형식의 bcrypt 해시임을 나타냅니다. 

그리고 그 바로뒤에 $10은 위에서 설명한 확장 라운드를 나타내고요!

 

원문을 조금 살펴보면

For example, the shadow password record $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy specifies a cost parameter of 10, indicating 2^10 key expansion rounds. The salt is N9qo8uLOickgx2ZMRZoMye and the resulting hash is IjZAgcfl7p92ldGxad68LJZdL17lhWy. Per standard practice, the user's password itself is not stored.

 

bcrypt를 나타내는 문자, 확장라운드 수, salt의 해시값, 패스워드 해시값

 

이렇게 이루어져 있습니다

 

자그럼 CompareHashAndPassword() 함수를 이용해서

 

검증하는 로직을 구현해보겠습니다

 

func (user *User) LoginUser() bool {
    db, err := ConnectToDb()
    if err != nil {
        log.Println(err)
    }
    defer db.Close()

    var hashPw string

    _ = db.QueryRow(
        "SELECT user_no, user_name, user_pw "+
            "FROM user_info "+
            "WHERE user_id = $1 AND is_enabled = 1",
        user.Email).Scan(&user.UserNo, &user.Name, &hashPw)

    err = bcrypt.CompareHashAndPassword([]byte(hashPw), []byte(user.Pw))
    if err != nil {
        return false
    } else {
        return true
    }
}

string으로 DB에 저장한 문자열을 다시 []byte(바이트 어레이)로 변환후

 

변수에 넣어주고 유저가 입력한 비밀번호도 []byte로 변환 후 변수에 넣어줍니다

 

bcrypt.CompareHashAndPassword([]byte(기존에 저장된 해시값), []byte(사용자가 입력한 비밀번호))

 

이렇게 넣어주면 비밀번호가 일치하지 않을 경우 err를 뱉어주는데요 한번 보겠습니다

'crypto/bcrypt: hashedPassword is not the hash of the given password'

 

이렇게 비밀번호가 틀리다고 에러를 뱉어주는 것을 확인할 수 있습니다

저는 이렇게 에러 유무에 따라 bool 형으로 리턴을 해줬습니다.

 

개발 로직에 맞게 맞춰서 구현하시면 될 것 같습니다!

 

 

이상으로 Golang으로 bcrypt 모듈을 이용해 비밀번호 검증하기였습니다!