플라스크, WTForms로 고급 입력양식

JOOYEUN SEO·2024년 10월 22일
0

100 Days of Python

목록 보기
61/76
post-thumbnail

❖ Flask-WTF

  • 플라스크 확장 모듈
  • Day60에서처럼 플라스크 서버로 HTML 입력양식을 동작시키는 것보다 장점이 많음
    • 쉬운 유효성 검증
      • 사용자가 필수 입력 항목에 올바른 형식으로 데이터를 입력했는지 검증
        예) 이메일 주소에 @과 .을 포함했는지 확인
      • 유효성 검증을 위한 코드를 직접 작성할 필요가 없다
    • 코드 라인 감소
    • CSRF 보호 기능 내장
      • 사이트 간 요청 위조(Cross Site Request Forgery)의 줄임
      • 모르는 사람에게 돈을 송금하는 등 사용자가 자신의 의지와 무관한 행동을 하게 하는 공격

◇ Flask-WTF 설치

터미널에서 pip으로 설치 혹은 업그레이드

pip install -U Flask-WTF

또는

💡 requirements.txt 파일

  • 프로젝트에 필요한 라이브러리(설치 패키지들)와 버전을 명시하는 파일
    예) Flask==1.0.2
  • 필요한 패키지를 포함하지 않고 적은 용량으로 프로젝트 공유 가능
  • 0.9.0 버전부터는 파일에 Flask-WTF 뿐만 아니라 wtforms도 추가해야 한다
  • 터미널에서 모든 파일 한꺼번에 설치
    • 윈도우: python -m pip install -r requirements.txt
    • 맥: pip3 install -r requirements.txt

🗂️ Day61 프로젝트: 비밀키 사이트 로그인 양식

사용자 이름과 비밀번호를 정확하게 입력해야만 비밀키를 가진 페이지에 접근 가능한 웹사이트

1. Flask-WTF로 입력양식 만들기

🔍 유의 사항

  • Quickstart 참조
  • 메인 화면의 email과 password 항목을 StringFields로 만들기
    (validators는 유효성 검사를 추가할 때 사용되므로 이번 단계에서 넘어감)
  • <form> 태그의 {{ form.csrf_token }} 은 CSRF 보호 기능을 추가하는 것
  • main.py에서 csrf_token을 생성하는 데 사용될 비밀키 생성하기
    app.secret_key = "원하는 문자열"

⌨️ main.py

from flask import Flask, render_template
from flask_wtf import FlaskForm
from wtforms import StringField

class LoginForm(FlaskForm):
    email = StringField('Email')
    password = StringField('Password')

app = Flask(__name__)
app.secret_key = "원하는-문자열"

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

@app.route("/login")
def login():
    login_form = LoginForm()
    return render_template('login.html', form=login_form)

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

🏗️ login.html

  <body>
    <div class="container">
      <h1>Login</h1>
	  <form method="POST" action="/login">
		{{ form.csrf_token }}
		{{ form.email.label }} {{ form.email(size=30) }}
		{{ form.password.label }} {{ form.password(size=30) }}
		<input type="submit" value="Go">
	  </form>
    </div>
  </body>

HTML로 <label>이나 <input> 요소를 직접 만들지 않고 양식 생성


2. WTForms 코드 개선

🔍 유의 사항

  • 1번에서 퀵스타트로 간단히 작성한 코드를 개선
  • 패스워드 입력창을 StringField에서 PasswordField 으로 변경
    (입력한 텍스트가 ••• 으로 가려짐)
  • StringField, PasswordField 생성시 label 키워드 인자를 추가해서 명확히 하기
    (login.html 파일에 전달될 때 .label)
  • action을 동적 URL로 수정
  • WTForms으로 생성한 입력양식의 레이블과 입력창에 HTML 요소를 사용하여 레이아웃을 지정
  • Go 버튼도 SubmitField로 변경

⌨️ main.py

