simple_auth.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769
  1. from typing import Optional, List
  2. import json
  3. from datetime import timedelta
  4. import logging
  5. from fastapi import APIRouter, Depends, HTTPException, Body, Request
  6. from sqlalchemy.orm import Session
  7. from pydantic import BaseModel
  8. from app.api.v1 import deps
  9. from app.core import security
  10. from app.core.config import settings
  11. from app.core.utils import generate_english_name, get_client_ip
  12. from app.core.cache import redis_client
  13. from app.models.user import User, UserRole, UserStatus
  14. from app.models.application import Application
  15. from app.models.mapping import AppUserMapping
  16. from app.schemas.simple_auth import (
  17. TicketExchangeRequest, TicketExchangeResponse,
  18. TicketValidateRequest, TicketValidateResponse,
  19. PasswordLoginRequest, PasswordLoginResponse,
  20. SmsLoginRequest,
  21. UserRegisterRequest, AdminPasswordResetRequest, AdminPasswordResetResponse,
  22. ChangePasswordRequest, MyMappingsResponse, UserMappingResponse,
  23. UserPromoteRequest, SsoLoginRequest, SsoLoginResponse
  24. )
  25. from app.services.signature_service import SignatureService
  26. from app.services.ticket_service import TicketService
  27. from app.services.log_service import LogService
  28. from app.services.login_log_service import LoginLogService
  29. from app.services.system_config_service import SystemConfigService
  30. from app.schemas.operation_log import ActionType
  31. from app.schemas.login_log import LoginLogCreate, LoginMethod, AuthType
  32. router = APIRouter()
  33. logger = logging.getLogger(__name__)
  34. @router.post("/login", response_model=PasswordLoginResponse, summary="密码登录")
  35. def login_with_password(
  36. req: PasswordLoginRequest,
  37. request: Request,
  38. db: Session = Depends(deps.get_db),
  39. ):
  40. """
  41. 1. 如果提供 app_id:应用 SSO 登录,返回 ticket。
  42. 2. 如果未提供 app_id:统一认证平台登录,返回 access_token。
  43. """
  44. # --- Platform Login ---
  45. if not req.app_id:
  46. # Prepare Log
  47. log_create = LoginLogCreate(
  48. mobile=req.identifier,
  49. ip_address=get_client_ip(request),
  50. login_method=LoginMethod.UNIFIED_PAGE,
  51. auth_type=AuthType.PASSWORD,
  52. user_agent=request.headers.get("user-agent")
  53. )
  54. # Find user by mobile only
  55. user = db.query(User).filter(User.mobile == req.identifier, User.is_deleted == 0).first()
  56. if not user:
  57. log_create.is_success = 0
  58. log_create.failure_reason = "用户未找到"
  59. LoginLogService.create_log(db, log_create)
  60. logger.warning(f"平台登录失败: 用户 {req.identifier} 未找到")
  61. raise HTTPException(status_code=404, detail="用户未找到")
  62. log_create.user_id = user.id
  63. is_valid = security.verify_password(req.password, user.password_hash)
  64. if not is_valid:
  65. logger.warning(f"平台登录失败: 用户 {user.mobile} 密码错误")
  66. log_create.is_success = 0
  67. log_create.failure_reason = "密码错误"
  68. LoginLogService.create_log(db, log_create)
  69. raise HTTPException(status_code=401, detail="密码错误")
  70. if user.status != UserStatus.ACTIVE:
  71. logger.warning(f"平台登录失败: 用户 {user.mobile} 已被禁用")
  72. log_create.is_success = 0
  73. log_create.failure_reason = "用户已禁用"
  74. LoginLogService.create_log(db, log_create)
  75. raise HTTPException(status_code=400, detail="用户已禁用")
  76. # Generate JWT Access Token
  77. access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
  78. if req.remember_me:
  79. access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES_LONG)
  80. access_token = security.create_access_token(
  81. user.id,
  82. expires_delta=access_token_expires,
  83. is_long_term=req.remember_me
  84. )
  85. # Log Success
  86. LoginLogService.create_log(db, log_create)
  87. logger.info(f"平台登录成功: 用户 {user.mobile} (ID: {user.id})")
  88. return {
  89. "access_token": access_token,
  90. "token_type": "bearer",
  91. "role": user.role
  92. }
  93. # --- App SSO Login ---
  94. log_create = LoginLogCreate(
  95. mobile=req.identifier,
  96. ip_address=get_client_ip(request),
  97. login_method=LoginMethod.CUSTOM_PAGE, # 假设应用自定义页面调用此接口
  98. auth_type=AuthType.PASSWORD,
  99. user_agent=request.headers.get("user-agent")
  100. )
  101. # 1. Verify App
  102. app = db.query(Application).filter(Application.app_id == req.app_id).first()
  103. if not app:
  104. log_create.is_success = 0
  105. log_create.failure_reason = "应用未找到"
  106. LoginLogService.create_log(db, log_create)
  107. logger.warning(f"应用登录失败: 应用ID {req.app_id} 未找到")
  108. raise HTTPException(status_code=404, detail="应用未找到")
  109. # 2. Verify Signature (Optional but recommended for server-side calls)
  110. if req.sign and req.timestamp:
  111. params = {
  112. "app_id": req.app_id,
  113. "identifier": req.identifier,
  114. "password": req.password,
  115. "timestamp": req.timestamp,
  116. "sign": req.sign
  117. }
  118. if not SignatureService.verify_signature(app.app_secret, params, req.sign):
  119. log_create.is_success = 0
  120. log_create.failure_reason = "签名无效"
  121. LoginLogService.create_log(db, log_create)
  122. logger.warning(f"应用登录失败: 应用 {req.app_id} 签名验证失败")
  123. raise HTTPException(status_code=400, detail="签名无效")
  124. # 3. Find User
  125. user = None
  126. # Auto-trim password to prevent common copy-paste errors
  127. if req.password:
  128. req.password = req.password.strip()
  129. # Try by mobile
  130. user = db.query(User).filter(User.mobile == req.identifier, User.is_deleted == 0).first()
  131. if not user:
  132. # Try by mapping
  133. mapping = db.query(AppUserMapping).filter(
  134. AppUserMapping.app_id == app.id,
  135. (AppUserMapping.mapped_key == req.identifier) | (AppUserMapping.mapped_email == req.identifier)
  136. ).first()
  137. if mapping:
  138. user = db.query(User).filter(User.id == mapping.user_id, User.is_deleted == 0).first()
  139. if not user:
  140. log_create.is_success = 0
  141. log_create.failure_reason = "用户未找到"
  142. LoginLogService.create_log(db, log_create)
  143. logger.warning(f"应用登录失败: 用户 {req.identifier} 在应用 {req.app_id} 中未找到")
  144. raise HTTPException(status_code=404, detail="用户未找到")
  145. log_create.user_id = user.id
  146. if user.status != UserStatus.ACTIVE:
  147. log_create.is_success = 0
  148. log_create.failure_reason = "用户已禁用"
  149. LoginLogService.create_log(db, log_create)
  150. logger.warning(f"应用登录失败: 用户 {user.mobile} 已被禁用")
  151. raise HTTPException(status_code=400, detail="用户已禁用")
  152. # 4. Verify Password
  153. # DEBUG: Log password verification details
  154. is_valid = security.verify_password(req.password, user.password_hash)
  155. if not is_valid:
  156. logger.warning(f"应用登录失败: 用户 {user.mobile} 密码验证失败 (App: {req.app_id})")
  157. log_create.is_success = 0
  158. log_create.failure_reason = "密码错误"
  159. LoginLogService.create_log(db, log_create)
  160. raise HTTPException(status_code=401, detail="密码错误")
  161. # 5. Generate Ticket (Self-Targeting)
  162. ticket = TicketService.generate_ticket(user.id, req.app_id)
  163. # Log Success (AuthType is PASSWORD leading to TICKET generation, keeping PASSWORD is fine or TICKET)
  164. # User requirement: "包括...认证方式". Here the auth method was PASSWORD.
  165. LoginLogService.create_log(db, log_create)
  166. logger.info(f"应用登录成功: 用户 {user.mobile} 获取 Ticket (App: {req.app_id})")
  167. return {"ticket": ticket}
  168. @router.post("/sms-login", response_model=PasswordLoginResponse, summary="短信验证码登录")
  169. def login_with_sms(
  170. req: SmsLoginRequest,
  171. request: Request,
  172. db: Session = Depends(deps.get_db),
  173. ):
  174. """
  175. 1. 如果提供 app_id:应用 SSO 登录,返回 ticket。
  176. 2. 如果未提供 app_id:统一认证平台登录,返回 access_token。
  177. """
  178. # 0. Check Config (Assuming PC enabled for API access, or check both)
  179. # Since this is an API used by external apps (likely web), we default to checking PC config
  180. # or we can check if EITHER is enabled.
  181. pc_enabled = SystemConfigService.get_config(db, "sms_login_pc_enabled")
  182. mobile_enabled = SystemConfigService.get_config(db, "sms_login_mobile_enabled")
  183. if pc_enabled != "true" and mobile_enabled != "true":
  184. logger.warning("短信登录尝试失败: 短信登录功能未开启")
  185. raise HTTPException(status_code=403, detail="短信登录功能未开启")
  186. # --- Platform Login ---
  187. if not req.app_id:
  188. # Prepare Log
  189. log_create = LoginLogCreate(
  190. mobile=req.mobile,
  191. ip_address=get_client_ip(request),
  192. login_method=LoginMethod.UNIFIED_PAGE,
  193. auth_type=AuthType.SMS,
  194. user_agent=request.headers.get("user-agent")
  195. )
  196. # 1. Verify Code
  197. key = f"SMS:{req.mobile}"
  198. stored_code = redis_client.get(key)
  199. if not stored_code or stored_code != req.code:
  200. log_create.is_success = 0
  201. log_create.failure_reason = "验证码错误或已过期"
  202. LoginLogService.create_log(db, log_create)
  203. logger.warning(f"平台短信登录失败: 手机号 {req.mobile} 验证码无效")
  204. raise HTTPException(status_code=400, detail="验证码错误或已过期")
  205. # 2. Find user
  206. user = db.query(User).filter(User.mobile == req.mobile, User.is_deleted == 0).first()
  207. if not user:
  208. log_create.is_success = 0
  209. log_create.failure_reason = "用户未找到"
  210. LoginLogService.create_log(db, log_create)
  211. logger.warning(f"平台短信登录失败: 手机号 {req.mobile} 未注册")
  212. raise HTTPException(status_code=404, detail="用户未找到")
  213. log_create.user_id = user.id
  214. if user.status != UserStatus.ACTIVE:
  215. log_create.is_success = 0
  216. log_create.failure_reason = "用户已禁用"
  217. LoginLogService.create_log(db, log_create)
  218. logger.warning(f"平台短信登录失败: 用户 {user.mobile} 已被禁用")
  219. raise HTTPException(status_code=400, detail="用户已禁用")
  220. # 3. Generate JWT Access Token
  221. access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
  222. access_token = security.create_access_token(
  223. user.id,
  224. expires_delta=access_token_expires
  225. )
  226. # Clear Code
  227. redis_client.delete(key)
  228. # Log Success
  229. LoginLogService.create_log(db, log_create)
  230. logger.info(f"平台短信登录成功: 用户 {user.mobile} (ID: {user.id})")
  231. return {
  232. "access_token": access_token,
  233. "token_type": "bearer",
  234. "role": user.role
  235. }
  236. # --- App SSO Login ---
  237. log_create = LoginLogCreate(
  238. mobile=req.mobile,
  239. ip_address=get_client_ip(request),
  240. login_method=LoginMethod.CUSTOM_PAGE,
  241. auth_type=AuthType.SMS,
  242. user_agent=request.headers.get("user-agent")
  243. )
  244. # 1. Verify App
  245. app = db.query(Application).filter(Application.app_id == req.app_id).first()
  246. if not app:
  247. log_create.is_success = 0
  248. log_create.failure_reason = "应用未找到"
  249. LoginLogService.create_log(db, log_create)
  250. logger.warning(f"应用短信登录失败: 应用ID {req.app_id} 未找到")
  251. raise HTTPException(status_code=404, detail="应用未找到")
  252. # 2. Verify Signature (Optional)
  253. if req.sign and req.timestamp:
  254. params = {
  255. "app_id": req.app_id,
  256. "mobile": req.mobile,
  257. "code": req.code,
  258. "timestamp": req.timestamp,
  259. "sign": req.sign
  260. }
  261. if not SignatureService.verify_signature(app.app_secret, params, req.sign):
  262. log_create.is_success = 0
  263. log_create.failure_reason = "签名无效"
  264. LoginLogService.create_log(db, log_create)
  265. logger.warning(f"应用短信登录失败: 应用 {req.app_id} 签名无效")
  266. raise HTTPException(status_code=400, detail="签名无效")
  267. # 3. Verify Code
  268. key = f"SMS:{req.mobile}"
  269. stored_code = redis_client.get(key)
  270. if not stored_code or stored_code != req.code:
  271. log_create.is_success = 0
  272. log_create.failure_reason = "验证码错误或已过期"
  273. LoginLogService.create_log(db, log_create)
  274. logger.warning(f"应用短信登录失败: 手机号 {req.mobile} 验证码无效")
  275. raise HTTPException(status_code=400, detail="验证码错误或已过期")
  276. # 4. Find User
  277. user = db.query(User).filter(User.mobile == req.mobile, User.is_deleted == 0).first()
  278. if not user:
  279. log_create.is_success = 0
  280. log_create.failure_reason = "用户未找到"
  281. LoginLogService.create_log(db, log_create)
  282. logger.warning(f"应用短信登录失败: 手机号 {req.mobile} 未注册")
  283. raise HTTPException(status_code=404, detail="用户未找到")
  284. log_create.user_id = user.id
  285. if user.status != UserStatus.ACTIVE:
  286. log_create.is_success = 0
  287. log_create.failure_reason = "用户已禁用"
  288. LoginLogService.create_log(db, log_create)
  289. logger.warning(f"应用短信登录失败: 用户 {user.mobile} 已被禁用")
  290. raise HTTPException(status_code=400, detail="用户已禁用")
  291. # 5. Generate Ticket (Self-Targeting)
  292. ticket = TicketService.generate_ticket(user.id, req.app_id)
  293. # Clear Code
  294. redis_client.delete(key)
  295. # Log Success
  296. LoginLogService.create_log(db, log_create)
  297. logger.info(f"应用短信登录成功: 用户 {user.mobile} 获取 Ticket (App: {req.app_id})")
  298. return {"ticket": ticket}
  299. @router.post("/register", response_model=PasswordLoginResponse, summary="用户注册")
  300. def register_user(
  301. req: UserRegisterRequest,
  302. db: Session = Depends(deps.get_db),
  303. ):
  304. """
  305. 注册新用户 (默认为普通用户)。
  306. """
  307. # Force role to ORDINARY_USER
  308. role = UserRole.ORDINARY_USER
  309. # Auto-login after registration (return token)
  310. if req.password:
  311. req.password = req.password.strip()
  312. if not security.validate_password_strength(req.password):
  313. raise HTTPException(status_code=400, detail="密码强度不足,必须包含字母和数字")
  314. existing_user = db.query(User).filter(User.mobile == req.mobile, User.is_deleted == 0).first()
  315. if existing_user:
  316. logger.info(f"用户注册失败: 手机号 {req.mobile} 已存在")
  317. raise HTTPException(status_code=400, detail="手机号已注册")
  318. english_name = generate_english_name(req.name)
  319. new_user = User(
  320. mobile=req.mobile,
  321. name=req.name,
  322. english_name=english_name,
  323. password_hash=security.get_password_hash(req.password),
  324. status=UserStatus.ACTIVE,
  325. role=role
  326. )
  327. db.add(new_user)
  328. db.commit()
  329. db.refresh(new_user)
  330. logger.info(f"用户注册成功: {req.mobile} (ID: {new_user.id})")
  331. # Auto-login after registration (return token)
  332. access_token = security.create_access_token(new_user.id)
  333. return {
  334. "access_token": access_token,
  335. "token_type": "bearer",
  336. "role": new_user.role
  337. }
  338. @router.post("/admin/reset-password", response_model=AdminPasswordResetResponse, summary="管理员重置密码")
  339. def admin_reset_password(
  340. req: AdminPasswordResetRequest,
  341. request: Request,
  342. db: Session = Depends(deps.get_db),
  343. current_user: User = Depends(deps.get_current_active_user),
  344. ):
  345. """
  346. 超级管理员重置用户密码。
  347. 随机生成8位密码,只显示一次。
  348. """
  349. if current_user.role != UserRole.SUPER_ADMIN:
  350. raise HTTPException(status_code=403, detail="权限不足")
  351. # Verify Admin Password
  352. if not security.verify_password(req.admin_password, current_user.password_hash):
  353. logger.warning(f"管理员重置密码失败: 管理员 {current_user.mobile} 密码验证错误")
  354. raise HTTPException(status_code=401, detail="管理员密码错误")
  355. target_user = db.query(User).filter(User.id == req.user_id).first()
  356. if not target_user:
  357. raise HTTPException(status_code=404, detail="用户未找到")
  358. # Generate random password (alphanumeric only)
  359. new_pwd = security.generate_alphanumeric_password(8)
  360. target_user.password_hash = security.get_password_hash(new_pwd)
  361. db.add(target_user)
  362. db.commit()
  363. # Log Operation
  364. LogService.create_log(
  365. db=db,
  366. operator_id=current_user.id,
  367. action_type=ActionType.RESET_PASSWORD,
  368. target_user_id=target_user.id,
  369. target_mobile=target_user.mobile,
  370. ip_address=get_client_ip(request),
  371. details={}
  372. )
  373. logger.info(f"管理员重置用户密码成功: 目标用户 {target_user.mobile} (ID: {target_user.id})")
  374. return {"new_password": new_pwd}
  375. @router.post("/admin/promote", summary="提升用户角色")
  376. def promote_user(
  377. req: UserPromoteRequest,
  378. db: Session = Depends(deps.get_db),
  379. current_user: User = Depends(deps.get_current_active_user),
  380. ):
  381. if current_user.role != UserRole.SUPER_ADMIN:
  382. raise HTTPException(status_code=403, detail="权限不足")
  383. if req.new_role not in [UserRole.SUPER_ADMIN, UserRole.DEVELOPER]:
  384. raise HTTPException(status_code=400, detail="只能提升为管理员 or 开发者")
  385. target_user = db.query(User).filter(User.id == req.user_id).first()
  386. if not target_user:
  387. raise HTTPException(status_code=404, detail="用户未找到")
  388. old_role = target_user.role
  389. target_user.role = req.new_role
  390. db.add(target_user)
  391. db.commit()
  392. logger.info(f"用户角色变更: 用户 {target_user.mobile} 从 {old_role} 变更为 {req.new_role} (操作者: {current_user.mobile})")
  393. return {"message": "success"}
  394. @router.get("/me/mappings", response_model=MyMappingsResponse, summary="我的映射")
  395. def get_my_mappings(
  396. skip: int = 0,
  397. limit: int = 10,
  398. app_name: str = None,
  399. db: Session = Depends(deps.get_db),
  400. current_user: User = Depends(deps.get_current_active_user),
  401. ):
  402. query = db.query(AppUserMapping).join(Application).filter(AppUserMapping.user_id == current_user.id)
  403. if app_name:
  404. query = query.filter(Application.app_name.ilike(f"%{app_name}%"))
  405. total = query.count()
  406. mappings = query.order_by(AppUserMapping.id.desc()).offset(skip).limit(limit).all()
  407. result = []
  408. for m in mappings:
  409. result.append(UserMappingResponse(
  410. app_name=m.application.app_name if m.application else "Unknown",
  411. app_id=m.application.app_id if m.application else "",
  412. protocol_type=m.application.protocol_type if m.application else "",
  413. mapped_key=m.mapped_key,
  414. mapped_email=m.mapped_email,
  415. is_active=m.is_active
  416. ))
  417. return {"total": total, "items": result}
  418. @router.post("/me/change-password", summary="修改密码")
  419. def change_my_password(
  420. req: ChangePasswordRequest,
  421. db: Session = Depends(deps.get_db),
  422. current_user: User = Depends(deps.get_current_active_user),
  423. ):
  424. if not security.verify_password(req.old_password, current_user.password_hash):
  425. logger.warning(f"用户修改密码失败: 用户 {current_user.mobile} 旧密码验证错误")
  426. raise HTTPException(status_code=400, detail="旧密码错误")
  427. if req.new_password:
  428. req.new_password = req.new_password.strip()
  429. if not security.validate_password_strength(req.new_password):
  430. raise HTTPException(status_code=400, detail="密码强度不足,必须包含字母和数字")
  431. current_user.password_hash = security.get_password_hash(req.new_password)
  432. db.add(current_user)
  433. db.commit()
  434. logger.info(f"用户修改密码成功: {current_user.mobile}")
  435. return {"message": "密码修改成功"}
  436. @router.post("/exchange", response_model=TicketExchangeResponse, summary="票据交换")
  437. def exchange_ticket(
  438. req: TicketExchangeRequest,
  439. db: Session = Depends(deps.get_db),
  440. ):
  441. """
  442. 源应用调用以获取目标应用的票据。
  443. """
  444. # 1. Verify Source App
  445. source_app = db.query(Application).filter(Application.app_id == req.app_id).first()
  446. if not source_app:
  447. logger.warning(f"票据交换失败: 源应用 {req.app_id} 未找到")
  448. raise HTTPException(status_code=404, detail="源应用未找到")
  449. # 2. Verify Signature
  450. params = {
  451. "app_id": req.app_id,
  452. "target_app_id": req.target_app_id,
  453. "user_mobile": req.user_mobile,
  454. "timestamp": req.timestamp,
  455. "sign": req.sign
  456. }
  457. # Use the stored secret to verify
  458. if not SignatureService.verify_signature(source_app.app_secret, params, req.sign):
  459. logger.warning(f"票据交换失败: 源应用 {req.app_id} 签名无效")
  460. raise HTTPException(status_code=400, detail="签名无效")
  461. # 3. Verify User Existence (Optional: Do we trust source app completely? Usually yes if signed.)
  462. # But we need user_id to generate ticket.
  463. # We query by mobile.
  464. user = db.query(User).filter(User.mobile == req.user_mobile, User.is_deleted == 0).first()
  465. if not user:
  466. # If user doesn't exist, we might auto-create OR fail.
  467. # Requirement: "Returns redirect_url".
  468. # For simplicity, if user not found, we cannot map.
  469. logger.warning(f"票据交换失败: 用户 {req.user_mobile} 未找到")
  470. raise HTTPException(status_code=404, detail="用户在 UAP 中未找到")
  471. # 4. Generate Ticket for Target App
  472. # Logic: The ticket allows the user to log in to Target App.
  473. ticket = TicketService.generate_ticket(user.id, req.target_app_id)
  474. # 5. Get Target App URL
  475. target_app = db.query(Application).filter(Application.app_id == req.target_app_id).first()
  476. if not target_app:
  477. logger.warning(f"票据交换失败: 目标应用 {req.target_app_id} 未找到")
  478. raise HTTPException(status_code=404, detail="目标应用未找到")
  479. # Construct redirect URL
  480. # Assuming target app handles /callback?ticket=...
  481. # We use the first redirect_uri or notification_url or custom logic.
  482. # Simplicity: We return the ticket and let the Source App handle the redirect,
  483. # OR we return a full redirect URL if target_app has a base URL configured.
  484. # Let's assume redirect_uris is a JSON list.
  485. redirect_base = ""
  486. if target_app.redirect_uris:
  487. try:
  488. # 尝试作为 JSON 数组解析
  489. uris = json.loads(target_app.redirect_uris)
  490. if isinstance(uris, list) and len(uris) > 0:
  491. redirect_base = uris[0]
  492. elif isinstance(uris, str):
  493. redirect_base = uris
  494. except (json.JSONDecodeError, TypeError):
  495. # 如果不是 JSON 格式,直接作为字符串使用
  496. redirect_base = target_app.redirect_uris.strip()
  497. if not redirect_base:
  498. # Fallback or error
  499. redirect_base = "http://unknown-target-url"
  500. full_redirect_url = f"{redirect_base}?ticket={ticket}"
  501. logger.info(f"票据交换成功: 用户 {req.user_mobile} 从 {req.app_id} -> {req.target_app_id}")
  502. return {
  503. "ticket": ticket,
  504. "redirect_url": full_redirect_url
  505. }
  506. @router.post("/sso-login", response_model=SsoLoginResponse, summary="SSO 登录 (简易模式)")
  507. def sso_login(
  508. req: SsoLoginRequest,
  509. request: Request,
  510. db: Session = Depends(deps.get_db),
  511. current_user: Optional[User] = Depends(deps.get_current_active_user_optional),
  512. ):
  513. """
  514. 简易 API 应用的 SSO 登录。
  515. 返回带有票据的重定向 URL。
  516. 支持:
  517. 1. 用户名 + 密码登录
  518. 2. 基于会话的自动登录(如果已登录)
  519. """
  520. # 1. Verify App
  521. app = db.query(Application).filter(Application.app_id == req.app_id).first()
  522. # Prepare Log
  523. log_create = LoginLogCreate(
  524. ip_address=get_client_ip(request),
  525. login_method=LoginMethod.DIRECT_JUMP,
  526. auth_type=AuthType.SSO,
  527. user_agent=request.headers.get("user-agent"),
  528. mobile=req.username
  529. )
  530. if not app:
  531. log_create.is_success = 0
  532. log_create.failure_reason = "应用未找到"
  533. LoginLogService.create_log(db, log_create)
  534. logger.warning(f"SSO登录失败: 应用 {req.app_id} 未找到")
  535. raise HTTPException(status_code=404, detail="应用未找到")
  536. if app.protocol_type != "SIMPLE_API":
  537. log_create.is_success = 0
  538. log_create.failure_reason = "协议不支持"
  539. LoginLogService.create_log(db, log_create)
  540. logger.warning(f"SSO登录失败: 应用 {req.app_id} 协议类型不支持 ({app.protocol_type})")
  541. raise HTTPException(status_code=400, detail="SSO 登录仅支持简易 API 应用。OIDC 请使用标准流程。")
  542. user = None
  543. # 2. Try Session Login first
  544. if current_user:
  545. user = current_user
  546. log_create.user_id = user.id
  547. log_create.mobile = user.mobile
  548. log_create.auth_type = AuthType.TOKEN # Used existing session
  549. # 3. If no session, try Credentials Login
  550. if not user and req.username and req.password:
  551. log_create.auth_type = AuthType.PASSWORD
  552. # Verify User Credentials
  553. user_query = db.query(User).filter(User.mobile == req.username, User.is_deleted == 0).first()
  554. if not user_query:
  555. # Check mapping
  556. mapping = db.query(AppUserMapping).filter(
  557. AppUserMapping.app_id == app.id,
  558. (AppUserMapping.mapped_key == req.username) | (AppUserMapping.mapped_email == req.username)
  559. ).first()
  560. if mapping:
  561. user_query = db.query(User).filter(User.id == mapping.user_id, User.is_deleted == 0).first()
  562. if user_query and security.verify_password(req.password, user_query.password_hash):
  563. user = user_query
  564. log_create.user_id = user.id
  565. if not user:
  566. log_create.is_success = 0
  567. log_create.failure_reason = "认证失败"
  568. LoginLogService.create_log(db, log_create)
  569. logger.warning(f"SSO登录失败: 用户认证失败 (Username: {req.username})")
  570. raise HTTPException(status_code=401, detail="认证失败")
  571. if user.status != "ACTIVE":
  572. log_create.is_success = 0
  573. log_create.failure_reason = "用户已禁用"
  574. LoginLogService.create_log(db, log_create)
  575. logger.warning(f"SSO登录失败: 用户 {user.mobile} 已被禁用")
  576. raise HTTPException(status_code=400, detail="用户已禁用")
  577. # 4. Generate Ticket
  578. ticket = TicketService.generate_ticket(user.id, req.app_id)
  579. # Log Success
  580. LoginLogService.create_log(db, log_create)
  581. logger.info(f"SSO登录成功: 用户 {user.mobile} 获取 Ticket (App: {req.app_id})")
  582. # 5. Get Redirect URL
  583. redirect_base = ""
  584. if app.redirect_uris:
  585. try:
  586. # 尝试作为 JSON 数组解析
  587. uris = json.loads(app.redirect_uris)
  588. if isinstance(uris, list) and len(uris) > 0:
  589. redirect_base = uris[0]
  590. elif isinstance(uris, str):
  591. redirect_base = uris
  592. except (json.JSONDecodeError, TypeError):
  593. # 如果不是 JSON 格式,直接作为字符串使用
  594. redirect_base = app.redirect_uris.strip()
  595. if not redirect_base:
  596. logger.error(f"SSO登录配置错误: 应用 {req.app_id} 未配置回调地址")
  597. raise HTTPException(status_code=400, detail="应用未配置重定向 URI")
  598. full_redirect_url = f"{redirect_base}?ticket={ticket}"
  599. return {"redirect_url": full_redirect_url}
  600. @router.post("/validate", response_model=TicketValidateResponse, summary="验证票据")
  601. def validate_ticket(
  602. req: TicketValidateRequest,
  603. db: Session = Depends(deps.get_db),
  604. ):
  605. """
  606. 目标应用调用以消费票据。
  607. """
  608. # 1. Verify App
  609. app = db.query(Application).filter(Application.app_id == req.app_id).first()
  610. if not app:
  611. logger.warning(f"票据验证失败: 应用 {req.app_id} 未找到")
  612. raise HTTPException(status_code=404, detail="应用未找到")
  613. # 2. Verify Signature
  614. params = {
  615. "ticket": req.ticket,
  616. "app_id": req.app_id,
  617. "timestamp": req.timestamp,
  618. "sign": req.sign
  619. }
  620. if not SignatureService.verify_signature(app.app_secret, params, req.sign):
  621. logger.warning(f"票据验证失败: 应用 {req.app_id} 签名无效")
  622. raise HTTPException(status_code=400, detail="签名无效")
  623. # 3. Consume Ticket
  624. ticket_data = TicketService.consume_ticket(req.ticket, req.app_id)
  625. if not ticket_data:
  626. logger.warning(f"票据验证失败: Ticket 无效或已过期 (App: {req.app_id})")
  627. return {"valid": False}
  628. user_id = ticket_data["user_id"]
  629. # 4. Get User Info & Mapping
  630. user = db.query(User).filter(User.id == user_id).first()
  631. mapping = db.query(AppUserMapping).filter(
  632. AppUserMapping.app_id == app.id,
  633. AppUserMapping.user_id == user_id
  634. ).first()
  635. mapped_key = mapping.mapped_key if mapping else None
  636. mapped_email = mapping.mapped_email if mapping else None
  637. logger.info(f"票据验证成功: 用户 {user.mobile} (App: {req.app_id})")
  638. return {
  639. "valid": True,
  640. "user_id": user.id,
  641. "mobile": user.mobile,
  642. "mapped_key": mapped_key,
  643. "mapped_email": mapped_email
  644. }