xn-auth 0.0.3.dev1__tar.gz → 0.0.4__tar.gz

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.
@@ -4,7 +4,7 @@ repos:
4
4
  - id: tag
5
5
  name: tag
6
6
  ### make tag with next ver only if "fix" in commit_msg or starts with "feat"
7
- entry: bash -c 'grep -e ^feat -e fix .git/COMMIT_EDITMSG && make patch || exit 0'
7
+ entry: bash -c 'grep -e "^feat:" -e "^fix:" .git/COMMIT_EDITMSG && make patch || exit 0'
8
8
  language: system
9
9
  verbose: true
10
10
  pass_filenames: false
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: xn-auth
3
- Version: 0.0.3.dev1
3
+ Version: 0.0.4
4
4
  Summary: Auth adapter for XN-Api framework
5
5
  Author-email: Artemiev <mixartemev@gmail.com>
6
6
  License: MIT
@@ -0,0 +1,116 @@
1
+ import logging
2
+ from datetime import timedelta, datetime
3
+ from enum import IntEnum
4
+ from typing import Annotated
5
+ from fastapi import Depends, HTTPException, Security
6
+ from fastapi.security import SecurityScopes, HTTPBearer
7
+ from jose import jwt, JWTError
8
+ from pydantic import BaseModel, ValidationError
9
+ from starlette import status
10
+ from starlette.authentication import SimpleUser, AuthenticationError
11
+ from starlette.requests import HTTPConnection
12
+ from starlette.responses import Response
13
+
14
+ from x_auth.enums import Scope, UserStatus
15
+ from x_auth.models import User
16
+ from x_auth.pydantic import UserAuth
17
+
18
+
19
+ class FailReason(IntEnum):
20
+ username = 1
21
+ password = 2
22
+ signature = 3
23
+ expired = 4
24
+ dep_not_installed = 5
25
+ undefined = 6
26
+
27
+
28
+ class AuthException(AuthenticationError, HTTPException):
29
+ detail: FailReason
30
+
31
+ def __init__(self, detail: FailReason, clear_cookie: str | None = "access_token", parent: Exception = None) -> None:
32
+ hdrs = (
33
+ {"set-cookie": clear_cookie + "=; expires=Thu, 01 Jan 1970 00:00:00 GMT"} if clear_cookie else None
34
+ ) # path=/;
35
+ if parent:
36
+ logging.error(repr(parent))
37
+ super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, detail=detail.name, headers=hdrs)
38
+
39
+
40
+ def on_error(_: HTTPConnection, exc: AuthException) -> Response:
41
+ hdr = {}
42
+ if exc.status_code == 303 and "/login" in (r.path for r in _.app.routes):
43
+ hdr = {"Location": "/login"}
44
+ resp = Response(str(exc), status_code=exc.status_code, headers=hdr)
45
+ resp.delete_cookie("access_token")
46
+ return resp
47
+
48
+
49
+ def jwt_decode(jwtoken: str, secret: str) -> UserAuth:
50
+ payload = jwt.decode(jwtoken, secret, algorithms=["HS256"])
51
+ return UserAuth(**payload)
52
+
53
+
54
+ class AuthUser(SimpleUser):
55
+ id: int
56
+
57
+ def __init__(self, uid: int, username: str) -> None:
58
+ super().__init__(username)
59
+ self.id = uid
60
+
61
+
62
+ class BaseAuth:
63
+ expires = timedelta(days=7)
64
+ auth_scheme = HTTPBearer()
65
+
66
+ class Token(BaseModel):
67
+ access_token: str
68
+ token_type: str
69
+ user: UserAuth
70
+
71
+ def __init__(self, secret: str, db_user_model: type[User] = User):
72
+ self.secret: str = secret
73
+ self.db_user_model: type[User] = db_user_model
74
+
75
+ self.read = Security(self.check_token, scopes=[Scope.READ.name])
76
+ self.write = Security(self.check_token, scopes=[Scope.WRITE.name])
77
+ self.my = Security(self.check_token, scopes=[Scope.ALL.name])
78
+ self.active = Depends(self.check_token)
79
+
80
+ def jwt_encode(self, data: UserAuth, expires_delta: timedelta = expires) -> str:
81
+ return jwt.encode({"exp": datetime.now() + expires_delta, **data}, self.secret)
82
+
83
+ def jwt_decode(self, jwtoken: str) -> UserAuth:
84
+ return jwt_decode(jwtoken, self.secret)
85
+
86
+ # dependency
87
+ async def check_token(self, security_scopes: SecurityScopes, token: Annotated[str, Depends(auth_scheme)]):
88
+ auth_val = "Bearer"
89
+ if security_scopes.scopes:
90
+ auth_val += f' scope="{security_scopes.scope_str}"'
91
+ cred_exc = HTTPException(
92
+ status_code=status.HTTP_401_UNAUTHORIZED,
93
+ detail="Could not validate credentials",
94
+ headers={"WWW-Authenticate": auth_val},
95
+ )
96
+ try:
97
+ user: UserAuth = self.jwt_decode(token)
98
+ except (JWTError, ValidationError) as e:
99
+ cred_exc.detail += f": {e}"
100
+ raise cred_exc
101
+ if not user.username or not user.id:
102
+ cred_exc.detail += "token"
103
+ raise cred_exc
104
+ # noinspection PyTypeChecker
105
+ user_status: UserStatus | None = await self.db_user_model.get_or_none(username=user.username).values_list(
106
+ "status", flat=True
107
+ )
108
+ if not user_status:
109
+ cred_exc.detail = "User not found"
110
+ raise cred_exc
111
+ elif user_status < UserStatus.TEST:
112
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user")
113
+ for scope in security_scopes.scopes:
114
+ if scope not in user.scopes:
115
+ cred_exc.detail = f"Not enough permissions. Need '{scope}'"
116
+ raise cred_exc
@@ -0,0 +1,28 @@
1
+ from jose import JWTError
2
+ from pydantic import ValidationError
3
+ from starlette.authentication import AuthenticationBackend, AuthCredentials
4
+ from starlette.requests import HTTPConnection
5
+
6
+ from x_auth import AuthUser, AuthException, FailReason, jwt_decode
7
+ from x_auth.pydantic import UserAuth
8
+
9
+
10
+ class AuthBackend(AuthenticationBackend):
11
+ def __init__(self, secret: str):
12
+ self.secret = secret
13
+
14
+ async def authenticate(self, conn: HTTPConnection) -> tuple[AuthCredentials, AuthUser] | None:
15
+ # todo: refact with BearerSchema
16
+ if not (auth := conn.headers.get("Authorization")):
17
+ return
18
+
19
+ scheme, credentials = auth.split()
20
+
21
+ if scheme.lower() == "bearer":
22
+ try:
23
+ user: UserAuth = jwt_decode(credentials, self.secret)
24
+ return AuthCredentials(user.scopes), AuthUser(**user.model_dump())
25
+ except JWTError as e:
26
+ raise AuthException(FailReason.expired, parent=e)
27
+ except ValidationError as e:
28
+ raise AuthException(FailReason.signature, parent=e)
@@ -0,0 +1,22 @@
1
+ from enum import IntEnum
2
+
3
+
4
+ class UserStatus(IntEnum):
5
+ BANNED = 0
6
+ WAIT = 1 # waiting for approve
7
+ TEST = 2 # trial
8
+ ACTIVE = 3
9
+ PREMIUM = 4
10
+
11
+
12
+ class Scope(IntEnum):
13
+ READ = 4
14
+ WRITE = 2
15
+ ALL = 1 # not only my
16
+
17
+
18
+ class Role(IntEnum):
19
+ READER = Scope.READ # 4
20
+ WRITER = Scope.WRITE # 2
21
+ MANAGER = Scope.READ + Scope.WRITE # 6
22
+ ADMIN = Scope.READ + Scope.WRITE + Scope.ALL # 7
@@ -0,0 +1,25 @@
1
+ from tortoise import fields
2
+ from x_model.model import Model as BaseModel, TsTrait
3
+
4
+ from x_auth.enums import UserStatus, Role, Scope
5
+
6
+
7
+ class Model(BaseModel):
8
+ _allowed: Role = None # allows access to read/write/all for all
9
+
10
+
11
+ class User(Model, TsTrait):
12
+ username: str | None = fields.CharField(95, unique=True, null=True)
13
+ status: UserStatus = fields.IntEnumField(UserStatus, default=UserStatus.WAIT)
14
+ email: str | None = fields.CharField(100, unique=True, null=True)
15
+ phone: int | None = fields.BigIntField(null=True)
16
+ role: Role = fields.IntEnumField(Role, default=Role.READER)
17
+
18
+ _icon = "user"
19
+ _name = {"username"}
20
+
21
+ def _can(self, scope: Scope) -> bool:
22
+ return bool(self.role.value & scope)
23
+
24
+ class Meta:
25
+ table_description = "Users"
@@ -0,0 +1,30 @@
1
+ from pydantic import BaseModel, computed_field
2
+
3
+ from x_auth.enums import UserStatus, Role, Scope
4
+
5
+
6
+ class UserReg(BaseModel):
7
+ username: str
8
+ email: str | None = None
9
+ phone: int | None = None
10
+
11
+
12
+ class UserUpdate(BaseModel):
13
+ username: str
14
+ status: UserStatus
15
+ email: str | None
16
+ phone: int | None
17
+ role: Role
18
+
19
+
20
+ class UserAuth(UserUpdate):
21
+ id: int
22
+ username: str
23
+ status: UserStatus
24
+ role: Role
25
+ # ref_id: int | None
26
+
27
+ @computed_field
28
+ @property
29
+ def scopes(self) -> list[str]:
30
+ return [scope.name for scope in Scope if self.role.value & scope.value]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: xn-auth
3
- Version: 0.0.3.dev1
3
+ Version: 0.0.4
4
4
  Summary: Auth adapter for XN-Api framework
5
5
  Author-email: Artemiev <mixartemev@gmail.com>
6
6
  License: MIT
@@ -5,7 +5,10 @@ README.md
5
5
  makefile
6
6
  pyproject.toml
7
7
  x_auth/__init__.py
8
+ x_auth/backend.py
9
+ x_auth/enums.py
8
10
  x_auth/models.py
11
+ x_auth/pydantic.py
9
12
  xn_auth.egg-info/PKG-INFO
10
13
  xn_auth.egg-info/SOURCES.txt
11
14
  xn_auth.egg-info/dependency_links.txt
@@ -1,211 +0,0 @@
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
@@ -1,24 +0,0 @@
1
- from tortoise.fields import CharField
2
- from x_model import User as BaseUser
3
-
4
-
5
- class User(BaseUser):
6
- from pwdlib import PasswordHash
7
-
8
- __cc = PasswordHash.recommended()
9
-
10
- password: str | None = CharField(60, null=True)
11
-
12
- def pwd_vrf(self, pwd: str) -> bool:
13
- return self.__cc.verify(pwd, self.password)
14
-
15
- @classmethod
16
- async def create(cls, using_db=None, **kwargs) -> BaseUser:
17
- user: User = await super().create(using_db, **kwargs)
18
- if pwd := kwargs.get("password"):
19
- await user.set_pwd(pwd)
20
- return user
21
-
22
- async def set_pwd(self, pwd: str = password) -> None:
23
- self.password = self.__cc.hash(pwd)
24
- await self.save()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes