戒酒的李白

Comprehensive enhancement of user authentication system security, including pass…

…word storage, session management, XSS/CSRF protection, etc.
1 import time 1 import time
2 import hashlib 2 import hashlib
3 -from flask import Blueprint, redirect, render_template, request, Flask, session, current_app 3 +from flask import Blueprint, redirect, render_template, request, Flask, session, current_app, make_response
4 from datetime import datetime, timedelta 4 from datetime import datetime, timedelta
5 import re 5 import re
6 from utils.query import query 6 from utils.query import query
@@ -8,40 +8,138 @@ from utils.errorResponse import errorResponse @@ -8,40 +8,138 @@ from utils.errorResponse import errorResponse
8 from utils.logger import app_logger as logging 8 from utils.logger import app_logger as logging
9 from functools import wraps 9 from functools import wraps
10 import secrets 10 import secrets
  11 +from flask_limiter import Limiter
  12 +from flask_limiter.util import get_remote_address
  13 +import redis
  14 +import json
  15 +import bleach
  16 +from argon2 import PasswordHasher
  17 +from argon2.exceptions import VerifyMismatchError
  18 +import html
  19 +
  20 +# 创建Argon2密码哈希器
  21 +ph = PasswordHasher()
  22 +
  23 +# Redis连接
  24 +redis_client = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)
  25 +
  26 +# 创建限流器
  27 +limiter = Limiter(
  28 + key_func=get_remote_address,
  29 + default_limits=["200 per day", "50 per hour"]
  30 +)
11 31
12 ub = Blueprint('user', 32 ub = Blueprint('user',
13 __name__, 33 __name__,
14 url_prefix='/user', 34 url_prefix='/user',
15 template_folder='templates') 35 template_folder='templates')
16 36
  37 +def sanitize_input(text):
  38 + """清理用户输入,防止XSS攻击"""
  39 + if text is None:
  40 + return None
  41 + return bleach.clean(str(text), strip=True)
  42 +
  43 +def validate_csrf_token():
  44 + """验证CSRF令牌"""
  45 + token = request.form.get('csrf_token')
  46 + stored_token = session.get('csrf_token')
  47 + if not token or not stored_token or token != stored_token:
  48 + return False
  49 + return True
  50 +
  51 +def get_client_info():
  52 + """获取客户端信息"""
  53 + return {
  54 + 'ip': request.remote_addr,
  55 + 'user_agent': str(request.user_agent.string),
  56 + 'platform': str(request.user_agent.platform),
  57 + 'browser': str(request.user_agent.browser),
  58 + }
  59 +
  60 +def is_suspicious_ip(ip):
  61 + """检查IP是否可疑"""
  62 + key = f"login_attempts:{ip}"
  63 + attempts = redis_client.get(key)
  64 + if attempts and int(attempts) >= 5: # 5次失败尝试
  65 + return True
  66 + return False
  67 +
  68 +def record_failed_attempt(ip):
  69 + """记录失败的登录尝试"""
  70 + key = f"login_attempts:{ip}"
  71 + pipe = redis_client.pipeline()
  72 + pipe.incr(key)
  73 + pipe.expire(key, 1800) # 30分钟后重置
  74 + pipe.execute()
  75 +
  76 +def clear_login_attempts(ip):
  77 + """清除登录尝试记录"""
  78 + redis_client.delete(f"login_attempts:{ip}")
  79 +
  80 +def set_secure_headers(response):
  81 + """设置安全响应头"""
  82 + response.headers['X-Content-Type-Options'] = 'nosniff'
  83 + response.headers['X-Frame-Options'] = 'SAMEORIGIN'
  84 + response.headers['X-XSS-Protection'] = '1; mode=block'
  85 + response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
  86 + response.headers['Content-Security-Policy'] = "default-src 'self'"
  87 + return response
  88 +
17 def login_required(f): 89 def login_required(f):
18 @wraps(f) 90 @wraps(f)
19 def decorated_function(*args, **kwargs): 91 def decorated_function(*args, **kwargs):
20 if 'username' not in session: 92 if 'username' not in session:
21 return redirect('/user/login') 93 return redirect('/user/login')
  94 +
  95 + # 验证会话完整性
  96 + if 'client_info' not in session or 'session_id' not in session:
  97 + session.clear()
  98 + return redirect('/user/login')
  99 +
  100 + # 验证客户端信息
  101 + current_client = get_client_info()
  102 + stored_client = session['client_info']
  103 +
  104 + if (current_client['ip'] != stored_client['ip'] or
  105 + current_client['user_agent'] != stored_client['user_agent']):
  106 + session.clear()
  107 + return redirect('/user/login')
  108 +
  109 + # 验证会话ID
  110 + stored_session_id = redis_client.get(f"session:{session['username']}")
  111 + if not stored_session_id or stored_session_id != session['session_id']:
  112 + session.clear()
  113 + return redirect('/user/login')
  114 +
