Merge remote-tracking branch 'origin/main'

This commit is contained in:
Untone 2021-08-19 07:48:40 +03:00
commit 5bb4553360
10 changed files with 257 additions and 163 deletions

View File

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

View File

@ -9,7 +9,7 @@ class Permission(BaseModel):
class AuthCredentials(BaseModel): class AuthCredentials(BaseModel):
user_id: Optional[int] = None user_id: Optional[int] = None
scopes: Optional[set] = {} scopes: Optional[dict] = {}
logged_in: bool = False logged_in: bool = False
error_message: str = "" error_message: str = ""

20
create_crt.sh Executable file → Normal file
View File

@ -1,10 +1,10 @@
#!/bin/bash #!/bin/bash
openssl req -newkey rsa:4096 \ openssl req -newkey rsa:4096 \
-x509 \ -x509 \
-sha256 \ -sha256 \
-days 3650 \ -days 3650 \
-nodes \ -nodes \
-out discours.crt \ -out discours.crt \
-keyout discours.key \ -keyout discours.key \
-subj "/C=RU/ST=Moscow/L=Moscow/O=Discours/OU=Site/CN=test-api.discours.io" -subj "/C=RU/ST=Moscow/L=Moscow/O=Discours/OU=Site/CN=test-api.discours.io"

View File

@ -1,4 +1,4 @@
from orm.rbac import Operation, Permission, Role from orm.rbac import Organization, Operation, Resource, Permission, Role
from orm.user import User from orm.user import User
from orm.message import Message from orm.message import Message
from orm.shout import Shout from orm.shout import Shout
@ -7,3 +7,5 @@ from orm.base import Base, engine
__all__ = ["User", "Role", "Operation", "Permission", "Message", "Shout"] __all__ = ["User", "Role", "Operation", "Permission", "Message", "Shout"]
Base.metadata.create_all(engine) Base.metadata.create_all(engine)
Operation.init_table()
Resource.init_table()

View File