from wtforms import StringField, PasswordField, SubmitField

class LoginForm(FlaskForm):
    email = StringField('Email')
    password = PasswordField('Password')
    submit = SubmitField(label='Log In')

🏗️ login.html

  <body>
    <div class="container">
      <h1>Login</h1>
	  <form method="POST" action="{{ url_for('login') }}">
		{{ form.csrf_token }}
		<p>{{ form.email.label }}<br>{{ form.email(size=30) }}</P>
		<p>{{ form.password.label }}<br>{{ form.password(size=30) }}</P>
		{{ form.submit }}
	  </form>
    </div>
  </body>

3. Flask-WTF로 입력양식에 유효성 검사 추가

🔍 유의 사항

  • Validators 참고
  • 입력양식의 입력창에 validator 객체 추가
    • validators 매개변수는 validator 객체의 리스트
    • DataRequired를 지정한 입력창에 아무것도 입력하지 않으면 오류 발생
  • 입력양식 제출 후 오류가 많으면 errors 리스트가 생성됨
    • 오류를 발생시킨 필드의 프로퍼티로 HTML에 전달: form.<field>.errors
    • 반복문을 사용하여 오류들을 텍스트로 보여줄 수 있다
  • 사용자가 제출 버튼을 눌렀을 때 입력값의 유효성 검증하기
    • POST 요청에 응답 후 데이터를 validate_on_submit()으로 검증하도록 수정
  • 모든 사용자가 입력창의 유효성 검증을 받아야 한다
    • 브라우저마다 내장된 검증 방법이 다르기 때문 (크롬의 경우 팝업 알림)
    • <form> 태그에 novalidate 속성을 넣어서 브라우저 검증 끄기
  • 이메일 검증을 위해 email-validator 패키지 설치하기

⌨️ main.py

from wtforms.validators import DataRequired, Email, Length

class LoginForm(FlaskForm):
    email = StringField('Email', validators=[
        DataRequired(), Email(message='Invalid email address')
    ])
    password = PasswordField('Password', validators=[
        DataRequired(), Length(min=8, message='Field must be at least characters long')
    ])
    submit = SubmitField(label='Log In')

🏗️ login.html

  <body>
    <div class="container">
      <h1>Login</h1>
	  <form method="POST" action="{{ url_for('login') }}" novalidate>
		{{ form.csrf_token }}
		<p>
		  {{ form.email.label }}<br>{{ form.email(size=30) }}
		  {% for err in form.email.errors %}
		    <span style="color:red">{{ err }}</span>
		  {% endfor %}
		</P>
		<p>
		  {{ form.password.label }}<br>{{ form.password(size=30) }}
		  {% for err in form.password.errors %}
		    <span style="color:red">{{ err }}</span>
		  {% endfor %}
		</P>
		{{ form.submit }}
	  </form>
    </div>
  </body>

4. Flask-WTF로 폼 데이터 수신

🔍 유의 사항

  • <form_object>.<form_field>.data 로 폼 데이터 찾기
  • validate_on_submit()의 반환값으로 요청이 GET 인지 POST인지 확인하기
    • 사용자가 폼을 제출한 후 유효성 검증이 성공한 경우 True, 실패한 경우 False
    • Day60에서는 if request.method == "POST" 사용
  • 이메일이 admin@email.com, 비밀번호는 12345678
    • 일치할 때 success.html 페이지 표시,
    • 일치하지 않을 경우 denied.html 페이지가 표시되도록 수정

⌨️ main.py

@app.route("/login", methods=["GET", "POST"])
def login():
    login_form = LoginForm()
    if login_form.validate_on_submit():
        if login_form.email.data == "admin@email.com" and login_form.password.data == "12345678":
            return render_template("success.html")
        else:
            return render_template("denied.html")
    return render_template('login.html', form=login_form)

5. Jinja2로 템플릿 상속