22 return f(*args, **kwargs) 115 return f(*args, **kwargs)
23 return decorated_function 116 return decorated_function
24 117
25 -# 密码加密函数  
26 -def hash_password(password: str, salt: str = None) -> tuple: 118 +def hash_password(password: str) -> str:
27 """ 119 """
28 - 使用 SHA256 对密码进行加盐哈希 120 + 使用Argon2id算法哈希密码
29 :param password: 用户输入的密码 121 :param password: 用户输入的密码
30 - :param salt: 可选的盐值  
31 - :return: (哈希后的密码, 盐值) 122 + :return: 哈希后的密码
  123 + """
  124 + return ph.hash(password)
  125 +
  126 +def verify_password(stored_hash: str, password: str) -> bool:
32 """ 127 """
33 - if not salt:  
34 - salt = secrets.token_hex(16)  
35 - hash_obj = hashlib.sha256()  
36 - hash_obj.update(salt.encode('utf-8'))  
37 - hash_obj.update(password.encode('utf-8'))  
38 - return hash_obj.hexdigest(), salt 128 + 验证密码
  129 + :param stored_hash: 存储的密码哈希
  130 + :param password: 用户输入的密码
  131 + :return: 是否匹配
  132 + """
  133 + try:
  134 + return ph.verify(stored_hash, password)
  135 + except VerifyMismatchError:
  136 + return False
39 137
40 def validate_password(password: str) -> bool: 138 def validate_password(password: str) -> bool:
41 """ 139 """
42 验证密码强度 140 验证密码强度
43 """ 141 """
44 - if len(password) < 8: 142 + if len(password) < 12: # 增加最小长度要求
45 return False 143 return False
46 if not re.search(r"[A-Z]", password): 144 if not re.search(r"[A-Z]", password):
47 return False 145 return False
@@ -51,96 +149,185 @@ def validate_password(password: str) -> bool: @@ -51,96 +149,185 @@ def validate_password(password: str) -> bool:
51 return False 149 return False
52 if not re.search(r"[!@#$%^&*(),.?\":{}|<>]", password): 150 if not re.search(r"[!@#$%^&*(),.?\":{}|<>]", password):
53 return False 151 return False
  152 + # 检查常见密码模式
  153 + common_patterns = ['password', '123456', 'qwerty']
  154 + if any(pattern in password.lower() for pattern in common_patterns):
  155 + return False
54 return True 156 return True
55 157
56 @ub.route('/login', methods=['GET', 'POST']) 158 @ub.route('/login', methods=['GET', 'POST'])
  159 +@limiter.limit("5 per minute")
57 def login(): 160 def login():
58 - """  
59 - 处理用户登录请求  
60 - """ 161 + """处理用户登录请求"""
61 if request.method == 'GET': 162 if request.method == 'GET':
62 - return render_template('login_and_register.html') 163 + response = make_response(render_template('login_and_register.html'))
  164 + return set_secure_headers(response)
63 165
64 try: 166 try:
65 - username = request.form.get('username')  
66 - password = request.form.get('password') 167 + if request.method == 'POST' and not validate_csrf_token():
  168 + logging.warning("CSRF验证失败")
  169 + return errorResponse('无效的请求')
  170 +
  171 + client_ip = request.remote_addr
  172 +
  173 + if is_suspicious_ip(client_ip):
  174 + logging.warning(f"可疑IP尝试登录: {client_ip}")
  175 + return errorResponse('由于多次失败尝试,请30分钟后再试')
  176 +
  177 + username = sanitize_input(request.form.get('username'))
  178 + password = request.form.get('password') # 密码不需要sanitize
67 179
68 if not username or not password: 180 if not username or not password:
69 logging.warning("登录失败:用户名或密码为空") 181 logging.warning("登录失败:用户名或密码为空")
70 - return render_template('login_and_register.html', msg='用户名和密码不能为空') 182 + return errorResponse('用户名和密码不能为空')
71 183
72 - # 查询用户和盐值  
73 - sql = "SELECT password, salt FROM user WHERE username = %s" 184 + # 查询用户信息
  185 + sql = "SELECT password, status FROM user WHERE username = %s"