@ -3,63 +3,85 @@ import warnings
from typing import Type from typing import Type
from sqlalchemy import String, Column, ForeignKey, types, UniqueConstraint from sqlalchemy import String, Column, ForeignKey, types, UniqueConstraint
from sqlalchemy.orm import relationship
from orm.base import Base, REGISTRY, engine from orm.base import Base, REGISTRY, engine, local_session
class ClassType(types.TypeDecorator): class ClassType(types.TypeDecorator):
impl = types.String impl = types.String
@property @property
def python_type(self): def python_type(self):
return NotImplemented return NotImplemented
def process_literal_param(self, value, dialect): def process_literal_param(self, value, dialect):
return NotImplemented return NotImplemented
def process_bind_param(self, value, dialect): def process_bind_param(self, value, dialect):
return value.__name__ if isinstance(value, type) else str(value) return value.__name__ if isinstance(value, type) else str(value)
def process_result_value(self, value, dialect): def process_result_value(self, value, dialect):
class_ = REGISTRY.get(value) class_ = REGISTRY.get(value)
if class_ is None: if class_ is None:
warnings.warn(f"Can't find class <{value}>,find it yourself 😊", stacklevel=2) warnings.warn(f"Can't find class <{value}>,find it yourself 😊", stacklevel=2)
return class_ return class_
class Organization(Base):
__tablename__ = 'organization'
name: str = Column(String, nullable=False, unique=True, comment="Organization Name")
class Role(Base): class Role(Base):
__tablename__ = 'role' __tablename__ = 'role'
name: str = Column(String, nullable=False, unique=True, comment="Role Name") name: str = Column(String, nullable=False, unique=True, comment="Role Name")
org_id: int = Column(ForeignKey("organization.id", ondelete="CASCADE"), nullable=False, comment="Organization")
permissions = relationship("Permission")
class Operation(Base): class Operation(Base):
__tablename__ = 'operation' __tablename__ = 'operation'
name: str = Column(String, nullable=False, unique=True, comment="Operation Name") name: str = Column(String, nullable=False, unique=True, comment="Operation Name")
@staticmethod
def init_table():
with local_session() as session:
edit_op = session.query(Operation).filter(Operation.name == "edit").first()
if not edit_op:
edit_op = Operation.create(name = "edit")
Operation.edit_id = edit_op.id
class Resource(Base): class Resource(Base):
__tablename__ = "resource" __tablename__ = "resource"
resource_class: Type[Base] = Column(ClassType, nullable=False, unique=True, comment="Resource class") resource_class: Type[Base] = Column(ClassType, nullable=False, unique=True, comment="Resource class")
name: str = Column(String, nullable=False, unique=True, comment="Resource name") name: str = Column(String, nullable=False, unique=True, comment="Resource name")
@staticmethod
def init_table():
with local_session() as session:
shout_res = session.query(Resource).filter(Resource.name == "shout").first()
if not shout_res:
shout_res = Resource.create(name = "shout", resource_class = "shout")
Resource.shout_id = shout_res.id
class Permission(Base): class Permission(Base):
__tablename__ = "permission" __tablename__ = "permission"
__table_args__ = (UniqueConstraint("role_id", "operation_id", "resource_id"), {"extend_existing": True}) __table_args__ = (UniqueConstraint("role_id", "operation_id", "resource_id"), {"extend_existing": True})
role_id: int = Column(ForeignKey("role.id", ondelete="CASCADE"), nullable=False, comment="Role") role_id: int = Column(ForeignKey("role.id", ondelete="CASCADE"), nullable=False, comment="Role")
operation_id: int = Column(ForeignKey("operation.id", ondelete="CASCADE"), nullable=False, comment="Operation") operation_id: int = Column(ForeignKey("operation.id", ondelete="CASCADE"), nullable=False, comment="Operation")
resource_id: int = Column(ForeignKey("operation.id", ondelete="CASCADE"), nullable=False, comment="Resource") resource_id: int = Column(ForeignKey("operation.id", ondelete="CASCADE"), nullable=False, comment="Resource")
if __name__ == '__main__': if __name__ == '__main__':
Base.metadata.create_all(engine) Base.metadata.create_all(engine)
ops = [ ops = [
Permission(role_id=1, operation_id=1, resource_id=1), Permission(role_id=1, operation_id=1, resource_id=1),
Permission(role_id=1, operation_id=2, resource_id=1), Permission(role_id=1, operation_id=2, resource_id=1),
Permission(role_id=1, operation_id=3, resource_id=1), Permission(role_id=1, operation_id=3, resource_id=1),
Permission(role_id=1, operation_id=4, resource_id=1), Permission(role_id=1, operation_id=4, resource_id=1),
Permission(role_id=2, operation_id=4, resource_id=1) Permission(role_id=2, operation_id=4, resource_id=1)
] ]
global_session.add_all(ops) global_session.add_all(ops)
global_session.commit() global_session.commit()

View File

@ -12,7 +12,7 @@ class Shout(Base):
id = None id = None
slug: str = Column(String, primary_key=True) slug: str = Column(String, primary_key=True)
org: str = Column(String, nullable=False) org_id: str = Column(ForeignKey("organization.id"), nullable=False)
author_id: str = Column(ForeignKey("user.id"), nullable=False, comment="Author") author_id: str = Column(ForeignKey("user.id"), nullable=False, comment="Author")
body: str = Column(String, nullable=False, comment="Body") body: str = Column(String, nullable=False, comment="Body")
createdAt: str = Column(DateTime, nullable=False, default = datetime.now, comment="Created at") createdAt: str = Column(DateTime, nullable=False, default = datetime.now, comment="Created at")

View File

