baodan/api/insurance/auth/service.py

487 lines
20 KiB
Python
Raw Normal View History

"""认证服务JWT 签发、企微 OAuth、密码校验。"""
import jwt
import bcrypt
import time
2026-07-12 14:17:18 +08:00
import hashlib
import re
import secrets
import requests
2026-07-12 14:17:18 +08:00
from datetime import datetime, timedelta
from flask import current_app
from insurance.config import get_config as _get_wecom_config
from insurance.db.compat import db
2026-07-12 14:17:18 +08:00
from insurance.models.email_verification import EmailVerificationCode
from insurance.models.wecom_user import WeComUserMapping
2026-07-12 14:17:18 +08:00
from insurance.utils.email import EmailSendError, send_email
class AuthService:
"""认证业务逻辑。"""
2026-07-12 14:17:18 +08:00
CODE_EXPIRE_MINUTES = 10
CODE_RESEND_SECONDS = 60
CODE_MAX_ATTEMPTS = 5
def _normalize_email(self, email: str) -> str:
return (email or "").strip().lower()
def _validate_email(self, email: str) -> bool:
return bool(re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", email or ""))
def _validate_password(self, password: str) -> str:
if not password or len(password) < 8 or len(password) > 128:
return "密码长度需在 8-128 之间"
if not any(c.isalpha() for c in password) or not any(c.isdigit() for c in password):
return "密码需包含字母和数字"
return ""
def _hash_code(self, email: str, purpose: str, code: str) -> str:
secret = current_app.config.get("JWT_SECRET", "change-this")
raw = f"{email}:{purpose}:{code}:{secret}"
return hashlib.sha256(raw.encode()).hexdigest()
def _user_payload(self, mapping: WeComUserMapping) -> dict:
return {
"id": str(mapping.id),
"username": mapping.username,
"email": mapping.email,
"real_name": mapping.real_name,
"wecom_userid": mapping.wecom_userid,
"role": mapping.role,
"department": mapping.department or "",
}
def send_email_code(self, email: str, purpose: str, ip: str = "") -> dict:
"""发送注册或找回密码验证码。"""
email = self._normalize_email(email)
if purpose not in ("register", "reset_password"):
return {"code": 1001, "message": "验证码用途无效", "data": None}
if not self._validate_email(email):
return {"code": 1001, "message": "邮箱格式不正确", "data": None}
if purpose == "register":
existing = db.session.query(WeComUserMapping).filter_by(email=email).first()
if existing:
return {"code": 1001, "message": "邮箱已被注册", "data": None}
elif not db.session.query(WeComUserMapping).filter_by(email=email).first():
return {"code": 0, "message": "验证码已发送", "data": None}
now = datetime.utcnow()
latest = db.session.query(EmailVerificationCode).filter_by(
email=email,
purpose=purpose,
).order_by(EmailVerificationCode.created_at.desc()).first()
if latest and latest.created_at and latest.created_at > now - timedelta(seconds=self.CODE_RESEND_SECONDS):
return {"code": 1001, "message": "验证码发送过于频繁,请稍后再试", "data": None}
code = f"{secrets.randbelow(1000000):06d}"
record = EmailVerificationCode(
email=email,
purpose=purpose,
code_hash=self._hash_code(email, purpose, code),
expires_at=now + timedelta(minutes=self.CODE_EXPIRE_MINUTES),
ip=ip,
)
subject = "保险智能客服系统验证码"
action = "注册账号" if purpose == "register" else "重置密码"
content = (
f"您正在{action},验证码为:{code}\n\n"
f"验证码 {self.CODE_EXPIRE_MINUTES} 分钟内有效。"
)
db.session.add(record)
try:
db.session.flush()
if not send_email(email, subject, content):
db.session.rollback()
return {"code": 5001, "message": "邮件服务未配置,请联系管理员", "data": None}
except EmailSendError as e:
db.session.rollback()
current_app.logger.warning("发送邮箱验证码失败: %s", e)
return {"code": 5001, "message": e.user_message, "data": None}
except Exception:
db.session.rollback()
current_app.logger.exception("发送邮箱验证码失败")
return {"code": 5001, "message": "验证码发送失败,请稍后重试", "data": None}
try:
db.session.commit()
except Exception:
db.session.rollback()
raise
return {"code": 0, "message": "验证码已发送", "data": None}
def _verify_email_code(self, email: str, purpose: str, code: str, commit_success: bool = True) -> tuple[bool, str]:
email = self._normalize_email(email)
code = (code or "").strip()
if not code:
return False, "请输入邮箱验证码"
record = db.session.query(EmailVerificationCode).filter_by(
email=email,
purpose=purpose,
consumed_at=None,
).order_by(EmailVerificationCode.created_at.desc()).first()
now = datetime.utcnow()
if not record or record.expires_at < now:
return False, "验证码已过期,请重新获取"
if (record.attempts or 0) >= self.CODE_MAX_ATTEMPTS:
return False, "验证码错误次数过多,请重新获取"
record.attempts = (record.attempts or 0) + 1
if record.code_hash != self._hash_code(email, purpose, code):
db.session.commit()
return False, "验证码错误"
record.consumed_at = now
if commit_success:
db.session.commit()
return True, ""
def wework_login(self, code: str, state: str) -> dict:
"""企微 OAuth 登录code 换 token -> 获取用户信息 -> 签发 JWT。"""
corp_id = _get_wecom_config("WECOM_CORP_ID")
secret = _get_wecom_config("WECOM_SECRET")
# 1. 用 code 换取企微 access_token
token_url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
resp = requests.get(token_url, params={"corpid": corp_id, "corpsecret": secret})
token_data = resp.json()
if token_data.get("errcode") != 0:
return {"code": 3001, "message": "企微 API 调用失败", "data": None}
access_token = token_data["access_token"]
# 2. 用 code 换取 userid标准企微 OAuth 流程)
getuserinfo_url = "https://qyapi.weixin.qq.com/cgi-bin/user/getuserinfo"
resp = requests.get(getuserinfo_url, params={"access_token": access_token, "code": code})
user_info = resp.json()
if user_info.get("errcode") != 0:
return {"code": 3001, "message": "获取企微用户信息失败", "data": None}
wecom_userid = user_info.get("userid", "")
# 3. 用 userid 获取详细用户信息
user_detail_url = "https://qyapi.weixin.qq.com/cgi-bin/user/get"
resp = requests.get(user_detail_url, params={"access_token": access_token, "userid": wecom_userid})
user_data = resp.json()
if user_data.get("errcode") != 0:
return {"code": 3001, "message": "获取企微用户详情失败", "data": None}
username = user_data.get("name", "")
department = user_data.get("department", [""])[0] if user_data.get("department") else ""
# 4. 查找或创建用户映射
mapping = db.session.query(WeComUserMapping).filter_by(wecom_userid=wecom_userid).first()
if not mapping:
mapping = WeComUserMapping(
wecom_userid=wecom_userid,
internal_user_id=f"wecom_{wecom_userid}",
username=username,
department=str(department),
role="sales",
)
db.session.add(mapping)
2026-07-12 14:17:18 +08:00
try:
db.session.commit()
except Exception:
db.session.rollback()
raise
# 4. 签发 JWT
token = self._generate_token(mapping.id, mapping.role, mapping.department or "")
return {
"code": 0,
"data": {
"token": token,
"expires_in": current_app.config.get("JWT_EXPIRE_SECONDS", 7200),
2026-07-12 14:17:18 +08:00
"user": self._user_payload(mapping),
},
}
2026-07-12 14:17:18 +08:00
def register(self, username: str, password: str, email: str, real_name: str, verification_code: str) -> dict:
"""用户注册。"""
2026-07-12 14:17:18 +08:00
email = self._normalize_email(email)
real_name = (real_name or "").strip()
# 用户名校验
if not username or len(username) < 3 or len(username) > 64:
return {"code": 1001, "message": "用户名长度需在 3-64 之间", "data": None}
2026-07-12 14:17:18 +08:00
if not self._validate_email(email):
return {"code": 1001, "message": "邮箱格式不正确", "data": None}
if not real_name or len(real_name) > 128:
return {"code": 1001, "message": "请输入真实姓名", "data": None}
# 密码校验
2026-07-12 14:17:18 +08:00
password_error = self._validate_password(password)
if password_error:
return {"code": 1001, "message": password_error, "data": None}
# 检查用户名是否已存在
existing = db.session.query(WeComUserMapping).filter_by(username=username).first()
if existing:
return {"code": 1001, "message": "用户名已存在", "data": None}
2026-07-12 14:17:18 +08:00
existing_email = db.session.query(WeComUserMapping).filter_by(email=email).first()
if existing_email:
return {"code": 1001, "message": "邮箱已被注册", "data": None}
ok, message = self._verify_email_code(email, "register", verification_code, commit_success=False)
if not ok:
return {"code": 1001, "message": message, "data": None}
# 生成密码哈希
password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
# 创建用户
import uuid
mapping = WeComUserMapping(
wecom_userid=f"local_{uuid.uuid4().hex[:8]}",
internal_user_id=f"user_{uuid.uuid4().hex[:8]}",
username=username,
2026-07-12 14:17:18 +08:00
email=email,
real_name=real_name,
email_verified="true",
password_hash=password_hash,
role="client",
status="active",
)
db.session.add(mapping)
2026-07-12 14:17:18 +08:00
try:
db.session.commit()
except Exception:
db.session.rollback()
raise
# 同步到 Dify accounts 表
2026-07-12 14:17:18 +08:00
self._sync_to_dify(username, password, mapping.id, email)
# 签发 JWT
token = self._generate_token(mapping.id, mapping.role)
from insurance.utils.audit import log_operation
log_operation(str(mapping.id), "register", "user", str(mapping.id))
return {
"code": 0,
"data": {
"token": token,
"expires_in": current_app.config.get("JWT_EXPIRE_SECONDS", 7200),
2026-07-12 14:17:18 +08:00
"user": self._user_payload(mapping),
},
}
2026-07-12 14:17:18 +08:00
def _sync_to_dify(self, username: str, password: str, local_user_id: int, email: str = "") -> bool:
"""同步用户到 Dify accounts 表。"""
try:
from insurance.db.compat import db
import uuid as uuid_mod
from datetime import datetime, timezone
# 检查 Dify 中是否已存在同名用户
result = db.session.execute(
db.text("SELECT id FROM accounts WHERE name = :name"),
{"name": username}
)
existing = result.fetchone()
if existing:
# 已存在,记录映射关系
return True
# 创建 Dify 账号
account_id = str(uuid_mod.uuid4())
2026-07-12 14:17:18 +08:00
account_email = email or f"{username}@baodan.local"
now = datetime.now(timezone.utc)
# Dify 期望密码已经是 bcrypt 哈希后的值
password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
# 插入 accounts 表
db.session.execute(
db.text("""
INSERT INTO accounts (id, name, email, password, avatar, interface_language,
interface_theme, timezone, last_login_at, last_active_at,
status, created_at, updated_at)
VALUES (:id, :name, :email, :password, '', 'zh-Hans', 'light', 'Asia/Shanghai',
:now, :now, 'active', :now, :now)
"""),
{
"id": account_id,
"name": username,
2026-07-12 14:17:18 +08:00
"email": account_email,
"password": password_hash,
"now": now,
}
)
# 获取默认租户
tenant_result = db.session.execute(
db.text("SELECT id FROM tenants LIMIT 1")
)
tenant = tenant_result.fetchone()
if tenant:
# 关联到租户
db.session.execute(
db.text("""
INSERT INTO tenant_account_joins (id, tenant_id, account_id, role,
invited_by, created_at, updated_at)
VALUES (:id, :tenant_id, :account_id, 'normal', NULL, :now, :now)
"""),
{
"id": str(uuid_mod.uuid4()),
"tenant_id": str(tenant[0]),
"account_id": account_id,
"now": now,
}
)
db.session.commit()
return True
except Exception as e:
# 同步失败不影响注册
print(f"同步到 Dify 失败: {e}")
db.session.rollback()
return False
def password_login(self, username: str, password: str) -> dict:
"""账密登录bcrypt 校验,返回 JWT。"""
mapping = db.session.query(WeComUserMapping).filter_by(username=username).first()
if not mapping or not mapping.password_hash:
return {"code": 1002, "message": "用户名或密码错误", "data": None}
if not bcrypt.checkpw(password.encode(), mapping.password_hash.encode()):
return {"code": 1002, "message": "用户名或密码错误", "data": None}
if mapping.status != "active":
return {"code": 1002, "message": "账号已停用", "data": None}
token = self._generate_token(mapping.id, mapping.role, mapping.department or "")
from insurance.utils.audit import log_operation
log_operation(str(mapping.id), "login", "user", str(mapping.id))
return {
"code": 0,
"data": {
"token": token,
"expires_in": current_app.config.get("JWT_EXPIRE_SECONDS", 7200),
2026-07-12 14:17:18 +08:00
"user": self._user_payload(mapping),
},
}
def refresh_token(self, user_id: str) -> dict:
"""刷新 Token签发新的 JWT。"""
mapping = db.session.query(WeComUserMapping).filter_by(id=int(user_id)).first()
if not mapping:
return {"code": 1002, "message": "用户不存在", "data": None}
token = self._generate_token(mapping.id, mapping.role, mapping.department or "")
return {
"code": 0,
"data": {
"token": token,
"expires_in": current_app.config.get("JWT_EXPIRE_SECONDS", 7200),
},
}
def change_password(self, user_id: str, old_password: str, new_password: str) -> dict:
"""修改密码。"""
mapping = db.session.query(WeComUserMapping).filter_by(id=int(user_id)).first()
if not mapping:
return {"code": 1005, "message": "用户不存在", "data": None}
if not mapping.password_hash:
return {"code": 1002, "message": "该账号无密码,请使用企微登录", "data": None}
if not bcrypt.checkpw(old_password.encode(), mapping.password_hash.encode()):
return {"code": 1002, "message": "旧密码错误", "data": None}
mapping.password_hash = bcrypt.hashpw(new_password.encode(), bcrypt.gensalt()).decode()
2026-07-12 14:17:18 +08:00
try:
db.session.commit()
except Exception:
db.session.rollback()
raise
self._sync_password_to_dify(mapping.username, new_password)
from insurance.utils.audit import log_operation
log_operation(user_id, "change_password", "user", user_id)
return {"code": 0, "message": "密码修改成功", "data": None}
2026-07-12 14:17:18 +08:00
def reset_password(self, email: str, verification_code: str, new_password: str) -> dict:
"""通过邮箱验证码重置密码。"""
email = self._normalize_email(email)
password_error = self._validate_password(new_password)
if password_error:
return {"code": 1001, "message": password_error, "data": None}
ok, message = self._verify_email_code(email, "reset_password", verification_code, commit_success=False)
if not ok:
return {"code": 1001, "message": message, "data": None}
mapping = db.session.query(WeComUserMapping).filter_by(email=email).first()
if not mapping:
return {"code": 1005, "message": "用户不存在", "data": None}
mapping.password_hash = bcrypt.hashpw(new_password.encode(), bcrypt.gensalt()).decode()
mapping.email_verified = "true"
try:
db.session.commit()
except Exception:
db.session.rollback()
raise
self._sync_password_to_dify(mapping.username, new_password)
from insurance.utils.audit import log_operation
log_operation(str(mapping.id), "reset_password", "user", str(mapping.id))
return {"code": 0, "message": "密码重置成功", "data": None}
def _sync_password_to_dify(self, username: str, password: str) -> bool:
"""同步本地密码到 Dify accounts 表。"""
try:
password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
db.session.execute(
db.text("UPDATE accounts SET password = :password, updated_at = :updated_at WHERE name = :name"),
{"password": password_hash, "updated_at": datetime.utcnow(), "name": username}
)
db.session.commit()
return True
except Exception as e:
print(f"同步密码到 Dify 失败: {e}")
db.session.rollback()
return False
def logout(self, token: str, user_id: str) -> dict:
"""吊销 Token加入 Redis 黑名单。"""
try:
from insurance.db.compat import redis_client
expire = current_app.config.get("JWT_EXPIRE_SECONDS", 7200)
redis_client.setex(f"token:blacklist:{token}", expire, "1")
except Exception:
pass # Redis 不可用时仍允许 logout
from insurance.utils.audit import log_operation
log_operation(user_id, "logout", "user", user_id)
return {"code": 0, "message": "success", "data": None}
def _generate_token(self, user_id: int, role: str, department: str = "") -> str:
"""生成 JWT Token。"""
secret = current_app.config.get("JWT_SECRET", "change-this")
expire = int(current_app.config.get("JWT_EXPIRE_SECONDS", 7200))
payload = {
"user_id": str(user_id),
"role": role,
"department": department,
"exp": int(time.time()) + expire,
"iat": int(time.time()),
}
return jwt.encode(payload, secret, algorithm="HS256")