Python/ETC / / 2023. 10. 30. 14:47

[번역] 파이썬을 통해 보는 웹 인증 및 인가 방법 1편 - HTTP, JWT, 세션(Session), 쿠키(Cookie) 등

 

Web Authentication Methods Compared

This article looks at the most commonly used web authentication methods.

testdriven.io

이 글에서는 웹 인증을 위해 가장 보편적으로 사용되는 방법들을 파이썬 웹 개발자 관점에서 볼 것이다. 

Authentication(인증) vs Authorization(인가)

Authentication(인증)은 유저나 디바이스가 제한된 시스템에 접근하려 할 때 신원을 증명하는 프로세스면, 인가는 주어진 시스템 내에서 특정한 태스크를 허락받았는지에 대한 증명 프로세스라고 보면 될 것이다.

 

간단히 말하자면,

  1. 인증 : 넌 누구인가?
  2. 인가 : 넌 뭘 할 수 있는가?

인증이 먼저이며 그 후에 인가가 온다. 그 의미인 즉슨,  유저는 무조건 그들의 인증 레벨에 따라 부여된 리소스 접근 권한을 부여받기 전에 유효해야 한다는 것이다. 가장 일반적인 인증 방식은 username(아이디)와 password(비밀번호)다. 한번 인증되면 관리자나, 모더레이터 등과 같은 다른 역할이 부여되며 이는 곧 시스템에 대한 특별 권한을 준다.

 

이렇게 알아봤으니, 유저를 인증하기 위한 각기 다른 방법들을 보자.

 

HTTP Basic Authentication

HTTP프로토콜에 내장되어 있는 Basic Authentication(기본 인증 방식)은 가장 기본적인 인증 형태이며, 이를 통해, 로그인시의 중요 정보를 각각의 리퀘스트를 보낼 때 마다 리퀘스트 헤더에 담는다. 

"Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=" your-website.com

아이디와 패스워드는 암호화되지 않는다. 대신, 아이디와 패스워드는 ":" 사이에 연결되어서 "username:password"형태를 띈 하나의 문자열로 보내진다. 이 문자열이 base64를 이용해서 암호화 된다.

>>> import base64
>>>
>>> auth = "username:password"
>>> auth_bytes = auth.encode('ascii') # convert to bytes
>>> auth_bytes
b'username:password'
>>>
>>> encoded = base64.b64encode(auth_bytes) # base64 encode
>>> encoded
b'dXNlcm5hbWU6cGFzc3dvcmQ='
>>> base64.b64decode(encoded) # base64 decode
b'username:password'

이 방식은 상태지속성을 띄지 않기에 Client(클라이언트)는 중요 정보를 각각의 모든 리퀘스트마다 공급해줘야 한다. 이는 영구적인 세션을 요구하지 않는 간단한 인증 흐름을 위한 API 호출에 적합하다.

 

Flow(흐름)

  1. 비인증 유저가 제한된 리소스를 요청한다.
  2. HTTP_401_Unauthorized 코드가 WWW-Authenticate: Basic이라는 키 밸류를 가진 헤더와 함께 돌아온다.
  3. WWW-Authenticate: Basic이 브라우저로 하여금 아이디와 패스워드 입력칸을 즉각적으로 보여주게 만든다.
  4. 정보를 입력하고 나면, 이 정보들이 각각의 리퀘스트마다 Authorization: Basic dcdvcmQ=와 같은 형식으로 전송된다.

장점

  • 많은 연산이 이루어지지 않기 때문에, 인증 자체는 더 빠르게 할 수 있다.
  • 구현 및 실행 자체가 쉽다.
  • 모든 주된 브라우저에서 지원된다

단점

  • Base64는 암호화와 다르다. 데이터의 표현 방식 중 하나일 뿐이다. base64로 암호화된 문자열은 일반적인 텍스트로 보내지기 때문에 쉽게 복호화가 가능하다. 이런 빈약한 보안 특징은 다양한 방식의 공격을 불러일으킨다. 이러한 이유로 HTTPS/SSL이 거의 필수적이다.
  • 중요 정보를 매 리퀘스트마다 보내야 한다.
  • 유저는 오직 잘못된 로그인 정보를 입력해야만 로그아웃이 가능하다. 

대표적인 파이썬 프레임워크 패키지

코드 예시

플라스크를 사용한다면 Flask-HTTP 패키지를 통해 Basic HTTP Authentication은 쉽게 구현가능하다.

from flask import Flask
from flask_httpauth import HTTPBasicAuth
from werkzeug.security import generate_password_hash, check_password_hash

app = Flask(__name__)
auth = HTTPBasicAuth()

users = {
    "username": generate_password_hash("password"),
}


@auth.verify_password
def verify_password(username, password):
    if username in users and check_password_hash(users.get("username"), password):
        return username


@app.route("/")
@auth.login_required
def index():
    return f"You have successfully logged in, {auth.current_user()}"


if __name__ == "__main__":
    app.run()

그 외 자료

 

HTTP Digest Authentication

HTTP Digest Authentication(또는 Digest Access Authentication)은 HTTP Basic Auth보다 더 안전한 형태이다. 가장 주된 차이점은 비밀번호가 평문이 아닌 MD5방식으로 해시(hash)되기에 Basic Auth보다 더 안전하다.

Flow(흐름)

  1. 비인증 유저가 제한된 리소스를 요청한다.
  2. Nonce(논스)라는 무작위 값을 서버가 생성하고 HTTP_401_Unauthorized 상태 코드와 함께 이 논스를 WWW-Authenticate헤더 키값과 함께 값으로서 Digest를 포함해 WWW-Authenticate: Digest nonce="44f0437004157342f50f9d39s01dfjgc"처럼 논스를  같이 보낸다.
  3. WWW-Authenticate: Digest가 브라우저로 하여금 아이디와 패스워드 입력칸을 즉각적으로 보여주게 만든다.
  4. 유저가 로그인 정보를 입력하면, 패스워드는 해시 처리되며 논스와 함께 매 리퀘스트마다 헤더에 담겨져서 다음과 같이 보내진다. Authorization: Digest username="username", nonce="16e30069e45a7f47b4e2606aeeb7ab62", response="89549b93e13d438cd0946c6d93321c52"
  5. 아이디와 함께 비밀번호를 얻은 서버는 논스와 함께 비밀번호를 해시 처리하며, 그 값이 같은지 검증한다.

장점

  • 비밀번호를 평문으로 보내지 않기 때문에 Basic Auth보다는 안전하다.
  • 구현 및 실행 자체가 쉽다.
  • 모든 주된 브라우저에서 지원된다

단점

  • Basic Auth와 비교해서 bcrypt - 해시 방법 중 하나 - 를 사용할 수 없기에 덜 안전할 수도 있다.
  • 중요 정보를 매 리퀘스트마다 보내야 한다.
  • 유저는 오직 잘못된 로그인 정보를 입력해야만 로그아웃이 가능하다. 
  • man-in-the-middle(중간자 공격)에 취약하다.

대표적인 파이썬 프레임워크 패키지

코드 예시

플라스크를 사용한다면 Flask-HTTP 패키지를 통해 Digest HTTP Authentication또 쉽게 구현가능하다.

from flask import Flask
from flask_httpauth import HTTPDigestAuth

app = Flask(__name__)
app.config["SECRET_KEY"] = "change me"
auth = HTTPDigestAuth()

users = {
    "username": "password"
}


@auth.get_password
def get_user(username):
    if username in users:
        return users.get(username)


@app.route("/")
@auth.login_required
def index():
    return f"You have successfully logged in, {auth.current_user()}"


if __name__ == "__main__":
    app.run()

그 외 자료

 

Session-based Auth

Session-based auth(세션 기반 인증) 혹은 세션 쿠키 인증, 쿠키 기반 인증은 유저의 상태가 서버에 저장된다. 매 요청마다 유저가 아이디와 비밀번호를 제공할 필요가 없다. 대신, 로그인 후, 서버가 로그인 정보를 검증하고 브라우저에게 세션 아이디(session ID)를 보내준다. 만약 (정보가) 유효하다면 서버는 세션을 생성하며 세션 창고에 저장하며 브라우저에게 세션 아이디를 리턴한다. 브라우저는 쿠키처럼 세션 아이디를 저장하고 이는 서버에서 매번 요청할 때 마다 얻을 수 있다.

 

세션 기반 인증은 상태가 지속적이다. 서버에 클라이언트가 매번 요청하기에, 서버는 이와 관련된 유저의 세션 아이디를 보내주기 때문, 무조건 메모리에 세션을 위치시켜야 한다.

 

Flow(흐름)

장점

  • 로그인 정보가 매번 요구되지 않기에, 로그인 유지를 위한 처리가 빠르다.
  • 향상된 유저 경험.
  • 상대적으로 쉬운 구현과 실행. 장고같은 많은 파이썬 프레임워크에 이러한 특징들이 내장되어 있다.

단점

  • 지속성을 가지기에 서버는 각각의 세션을 서버 쪽에다가 유지해야한다. 유저의 세션 정보를 저장하는 창고는 다양한 서비스의 인증을 위해 공유되어야 한다. 이러한 이유로 REST한 서비스와는 잘 안맞는데, 이는 REST자체가 비 지속적인 프로토콜이기 때문이다.
  • 인증을 필요로 하지 않아도, 매 요청마다 쿠키가 담겨서 보내진다.
  • CSRF공격에 약하다.

대표적인 파이썬 프레임워크 패키지

코드 예시

플라스크를 사용한다면 Flask-Login은 세션 기반 인증을 위한 최고의 패키지다. 패키지를 통해 로그인, 로그아웃 그리고 일전 기간동안 유저 정보 기억등이 가능하다.

 

from flask import Flask, request
from flask_login import (
    LoginManager,
    UserMixin,
    current_user,
    login_required,
    login_user,
)
from werkzeug.security import generate_password_hash, check_password_hash


app = Flask(__name__)
app.config.update(
    SECRET_KEY="change_this_key",
)

login_manager = LoginManager()
login_manager.init_app(app)


users = {
    "username": generate_password_hash("password"),
}


class User(UserMixin):
    ...


@login_manager.user_loader
def user_loader(username: str):
    if username in users:
        user_model = User()
        user_model.id = username
        return user_model
    return None


@app.route("/login", methods=["POST"])
def login_page():
    data = request.get_json()
    username = data.get("username")
    password = data.get("password")

    if username in users:
        if check_password_hash(users.get(username), password):
            user_model = User()
            user_model.id = username
            login_user(user_model)
        else:
            return "Wrong credentials"
    return "logged in"


@app.route("/")
@login_required
def protected():
    return f"Current user: {current_user.id}"


if __name__ == "__main__":
    app.run()

그 외 자료

 

Token-Based Authentication

이 방식은 쿠키 대신 토큰을 사용하여 유저를 인증한다. 유요한 로그인 정보로 유저는 인증을 하고 서버는 서명된 토큰을 전달한다. 이 토큰은 계속되는 요청에 사용될 수 있다. 가장 널리 사용되는 토큰은 JSON Web Token(JWT)이며, 이는 세 개의 파트로 나뉜다.

  • 헤더(Header): 토큰 타입과 사용된 해시 알고리즘을 담고 있다.
  • 페이로드(Payload): 토큰에서 사용할 상태의 주제에 대한 클레임을 담고 있다.
  • 서명(Signature): 토큰이 보내지는 동안 메시지가 바뀌지 않았다는 것을 증명할 때 사용된다.

이 세가지는 base64로 암호화되어 "."사이사이 연결되고 해시된다. (base64로)암호화된 것이기에 누구나 복호화해서 메시지를 읽을 수 있지만, 인증된 유저만 유효하게 서명된 토큰을 제공할 수 있다. 개인키를 통해 서명된 토큰은 서명(Signature)을 통해 인증된다.

JSON Web Token(JWT)이 작고, URL에 대해 안전하다는 의미는 (페이로드의) 클레임들이 두개의 축에서 전달될 수 있다는 것을 뜻한다. JWT의 클레임은 JSON Web Signature(JWS) 구조의 페이로드(내용)나 JSON Web Encryption(JWE) 구조의 평문으로 사용되는 JSON 객체로 인코딩 되며, 이는 클레임이 전자 서명이 되거나 Message Authentication Code(MAC)을 통해 통합적으로 보호되고 혹은 암호화되는 것을 뜻한다.
 
IETF 출처

토큰은 서버 쪽에 저장될 필요성이 없다. 서명을 이용해 증명되면 된다. 최근에는 RESTful API와 Single Page Applications(SPAs)의 도입 성장세로 토큰 방식의 채택이 증가했다.

 

흐름

Pros

  • 비지속성을 띈다. 토큰은 사인을 통해 증명되기 때문에 서버는 토큰을 저장할 필요가 없다. 이는 데이터베이스 참조를 필요로 하지 않기 때문에 요청을 더 빨리 처리할 수 있게 된다.
  • 다양한 서비스들이 인증을 요구하기에, MSA(Micro Service Architecture)에 적합하다. 그저, 각각의 엔드포인트들이 어떻게 토큰과 토큰 비밀 정보를 다룰지를 설정해주면 끝이다.

Cons

  • 토큰이 클라이언트 측에 어떻게 저장되는지에 따라, XSS(local storage에 저장하는 경우)공격이나 CSRF(쿠키로 저장하는 경우)공격으로 이어질 수 있따.
  • 토큰들은 삭제될 수 없다. 오직 만료될 뿐이다. 이는 즉, 토큰이 유출되면 만료될 때 까지 공격자가 오남용할 수 있다는 것이다. 그렇기에 15분 정도로 아주 짧게 만료기간을 설정하는 것이 좋다.
  • 토큰이 만료될 때마다 자동으로 발급해주는 Refresh token(리프레시 토큰)을 준비할 필요가 있다.
  • 토큰을 삭제하는 방법은 토큰에 대한 블랙리스트를 데이터베이스에 저장하는 것이다. 하지만 이는 곧 MSA같은 구조에 추가적인 부하를 줄 수 있다.

대표적인 파이썬 프레임워크 패키지

코드 예시

from flask import Flask, request, jsonify
from flask_jwt_extended import (
    JWTManager,
    jwt_required,
    create_access_token,
    get_jwt_identity,
)
from werkzeug.security import check_password_hash, generate_password_hash

app = Flask(__name__)
app.config.update(
    JWT_SECRET_KEY="please_change_this",
)

jwt = JWTManager(app)

users = {
    "username": generate_password_hash("password"),
}


@app.route("/login", methods=["POST"])
def login_page():
    username = request.json.get("username")
    password = request.json.get("password")

    if username in users:
        if check_password_hash(users.get(username), password):
            access_token = create_access_token(identity=username)
            return jsonify(access_token=access_token), 200

    return "Wrong credentials", 400


@app.route("/")
@jwt_required
def protected():
    return jsonify(logged_in_as=get_jwt_identity()), 200


if __name__ == "__main__":
    app.run()

그 외 자료

728x90
반응형
  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유