Flask를 사용한 회원가입, 로그인 기능을 구현해보려고 한다. 구현에 앞서 사용할 암호화 방법에 대해 간단히 설명하고자 한다.
해시함수란, 알고리즘의 한 종류로서 임의의 데이터를 입력 받아 항상 고정된 길이의 임의의 값으로 변환해주는 함수를 말한다. 회원가입에 사용할 해시함수 SHA256은 어떤 길이의 입력값을 넣어도 항상 256바이트의 결과값이 나온다. 추가적으로 동일한 입력값은 항상 같은 결과값이 나오고, 입력값이 조금이라도 달라지면 완전히 다른 결과값이 나온다. 아주 작은 확률로 입력값이 다름에도 불구하고 출력값이 같은 경우가 발생하는데 이것을 충돌이라고 한다. 이러한 충돌의 발생 확률이 낮을수록 좋은 함수라고 평가된다. 그리고 결과값을 통해 입력값을 알아내는 것이 불가능하다. 이러한 특징을 가진 해시함수를 이용해 회원가입 시 비밀번호를 암호화하여 DB에 저장할 것이다.
암호화를 하지 않고 평문으로 DB에 저장해놓는다면...?? 누군가 접근해서 비밀번호를 가져간다면... 생각만 해도 끔찍하다 🤭
페이스북이 평문으로 저장한 사례가 있었다. 하지만 외부인의 접근이 없었기 때문에 다행...
➡️ 페이스북 이슈 기사, https://www.boannews.com/media/view.asp?idx=78058&page=1&kind=1
SHA256은 단방향 암호화 방법 중 하나다. 단방향 암호화는 평문을 암호화했을 때 다시 평문으로 복호화할 수 없는 암호화이다. 대표적으로 많이 사용되는 알고리즘이 SHA-256 암호화 알고리즘이다. SHA-256은 임의의 길이 메시지를 256 비트의 축약된 메시지로 만들어내는 해시 알고리즘이다. 데이터의 수정과 변경을 검출 할 수 있으나 인증은 불가능하다. 인증에 사용하기 위해 메시지 인증 코드와 디지털 서명이 요구된다.
반대로 양방향 암호화는 암호화된 암호문을 복호화 할 수 있는 암호화 방법이다. 양방향 알고리즘에는 대칭키(비공개 키), 비대칭키(공개 키) 알고리즘 2가지 방식이 있다.
참고
“SHA256.” SHA256 - 해시넷, http://wiki.hash.kr/index.php/SHA256.
참고
김승민 Software Developer . “[Security] 암호화 알고리즘 - 양방향 암호화, Encryption.” Theo Blog, https://k0102575.github.io/articles/2020-03/encryption.
HMAC(해시 메시지 인증코드)부터 설명하면, 송신자와 수신자만이 공유하고 있는 키와 메시지를 혼합해 해시 값을 만드는 것이다.
HMAC는 송신자와 수신자가 비밀 키를 공유할 경우 보안되지 않은 채널을 통해 보낸 메시지가 훼손되었는지 여부를 확인하는 데 사용할 수 있다. 송신자는 원래 데이터의 해시 값을 계산하여 원래 데이터와 해시 값을 모두 단일 메시지로 보내고, 수신자는 받은 메시지에 대해 해시 값을 다시 계산하고 계산된 HMAC가 전송된 HMAC와 일치하는지 확인한다.
메시지를 변경하거나 올바른 해시 값을 다시 만들기 위해서는 비밀 키를 알아야 하므로 악의적 사용자가 데이터나 해시 값을 변경하면 불일치 상태가 발생하게 된다. 그러므로 원래 해시 값과 계산된 해시 값이 일치할 경우에 메시지가 바르다고 인증할 수 있게 된다.
HMAC SHA-256의 경우는, 비밀 키를 메시지 데이터와 혼합하여 그 결과를 해시 함수로 해시한 다음 해시 값을 다시 비밀 키와 혼합한 후 해시 함수를 한 번 더 적용한다.
이 과정을 통해 HMAC SHA-256(HS256)은 모든 크기의 키(길이가 0인 경우 포함)를 허용하며 길이가 256비트인 해시 시퀀스를 생성한다.
마지막으로, 위 방법은 송신자와 수신자가 같은 비밀 키를 공유하는 대칭키 방법이다.
이는 서버-클라이언트 관계처럼 키를 공유해야하는 대상이 많거나 특정할 수 없는 경우에는 비밀 키를 전달하는 과정에서 위험이 발생할 수 있으므로 권장할 수 없다. 하지만, 고객이 한정된 서비스를 제공하거나 서비스 개시 전에 계약서나 직접 방문을 통해 환경을 설정하는 경우에는 비교적 비밀 키 전달과정이 안전하므로 유용하다. 예를 들면, 결제 서비스를 제공하는 곳과 사용하는 곳은 서로간의 신용과 환경 등을 검증하기 때문에 적용할만 하다.
참고
Sunphiz. “Dog발자.” – Dog발자, http://sunphiz.me/wp/archives/1104.
JWT는 JSON Web Token의 약자로 전자 서명 된 URL-safe (URL로 이용할 수있는 문자 만 구성된)의 JSON이다. JWT는 서버와 클라이언트 간 정보를 주고 받을 때 HTTP 요청 헤더에 JSON 토큰을 넣은 후 서버는 별도의 인증 과정없이 헤더에 포함되어 있는 JWT 정보를 통해 인증한다. 이때 사용되는 JSON 데이터는 URL-Safe 하도록 URL에 포함할 수 있는 문자만으로 만든다. JWT는 HMAC 알고리즘을 사용하여 비밀키 또는 RSA를 이용한 Public Key / Private Key 쌍으로 서명할 수 있다.
참고
“JWT (JSON Web Token) 이해하기와 활용 방안.” Opennaru, Inc., 26 Apr. 2021, http://www.opennaru.com/opennaru-blog/jwt-json-web-token/.
@app.route('/sign_up/save', methods=['POST'])
def sign_up():
username_receive = request.form['username_give']
password_receive = request.form['password_give']
password_hash = hashlib.sha256(password_receive.encode('utf-8')).hexdigest()
doc = {
"username": username_receive, # 아이디
"password": password_hash, # 비밀번호
}
db.users.insert_one(doc)
return jsonify({'result': 'success'})
프론트에서 Ajax를 사용해 사이트에서 사용할 아이디와 비밀번호를 서버로 보낸다. 그럼 서버는 그 데이터를 받아 users라는 db에 저장한다.
@app.route('/sign_up/check_dup', methods=['POST'])
def check_dup():
username_receive = request.form['username_give']
exists = bool(db.users.find_one({"username": username_receive}))
return jsonify({'result': 'success', 'exists': exists})
기존에 등록된 회원인지 체크하기 위해서는 위와 같은 코드처럼 db에 해당 아이디가 있는지를 exists라는 변수에 True인지 False인지 저장하고 이를 json 포맷으로 반환한다. 이 반환 값을 이용해서 프론트에서 적절한 이벤트를 처리할 수 있을 것이다.
@app.route('/')
def home():
token_receive = request.cookies.get('hellotoken')
try:
payload = jwt.decode(token_receive, SECRET_KEY, algorithms=['HS256'])
user_info = db.users.find_one({"username": payload["id"]})
return render_template('index.html', user_info=user_info)
except jwt.ExpiredSignatureError:
return redirect(url_for("login", msg="로그인 시간이 만료되었습니다."))
except jwt.exceptions.DecodeError:
return redirect(url_for("login", msg="로그인 정보가 존재하지 않습니다."))
쿠키에 hellotoken이라는 키가 있으면 해당 키의 값을 token_receive라는 변수에 담는다. HTTP 헤더에 HS256 알고리즘으로 암호화 되어 있는 JSON 토큰 값을 복호화해 json 포맷으로 payload 변수에 담는다.
payload에 저장한 id키의 값을 가진 아이디의 데이터들을 db에서 조회해 user_info 변수에 담는다. (로그인 할 때 payload에 저장한다. 해당 과정은 아래에 나온다.) 이후에 index.html를 렌더링하고 해당 데이터들을 index.html로 보낸다.
만약 해당 코드를 진행하다가 ExpiredSignatureError 예외가 발생하면(JWT 토큰의 유효시간이 지나면) 해당 msg 메세지와 함께 login 함수를 실행시키고, DecodeError 예외가 발생하면 해당 msg 메시지와 함께 login 함수를 실행시킨다. login 함수는 아래와 같다.
@app.route('/login')
def login():
msg = request.args.get("msg")
return render_template('login.html', msg=msg)
로그인에 실패하면 payload 값을 불러올 수 없다. 그래서 decode 에러가 발생하는 것이다.
@app.route('/sign_in', methods=['POST'])
def sign_in():
# 로그인
username_receive = request.form['username_give']
password_receive = request.form['password_give']
pw_hash = hashlib.sha256(password_receive.encode('utf-8')).hexdigest()
result = db.users.find_one({'username': username_receive, 'password': pw_hash})
if result is not None:
payload = {
'id': username_receive,
'exp': datetime.utcnow() + timedelta(seconds=60 * 60 * 24) # 로그인 24시간 유지
}
token = jwt.encode(payload, SECRET_KEY, algorithm='HS256')
return jsonify({'result': 'success', 'token': token})
# 찾지 못하면
else:
return jsonify({'result': 'fail', 'msg': '아이디/비밀번호가 일치하지 않습니다.'})
위 코드는 로그인에 대한 코드다. 입력한 비밀번호를 utf-8로 가져와 sha256 암호화 방법으로 pw_hash 변수에 저장한다. sha256으로 하는 이유는 이는 단방향 암호화 방법이기 때문에 복호화가 불가능하다.
해당 아이디와 비밀번호로 등록된 유저가 db에 있다면 payload에 아이디와 만료시간을 키-값 형태로 저장하고, HS256 알고리즘을 통해 payload 정보들을 SECRET KEY를 이용해 암호화해서 token 변수에 담는다. 그리고 token을 반환한다. 이 코드가 정상적으로 실행이 되면 프론트로 응답을 보내준다.
function sign_in() {
let username = $("#input-username").val()
let password = $("#input-password").val()
$.ajax({
type: "POST",
url: "/sign_in",
data: {
username_give: username,
password_give: password
},
success: function (response) {
if (response['result'] == 'success') {
$.cookie('hellotoken', response['token'], {path: '/'});
window.location.replace("/")
// replace: 히스토리에 현재 페이지의 URL이 기록되지 않아서 이동 후 뒤로가기로 이동 불가
} else {
alert(response['msg'])
}
}
});
}
응답이 성공적으로 이뤄지면 서버에서 응답받은 token 값을 HTTP 헤더의 Cookie에 hellotoken이라는 키에 저장한다. path는 해당 경로 이하의 경로들에 쿠키를 사용한다는 것이다.
만약 Path=/docs이 설정되면, 다음의 경로들은 모두 쿠키가 적용될 것이다.
/docs
/docs/Web/
/docs/Web/HTTP
참고
“HTTP 쿠키 - Http: MDN.” HTTP | MDN, https://developer.mozilla.org/ko/docs/Web/HTTP/Cookies.
로그인에 성공하고 HTTP 헤더를 확인해보면
정상적으로 쿠키가 적용되었다!!(위 사진은 hellotoken 대신 mytoken 사용) 이 토큰이 있다면 cookie path에서 설정한 경로 이하의 사이트들은 로그인 과정 없이 계속 이용할 수 있다!! HTTP는 Stateless한 특성(통신이 끝나면 상태를 유지하지 않음) 때문에 쿠키, 세션 등을 이용해 로그인 기능을 구현한다.
참고
“UTF-8.” Wikipedia, Wikimedia Foundation, 12 Oct. 2021, https://ko.wikipedia.org/wiki/UTF-8.
번외
HS256 알고리즘으로 암-복호화 결과 확인, https://pyjwt.readthedocs.io/en/latest/usage.html