services.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. import hashlib
  2. import json
  3. from dataclasses import dataclass
  4. from datetime import datetime
  5. from app.db.models import ApiKey, Role, RoleAssignment, RolePermissionBinding, User
  6. from app.domain.repositories import (
  7. ApiKeyRepository,
  8. RoleAssignmentRepository,
  9. RolePermissionBindingRepository,
  10. RoleRepository,
  11. UserRepository,
  12. )
  13. from app.infrastructure.api_keys import generate_api_key, get_api_key_prefix, hash_api_key
  14. from app.infrastructure.passwords import verify_password
  15. from app.infrastructure.tokens import TokenError, issue_access_token, verify_access_token
  16. @dataclass(frozen=True)
  17. class LoginResult:
  18. access_token: str
  19. expires_time: datetime
  20. user: User
  21. @dataclass(frozen=True)
  22. class TokenVerificationResult:
  23. active: bool
  24. user_id: str | None = None
  25. username: str | None = None
  26. expires_time: datetime | None = None
  27. reason: str | None = None
  28. @dataclass(frozen=True)
  29. class PermissionCheckResult:
  30. allowed: bool
  31. reason: str
  32. matched_role_ids: list[str]
  33. class AuthApplicationService:
  34. def __init__(
  35. self,
  36. *,
  37. user_repository: UserRepository,
  38. role_repository: RoleRepository,
  39. assignment_repository: RoleAssignmentRepository,
  40. permission_binding_repository: RolePermissionBindingRepository,
  41. api_key_repository: ApiKeyRepository,
  42. token_secret: str,
  43. redis_client: object | None = None,
  44. permission_cache_ttl_seconds: int = 60) -> None:
  45. self.user_repository = user_repository
  46. self.role_repository = role_repository
  47. self.assignment_repository = assignment_repository
  48. self.permission_binding_repository = permission_binding_repository
  49. self.api_key_repository = api_key_repository
  50. self.token_secret = token_secret
  51. self.redis_client = redis_client
  52. self.permission_cache_ttl_seconds = permission_cache_ttl_seconds
  53. def login(self, *, username: str, password: str) -> LoginResult | None:
  54. user = self.user_repository.get_by_username(username=username)
  55. if user is None or user.status != "active":
  56. return None
  57. if not verify_password(password, user.password_hash):
  58. return None
  59. self.user_repository.touch_last_login_time(user_id=user.id)
  60. access_token, expires_time = issue_access_token(
  61. user_id=user.id,
  62. secret=self.token_secret)
  63. return LoginResult(
  64. access_token=access_token,
  65. expires_time=expires_time,
  66. user=user)
  67. def verify_token(self, *, access_token: str) -> TokenVerificationResult:
  68. if self._is_token_revoked(access_token=access_token):
  69. return TokenVerificationResult(active=False, reason="token_revoked")
  70. try:
  71. token_payload = verify_access_token(access_token, secret=self.token_secret)
  72. except TokenError as exc:
  73. return TokenVerificationResult(active=False, reason=str(exc))
  74. expires_time_raw = token_payload["expires_time"]
  75. user = self.user_repository.get_by_id(user_id=token_payload["user_id"])
  76. if user is None or user.status != "active":
  77. return TokenVerificationResult(active=False, reason="user_not_active")
  78. return TokenVerificationResult(
  79. active=True,
  80. user_id=user.id,
  81. username=user.username,
  82. expires_time=datetime.fromisoformat(expires_time_raw.removesuffix("Z")))
  83. def logout(self, *, access_token: str | None) -> bool:
  84. if not access_token or self.redis_client is None:
  85. return True
  86. try:
  87. token_payload = verify_access_token(access_token, secret=self.token_secret)
  88. except TokenError:
  89. return True
  90. expires_time = datetime.fromisoformat(token_payload["expires_time"].removesuffix("Z"))
  91. ttl_seconds = max(1, int((expires_time - datetime.utcnow()).total_seconds()))
  92. try:
  93. self.redis_client.set(
  94. self._revoked_token_key(access_token=access_token),
  95. "1",
  96. ex=ttl_seconds)
  97. except Exception:
  98. return False
  99. return True
  100. def list_users(self) -> list[User]:
  101. return self.user_repository.list_all()
  102. def list_users_page(
  103. self,
  104. *,
  105. page: int,
  106. page_size: int,
  107. keyword: str | None) -> tuple[list[User], int]:
  108. return self.user_repository.list_page(
  109. offset=(page - 1) * page_size,
  110. limit=page_size,
  111. keyword=keyword)
  112. def list_roles(self) -> list[Role]:
  113. return self.role_repository.list_all()
  114. def list_roles_page(
  115. self,
  116. *,
  117. page: int,
  118. page_size: int,
  119. keyword: str | None) -> tuple[list[Role], int]:
  120. return self.role_repository.list_page(
  121. offset=(page - 1) * page_size,
  122. limit=page_size,
  123. keyword=keyword)
  124. def list_assignments(self, *, user_id: str) -> list[RoleAssignment]:
  125. return self.assignment_repository.list_by_user(user_id=user_id)
  126. def list_role_permission_bindings(
  127. self,
  128. *,
  129. role_id: str,
  130. page: int,
  131. page_size: int) -> tuple[list[RolePermissionBinding], int]:
  132. return self.permission_binding_repository.list_by_role(
  133. role_id=role_id,
  134. offset=(page - 1) * page_size,
  135. limit=page_size)
  136. def add_role_permission_binding(
  137. self,
  138. *,
  139. role_id: str,
  140. permission: str,
  141. scope_type: str | None,
  142. scope_id: str | None) -> RolePermissionBinding:
  143. return self.permission_binding_repository.create(
  144. role_id=role_id,
  145. permission=permission,
  146. scope_type=scope_type,
  147. scope_id=scope_id)
  148. def remove_role_permission_binding(self, *, binding_id: str) -> bool:
  149. return self.permission_binding_repository.delete(binding_id=binding_id)
  150. def list_api_keys_page(
  151. self,
  152. *,
  153. page: int,
  154. page_size: int,
  155. keyword: str | None) -> tuple[list[ApiKey], int]:
  156. return self.api_key_repository.list_page(
  157. offset=(page - 1) * page_size,
  158. limit=page_size,
  159. keyword=keyword)
  160. def create_api_key(
  161. self,
  162. *,
  163. name: str,
  164. scopes: str | None,
  165. expires_time: datetime | None) -> tuple[ApiKey, str]:
  166. secret = generate_api_key()
  167. entity = self.api_key_repository.create(
  168. name=name,
  169. key_prefix=get_api_key_prefix(secret),
  170. key_hash=hash_api_key(secret),
  171. scopes=scopes,
  172. expires_time=expires_time)
  173. return entity, secret
  174. def revoke_api_key(self, *, api_key_id: str) -> ApiKey | None:
  175. return self.api_key_repository.revoke(api_key_id=api_key_id)
  176. def check_permission(
  177. self,
  178. *,
  179. user_id: str,
  180. permission: str,
  181. scope_type: str | None,
  182. scope_id: str | None) -> PermissionCheckResult:
  183. cached_result = self._read_permission_cache(
  184. user_id=user_id,
  185. permission=permission,
  186. scope_type=scope_type,
  187. scope_id=scope_id)
  188. if cached_result is not None:
  189. return cached_result
  190. result = self._check_permission_uncached(
  191. user_id=user_id,
  192. permission=permission,
  193. scope_type=scope_type,
  194. scope_id=scope_id)
  195. self._write_permission_cache(
  196. user_id=user_id,
  197. permission=permission,
  198. scope_type=scope_type,
  199. scope_id=scope_id,
  200. result=result)
  201. return result
  202. def _check_permission_uncached(
  203. self,
  204. *,
  205. user_id: str,
  206. permission: str,
  207. scope_type: str | None,
  208. scope_id: str | None) -> PermissionCheckResult:
  209. user = self.user_repository.get_by_id(user_id=user_id)
  210. if user is None or user.status != "active":
  211. return PermissionCheckResult(
  212. allowed=False,
  213. reason="user_not_active",
  214. matched_role_ids=[])
  215. assignments = self.assignment_repository.list_by_user(user_id=user_id)
  216. matched_role_ids: list[str] = []
  217. now = datetime.utcnow()
  218. for assignment in assignments:
  219. if assignment.status != "active":
  220. continue
  221. if assignment.expires_time is not None and assignment.expires_time <= now:
  222. continue
  223. if not self._scope_matches(
  224. assignment=assignment,
  225. scope_type=scope_type,
  226. scope_id=scope_id):
  227. continue
  228. role = self.role_repository.get_by_id(role_id=assignment.role_id)
  229. if role is None or role.status != "active":
  230. continue
  231. if self._role_has_permission(
  232. role,
  233. permission,
  234. scope_type=scope_type,
  235. scope_id=scope_id):
  236. matched_role_ids.append(role.id)
  237. return PermissionCheckResult(
  238. allowed=bool(matched_role_ids),
  239. reason="matched" if matched_role_ids else "permission_not_found",
  240. matched_role_ids=matched_role_ids)
  241. def _is_token_revoked(self, *, access_token: str) -> bool:
  242. if self.redis_client is None:
  243. return False
  244. try:
  245. return self.redis_client.exists(self._revoked_token_key(access_token=access_token)) > 0
  246. except Exception:
  247. return False
  248. def _revoked_token_key(self, *, access_token: str) -> str:
  249. return f"auth:revoked-token:{self._token_digest(access_token)}"
  250. def _token_digest(self, access_token: str) -> str:
  251. return hashlib.sha256(access_token.encode("utf-8")).hexdigest()
  252. def _read_permission_cache(
  253. self,
  254. *,
  255. user_id: str,
  256. permission: str,
  257. scope_type: str | None,
  258. scope_id: str | None) -> PermissionCheckResult | None:
  259. if self.redis_client is None:
  260. return None
  261. try:
  262. raw_value = self.redis_client.get(
  263. self._permission_cache_key(
  264. user_id=user_id,
  265. permission=permission,
  266. scope_type=scope_type,
  267. scope_id=scope_id))
  268. except Exception:
  269. return None
  270. if not isinstance(raw_value, (bytes, str)):
  271. return None
  272. decoded = raw_value.decode("utf-8") if isinstance(raw_value, bytes) else raw_value
  273. try:
  274. payload = json.loads(decoded)
  275. except json.JSONDecodeError:
  276. return None
  277. if not isinstance(payload, dict):
  278. return None
  279. matched_role_ids = payload.get("matched_role_ids")
  280. if not isinstance(matched_role_ids, list):
  281. return None
  282. return PermissionCheckResult(
  283. allowed=bool(payload.get("allowed")),
  284. reason=str(payload.get("reason") or "cached"),
  285. matched_role_ids=[
  286. item for item in matched_role_ids
  287. if isinstance(item, str)
  288. ])
  289. def _write_permission_cache(
  290. self,
  291. *,
  292. user_id: str,
  293. permission: str,
  294. scope_type: str | None,
  295. scope_id: str | None,
  296. result: PermissionCheckResult) -> None:
  297. if self.redis_client is None or self.permission_cache_ttl_seconds <= 0:
  298. return
  299. payload = {
  300. "allowed": result.allowed,
  301. "reason": result.reason,
  302. "matched_role_ids": result.matched_role_ids,
  303. }
  304. try:
  305. self.redis_client.set(
  306. self._permission_cache_key(
  307. user_id=user_id,
  308. permission=permission,
  309. scope_type=scope_type,
  310. scope_id=scope_id),
  311. json.dumps(payload, ensure_ascii=False),
  312. ex=self.permission_cache_ttl_seconds)
  313. except Exception:
  314. return
  315. def _permission_cache_key(
  316. self,
  317. *,
  318. user_id: str,
  319. permission: str,
  320. scope_type: str | None,
  321. scope_id: str | None) -> str:
  322. raw_key = json.dumps(
  323. {
  324. "user_id": user_id,
  325. "permission": permission,
  326. "scope_type": scope_type,
  327. "scope_id": scope_id,
  328. },
  329. sort_keys=True,
  330. separators=(",", ":"))
  331. digest = hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
  332. return f"auth:permission-check:{digest}"
  333. def _permission_matches(self, permissions: list[str], requested_permission: str) -> bool:
  334. if "*" in permissions or requested_permission in permissions:
  335. return True
  336. return any(
  337. permission.endswith(":*")
  338. and requested_permission.startswith(permission.removesuffix("*"))
  339. for permission in permissions
  340. )
  341. def _scope_matches(
  342. self,
  343. *,
  344. assignment: RoleAssignment,
  345. scope_type: str | None,
  346. scope_id: str | None) -> bool:
  347. if assignment.scope_type is None and assignment.scope_id is None:
  348. return True
  349. return assignment.scope_type == scope_type and assignment.scope_id == scope_id
  350. def _role_has_permission(
  351. self,
  352. role: Role,
  353. requested_permission: str,
  354. *,
  355. scope_type: str | None,
  356. scope_id: str | None) -> bool:
  357. bindings = self.permission_binding_repository.list_all_by_role(role_id=role.id)
  358. return any(
  359. (binding.scope_type is None and binding.scope_id is None
  360. or (binding.scope_type == scope_type and binding.scope_id == scope_id))
  361. and
  362. self._permission_matches([binding.permission], requested_permission)
  363. for binding in bindings
  364. )