wip: redis, sqlalchemy, structured, etc
This commit is contained in:
9
auth/README.md
Normal file
9
auth/README.md
Normal 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
78
auth/authenticate.py
Normal 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
39
auth/authorize.py
Normal 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
34
auth/credentials.py
Normal 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
17
auth/identity.py
Normal 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
11
auth/password.py
Normal 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
23
auth/token.py
Normal 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
28
auth/validations.py
Normal 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
|
Reference in New Issue
Block a user