포스트

[Python 100일 챌린지] Day 88 - 사용자 인증

[Python 100일 챌린지] Day 88 - 사용자 인증

지금까지 만든 웹사이트는 누구나 모든 기능을 사용할 수 있었습니다. 🤔 하지만 실제 서비스는 사용자 인증(Authentication)이 필요합니다!

  • 로그인한 사용자만 글을 쓸 수 있고,
  • 자신의 글만 수정/삭제할 수 있고,
  • 관리자만 접근할 수 있는 페이지가 있죠.

오늘은 Flask에서 완전한 인증 시스템을 구현합니다:

  • 회원가입과 로그인
  • 세션 관리
  • 비밀번호 암호화
  • 로그인 필수 페이지

보안이 중요한 만큼 신중하게 배워봅시다! 🔒

(40분 완독 ⭐⭐⭐⭐⭐)

🎯 오늘의 학습 목표

  1. 세션과 쿠키 이해하기
  2. 비밀번호 암호화하기
  3. 회원가입과 로그인 구현하기
  4. 인증 데코레이터와 권한 관리

📚 사전 지식


🎯 학습 목표 1: 세션과 쿠키 이해하기

HTTP의 무상태성 (Stateless)

1
2
3
4
5
6
7
8
9
10
11
# HTTP는 각 요청이 독립적입니다

요청 1: GET /page1    응답
요청 2: GET /page2    응답
# ↑ 서버는 요청 1과 요청 2가 같은 사용자인지 모름!

# 문제:
# - 로그인 후 다음 페이지에서 로그인 상태를 어떻게 유지?
# - 장바구니에 담은 상품을 어떻게 기억?

# 해결: 세션과 쿠키!
1
2
3
4
5
6
7
8
9
10
11
12
# 쿠키 = 브라우저에 저장되는 작은 데이터

┌─────────────┐
  브라우저   
             
 쿠키 저장소 
 user_id=123 
└─────────────┘
       (모든 요청에 자동 포함)
┌─────────────┐
   서버      
└─────────────┘

Flask에서 쿠키 사용:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from flask import Flask, make_response, request

app = Flask(__name__)

@app.route('/set-cookie')
def set_cookie():
    """쿠키 설정"""
    response = make_response("쿠키가 설정되었습니다")
    response.set_cookie('username', 'alice', max_age=3600)  # 1시간
    return response

@app.route('/get-cookie')
def get_cookie():
    """쿠키 읽기"""
    username = request.cookies.get('username')
    return f"사용자: {username}"

@app.route('/delete-cookie')
def delete_cookie():
    """쿠키 삭제"""
    response = make_response("쿠키가 삭제되었습니다")
    response.set_cookie('username', '', expires=0)
    return response

세션 (Session)

1
2
3
4
5
6
7
8
9
10
# 세션 = 서버에 저장되는 사용자 데이터
# 쿠키에는 세션 ID만 저장

┌─────────────┐                 ┌─────────────┐
  브라우저                       서버      
                                           
 쿠키:         session_id=abc  세션 저장소 
 session_id   ──────────────→  abc: {...}  
   = abc                       def: {...}  
└─────────────┘                 └─────────────┘

Flask에서 세션 사용:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from flask import Flask, session

app = Flask(__name__)
app.secret_key = 'your-secret-key-here'  # 세션 암호화 키 (필수!)

@app.route('/login', methods=['POST'])
def login():
    """로그인 - 세션에 사용자 정보 저장"""
    username = request.form.get('username')
    session['username'] = username
    session['logged_in'] = True
    return "로그인 성공!"

@app.route('/profile')
def profile():
    """프로필 - 세션에서 사용자 정보 읽기"""
    if 'logged_in' in session:
        username = session.get('username')
        return f"환영합니다, {username}님!"
    else:
        return "로그인이 필요합니다", 401

@app.route('/logout')
def logout():
    """로그아웃 - 세션 삭제"""
    session.pop('username', None)
    session.pop('logged_in', None)
    # 또는 session.clear()
    return "로그아웃 되었습니다"