74 result = query(sql, [username], "select") 186 result = query(sql, [username], "select")
75 187
76 if result: 188 if result:
77 stored_password = result[0]['password'] 189 stored_password = result[0]['password']
78 - salt = result[0]['salt'] 190 + status = result[0]['status']
79 191
80 - # 验证密码  
81 - hashed_input, _ = hash_password(password, salt) 192 + if status != 'active':
  193 + logging.warning(f"已禁用的账户尝试登录: {username}")
  194 + return errorResponse('账户已被禁用')
82 195
83 - if hashed_input == stored_password: 196 + if verify_password(stored_password, password):
84 session.clear() 197 session.clear()
  198 + session.regenerate()
  199 +
  200 + # 生成唯一会话ID
  201 + session_id = secrets.token_hex(32)
  202 + client_info = get_client_info()
  203 +
  204 + # 存储会话信息
85 session['username'] = username 205 session['username'] = username
86 session['login_time'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') 206 session['login_time'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
87 session['csrf_token'] = secrets.token_hex(32) 207 session['csrf_token'] = secrets.token_hex(32)
  208 + session['client_info'] = client_info
  209 + session['session_id'] = session_id
88 session.permanent = True 210 session.permanent = True
89 current_app.permanent_session_lifetime = timedelta(hours=2) 211 current_app.permanent_session_lifetime = timedelta(hours=2)
90 212
  213 + # 在Redis中存储会话ID
  214 + redis_client.setex(
  215 + f"session:{username}",
  216 + int(current_app.permanent_session_lifetime.total_seconds()),
  217 + session_id
  218 + )
  219 +
  220 + clear_login_attempts(client_ip)
  221 +
  222 + # 记录登录历史
  223 + login_history_sql = '''
  224 + INSERT INTO login_history
  225 + (username, login_time, ip_address, user_agent, success, attempt_count)
  226 + VALUES (%s, %s, %s, %s, %s, %s)
  227 + '''
  228 + query(login_history_sql, [
  229 + username,
  230 + datetime.now(),
  231 + client_info['ip'],
  232 + client_info['user_agent'],
  233 + True,
  234 + redis_client.get(f"login_attempts:{client_ip}") or 0
  235 + ])
  236 +
91 logging.info(f"用户 {username} 登录成功") 237 logging.info(f"用户 {username} 登录成功")
92 - return redirect('/page/home') 238 + response = make_response(redirect('/page/home'))
  239 + return set_secure_headers(response)
93 240
94 - # 使用相同的响应防止用户枚举 241 + record_failed_attempt(client_ip)
95 logging.warning(f"登录失败:用户名或密码错误") 242 logging.warning(f"登录失败:用户名或密码错误")
96 - return render_template('login_and_register.html', msg='用户名或密码错误') 243 + return errorResponse('用户名或密码错误')
97 244
98 except Exception as e: 245 except Exception as e:
99 logging.error(f"登录过程发生错误: {e}") 246 logging.error(f"登录过程发生错误: {e}")
100 - return render_template('login_and_register.html', msg='登录失败,请稍后重试') 247 + return errorResponse('登录失败,请稍后重试')
101 248
102 @ub.route('/register', methods=['GET', 'POST']) 249 @ub.route('/register', methods=['GET', 'POST'])
  250 +@limiter.limit("3 per hour")
103 def register(): 251 def register():
104 if request.method == 'GET': 252 if request.method == 'GET':
105 - return render_template('login_and_register.html') 253 + response = make_response(render_template('login_and_register.html'))
  254 + return set_secure_headers(response)
106 255
107 try: 256 try:
108 - username = request.form.get('username') 257 + if request.method == 'POST' and not validate_csrf_token():
  258 + logging.warning("CSRF验证失败")
  259 + return errorResponse('无效的请求')
  260 +
  261 + username = sanitize_input(request.form.get('username'))
109 password = request.form.get('password') 262 password = request.form.get('password')
  263 + email = sanitize_input(request.form.get('email'))
110 264
111 - if not username or not password:  
112 - return errorResponse('用户名和密码不能为空') 265 + if not username or not password or not email:
  266 + return errorResponse('用户名、密码和邮箱不能为空')
113 267
114 # 验证用户名格式 268 # 验证用户名格式
115 if not re.match(r'^[a-zA-Z0-9_]{4,20}$', username): 269 if not re.match(r'^[a-zA-Z0-9_]{4,20}$', username):
116 return errorResponse('用户名只能包含字母、数字和下划线,长度4-20位') 270 return errorResponse('用户名只能包含字母、数字和下划线,长度4-20位')
117 271
  272 + # 验证邮箱格式
  273 + if not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', email):
  274 + return errorResponse('邮箱格式不正确')
  275 +
118 # 验证密码强度 276 # 验证密码强度
119 if not validate_password(password): 277 if not validate_password(password):
120 - return errorResponse('密码必须包含大小写字母、数字和特殊字符,且长度至少8位') 278 + return errorResponse('密码必须包含大小写字母、数字和特殊字符,且长度至少12位')
121 279
122 - # 使用事务处理竞态条件  
123 try: 280 try:
124 - # 检查用户名是否存在  
125 - check_sql = "SELECT COUNT(*) as count FROM user WHERE username = %s"  
126 - result = query(check_sql, [username], "select") 281 + # 检查用户名和邮箱是否存在
  282 + check_sql = """
  283 + SELECT
  284 + (SELECT COUNT(*) FROM user WHERE LOWER(username) = LOWER(%s)) as username_count,
  285 + (SELECT COUNT(*) FROM user WHERE LOWER(email) = LOWER(%s)) as email_count
  286 + """
  287 + result = query(check_sql, [username.lower(), email.lower()], "select")
127 288
128 - if result[0]['count'] > 0: 289 + if result[0]['username_count'] > 0:
129 return errorResponse('该用户名已被注册') 290 return errorResponse('该用户名已被注册')
130 291
131 - # 生成密码哈希和盐值  
132 - hashed_password, salt = hash_password(password) 292 + if result[0]['email_count'] > 0:
  293 + return errorResponse('该邮箱已被注册')
  294 +
  295 + # 哈希密码
  296 + hashed_password = hash_password(password)
133 297
134 # 插入新用户 298 # 插入新用户
135 insert_sql = ''' 299 insert_sql = '''
136 - INSERT INTO user(username, password, salt, createTime)  
137 - VALUES(%s, %s, %s, %s) 300 + INSERT INTO user(username, password, email, status, createTime, last_password_change)
  301 + VALUES(%s, %s, %s, %s, %s, %s)
  302 + '''
  303 + current_time = datetime.now()
  304 + query(insert_sql, [
  305 + username,
  306 + hashed_password,
  307 + email,
  308 + 'active',
  309 + current_time,
  310 + current_time
  311 + ])
  312 +
  313 + # 记录注册信息
  314 + client_info = get_client_info()
  315 + register_history_sql = '''
  316 + INSERT INTO register_history
  317 + (username, register_time, ip_address, user_agent, email)
  318 + VALUES (%s, %s, %s, %s, %s)
138 ''' 319 '''
139 - current_time = datetime.now().strftime('%Y-%m-%d')  
140 - query(insert_sql, [username, hashed_password, salt, current_time]) 320 + query(register_history_sql, [
  321 + username,
  322 + current_time,
  323 + client_info['ip'],
  324 + client_info['user_agent'],
  325 + email
  326 + ])
141 327
142 logging.info(f"新用户注册成功: {username}") 328 logging.info(f"新用户注册成功: {username}")
143 - return redirect('/user/login') 329 + response = make_response(redirect('/user/login'))
  330 + return set_secure_headers(response)
144 331
145 except Exception as e: 332 except Exception as e:
146 logging.error(f"注册过程发生错误: {e}") 333 logging.error(f"注册过程发生错误: {e}")
@@ -156,9 +343,30 @@ def logout(): @@ -156,9 +343,30 @@ def logout():
156 """用户登出""" 343 """用户登出"""
157 try: 344 try:
158 username = session.get('username') 345 username = session.get('username')
  346 + client_info = session.get('client_info', {})
  347 +
  348 + # 记录登出历史
  349 + logout_history_sql = '''
  350 + INSERT INTO logout_history
  351 + (username, logout_time, ip_address, user_agent, session_id)
  352 + VALUES (%s, %s, %s, %s, %s)
  353 + '''
  354 + query(logout_history_sql, [
  355 + username,
  356 + datetime.now(),
  357 + client_info.get('ip'),
  358 + client_info.get('user_agent'),
  359 + session.get('session_id')
  360 + ])
  361 +
  362 + # 删除Redis中的会话
  363 + redis_client.delete(f"session:{username}")
  364 +
159 session.clear() 365 session.clear()
160 logging.info(f"用户 {username} 成功登出") 366 logging.info(f"用户 {username} 成功登出")
161 - return redirect('/user/login') 367 + response = make_response(redirect('/user/login'))
  368 + return set_secure_headers(response)
162 except Exception as e: 369 except Exception as e:
163 logging.error(f"登出过程发生错误: {e}") 370 logging.error(f"登出过程发生错误: {e}")
164 - return redirect('/user/login') 371 + response = make_response(redirect('/user/login'))
  372 + return set_secure_headers(response)