simple_auth.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557
  1. from typing import Optional, List
  2. import json
  3. from fastapi import APIRouter, Depends, HTTPException, Body
  4. from sqlalchemy.orm import Session
  5. from pydantic import BaseModel
  6. from app.api.v1 import deps
  7. from app.core import security
  8. from app.models.user import User, UserRole, UserStatus
  9. from app.models.application import Application
  10. from app.models.mapping import AppUserMapping
  11. from app.schemas.simple_auth import (
  12. TicketExchangeRequest, TicketExchangeResponse,
  13. TicketValidateRequest, TicketValidateResponse,
  14. PasswordLoginRequest, PasswordLoginResponse,
  15. UserRegisterRequest, AdminPasswordResetRequest, AdminPasswordResetResponse,
  16. ChangePasswordRequest, MyMappingsResponse, UserMappingResponse,
  17. UserPromoteRequest, SsoLoginRequest, SsoLoginResponse
  18. )
  19. from app.services.signature_service import SignatureService
  20. from app.services.ticket_service import TicketService
  21. from app.services.log_service import LogService
  22. from app.services.login_log_service import LoginLogService
  23. from app.schemas.operation_log import ActionType
  24. from app.schemas.login_log import LoginLogCreate, LoginMethod, AuthType
  25. from fastapi import Request
  26. router = APIRouter()
  27. @router.post("/login", response_model=PasswordLoginResponse, summary="密码登录")
  28. def login_with_password(
  29. req: PasswordLoginRequest,
  30. request: Request,
  31. db: Session = Depends(deps.get_db),
  32. ):
  33. """
  34. 1. 如果提供 app_id:应用 SSO 登录,返回 ticket。
  35. 2. 如果未提供 app_id:统一认证平台登录,返回 access_token。
  36. """
  37. # --- Platform Login ---
  38. if not req.app_id:
  39. # Prepare Log
  40. log_create = LoginLogCreate(
  41. mobile=req.identifier,
  42. ip_address=request.client.host,
  43. login_method=LoginMethod.UNIFIED_PAGE,
  44. auth_type=AuthType.PASSWORD,
  45. user_agent=request.headers.get("user-agent")
  46. )
  47. # Find user by mobile only
  48. user = db.query(User).filter(User.mobile == req.identifier, User.is_deleted == 0).first()
  49. if not user:
  50. log_create.is_success = 0
  51. log_create.failure_reason = "用户未找到"
  52. LoginLogService.create_log(db, log_create)
  53. raise HTTPException(status_code=404, detail="用户未找到")
  54. log_create.user_id = user.id
  55. is_valid = security.verify_password(req.password, user.password_hash)
  56. if not is_valid:
  57. import logging
  58. logger = logging.getLogger(__name__)
  59. logger.error(f"Platform Login failed for user {user.mobile}")
  60. log_create.is_success = 0
  61. log_create.failure_reason = "密码错误"
  62. LoginLogService.create_log(db, log_create)
  63. raise HTTPException(status_code=401, detail="密码错误")
  64. if user.status != UserStatus.ACTIVE:
  65. log_create.is_success = 0
  66. log_create.failure_reason = "用户已禁用"
  67. LoginLogService.create_log(db, log_create)
  68. raise HTTPException(status_code=400, detail="用户已禁用")
  69. # Generate JWT Access Token
  70. access_token = security.create_access_token(user.id)
  71. # Log Success
  72. LoginLogService.create_log(db, log_create)
  73. return {
  74. "access_token": access_token,
  75. "token_type": "bearer",
  76. "role": user.role
  77. }
  78. # --- App SSO Login ---
  79. log_create = LoginLogCreate(
  80. mobile=req.identifier,
  81. ip_address=request.client.host,
  82. login_method=LoginMethod.CUSTOM_PAGE, # 假设应用自定义页面调用此接口
  83. auth_type=AuthType.PASSWORD,
  84. user_agent=request.headers.get("user-agent")
  85. )
  86. # 1. Verify App
  87. app = db.query(Application).filter(Application.app_id == req.app_id).first()
  88. if not app:
  89. log_create.is_success = 0
  90. log_create.failure_reason = "应用未找到"
  91. LoginLogService.create_log(db, log_create)
  92. raise HTTPException(status_code=404, detail="应用未找到")
  93. # 2. Verify Signature (Optional but recommended for server-side calls)
  94. if req.sign and req.timestamp:
  95. params = {
  96. "app_id": req.app_id,
  97. "identifier": req.identifier,
  98. "password": req.password,
  99. "timestamp": req.timestamp,
  100. "sign": req.sign
  101. }
  102. if not SignatureService.verify_signature(app.app_secret, params, req.sign):
  103. log_create.is_success = 0
  104. log_create.failure_reason = "签名无效"
  105. LoginLogService.create_log(db, log_create)
  106. raise HTTPException(status_code=400, detail="签名无效")
  107. # 3. Find User
  108. user = None
  109. # Auto-trim password to prevent common copy-paste errors
  110. if req.password:
  111. req.password = req.password.strip()
  112. # Try by mobile
  113. user = db.query(User).filter(User.mobile == req.identifier, User.is_deleted == 0).first()
  114. if not user:
  115. # Try by mapping
  116. mapping = db.query(AppUserMapping).filter(
  117. AppUserMapping.app_id == app.id,
  118. (AppUserMapping.mapped_key == req.identifier) | (AppUserMapping.mapped_email == req.identifier)
  119. ).first()
  120. if mapping:
  121. user = db.query(User).filter(User.id == mapping.user_id, User.is_deleted == 0).first()
  122. if not user:
  123. log_create.is_success = 0
  124. log_create.failure_reason = "用户未找到"
  125. LoginLogService.create_log(db, log_create)
  126. raise HTTPException(status_code=404, detail="用户未找到")
  127. log_create.user_id = user.id
  128. if user.status != UserStatus.ACTIVE:
  129. log_create.is_success = 0
  130. log_create.failure_reason = "用户已禁用"
  131. LoginLogService.create_log(db, log_create)
  132. raise HTTPException(status_code=400, detail="用户已禁用")
  133. # 4. Verify Password
  134. import logging
  135. logger = logging.getLogger(__name__)
  136. # DEBUG: Log password verification details
  137. is_valid = security.verify_password(req.password, user.password_hash)
  138. if not is_valid:
  139. logger.error(f"Password verification failed for user {user.mobile}")
  140. log_create.is_success = 0
  141. log_create.failure_reason = "密码错误"
  142. LoginLogService.create_log(db, log_create)
  143. raise HTTPException(status_code=401, detail="密码错误")
  144. # 5. Generate Ticket (Self-Targeting)
  145. ticket = TicketService.generate_ticket(user.id, req.app_id)
  146. # Log Success (AuthType is PASSWORD leading to TICKET generation, keeping PASSWORD is fine or TICKET)
  147. # User requirement: "包括...认证方式". Here the auth method was PASSWORD.
  148. LoginLogService.create_log(db, log_create)
  149. return {"ticket": ticket}
  150. @router.post("/register", response_model=PasswordLoginResponse, summary="用户注册")
  151. def register_user(
  152. req: UserRegisterRequest,
  153. db: Session = Depends(deps.get_db),
  154. ):
  155. """
  156. 注册新用户 (默认为普通用户)。
  157. """
  158. # Force role to ORDINARY_USER
  159. role = UserRole.ORDINARY_USER
  160. # Auto-login after registration (return token)
  161. if req.password:
  162. req.password = req.password.strip()
  163. existing_user = db.query(User).filter(User.mobile == req.mobile, User.is_deleted == 0).first()
  164. if existing_user:
  165. raise HTTPException(status_code=400, detail="手机号已注册")
  166. new_user = User(
  167. mobile=req.mobile,
  168. password_hash=security.get_password_hash(req.password),
  169. status=UserStatus.ACTIVE,
  170. role=role
  171. )
  172. db.add(new_user)
  173. db.commit()
  174. db.refresh(new_user)
  175. # Auto-login after registration (return token)
  176. access_token = security.create_access_token(new_user.id)
  177. return {
  178. "access_token": access_token,
  179. "token_type": "bearer",
  180. "role": new_user.role
  181. }
  182. @router.post("/admin/reset-password", response_model=AdminPasswordResetResponse, summary="管理员重置密码")
  183. def admin_reset_password(
  184. req: AdminPasswordResetRequest,
  185. request: Request,
  186. db: Session = Depends(deps.get_db),
  187. current_user: User = Depends(deps.get_current_active_user),
  188. ):
  189. """
  190. 超级管理员重置用户密码。
  191. 随机生成8位密码,只显示一次。
  192. """
  193. if current_user.role != UserRole.SUPER_ADMIN:
  194. raise HTTPException(status_code=403, detail="权限不足")
  195. # Verify Admin Password
  196. if not security.verify_password(req.admin_password, current_user.password_hash):
  197. raise HTTPException(status_code=401, detail="管理员密码错误")
  198. target_user = db.query(User).filter(User.id == req.user_id).first()
  199. if not target_user:
  200. raise HTTPException(status_code=404, detail="用户未找到")
  201. # Generate random password (alphanumeric only)
  202. new_pwd = security.generate_alphanumeric_password(8)
  203. target_user.password_hash = security.get_password_hash(new_pwd)
  204. db.add(target_user)
  205. db.commit()
  206. # Log Operation
  207. LogService.create_log(
  208. db=db,
  209. operator_id=current_user.id,
  210. action_type=ActionType.RESET_PASSWORD,
  211. target_user_id=target_user.id,
  212. target_mobile=target_user.mobile,
  213. ip_address=request.client.host,
  214. details={}
  215. )
  216. return {"new_password": new_pwd}
  217. @router.post("/admin/promote", summary="提升用户角色")
  218. def promote_user(
  219. req: UserPromoteRequest,
  220. db: Session = Depends(deps.get_db),
  221. current_user: User = Depends(deps.get_current_active_user),
  222. ):
  223. if current_user.role != UserRole.SUPER_ADMIN:
  224. raise HTTPException(status_code=403, detail="权限不足")
  225. if req.new_role not in [UserRole.SUPER_ADMIN, UserRole.DEVELOPER]:
  226. raise HTTPException(status_code=400, detail="只能提升为管理员 or 开发者")
  227. target_user = db.query(User).filter(User.id == req.user_id).first()
  228. if not target_user:
  229. raise HTTPException(status_code=404, detail="用户未找到")
  230. target_user.role = req.new_role
  231. db.add(target_user)
  232. db.commit()
  233. return {"message": "success"}
  234. @router.get("/me/mappings", response_model=MyMappingsResponse, summary="我的映射")
  235. def get_my_mappings(
  236. skip: int = 0,
  237. limit: int = 10,
  238. app_name: str = None,
  239. db: Session = Depends(deps.get_db),
  240. current_user: User = Depends(deps.get_current_active_user),
  241. ):
  242. query = db.query(AppUserMapping).join(Application).filter(AppUserMapping.user_id == current_user.id)
  243. if app_name:
  244. query = query.filter(Application.app_name.ilike(f"%{app_name}%"))
  245. total = query.count()
  246. mappings = query.order_by(AppUserMapping.id.desc()).offset(skip).limit(limit).all()
  247. result = []
  248. for m in mappings:
  249. result.append(UserMappingResponse(
  250. app_name=m.application.app_name if m.application else "Unknown",
  251. app_id=m.application.app_id if m.application else "",
  252. protocol_type=m.application.protocol_type if m.application else "",
  253. mapped_key=m.mapped_key,
  254. mapped_email=m.mapped_email,
  255. is_active=m.is_active
  256. ))
  257. return {"total": total, "items": result}
  258. @router.post("/me/change-password", summary="修改密码")
  259. def change_my_password(
  260. req: ChangePasswordRequest,
  261. db: Session = Depends(deps.get_db),
  262. current_user: User = Depends(deps.get_current_active_user),
  263. ):
  264. if not security.verify_password(req.old_password, current_user.password_hash):
  265. raise HTTPException(status_code=400, detail="旧密码错误")
  266. if req.new_password:
  267. req.new_password = req.new_password.strip()
  268. current_user.password_hash = security.get_password_hash(req.new_password)
  269. db.add(current_user)
  270. db.commit()
  271. return {"message": "密码修改成功"}
  272. @router.post("/exchange", response_model=TicketExchangeResponse, summary="票据交换")
  273. def exchange_ticket(
  274. req: TicketExchangeRequest,
  275. db: Session = Depends(deps.get_db),
  276. ):
  277. """
  278. 源应用调用以获取目标应用的票据。
  279. """
  280. # 1. Verify Source App
  281. source_app = db.query(Application).filter(Application.app_id == req.app_id).first()
  282. if not source_app:
  283. raise HTTPException(status_code=404, detail="源应用未找到")
  284. # 2. Verify Signature
  285. params = {
  286. "app_id": req.app_id,
  287. "target_app_id": req.target_app_id,
  288. "user_mobile": req.user_mobile,
  289. "timestamp": req.timestamp,
  290. "sign": req.sign
  291. }
  292. # Use the stored secret to verify
  293. if not SignatureService.verify_signature(source_app.app_secret, params, req.sign):
  294. raise HTTPException(status_code=400, detail="签名无效")
  295. # 3. Verify User Existence (Optional: Do we trust source app completely? Usually yes if signed.)
  296. # But we need user_id to generate ticket.
  297. # We query by mobile.
  298. user = db.query(User).filter(User.mobile == req.user_mobile, User.is_deleted == 0).first()
  299. if not user:
  300. # If user doesn't exist, we might auto-create OR fail.
  301. # Requirement: "Returns redirect_url".
  302. # For simplicity, if user not found, we cannot map.
  303. raise HTTPException(status_code=404, detail="用户在 UAP 中未找到")
  304. # 4. Generate Ticket for Target App
  305. # Logic: The ticket allows the user to log in to Target App.
  306. ticket = TicketService.generate_ticket(user.id, req.target_app_id)
  307. # 5. Get Target App URL
  308. target_app = db.query(Application).filter(Application.app_id == req.target_app_id).first()
  309. if not target_app:
  310. raise HTTPException(status_code=404, detail="目标应用未找到")
  311. # Construct redirect URL
  312. # Assuming target app handles /callback?ticket=...
  313. # We use the first redirect_uri or notification_url or custom logic.
  314. # Simplicity: We return the ticket and let the Source App handle the redirect,
  315. # OR we return a full redirect URL if target_app has a base URL configured.
  316. # Let's assume redirect_uris is a JSON list.
  317. redirect_base = ""
  318. if target_app.redirect_uris:
  319. try:
  320. # 尝试作为 JSON 数组解析
  321. uris = json.loads(target_app.redirect_uris)
  322. if isinstance(uris, list) and len(uris) > 0:
  323. redirect_base = uris[0]
  324. elif isinstance(uris, str):
  325. redirect_base = uris
  326. except (json.JSONDecodeError, TypeError):
  327. # 如果不是 JSON 格式,直接作为字符串使用
  328. redirect_base = target_app.redirect_uris.strip()
  329. if not redirect_base:
  330. # Fallback or error
  331. redirect_base = "http://unknown-target-url"
  332. full_redirect_url = f"{redirect_base}?ticket={ticket}"
  333. return {
  334. "ticket": ticket,
  335. "redirect_url": full_redirect_url
  336. }
  337. @router.post("/sso-login", response_model=SsoLoginResponse, summary="SSO 登录 (简易模式)")
  338. def sso_login(
  339. req: SsoLoginRequest,
  340. request: Request,
  341. db: Session = Depends(deps.get_db),
  342. current_user: Optional[User] = Depends(deps.get_current_active_user_optional),
  343. ):
  344. """
  345. 简易 API 应用的 SSO 登录。
  346. 返回带有票据的重定向 URL。
  347. 支持:
  348. 1. 用户名 + 密码登录
  349. 2. 基于会话的自动登录(如果已登录)
  350. """
  351. # 1. Verify App
  352. app = db.query(Application).filter(Application.app_id == req.app_id).first()
  353. # Prepare Log
  354. log_create = LoginLogCreate(
  355. ip_address=request.client.host,
  356. login_method=LoginMethod.DIRECT_JUMP,
  357. auth_type=AuthType.SSO,
  358. user_agent=request.headers.get("user-agent"),
  359. mobile=req.username
  360. )
  361. if not app:
  362. log_create.is_success = 0
  363. log_create.failure_reason = "应用未找到"
  364. LoginLogService.create_log(db, log_create)
  365. raise HTTPException(status_code=404, detail="应用未找到")
  366. if app.protocol_type != "SIMPLE_API":
  367. log_create.is_success = 0
  368. log_create.failure_reason = "协议不支持"
  369. LoginLogService.create_log(db, log_create)
  370. raise HTTPException(status_code=400, detail="SSO 登录仅支持简易 API 应用。OIDC 请使用标准流程。")
  371. user = None
  372. # 2. Try Session Login first
  373. if current_user:
  374. user = current_user
  375. log_create.user_id = user.id
  376. log_create.mobile = user.mobile
  377. log_create.auth_type = AuthType.TOKEN # Used existing session
  378. # 3. If no session, try Credentials Login
  379. if not user and req.username and req.password:
  380. log_create.auth_type = AuthType.PASSWORD
  381. # Verify User Credentials
  382. user_query = db.query(User).filter(User.mobile == req.username, User.is_deleted == 0).first()
  383. if not user_query:
  384. # Check mapping
  385. mapping = db.query(AppUserMapping).filter(
  386. AppUserMapping.app_id == app.id,
  387. (AppUserMapping.mapped_key == req.username) | (AppUserMapping.mapped_email == req.username)
  388. ).first()
  389. if mapping:
  390. user_query = db.query(User).filter(User.id == mapping.user_id, User.is_deleted == 0).first()
  391. if user_query and security.verify_password(req.password, user_query.password_hash):
  392. user = user_query
  393. log_create.user_id = user.id
  394. if not user:
  395. log_create.is_success = 0
  396. log_create.failure_reason = "认证失败"
  397. LoginLogService.create_log(db, log_create)
  398. raise HTTPException(status_code=401, detail="认证失败")
  399. if user.status != "ACTIVE":
  400. log_create.is_success = 0
  401. log_create.failure_reason = "用户已禁用"
  402. LoginLogService.create_log(db, log_create)
  403. raise HTTPException(status_code=400, detail="用户已禁用")
  404. # 4. Generate Ticket
  405. ticket = TicketService.generate_ticket(user.id, req.app_id)
  406. # Log Success
  407. LoginLogService.create_log(db, log_create)
  408. # 5. Get Redirect URL
  409. redirect_base = ""
  410. if app.redirect_uris:
  411. try:
  412. # 尝试作为 JSON 数组解析
  413. uris = json.loads(app.redirect_uris)
  414. if isinstance(uris, list) and len(uris) > 0:
  415. redirect_base = uris[0]
  416. elif isinstance(uris, str):
  417. redirect_base = uris
  418. except (json.JSONDecodeError, TypeError):
  419. # 如果不是 JSON 格式,直接作为字符串使用
  420. redirect_base = app.redirect_uris.strip()
  421. if not redirect_base:
  422. raise HTTPException(status_code=400, detail="应用未配置重定向 URI")
  423. full_redirect_url = f"{redirect_base}?ticket={ticket}"
  424. return {"redirect_url": full_redirect_url}
  425. @router.post("/validate", response_model=TicketValidateResponse, summary="验证票据")
  426. def validate_ticket(
  427. req: TicketValidateRequest,
  428. db: Session = Depends(deps.get_db),
  429. ):
  430. """
  431. 目标应用调用以消费票据。
  432. """
  433. # 1. Verify App
  434. app = db.query(Application).filter(Application.app_id == req.app_id).first()
  435. if not app:
  436. raise HTTPException(status_code=404, detail="应用未找到")
  437. # 2. Verify Signature
  438. params = {
  439. "ticket": req.ticket,
  440. "app_id": req.app_id,
  441. "timestamp": req.timestamp,
  442. "sign": req.sign
  443. }
  444. if not SignatureService.verify_signature(app.app_secret, params, req.sign):
  445. raise HTTPException(status_code=400, detail="签名无效")
  446. # 3. Consume Ticket
  447. ticket_data = TicketService.consume_ticket(req.ticket, req.app_id)
  448. if not ticket_data:
  449. return {"valid": False}
  450. user_id = ticket_data["user_id"]
  451. # 4. Get User Info & Mapping
  452. user = db.query(User).filter(User.id == user_id).first()
  453. mapping = db.query(AppUserMapping).filter(
  454. AppUserMapping.app_id == app.id,
  455. AppUserMapping.user_id == user_id
  456. ).first()
  457. mapped_key = mapping.mapped_key if mapping else None
  458. mapped_email = mapping.mapped_email if mapping else None
  459. return {
  460. "valid": True,
  461. "user_id": user.id,
  462. "mobile": user.mobile,
  463. "mapped_key": mapped_key,
  464. "mapped_email": mapped_email
  465. }