wip: redis, sqlalchemy, structured, etc

This commit is contained in:
2021-06-28 12:08:09 +03:00
parent 133e1cd490
commit 9f01572557
37 changed files with 1297 additions and 62 deletions

9
auth/README.md Normal file
View File

@@ -0,0 +1,9 @@
## Based on
* pyjwt
* [ariadne](https://github.com/mirumee/ariadne)
* [aioredis](https://github.com/aio-libs/aioredis)
* [starlette](https://github.com/encode/starlette)、
* sqlalchmy ORM
token is valid for one day, user can choose to logout, logout is revoke token

78
auth/authenticate.py Normal file
View File

@@ -0,0 +1,78 @@
from functools import wraps
from typing import Optional, Tuple
from graphql import GraphQLResolveInfo
from jwt import DecodeError, ExpiredSignatureError
from starlette.authentication import AuthenticationBackend
from starlette.requests import HTTPConnection
from auth.credentials import AuthCredentials, AuthUser
from auth.token import Token
from exceptions import InvalidToken, OperationNotAllowed
from orm import User
from redis import redis
from settings import JWT_AUTH_HEADER
class _Authenticate:
@classmethod
async def verify(cls, token: str):
"""
Rules for a token to be valid.
1. token format is legal &&
token exists in redis database &&
token is not expired
2. token format is legal &&
token exists in redis database &&
token is expired &&
token is of specified type
"""
try:
payload = Token.decode(token)
except ExpiredSignatureError:
payload = Token.decode(token, verify_exp=False)
if not await cls.exists(payload.user_id, token):
raise InvalidToken("Login expired, please login again")
if payload.device == "mobile": # noqa
"we cat set mobile token to be valid forever"
return payload
except DecodeError as e:
raise InvalidToken("token format error") from e
else:
if not await cls.exists(payload.user_id, token):
raise InvalidToken("Login expired, please login again")
return payload
@classmethod
async def exists(cls, user_id, token):
token = await redis.execute("GET", f"{user_id}-{token}")
return token is not None
class JWTAuthenticate(AuthenticationBackend):
async def authenticate(
self, request: HTTPConnection
) -> Optional[Tuple[AuthCredentials, AuthUser]]:
if JWT_AUTH_HEADER not in request.headers:
return AuthCredentials(scopes=[]), AuthUser(user_id=None)
auth = request.headers[JWT_AUTH_HEADER]
try:
scheme, token = auth.split()
payload = await _Authenticate.verify(token)
except Exception as exc:
return AuthCredentials(scopes=[], error_message=str(exc)), AuthUser(user_id=None)
scopes = User.get_permission(user_id=payload.user_id)
return AuthCredentials(scopes=scopes, logged_in=True), AuthUser(user_id=payload.user_id)
def login_required(func):
@wraps(func)
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
auth: AuthCredentials = info.context["request"].auth
if not auth.logged_in:
raise OperationNotAllowed(auth.error_message or "Please login")
return await func(parent, info, *args, **kwargs)
return wrap

39
auth/authorize.py Normal file
View File

@@ -0,0 +1,39 @@
from datetime import datetime, timedelta
from auth.token import Token
from redis import redis
from settings import JWT_LIFE_SPAN
from validations import User
class Authorize:
@staticmethod
async def authorize(user: User, device: str = "pc", auto_delete=True) -> str:
"""
:param user:
:param device:
:param auto_delete: Whether the expiration is automatically deleted, the default is True
:return:
"""
exp = datetime.utcnow() + timedelta(seconds=JWT_LIFE_SPAN)
token = Token.encode(user, exp=exp, device=device)
await redis.execute("SET", f"{user.id}-{token}", "True")
if auto_delete:
expire_at = (exp + timedelta(seconds=JWT_LIFE_SPAN)).timestamp()
await redis.execute("EXPIREAT", f"{user.id}-{token}", int(expire_at))
return token
@staticmethod
async def revoke(token: str) -> bool:
try:
payload = Token.decode(token)
except: # noqa
pass
else:
await redis.execute("DEL", f"{payload.id}-{token}")
return True
@staticmethod
async def revoke_all(user: User):
tokens = await redis.execute("KEYS", f"{user.id}-*")
await redis.execute("DEL", *tokens)

34
auth/credentials.py Normal file
View File

@@ -0,0 +1,34 @@
from typing import List, Optional, Text
from pydantic import BaseModel
class Permission(BaseModel):
name: Text
class AuthCredentials(BaseModel):
user_id: Optional[int] = None
scopes: Optional[set] = {}
logged_in: bool = False
error_message: str = ""
@property
def is_admin(self):
return True
async def permissions(self) -> List[Permission]:
assert self.user_id is not None, "Please login first"
return NotImplemented()
class AuthUser(BaseModel):
user_id: Optional[int]
@property
def is_authenticated(self) -> bool:
return self.user_id is not None
@property
def display_id(self) -> int:
return self.user_id

17
auth/identity.py Normal file
View File

@@ -0,0 +1,17 @@
from auth.password import Password
from exceptions import InvalidPassword, ObjectNotExist
from orm import User as OrmUser
from orm.base import global_session
from validations import User
class Identity:
@staticmethod
def identity(user_id: int, password: str) -> User:
user = global_session.query(OrmUser).filter_by(id=user_id).first()
if not user:
raise ObjectNotExist("User does not exist")
user = User(**user.dict())
if not Password.verify(password, user.password):
raise InvalidPassword("Wrong user password")
return user

11
auth/password.py Normal file
View File

@@ -0,0 +1,11 @@
from passlib.hash import pbkdf2_sha256
class Password:
@staticmethod
def encode(password: str) -> str:
return pbkdf2_sha256.hash(password)
@staticmethod
def verify(password: str, other: str) -> bool:
return pbkdf2_sha256.verify(password, other)

23
auth/token.py Normal file
View File

@@ -0,0 +1,23 @@
from datetime import datetime
import jwt
from settings import JWT_ALGORITHM, JWT_SECRET_KEY
from validations import PayLoad, User
class Token:
@staticmethod
def encode(user: User, exp: datetime, device: str = "pc") -> str:
payload = {"user_id": user.id, "device": device, "exp": exp, "iat": datetime.utcnow()}
return jwt.encode(payload, JWT_SECRET_KEY, JWT_ALGORITHM).decode("UTF-8")
@staticmethod
def decode(token: str, verify_exp: bool = True) -> PayLoad:
payload = jwt.decode(
token,
key=JWT_SECRET_KEY,
options={"verify_exp": verify_exp},
algorithms=[JWT_ALGORITHM],
)
return PayLoad(**payload)

28
auth/validations.py Normal file
View File

@@ -0,0 +1,28 @@
from datetime import datetime
from typing import Optional, Text
from pydantic import BaseModel
class User(BaseModel):
id: Optional[int]
# age: Optional[int]
username: Optional[Text]
# phone: Optional[Text]
password: Optional[Text]
class PayLoad(BaseModel):
user_id: int
device: Text
exp: datetime
iat: datetime
class CreateUser(BaseModel):
username: Text
# age: Optional[int]
# phone: Optional[Text]
password: Optional[Text]
# TODO: update validations