🔍 유의 사항

  • 전체 웹 사이트에 동일한 디자인 템플릿을 사용하지만 일부 코드를 변경해야할 경우 템플릿 상속 사용
  • 클래스 상속과 유사해서 상위 템플릿을 가져와 하위 웹 페이지에서 스타일 확장 가능
    • 상위 템플릿 base.html에 새 콘텐츠를 삽입할 수 있는 미리 정의된 영역(블록) 존재
    • 하위 템플릿 success.html, denied.html이 상위 템플릿을 상속
  • 슈퍼 블록
    • super 키워드는 자식 클래스에 상속하는 부모 클래스를 나타냄
    • 템플릿을 상속받을 때 일부는 유지하되 일부는 추가하고 싶은 경우 사용
    • 부모 클래스에서 블록 지정(원하는 이름 사용 가능)
    • 자식 클래스에서 슈퍼 블록 {{ super() }}을 추가하면 모든 코드가 하위 페이지에 삽입
  • ❗️모든 블록은 {% endblock %} 으로 닫는 것 잊지 말기

🏗️ base.html

<head>
  <meta charset="UTF-8">
  <title>{% block title %}{% endblock %}</title>
  <style>
    {% block styling %}
    body {
        background: purple;
    }
    {% endblock %}
  </style>
</head>
<body>
  {% block content %}{% endblock %}
</body>

🏗️ success.html

<!-- 템플릿 엔진(진자)이 'base.html'을 이 페이지의 템플릿으로 사용하도록 명령 -->
{% extends "base.html" %}

<!-- 템플릿의 헤더에 사용자 정의된 제목을 삽입 -->
{% block title %}Success{% endblock %}

<!-- 웹 사이트의 콘텐츠를 제공하는 부분 -->
{% block content %}

  <div class="container">
  	<h1>Top Secret </h1>
	  <iframe src="https://giphy.com/embed/Ju7l5y9osyymQ" width="480" height="360" frameBorder="0" class="giphy-embed" allowFullScreen></iframe>
	  <p><a href="https://giphy.com/gifs/rick-astley-Ju7l5y9osyymQ">via GIPHY</a></p>
  </div>

{% endblock %}

🏗️ denied.html

{% extends "base.html" %}
{% block title %}Access Denied{% endblock %}

<!-- denied 페이지에만 다르게 적용할 스타일 -->
{% block styling %}
  {{ super() }}
  h1 {
    color: red;
  }
{% endblock %}

{% block content %}

  <div class="container">
    <h1>Access Denied </h1>
	<iframe src="https://giphy.com/embed/1xeVd1vr43nHO" width="480" height="271" frameBorder="0" class="giphy-embed" allowFullScreen></iframe>
	<p><a href="https://giphy.com/gifs/cheezburger-funny-dog-fails-1xeVd1vr43nHO">via GIPHY</a></p>
  </div>

{% endblock %}

6. 플라스크 부트스트랩을 상속된 템플릿으로 사용

🔍 유의 사항

  • Flask-Bootstrap 패키지 설치
  • 부트스트랩을 템플릿으로 사용하도록 변경
    • 5번에서 작성한 denied.html의 슈퍼 블록 삭제
    • base.html 에서 Bootstrap CSS 포함시키기
      • 강의와 달리 부트스트랩 문서에서 CDN 링크를 복사해서 붙여야 작동함
      • 링크에서 최신 버전 링크로 작성해야 스타일이 제대로 적용됨
    • denied.html, success.html, login.html, index.html 코드 변경

⌨️ main.py

from flask import Flask, render_template
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Email, Length
from flask_bootstrap import Bootstrap

class LoginForm(FlaskForm):
    email = StringField('Email', validators=[
        DataRequired(), Email(message='Invalid email address')
    ])
    password = PasswordField('Password', validators=[
        DataRequired(), Length(min=8, message='Field must be at least 8 characters long')
    ])
    submit = SubmitField(label='Log In')


