Flask에 인증(Auth) 붙이는 방법


  • Flask에 인증을 붙이는 방법(Flask with auth)를 작성한 글입니다
  • 특히 사용자의 비밀번호 암호화에 대해 자세히 알아볼 예정

인증

  • 많은 API에서 인증(Authentication)은 공통적으로 구현되는 엔드포인트들 중 하나
    • Private한 API나 Public한 API도 기본적 인증을 요구함
    • Private API
      • 사용할 수 있는 사용자나 클라이언트를 제한
    • Public API
      • 사용자나 클라이언트를 제한하진 않지만 사용 횟수 제한, 남용 방지, 사용자 통계 등의 이유로 인증을 대부분 필요로 함
  • 인증은 사용자의 신원을 확인하는 절차로 일반적으로 웹사이트에서 사용자가 로그인해 아이디와 비번을 확인하는 절차를 이야기함
    • 즉, 로그인 기능을 구현하는 것이 인증 엔드포인트임

로그인 기능 구현 절차

  • 사용자 가입 절차를 진행해 사용자의 ID, 비밀번호를 생성해야 함
  • 가입한 사용자의 ID, 비밀번호를 데이터베이스에 저장하고 사용자의 비밀번호는 암호화해서 저장
  • 사용자가 로그인할 때 본인의 ID, 비밀번호를 입력
  • 사용자가 입력한 비밀번호를 암화한 후, 암호화되어서 DB에 저장된 비밀번호와 비교
  • 비밀번호가 일치하면 로그인 성공
  • 로그인에 성공하면 API 서버는 access token을 프론트엔드 혹은 클라이언트에 전송
  • 프론트엔드 서버는 로그인 성공 후 해당 사용자의 access token을 첨부해 request를 서버에 전송해 매번 로그인하지 않아도 되도록 함

사용자 비밀번호 암호화

  • 사용자의 비밀번호를 암호화해서 저장해야 하는 이유
    • 1) 외부 해킹 공격에 의해 데이터베이스가 노출되었을 경우를 대비
    • 2) 내부 인력에 의해 데이터베이스가 노출되었을 경우에 대비
  • 사용자의 비밀번호를 암화할 때는 단방향 해시 함수(one way hash function)가 일반적으로 쓰임
    • 단방향 해시 함수는 복호화를 할 수 없는 알고리즘
  • 단방향 해시 함수 구현

      import hashlib
      m = hashlib.sha256()
      m.update(b"test password")
      m.hexdigest()
    

bcrypt 암호 알고리즘

  • 단방향 해시 암호 알고리즘도 충분히 해킹할 수 있음
    • 대표적으로 rainbow attack
    • 미리 해시 값들을 계산해놓은 테이블을 생성하고 해시 함수값을 역추적해서 본래 값을 찾음
  • 이런 해시 함수의 취약점을 보완하기 위해 2가지 방법을 사용
    • 1) salting
      • 음식에 간을 맞추기 위해 소금을 더하듯 비밀번호에 추가적으로 랜덤 데이터를 더해 해시 값을 계산
      • 기존 비밀번호 + 특정 스트링을 붙이고 해싱 진행
    • 2) key stretching
      • 단방향 해시 값을 계산하고 또 해시하고, 여러번 반복하는 방법
      • 해시 알고리즘의 실행 속도가 너무 빠르기 때문에 이런 방식을 사용
  • 2가지 방법을 구현한 해시 함수 중 널리 사용되는 것이 bcrypt
  • 설치

      pip3 install bcrypt
    
  • 암호화

      import bcrypt
      bcrypt.hashpw(b"password", bcrypt.gensalt())
      bcrypt.hashpw(b"password", bcrypt.gensalt()).hex()
    

Access Token

  • HTTP는 stateless라 이전에 어떤 HTTP 통신들이 실행됬는지 알지 못함
    • 이전에 인증되었는지 알지 못하게 됨
  • 로그인 정보를 HTTP 요청에 첨부해서 보내야 API 서버에서 로그인된 상태를 처리
    • 이런 로그인 정보를 담고있는 것이 access token
  • access token을 생성하는 방법
    • 대표적으로 쓰이는 기술이 JWT(JSON Web Token)
    • JSON 데이터를 token으로 변환하는 방식
    • 유저가 로그인 요청하면 API 서버가 인증 확인 후, access token을 떨굼
    • 그 토큰을 쿠키 등에 저장했다가 요청할 때 사용
  • 가짜 JWT를 전송할 경우 백엔드 API에서 자신이 생성한 JWT인지 아닌지 확인함

JWT 구조

xxxxx.yyyyy.zzzzz
  • header : x
    • 토큰 타입과 사용되는 해시 알고리즘을 지정
      {
       "alg": "HS256",
       "typ": "JWT"
      }
    
  • payload : y
    • JWT를 통해 실제로 서버 간에 전송하고자 하는 데이터
    • HTTP 메세제의 body와 비슷
  • signature : z
    • JWT가 원본 그대로라는 것을 확인할 떄 사용되는 부분
    • Base64URL 코드화된 header, payload, JWT Secret을 헤더에 지정된 암호 알고리즘으로 암호화해 전송
    • 프론트엔드가 JWT를 백엔드 API로 전송하면 서버에서 JWT signature 부분을 복호화해 서버에서 생성했는지 확인
    • 누구나 원본 데이터를 볼 수 있는 부분(Base64URL 코드화)이 라 민감한 데이터는 저장하지 않도록 해야 함

PyJWT

  • 파이썬에서 JWT를 구현할 떄 사용할 수 있는 라이브러리
  • 설치

      pip3 install PyJWT
    
  • 사용법

      import jwt
      data_to_encode = {"some": "payload"}
      encryption_secret = "secrete"
      algorithm = "HS256"
      encoded = jwt.encode(data_to_encode, encryption_secre, algorithm=algorithm)
      jwt.decode(encoded, encryption_secret, algorithms=[algorithm])
    

엔드포인트 구현

  • /sign-up 수정 : 암호화해서 비밀번호 저장

      @app.route("/sign-up", methods=["POST"])
      def sign_up():
          new_user = request.json
          new_user['password'] = bcrypt.hashpw(new_user['password'].encode('UTF-8'), bcrypt.gensalt())
            
          new_user_id = app.database.execute(text("""
              INSERT INTO users (
                name,
                email,
                profile,
                hashed_password
              ) VALUES (
                :name,
                :email,
                :profile,
                :password
              )
          """), new_user).lastrowid
    
          row = app.database.execute(text("""
              SELECT
                  id,
                  name,
                  email,
                  profile
              FROM users
              WHERE id = :user_id
          """), {
              "user_id": new_user_id
          }).fetchone()
    
          create_user = {
              "id": row["id"],
              "name": row["name"],
              "email": row["email"],
              "profile": row["profile"]
          } if row else None
    
          return jsonify(create_user)
    
  • 인증 엔드포인트 수정
  • HTTP Post request에 JSON 데이터로 사용자의 아이디와 비밀번호를 전송받아 데이터베이스에 저장된 사용자의 비밀번호와 동일한지 확인

      @app.route("/login", methods=["POST"])
      def login():
          credential = request.json
          email = credential["email"]
          password = credential["password"]
    
          row = app.database.execute(text("""
              SELECT
                  id,
                  hashed_password
              FROM users
              WHERE email = :email
          """), {'email': email}).fetchone()
    
          if row and bcrypt.checkpw(password.encode("UTF-8"), row["hashed_password"].encode("UTF-8")):
              user_id = row["id"]
              payload = {
                  "user_id": user_id,
                  "exp": datetime.utcnow() + timedelta(seconds=60 * 60 * 24)
              }
    
              token = jwt.encode(payload, app.config["JWT_SECRET_KEY"], "HS256")
    
              return jsonify({
                  "access_token" : token.decode("UTF-8")
              })
          else:
              return '', 401
    

인증 절차를 다른 엔드포인트에 적용하기

  • tweet, follow, unfollow 엔드포인트에 인증을 적용
  • 공통적인 기능을 필요로 하는 경우 파이썬의 데코레이터를 사용
  • functools의 wraps를 사용해 데코레이터 생성
  • 인증 decorator 함수는 전송된 HTTP 요청에서 Authorization 헤더 값을 읽어 JWT access token을 읽고 복호화해서 사용자 아이디를 읽음 => 로그인 여부 결정

  • 인증 Decorator 함수

      def login_required(f):
          @wraps(f)
          def decorated_function(*args, **kwargs):
              access_token = request.headers.get("Authorization")
              if access_token is not None:
                  try:
                      payload = jwt.decode(access_token, current_app.config["JWT_SECRET_KEY"], "HS256")
                  except jwt.InvalidTokenError:
                      payload = None
    	
                  if payload is None:
                      return Response(status=401)
    	
                  user_id = payload["user_id"]
                  g.user_id = user_id
                  g.user = get_user_info(user_id) if user_id else None
              else:
                  return Response(status=401)
    	
              return f(*args, **kwargs)
          return decorated_function
    
  • 인증 Decorator 적용하기
    • def tweet, def follow, def unfollow 위에 아래처럼 명시하면 됨
      @app.route("/tweet", methods=["POST"])
      @login_required
      def tweet():
          ~~~~
    
  • 확인
    • 이제 먼저 로그인을 해야 함, 로그인하지 않고 아래 request를 날리면 오류 발생
      http -v POST localhost:5000/tweet tweet="Hi!"
    
    • 액세스 토큰 생성
      http -v POST localhost:5000/login email=zzsza@naver.com password=1234
    
    • access_token을 복사한 후 아래와 같이 요청
      http -v POST localhost:5000/tweet tweet="Hi!" "Authorization:eyJ0e~~~~~~~~~"
    
  • 기존엔 tweet할 때 id도 붙여야 했는데, 이젠 인증으로 처리해서 id도 필요 없음. 코드 살짝 수정

      @app.route("/tweet", methods=["POST"])
      @login_required
      def tweet():
          user_tweet = request.json
          user_tweet["id"] = g.user_id # 이 부분
          tweet = user_tweet["tweet"]
    
    
          if len(tweet) > 300:
              return "300자를 초과했습니다", 400
    
          app.database.execute(text("""
              INSERT INTO tweets (
                user_id,
                tweet
              ) VALUES (
                :id,
                :tweet
              )
          """), user_tweet)
    
          return "", 200
    

Reference


카일스쿨 유튜브 채널을 만들었습니다. 데이터 사이언스, 성장, 리더십, BigQuery 등을 이야기할 예정이니, 관심 있으시면 구독 부탁드립니다 :)

PM을 위한 데이터 리터러시 강의를 만들었습니다. 문제 정의, 지표, 실험 설계, 문화 만들기, 로그 설계, 회고 등을 담은 강의입니다

이 글이 도움이 되셨거나 다양한 의견이 있다면 댓글 부탁드립니다 :)

Buy me a coffeeBuy me a coffee





© 2017. by Seongyun Byeon

Powered by zzsza