micosauth 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
micosauth/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ from .provider import EmptyMicosAccessProvider, MicosAccessProvider
2
+ from .service import MicosService
3
+ from .setting import MicosDefaultsSetting, MicosRealmSetting, MicosRedisSetting, MicosSetting
4
+ from .token_util import MicosTokenUtil
5
+ from .utils.auth import MicosAuthUtil
6
+ from .utils.auth_inner import MicosAuthInnerUtil
7
+ from .utils.session import MicosSessionUtil
8
+
9
+ __all__ = [
10
+ "MicosAccessProvider",
11
+ "EmptyMicosAccessProvider",
12
+ "MicosRedisSetting",
13
+ "MicosRealmSetting",
14
+ "MicosDefaultsSetting",
15
+ "MicosService",
16
+ "MicosSetting",
17
+ "MicosTokenUtil",
18
+ "MicosAuthUtil",
19
+ "MicosAuthInnerUtil",
20
+ "MicosSessionUtil",
21
+ ]
micosauth/_helpers.py ADDED
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ from datetime import datetime, timedelta, timezone
5
+ from fnmatch import fnmatchcase
6
+
7
+
8
+ def utc_now() -> datetime:
9
+ return datetime.now(timezone.utc)
10
+
11
+
12
+ def expire_at(seconds: int) -> datetime:
13
+ return utc_now() + timedelta(seconds=max(1, int(seconds)))
14
+
15
+
16
+ def random_token(length: int = 64) -> str:
17
+ if length <= 0:
18
+ length = 64
19
+ raw = secrets.token_hex((length + 1) // 2)
20
+ return raw[:length]
21
+
22
+
23
+ def permission_matches(granted: str, required: str) -> bool:
24
+ """判断权限是否匹配,支持通配符。"""
25
+
26
+ granted_value = str(granted or "").strip()
27
+ required_value = str(required or "").strip()
28
+ if not granted_value or not required_value:
29
+ return False
30
+ return fnmatchcase(required_value, granted_value) or fnmatchcase(granted_value, required_value)
@@ -0,0 +1 @@
1
+ __all__ = []
@@ -0,0 +1,13 @@
1
+ from .deps import require_login_dep, require_permissions_dep, require_roles_dep
2
+ from .install import install_fastapi_auth
3
+ from .lifespan import build_micosauth_lifespan
4
+ from .middleware import MicosAuthMiddleware
5
+
6
+ __all__ = [
7
+ "install_fastapi_auth",
8
+ "build_micosauth_lifespan",
9
+ "MicosAuthMiddleware",
10
+ "require_login_dep",
11
+ "require_permissions_dep",
12
+ "require_roles_dep",
13
+ ]
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from fastapi import HTTPException, Request, status
6
+
7
+ from ...exceptions import MicosConfigError
8
+ from ...service import MicosService
9
+ from ...utils.auth import MicosAuthUtil
10
+ from ...utils.session import MicosSessionUtil
11
+
12
+
13
+ @dataclass(slots=True)
14
+ class FastapiMicosContext:
15
+ service: MicosService
16
+ auth: MicosAuthUtil
17
+ session: MicosSessionUtil
18
+
19
+
20
+ def get_micos_service(request: Request) -> MicosService:
21
+ service = getattr(request.app.state, "micos_service", None)
22
+ if service is None:
23
+ raise MicosConfigError("FastAPI app has no micos_service")
24
+ return service
25
+
26
+
27
+ def get_micos_context(request: Request) -> FastapiMicosContext:
28
+ service = get_micos_service(request)
29
+ auth = getattr(request.app.state, "micos_auth", None)
30
+ session = getattr(request.app.state, "micos_session", None)
31
+ if auth is None or session is None:
32
+ raise MicosConfigError("FastAPI app has not initialized micosauth utilities")
33
+ return FastapiMicosContext(service=service, auth=auth, session=session)
34
+
35
+
36
+ def get_current_realm_id(request: Request, explicit_realm_id: str | None = None) -> str:
37
+ if explicit_realm_id:
38
+ return explicit_realm_id
39
+ raise MicosConfigError("realm_id is required")
40
+
41
+
42
+ def get_current_token(request: Request, realm_id: str | None = None) -> str:
43
+ resolved_realm_id = get_current_realm_id(request, realm_id)
44
+ realm = get_micos_service(request).get_realm(resolved_realm_id)
45
+ token = request.headers.get(realm.token_name) or request.cookies.get(realm.token_name)
46
+ return str(token or "")
47
+
48
+
49
+ def unauthorized(detail: str = "未登录或令牌无效") -> HTTPException:
50
+ return HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=detail)
51
+
52
+
53
+ def forbidden(detail: str = "无权限访问") -> HTTPException:
54
+ return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail)
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import Request
4
+
5
+ from ...exceptions import MicosConfigError
6
+ from ...exceptions import MicosAuthenticationError, MicosAuthorizationError
7
+ from .context import get_current_realm_id, get_current_token, get_micos_context, unauthorized
8
+
9
+
10
+ def require_login_dep(*, realm: str):
11
+ async def dependency(request: Request):
12
+ context = get_micos_context(request)
13
+ try:
14
+ resolved_realm_id = get_current_realm_id(request, realm)
15
+ except MicosConfigError as exc:
16
+ raise unauthorized(str(exc)) from exc
17
+ token = get_current_token(request, resolved_realm_id)
18
+ if not token:
19
+ raise unauthorized()
20
+ try:
21
+ result = await context.auth.require_login(token, resolved_realm_id)
22
+ except MicosAuthenticationError as exc:
23
+ raise unauthorized(str(exc) or "未登录或令牌无效") from exc
24
+ request.state.micos_token = token
25
+ request.state.micos_realm_id = result.realm_id
26
+ request.state.micos_login_id = result.login_id
27
+ request.state.micos_claims = result.claims
28
+ request.state.micos_session = result.session
29
+ request.state.micos_acl = result.acl
30
+ return result
31
+
32
+ return dependency
33
+
34
+
35
+ def require_permissions_dep(permissions: list[str] | str, *, realm: str, mode: str = "AND"):
36
+ values = [permissions] if isinstance(permissions, str) else list(permissions)
37
+
38
+ async def dependency(request: Request):
39
+ context = get_micos_context(request)
40
+ try:
41
+ resolved_realm_id = get_current_realm_id(request, realm)
42
+ except MicosConfigError as exc:
43
+ raise unauthorized(str(exc)) from exc
44
+ token = get_current_token(request, resolved_realm_id)
45
+ if not token:
46
+ raise unauthorized()
47
+ try:
48
+ await context.auth.require_permissions(token, values, resolved_realm_id, mode)
49
+ except MicosAuthenticationError as exc:
50
+ raise unauthorized(str(exc) or "未登录或令牌无效") from exc
51
+ except MicosAuthorizationError as exc:
52
+ from .context import forbidden
53
+
54
+ raise forbidden(str(exc) or "无权限访问") from exc
55
+ return True
56
+
57
+ return dependency
58
+
59
+
60
+ def require_roles_dep(roles: list[str] | str, *, realm: str, mode: str = "AND"):
61
+ values = [roles] if isinstance(roles, str) else list(roles)
62
+
63
+ async def dependency(request: Request):
64
+ context = get_micos_context(request)
65
+ try:
66
+ resolved_realm_id = get_current_realm_id(request, realm)
67
+ except MicosConfigError as exc:
68
+ raise unauthorized(str(exc)) from exc
69
+ token = get_current_token(request, resolved_realm_id)
70
+ if not token:
71
+ raise unauthorized()
72
+ try:
73
+ await context.auth.require_roles(token, values, resolved_realm_id, mode)
74
+ except MicosAuthenticationError as exc:
75
+ raise unauthorized(str(exc) or "未登录或令牌无效") from exc
76
+ except MicosAuthorizationError as exc:
77
+ from .context import forbidden
78
+
79
+ raise forbidden(str(exc) or "无权限访问") from exc
80
+ return True
81
+
82
+ return dependency
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ from ...utils.auth import MicosAuthUtil
4
+ from ...utils.session import MicosSessionUtil
5
+ from .middleware import MicosAuthMiddleware
6
+
7
+
8
+ def install_fastapi_auth(app, service) -> None:
9
+ app.state.micos_service = service
10
+ app.state.micos_auth = MicosAuthUtil(service)
11
+ app.state.micos_session = MicosSessionUtil(service)
12
+ app.add_middleware(MicosAuthMiddleware)
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import asynccontextmanager
4
+
5
+ from ...utils.auth import MicosAuthUtil
6
+ from ...utils.session import MicosSessionUtil
7
+
8
+
9
+ def build_micosauth_lifespan(service):
10
+ @asynccontextmanager
11
+ async def lifespan(app):
12
+ app.state.micos_service = service
13
+ app.state.micos_auth = MicosAuthUtil(service)
14
+ app.state.micos_session = MicosSessionUtil(service)
15
+ await service.init()
16
+ try:
17
+ yield
18
+ finally:
19
+ await service.close()
20
+
21
+ return lifespan
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import Request
4
+ from starlette.middleware.base import BaseHTTPMiddleware
5
+
6
+ class MicosAuthMiddleware(BaseHTTPMiddleware):
7
+ async def dispatch(self, request: Request, call_next):
8
+ return await call_next(request)
@@ -0,0 +1,9 @@
1
+ from .check_login import require_login
2
+ from .check_permission import require_permissions
3
+ from .check_role import require_roles
4
+
5
+ __all__ = [
6
+ "require_login",
7
+ "require_permissions",
8
+ "require_roles",
9
+ ]
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from functools import wraps
5
+
6
+ from fastapi import HTTPException, Request, status
7
+
8
+ from ..exceptions import MicosAuthenticationError, MicosAuthorizationError
9
+
10
+
11
+ def call_maybe_awaitable(fn, *args, **kwargs):
12
+ result = fn(*args, **kwargs)
13
+ if inspect.isawaitable(result):
14
+ return result
15
+ return result
16
+
17
+
18
+ def get_token_from_call(args, kwargs) -> str:
19
+ token = kwargs.get("token")
20
+ if isinstance(token, str):
21
+ return token
22
+ request = kwargs.get("request")
23
+ if isinstance(request, Request):
24
+ return str(getattr(request.state, "micos_token", "") or "")
25
+ for arg in args:
26
+ if isinstance(arg, Request):
27
+ return str(getattr(arg.state, "micos_token", "") or "")
28
+ if isinstance(arg, str):
29
+ return arg
30
+ return ""
31
+
32
+
33
+ def get_token_from_request_by_realm(args, kwargs, auth_util, realm: str) -> str:
34
+ request = kwargs.get("request")
35
+ if not isinstance(request, Request):
36
+ for arg in args:
37
+ if isinstance(arg, Request):
38
+ request = arg
39
+ break
40
+ if not isinstance(request, Request):
41
+ return ""
42
+ realm_model = auth_util.service.get_realm(realm)
43
+ token = request.headers.get(realm_model.token_name) or request.cookies.get(realm_model.token_name)
44
+ return str(token or "")
45
+
46
+
47
+ def get_request_from_call(args, kwargs):
48
+ request = kwargs.get("request")
49
+ if isinstance(request, Request):
50
+ return request
51
+ for arg in args:
52
+ if isinstance(arg, Request):
53
+ return arg
54
+ return None
55
+
56
+
57
+ def apply_auth_result_to_request(args, kwargs, token: str, result) -> None:
58
+ request = get_request_from_call(args, kwargs)
59
+ if not isinstance(request, Request) or result is None:
60
+ return
61
+ request.state.micos_token = token
62
+ request.state.micos_realm_id = result.realm_id
63
+ request.state.micos_login_id = result.login_id
64
+ request.state.micos_claims = result.claims
65
+ request.state.micos_session = result.session
66
+ request.state.micos_acl = result.acl
67
+
68
+
69
+ def wraps_guard(func, checker):
70
+ @wraps(func)
71
+ async def wrapper(*args, **kwargs):
72
+ try:
73
+ await checker(*args, **kwargs)
74
+ except HTTPException:
75
+ raise
76
+ except MicosAuthenticationError as exc:
77
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc) or "未登录或令牌无效") from exc
78
+ except MicosAuthorizationError as exc:
79
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc) or "无权限访问") from exc
80
+ result = call_maybe_awaitable(func, *args, **kwargs)
81
+ if inspect.isawaitable(result):
82
+ return await result
83
+ return result
84
+
85
+ return wrapper
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from ..exceptions import MicosConfigError
4
+ from ._support import apply_auth_result_to_request, get_token_from_call, get_token_from_request_by_realm, wraps_guard
5
+
6
+
7
+ def require_login(auth_util, *, realm: str):
8
+ if not realm:
9
+ raise MicosConfigError("realm is required")
10
+
11
+ def decorator(func):
12
+ async def checker(*args, **kwargs):
13
+ token = get_token_from_call(args, kwargs) or get_token_from_request_by_realm(args, kwargs, auth_util, realm)
14
+ result = await auth_util.require_login(token, realm)
15
+ apply_auth_result_to_request(args, kwargs, token, result)
16
+
17
+ return wraps_guard(func, checker)
18
+
19
+ return decorator
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from ..exceptions import MicosConfigError
4
+ from ._support import (
5
+ apply_auth_result_to_request,
6
+ get_token_from_call,
7
+ get_token_from_request_by_realm,
8
+ wraps_guard,
9
+ )
10
+
11
+
12
+ def require_permissions(auth_util, permissions: list[str] | str, *, realm: str, mode: str = "AND"):
13
+ if not realm:
14
+ raise MicosConfigError("realm is required")
15
+ values = [permissions] if isinstance(permissions, str) else list(permissions)
16
+
17
+ def decorator(func):
18
+ async def checker(*args, **kwargs):
19
+ token = get_token_from_call(args, kwargs) or get_token_from_request_by_realm(args, kwargs, auth_util, realm)
20
+ await auth_util.require_permissions(token, values, realm, mode)
21
+ result = await auth_util.require_login(token, realm)
22
+ apply_auth_result_to_request(args, kwargs, token, result)
23
+
24
+ return wraps_guard(func, checker)
25
+
26
+ return decorator
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from ..exceptions import MicosConfigError
4
+ from ._support import (
5
+ apply_auth_result_to_request,
6
+ get_token_from_call,
7
+ get_token_from_request_by_realm,
8
+ wraps_guard,
9
+ )
10
+
11
+
12
+ def require_roles(auth_util, roles: list[str] | str, *, realm: str, mode: str = "AND"):
13
+ if not realm:
14
+ raise MicosConfigError("realm is required")
15
+ values = [roles] if isinstance(roles, str) else list(roles)
16
+
17
+ def decorator(func):
18
+ async def checker(*args, **kwargs):
19
+ token = get_token_from_call(args, kwargs) or get_token_from_request_by_realm(args, kwargs, auth_util, realm)
20
+ await auth_util.require_roles(token, values, realm, mode)
21
+ result = await auth_util.require_login(token, realm)
22
+ apply_auth_result_to_request(args, kwargs, token, result)
23
+
24
+ return wraps_guard(func, checker)
25
+
26
+ return decorator
@@ -0,0 +1,26 @@
1
+ class MicosAuthError(Exception):
2
+ """micosauth 基础异常。"""
3
+
4
+
5
+ class MicosConfigError(MicosAuthError):
6
+ """配置无效时抛出。"""
7
+
8
+
9
+ class MicosRedisError(MicosAuthError):
10
+ """Redis 不可用或配置错误时抛出。"""
11
+
12
+
13
+ class MicosRealmError(MicosAuthError):
14
+ """Realm 不存在或无效时抛出。"""
15
+
16
+
17
+ class MicosTokenError(MicosAuthError):
18
+ """Token 无效时抛出。"""
19
+
20
+
21
+ class MicosAuthenticationError(MicosAuthError):
22
+ """认证失败时抛出。"""
23
+
24
+
25
+ class MicosAuthorizationError(MicosAuthError):
26
+ """鉴权失败时抛出。"""
micosauth/guards.py ADDED
@@ -0,0 +1,9 @@
1
+ from .decorators.check_login import require_login
2
+ from .decorators.check_permission import require_permissions
3
+ from .decorators.check_role import require_roles
4
+
5
+ __all__ = [
6
+ "require_login",
7
+ "require_permissions",
8
+ "require_roles",
9
+ ]
micosauth/models.py ADDED
@@ -0,0 +1,125 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from typing import Any
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class MicosAcl:
10
+ roles: list[str] = field(default_factory=list)
11
+ permissions: list[str] = field(default_factory=list)
12
+ data_scopes: list[str] = field(default_factory=list)
13
+ extra: dict[str, Any] = field(default_factory=dict)
14
+
15
+
16
+ @dataclass(slots=True)
17
+ class MicosRealm:
18
+ realm_id: str
19
+ token_name: str = "Authorization"
20
+ token_ttl_seconds: int = 2592000
21
+ temp_token_ttl_seconds: int = 300
22
+ allow_multi_device_login: bool = True
23
+ keep_old_token_on_same_device_login: bool = False
24
+ keep_old_token_on_new_device_login: bool = True
25
+ max_devices_per_login_id: int = 0
26
+ max_tokens_per_login_id: int = 0
27
+ max_tokens_per_device: int = 0
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class MicosTokenClaims:
32
+ realm_id: str
33
+ login_id: str
34
+ device_id: str
35
+ token: str
36
+ issued_at: datetime
37
+ expires_at: datetime
38
+ last_access_at: datetime
39
+ extra: dict[str, Any] = field(default_factory=dict)
40
+
41
+
42
+ @dataclass(slots=True)
43
+ class MicosSessionRecord:
44
+ realm_id: str
45
+ login_id: str
46
+ token_count: int
47
+ device_count: int
48
+ last_login_at: datetime
49
+ last_access_at: datetime
50
+ extra: dict[str, Any] = field(default_factory=dict)
51
+
52
+
53
+ @dataclass(slots=True)
54
+ class MicosTokenRecord:
55
+ realm_id: str
56
+ login_id: str
57
+ device_id: str
58
+ token: str
59
+ issued_at: datetime
60
+ expires_at: datetime
61
+ last_access_at: datetime
62
+ extra: dict[str, Any] = field(default_factory=dict)
63
+
64
+
65
+ @dataclass(slots=True)
66
+ class MicosTempTokenRecord:
67
+ token: str
68
+ temp_id: str
69
+ token_type: str
70
+ realm_id: str
71
+ expires_at: datetime
72
+ extra: dict[str, Any] = field(default_factory=dict)
73
+
74
+
75
+ @dataclass(slots=True)
76
+ class MicosTokenInspectResult:
77
+ valid: bool
78
+ reason: str = ""
79
+ realm_id: str = ""
80
+ login_id: str = ""
81
+ device_id: str = ""
82
+ token: str = ""
83
+ claims: MicosTokenClaims | None = None
84
+ session: MicosSessionRecord | None = None
85
+ acl: MicosAcl | None = None
86
+
87
+
88
+ @dataclass(slots=True)
89
+ class MicosSessionAnalysis:
90
+ total_login_count: int = 0
91
+ total_token_count: int = 0
92
+ total_device_count: int = 0
93
+ one_hour_new_token_count: int = 0
94
+
95
+
96
+ @dataclass(slots=True)
97
+ class MicosSessionChartData:
98
+ days: list[str] = field(default_factory=list)
99
+ realm_series: dict[str, list[int]] = field(default_factory=dict)
100
+
101
+
102
+ @dataclass(slots=True)
103
+ class MicosRealmDistributionItem:
104
+ realm_id: str
105
+ login_count: int = 0
106
+ token_count: int = 0
107
+ device_count: int = 0
108
+
109
+
110
+ @dataclass(slots=True)
111
+ class MicosDeviceSessionSummary:
112
+ realm_id: str
113
+ login_id: str
114
+ device_id: str
115
+ token_count: int = 0
116
+ last_login_at: datetime | None = None
117
+ last_access_at: datetime | None = None
118
+
119
+
120
+ @dataclass(slots=True)
121
+ class MicosPageResult:
122
+ items: list[object] = field(default_factory=list)
123
+ total: int = 0
124
+ current: int = 1
125
+ size: int = 10
micosauth/provider.py ADDED
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Protocol
4
+
5
+
6
+ class MicosAccessProvider(Protocol):
7
+ async def get_roles(self, realm_id: str, login_id: str) -> list[str]:
8
+ """获取角色列表。"""
9
+
10
+ async def get_permissions(self, realm_id: str, login_id: str) -> list[str]:
11
+ """获取权限列表。"""
12
+
13
+ async def get_data_scopes(self, realm_id: str, login_id: str) -> list[str]:
14
+ """获取数据范围列表。"""
15
+
16
+ async def get_extra(self, realm_id: str, login_id: str) -> dict[str, Any]:
17
+ """获取额外信息。"""
18
+
19
+
20
+ class EmptyMicosAccessProvider:
21
+ async def get_roles(self, realm_id: str, login_id: str) -> list[str]:
22
+ return []
23
+
24
+ async def get_permissions(self, realm_id: str, login_id: str) -> list[str]:
25
+ return []
26
+
27
+ async def get_data_scopes(self, realm_id: str, login_id: str) -> list[str]:
28
+ return []
29
+
30
+ async def get_extra(self, realm_id: str, login_id: str) -> dict[str, Any]:
31
+ return {}