xn-auth 0.0.2__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.
- x_auth/__init__.py +211 -0
- xn_auth-0.0.2.dist-info/METADATA +34 -0
- xn_auth-0.0.2.dist-info/RECORD +5 -0
- xn_auth-0.0.2.dist-info/WHEEL +5 -0
- xn_auth-0.0.2.dist-info/top_level.txt +1 -0
x_auth/__init__.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
from datetime import timedelta, datetime
|
|
2
|
+
from enum import IntEnum
|
|
3
|
+
|
|
4
|
+
# from importlib.metadata import distributions
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
from fastapi import Depends, HTTPException, Security
|
|
7
|
+
from fastapi.security import OAuth2PasswordBearer, SecurityScopes, OAuth2PasswordRequestForm
|
|
8
|
+
from jose import jwt, JWTError
|
|
9
|
+
from pydantic import BaseModel, ValidationError
|
|
10
|
+
from starlette import status
|
|
11
|
+
from starlette.authentication import AuthCredentials, SimpleUser, AuthenticationError, AuthenticationBackend
|
|
12
|
+
from starlette.requests import HTTPConnection
|
|
13
|
+
from starlette.responses import Response
|
|
14
|
+
from x_model.enum import Scope, UserStatus
|
|
15
|
+
from x_model.model import Model, User
|
|
16
|
+
from x_model.pydantic import UserReg, UserAuth
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuthFailReason(IntEnum):
|
|
20
|
+
username = 1
|
|
21
|
+
password = 2
|
|
22
|
+
signature = 3
|
|
23
|
+
expired = 4
|
|
24
|
+
dep_not_installed = 5
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AuthException(AuthenticationError, HTTPException):
|
|
28
|
+
detail: AuthFailReason
|
|
29
|
+
|
|
30
|
+
def __init__(self, detail: AuthFailReason, clear_cookie: str | None = "access_token") -> None:
|
|
31
|
+
hdrs = (
|
|
32
|
+
{"set-cookie": clear_cookie + "=; expires=Thu, 01 Jan 1970 00:00:00 GMT"} if clear_cookie else None
|
|
33
|
+
) # path=/;
|
|
34
|
+
super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, detail=detail.name, headers=hdrs)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def on_error(_: HTTPConnection, exc: AuthException) -> Response:
|
|
38
|
+
hdr = (
|
|
39
|
+
{
|
|
40
|
+
"Location": "/login",
|
|
41
|
+
}
|
|
42
|
+
if exc.status_code == 303 and "/login" in (r.path for r in _.app.routes)
|
|
43
|
+
else {}
|
|
44
|
+
)
|
|
45
|
+
resp = Response(str(exc), status_code=exc.status_code, headers=hdr)
|
|
46
|
+
resp.delete_cookie("access_token")
|
|
47
|
+
return resp
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AuthType(IntEnum):
|
|
51
|
+
pwd = 1
|
|
52
|
+
tg = 2
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TokenData(BaseModel):
|
|
56
|
+
id: int
|
|
57
|
+
username: str | None = None
|
|
58
|
+
scopes: list[str] = []
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AuthUser(SimpleUser):
|
|
62
|
+
id: int
|
|
63
|
+
|
|
64
|
+
def __init__(self, uid: int, username: str) -> None:
|
|
65
|
+
super().__init__(username)
|
|
66
|
+
self.id = uid
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class OAuth(AuthenticationBackend):
|
|
70
|
+
EXPIRES = timedelta(days=7)
|
|
71
|
+
|
|
72
|
+
class Token(BaseModel):
|
|
73
|
+
access_token: str
|
|
74
|
+
token_type: str
|
|
75
|
+
user: UserAuth
|
|
76
|
+
|
|
77
|
+
def __init__(self, secret: str, db_user_model: type[User] = User, auth_type: AuthType = AuthType.pwd):
|
|
78
|
+
self.secret: str = secret
|
|
79
|
+
self.db_user_model: type[User] = db_user_model
|
|
80
|
+
self.auth_type: AuthType = auth_type
|
|
81
|
+
|
|
82
|
+
self.read = Security(self.check_token, scopes=[Scope.READ.name])
|
|
83
|
+
self.write = Security(self.check_token, scopes=[Scope.WRITE.name])
|
|
84
|
+
self.my = Security(self.check_token, scopes=[Scope.ALL.name])
|
|
85
|
+
self.active = Depends(self.check_token)
|
|
86
|
+
|
|
87
|
+
oauth2_scheme = OAuth2PasswordBearer(
|
|
88
|
+
tokenUrl="token",
|
|
89
|
+
scopes={
|
|
90
|
+
Scope.READ.name: "READ own items",
|
|
91
|
+
Scope.WRITE.name: "Write own items",
|
|
92
|
+
Scope.ALL.name: "Access for not only own items",
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# async def get_token_for_tg(self, tg_user: WebAppUser) -> Token:
|
|
97
|
+
# user: User
|
|
98
|
+
# user, cr = await user_upsert(tg_user)
|
|
99
|
+
# access_token = self.gen_access_token(
|
|
100
|
+
# data={"sub": tg_user.username or str(tg_user.id), "id": tg_user.id,
|
|
101
|
+
# "scopes": self.role_scopes_map[user.role]},
|
|
102
|
+
# expires_delta=self.EXPIRES,
|
|
103
|
+
# )
|
|
104
|
+
# auth_user: UserAuth = UserAuth.model_validate(user, from_attributes=True)
|
|
105
|
+
# return self.Token.model_validate(
|
|
106
|
+
# {"access_token": access_token, "token_type": "bearer", "user": auth_user},
|
|
107
|
+
# from_attributes=True
|
|
108
|
+
# )
|
|
109
|
+
|
|
110
|
+
def get_data_from_jwt(self, jwtoken: str) -> tuple[AuthCredentials, AuthUser]:
|
|
111
|
+
payload = jwt.decode(jwtoken, self.secret, algorithms=["HS256"])
|
|
112
|
+
uid: int = payload.get("id")
|
|
113
|
+
username: str = payload.get("sub")
|
|
114
|
+
scopes = payload.get("scopes", [])
|
|
115
|
+
return AuthCredentials(scopes), AuthUser(uid, username)
|
|
116
|
+
|
|
117
|
+
async def authenticate(self, conn: HTTPConnection) -> tuple[AuthCredentials, AuthUser] | None:
|
|
118
|
+
if not (auth := conn.headers.get("Authorization")):
|
|
119
|
+
return
|
|
120
|
+
# try:
|
|
121
|
+
scheme, credentials = auth.split()
|
|
122
|
+
if scheme.lower() == "bearer":
|
|
123
|
+
try:
|
|
124
|
+
return self.get_data_from_jwt(credentials)
|
|
125
|
+
except JWTError as e:
|
|
126
|
+
print(e)
|
|
127
|
+
raise AuthException(AuthFailReason.expired, "access_token")
|
|
128
|
+
except ValidationError as e:
|
|
129
|
+
print(e)
|
|
130
|
+
raise AuthException(AuthFailReason.signature, "access_token")
|
|
131
|
+
# except Exception as exc:
|
|
132
|
+
# raise AuthenticationError(exc, 'Invalid auth credentials')
|
|
133
|
+
# elif scheme.lower() == 'tg':
|
|
134
|
+
# if 'xtg-auth' not in [d.name for d in distributions()]:
|
|
135
|
+
# raise AuthException(AuthFailReason.dep_not_installed, None)
|
|
136
|
+
# from aiogram.utils.web_app import safe_parse_webapp_init_data
|
|
137
|
+
# tgData = safe_parse_webapp_init_data(self.secret, credentials)
|
|
138
|
+
# credentials = (await self.get_token_for_tg(tgData.user)).access_token
|
|
139
|
+
|
|
140
|
+
# dependency
|
|
141
|
+
async def check_token(
|
|
142
|
+
self, security_scopes: SecurityScopes, token: Annotated[str | None, Depends(oauth2_scheme)]
|
|
143
|
+
): # , tg_data: [str, ]
|
|
144
|
+
auth_val = "Bearer"
|
|
145
|
+
if security_scopes.scopes:
|
|
146
|
+
auth_val += f' scope="{security_scopes.scope_str}"'
|
|
147
|
+
cred_exc = HTTPException(
|
|
148
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
149
|
+
detail="Could not validate credentials",
|
|
150
|
+
headers={"WWW-Authenticate": auth_val},
|
|
151
|
+
)
|
|
152
|
+
try:
|
|
153
|
+
creds, user = self.get_data_from_jwt(token)
|
|
154
|
+
except (JWTError, ValidationError) as e:
|
|
155
|
+
cred_exc.detail += f": {e}"
|
|
156
|
+
raise cred_exc
|
|
157
|
+
if not user.username or not user.id:
|
|
158
|
+
cred_exc.detail += "token"
|
|
159
|
+
raise cred_exc
|
|
160
|
+
# noinspection PyTypeChecker
|
|
161
|
+
user_status: UserStatus | None = await self.db_user_model.get_or_none(username=user.username).values_list(
|
|
162
|
+
"status", flat=True
|
|
163
|
+
)
|
|
164
|
+
if not user_status:
|
|
165
|
+
cred_exc.detail = "User not found"
|
|
166
|
+
raise cred_exc
|
|
167
|
+
elif user_status < UserStatus.TEST:
|
|
168
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user")
|
|
169
|
+
for scope in security_scopes.scopes:
|
|
170
|
+
if scope not in creds.scopes:
|
|
171
|
+
cred_exc.detail = f"Not enough permissions. Need `{scope}`"
|
|
172
|
+
raise cred_exc
|
|
173
|
+
|
|
174
|
+
# api reg endpoint
|
|
175
|
+
async def reg_user(self, user_reg_input: UserReg) -> Token:
|
|
176
|
+
data = user_reg_input.model_dump()
|
|
177
|
+
try:
|
|
178
|
+
await self.db_user_model.create(**data)
|
|
179
|
+
except Exception as e:
|
|
180
|
+
raise HTTPException(status.HTTP_406_NOT_ACCEPTABLE, detail=e.__repr__())
|
|
181
|
+
tok = await self.login_for_access_token(
|
|
182
|
+
OAuth2PasswordRequestForm(username=user_reg_input.username, password=user_reg_input.password)
|
|
183
|
+
)
|
|
184
|
+
return tok
|
|
185
|
+
|
|
186
|
+
async def authenticate_user(self, username: str, password: str) -> tuple[TokenData, Model]:
|
|
187
|
+
if user_db := await self.db_user_model.get_or_none(username=username):
|
|
188
|
+
td = TokenData.model_validate(user_db, from_attributes=True)
|
|
189
|
+
td.scopes = self.role_scopes_map[user_db.role]
|
|
190
|
+
if user_db.pwd_vrf(password):
|
|
191
|
+
return td, user_db
|
|
192
|
+
reason = AuthFailReason.password
|
|
193
|
+
else:
|
|
194
|
+
reason = AuthFailReason.username
|
|
195
|
+
raise AuthException(detail=reason)
|
|
196
|
+
|
|
197
|
+
def gen_access_token(self, data: dict, expires_delta: timedelta = EXPIRES) -> str:
|
|
198
|
+
return jwt.encode({"exp": datetime.utcnow() + expires_delta, **data}, self.secret)
|
|
199
|
+
|
|
200
|
+
# api login endpoint
|
|
201
|
+
async def login_for_access_token(self, form_data: Annotated[OAuth2PasswordRequestForm, Depends()]) -> Token:
|
|
202
|
+
token, user_db = await self.authenticate_user(form_data.username, form_data.password)
|
|
203
|
+
if isinstance(token, TokenData):
|
|
204
|
+
access_token = self.gen_access_token(
|
|
205
|
+
data={"id": token.id, "sub": token.username, "scopes": token.scopes},
|
|
206
|
+
expires_delta=self.EXPIRES,
|
|
207
|
+
)
|
|
208
|
+
r = self.Token.model_validate(
|
|
209
|
+
{"access_token": access_token, "token_type": "bearer", "user": user_db}, from_attributes=True
|
|
210
|
+
)
|
|
211
|
+
return r
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: xn-auth
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Auth adapter for XN-Api framework
|
|
5
|
+
Author-email: Artemiev <mixartemev@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/XyncNet/x-api
|
|
8
|
+
Project-URL: Repository, https://github.com/XyncNet/x-api
|
|
9
|
+
Keywords: starlette,fastapi,auth
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: fastapi
|
|
13
|
+
Requires-Dist: pwdlib[argon2]
|
|
14
|
+
Requires-Dist: python-jose[cryptography]
|
|
15
|
+
Requires-Dist: xn-model
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: build ; extra == 'dev'
|
|
18
|
+
Requires-Dist: python-dotenv ; extra == 'dev'
|
|
19
|
+
Requires-Dist: setuptools-scm ; extra == 'dev'
|
|
20
|
+
Requires-Dist: twine ; extra == 'dev'
|
|
21
|
+
|
|
22
|
+
# X-Auth
|
|
23
|
+
###### Pswd-jwt authentication for x-api
|
|
24
|
+
|
|
25
|
+
#### Requirements
|
|
26
|
+
- Python >= 3.12
|
|
27
|
+
|
|
28
|
+
### INSTALL
|
|
29
|
+
```bash
|
|
30
|
+
pip install x-auth
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
Made with ❤ on top of the [X-Model](https://github.com/XyncNet/x-model).
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
x_auth/__init__.py,sha256=U4r4nmbzG2UsIRUVvnEi1v3kwE_cNQRSVHIs7q1DtvU,8356
|
|
2
|
+
xn_auth-0.0.2.dist-info/METADATA,sha256=jofSffpfrbLzMJ9APbPfobMcWY7s9Hwx7X7iZb_OCmo,882
|
|
3
|
+
xn_auth-0.0.2.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
|
|
4
|
+
xn_auth-0.0.2.dist-info/top_level.txt,sha256=ydMDkzxgQPtW-E_MNDfUAroAFZvWSqU-x_kZSA7NSFo,7
|
|
5
|
+
xn_auth-0.0.2.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
x_auth
|