@ -1,28 +1,41 @@
from typing import List from typing import List
from sqlalchemy import Column, Integer, String, ForeignKey #, relationship from sqlalchemy import Column, Integer, String, ForeignKey
from sqlalchemy.orm import relationship
from orm import Permission from orm import Permission
from orm.base import Base, local_session from orm.base import Base, local_session
class UserRole(Base):
__tablename__ = 'user_role'
id = None
user_id: int = Column(ForeignKey("user.id"), primary_key = True)
role_id: int = Column(ForeignKey("role.id"), primary_key = True)
class User(Base): class User(Base):
__tablename__ = 'user' __tablename__ = 'user'
email: str = Column(String, nullable=False) email: str = Column(String, unique=True, nullable=False)
username: str = Column(String, nullable=False, comment="Name") username: str = Column(String, nullable=False, comment="Name")
password: str = Column(String, nullable=True, comment="Password") password: str = Column(String, nullable=True, comment="Password")
role_id: list = Column(ForeignKey("role.id"), nullable=True, comment="Role")
# roles = relationship("Role") TODO: one to many, see schema.graphql
oauth_id: str = Column(String, nullable=True) oauth_id: str = Column(String, nullable=True)
roles = relationship("Role", secondary=UserRole.__table__)
@classmethod @classmethod
def get_permission(cls, user_id): def get_permission(cls, user_id):
scope = {}
with local_session() as session: with local_session() as session:
perms: List[Permission] = session.query(Permission).join(User, User.role_id == Permission.role_id).filter( user = session.query(User).filter(User.id == user_id).first()
User.id == user_id).all() for role in user.roles:
return {f"{p.operation_id}-{p.resource_id}" for p in perms} for p in role.permissions:
if not p.resource_id in scope:
scope[p.resource_id] = set()
scope[p.resource_id].add(p.operation_id)
return scope
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -1,50 +1,49 @@
from typing import Optional from typing import Optional
import aioredis import aioredis
# from aioredis import ConnectionsPool
from settings import REDIS_URL
from settings import REDIS_URL
class Redis:
class Redis: def __init__(self, uri=REDIS_URL):
def __init__(self, uri=REDIS_URL): self._uri: str = uri
self._uri: str = uri self._instance = None
self._instance = None
async def connect(self):
async def connect(self): if self._instance is not None:
if self._instance is not None: return
return self._instance = aioredis.from_url(self._uri, encoding="utf-8")
self._instance = await aioredis.from_url(self._uri)# .create_pool(self._uri)
async def disconnect(self):
async def disconnect(self): if self._instance is None:
if self._instance is None: return
return self._instance.close()
self._instance.close() await self._instance.wait_closed()
await self._instance.wait_closed() self._instance = None
self._instance = None
async def execute(self, command, *args, **kwargs):
async def execute(self, command, *args, **kwargs): return await self._instance.execute_command(command, *args, **kwargs)
return await self._instance.execute(command, *args, **kwargs, encoding="UTF-8")
async def test():
async def test(): redis = Redis()
redis = Redis() from datetime import datetime
from datetime import datetime
await redis.connect()
await redis.connect() await redis.execute("SET", "1-KEY1", 1)
await redis.execute("SET", "1-KEY1", 1) await redis.execute("SET", "1-KEY2", 1)
await redis.execute("SET", "1-KEY2", 1) await redis.execute("SET", "1-KEY3", 1)
await redis.execute("SET", "1-KEY3", 1) await redis.execute("SET", "1-KEY4", 1)
await redis.execute("SET", "1-KEY4", 1) await redis.execute("EXPIREAT", "1-KEY4", int(datetime.utcnow().timestamp()))
await redis.execute("EXPIREAT", "1-KEY4", int(datetime.utcnow().timestamp())) v = await redis.execute("KEYS", "1-*")
v = await redis.execute("KEYS", "1-*") print(v)
print(v) await redis.execute("DEL", *v)
await redis.execute("DEL", *v) v = await redis.execute("KEYS", "1-*")
v = await redis.execute("KEYS", "1-*") print(v)
print(v)
if __name__ == '__main__':
if __name__ == '__main__': import asyncio
import asyncio
asyncio.run(test())
asyncio.run(test())

View File

