auth_routes.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476
  1. import os
  2. import json
  3. import uuid
  4. import logging
  5. from typing import Dict
  6. import requests
  7. from flask import Blueprint, request, jsonify, session, make_response, current_app, render_template, redirect, url_for
  8. from http import HTTPStatus
  9. # 导入数据库模型
  10. from models import get_or_create_user, update_user_login_info, User, db
  11. # 认证蓝图
  12. auth_bp = Blueprint('auth', __name__, url_prefix='/api/auth')
  13. logger = logging.getLogger(__name__)
  14. def get_dt_config() -> Dict[str, str]:
  15. """获取钉钉配置"""
  16. return {
  17. 'client_id': os.getenv('DINGTALK_CLIENT_ID', ''),
  18. 'client_secret': os.getenv('DINGTALK_CLIENT_SECRET', ''),
  19. 'redirect_uri': os.getenv('DINGTALK_REDIRECT_URI', '')
  20. }
  21. def dt_enabled() -> bool:
  22. """检查钉钉是否已配置"""
  23. cfg = get_dt_config()
  24. return all([cfg['client_id'], cfg['client_secret'], cfg['redirect_uri']])
  25. def check_database_connection() -> bool:
  26. """检查数据库连接是否正常"""
  27. try:
  28. with current_app.app_context():
  29. # 尝试执行一个简单的查询
  30. from sqlalchemy import text
  31. db.session.execute(text('SELECT 1'))
  32. return True
  33. except Exception as e:
  34. logger.error(f'数据库连接检查失败: {e}')
  35. return False
  36. @auth_bp.route('/ping', methods=['GET'])
  37. def ping():
  38. """健康检查接口"""
  39. return jsonify({
  40. 'success': True,
  41. 'message': 'auth ok',
  42. 'dingtalk_enabled': dt_enabled()
  43. })
  44. @auth_bp.route('/login_error')
  45. def login_error():
  46. """登录错误页面"""
  47. error_type = request.args.get('error', 'unknown')
  48. error_message = request.args.get('message', '')
  49. error_code = request.args.get('code', '')
  50. return render_template('login_error.html',
  51. error_type=error_type,
  52. error_message=error_message,
  53. error_code=error_code)
  54. @auth_bp.route('/dingtalk/login_url', methods=['GET'])
  55. def dingtalk_login_url():
  56. """生成钉钉登录URL"""
  57. if not dt_enabled():
  58. return jsonify({'success': False, 'error': 'DingTalk未配置'}), HTTPStatus.BAD_REQUEST
  59. try:
  60. state = uuid.uuid4().hex
  61. session['dt_oauth_state'] = state
  62. cfg = get_dt_config()
  63. auth_url = (
  64. 'https://login.dingtalk.com/oauth2/auth'
  65. f'?client_id={cfg["client_id"]}'
  66. f'&response_type=code'
  67. f'&scope=openid%20profile%20Contact.User.Read'
  68. f'&redirect_uri={requests.utils.quote(cfg["redirect_uri"], safe="")}'
  69. f'&state={state}'
  70. f'&prompt=consent'
  71. )
  72. return jsonify({'success': True, 'url': auth_url})
  73. except Exception as e:
  74. logger.error(f'生成钉钉登录URL失败: {e}')
  75. return jsonify({'success': False, 'error': '生成登录链接失败'}), HTTPStatus.INTERNAL_SERVER_ERROR
  76. @auth_bp.route('/dingtalk/callback', methods=['GET'])
  77. def dingtalk_oauth_callback():
  78. """钉钉OAuth回调处理"""
  79. if not dt_enabled():
  80. return redirect(url_for('auth.login_error',
  81. error='config_error',
  82. message='钉钉OAuth未正确配置,请联系管理员'))
  83. # 检查数据库连接
  84. if not check_database_connection():
  85. return redirect(url_for('auth.login_error',
  86. error='database_error',
  87. message='数据库连接失败,请稍后重试',
  88. code='DATABASE_CONNECTION_FAILED'))
  89. try:
  90. # 验证参数
  91. code = request.args.get('code')
  92. state = request.args.get('state')
  93. if not code or not state or state != session.get('dt_oauth_state'):
  94. return redirect(url_for('auth.login_error',
  95. error='oauth_failed',
  96. message='OAuth认证参数无效或已过期',
  97. code='INVALID_PARAMS'))
  98. # 获取access token
  99. cfg = get_dt_config()
  100. try:
  101. token_resp = requests.post(
  102. 'https://api.dingtalk.com/v1.0/oauth2/userAccessToken',
  103. json={
  104. 'clientId': cfg['client_id'],
  105. 'clientSecret': cfg['client_secret'],
  106. 'code': code,
  107. 'grantType': 'authorization_code'
  108. },
  109. timeout=15
  110. )
  111. except requests.exceptions.Timeout:
  112. logger.error('获取access token超时')
  113. return redirect(url_for('auth.login_error',
  114. error='timeout',
  115. message='请求超时,请稍后重试',
  116. code='TOKEN_TIMEOUT'))
  117. except requests.exceptions.RequestException as e:
  118. logger.error(f'获取access token网络错误: {e}')
  119. return redirect(url_for('auth.login_error',
  120. error='network_error',
  121. message='网络连接异常,请检查网络后重试',
  122. code='NETWORK_ERROR'))
  123. if token_resp.status_code != 200:
  124. logger.error(f'获取access token失败: {token_resp.status_code}')
  125. return redirect(url_for('auth.login_error',
  126. error='token_failed',
  127. message=f'获取访问令牌失败 (HTTP {token_resp.status_code})',
  128. code=f'TOKEN_HTTP_{token_resp.status_code}'))
  129. token_data = token_resp.json()
  130. access_token = token_data.get('accessToken')
  131. if not access_token:
  132. logger.error(f"access_token为空: {token_data}")
  133. return redirect(url_for('auth.login_error',
  134. error='token_failed',
  135. message='获取到的访问令牌无效',
  136. code='EMPTY_TOKEN'))
  137. # 获取用户信息
  138. user_info = get_dingtalk_user_info(access_token)
  139. if not user_info:
  140. return redirect(url_for('auth.login_error',
  141. error='userinfo_failed',
  142. message='无法获取用户信息,请确保钉钉账户信息完整',
  143. code='USERINFO_FAILED'))
  144. user_id = user_info.get('openid') or str(uuid.uuid4())
  145. # 保存或更新用户信息到数据库
  146. try:
  147. # 获取客户端IP
  148. client_ip = request.headers.get('X-Forwarded-For', request.remote_addr)
  149. if client_ip and ',' in client_ip:
  150. client_ip = client_ip.split(',')[0].strip()
  151. # 准备用户数据
  152. user_display_name = user_info.get('name', '钉钉用户')
  153. user_data = {
  154. 'username': f"dingtalk_{user_id[:8]}", # 系统内部唯一标识
  155. 'nickname': user_display_name, # 用户显示名称
  156. 'avatar_url': user_info.get('avatar', ''),
  157. 'email': user_info.get('email', ''),
  158. 'phone': user_info.get('mobile', '') or None, # 空字符串转换为None
  159. 'dingtalk_id': user_id,
  160. 'dingtalk_unionid': user_info.get('openid', ''),
  161. 'openid': user_info.get('openid', ''),
  162. 'status': 'active'
  163. }
  164. # 获取或创建用户
  165. db_user = get_or_create_user('dingtalk', **user_data)
  166. # 更新登录信息
  167. update_user_login_info(db_user, client_ip)
  168. # 更新session中的用户信息,包含数据库ID
  169. user_info['db_user_id'] = db_user.user_id
  170. except Exception as e:
  171. logger.error(f'保存用户信息到数据库失败: {e}', exc_info=True)
  172. # 数据库操作失败,返回错误页面
  173. return redirect(url_for('auth.login_error',
  174. error='database_error',
  175. message=f'用户信息保存失败: {str(e)}',
  176. code='DATABASE_SAVE_FAILED'))
  177. # 生成JWT token
  178. jwt_token = generate_jwt_token(user_id, user_info)
  179. if not jwt_token:
  180. return redirect(url_for('auth.login_error',
  181. error='jwt_failed',
  182. message='生成登录凭证失败,系统可能正在维护中',
  183. code='JWT_GENERATION_FAILED'))
  184. # 保存到session
  185. session['auth_token'] = jwt_token
  186. session['user_info'] = user_info
  187. # 直接跳转到首页,通过URL参数传递用户信息
  188. import urllib.parse
  189. user_info_json = urllib.parse.quote(json.dumps(user_info, ensure_ascii=False))
  190. redirect_url = f"/?login_success=true&token={jwt_token}&user_info={user_info_json}"
  191. return redirect(redirect_url)
  192. except Exception as e:
  193. logger.error(f'钉钉OAuth回调处理失败: {e}', exc_info=True)
  194. return redirect(url_for('auth.login_error',
  195. error='unknown',
  196. message=f'登录过程中发生未知错误: {str(e)}',
  197. code='CALLBACK_EXCEPTION'))
  198. def get_dingtalk_user_info(access_token: str) -> Dict:
  199. """获取钉钉用户信息"""
  200. try:
  201. # 使用正确的钉钉API端点
  202. headers = {
  203. 'x-acs-dingtalk-access-token': access_token,
  204. 'Content-Type': 'application/json'
  205. }
  206. # 钉钉官方推荐的获取用户信息API
  207. api_url = 'https://api.dingtalk.com/v1.0/contact/users/me'
  208. resp = requests.get(api_url, headers=headers, timeout=15)
  209. if resp.status_code == 200:
  210. data = resp.json()
  211. # 检查是否有错误码
  212. if not data.get('errcode') or data.get('errcode') == 0:
  213. user_info = parse_dingtalk_user_data(data)
  214. if user_info:
  215. return user_info
  216. else:
  217. logger.warning(f'钉钉API返回错误: {data.get("errcode")} - {data.get("errmsg")}')
  218. else:
  219. logger.warning(f'钉钉API调用失败: HTTP {resp.status_code}')
  220. # 如果API调用失败,返回默认用户信息
  221. logger.warning('钉钉API调用失败,使用默认用户信息')
  222. return get_default_user_info()
  223. except Exception as e:
  224. logger.warning(f'获取钉钉用户信息失败: {e}')
  225. return get_default_user_info()
  226. def parse_dingtalk_user_data(data: Dict) -> Dict:
  227. """解析钉钉用户数据"""
  228. try:
  229. # 尝试多种可能的字段名
  230. name_fields = ['nick', 'nickname', 'realName', 'name', 'nickName', 'userid', 'user_id']
  231. name = None
  232. for field in name_fields:
  233. if data.get(field) and data.get(field).strip():
  234. name = data.get(field).strip()
  235. break
  236. if not name:
  237. name = '钉钉用户'
  238. # 获取用户ID
  239. openid = (data.get('openId') or data.get('openid') or
  240. data.get('unionId') or data.get('unionid') or
  241. data.get('userid') or data.get('user_id') or
  242. str(uuid.uuid4()))
  243. # 获取头像
  244. avatar_fields = ['avatarUrl', 'avatar_url', 'avatar', 'headimgurl', 'avatarUrl']
  245. avatar = None
  246. for field in avatar_fields:
  247. if data.get(field) and data.get(field).strip():
  248. avatar = data.get(field).strip()
  249. break
  250. # 如果头像URL是相对路径,转换为完整URL
  251. if avatar and not avatar.startswith(('http://', 'https://')):
  252. if avatar.startswith('/'):
  253. avatar = f'https://oapi.dingtalk.com{avatar}'
  254. else:
  255. avatar = f'https://oapi.dingtalk.com/{avatar}'
  256. # 获取其他信息
  257. email = data.get('email') or data.get('mail') or ''
  258. mobile = data.get('mobile') or data.get('phone') or ''
  259. job_number = data.get('jobNumber') or data.get('job_number') or ''
  260. title = data.get('title') or data.get('position') or ''
  261. dept_name = data.get('deptName') or data.get('department_name') or ''
  262. result = {
  263. 'name': name,
  264. 'nickname': name, # 添加nickname字段,确保前端能正确显示
  265. 'openid': openid,
  266. 'avatar': avatar or '',
  267. 'email': email,
  268. 'mobile': mobile,
  269. 'job_number': job_number,
  270. 'title': title,
  271. 'dept_name': dept_name,
  272. 'login_type': 'dingtalk'
  273. }
  274. return result
  275. except Exception as e:
  276. logger.error(f'解析钉钉用户数据失败: {e}')
  277. return None
  278. def get_default_user_info() -> Dict:
  279. """获取默认用户信息"""
  280. return {
  281. 'name': '钉钉用户',
  282. 'nickname': '钉钉用户', # 添加nickname字段
  283. 'openid': str(uuid.uuid4()),
  284. 'avatar': '',
  285. 'email': '',
  286. 'mobile': '',
  287. 'job_number': '',
  288. 'title': '',
  289. 'dept_name': '',
  290. 'login_type': 'dingtalk'
  291. }
  292. def generate_jwt_token(user_id: str, user_info: Dict) -> str:
  293. """生成JWT token"""
  294. try:
  295. if not hasattr(current_app, 'auth_middleware'):
  296. logger.error('auth_middleware未配置')
  297. return None
  298. auth_middleware = current_app.auth_middleware
  299. if not auth_middleware:
  300. logger.error('auth_middleware为空')
  301. return None
  302. # 确保user_info包含必要的字段
  303. if not user_info.get('name') and not user_info.get('nickname'):
  304. user_info['name'] = '钉钉用户'
  305. user_info['nickname'] = '钉钉用户'
  306. token = auth_middleware.generate_token(user_id, user_info)
  307. return token
  308. except Exception as e:
  309. logger.error(f'生成JWT token失败: {e}', exc_info=True)
  310. return None
  311. @auth_bp.route('/logout', methods=['POST'])
  312. def logout():
  313. """退出登录"""
  314. try:
  315. session.clear()
  316. return jsonify({'success': True, 'message': '退出登录成功'})
  317. except Exception as e:
  318. logger.error(f'退出登录失败: {e}')
  319. return jsonify({'success': False, 'error': '退出登录失败'}), HTTPStatus.INTERNAL_SERVER_ERROR
  320. @auth_bp.route('/verify', methods=['GET'])
  321. def verify_token():
  322. """验证令牌"""
  323. try:
  324. token = request.headers.get('Authorization', '').replace('Bearer ', '')
  325. if not token:
  326. return jsonify({'success': False, 'error': '缺少令牌'}), HTTPStatus.UNAUTHORIZED
  327. auth_middleware = current_app.auth_middleware
  328. payload = auth_middleware.verify_token(token)
  329. return jsonify({
  330. 'success': True,
  331. 'user_id': payload['user_id'],
  332. 'user_info': payload['user_info']
  333. })
  334. except ValueError as e:
  335. return jsonify({'success': False, 'error': str(e)}), HTTPStatus.UNAUTHORIZED
  336. except Exception as e:
  337. logger.error(f'验证令牌失败: {e}')
  338. return jsonify({'success': False, 'error': '验证失败'}), HTTPStatus.INTERNAL_SERVER_ERROR
  339. @auth_bp.route('/user/info', methods=['GET'])
  340. def get_user_info():
  341. """获取用户信息"""
  342. try:
  343. token = request.headers.get('Authorization', '').replace('Bearer ', '')
  344. if not token:
  345. return jsonify({'success': False, 'error': '缺少令牌'}), HTTPStatus.UNAUTHORIZED
  346. auth_middleware = current_app.auth_middleware
  347. payload = auth_middleware.verify_token(token)
  348. return jsonify({'success': True, 'user_info': payload['user_info']})
  349. except ValueError as e:
  350. return jsonify({'success': False, 'error': str(e)}), HTTPStatus.UNAUTHORIZED
  351. except Exception as e:
  352. logger.error(f'获取用户信息失败: {e}')
  353. return jsonify({'success': False, 'error': '获取用户信息失败'}), HTTPStatus.INTERNAL_SERVER_ERROR
  354. @auth_bp.route('/refresh', methods=['POST'])
  355. def refresh_token():
  356. """刷新JWT令牌"""
  357. try:
  358. token = request.headers.get('Authorization', '').replace('Bearer ', '')
  359. if not token:
  360. logger.warning('刷新token请求缺少Authorization头')
  361. return jsonify({
  362. 'success': False,
  363. 'error': '缺少令牌',
  364. 'code': 'MISSING_TOKEN'
  365. }), HTTPStatus.UNAUTHORIZED
  366. auth_middleware = current_app.auth_middleware
  367. try:
  368. # 验证当前token
  369. payload = auth_middleware.verify_token(token)
  370. user_id = payload['user_id']
  371. user_info = payload['user_info']
  372. # 生成新的token
  373. new_token = auth_middleware.generate_token(user_id, user_info)
  374. logger.info(f'Token刷新成功,用户ID: {user_id}')
  375. return jsonify({
  376. 'success': True,
  377. 'token': new_token,
  378. 'message': 'Token刷新成功'
  379. })
  380. except ValueError as e:
  381. logger.warning(f'Token验证失败: {str(e)}')
  382. # Token已过期或无效,尝试从session获取用户信息
  383. user_info = session.get('user_info')
  384. if user_info:
  385. user_id = user_info.get('openid') or str(uuid.uuid4())
  386. new_token = auth_middleware.generate_token(user_id, user_info)
  387. logger.info(f'从session刷新token成功,用户ID: {user_id}')
  388. return jsonify({
  389. 'success': True,
  390. 'token': new_token,
  391. 'message': 'Token刷新成功'
  392. })
  393. else:
  394. logger.warning('Token已过期且session中无用户信息,需要重新登录')
  395. return jsonify({
  396. 'success': False,
  397. 'error': 'Token已过期且无法刷新,请重新登录',
  398. 'code': 'TOKEN_EXPIRED_NO_SESSION'
  399. }), HTTPStatus.UNAUTHORIZED
  400. except Exception as e:
  401. logger.error(f'刷新Token失败: {e}', exc_info=True)
  402. return jsonify({
  403. 'success': False,
  404. 'error': '刷新Token失败',
  405. 'code': 'REFRESH_ERROR'
  406. }), HTTPStatus.INTERNAL_SERVER_ERROR