🎯 학습 목표 2: 비밀번호 암호화하기

왜 암호화가 필요한가?

1
2
3
4
5
6
7
8
9
10
11
12
# ❌ 절대 하면 안 되는 것!
# 비밀번호를 평문으로 저장

users = [
    {'username': 'alice', 'password': 'alice123'},  # 😱
    {'username': 'bob', 'password': 'bob456'},      # 😱
]

# 문제점:
# 1. 데이터베이스 해킹 시 모든 비밀번호 노출
# 2. 관리자도 사용자 비밀번호를 볼 수 있음
# 3. 사용자가 다른 사이트에서 같은 비밀번호 사용 시 위험

해싱 (Hashing)

1
2
3
4
5
6
7
8
9
10
11
12
# 해싱 = 일방향 암호화
# 원본 → 해시값 (O)
# 해시값 → 원본 (X, 불가능!)

원본:    "password123"
       해싱
해시값:  "$2b$12$KIX..." (60 정도의 랜덤 문자열)

# 같은 비밀번호도 매번 다른 해시값 생성 (Salt 사용)
"password123"  "$2b$12$ABC..."
"password123"  "$2b$12$XYZ..."
# ↑ 무지개 테이블 공격 방어

bcrypt를 사용한 암호화

설치:

1
pip install bcrypt

사용법:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import bcrypt

# 1. 비밀번호 해싱
password = "alice123"
hashed = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt())
print(hashed)  # b'$2b$12$...'

# 데이터베이스에 저장할 때는 문자열로 변환
hashed_str = hashed.decode('utf-8')

# 2. 비밀번호 확인
input_password = "alice123"
if bcrypt.checkpw(input_password.encode('utf-8'), hashed):
    print("비밀번호 일치!")
else:
    print("비밀번호 틀림!")

헬퍼 함수:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import bcrypt

def hash_password(password):
    """비밀번호를 해시화"""
    return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')

def check_password(password, hashed):
    """비밀번호 확인"""
    return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))

# 사용:
hashed = hash_password("mypassword")
print(check_password("mypassword", hashed))  # True
print(check_password("wrongpass", hashed))   # False

🎯 학습 목표 3: 회원가입과 로그인 구현하기

데이터베이스 스키마

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# init_db.py
import sqlite3

def init_database():
    conn = sqlite3.connect('auth_demo.db')
    cursor = conn.cursor()

    cursor.execute('''
        CREATE TABLE IF NOT EXISTS users (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            username TEXT UNIQUE NOT NULL,
            email TEXT UNIQUE NOT NULL,
            password_hash TEXT NOT NULL,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
    ''')

    conn.commit()
    conn.close()
    print("✅ 데이터베이스 초기화 완료")

if __name__ == '__main__':
    init_database()

완전한 인증 시스템

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
# auth_app.py
from flask import Flask, render_template, request, redirect, url_for, flash, session
import sqlite3
import bcrypt

app = Flask(__name__)
app.secret_key = 'your-very-secret-key-change-this'

DATABASE = 'auth_demo.db'

def get_db():
    db = sqlite3.connect(DATABASE)
    db.row_factory = sqlite3.Row
    return db

def hash_password(password):
    return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')

def check_password(password, hashed):
    return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))

# 1. 회원가입
@app.route('/register', methods=['GET', 'POST'])
def register():
    """회원가입"""
    if request.method == 'POST':
        username = request.form.get('username', '').strip()
        email = request.form.get('email', '').strip()
        password = request.form.get('password')
        password_confirm = request.form.get('password_confirm')

        # 검증
        errors = []

        if not username or len(username) < 3:
            errors.append('아이디는 3자 이상이어야 합니다')

        if not email or '@' not in email:
            errors.append('올바른 이메일을 입력하세요')

        if not password or len(password) < 6:
            errors.append('비밀번호는 6자 이상이어야 합니다')

        if password != password_confirm:
            errors.append('비밀번호가 일치하지 않습니다')

        if errors:
            for error in errors:
                flash(error, 'error')
            return redirect(url_for('register'))

        # 비밀번호 해싱
        password_hash = hash_password(password)

        # 데이터베이스에 저장
        db = get_db()
        cursor = db.cursor()

        try:
            cursor.execute('''
                INSERT INTO users (username, email, password_hash)
                VALUES (?, ?, ?)
            ''', (username, email, password_hash))
            db.commit()

            flash(f'{username}님, 회원가입을 환영합니다!', 'success')
            return redirect(url_for('login'))

        except sqlite3.IntegrityError:
            flash('이미 존재하는 아이디 또는 이메일입니다', 'error')

        finally:
            db.close()

    return render_template('register.html')

