baodan/api/insurance/auth/service.py
2026-07-12 14:17:18 +08:00

487 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""认证服务JWT 签发、企微 OAuth、密码校验。"""
import jwt
import bcrypt
import time
import hashlib
import re
import secrets
import requests
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
from insurance.models.email_verification import EmailVerificationCode
from insurance.models.wecom_user import WeComUserMapping
from insurance.utils.email import EmailSendError, send_email
class AuthService:
"""认证业务逻辑。"""
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)
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),
"user": self._user_payload(mapping),
},
}
def register(self, username: str, password: str, email: str, real_name: str, verification_code: str) -> dict:
"""用户注册。"""
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}
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}
# 密码校验
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}
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,
email=email,
real_name=real_name,
email_verified="true",
password_hash=password_hash,
role="client",
status="active",
)
db.session.add(mapping)
try:
db.session.commit()
except Exception:
db.session.rollback()
raise
# 同步到 Dify accounts 表
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),
"user": self._user_payload(mapping),
},
}
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())
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,
"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),
"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()
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}
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")