📝 TIL

[TIL] Flask 로그인/회원가입 구현하기(JWT, 유효성 검사)

오늘 ONEUL 2022. 11. 15. 10:21

 

인증 방식에 대해

웹서비스의 필수 기능이라고 할 수 있는 로그인, 회원가입 기능.
서버에서 회원을 인증하는 방식은 여러 가지이다.
이전에는 세션을 이용한 방식으로 로그인을 구현했었는데,
오늘은 토큰 기반 인증 방식의 JWT를 이용해보려 한다.

 

 

 

회원가입 기능을 구현해보자

회원가입 로직

  • 회원가입을 하기 위해 필요한 정보를 선별한다.(아이디, 닉네임, 비밀번호 등)
  • 각각의 규칙을 정해 검사하고, 저장된 회원 정보와 중복되는 값이 없도록 확인한다.
  • 확인이 끝났다면, 입력 정보를 DB에 저장한다.
  • 이때, 비밀번호는 항상 암호화 필수!

 

비밀번호 암호화

  • 해시 함수란? 알고리즘의 한 종류로서 임의의 데이터를 입력받아 항상 고정된 길이의 임의의 값으로 변환해주는 함수이다.
  • sha256은 어떤 길이의 입력값을 넣어도 항상 256바이트의 결과값이 나온다.
  • 동일한 입력값은 항상 같은 결과값이 나온다.
  • 입력값이 조금이라도 달라지면 완전히 다른 값이 나온다.
  • 결과값을 통해 입력값을 알아내는 것은 불가능하다. (단방향성)
  • 정말 불가능할까? 패스워드 저장 시 단방향 해시 함수의 문제점과 해결법
# [회원가입 API]
# id, pw, nickname을 받아서, mongoDB에 저장합니다.
# 저장하기 전에, pw를 sha256 방법(=단방향 암호화. 풀어볼 수 없음)으로 암호화해서 저장합니다.
@app.route('/api/register', methods=['POST'])
def api_register():
    id_receive = request.form['id_give']
    pw_receive = request.form['pw_give']
    nick_receive = request.form['nick_give']

    // hashlib을 이용하여 암호화합니다.
    pw_hash = hashlib.sha256(pw_receive.encode('utf-8')).hexdigest()
    
    count = list(db.user.find({}, {'_id': False}))
    num = len(count) + 1

    db.user.insert_one({'uid':num, 'id': id_receive, 'pw': pw_hash, 'nick': nick_receive, 'fav':[]})

    return jsonify({'result': 'success'})

 

회원가입 유효성 검사

  • 모든 입력 칸의 입력 확인
  • 아이디, 닉네임 중복 확인
  • 각각 정해진 규칙에 맞는지 확인(정규식)
  • 비밀번호 두 번 확인
  • 이 모든 것을 통과해야 회원 가입을 할 수 있다!
document.getElementById('register-btn').addEventListener('click', register);
document.getElementById('id-check-btn').addEventListener('click', id_duple_check);
document.getElementById('nick-check-btn').addEventListener('click', nick_duple_check);

// input 요소들
const id_elem = document.getElementById('id');
const nick_elem = document.getElementById('nick');
const pw_elem = document.getElementById('pw');
const pw_check_elem = document.getElementById('pw-check');

// input-msg 요소들
const id_msg_elem = document.getElementById('id-msg');
const nick_msg_elem = document.getElementById('nick-msg');
const pw_msg_elem = document.getElementById('pw-msg');
const pw_check_msg_elem = document.getElementById('pw-check-msg');

// 중복 확인 flag
let id_duple_flag = false;
let nick_duple_flag = false;


// 회원 데이터를 서버로 보내기
function register_to_server() {
    $.ajax({
        type: "POST",
        url: "/api/register",
        data: {
            id_give: id_elem.value,
            pw_give: pw_elem.value,
            nick_give: nick_elem.value
        },
        success: function (response) {
            if (response['result'] === 'success') {
                alert('회원가입이 완료되었습니다.');
                window.location.href = '/login';
            } else {
                alert('문제가 발생했습니다. 관리자에게 문의해주세요.');
            }
        }
    })
}

// 아이디 중복 검사
function id_duple_check() {
    if (id_elem.value === '') {
        font_color_change(id_msg_elem, "danger");
        id_msg_elem.innerText = '아이디를 입력해주세요.';
        id_duple_flag = false;
    } else {
        $.ajax({
        type: "POST",
        url: "/api/idCheck",
        data: {
        	id_give: id_elem.value
        },
        success: function (response) {
            if (response['result'] == 'available') {
                font_color_change(id_msg_elem, "success");
                id_msg_elem.innerText = '사용 가능한 아이디입니다.';
                alert('사용 가능한 아이디입니다.');
                id_duple_flag = true;
            } else {
                font_color_change(id_msg_elem, "danger");
                id_msg_elem.innerText = '이미 사용중인 아이디입니다.';
                alert('이미 사용중인 아이디입니다.');
                id_duple_flag = false;
            }
        }
        })
    }
}

// 닉네임 중복 검사
function nick_duple_check() {
    const nick_msg_elem = document.getElementById('nick-msg');
    if (nick_elem.value === '') {
        font_color_change(nick_msg_elem, "danger");
        nick_msg_elem.innerText = '닉네임을 입력해주세요.';
        nick_duple_flag = false;
    } else {
        $.ajax({
        type: "POST",
        url: "/api/nickCheck",
        data: {
        	nick_give: nick_elem.value
        },
        success: function (response) {
            if (response['result'] == 'available') {
                font_color_change(nick_msg_elem, "success");
                nick_msg_elem.innerText = '사용 가능한 닉네임입니다.';
                alert('사용 가능한 아이디입니다.');
                nick_duple_flag = true;
            } else {
                font_color_change(nick_msg_elem, "danger");
                nick_msg_elem.innerText = '이미 사용중인 닉네임입니다.';
                alert('이미 사용중인 닉네임입니다.');
                nick_duple_flag = false;
            }
        }
    })
    }
}

// 정규식을 통한 유효성 검사
id_elem.addEventListener('focusout', id_validate);
nick_elem.addEventListener('focusout', nick_validate);
pw_elem.addEventListener('focusout', pw_validate);
pw_check_elem.addEventListener('focusout', pw_check_validate);

function id_validate() {
    id_duple_flag = false;

    // 아이디는 영문 소문자, 숫자 조합으로 5~13자
    const id_reg = /^[a-z0-9]{5,13}$/;
    if (id_elem.value === '') {
        font_color_change(id_msg_elem, "danger");
        id_msg_elem.innerText = '아이디를 입력해주세요.';
        id_elem.focus();
    } else if (!id_reg.test(id_elem.value)) {
        id_elem.value = '';
        font_color_change(id_msg_elem, "danger");
        id_msg_elem.innerText = '아이디는 영문 소문자, 숫자 조합으로 5~13자만 가능합니다.';
        id_elem.focus();
    } else {
        font_color_change(id_msg_elem, "success");
        id_msg_elem.innerText = '사용 가능한 아이디입니다.';
    }
}
function nick_validate() {
    nick_duple_flag = false;

    // 닉네임은 영문, 한글, 숫자 조합으로 2~8자
    const nick_reg = /^[가-힣a-zA-Z0-9]{2,8}$/;
    if (nick_elem.value === '') {
        font_color_change(nick_msg_elem, "danger");
        nick_msg_elem.innerText = '닉네임을 입력해주세요.';
        nick_elem.focus();
    } else if (!nick_reg.test(nick_elem.value)) {
        nick_elem.value = '';
        font_color_change(nick_msg_elem, "danger");
        nick_msg_elem.innerText = '닉네임은 2~8자만 가능합니다.';
        nick_elem.focus();
    } else {
        font_color_change(nick_msg_elem, "success");
        nick_msg_elem.innerText = '사용 가능한 닉네임입니다.';
    }
}
function pw_validate() {
    // 비밀번호는 영문, 숫자 조합으로 2~8자
    const nick_reg = /^(?=.*[a-zA-z])(?=.*[0-9])(?=.*[$`~!@$!%*#^?&\\(\\)\-_=+]).{8,20}$/;
    if (pw_elem.value === '') {
        font_color_change(pw_msg_elem, "danger");
        pw_msg_elem.innerText = '비밀번호를 입력해주세요.';
        pw_elem.focus();
    } else if (!nick_reg.test(pw_elem.value)) {
        pw_elem.value = '';
        pw_check_elem.value = '';
        font_color_change(pw_msg_elem, "danger");
        pw_msg_elem.innerText = '비밀번호는 영문, 숫자, 특수문자의 조합으로 8~20자만 가능합니다.';
        pw_elem.focus();
    } else {
        font_color_change(pw_msg_elem, "success");
        pw_msg_elem.innerText = '사용 가능한 비밀번호입니다.';
    }
}
function pw_check_validate() {
    if (pw_check_elem.value === '') {
        font_color_change(pw_check_msg_elem, "danger");
        pw_check_msg_elem.innerText = '비밀번호를 다시 입력해주세요.';
        pw_elem.focus();
    } else if (pw_check_elem.value != pw_elem.value) {
        pw_elem.value = '';
        pw_check_elem.value = '';
        font_color_change(pw_check_msg_elem, "danger");
        pw_check_msg_elem.innerText = '비밀번호가 일치하지 않습니다.';
        pw_elem.focus();
    } else {
        font_color_change(pw_check_msg_elem, "success");
        pw_check_msg_elem.innerText = '비밀번호가 일치합니다.';
    }
}

// 유효성 검사와 중복 확인을 모두 통과한 사람만 회원가입
function register() {
    if (id_elem.value === '') {
        font_color_change(id_msg_elem, "danger");
        id_msg_elem.innerText = '아이디를 입력해주세요.';
        return false;
    }
    if (!id_duple_flag) {
        font_color_change(id_msg_elem, "danger");
        id_msg_elem.innerText = '아이디 중복 검사가 필요합니다.';
        return false;
    }
    if (nick_elem.value === '') {
        font_color_change(nick_msg_elem, "danger");
        nick_msg_elem.innerText = '닉네임을 입력해주세요.';
        return false;
    }
    if (!nick_duple_flag) {
        font_color_change(nick_msg_elem, "danger");
        nick_msg_elem.innerText = '닉네임 중복 검사가 필요합니다.';
        return false;
    }
    if (pw_elem.value === '') {
        font_color_change(pw_msg_elem, "danger");
        pw_msg_elem.innerText = '비밀번호를 입력해주세요.';
        return false;
    }
    if (pw_check_elem.value === '') {
        font_color_change(pw_check_msg_elem, "danger");
        pw_check_msg_elem.innerText = '비밀번호를 다시 입력해주세요.';
        return false;
    }


    register_to_server();
}

// 글씨 색상 전환
function font_color_change(msg_elem, danger_or_success) {
    if (danger_or_success === 'danger') {
        msg_elem.classList.remove('text-success');
        msg_elem.classList.add('text-danger');
    } else if (danger_or_success === 'success') {
        msg_elem.classList.remove('text-danger');
        msg_elem.classList.add('text-success');
    }
}

 

 

 

로그인 기능을 구현해보자

로그인 로직

  • 회원이 입력한 정보를 바탕으로 DB에 해당 정보가 있는 확인 한다.
  • 이때, 비밀번호는 회원가입 때와 동일한 방식으로 암호화한다.
  • 회원 정보가 없는 경우, 실패 메시지를 보낸다.
  • 회원 정보가 있는 경우, 아이디와 토큰 만료 시간을 저장하는 토큰을 만들어 클라이언트로 넘겨준다.
  • 클라이언트는 건네받은 토큰을 쿠키로 저장하여 만료되기 전까지 갖고 있으면서, API 요청을 보낼 때마다 회원임을 확인받는다.
  • 로그아웃 시, 쿠키에 저장되어 있는 토큰을 삭제한다.

 

JWT란?

  • JSON Web Token의 줄임말로, JSON 객체를 사용해 정보를 안정성 있게 전달하는 웹 표준이다.
  • 사용자가 로그인을 하면 서버에서 회원임을 인증하는 토큰을 넘겨주고, 이후 회원만 접근할 수 있는 서비스에서 회원임을 증명할 수 있다. 일종의 자유 이용권!
# [로그인 API]
# id, pw를 받아서 맞춰보고, 토큰을 만들어 발급합니다.
@app.route('/api/login', methods=['POST'])
def api_login():
    id_receive = request.form['id_give']
    pw_receive = request.form['pw_give']

    # 회원가입 때와 같은 방법으로 pw를 암호화합니다.
    pw_hash = hashlib.sha256(pw_receive.encode('utf-8')).hexdigest()

    # id, 암호화된pw을 가지고 해당 유저를 찾습니다.
    result = db.user.find_one({'id': id_receive, 'pw': pw_hash})

    # 찾으면 JWT 토큰을 만들어 발급합니다.
    if result is not None:
        # JWT 토큰에는, payload와 시크릿키가 필요합니다.
        # 시크릿키가 있어야 토큰을 디코딩(=풀기) 해서 payload 값을 볼 수 있습니다.
        # 아래에선 id와 exp를 담았습니다. 즉, JWT 토큰을 풀면 유저ID 값을 알 수 있습니다.
        # exp에는 만료시간을 넣어줍니다. 만료시간이 지나면, 시크릿키로 토큰을 풀 때 만료되었다고 에러가 납니다.
        payload = {
            'id': id_receive,
            'exp': datetime.datetime.utcnow() + datetime.timedelta(seconds=120)
        }
        token = jwt.encode(payload, SECRET_KEY, algorithm='HS256')

        # token을 줍니다.
        return jsonify({'result': 'success', 'token': token})
    # 찾지 못하면
    else:
        return jsonify({'result': 'fail', 'msg': '아이디/비밀번호가 일치하지 않습니다.'})

 

 

 

로그인한 회원 정보를 화면에 출력해보자

이 부분이 굉장히 헷갈렸다.
처음에는 토큰을 쿠키에 저장하기 때문에 서버에서 회원의 관한 정보를(예를 들면 닉네임 같은) payload에 담아 클라이언트로 보내주면 클라이언트에서 토큰을 디코딩하여 회원 정보를 사용해야 한다고 생각했는데 더 간단한 방법이 있었다.

서버 쪽에서 request.cookies.get()을 이용하여 쿠키를 가져오고(쿠키는 항상 가지고 다니기 때문!), PyJWT 라이브러리로 디코딩하여 html과 함께 회원정보를 클라이언트로 보내주면 되는 것이었다. try/except 구문으로 디코딩 쪽에서 에러가 났을 경우엔 html만 내려주면 된다. 클라이언트 쪽에서는 Jinja2 템플릿 문법으로 해당 내용을 화면에 출력할 수 있다. 이렇게 하면 로그인 한 회원과 그렇지 않은 회원에게 각각 다른 화면을 보여주게 되는 것이다.(가령 로그인/로그아웃 버튼 같은)
만약 회원만 접근할 수 있는 페이지라면 except 부분에서 redirect를 해주면 된다.

이걸 깨닫기 전까지 'javascript payload decode' 같은 키워드로 몇 시간을 구글링 하다 지쳐서 뭐에 홀린 듯 코드를 고쳤는데 그게 돌아가는 경우란... 되는 거 보자마자 너무 신나서 ZEP 여기저기를 돌아다니며 자랑하고 다녔다🤣

@app.route('/')
def home():
    token_receive = request.cookies.get('mytoken')
    try:
        payload = jwt.decode(token_receive, SECRET_KEY, algorithms=['HS256'])
        user_info = db.user.find_one({"id": payload['id']})
        return render_template('index.html', nickname=user_info["nick"])
    except jwt.ExpiredSignatureError:
        return render_template('index.html')
    except jwt.exceptions.DecodeError:
        return render_template('index.html')
<div class="collapse navbar-collapse" id="navbarsExample09">
    <ul class="navbar-nav ml-auto">
        {% if nickname == null %}
        <li class="nav-item"><a class="nav-link" href="/register">회원가입</a></li>
        <li class="nav-item"><a class="nav-link" href="/login">로그인</a></li>
        {% else %}
        <li class="nav-item text-black"><a class="nav-link">반갑습니다 {{nickname}}님!</a></li>
        <li class="nav-item"><a class="nav-link" href="/mypage">마이페이지</a></li>
        <li class="nav-item"><a class="nav-link" id="logout-btn" href="">로그아웃</a></li>
        {% endif %}
    </ul>
</div>

 

 

 

오늘의 나는

회원에 관련된 기능을 이렇게 온전히 나 혼자서는 처음 개발해봤는데,
유저의 동선을 따라가면서 데이터의 흐름을 생각하는 일이 꽤나 재밌었다.
중간에 흐름이 꼬일 때마다 내가 어떤 결과물을 원하는지, 어떤 로직으로 구현하려 하는지
글로 정리하는 습관을 들이면서 더 효율적으로 개발할 수 있게 된 것 같다.

요즘 시간이 참 빨리 간다고 느낀다.
하나의 에러를 놓고 동료들과 모여서 집단 지성으로 에러를 해결하다 보면 나도 모르게 하루가 뚝딱 가 있다.
열정적인 사람들과 함께함을 항상 감사히 생각하고, 끝까지 지치지 말자!

 

 

 

 

※ 참고 자료

 

쉽게 알아보는 서버 인증 1편(세션/쿠키 , JWT)

앱 개발을 처음 배우게 됐을 때, 각종 화면을 디자인해보면서 프론트엔드 개발에 큰 흥미가 생겼습니다. 한때 프론트엔드 개발자를 꿈꾸기도 했었죠(현실은 ...) 그러나 서버와 통신을 처음 배웠

tansfil.tistory.com

 

 

패스워드 저장 시 단방향 해시 함수의 문제점과 해결법

단방향 해시 함수(one-way hash function)의 다이제스트(digest) 보통 단방향 해시 함수는 수학적인 연산을 통해 원본 메시지를 변환하여 암호화된 메시지인 다이제스트를 생성한다. 원본 메시를 안다 ->

giron.tistory.com