# 2. 로그인
@app.route('/login', methods=['GET', 'POST'])
def login():
    """로그인"""
    if request.method == 'POST':
        username = request.form.get('username')
        password = request.form.get('password')

        db = get_db()
        cursor = db.cursor()

        cursor.execute('''
            SELECT * FROM users WHERE username = ?
        ''', (username,))

        user = cursor.fetchone()
        db.close()

        if user and check_password(password, user['password_hash']):
            # 로그인 성공 - 세션에 저장
            session['user_id'] = user['id']
            session['username'] = user['username']

            flash(f'환영합니다, {username}님!', 'success')
            return redirect(url_for('dashboard'))
        else:
            flash('아이디 또는 비밀번호가 틀렸습니다', 'error')

    return render_template('login.html')

# 3. 로그아웃
@app.route('/logout')
def logout():
    """로그아웃"""
    username = session.get('username', '사용자')
    session.clear()
    flash(f'{username}님, 로그아웃 되었습니다', 'success')
    return redirect(url_for('login'))

# 4. 대시보드 (로그인 필요)
@app.route('/dashboard')
def dashboard():
    """사용자 대시보드"""
    if 'user_id' not in session:
        flash('로그인이 필요합니다', 'error')
        return redirect(url_for('login'))

    return render_template('dashboard.html', username=session['username'])

# 5. 홈
@app.route('/')
def home():
    return render_template('home.html')

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

템플릿 예시

