TIL

TIL, 0주차 미니 프로젝트 #1

pwerty 2025. 3. 19. 13:46

2025년 3월 10일 정글 입소 후 점심 먹자마자 냅다 뭔가 만들어내야 했던 이야기에 대해 논한다.

오후 3시부터 정글에서 미니 프로젝트를 시작했다. 이 내용은 복기가 주로 이루어진 내용들이라 실제 일어난 일과 크게 차이가 있다.

미니 프로젝트의 조건이 주어졌었다.

  • 로그인 기능을 JWT 시스템을 이용한 내용으로 구성
  • Bootstrap이 아닌 다른 비주얼 라이브러리를 이용
  • Jinja2를 이용한 서버 사이드 렌더링
  • 문자열을 해당 힌트와 같이 제시된 문제 해답으로 적으며 수행하는 보물찾기 게임

위에서 요구하는 두 조건이 조금 이상하게 다가왔다. 뭘 해야할지 감 잡기가 많이 어려웠다.
어찌저찌 돌다보니 JWT 라이브러리를 사용한 로그인 시스템을 맡게 되었고 그와 동시에 백엔드 개발을 진행했다.

코드 복붙 과정에서 JWT에 관한 설명을 좀 정확히 알고 있었어야 했다. 다른 내용들도 찾아보려했지만 다른 학우의 공유가 도움이 되었다.
https://youtu.be/36lpDzQzVXs?si=DNTYeGSTVhQBoeQ0
이 영상이 많은 도움이 되었다.

JWT는 인증을 구현하는데 이용 할 수 있는 기술 중 하나이며, 쿠키-세션 기술과 대비된다.
유의미하게 다른 점은 인증을 위한 매개체의 저장 위치라는 것이다.
쿠키-세션은 서버에 주로 저장되고, JWT는 클라이언트에 저장되어 유효 한 시간동안 존재 의의를 수행하게 된다.

제일 흔한 인증의 개념이라고 하면 역시 로그인-로그아웃을 생각 해 볼 수 있다.
JWT로 실제 로그인을 시도한다면 3가지 데이터가 있다.

1. 암호화 양식 header (알고리즘 양식 등이 담기는 자리)

2. 실제 내용 payload (영어로는 보낸 측의 Claim, 주장이라고 한다. 왜냐하면 진짜 맞는지는 대봐야 알기 때문이다.)

3. 암호 키 내용 signature (암호 키는 미니 프로젝트 당시에는 백엔드 코드에 하드 코딩으로 기재했지만 실제 더 큰 서비스를 구현 할 때는 별도의 파일로 빼는 식의 따로 보안처리가 또 필요하다.)

클라이언트는 서버에게 JWT 양식의 토큰을 발급 받고 난 뒤 서버는 JWT에 관한 어떠한 서비스를 제공했다는
기록조차 갖고 있을 필요가 없다. 단, signature 만큼은 들고 있어야 한다.

클라이언트는 서비스를 이용하는 동안 토큰을 어딘가에 저장하게 된다.
이건 저장하는 위치에 따라 장단점이 있다고 하는데 당장 유의미하진 않으니 넘어간다.

서비스를 이용 할 때 토큰이 필요한 상황이 발생하면 클라이언트는 자신이 저장해둔 토큰을 불러와서 서버에게 제시한다.
토큰은 header와 payload로 구성 되어있으니, 서버는 자신이 들고 있는 signature만 갖다 대서 나온 결과가 유의미한지만 확인하면 된다.

별개로, JWT은 토큰의 만료 시간 개념을 가지고 있고 이를 설정 할 수 있다.
만료 시간이 지나치게 멀리 지정된 토큰은 보안 위험이 생길 수 있다. JWT을 쓰겠다면 이 부분을 꼭 짚고 가야한다.

from flask import Flask, render_template, jsonify, request, session, redirect, url_for
from flask.json.provider import JSONProvider
from bson import ObjectId
import json
app = Flask(__name__)

from dotenv import load_dotenv
from pymongo import MongoClient
import os
import certifi
import jwt
import datetime
import hashlib

load_dotenv()
ca = certifi.where()
client = MongoClient('mongodb://localhost:27017/')
#client = MongoClient('mongodb://test:test@localhost', 27017)

# 실제 배포 때는 일부 수정이 필요
db = client['realjungle']
SECRET_KEY = os.getenv('SECRET_KEY')

@app.route('/')
def home():
    # 로그인 정보를 알아보기위해 토큰을 얻어오기를 시도합니다.
    receivedToken = request.cookies.get('mytoken')
    # 얘가 비어있으면 100% 로그아웃 상태입니다.
    # 비어있지 않으면 올바른 로그인 상태인지 확인해야합니다.

    if receivedToken is not None:
        try:
            payload = jwt.decode(receivedToken, SECRET_KEY, algorithms=['HS256'])
            user_info = db.user.find_one({"id": payload['id']})
            return render_template('index.html', idName=user_info["id"])
        except jwt.ExpiredSignatureError:
            return render_template('index.html', idName="%")
           # return redirect(url_for('login', msg="로그인 시간이 만료되었습니다. 다시 로그인해야합니다."))
        except jwt.exceptions.DecodeError:
            return render_template('index.html', idName="%")

          #  return redirect(url_for("login", msg="로그인 정보가 없습니다."))
    else:
        # 여긴 어쨌든 로그인이 안된 영역이니 %로 로그아웃 상태임을 보내기
        return render_template('index.html', idName="%")


@app.route('/login')
def login():
    receivedToken = request.cookies.get('mytoken')

    if receivedToken is None:
        return render_template('login.html')
    else:
        try:
            payload = jwt.decode(receivedToken, SECRET_KEY, algorithms=['HS256'])
            user_info = db.user.find_one({"id": payload['id']})
            return render_template('index.html', idName=user_info["id"])
        except jwt.ExpiredSignatureError:
            return render_template('login.html')
        except jwt.exceptions.DecodeError:
            return render_template('login.html')

@app.route('/register')
def register():
    receivedToken = request.cookies.get('mytoken')

    if receivedToken is not None:
        try:
            payload = jwt.decode(receivedToken, SECRET_KEY, algorithms=['HS256'])
            user_info = db.user.find_one({"id": payload['id']})
            return render_template('index.html', idName=user_info["id"])
        except jwt.ExpiredSignatureError:
            return render_template('register.html')
        except jwt.exceptions.DecodeError:
            return render_template('register.html')
    else:
        return render_template('register.html')


# [회원가입 API]
# 저장하기 전에, pw를 sha256 방법(=단방향 암호화. 풀어볼 수 없음)으로 암호화해서 저장합니다.
@app.route('/api/register', methods=['POST'])
def api_register():
    id_receive = request.form['id_give']
    pw_receive = request.form['pw_give']

    isExistUser = db.user.find_one({'id': id_receive})

    if isExistUser is not None:
        return jsonify({'result': 'fail', 'msg': '이미 가입 되어 있는 아이디입니다.'})
    else:
        pw_hash = hashlib.sha256(pw_receive.encode('utf-8')).hexdigest()
        db.user.insert_one({'id': id_receive, 'pw': pw_hash, "problemList": [False] * 20, "probSolvedCnt": 0})
        return jsonify({'result': 'success'})


# [로그인 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에는 만료시간을 넣어줍니다(5초). 만료시간이 지나면, 시크릿키로 토큰을 풀 때 만료되었다고 에러가 납니다.
        payload = {
            'id': id_receive,
            'exp': datetime.datetime.utcnow() + datetime.timedelta(seconds=1000000)
        }
        token = jwt.encode(payload, SECRET_KEY, algorithm='HS256')

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


if __name__ == '__main__':
    app.run('0.0.0.0', port=5001, debug=True)
@app.route('/')
def home():
    # 로그인 정보를 알아보기위해 토큰을 얻어오기를 시도합니다.
    receivedToken = request.cookies.get('mytoken')
    # 얘가 비어있으면 100% 로그아웃 상태입니다.
    # 비어있지 않으면 올바른 로그인 상태인지 확인해야합니다.

    if receivedToken is not None:
        try:
            payload = jwt.decode(receivedToken, SECRET_KEY, algorithms=['HS256'])
            user_info = db.user.find_one({"id": payload['id']})
            return render_template('index.html', idName=user_info["id"])
        except jwt.ExpiredSignatureError:
            return render_template('index.html', idName="%")
           # return redirect(url_for('login', msg="로그인 시간이 만료되었습니다. 다시 로그인해야합니다."))
        except jwt.exceptions.DecodeError:
            return render_template('index.html', idName="%")

          #  return redirect(url_for("login", msg="로그인 정보가 없습니다."))
    else:
        # 여긴 어쨌든 로그인이 안된 영역이니 %로 로그아웃 상태임을 보내기
        return render_template('index.html', idName="%")

recievedToken을 통해 이 클라이언트-브라우저에 저장된 토큰을 찾아온다. 여기서 선택지를 생각 할 수 있다:

  • 무언가 값이 있다면, 토큰을 받아오는데는 성공한 것이니, 이 값의 유효 유무를 생각해봐야 한다.
    • 이게 payload 에 값을 배정하는 과정에서 이뤄진다. 값을 찾지 못했거나 JWT계열 함수에서 토큰에 관한 이상을 감지하면 모두 로그인을 다시 유도시키는 방향으로 작성하였다.
  • 무언가 값이 없다.
    • 그렇다면, 무조건 로그인 한 적이 없다는게 되니 로그인 페이지로 보내주자.
@app.route('/login')
def login():
    receivedToken = request.cookies.get('mytoken')

    if receivedToken is None:
        return render_template('login.html')
    else:
        try:
            payload = jwt.decode(receivedToken, SECRET_KEY, algorithms=['HS256'])
            user_info = db.user.find_one({"id": payload['id']})
            return render_template('index.html', idName=user_info["id"])
        except jwt.ExpiredSignatureError:
            return render_template('login.html')
        except jwt.exceptions.DecodeError:
            return render_template('login.html')

로그인에도 토큰 존재 여부와 유효 여부를 같이 확인해서, 이에 따라 작동 내용이 달라지게끔 구성하였다.

회원가입에 관한 내용은 JWT을 다루는 코드가 없으니 넘어가겠다..

# [로그인 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에는 만료시간을 넣어줍니다(5초). 만료시간이 지나면, 시크릿키로 토큰을 풀 때 만료되었다고 에러가 납니다.
        payload = {
            'id': id_receive,
            'exp': datetime.datetime.utcnow() + datetime.timedelta(seconds=1000000)
        }
        token = jwt.encode(payload, SECRET_KEY, algorithm='HS256')

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

제일 중요한 로그인에 관한 내용이다. 여긴 jwt.encode를 통해 만들어진 token을 반환하게 끔 구조가 만들어져있다.
이 token은 POST-login을 요청한 코드로 넘어간 뒤
클라이언트 어딘가에 저장되어 payload에 담겨 있는 만료 시간때까지 유의미한 토큰으로 활동이 가능하다.

작동이 주 목적이었던 코드인 만큼 상용화 된 사이트나 상식적인 작동 영역에선 다소 벗어났을지도 모르겠지만, 눈이 높아진 뒤의 개선점을 생각 할 수 있도록 해야겠다고 다짐했다.