API(Private & Public 모두)에서 가장 자주 구현되는 기능 중 하나인 인증과 인가에 대해 알아보자.
웹 관리자에게 접속하고자하는 사용자를 설명하는 과정에서 이 웹이 사용자의 개인정보를 어디까지 얻을 것인지 해당 사용자의 권한은 얼마나 허용될 것인지를 결정하고
서비스를 누가 쓰는지 어떻게 사용하는지 추적이 가능하도록 하기 위해 필요
정보 암호화에는 두 가지가 있는데
1) 단방향 암호화 Hash (ex. 데이터를 DB에 저장할 때, 해싱하여 알아볼 수 없게하는 암호화)
2) 양방향 암호화 Encryption (ex. 통신 시 개인정보를 주고받을 때 SSL을 적용하여 하는 암호화(HTTPS), AES)
이 중 나는 1)번에 대해 이야기해보겠다.
SSL(Secure Socket Layer)이란
일반적으로 네트워크 상에서 데이터 혹은 신원에 대한 정보를 보호하기 위해서는 암호화 방식을 많이 사용한다. 하지만 단순 암호화만을 통해 잘못 설계된 프로토콜의 경우는 재생(Replay)을 통해 공격당할 수 있다. 확실한 내용까지는 모르더라도 관찰된 통신 내용을 동일하게 재생하면 동일한 결과를 얻을 수 있는 것이다. 따라서, 단순한 암호화 외에 암호화 통신을 할 때 여러 가지 정보를 포함하여 암호화하는 것이 보통이다.
유저는 보통 여러 개의 사이트에 익숙한 아이디와 비밀번호를 통해 가입한다. 그래서 개인이 자주 사용하는 아이디와 비밀번호를 알면 얼마든지 다른 사이트 해킹이 가능하다. 각 사이트는 유저의 이러한 특성을 알고 있기 때문에 사이트마다 아이디와 비밀번호를 암호화시켜서 개인정보를 보호하도록 조치하고 있다. 이를 도와주는 것이 바로 SSL이다.
그대로 해석하면 보안 소켓 레이어라는 뜻이다. 더 쉽게 말하면 웹 서버 인증, 서버 인증이라고 말할 수 있는데 클라이언트와 서버 간의 통신을 제3자가 보증해주는 전자화된 문서이다.
절차: [클라이언트가 서버에 접속] --> [서버가 클라이언트에게 이 인증서 정보를 전달] --> [클라이언트는 이 인증서 정보가 신뢰할 수 있는 것인지를 검증]
이처럼 자물쇠 표시가 있는 경우, SSL이라는 보안 프로토콜을 사용하고 있는 웹사이트라는 것인데 해당 자물쇠 표시를 눌러보면 아래와 같은 인증서를 확인할 수 있다. 이 인증서는 신뢰할 수 있는 인증기관에서 발급받을 수 있고 이 인증서 속에는 인증서의 해당 사이트의 공개키와 사이트의 정보가 들어있다. 인증서가 전송될 때는 인증기관이 제공하는 개인키로 암호화하여 전송된다.
이 인증서를 사용했을 때의 이점은 아래와 같다.
- 통신 내용이 공격자에게 노출되는 것을 막을 수 있음
- 클라이언트가 접속하려는 서버가 신뢰 할 수 있는 서버인지 판단 가능
- 통신 내용의 악의적인 변경 방지 가능
만일 인증서를 가짜사이트 또는 해커가 인위적으로 만들었다면 사용자의 정보가 노출이 될수 있는데 이를 막기 위해 인증기관의 공개키를 브라우저에게 알려준다. 브라우저에서는 해당 사이트에서 제공하는 공개키가 아닌 브라우저의 공개키를 사용하기 때문에 대상이 인증기관인지 아닌지 판별할 수 있다. 이런 인증서에는 만료기간이 있어서 인증서가 만료되기 전에 갱신을 해야한다.
이런 사이트들은 보통 도메인이 https로 시작하기 때문에 HTTPS와 SSL을 완전히 같은 것으로 이해하는 경우도 있는데, HTTPS는 SSL 위에서 돌아가는 프로토콜이다. 즉 HTTPS로 데이터 전송을 시작할 때, SSL이 데이터 보안을 제공한다. HTTPS를 제공하기 위한 보안 기술이 SSL인 것이다.
1) 유저 아이디와 비번 생성
2) 유저의 비번을 암호화 하여 DB에 저장
3) 유저가 아이디와 비번 정보를 입력하여 로그인
4) 유저가 입력한 비번을 암호화하여 DB에 저장된 유저 비밀번호와 비교
5) 일치 시 로그인 성공
6) 로그인 성공 시, 🌝access token을 클라이언트에게 전송
7) 유저는 로그인 성공 후 다음부터는 access token을 첨부해서 request를 서버에 전송하게 되어 매번 로그인 할 필요가 없어짐 (==한 번 로그인한 이후로는 다시 로그인할 필요 없이 모든 서비스를 이용할 수 있는 것)
유저의 ID와 PW를 확인하는 절차로, 이를 위해서는 우선 ID와 PW를 생성하는 기능도 필요하다.
유저의 비번은 절대 그대로 DB에 저장되는 것이 아님 왜냐하면,
1) DB가 해킹을 당하면 유저의 비밀번호도 그대로 노출될 수 있고
2) 외부 해킹이 아니더라도 내부 개발자나 인력이 유저들의 비밀번호를 볼 수 있기 때문
대신 비번을 암호화하여 저장할 경우,
DB가 해킹을 당해도 비밀번호가 그대로 노출되지 않으며 내부 인력도 비밀번호를 알 수가 없다.
비밀번호 암호에는 단방향 해쉬 함수(one-way hash function)가 일반적으로 쓰인다.
단방향 해시 함수
원본 메시지를 변환하여 암호화된 메시지인 🌝다이제스트(digest)를 생성하여 사용
(단순 텍스트를 사용하는 것은 현업에서는 말도 안되는 행위이므로 반드시 단방향 해시함수 적용)단방향성(one-way)?
원본 메시지를 알면 암호화된 메시지를 구하기는 쉽지만 암호화된 메시지로는 원본 메시지를 구할 수 없어서 단방향성(one-way) 이라고 한다예를 들자면
"test_password"에 hash256 해쉬 함수(SHA-256)를 사용하면 0b47c69b1033498d5f33f5f7d97bb6a3126134751629f4d0185c115db44c094e값 (이 값이 다이제스트)이 나온다.
만일 "test_password2"에 hash256 해쉬 함수를 사용하면 d34b32af5c7bc7f54153e2fdddf251550e7011e846b465e64207e8ccda4c1aeb값 (이 값이 다이제스트)이 나온다.여기서 실제 비밀번호는 비슷해도(test password, test password2) 해쉬 함수 값은 완전히 다르게 나오는 것을 볼 수 있는데 이러한 효과를 🌝Avalanche라고 한다. Avalnache는 비밀번호 해시값을 어렵게 만들어 해킹을 어렵게 만드는 하나의 요소이다.
import hashlib
m = hashlib.sha256()
m.update(b"test_password")
m.hexdigest()
Out:
'0b47c69b1033498d5f33f5f7d97bb6a3126134751629f4d0185c115db44c094e'
m = hashlib.sha256()
m.update(b"test_password2")
m.hexdigest()
Out:
'd34b32af5c7bc7f54153e2fdddf251550e7011e846b465e64207e8ccda4c1aeb'
단방향 해쉬 함수의 몇 가지 취약점을 보완하기 위한 방법이 🌝Bcrpyt이다.
(Salting과 Key Stretching을 해주는 대표적인 라이브러리)
다양한 언어를 지원하여 사용이 간편하고 쉽다.
해시 결과값에 솔트값과 해시값 및 반복횟수를 같이 보관하기 때문에 비밀번호 해싱을 적용하는데 있어 DB 설계를 복잡하게 할 필요가 없다.
(대표적으로 단방향 해쉬 함수에서 맞을 수 있는 위기: 🌝Rainbow table attack
사람들이 많이 쓰는 비번 해시값을 쫘라락 저장(미리 해시값들을 계산해 놓은 테이블을 Rainbow table이라고 한다)해놓고, 불특정 다수의 아이디에 그냥 마구잡이로 돌려서 걸리면 잡았다! 하고 해킹하는 것. 심지어 rainbow table은 단순 검색으로도 나오고, 거래도 된다고 함.)
해시 함수는 원래 패스워드를 저장하기 위해서 설계된 것이 아니라 짧은 시간에 데이터를 검색하기 위해 설계된 것이라 본래 처리 속도가 아주 빠르다. 이러한 속성으로 인해 공격자는 매우 빠른 속도로 임의의 문자열의 다이제스트와 해킹할 대상의 다이제스트를 비교할 수 있다(MD5를 사용한 경우 일반적인 장비를 이용하여 1초당 56억 개의 다이제스트를 대입할 수 있다). 이런 방식으로 패스워드를 추측하면 패스워드가 충분히 길거나 복잡하지 않은 경우에는 그리 긴 시간이 걸리지 않는다. (대부분 사용자의 패스워드는 길거나 복잡하지 않을 뿐 아니라, 동일한 패스워드를 사용하는 경우도 많다)
이를 방지하기 위해 일반적으로 2가지 보완점들이 사용된다.
🌝Salting
실제 비밀번호 이외에 추가적으로 랜덤 데이터를 더해서 해시값을 계산하는 방법.
🌝Key Stretching
단방향 해시값을 계산 한 후 그 해쉬값을 또 솔팅+해시하고, 또 솔팅+해시하고, 또 이를 반복하는 것을 말한다.
최근에는 일반적인 장비로 1초에 50억 개 이상의 다이제스트를 비교할 수 있지만, 키 스트레칭을 적용하여 동일한 장비에서 1초에 5번 정도만 비교할 수 있게 한다. GPU(Graphics Processing Unit)를 사용하더라도 수백에서 수천 번 정도만 비교할 수 있다. 50억번과는 비교할 수도 없을 정도로 적은 횟수다. 앞으로 컴퓨터 성능이 더 향상되면 몇 번의 반복을 추가하여 보완할 수 있을 것이다.
Salting과 Key Stretching을 구현한 해쉬 함수중 가장 널리 사용되는 것이 바로 Bcrypt이다. bcrypt는 처음부터 비밀번호를 단방향 암호화 하기 위해 만들어진 해시함수 이다.
import bcrypt
bcrypt.hashpw(b"secret_password", bcrypt.gensalt())
Out:
b'$2b$12$.XIJKgAepSrI5ghrJUaJa.ogLHJHLyY8ikIC.7gDoUMkaMfzNhGo6'
bcrypt.hashpw(b"secret_password", bcrypt.gensalt()).hex()
Out:
'243262243132242e6b426f39757a69666e344f563852694a43666b5165445469397448446c4d366635613542396847366d5132446d62744b70357353'
# hex()는 정수 값을 소문자 16진수 문자열로 "0x" 값과 함께 출력하는 함수
암호화된 비번을 암호화된 비번과 비교하는 것이지,
암호화된 비번을 도로 복호화하여 비교하는 것이 아님.
위에 언급했듯 유저는 일단 로그인이 되면 그 이후로는 access token이라고 하는 암호화된 유저 정보를 첨부해서 request를 보내게 된다.
# 유저 로그인
POST /auth HTTP/1.1
Host: localhost:5000
Content-Type: application/json
{
"username": "joe",
"password": "pass"
}
# 유저가 로그인 시 얻게 되는 access token(차후 모든 request에 함께 보내짐)
HTTP/1.1 200 OK
Content-Type: application/json
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZGVudGl0eSI6MSwiaWF0IjoxNDQ0OTE3NjQwLCJuYmYiOjE0NDQ5MTc2NDAsImV4cCI6MTQ0NDkxNzk0MH0.KPmI6WSjRjlpzecPvs3q_T3cJQvAgJvaQAPtk1abC_E"
}
이제 서버에서는 access token을 복호화하여 해당 유저 정보를 얻게 된다.
예를들어 access token인
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZGVudGl0eSI6MSwiaWF0IjoxNDQ0OTE3NjQwLCJuYmYiOjE0NDQ5MTc2NDAsImV4cCI6MTQ0NDkxNzk0MH0.KPmI6WSjRjlpzecPvs3q_T3cJQvAgJvaQAPtk1abC_E를 복호화하면 다음과 같은 정보를 얻을 수 있다
# 복호화하여 얻은 유저 아이디를 통해 해당 유저가 누구인지 알아냄
{
user_id = 1
}
이런 절차의 목적은
해당 유저가 매번 로그인 해도 되지 않도록 하는 것에 있다.
access token을 생성하는 방법은 여러가지가 있는데,
그 중 가장 널리 사용되는 기술중 하가 바로 🌝JWT(JSON Web Tokens)이다.
JWT는 말 그대로
유저 정보를 담은 JSON 데이터를 암호화하여 클라이언트와 서버간에 주고 받는 것을 이른다.
한글로는 이 그림도 있다.
위 그림에서 4번에 나오는 헤더가 무엇인지는 아래 JWT 토큰 구성 사진을 참고한다.
JSON WEb Token
1) Header는 토큰의 타입과 해시 암호화 알고리즘으로 구성 (메타 데이터)
첫째는 토큰의 유형 (JWT)을 나타내고, 두 번째는 HMAC, SHA256 또는 RSA와 같은 해시 알고리즘을 나타내는 부분입니다.
암호화를 하지는 않고, 인코딩 정도만 한다.2) Payload는 토큰에 담을 클레임(claim) 정보를 포함
Payload 에 담는 정보의 한 ‘조각’ 을 클레임이라고 부르고, 이는 name / value 의 한 쌍으로 이루어짐 (다수의 클레임도 담을 수 있다)
클레임의 정보에는 등록된 (registered) 클레임, 공개 (public) 클레임, 비공개 (private) 클레임 세 종류가 있음
암호화를 하지는 않고, 역시 인코딩 정도는 한다.3) Signature는 secret key를 포함하여 암호화되어 있음
어느 서버가 이 토큰을 만들어줬는지가 붙음
(ex. 쿠팡 토큰을 달고 당근마켓에서 갑자기 적용되면 이상)
헤더와 페이로드 부분과 달리 완벽한 암호화가 필요함 --> 헤더, 페이로드를 암호화하여 시그니쳐와 비교하게 됨
1) Performance
2) Client-side storage
이 유저가 과연 요청하는 request를 실행할 수 있는 권한이 있는 the 유저인가를 확인하고 해당 권한을 부여하는 절차
예를 들자면
"해당 유저는 고객 정보를 볼 수 있는 있지만 수정할 수는 없다" 처럼
권한을 확인하여 옳은 권한만 부여하는 것
인가 역시 JWT를 통해서 구현가능한데,
access token을 통해 해당 유저 정보를 얻을 수 있으므로 해당 유저가 가지고 있는 권한(permission)을 확인 할 수 있다.
참고로 HTTP가 stateless(저장하지 않는)한 성질을 가지기 때문에, 내가 로그인했다는 사실을 알려주기 위해 access token을 꼭 request에 넣어 전달해줘야 하는 것.
1) 인증(Authentication) 절차를 통해 access token 생성
2) access token에는 유저 정보를 확인할 수 있는 정보가 들어가 있어야 함 (예를 들어 user id)
3) 유저는 request를 보낼때 access token을 첨부해서 보냄
4) 서버에서는 유저가 보낸 access token을 복호화
5) 복호화된 데이터를 통해 user id를 얻음
6) user id를 사용해서 database에서 해당 유저의 권한(permission)을 확인
7-a) 유저가 충분한 권한을 가지고 있으면 해당 요청을 처리
7-b) 유저가 권한을 가지고 있지 않으면 Unauthorized Response(401) 혹은 다른 에러 코드출력
가상 환경 만든 후, 가상 환경 접속
conda create -n auth python=3.8
conda activate auth
bcrypt 설치
pip install bcrypt
python 돌리기
python
import bcrypt
# 우리가 아는 그 암호(눈으로 읽을 수 있는) 변수에 저장
password ="1234"
# 임포트한 bcrypt 사용하여 방금 저장한 password를 쏠팅하여 암호화
bcrypt.hashpw( password, bcrypt.gensalt() )
// Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/Users/soomialexhwang/miniconda3/envs/auth/lib/python3.8/site-packages/bcrypt/__init__.py", line 61, in hashpw
raise TypeError("Unicode-objects must be encoded before hashing")
TypeError: Unicode-objects must be encoded before hashing
// 해싱 전에 인코딩 먼저 하라는 에러 발생
# 인코딩 먼저
encoded_password = password.encode('utf-8')
encoded_password
>> b'1234'
type(encoded_password)
>> class 'bytes'
# 하지만,
type(password)
>> class 'str'
# 알 수 있는 사실: 원래는 스트링이었다가 인코딩된 비번은 이제 바이트 형식
# 그럼 디코딩해보자
decoded_password = encoded_password.decode('utf-8')
decoded_password
>> '1234'
# 아까 인코딩하기 전에 해싱 안해준다고 했는데 이제 인코딩 마쳤으니 해싱해보자
hashed_password = bcrypt.hashpw( password.encode('utf-8'), bcrypt.gensalt() )
hashed_password
>> b'$2b$12$8A0OSGUSrEQn/Hs0ZAoBnOaHarSwSF5Xo88qPYZbxaG7AlvYsac9G'
# 인코딩 마친 후, 해싱 패스워드 내보니 알 수 없는 문자로 잘 해싱되었다.
# 해싱할 때 못 알아보게 만들기 위해 뿌리는 쏠트는
# 랜덤으로 추출되기 때문에 칠 때마다 다른 값을 낸다
bcrypt.gensalt()
>> b'$2b$12$mh3Ji4hcdSXTnrxd6oky6e' #a
bcrypt.gensalt()
>> b'$2b$12$.CGK.iRdLkdKB6vUCsGc5O' #b
bcrypt.gensalt()
>> b'$2b$12$O/1hjtS0toZ8A41zLQ0W9O' #c
bcrypt.gensalt()
>> b'$2b$12$bw7P/ROEDvSAzxknpKSox.' #d
bcrypt.gensalt()
>> b'$2b$12$tiLsQ6qPXovMJ0IyhMUFS.' #e
# a, b, c, d, e 모두 다른 값
# 하지만 쏠트 값을 같게 줄 수도 있는데 그 방법은
salt = bcrypt.gensalt()
# 이렇게 쏠트를 salt라는 변수 안에 저장해버리면 이제 그 때는 동일한 쏠트 값을 내게 된다
salt
>> b'$2b$12$/Yxh.rmB6waTG.CLZ.yqg.' #a
salt
>> b'$2b$12$/Yxh.rmB6waTG.CLZ.yqg.' #b
salt
>> b'$2b$12$/Yxh.rmB6waTG.CLZ.yqg.' #c
# 변수로 지정한 이후의 a, b, c 모두 같은 값 출력
# 그럼 salt 변수 속에 담은 같은 값의 쏠트값으로 쏠팅하여 해싱한 값은 어떻게 나올까?
hashed_password =bcrypt.hashpw( password.encode('utf-8'), salt )
# 쏠팅 시, salt 변수에 담아 정해버린 쏠트값 사용
hashed_password
>> b'$2b$12$/Yxh.rmB6waTG.CLZ.yqg.VqwurTX4KGOnByFrK57Dfua2hDaKKvW' #a
hashed_password
>> b'$2b$12$/Yxh.rmB6waTG.CLZ.yqg.VqwurTX4KGOnByFrK57Dfua2hDaKKvW' #b
hashed_password
>> b'$2b$12$/Yxh.rmB6waTG.CLZ.yqg.VqwurTX4KGOnByFrK57Dfua2hDaKKvW' #c
hashed_password
>> b'$2b$12$/Yxh.rmB6waTG.CLZ.yqg.VqwurTX4KGOnByFrK57Dfua2hDaKKvW' #d
# a, b, c, d 모두 같게 해싱된 암호 출력
# 해싱된 암호도 역시 타입은 바이트
type(hashed_password)
>> <class 'bytes'>
# 그러면, 이 암호들이 과연 실제로 같은 값을 의미하는지도 쳐보자
bcrypt.checkpw('1234'.encode('utf-8'), hashed_password)
>> True # 처음 지정한 1234를 인코딩한 값과 그 값을 해싱한 값은 서로 같다
bcrypt.checkpw('123'.encode('utf-8'), hashed_password)
>> False # 하지만 123을 인코딩하면 같지 않다고 나옴. 로그인 시 비번에서 단 한글자만 틀려도 로긴이 안되는 이유.
# 비크립트 방식을 이용하면, 실제 해당 값들이 같은지 비교는 가능하나 디코딩하여 원래 무슨 값을 의미하는지 알 수는 없다.
같은 가상 환경에서 계속 진행
pyjwt 설치
pip install pyjwt
python 돌리기
python
import jwt
encoded_jwt = jwt.encode({'user-id':5}, 'secret', algorithm='HS256')
encoded_jwt
>> b'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyLWlkIjo1fQ.tBQu0HfnOYK7lL3tH5ImgsI-y4Jz1RKscJWbV3U2QMI'
# 타입 검사 해보자
type(encoded_jwt)
>> class 'bytes'
# 그럼 디코딩은 될까?
>>> jwt.decode(encoded_jwt, 'secret', algorithm='HS256' )
{'user-id': 5}
# jwt 방식을 이용하면 디코딩이 가능하여 실제 어떤 값이었었는지까지 볼 수 있음
references: