Comprehensive enhancement of user authentication system security, including pass…
…word storage, session management, XSS/CSRF protection, etc.
Showing
1 changed file
with
257 additions
and
49 deletions
| 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 | |||
| 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) |
-
Please register or login to post a comment