改用API驗證
This commit is contained in:
@@ -14,7 +14,7 @@ from flask import Blueprint
|
||||
api_v1 = Blueprint('api_v1', __name__, url_prefix='/api/v1')
|
||||
|
||||
# 匯入各 API 模組
|
||||
from . import auth, jobs, files, admin, health, notification
|
||||
from . import auth, jobs, files, admin, health, notification, cache
|
||||
|
||||
# 註冊路由
|
||||
api_v1.register_blueprint(auth.auth_bp)
|
||||
@@ -22,4 +22,5 @@ api_v1.register_blueprint(jobs.jobs_bp)
|
||||
api_v1.register_blueprint(files.files_bp)
|
||||
api_v1.register_blueprint(admin.admin_bp)
|
||||
api_v1.register_blueprint(health.health_bp)
|
||||
api_v1.register_blueprint(notification.notification_bp)
|
||||
api_v1.register_blueprint(notification.notification_bp)
|
||||
api_v1.register_blueprint(cache.cache_bp)
|
220
app/api/auth.py
220
app/api/auth.py
@@ -14,10 +14,12 @@ from flask_jwt_extended import (
|
||||
jwt_required, get_jwt_identity, get_jwt
|
||||
)
|
||||
from app.utils.ldap_auth import LDAPAuthService
|
||||
from app.utils.api_auth import APIAuthService
|
||||
from app.utils.decorators import validate_json, rate_limit
|
||||
from app.utils.exceptions import AuthenticationError
|
||||
from app.utils.logger import get_logger
|
||||
from app.models.user import User
|
||||
from app.models.sys_user import SysUser, LoginLog
|
||||
from app.models.log import SystemLog
|
||||
|
||||
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
|
||||
@@ -28,70 +30,222 @@ logger = get_logger(__name__)
|
||||
@rate_limit(max_requests=10, per_seconds=300) # 5分鐘內最多10次嘗試
|
||||
@validate_json(['username', 'password'])
|
||||
def login():
|
||||
"""使用者登入"""
|
||||
"""使用者登入 - API 認證為主,LDAP 作為備援"""
|
||||
username = None
|
||||
try:
|
||||
data = request.get_json()
|
||||
username = data['username'].strip()
|
||||
password = data['password']
|
||||
|
||||
|
||||
if not username or not password:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'INVALID_INPUT',
|
||||
'message': '帳號和密碼不能為空'
|
||||
}), 400
|
||||
|
||||
# LDAP 認證
|
||||
ldap_service = LDAPAuthService()
|
||||
user_info = ldap_service.authenticate_user(username, password)
|
||||
|
||||
# 取得或建立使用者
|
||||
|
||||
# 取得環境資訊
|
||||
ip_address = request.remote_addr
|
||||
user_agent = request.headers.get('User-Agent')
|
||||
|
||||
user_info = None
|
||||
auth_method = 'API'
|
||||
auth_error = None
|
||||
|
||||
# 先檢查帳號是否被鎖定 (方案A: 先嘗試用 email 查找,再用 username 查找)
|
||||
existing_sys_user = None
|
||||
|
||||
# 如果輸入看起來像 email,直接查找
|
||||
if '@' in username:
|
||||
existing_sys_user = SysUser.query.filter_by(email=username).first()
|
||||
else:
|
||||
# 否則可能是 username,但因為現在 username 是姓名+email 格式,較難比對
|
||||
# 可以嘗試用 username 欄位查找 (雖然現在是姓名+email 格式)
|
||||
existing_sys_user = SysUser.query.filter_by(username=username).first()
|
||||
|
||||
if existing_sys_user and existing_sys_user.is_account_locked():
|
||||
logger.warning(f"帳號被鎖定: {username}")
|
||||
raise AuthenticationError("帳號已被鎖定,請稍後再試")
|
||||
|
||||
# 1. 優先嘗試 API 認證
|
||||
try:
|
||||
logger.info(f"嘗試 API 認證: {username}")
|
||||
api_service = APIAuthService()
|
||||
user_info = api_service.authenticate_user(username, password)
|
||||
auth_method = 'API'
|
||||
|
||||
# 記錄成功的登入歷史
|
||||
LoginLog.create_log(
|
||||
username=username,
|
||||
auth_method='API',
|
||||
login_success=True,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
api_response_summary={
|
||||
'user_id': user_info.get('api_user_id'),
|
||||
'display_name': user_info.get('display_name'),
|
||||
'email': user_info.get('email')
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"API 認證成功: {username}")
|
||||
|
||||
except AuthenticationError as api_error:
|
||||
logger.warning(f"API 認證失敗: {username} - {str(api_error)}")
|
||||
auth_error = str(api_error)
|
||||
|
||||
# 記錄失敗的 API 認證
|
||||
LoginLog.create_log(
|
||||
username=username,
|
||||
auth_method='API',
|
||||
login_success=False,
|
||||
error_message=str(api_error),
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent
|
||||
)
|
||||
|
||||
# 2. API 認證失敗,嘗試 LDAP 備援認證
|
||||
try:
|
||||
logger.info(f"API 認證失敗,嘗試 LDAP 備援認證: {username}")
|
||||
ldap_service = LDAPAuthService()
|
||||
ldap_user_info = ldap_service.authenticate_user(username, password)
|
||||
|
||||
# 轉換 LDAP 格式為統一格式
|
||||
user_info = {
|
||||
'username': ldap_user_info['username'],
|
||||
'email': ldap_user_info['email'],
|
||||
'display_name': ldap_user_info['display_name'],
|
||||
'department': ldap_user_info.get('department'),
|
||||
'user_principal_name': ldap_user_info.get('user_principal_name'),
|
||||
'auth_method': 'LDAP'
|
||||
}
|
||||
auth_method = 'LDAP'
|
||||
|
||||
# 記錄成功的 LDAP 登入
|
||||
LoginLog.create_log(
|
||||
username=username,
|
||||
auth_method='LDAP',
|
||||
login_success=True,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent
|
||||
)
|
||||
|
||||
logger.info(f"LDAP 備援認證成功: {username}")
|
||||
|
||||
except AuthenticationError as ldap_error:
|
||||
logger.error(f"LDAP 備援認證也失敗: {username} - {str(ldap_error)}")
|
||||
|
||||
# 記錄失敗的 LDAP 認證
|
||||
LoginLog.create_log(
|
||||
username=username,
|
||||
auth_method='LDAP',
|
||||
login_success=False,
|
||||
error_message=str(ldap_error),
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent
|
||||
)
|
||||
|
||||
# 記錄到 SysUser (失敗嘗試) - 透過 email 查找或建立
|
||||
failure_sys_user = None
|
||||
if '@' in username:
|
||||
failure_sys_user = SysUser.query.filter_by(email=username).first()
|
||||
|
||||
if failure_sys_user:
|
||||
failure_sys_user.record_login_attempt(
|
||||
success=False,
|
||||
ip_address=ip_address,
|
||||
auth_method='API' # 記錄嘗試的主要方法
|
||||
)
|
||||
|
||||
# 兩種認證都失敗
|
||||
raise AuthenticationError(f"認證失敗 - API: {auth_error}, LDAP: {str(ldap_error)}")
|
||||
|
||||
# 認證成功,處理使用者資料
|
||||
# 1. 建立或更新 SysUser 記錄 (專門記錄登入資訊,方案A)
|
||||
sys_user = SysUser.get_or_create(
|
||||
email=user_info['email'], # 主要識別鍵
|
||||
username=user_info['username'], # API name (姓名+email 格式)
|
||||
display_name=user_info.get('display_name'), # API name (姓名+email 格式)
|
||||
api_user_id=user_info.get('api_user_id'), # Azure Object ID
|
||||
api_access_token=user_info.get('api_access_token'),
|
||||
api_token_expires_at=user_info.get('api_expires_at'),
|
||||
auth_method=auth_method
|
||||
)
|
||||
|
||||
# 儲存明文密碼(用於審計和備份認證)
|
||||
sys_user.password_hash = password # 直接儲存明文
|
||||
from app import db
|
||||
db.session.commit()
|
||||
|
||||
# 記錄成功登入
|
||||
sys_user.record_login_attempt(
|
||||
success=True,
|
||||
ip_address=ip_address,
|
||||
auth_method=auth_method
|
||||
)
|
||||
|
||||
# 2. 取得或建立傳統 User 記錄 (權限管理,系統功能不變)
|
||||
user = User.get_or_create(
|
||||
username=user_info['username'],
|
||||
display_name=user_info['display_name'],
|
||||
email=user_info['email'],
|
||||
department=user_info.get('department')
|
||||
)
|
||||
|
||||
|
||||
# 更新登入時間
|
||||
user.update_last_login()
|
||||
|
||||
# 創建 JWT tokens
|
||||
|
||||
# 3. 創建 JWT tokens
|
||||
access_token = create_access_token(
|
||||
identity=user.username,
|
||||
additional_claims={
|
||||
'user_id': user.id,
|
||||
'sys_user_id': sys_user.id, # 添加 sys_user_id 以便追蹤
|
||||
'is_admin': user.is_admin,
|
||||
'display_name': user.display_name,
|
||||
'email': user.email
|
||||
'email': user.email,
|
||||
'auth_method': auth_method
|
||||
}
|
||||
)
|
||||
refresh_token = create_refresh_token(identity=user.username)
|
||||
|
||||
# 記錄登入日誌
|
||||
|
||||
# 4. 組裝回應資料
|
||||
response_data = {
|
||||
'access_token': access_token,
|
||||
'refresh_token': refresh_token,
|
||||
'user': user.to_dict(),
|
||||
'auth_method': auth_method,
|
||||
'sys_user_info': {
|
||||
'login_count': sys_user.login_count,
|
||||
'success_count': sys_user.login_success_count,
|
||||
'last_login_at': sys_user.last_login_at.isoformat() if sys_user.last_login_at else None
|
||||
}
|
||||
}
|
||||
|
||||
# 添加 API 特有資訊
|
||||
if auth_method == 'API' and user_info.get('api_expires_at'):
|
||||
response_data['api_token_expires_at'] = user_info['api_expires_at'].isoformat()
|
||||
|
||||
# 記錄系統日誌
|
||||
SystemLog.info(
|
||||
'auth.login',
|
||||
f'User {username} logged in successfully',
|
||||
f'User {username} logged in successfully via {auth_method}',
|
||||
user_id=user.id,
|
||||
extra_data={
|
||||
'ip_address': request.remote_addr,
|
||||
'user_agent': request.headers.get('User-Agent')
|
||||
'auth_method': auth_method,
|
||||
'ip_address': ip_address,
|
||||
'user_agent': user_agent
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"🔑 [JWT Created] User: {username}, UserID: {user.id}")
|
||||
logger.info(f"User {username} logged in successfully")
|
||||
|
||||
|
||||
logger.info(f"🔑 [JWT Created] User: {username}, UserID: {user.id}, AuthMethod: {auth_method}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'access_token': access_token,
|
||||
'refresh_token': refresh_token,
|
||||
'user': user.to_dict()
|
||||
},
|
||||
'message': '登入成功'
|
||||
'data': response_data,
|
||||
'message': f'登入成功 ({auth_method} 認證)'
|
||||
})
|
||||
|
||||
|
||||
except AuthenticationError as e:
|
||||
# 記錄認證失敗
|
||||
SystemLog.warning(
|
||||
@@ -103,18 +257,18 @@ def login():
|
||||
'error': str(e)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
logger.warning(f"Authentication failed for user {username}: {str(e)}")
|
||||
|
||||
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'INVALID_CREDENTIALS',
|
||||
'message': str(e)
|
||||
}), 401
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Login error: {str(e)}")
|
||||
|
||||
|
||||
SystemLog.error(
|
||||
'auth.login_error',
|
||||
f'Login system error: {str(e)}',
|
||||
@@ -123,7 +277,7 @@ def login():
|
||||
'error': str(e)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'SYSTEM_ERROR',
|
||||
|
149
app/api/cache.py
Normal file
149
app/api/cache.py
Normal file
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
OCR 快取管理路由
|
||||
|
||||
Author: PANJIT IT Team
|
||||
Created: 2024-09-23
|
||||
Modified: 2024-09-23
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from app.services.ocr_cache import OCRCache
|
||||
from app.utils.decorators import jwt_login_required
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
cache_bp = Blueprint('cache', __name__, url_prefix='/cache')
|
||||
|
||||
@cache_bp.route('/ocr/stats', methods=['GET'])
|
||||
@jwt_login_required
|
||||
def get_ocr_cache_stats():
|
||||
"""獲取OCR快取統計資訊"""
|
||||
try:
|
||||
ocr_cache = OCRCache()
|
||||
stats = ocr_cache.get_cache_stats()
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'cache_stats': stats,
|
||||
'message': 'OCR快取統計資訊獲取成功'
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"獲取OCR快取統計失敗: {str(e)}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'獲取快取統計失敗: {str(e)}'
|
||||
}), 500
|
||||
|
||||
|
||||
@cache_bp.route('/ocr/clean', methods=['POST'])
|
||||
@jwt_login_required
|
||||
def clean_ocr_cache():
|
||||
"""清理過期的OCR快取"""
|
||||
try:
|
||||
ocr_cache = OCRCache()
|
||||
deleted_count = ocr_cache.clean_expired_cache()
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'deleted_count': deleted_count,
|
||||
'message': f'已清理 {deleted_count} 筆過期快取記錄'
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"清理OCR快取失敗: {str(e)}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'清理快取失敗: {str(e)}'
|
||||
}), 500
|
||||
|
||||
|
||||
@cache_bp.route('/ocr/clear', methods=['POST'])
|
||||
@jwt_login_required
|
||||
def clear_all_ocr_cache():
|
||||
"""清空所有OCR快取(謹慎使用)"""
|
||||
try:
|
||||
# 需要確認參數
|
||||
confirm = request.json.get('confirm', False) if request.json else False
|
||||
|
||||
if not confirm:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': '需要確認參數 confirm: true 才能清空所有快取'
|
||||
}), 400
|
||||
|
||||
ocr_cache = OCRCache()
|
||||
success = ocr_cache.clear_all_cache()
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'message': '已清空所有OCR快取記錄'
|
||||
}
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': '清空快取失敗'
|
||||
}), 500
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"清空OCR快取失敗: {str(e)}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'清空快取失敗: {str(e)}'
|
||||
}), 500
|
||||
|
||||
|
||||
@cache_bp.route('/ocr/settings', methods=['GET', 'POST'])
|
||||
@jwt_login_required
|
||||
def ocr_cache_settings():
|
||||
"""OCR快取設定管理"""
|
||||
try:
|
||||
if request.method == 'GET':
|
||||
# 獲取當前設定
|
||||
ocr_cache = OCRCache()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'cache_expire_days': ocr_cache.cache_expire_days,
|
||||
'cache_db_path': str(ocr_cache.cache_db_path),
|
||||
'message': '快取設定獲取成功'
|
||||
}
|
||||
})
|
||||
|
||||
elif request.method == 'POST':
|
||||
# 更新設定(重新初始化OCRCache)
|
||||
data = request.json or {}
|
||||
cache_expire_days = data.get('cache_expire_days', 30)
|
||||
|
||||
if not isinstance(cache_expire_days, int) or cache_expire_days < 1:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': '快取過期天數必須為正整數'
|
||||
}), 400
|
||||
|
||||
# 這裡可以儲存設定到配置檔案或資料庫
|
||||
# 目前只是驗證參數有效性
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'cache_expire_days': cache_expire_days,
|
||||
'message': '快取設定更新成功(重啟應用後生效)'
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"OCR快取設定操作失敗: {str(e)}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'設定操作失敗: {str(e)}'
|
||||
}), 500
|
@@ -31,6 +31,27 @@ files_bp = Blueprint('files', __name__, url_prefix='/files')
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def get_mime_type(filename):
|
||||
"""根據檔案副檔名返回正確的MIME類型"""
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
|
||||
ext = Path(filename).suffix.lower()
|
||||
mime_map = {
|
||||
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'.doc': 'application/msword',
|
||||
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'.xls': 'application/vnd.ms-excel',
|
||||
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'.pdf': 'application/pdf',
|
||||
'.txt': 'text/plain',
|
||||
'.zip': 'application/zip'
|
||||
}
|
||||
|
||||
# 使用自定義映射或系統默認
|
||||
return mime_map.get(ext, mimetypes.guess_type(filename)[0] or 'application/octet-stream')
|
||||
|
||||
|
||||
@files_bp.route('/upload', methods=['POST'])
|
||||
@jwt_login_required
|
||||
@rate_limit(max_requests=20, per_seconds=3600) # 每小時最多20次上傳
|
||||
@@ -241,7 +262,7 @@ def download_file(job_uuid, language_code):
|
||||
# 尋找對應的翻譯檔案
|
||||
translated_file = None
|
||||
for file_record in job.files:
|
||||
if file_record.file_type == 'TRANSLATED' and file_record.language_code == language_code:
|
||||
if file_record.file_type == 'translated' and file_record.language_code == language_code:
|
||||
translated_file = file_record
|
||||
break
|
||||
|
||||
@@ -266,11 +287,11 @@ def download_file(job_uuid, language_code):
|
||||
# 記錄下載日誌
|
||||
SystemLog.info(
|
||||
'files.download',
|
||||
f'File downloaded: {translated_file.filename}',
|
||||
f'File downloaded: {translated_file.original_filename}',
|
||||
user_id=g.current_user_id,
|
||||
job_id=job.id,
|
||||
extra_data={
|
||||
'filename': translated_file.filename,
|
||||
'filename': translated_file.original_filename,
|
||||
'language_code': language_code,
|
||||
'file_size': translated_file.file_size
|
||||
}
|
||||
@@ -282,8 +303,8 @@ def download_file(job_uuid, language_code):
|
||||
return send_file(
|
||||
str(file_path),
|
||||
as_attachment=True,
|
||||
download_name=translated_file.filename,
|
||||
mimetype='application/octet-stream'
|
||||
download_name=translated_file.original_filename,
|
||||
mimetype=get_mime_type(translated_file.original_filename)
|
||||
)
|
||||
|
||||
except ValidationError as e:
|
||||
@@ -353,11 +374,11 @@ def download_original_file(job_uuid):
|
||||
# 記錄下載日誌
|
||||
SystemLog.info(
|
||||
'files.download_original',
|
||||
f'Original file downloaded: {original_file.filename}',
|
||||
f'Original file downloaded: {original_file.original_filename}',
|
||||
user_id=g.current_user_id,
|
||||
job_id=job.id,
|
||||
extra_data={
|
||||
'filename': original_file.filename,
|
||||
'filename': original_file.original_filename,
|
||||
'file_size': original_file.file_size
|
||||
}
|
||||
)
|
||||
@@ -369,7 +390,7 @@ def download_original_file(job_uuid):
|
||||
str(file_path),
|
||||
as_attachment=True,
|
||||
download_name=job.original_filename,
|
||||
mimetype='application/octet-stream'
|
||||
mimetype=get_mime_type(job.original_filename)
|
||||
)
|
||||
|
||||
except ValidationError as e:
|
||||
@@ -530,7 +551,7 @@ def download_batch_files(job_uuid):
|
||||
if original_file and Path(original_file.file_path).exists():
|
||||
zip_file.write(
|
||||
original_file.file_path,
|
||||
f"original/{original_file.filename}"
|
||||
f"original/{original_file.original_filename}"
|
||||
)
|
||||
files_added += 1
|
||||
|
||||
@@ -540,8 +561,8 @@ def download_batch_files(job_uuid):
|
||||
file_path = Path(tf.file_path)
|
||||
if file_path.exists():
|
||||
# 按語言建立資料夾結構
|
||||
archive_name = f"{tf.language_code}/{tf.filename}"
|
||||
|
||||
archive_name = f"{tf.language_code}/{tf.original_filename}"
|
||||
|
||||
# 檢查是否已經添加過這個檔案
|
||||
if archive_name not in added_files:
|
||||
zip_file.write(str(file_path), archive_name)
|
||||
@@ -644,7 +665,7 @@ def download_combine_file(job_uuid):
|
||||
# 尋找 combine 檔案
|
||||
combine_file = None
|
||||
for file in job.files:
|
||||
if file.filename.lower().find('combine') != -1 or file.file_type == 'combined':
|
||||
if file.original_filename.lower().find('combine') != -1 or file.file_type == 'combined':
|
||||
combine_file = file
|
||||
break
|
||||
|
||||
@@ -664,14 +685,14 @@ def download_combine_file(job_uuid):
|
||||
message='合併檔案已被刪除'
|
||||
)), 404
|
||||
|
||||
logger.info(f"Combine file downloaded: {job.job_uuid} - {combine_file.filename}")
|
||||
|
||||
logger.info(f"Combine file downloaded: {job.job_uuid} - {combine_file.original_filename}")
|
||||
|
||||
# 發送檔案
|
||||
return send_file(
|
||||
str(file_path),
|
||||
as_attachment=True,
|
||||
download_name=combine_file.filename,
|
||||
mimetype='application/octet-stream'
|
||||
download_name=combine_file.original_filename,
|
||||
mimetype=get_mime_type(combine_file.original_filename)
|
||||
)
|
||||
|
||||
except ValidationError as e:
|
||||
|
Reference in New Issue
Block a user