app = Flask(__name__)
app.secret_key = "secret-key-string"
Bootstrap(app)

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

@app.route("/login", methods=["GET", "POST"])
def login():
    login_form = LoginForm()
    if login_form.validate_on_submit():
        if login_form.email.data == "admin@email.com" and login_form.password.data == "12345678":
            return render_template("success.html")
        else:
            return render_template("denied.html")
    return render_template('login.html', form=login_form)

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

🏗️ base.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

  {% block styles %}
    <!-- Bootstrap CSS CDN -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
  {% endblock %}

  <title>{% block title %}{% endblock %}</title>
</head>
<body>
  {% block content %}{% endblock %}
  <!-- (필요할 경우) Bootstrap JS and dependencies CDN -->
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script>
</body>
</html>

🏗️ index.html

{% extends "base.html" %}
{% block title %}Secrets{% endblock %}

{% block content %}
<!--Using Boostrap classes for styling here-->
<div class="hero bg-light py-5">
  <div class="container">
	<h1>Welcome</h1>
	<p>Are you ready to discover my secret?</p>
	<a class="btn btn-primary btn-lg" href="{{ url_for('login') }}">Login</a>
  </div>
</div>
{% endblock %}

🏗️ login.html

{% extends "base.html" %}
{% block title %}Login{% endblock %}

{% block content %}
  <div class="container">
    <h1>Login</h1>
    <form method="POST" action="{{ url_for('login') }}" novalidate>
	  {{ form.csrf_token }}
	  <p>
	    {{ form.email.label }}<br>{{ form.email(size=30) }}
	    {% for err in form.email.errors %}
		  <span style="color:red">{{ err }}</span>
	    {% endfor %}
	  </P>
	  <p>
	    {{ form.password.label }}<br>{{ form.password(size=30) }}
	    {% for err in form.password.errors %}
		  <span style="color:red">{{ err }}</span>
	    {% endfor %}
	  </P>
	  {{ form.submit }}
    </form>
  </div>
{% endblock %}

🏗️ denied.html

{% extends "base.html" %}
{% block title %}Access Denied{% endblock %}

{% block content %}
  <div class="container">
    <h1>Access Denied </h1>
	<iframe src="https://giphy.com/embed/1xeVd1vr43nHO" width="480" height="271" frameBorder="0" class="giphy-embed" allowFullScreen></iframe>
	<p><a href="https://giphy.com/gifs/cheezburger-funny-dog-fails-1xeVd1vr43nHO">via GIPHY</a></p>
  </div>
{% endblock %}

🏗️ success.html

{% extends "base.html" %}
{% block title %}Access Granted{% endblock %}

{% block content %}
  <div class="container">
  	<h1>Top Secret </h1>
	  <iframe src="https://giphy.com/embed/Ju7l5y9osyymQ" width="480" height="360" frameBorder="0" class="giphy-embed" allowFullScreen></iframe>
	  <p><a href="https://giphy.com/gifs/rick-astley-Ju7l5y9osyymQ">via GIPHY</a></p>
  </div>
{% endblock %}

7. WTForms를 지원하는 플라스크 부트스트랩

🔍 유의 사항

  • {% import "bootstrap/wtf.html" as wtf %} 를 임포트하고
    {{ wtf.quick_form(form) }} 를 사용하여 한 줄의 코드로 간단히 폼 작성 가능
    • 템플릿에 상속된 WTForm 객체(form)를 가져오는 코드 라인
    • 폼에 대한 모든 레이블, 입력, 버튼, 스타일을 생성한다
  • novalidate=True 옵션으로 브라우저 내장 유효성 검사 기능 끄기

🏗️ login.html

{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% block title %}Login{% endblock %}

{% block content %}
  <div class="container">
    <h1>Login</h1>
    <div class="row">
      <div class="col-md-4">
        {{ wtf.quick_form(form, novalidate=True) }}
      </div>
    </div>




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

0개의 댓글