templates/register.html:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
<!DOCTYPE html>
<html>
<head>
    <title>회원가입</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { font-family: Arial; background: #f5f5f5; }
        .container { max-width: 500px; margin: 50px auto; background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
        h1 { margin-bottom: 20px; color: #333; }
        .form-group { margin: 15px 0; }
        label { display: block; margin-bottom: 5px; font-weight: bold; color: #555; }
        input { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; }
        button { width: 100%; padding: 12px; background: #007bff; color: white; border: none; border-radius: 5px; font-size: 16px; cursor: pointer; margin-top: 10px; }
        button:hover { background: #0056b3; }
        .flash { padding: 10px; margin: 10px 0; border-radius: 5px; }
        .flash-error { background: #f8d7da; color: #721c24; }
        .flash-success { background: #d4edda; color: #155724; }
        .link { text-align: center; margin-top: 20px; }
        .link a { color: #007bff; text-decoration: none; }
    </style>
</head>
<body>
    <div class="container">
        <h1>🔐 회원가입</h1>

        {% with messages = get_flashed_messages(with_categories=true) %}
            {% for category, message in messages %}
                <div class="flash flash-{{ category }}">{{ message }}</div>
            {% endfor %}
        {% endwith %}

        <form method="POST">
            <div class="form-group">
                <label for="username">아이디</label>
                <input type="text" id="username" name="username" required>
            </div>

            <div class="form-group">
                <label for="email">이메일</label>
                <input type="email" id="email" name="email" required>
            </div>

            <div class="form-group">
                <label for="password">비밀번호</label>
                <input type="password" id="password" name="password" required>
            </div>

            <div class="form-group">
                <label for="password_confirm">비밀번호 확인</label>
                <input type="password" id="password_confirm" name="password_confirm" required>
            </div>

            <button type="submit">가입하기</button>
        </form>

        <div class="link">
            이미 계정이 있나요? <a href="{{ url_for('login') }}">로그인</a>
        </div>
    </div>
</body>
</html>

templates/login.html:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<!DOCTYPE html>
<html>
<head>
    <title>로그인</title>
    <style>
        /* register.html과 동일한 스타일 */
    </style>
</head>
<body>
    <div class="container">
        <h1>🔑 로그인</h1>

        {% with messages = get_flashed_messages(with_categories=true) %}
            {% for category, message in messages %}
                <div class="flash flash-{{ category }}">{{ message }}</div>
            {% endfor %}
        {% endwith %}

        <form method="POST">
            <div class="form-group">
                <label for="username">아이디</label>
                <input type="text" id="username" name="username" required>
            </div>

            <div class="form-group">
                <label for="password">비밀번호</label>
                <input type="password" id="password" name="password" required>
            </div>

            <button type="submit">로그인</button>
        </form>

        <div class="link">
            계정이 없나요? <a href="{{ url_for('register') }}">회원가입</a>
        </div>
    </div>
</body>
</html>

templates/dashboard.html:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html>
<head>
    <title>대시보드</title>
    <style>
        /* 동일한 스타일 */
    </style>
</head>
<body>
    <div class="container">
        <h1>👋 환영합니다, {{ username }}님!</h1>
        <p>로그인에 성공했습니다.</p>

        <div style="margin-top: 30px;">
            <a href="{{ url_for('home') }}" style="display: inline-block; padding: 10px 20px; background: #007bff; color: white; text-decoration: none; border-radius: 5px;">홈으로</a>
            <a href="{{ url_for('logout') }}" style="display: inline-block; padding: 10px 20px; background: #dc3545; color: white; text-decoration: none; border-radius: 5px;">로그아웃</a>
        </div>
    </div>
</body>
</html>

🎯 학습 목표 4: 인증 데코레이터와 권한 관리

로그인 필수 데코레이터

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from functools import wraps
from flask import session, redirect, url_for, flash

def login_required(f):
    """로그인이 필요한 페이지를 위한 데코레이터"""
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if 'user_id' not in session:
            flash('로그인이 필요한 페이지입니다', 'error')
            return redirect(url_for('login'))
        return f(*args, **kwargs)
    return decorated_function

# 사용:
@app.route('/dashboard')
@login_required
def dashboard():
    """로그인한 사용자만 접근 가능"""
    return render_template('dashboard.html')

@app.route('/profile')
@login_required
def profile():
    """프로필 페이지"""
    return "프로필 페이지"

현재 사용자 정보 가져오기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def get_current_user():
    """현재 로그인한 사용자 정보 조회"""
    if 'user_id' not in session:
        return None

    db = get_db()
    cursor = db.cursor()
    cursor.execute('SELECT * FROM users WHERE id = ?', (session['user_id'],))
    user = cursor.fetchone()
    db.close()

    return dict(user) if user else None

# 사용:
@app.route('/profile')
@login_required
def profile():
    user = get_current_user()
    return render_template('profile.html', user=user)

템플릿에서 로그인 상태 확인

1
2
3
4
5
# 템플릿 전역 함수 등록
@app.context_processor
def inject_user():
    """모든 템플릿에서 current_user 사용 가능"""
    return dict(current_user=get_current_user())

templates/base.html:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!DOCTYPE html>
<html>
<body>
    <header>
        <h1>내 웹사이트</h1>
        <nav>
            <a href="/"></a>
            {% if current_user %}
                <a href="/dashboard">대시보드</a>
                <a href="/logout">로그아웃 ({{ current_user.username }})</a>
            {% else %}
                <a href="/login">로그인</a>
                <a href="/register">회원가입</a>
            {% endif %}
        </nav>
    </header>

    <main>
        {% block content %}{% endblock %}
    </main>
</body>
</html>

관리자 권한 체크

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# users 테이블에 role 컬럼 추가
"""
CREATE TABLE users (
    ...
    role TEXT DEFAULT 'user'  -- 'user', 'admin'
);
"""

def admin_required(f):
    """관리자만 접근 가능한 데코레이터"""
    @wraps(f)
    def decorated_function(*args, **kwargs):
        user = get_current_user()
        if not user:
            flash('로그인이 필요합니다', 'error')
            return redirect(url_for('login'))

        if user['role'] != 'admin':
            flash('관리자만 접근할 수 있습니다', 'error')
            return redirect(url_for('home'))

        return f(*args, **kwargs)
    return decorated_function

# 사용:
@app.route('/admin')
@admin_required
def admin_panel():
    """관리자 페이지"""
    return render_template('admin.html')

Remember Me (자동 로그인)

1
2
3
4
5
6
7
8
9
10
11
12
13
@app.route('/login', methods=['POST'])
def login():
    # ...로그인 로직...

    remember = request.form.get('remember')  # 체크박스

    if remember:
        # 세션 영구 저장 (브라우저 닫아도 유지)
        session.permanent = True
        app.permanent_session_lifetime = timedelta(days=30)

    session['user_id'] = user['id']
    # ...

⚠️ 주의사항

1. Secret Key 관리

1
2
3
4
5
6
7
8
9
10
# ❌ 위험: 하드코딩
app.secret_key = 'my-secret-key'

# ✅ 안전: 환경 변수 사용
import os
app.secret_key = os.environ.get('SECRET_KEY', 'dev-key-change-in-production')

# 또는 별도 설정 파일
import secrets
app.secret_key = secrets.token_hex(32)

2. HTTPS 사용

1
2
3
4
5
6
7
8
# 프로덕션에서는 반드시 HTTPS 사용!
# HTTP로 전송 시 비밀번호가 평문으로 노출됨

# Flask에서 HTTPS만 허용
@app.before_request
def before_request():
    if not request.is_secure and app.env == 'production':
        return redirect(request.url.replace('http://', 'https://'))

3. Rate Limiting (무차별 대입 공격 방지)

1
2
3
4
5
6
7
8
from flask_limiter import Limiter

limiter = Limiter(app, default_limits=["200 per day", "50 per hour"])

@app.route('/login', methods=['POST'])
@limiter.limit("5 per minute")  # 1분에 5번만 시도 가능
def login():
    # ...

🧪 연습 문제

문제: 비밀번호 재설정 기능

이메일 인증을 통한 비밀번호 재설정 기능을 구현하세요:

  1. 비밀번호 찾기 페이지
  2. 이메일로 임시 토큰 발송 (실제로는 이메일 서비스 필요)
  3. 토큰 확인 후 새 비밀번호 설정
💡 힌트
1
2
3
4
5
6
7
8
9
10
11
12
import secrets
from datetime import datetime, timedelta

# 토큰 생성
token = secrets.token_urlsafe(32)

# 데이터베이스에 저장 (만료 시간 포함)
expire_at = datetime.now() + timedelta(hours=1)

# 토큰 검증
if token_valid and not_expired:
    # 비밀번호 재설정

📝 요약

이번 Day 88에서 학습한 내용:

  1. 세션과 쿠키: 상태 유지 방법
  2. 비밀번호 암호화: bcrypt, 해싱
  3. 인증 시스템: 회원가입, 로그인, 로그아웃
  4. 권한 관리: 데코레이터, 역할 기반 접근 제어

핵심 패턴:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 회원가입
password_hash = hash_password(password)
INSERT INTO users ... VALUES (..., password_hash)

# 로그인
user = SELECT * FROM users WHERE username = ?
if check_password(input_password, user.password_hash):
    session['user_id'] = user.id

# 로그인 확인
@login_required
def protected_page():
    user = get_current_user()
    ...

📚 다음 학습

Day 89: 배포 준비하기 ⭐⭐⭐⭐

내일은 만든 Flask 애플리케이션을 실제 서버에 배포하는 방법을 배웁니다!


“보안은 선택이 아니라 필수입니다!” 🔒

Day 88/100 Phase 9: 웹 개발 입문 #100DaysOfPython
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.