authly-sso 0.4.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.
authly/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ from .client import (
2
+ AuthlyClient,
3
+ calculate_signature,
4
+ verify_webhook_signature,
5
+ )
6
+ from .exceptions import (
7
+ AuthlySDKError,
8
+ AuthlyTokenExchangeError,
9
+ AuthlyProfileFetchError,
10
+ AuthlyWebhookSignatureError,
11
+ )
12
+ from .services import (
13
+ AuthlyService,
14
+ get_authly_client,
15
+ populate_user_fields,
16
+ find_or_create_user,
17
+ generate_user_jwt_tokens,
18
+ extract_user_payload,
19
+ )
20
+
21
+ __version__ = "0.4.0"
22
+
23
+ __all__ = [
24
+ "AuthlyClient",
25
+ "AuthlyService",
26
+ "get_authly_client",
27
+ "populate_user_fields",
28
+ "find_or_create_user",
29
+ "generate_user_jwt_tokens",
30
+ "extract_user_payload",
31
+ "AuthlySDKError",
32
+ "AuthlyTokenExchangeError",
33
+ "AuthlyProfileFetchError",
34
+ "AuthlyWebhookSignatureError",
35
+ "calculate_signature",
36
+ "verify_webhook_signature",
37
+ ]
authly/client.py ADDED
@@ -0,0 +1,178 @@
1
+ import hashlib
2
+ import hmac
3
+ import requests
4
+ from urllib.parse import urlencode
5
+ from typing import Dict, Any, Optional, Tuple
6
+ from .exceptions import (
7
+ AuthlySDKError,
8
+ AuthlyTokenExchangeError,
9
+ AuthlyProfileFetchError,
10
+ AuthlyWebhookSignatureError,
11
+ )
12
+
13
+
14
+ def calculate_signature(secret: str, payload_bytes: bytes) -> str:
15
+ """HMAC-SHA256 yordamida payload imzosini hisoblaydi."""
16
+ digest = hmac.new(
17
+ key=secret.encode("utf-8"),
18
+ msg=payload_bytes,
19
+ digestmod=hashlib.sha256,
20
+ ).hexdigest()
21
+ return f"sha256={digest}"
22
+
23
+
24
+ def verify_webhook_signature(secret: str, payload_bytes: bytes, signature: str) -> bool:
25
+ """Webhook so'rovidan kelgan imzoni tekshiradi."""
26
+ if not secret or not signature:
27
+ return False
28
+ expected = calculate_signature(secret, payload_bytes)
29
+ return hmac.compare_digest(expected, signature)
30
+
31
+
32
+ class AuthlyClient:
33
+ """
34
+ Authly SSO (OAuth2) bilan aloqa o'rnatuvchi mijoz klassi.
35
+ Sof Python, FastAPI, Flask yoki Django loyihalarida to'g'ridan-to'g'ri ishlatilishi mumkin.
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ backend_url: Optional[str] = None,
41
+ frontend_url: Optional[str] = None,
42
+ client_id: str = "",
43
+ client_secret: str = "",
44
+ redirect_uri: str = "",
45
+ timeout: int = 10,
46
+ webhook_secret: Optional[str] = None,
47
+ ):
48
+ clean_backend = (backend_url or "").rstrip("/")
49
+ clean_frontend = (frontend_url or clean_backend).rstrip("/")
50
+
51
+ self.backend_url = clean_backend
52
+ self.frontend_url = clean_frontend
53
+ self.client_id = client_id
54
+ self.client_secret = client_secret
55
+ self.redirect_uri = redirect_uri
56
+ self.timeout = timeout
57
+ self.webhook_secret = webhook_secret
58
+
59
+ def get_authorization_url(
60
+ self,
61
+ scope: str = "read",
62
+ state: Optional[str] = None,
63
+ redirect_uri: Optional[str] = None,
64
+ ) -> str:
65
+ """Foydalanuvchini Authly SSO login sahifasiga yo'naltiruvchi havola yasaydi."""
66
+ target_redirect_uri = redirect_uri or self.redirect_uri
67
+ if not target_redirect_uri:
68
+ raise AuthlySDKError(
69
+ "'redirect_uri' ko'rsatilmadi. Metodga 'redirect_uri' uzating yoki sozlamalarda 'redirect_uri' ni belgilang."
70
+ )
71
+ params = {
72
+ "client_id": self.client_id,
73
+ "redirect_uri": target_redirect_uri,
74
+ "scope": scope,
75
+ }
76
+ if state:
77
+ params["state"] = state
78
+ return f"{self.frontend_url}/login?{urlencode(params)}"
79
+
80
+ def exchange_code_for_token(
81
+ self,
82
+ code: str,
83
+ code_verifier: Optional[str] = None,
84
+ redirect_uri: Optional[str] = None,
85
+ ) -> Dict[str, Any]:
86
+ """Bir martalik avtorizatsiya kodini OAuth tokenlarga almashtiradi."""
87
+ target_redirect_uri = redirect_uri or self.redirect_uri
88
+ if not target_redirect_uri:
89
+ raise AuthlyTokenExchangeError(
90
+ "'redirect_uri' ko'rsatilmadi. Metodga 'redirect_uri' uzating yoki sozlamalarda 'redirect_uri' ni belgilang."
91
+ )
92
+ token_endpoint = f"{self.backend_url}/oauth/token/"
93
+ payload = {
94
+ "grant_type": "authorization_code",
95
+ "code": code,
96
+ "redirect_uri": target_redirect_uri,
97
+ "client_id": self.client_id,
98
+ "client_secret": self.client_secret,
99
+ }
100
+ if code_verifier:
101
+ payload["code_verifier"] = code_verifier
102
+
103
+ headers = {
104
+ "Content-Type": "application/x-www-form-urlencoded",
105
+ "User-Agent": "Authly-Python-SDK/0.4.0",
106
+ }
107
+
108
+ try:
109
+ response = requests.post(
110
+ token_endpoint, data=payload, headers=headers, timeout=self.timeout
111
+ )
112
+ if response.status_code != 200:
113
+ raise AuthlyTokenExchangeError(
114
+ message="Token olishda xatolik yuz berdi",
115
+ status_code=response.status_code,
116
+ details=response.text,
117
+ )
118
+ return response.json()
119
+ except requests.RequestException as e:
120
+ raise AuthlyTokenExchangeError(
121
+ f"Authly serveriga bog'lanishda xatolik: {str(e)}"
122
+ )
123
+
124
+ def get_user_profile(self, access_token: str) -> Dict[str, Any]:
125
+ """OAuth access_token orqali foydalanuvchi ma'lumotlarini yuklaydi."""
126
+ profile_endpoint = f"{self.backend_url}/api/v1/sso/profile/"
127
+ headers = {
128
+ "Authorization": f"Bearer {access_token}",
129
+ "User-Agent": "Authly-Python-SDK/0.4.0",
130
+ }
131
+
132
+ try:
133
+ response = requests.get(
134
+ profile_endpoint, headers=headers, timeout=self.timeout
135
+ )
136
+ if response.status_code != 200:
137
+ raise AuthlyProfileFetchError(
138
+ message="Profil ma'lumotlarini olish imkoni bo'lmadi",
139
+ status_code=response.status_code,
140
+ details=response.text,
141
+ )
142
+ data = response.json()
143
+ if isinstance(data, dict):
144
+ if "data" in data and isinstance(data["data"], dict):
145
+ return data["data"]
146
+ if "user" in data and isinstance(data["user"], dict):
147
+ return data["user"]
148
+ return data
149
+ except requests.RequestException as e:
150
+ raise AuthlyProfileFetchError(
151
+ f"Authly serveriga bog'lanishda xatolik: {str(e)}"
152
+ )
153
+
154
+ def authenticate_code(
155
+ self,
156
+ code: str,
157
+ code_verifier: Optional[str] = None,
158
+ redirect_uri: Optional[str] = None,
159
+ ) -> Tuple[Dict[str, Any], str]:
160
+ """
161
+ Kodni berib, foydalanuvchi profili va Authly access_token ni qaytaruvchi metod.
162
+ Qaytaradi: (profile_dict, access_token_str)
163
+ """
164
+ token_data = self.exchange_code_for_token(
165
+ code, code_verifier=code_verifier, redirect_uri=redirect_uri
166
+ )
167
+ access_token = token_data.get("access_token")
168
+ profile = self.get_user_profile(access_token)
169
+ return profile, access_token
170
+
171
+ def verify_webhook_signature(
172
+ self, payload_bytes: bytes, signature: str, secret: Optional[str] = None
173
+ ) -> bool:
174
+ """Kelgan webhook so'rovining imzosini tekshiradi."""
175
+ target_secret = secret or self.webhook_secret
176
+ return verify_webhook_signature(target_secret, payload_bytes, signature)
177
+
178
+
authly/exceptions.py ADDED
@@ -0,0 +1,28 @@
1
+ class AuthlySDKError(Exception):
2
+ """Asosiy SDK xatoligi."""
3
+
4
+ pass
5
+
6
+
7
+ class AuthlyTokenExchangeError(AuthlySDKError):
8
+ """Kodni tokenga almashtirishda xatolik yuz berganda."""
9
+
10
+ def __init__(self, message, status_code=None, details=None):
11
+ super().__init__(message)
12
+ self.status_code = status_code
13
+ self.details = details
14
+
15
+
16
+ class AuthlyProfileFetchError(AuthlySDKError):
17
+ """Foydalanuvchi profilini olishda xatolik yuz berganda."""
18
+
19
+ def __init__(self, message, status_code=None, details=None):
20
+ super().__init__(message)
21
+ self.status_code = status_code
22
+ self.details = details
23
+
24
+
25
+ class AuthlyWebhookSignatureError(AuthlySDKError):
26
+ """Webhook imzosini tekshirishda xatolik yuz berganda."""
27
+
28
+ pass
authly/services.py ADDED
@@ -0,0 +1,281 @@
1
+ import logging
2
+ import os
3
+ from typing import Tuple, List, Dict, Any, Optional
4
+ from urllib.parse import urlparse
5
+
6
+ import requests
7
+
8
+ try:
9
+ from django.conf import settings
10
+ except ImportError:
11
+ settings = None
12
+
13
+ from .client import AuthlyClient
14
+ from .exceptions import AuthlySDKError
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def get_authly_client() -> AuthlyClient:
20
+ """AuthlyClient instansiyasini Django sozlamalari asosida qaytaradi."""
21
+ if settings is None:
22
+ raise AuthlySDKError("Django o'rnatilmagan yoki sozlanmagan.")
23
+
24
+ backend_url = getattr(settings, "AUTHLY_BACKEND_URL", "")
25
+ frontend_url = getattr(settings, "AUTHLY_FRONTEND_URL", None) or backend_url
26
+ client_id = getattr(settings, "AUTHLY_CLIENT_ID", "")
27
+ client_secret = getattr(settings, "AUTHLY_CLIENT_SECRET", "")
28
+ redirect_uri = getattr(settings, "AUTHLY_REDIRECT_URI", "")
29
+ timeout = getattr(settings, "AUTHLY_TIMEOUT", 10)
30
+ webhook_secret = getattr(settings, "AUTHLY_WEBHOOK_SECRET", None)
31
+
32
+ return AuthlyClient(
33
+ backend_url=backend_url,
34
+ frontend_url=frontend_url,
35
+ client_id=client_id,
36
+ client_secret=client_secret,
37
+ redirect_uri=redirect_uri,
38
+ timeout=timeout,
39
+ webhook_secret=webhook_secret,
40
+ )
41
+
42
+
43
+ def extract_user_payload(data: dict) -> dict:
44
+ """Authly API yoki webhook payload ichidan user ma'lumotlarini ajratib oladi."""
45
+ if not isinstance(data, dict):
46
+ return {}
47
+ if "data" in data and isinstance(data["data"], dict):
48
+ return data["data"]
49
+ if "user" in data and isinstance(data["user"], dict):
50
+ return data["user"]
51
+ return data
52
+
53
+
54
+ def populate_user_fields(user, user_data: dict) -> List[str]:
55
+ """
56
+ Authly SSO dan kelgan ma'lumotlarni User obyektiga moslab to'ldiradi.
57
+ Lokal modelda mavjud bo'lgan barcha mos maydonlar avtomatik to'ldiriladi:
58
+ - authly_id
59
+ - first_name, last_name, email
60
+ - phone / phone_number
61
+ - avatar
62
+ - birth_date
63
+ """
64
+ clean_data = extract_user_payload(user_data)
65
+ updated_fields = []
66
+
67
+ raw_authly_id = clean_data.get("authly_id") or clean_data.get("id")
68
+ authly_id = (
69
+ str(raw_authly_id).strip()
70
+ if raw_authly_id and str(raw_authly_id).strip()
71
+ else None
72
+ )
73
+
74
+ if authly_id and hasattr(user, "authly_id") and getattr(user, "authly_id") != authly_id:
75
+ user.authly_id = authly_id
76
+ updated_fields.append("authly_id")
77
+
78
+ first_name = (clean_data.get("first_name") or "").strip()
79
+ if (
80
+ first_name
81
+ and hasattr(user, "first_name")
82
+ and getattr(user, "first_name") != first_name
83
+ ):
84
+ user.first_name = first_name
85
+ updated_fields.append("first_name")
86
+
87
+ last_name = (clean_data.get("last_name") or "").strip()
88
+ if (
89
+ last_name
90
+ and hasattr(user, "last_name")
91
+ and getattr(user, "last_name") != last_name
92
+ ):
93
+ user.last_name = last_name
94
+ updated_fields.append("last_name")
95
+
96
+ raw_email = clean_data.get("email")
97
+ email = (
98
+ raw_email.strip()
99
+ if isinstance(raw_email, str) and raw_email.strip()
100
+ else None
101
+ )
102
+ if email and hasattr(user, "email") and getattr(user, "email") != email:
103
+ user.email = email
104
+ updated_fields.append("email")
105
+
106
+ raw_phone = clean_data.get("phone") or clean_data.get("phone_number")
107
+ phone = (
108
+ str(raw_phone).strip() if raw_phone and str(raw_phone).strip() else None
109
+ )
110
+ if phone:
111
+ if hasattr(user, "phone_number") and getattr(user, "phone_number") != phone:
112
+ user.phone_number = phone
113
+ updated_fields.append("phone_number")
114
+ elif hasattr(user, "phone") and getattr(user, "phone") != phone:
115
+ user.phone = phone
116
+ updated_fields.append("phone")
117
+
118
+ avatar = (
119
+ clean_data.get("avatar")
120
+ or clean_data.get("image")
121
+ or clean_data.get("photo")
122
+ or ""
123
+ )
124
+ avatar_field_name = None
125
+ for fname in ["avatar", "image", "photo", "profile_image", "profile_photo"]:
126
+ if hasattr(user, fname):
127
+ avatar_field_name = fname
128
+ break
129
+
130
+ if avatar and avatar_field_name:
131
+ is_file_field = False
132
+ try:
133
+ from django.db.models import FileField
134
+
135
+ field = user._meta.get_field(avatar_field_name)
136
+ is_file_field = isinstance(field, FileField)
137
+ except Exception:
138
+ pass
139
+
140
+ if is_file_field:
141
+ if isinstance(avatar, str) and (
142
+ avatar.startswith("http://") or avatar.startswith("https://")
143
+ ):
144
+ try:
145
+ res = requests.get(avatar, timeout=5)
146
+ if res.status_code == 200:
147
+ from django.core.files.base import ContentFile
148
+
149
+ path = urlparse(avatar).path
150
+ ext = os.path.splitext(path)[1] or ".jpg"
151
+ filename = f"authly_{authly_id or user.pk or 'avatar'}{ext}"
152
+ getattr(user, avatar_field_name).save(
153
+ filename, ContentFile(res.content), save=False
154
+ )
155
+ updated_fields.append(avatar_field_name)
156
+ except Exception as exc:
157
+ logger.warning("Avatar yuklab olishda xatolik yuz berdi: %s", exc)
158
+ else:
159
+ if getattr(user, avatar_field_name) != avatar:
160
+ setattr(user, avatar_field_name, avatar)
161
+ updated_fields.append(avatar_field_name)
162
+
163
+ birth_date = clean_data.get("birth_date")
164
+ if (
165
+ birth_date
166
+ and hasattr(user, "birth_date")
167
+ and getattr(user, "birth_date") != birth_date
168
+ ):
169
+ user.birth_date = birth_date
170
+ updated_fields.append("birth_date")
171
+
172
+ return updated_fields
173
+
174
+
175
+ def find_or_create_user(user_model, user_data: dict) -> Tuple[Any, bool]:
176
+ """
177
+ Authly ma'lumotlari orqali foydalanuvchini topadi yoki yangi yaratadi.
178
+ Qidirish tartibi:
179
+ 1. authly_id
180
+ 2. username ("authly_{authly_id}")
181
+ 3. phone / phone_number
182
+ 4. email
183
+ """
184
+ clean_data = extract_user_payload(user_data)
185
+ raw_authly_id = clean_data.get("authly_id") or clean_data.get("id")
186
+ authly_id = (
187
+ str(raw_authly_id).strip()
188
+ if raw_authly_id and str(raw_authly_id).strip()
189
+ else None
190
+ )
191
+ user = None
192
+
193
+ if authly_id and hasattr(user_model, "authly_id"):
194
+ user = user_model.objects.filter(authly_id=authly_id).first()
195
+
196
+ if not user and authly_id and hasattr(user_model, "username"):
197
+ user = user_model.objects.filter(username=f"authly_{authly_id}").first()
198
+
199
+ raw_phone = clean_data.get("phone") or clean_data.get("phone_number")
200
+ phone = (
201
+ str(raw_phone).strip() if raw_phone and str(raw_phone).strip() else None
202
+ )
203
+ if not user and phone:
204
+ if hasattr(user_model, "phone_number"):
205
+ user = user_model.objects.filter(phone_number=phone).first()
206
+ elif hasattr(user_model, "phone"):
207
+ user = user_model.objects.filter(phone=phone).first()
208
+
209
+ raw_email = clean_data.get("email")
210
+ email = (
211
+ raw_email.strip()
212
+ if isinstance(raw_email, str) and raw_email.strip()
213
+ else None
214
+ )
215
+ if not user and email and hasattr(user_model, "email"):
216
+ user = user_model.objects.filter(email=email).first()
217
+
218
+ if user:
219
+ created = False
220
+ updated = populate_user_fields(user, clean_data)
221
+ if updated:
222
+ user.save()
223
+ return user, created
224
+
225
+ created = True
226
+ user = user_model()
227
+ if hasattr(user, "username"):
228
+ user.username = (
229
+ f"authly_{authly_id}"
230
+ if authly_id
231
+ else (clean_data.get("username") or phone or email)
232
+ )
233
+
234
+ populate_user_fields(user, clean_data)
235
+ if hasattr(user, "set_unusable_password"):
236
+ user.set_unusable_password()
237
+
238
+ user.save()
239
+ return user, created
240
+
241
+
242
+ def generate_user_jwt_tokens(user) -> Dict[str, str]:
243
+ """
244
+ Agar rest_framework_simplejwt o'rnatilgan bo'lsa,
245
+ user uchun access va refresh tokenlarni generatsiya qiladi.
246
+ """
247
+ tokens = {}
248
+ try:
249
+ from rest_framework_simplejwt.tokens import RefreshToken
250
+
251
+ refresh = RefreshToken.for_user(user)
252
+ tokens = {
253
+ "refresh": str(refresh),
254
+ "access": str(refresh.access_token),
255
+ }
256
+ except ImportError:
257
+ pass
258
+ return tokens
259
+
260
+
261
+ class AuthlyService:
262
+ """
263
+ Authly SSO uchun servis klassi.
264
+ Biznes mantiqini bitta joyda jamlaydi.
265
+ """
266
+
267
+ @staticmethod
268
+ def get_client() -> AuthlyClient:
269
+ return get_authly_client()
270
+
271
+ @staticmethod
272
+ def populate_user(user, user_data: dict) -> List[str]:
273
+ return populate_user_fields(user, user_data)
274
+
275
+ @staticmethod
276
+ def get_or_create_user(user_model, user_data: dict) -> Tuple[Any, bool]:
277
+ return find_or_create_user(user_model, user_data)
278
+
279
+ @staticmethod
280
+ def generate_tokens(user) -> Dict[str, str]:
281
+ return generate_user_jwt_tokens(user)
authly/urls.py ADDED
@@ -0,0 +1,16 @@
1
+ from django.urls import path
2
+ from .views import (
3
+ AuthlyAuthorizeURLAPIView,
4
+ AuthlyCallbackAPIView,
5
+ AuthlyWebhookAPIView,
6
+ AuthlySyncProfileAPIView,
7
+ )
8
+
9
+ app_name = "authly"
10
+
11
+ urlpatterns = [
12
+ path("login-url/", AuthlyAuthorizeURLAPIView.as_view(), name="authly_login_url"),
13
+ path("callback/", AuthlyCallbackAPIView.as_view(), name="authly_callback"),
14
+ path("webhook/", AuthlyWebhookAPIView.as_view(), name="authly_webhook"),
15
+ path("sync/", AuthlySyncProfileAPIView.as_view(), name="authly_sync"),
16
+ ]