@ -1,4 +1,4 @@
from orm import Shout, User from orm import Shout, User, Organization, Resource
from orm.base import local_session from orm.base import local_session
from resolvers.base import mutation, query from resolvers.base import mutation, query
@ -15,10 +15,10 @@ class GitTask:
queue = asyncio.Queue() queue = asyncio.Queue()
def __init__(self, input, username, user_email, comment): def __init__(self, input, org, username, user_email, comment):
self.slug = input["slug"]; self.slug = input["slug"];
self.org = input["org"];
self.shout_body = input["body"]; self.shout_body = input["body"];
self.org = org;
self.username = username; self.username = username;
self.user_email = user_email; self.user_email = user_email;
self.comment = comment; self.comment = comment;
@ -84,12 +84,19 @@ async def create_shout(_, info, input):
auth = info.context["request"].auth auth = info.context["request"].auth
user_id = auth.user_id user_id = auth.user_id
org_id = org = input["org_id"]
with local_session() as session: with local_session() as session:
user = session.query(User).filter(User.id == user_id).first() user = session.query(User).filter(User.id == user_id).first()
org = session.query(Organization).filter(Organization.id == org_id).first()
if not org:
return {
"error" : "invalid organization"
}
new_shout = Shout.create( new_shout = Shout.create(
slug = input["slug"], slug = input["slug"],
org = input["org"], org_id = org_id,
author_id = user_id, author_id = user_id,
body = input["body"], body = input["body"],
replyTo = input.get("replyTo"), replyTo = input.get("replyTo"),
@ -100,6 +107,7 @@ async def create_shout(_, info, input):
task = GitTask( task = GitTask(
input, input,
org.name,
user.username, user.username,
user.email, user.email,
"new shout %s" % (new_shout.slug) "new shout %s" % (new_shout.slug)
@ -109,5 +117,51 @@ async def create_shout(_, info, input):
"shout" : new_shout "shout" : new_shout
} }
@mutation.field("updateShout")
@login_required
async def update_shout(_, info, input):
auth = info.context["request"].auth
user_id = auth.user_id
slug = input["slug"]
org_id = org = input["org_id"]
with local_session() as session:
user = session.query(User).filter(User.id == user_id).first()
shout = session.query(Shout).filter(Shout.slug == slug).first()
org = session.query(Organization).filter(Organization.id == org_id).first()
if not shout:
return {
"error" : "shout not found"
}
if shout.author_id != user_id:
scopes = auth.scopes
print(scopes)
if not Resource.shout_id in scopes:
return {
"error" : "access denied"
}
shout.body = input["body"],
shout.replyTo = input.get("replyTo"),
shout.versionOf = input.get("versionOf"),
shout.tags = input.get("tags"),
shout.topics = input.get("topics")
with local_session() as session:
session.commit()
task = GitTask(
input,
org.name,
user.username,
user.email,
"update shout %s" % (shout.slug)
)
return {
"shout" : shout
}
# TODO: paginate, get, update, delete # TODO: paginate, get, update, delete

View File

@ -23,7 +23,7 @@ type MessageResult {
} }
input ShoutInput { input ShoutInput {
org: String! org_id: Int!
slug: String! slug: String!
body: String! body: String!
replyTo: String # another shout replyTo: String # another shout
@ -61,10 +61,11 @@ type Mutation {
# invalidateTokenById(id: Int!): Boolean! # invalidateTokenById(id: Int!): Boolean!
# requestEmailConfirmation: User! # requestEmailConfirmation: User!
# requestPasswordReset(email: String!): Boolean! # requestPasswordReset(email: String!): Boolean!
registerUser(email: String!, password: String!): AuthResult! registerUser(email: String!, password: String!): AuthResult!
# shout # shout
createShout(input: ShoutInput!): ShoutResult! createShout(input: ShoutInput!): ShoutResult!
updateShout(input: ShoutInput!): ShoutResult!
deleteShout(slug: String!): Result! deleteShout(slug: String!): Result!
rateShout(slug: String!, value: Int!): Result! rateShout(slug: String!, value: Int!): Result!
@ -151,7 +152,7 @@ type Message {
# is publication # is publication
type Shout { type Shout {
org: String! org_id: Int!
slug: String! slug: String!
author: Int! author: Int!
body: String! body: String!