플라스크를 이용한 인증

JOOYEUN SEO·2024년 12월 2일

100 Days of Python

목록 보기
68/76
post-thumbnail

❖ 인증

🔐 인증(authentication)이 필요한 이유

  1. 사용자가 생성한 데이터를 개인 계정에 로그인해야 접근할 수 있도록 보호
  2. 사용자의 상태에 따라 사이트의 특정 영역에 접근하는 것을 제한하기 위함
    예) 구독료를 낸 사용자만 컨텐츠를 이용할 수 있는 경우

🔐 사이트 보안 수준을 높이는 것이 중요

🗂️ Day68 프로젝트: 사용자 인증

신규 사용자 등록 또는 계정에 로그인할 때만 기밀 자료를 다운로드할 수 있는 사용자 인증 만들기

1. 신규 사용자 등록

🔍 유의 사항

  • 신규 User 객체 생성
    • register.html에 입력한 정보를 가져와 users.db에 email, name, password로 저장
    • 사용자 등록이 완료되면 바로 secrets.html 페이지로 이동시키기
  • secrets.html 페이지에 'Hello 양식에 입력한 이름!' 이 표시되어야 함

2. 파일 다운로드

🔍 유의 사항

  • secrets.html 페이지에서 파일 다운로드 버튼을 클릭하면 기밀 파일을 다운로드 하도록 변경
    • /download 경로에서 서버에 GET 요청
    • 플라스크 메소드 send_from_directory() 사용 (문서)

❖ 암호화와 해싱

  • 1단계 인증
    • 단순히 비밀번호를 받아 평문 그대로 데이터베이스에 저장(위에서 했던 방식)
    • 페이지에서 마우스를 우클릭하는 것만으로는 개인정보를 확인할 수 없음
  • 2단계 인증
    • 암호화(encryption)를 사용
    • 특정 암호 기법으로 원문을 꼬아 알아볼 수 없는 암호문(ciphertext)을 만드는 것
    • 암호키(key)를 알아야 복호화할 수 있음
    • 예) 카이사르 암호(caesar cipher)
      • 현재 알려진 가장 단순한 암호화 기법으로, 문자 치환 기법만 사용
      • 알파벳을 이동한 간격이 암호키가 되므로 이동 패턴만 알면 쉽게 해독 가능
    • https://cryptii.com (여러 암호 기법으로 암호문을 생성해보는 사이트)
  • 3단계 인증
    • 해싱(hashing)으로 암호키나 복호화를 할 필요없게 만들어 보안성을 높임
      • 해시 함수(hash function)로 원문을 해시(hash)로 변환 후 데이터베이스에 저장
      • 해시 함수는 역처리에 많은 시간이 소요되도록 고안됐기 때문에, 다시 비밀번호로 복원하기 거의 불가능함
        (13×29=37713\times29=377은 쉬운 계산이나, 377377의 '1과 377이 아닌 인수'를 찾으려면 오래 걸림)
    • 예) 사이트에 로그인
      1. 사용자가 사이트에 가입 시 입력한 비밀번호를 해시로 변환해 데이터베이스에 저장
      2. 나중에 로그인 시 비밀번호를 입력하면 역시 해시로 변환
      3. 데이터베이스에 저장된 해시와 비교하여 일치하면 로그인 성공
  • 4단계 인증
    아래의 '비밀번호 솔팅'에서 다룸

❖ 비밀번호 해킹

💡 https://plaintextoffenders.com

  • 사용자가 웹사이트에서 계정을 생성하거나 비밀번호 초기화 요청을 했을 때 받은 메일을 모은 사이트
  • 평문 비밀번호가 메일에 그대로 노출되어 있음
  • 보안 수준이 낮은 기업들을 볼 수 있다

[해킹 예시 1]

  1. 같은 비밀번호를 입력한 사용자들의 해시는 모두 같다는 점을 이용
  2. 가장 흔한 비밀번호들을 찾아서 해시 함수로 해시값을 생성한 후, 이를 모은 해시 테이블을 제작
  3. 사용자의 해시를 해시 테이블의 해시값과 비교

[해킹 예시 2]

  1. 사용자가 생일이나 반려동물 이름 등으로 비밀번호를 설정한 경우를 이용
  2. 모든 가능성을 담은 대형 해시 테이블 제작 (MD5 함수가 가장 흔함)
    a. "사전에 등재된 모든 어휘 + 모든 전화번호 + 최대 6자리까지의 모든 알파벳 조합 = 약 198억개
    b. 병렬 처리가 가능한 최신 GPU와 그래픽 카드로 약 0.9초만에 모든 조합을 해싱 가능
  3. 해킹한 데이터베이스에서 찾은 해시값을 구글에 검색하면 원래 문자열을 알아낼 수 있다

[해킹을 방지하는 방법]

  • 해시 테이블에 없는 강력한 비밀번호를 설정하기
    • 대소문자, 숫자, 특수문자를 골고루 잘 섞을수록 좋음
    • 자릿수를 늘리는 것이 중요
  • 사이트마다 다른 비밀번호 사용하기

❖ 비밀번호 솔팅

  • 1-3단계 인증은 위에서 다룸
  • 4단계 인증
    • 솔팅(salting)으로 '사전 대입 공격'이나 '해시 테이블 공격' 방지
      • 임의의 문자열 솔트(salt)를 생성하여 평문과 결합 후, 해시 함수에 통과시키는 방법
      • 사용자마다 솔트값이 다르기 때문에 같은 비밀번호를 입력해도 다른 해시값을 갖게 됨
      • 솔트는 해시와 함께 데이터베이스에 저장됨
    • 보안성을 높이기 위해 사용하는 방법
      • MD5 대신 bcrypt 해시 함수를 사용
        • 사용자 비밀번호 보호에 사용되는 업계 표준 해시 알고리즘 중 하나
        • MD5에 비해 매우 느린 속도 때문에 솔트를 결합한 해시 테이블을 만드는 데 오래 걸림
      • 비밀번호에 솔트를 결합하는 횟수, 즉 솔트 라운드(salt rounds)를 높이기
        • 평문과 임의의 솔트를 결합 후 해시 함수를 거쳐 해시를 얻으면 1회차,
          1회차로 생성한 해시에 같은 솔트를 다시 결합하여 해시 함수에 통과시키면 2회차
        • 컴퓨터가 빨라질수록 유용한 방법
          (컴퓨터가 업그레이드되면 솔트 라운드 횟수만 늘리면 되기 때문)

3. Werkzeug를 사용하여 비밀번호 해싱하기

🔍 유의 사항

  • 사용자의 비밀번호가 평문 그대로 저장되는 대신 해싱 및 솔트를 한 후 저장하기
  • 벡자이크(Werkzeug)의 헬퍼 함수 generate_password_hash() 사용 (문서)
  • pbkdf2:sha256를 사용하여 비밀번호를 해시하고 솔트 길이는 8로 설정

4. Flask_Login 패키지로 사용자 인증하기

🔍 유의 사항

  • 등록하거나 로그인한 사용자만 비밀 페이지에 접속할 수 있도록 /secrets 경로 보호하기
  • Flask_Login 패키지 (문서)
    • 패키지의 LoginManager 클래스 임포트하고 객체 생성
    • user_loader 함수 생성
    • 사용자 클래스에서 UserMixin을 구현
      • Mixin : 파이썬에 다중 상속을 제공하는 방법
      • UserMixin : 패키지에서 제공하는 클래스 객체로, 이를 상속받으면 간단하게 구현 가능
    • check_password_hash() 함수를 사용하여 사용자의 비밀번호 확인
    • 로그인 양식에 입력한 이메일로 사용자 찾기
    • 사용자가 성공적으로 로그인 또는 등록한 경우, login_user() 함수를 사용하여 인증
    • 인증된 사용자만 액세스할 수 있도록 /secrets과 /download 경로를 모두 보호
    • /logout 경로에는 logout_user() 함수를 사용하고 메인 페이지로 리다이렉트하기

5. 플라스크 플래시 메시지

🔍 유의 사항

  • Flask Flash 메시지로 사용자가 수행한 작업에 대한 피드백 주기 (문서)
    • 템플릿으로 전송되어 표시되는 일회성 메시지
    • 페이지를 새로고침하면 사라짐
  • 데이터베이스에 없는 이메일로 로그인을 시도할 경우 메시지 + 로그인 페이지로 돌아가기
  • check_password_hash 함수가 False를 반환할 경우 메시지 + 로그인 페이지로 돌아가기
  • 이미 데이터베이스에 있는 이메일로 가입할 경우 메시지 + 로그인 페이지로 리디렉션

6. 인증상태를 템플릿에 전달하기

🔍 유의 사항

  • 로그인 전에는 네비게이션 바에 로그아웃 버튼을 숨기기
  • 로그인했을 때는 홈페이지와 네비게이션 바에 로그인/등록 버튼을 숨기고 로그아웃 버튼 보이기
  • flask_login 패키지의 current_user로 현재 로그인 상태를 알 수 있음

⌨️ main.py

from flask import Flask, render_template, request, url_for, redirect, flash, send_from_directory
from werkzeug.security import generate_password_hash, check_password_hash
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import Integer, String
from flask_login import UserMixin, login_user, LoginManager, login_required, current_user, logout_user

app = Flask(__name__)
app.config['SECRET_KEY'] = 'secret-key-goes-here'

# CREATE DATABASE
class Base(DeclarativeBase):
    pass
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///users.db'
db = SQLAlchemy(model_class=Base)
db.init_app(app)

# 사용자 인증
login_manager = LoginManager()
login_manager.init_app(app)

@login_manager.user_loader
def load_user(user_id):
    return User.query.get(int(user_id))

# CREATE TABLE IN DB
class User(UserMixin, db.Model):
    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    email: Mapped[str] = mapped_column(String(100), unique=True)
    password: Mapped[str] = mapped_column(String(100))
    name: Mapped[str] = mapped_column(String(1000))
 
with app.app_context():
    db.create_all()


@app.route('/')
def home():
    return render_template("index.html")


@app.route('/register', methods=["GET", "POST"])
def register():
    if request.method == "POST":

        # 입력한 이메일이 이미 데이터베이스에 있으면 로그인 페이지로 이동
        if User.query.filter_by(email=request.form.get('email')).first():
            flash("You've already signed up with that email, log in instead!")
            return redirect(url_for('login'))

        # 비밀번호 해싱과 솔팅
        hash_and_salted_password = generate_password_hash(
            request.form.get('password'),
            method='pbkdf2:sha256',
            salt_length=8
        )

        # 데이터베이스에 등록
        new_user = User(
            email=request.form.get('email'),
            name=request.form.get('name'),
            password=hash_and_salted_password
        )
        db.session.add(new_user)
        db.session.commit()

        # 데이터베이스에 새 유저로 저장 후 로그인 및 인증 진행
        login_user(new_user)

        return redirect(url_for("secrets", name=new_user.name))

    return render_template("register.html")


@app.route('/login', methods=["GET", "POST"])
def login():
    if request.method == "POST":
        email = request.form.get('email')
        password = request.form.get('password')

        user = User.query.filter_by(email=email).first()

        # 이메일이 데이터베이스에 존재하지 않을 경우 로그인 페이지로 돌아감
        if not user:
            flash("That email does not exist, please try again.")
            return redirect(url_for('login'))
        # 입력한 비밀번호의 해시가 해당 유저의 비밀번호 해시값과 다른 경우 로그인 페이지로 돌아감
        elif not check_password_hash(user.password, password):
            flash('Password incorrect, please try again.')
            return redirect(url_for('login'))
        # 이메일이 존재하고 비밀번호도 올바를 경우 로그인
        else:
            login_user(user)
            return redirect(url_for('secrets', name=user.name))

    return render_template("login.html")


@app.route('/secrets/<name>')
@login_required
def secrets(name):
    return render_template("secrets.html", name=name)


@app.route('/logout')
def logout():
    logout_user()
    return redirect(url_for('home'))


@app.route('/download')
@login_required
def download():
    return send_from_directory(
        directory="static", path="files/cheat_sheet.pdf", as_attachment=True
    )


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

🏗️ base.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Flask Authentication</title>
    <link
      rel="stylesheet"
      href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"
    />
    <link
      rel="stylesheet"
      href="{{ url_for('static', filename='css/styles.css')}}"
    />
  </head>

  <body>
    <nav class="navbar navbar-expand-lg navbar-light bg-light">
      <a class="navbar-brand" href="/">Flask Authentication</a>
      <div class="collapse navbar-collapse">
        <ul class="navbar-nav ml-auto">
          <li class="nav-item">
            <a class="nav-link" href="{{ url_for('home') }}">Home</a>
          </li>
          <!-- 로그인 한 유저에게는 네비게이션 바의 로그인/등록 버튼을 숨기고 로그아웃 버튼 보이기 -->
          {% if not current_user.is_authenticated: %}
            <li class="nav-item">
              <a class="nav-link" href="{{ url_for('login') }}">Login</a>
            </li>
            <li class="nav-item">
              <a class="nav-link" href="{{ url_for('register') }}">Register</a>
            </li>
          {% else %}
            <li class="nav-item">
              <a class="nav-link" href="{{ url_for('logout') }}">Log Out</a>
            </li>
          {% endif %}
        </ul>
      </div>
    </nav>
    {% block content %} {% endblock %}
  </body>
</html>

🏗️ index.html

{% extends "base.html" %} {% block content %}

<div class="box">
  <h1>Flask Authentication</h1>
  <!-- 로그인 한 유저에게는 로그인/등록 버튼을 숨기기 -->
  {% if not current_user.is_authenticated: %}
    <a href="{{ url_for('login') }}" class="btn btn-primary btn-block btn-large">Login</a>
    <a
      href="{{ url_for('register') }}"
      class="btn btn-secondary btn-block btn-large"
      >Register</a
    >
  {% else %}
    <p>( you are already logged in )</p>
  {% endif %}
</div>

{% endblock %}

🏗️ login.html

{% extends "base.html" %} {% block content %}

<div class="box">
  <h1>Login</h1>

  {% with messages = get_flashed_messages() %}
    {% if messages %}
      {% for message in messages %}
        <p>{{ message }}</p>
      {% endfor %}
    {% endif %}
  {% endwith %}

  <form action="{{ url_for('login') }}" method="post">
    <input type="text" name="email" placeholder="Email" required="required" />
    <input type="password" name="password" placeholder="Password" required="required" />
    <button type="submit" class="btn btn-primary btn-block btn-large">Let me in.</button>
  </form>
</div>

{% endblock %}

🏗️ register.html

{% extends "base.html" %}
{% block content %}

<div class="box">
	<h1>Register</h1>
    <form action="{{ url_for('register') }}" method="post">
    	<input type="text" name="name" placeholder="Name" required="required" />
		<input type="email" name="email" placeholder="Email" required="required" />
        <input type="password" name="password" placeholder="Password" required="required" />
        <button type="submit" class="btn btn-primary btn-block btn-large">Sign me up.</button>
    </form>
</div>

{% endblock %}

🏗️ secrets.html

{% extends "base.html" %} {% block content %}

<div class="container">
  <h1 class="title">Welcome, {{ name }}!</h1>
  <a href="{{ url_for('download') }}">Download Your File</a>
</div>
{% endblock %}




▷ Angela Yu, [Python 부트캠프 : 100개의 프로젝트로 Python 개발 완전 정복], Udemy, https://www.udemy.com/course/best-100-days-python/?couponCode=ST3MT72524

0개의 댓글