| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476 |
- import os
- import json
- import uuid
- import logging
- from typing import Dict
- import requests
- from flask import Blueprint, request, jsonify, session, make_response, current_app, render_template, redirect, url_for
- from http import HTTPStatus
- # 导入数据库模型
- from models import get_or_create_user, update_user_login_info, User, db
- # 认证蓝图
- auth_bp = Blueprint('auth', __name__, url_prefix='/api/auth')
- logger = logging.getLogger(__name__)
- def get_dt_config() -> Dict[str, str]:
- """获取钉钉配置"""
- return {
- 'client_id': os.getenv('DINGTALK_CLIENT_ID', ''),
- 'client_secret': os.getenv('DINGTALK_CLIENT_SECRET', ''),
- 'redirect_uri': os.getenv('DINGTALK_REDIRECT_URI', '')
- }
- def dt_enabled() -> bool:
- """检查钉钉是否已配置"""
- cfg = get_dt_config()
- return all([cfg['client_id'], cfg['client_secret'], cfg['redirect_uri']])
- def check_database_connection() -> bool:
- """检查数据库连接是否正常"""
- try:
- with current_app.app_context():
- # 尝试执行一个简单的查询
- from sqlalchemy import text
- db.session.execute(text('SELECT 1'))
- return True
- except Exception as e:
- logger.error(f'数据库连接检查失败: {e}')
- return False
- @auth_bp.route('/ping', methods=['GET'])
- def ping():
- """健康检查接口"""
- return jsonify({
- 'success': True,
- 'message': 'auth ok',
- 'dingtalk_enabled': dt_enabled()
- })
- @auth_bp.route('/login_error')
- def login_error():
- """登录错误页面"""
- error_type = request.args.get('error', 'unknown')
- error_message = request.args.get('message', '')
- error_code = request.args.get('code', '')
- return render_template('login_error.html',
- error_type=error_type,
- error_message=error_message,
- error_code=error_code)
- @auth_bp.route('/dingtalk/login_url', methods=['GET'])
- def dingtalk_login_url():
- """生成钉钉登录URL"""
- if not dt_enabled():
- return jsonify({'success': False, 'error': 'DingTalk未配置'}), HTTPStatus.BAD_REQUEST
- try:
- state = uuid.uuid4().hex
- session['dt_oauth_state'] = state
- cfg = get_dt_config()
- auth_url = (
- 'https://login.dingtalk.com/oauth2/auth'
- f'?client_id={cfg["client_id"]}'
- f'&response_type=code'
- f'&scope=openid%20profile%20Contact.User.Read'
- f'&redirect_uri={requests.utils.quote(cfg["redirect_uri"], safe="")}'
- f'&state={state}'
- f'&prompt=consent'
- )
- return jsonify({'success': True, 'url': auth_url})
- except Exception as e:
- logger.error(f'生成钉钉登录URL失败: {e}')
- return jsonify({'success': False, 'error': '生成登录链接失败'}), HTTPStatus.INTERNAL_SERVER_ERROR
- @auth_bp.route('/dingtalk/callback', methods=['GET'])
- def dingtalk_oauth_callback():
- """钉钉OAuth回调处理"""
- if not dt_enabled():
- return redirect(url_for('auth.login_error',
- error='config_error',
- message='钉钉OAuth未正确配置,请联系管理员'))
-
- # 检查数据库连接
- if not check_database_connection():
- return redirect(url_for('auth.login_error',
- error='database_error',
- message='数据库连接失败,请稍后重试',
- code='DATABASE_CONNECTION_FAILED'))
- try:
- # 验证参数
- code = request.args.get('code')
- state = request.args.get('state')
- if not code or not state or state != session.get('dt_oauth_state'):
- return redirect(url_for('auth.login_error',
- error='oauth_failed',
- message='OAuth认证参数无效或已过期',
- code='INVALID_PARAMS'))
- # 获取access token
- cfg = get_dt_config()
- try:
- token_resp = requests.post(
- 'https://api.dingtalk.com/v1.0/oauth2/userAccessToken',
- json={
- 'clientId': cfg['client_id'],
- 'clientSecret': cfg['client_secret'],
- 'code': code,
- 'grantType': 'authorization_code'
- },
- timeout=15
- )
- except requests.exceptions.Timeout:
- logger.error('获取access token超时')
- return redirect(url_for('auth.login_error',
- error='timeout',
- message='请求超时,请稍后重试',
- code='TOKEN_TIMEOUT'))
- except requests.exceptions.RequestException as e:
- logger.error(f'获取access token网络错误: {e}')
- return redirect(url_for('auth.login_error',
- error='network_error',
- message='网络连接异常,请检查网络后重试',
- code='NETWORK_ERROR'))
- if token_resp.status_code != 200:
- logger.error(f'获取access token失败: {token_resp.status_code}')
- return redirect(url_for('auth.login_error',
- error='token_failed',
- message=f'获取访问令牌失败 (HTTP {token_resp.status_code})',
- code=f'TOKEN_HTTP_{token_resp.status_code}'))
- token_data = token_resp.json()
- access_token = token_data.get('accessToken')
- if not access_token:
- logger.error(f"access_token为空: {token_data}")
- return redirect(url_for('auth.login_error',
- error='token_failed',
- message='获取到的访问令牌无效',
- code='EMPTY_TOKEN'))
- # 获取用户信息
- user_info = get_dingtalk_user_info(access_token)
- if not user_info:
- return redirect(url_for('auth.login_error',
- error='userinfo_failed',
- message='无法获取用户信息,请确保钉钉账户信息完整',
- code='USERINFO_FAILED'))
- user_id = user_info.get('openid') or str(uuid.uuid4())
- # 保存或更新用户信息到数据库
- try:
- # 获取客户端IP
- client_ip = request.headers.get('X-Forwarded-For', request.remote_addr)
- if client_ip and ',' in client_ip:
- client_ip = client_ip.split(',')[0].strip()
-
- # 准备用户数据
- user_display_name = user_info.get('name', '钉钉用户')
- user_data = {
- 'username': f"dingtalk_{user_id[:8]}", # 系统内部唯一标识
- 'nickname': user_display_name, # 用户显示名称
- 'avatar_url': user_info.get('avatar', ''),
- 'email': user_info.get('email', ''),
- 'phone': user_info.get('mobile', '') or None, # 空字符串转换为None
- 'dingtalk_id': user_id,
- 'dingtalk_unionid': user_info.get('openid', ''),
- 'openid': user_info.get('openid', ''),
- 'status': 'active'
- }
-
- # 获取或创建用户
- db_user = get_or_create_user('dingtalk', **user_data)
-
- # 更新登录信息
- update_user_login_info(db_user, client_ip)
-
- # 更新session中的用户信息,包含数据库ID
- user_info['db_user_id'] = db_user.user_id
-
- except Exception as e:
- logger.error(f'保存用户信息到数据库失败: {e}', exc_info=True)
- # 数据库操作失败,返回错误页面
- return redirect(url_for('auth.login_error',
- error='database_error',
- message=f'用户信息保存失败: {str(e)}',
- code='DATABASE_SAVE_FAILED'))
- # 生成JWT token
- jwt_token = generate_jwt_token(user_id, user_info)
- if not jwt_token:
- return redirect(url_for('auth.login_error',
- error='jwt_failed',
- message='生成登录凭证失败,系统可能正在维护中',
- code='JWT_GENERATION_FAILED'))
- # 保存到session
- session['auth_token'] = jwt_token
- session['user_info'] = user_info
- # 直接跳转到首页,通过URL参数传递用户信息
- import urllib.parse
- user_info_json = urllib.parse.quote(json.dumps(user_info, ensure_ascii=False))
- redirect_url = f"/?login_success=true&token={jwt_token}&user_info={user_info_json}"
- return redirect(redirect_url)
- except Exception as e:
- logger.error(f'钉钉OAuth回调处理失败: {e}', exc_info=True)
- return redirect(url_for('auth.login_error',
- error='unknown',
- message=f'登录过程中发生未知错误: {str(e)}',
- code='CALLBACK_EXCEPTION'))
- def get_dingtalk_user_info(access_token: str) -> Dict:
- """获取钉钉用户信息"""
- try:
- # 使用正确的钉钉API端点
- headers = {
- 'x-acs-dingtalk-access-token': access_token,
- 'Content-Type': 'application/json'
- }
- # 钉钉官方推荐的获取用户信息API
- api_url = 'https://api.dingtalk.com/v1.0/contact/users/me'
-
- resp = requests.get(api_url, headers=headers, timeout=15)
-
- if resp.status_code == 200:
- data = resp.json()
-
- # 检查是否有错误码
- if not data.get('errcode') or data.get('errcode') == 0:
- user_info = parse_dingtalk_user_data(data)
- if user_info:
- return user_info
- else:
- logger.warning(f'钉钉API返回错误: {data.get("errcode")} - {data.get("errmsg")}')
- else:
- logger.warning(f'钉钉API调用失败: HTTP {resp.status_code}')
-
- # 如果API调用失败,返回默认用户信息
- logger.warning('钉钉API调用失败,使用默认用户信息')
- return get_default_user_info()
-
- except Exception as e:
- logger.warning(f'获取钉钉用户信息失败: {e}')
- return get_default_user_info()
- def parse_dingtalk_user_data(data: Dict) -> Dict:
- """解析钉钉用户数据"""
- try:
- # 尝试多种可能的字段名
- name_fields = ['nick', 'nickname', 'realName', 'name', 'nickName', 'userid', 'user_id']
- name = None
- for field in name_fields:
- if data.get(field) and data.get(field).strip():
- name = data.get(field).strip()
- break
-
- if not name:
- name = '钉钉用户'
-
- # 获取用户ID
- openid = (data.get('openId') or data.get('openid') or
- data.get('unionId') or data.get('unionid') or
- data.get('userid') or data.get('user_id') or
- str(uuid.uuid4()))
-
- # 获取头像
- avatar_fields = ['avatarUrl', 'avatar_url', 'avatar', 'headimgurl', 'avatarUrl']
- avatar = None
- for field in avatar_fields:
- if data.get(field) and data.get(field).strip():
- avatar = data.get(field).strip()
- break
-
- # 如果头像URL是相对路径,转换为完整URL
- if avatar and not avatar.startswith(('http://', 'https://')):
- if avatar.startswith('/'):
- avatar = f'https://oapi.dingtalk.com{avatar}'
- else:
- avatar = f'https://oapi.dingtalk.com/{avatar}'
-
- # 获取其他信息
- email = data.get('email') or data.get('mail') or ''
- mobile = data.get('mobile') or data.get('phone') or ''
- job_number = data.get('jobNumber') or data.get('job_number') or ''
- title = data.get('title') or data.get('position') or ''
- dept_name = data.get('deptName') or data.get('department_name') or ''
-
- result = {
- 'name': name,
- 'nickname': name, # 添加nickname字段,确保前端能正确显示
- 'openid': openid,
- 'avatar': avatar or '',
- 'email': email,
- 'mobile': mobile,
- 'job_number': job_number,
- 'title': title,
- 'dept_name': dept_name,
- 'login_type': 'dingtalk'
- }
-
- return result
-
- except Exception as e:
- logger.error(f'解析钉钉用户数据失败: {e}')
- return None
- def get_default_user_info() -> Dict:
- """获取默认用户信息"""
- return {
- 'name': '钉钉用户',
- 'nickname': '钉钉用户', # 添加nickname字段
- 'openid': str(uuid.uuid4()),
- 'avatar': '',
- 'email': '',
- 'mobile': '',
- 'job_number': '',
- 'title': '',
- 'dept_name': '',
- 'login_type': 'dingtalk'
- }
- def generate_jwt_token(user_id: str, user_info: Dict) -> str:
- """生成JWT token"""
- try:
- if not hasattr(current_app, 'auth_middleware'):
- logger.error('auth_middleware未配置')
- return None
-
- auth_middleware = current_app.auth_middleware
- if not auth_middleware:
- logger.error('auth_middleware为空')
- return None
-
- # 确保user_info包含必要的字段
- if not user_info.get('name') and not user_info.get('nickname'):
- user_info['name'] = '钉钉用户'
- user_info['nickname'] = '钉钉用户'
-
- token = auth_middleware.generate_token(user_id, user_info)
- return token
-
- except Exception as e:
- logger.error(f'生成JWT token失败: {e}', exc_info=True)
- return None
- @auth_bp.route('/logout', methods=['POST'])
- def logout():
- """退出登录"""
- try:
- session.clear()
- return jsonify({'success': True, 'message': '退出登录成功'})
- except Exception as e:
- logger.error(f'退出登录失败: {e}')
- return jsonify({'success': False, 'error': '退出登录失败'}), HTTPStatus.INTERNAL_SERVER_ERROR
- @auth_bp.route('/verify', methods=['GET'])
- def verify_token():
- """验证令牌"""
- try:
- token = request.headers.get('Authorization', '').replace('Bearer ', '')
- if not token:
- return jsonify({'success': False, 'error': '缺少令牌'}), HTTPStatus.UNAUTHORIZED
-
- auth_middleware = current_app.auth_middleware
- payload = auth_middleware.verify_token(token)
-
- return jsonify({
- 'success': True,
- 'user_id': payload['user_id'],
- 'user_info': payload['user_info']
- })
-
- except ValueError as e:
- return jsonify({'success': False, 'error': str(e)}), HTTPStatus.UNAUTHORIZED
- except Exception as e:
- logger.error(f'验证令牌失败: {e}')
- return jsonify({'success': False, 'error': '验证失败'}), HTTPStatus.INTERNAL_SERVER_ERROR
- @auth_bp.route('/user/info', methods=['GET'])
- def get_user_info():
- """获取用户信息"""
- try:
- token = request.headers.get('Authorization', '').replace('Bearer ', '')
- if not token:
- return jsonify({'success': False, 'error': '缺少令牌'}), HTTPStatus.UNAUTHORIZED
-
- auth_middleware = current_app.auth_middleware
- payload = auth_middleware.verify_token(token)
-
- return jsonify({'success': True, 'user_info': payload['user_info']})
-
- except ValueError as e:
- return jsonify({'success': False, 'error': str(e)}), HTTPStatus.UNAUTHORIZED
- except Exception as e:
- logger.error(f'获取用户信息失败: {e}')
- return jsonify({'success': False, 'error': '获取用户信息失败'}), HTTPStatus.INTERNAL_SERVER_ERROR
- @auth_bp.route('/refresh', methods=['POST'])
- def refresh_token():
- """刷新JWT令牌"""
- try:
- token = request.headers.get('Authorization', '').replace('Bearer ', '')
- if not token:
- logger.warning('刷新token请求缺少Authorization头')
- return jsonify({
- 'success': False,
- 'error': '缺少令牌',
- 'code': 'MISSING_TOKEN'
- }), HTTPStatus.UNAUTHORIZED
-
- auth_middleware = current_app.auth_middleware
-
- try:
- # 验证当前token
- payload = auth_middleware.verify_token(token)
- user_id = payload['user_id']
- user_info = payload['user_info']
-
- # 生成新的token
- new_token = auth_middleware.generate_token(user_id, user_info)
-
- logger.info(f'Token刷新成功,用户ID: {user_id}')
- return jsonify({
- 'success': True,
- 'token': new_token,
- 'message': 'Token刷新成功'
- })
-
- except ValueError as e:
- logger.warning(f'Token验证失败: {str(e)}')
- # Token已过期或无效,尝试从session获取用户信息
- user_info = session.get('user_info')
- if user_info:
- user_id = user_info.get('openid') or str(uuid.uuid4())
- new_token = auth_middleware.generate_token(user_id, user_info)
-
- logger.info(f'从session刷新token成功,用户ID: {user_id}')
- return jsonify({
- 'success': True,
- 'token': new_token,
- 'message': 'Token刷新成功'
- })
- else:
- logger.warning('Token已过期且session中无用户信息,需要重新登录')
- return jsonify({
- 'success': False,
- 'error': 'Token已过期且无法刷新,请重新登录',
- 'code': 'TOKEN_EXPIRED_NO_SESSION'
- }), HTTPStatus.UNAUTHORIZED
-
- except Exception as e:
- logger.error(f'刷新Token失败: {e}', exc_info=True)
- return jsonify({
- 'success': False,
- 'error': '刷新Token失败',
- 'code': 'REFRESH_ERROR'
- }), HTTPStatus.INTERNAL_SERVER_ERROR
-
|