Commit existing codebase

This commit is contained in:
Andrey Chervyakov 2021-02-25 01:39:14 +06:00
commit 49bc902bb9
24 changed files with 1208 additions and 0 deletions

0
app/__init__.py Normal file
View file

0
app/auth/__init__.py Normal file
View file

10
app/auth/dto.py Normal file
View file

@ -0,0 +1,10 @@
from pydantic import BaseModel
class Credentials(BaseModel):
username: str
password: str
class TokenModel(BaseModel):
token: str

17
app/auth/handlers.py Normal file
View file

@ -0,0 +1,17 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.auth.dto import Credentials, TokenModel
from app.auth.service import authenticate
from app.db import get_db
router = APIRouter()
@router.post("/auth", status_code=200, response_model=TokenModel)
def issue_access_token(credentials: Credentials, db: Session = Depends(get_db)) -> TokenModel:
token = authenticate(credentials, db)
if token is None:
raise HTTPException(status_code=401)
else:
return TokenModel(token=token)

17
app/auth/middleware.py Normal file
View file

@ -0,0 +1,17 @@
from fastapi import Depends, HTTPException
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import JWTError
from app.auth.service import verify_token
from app.user.model import User
from app.user.service import get_user_by_id
from app.db import get_db
def get_auth_user(auth: HTTPAuthorizationCredentials = Depends(HTTPBearer()), db=Depends(get_db)) -> User:
try:
decoded_token = verify_token(auth.credentials)
auth_user = get_user_by_id(db, int(decoded_token["sub"]))
return auth_user
except JWTError:
raise HTTPException(status_code=403, detail="Invalid token")

39
app/auth/service.py Normal file
View file

@ -0,0 +1,39 @@
import os
from datetime import datetime, timedelta
from typing import Optional
from jose import jwt
from jose.constants import ALGORITHMS
from sqlalchemy.orm import Session
from app.auth.dto import Credentials
from app.config import config
from app.user.service import get_user_by_username, passwords_match
JWT_SECRET = config["CGNO_ID_JWT_SECRET"]
JWT_ISSUER = "Energia"
def authenticate(credentials: Credentials, db: Session) -> Optional[str]:
user = get_user_by_username(db, credentials.username)
if passwords_match(user.password, credentials.password):
token = issue_token(user.id)
return token
else:
return None
def issue_token(user_id: int) -> str:
now = datetime.utcnow()
claims = {
"sub": str(user_id),
"iss": JWT_ISSUER,
"iat": now,
"nbf": now,
"exp": now + timedelta(weeks=1)
}
return jwt.encode(claims, JWT_SECRET, algorithm=ALGORITHMS.HS256)
def verify_token(token: str) -> dict:
return jwt.decode(token, JWT_SECRET, algorithms=ALGORITHMS.HS256, issuer=JWT_ISSUER)

9
app/config.py Normal file
View file

@ -0,0 +1,9 @@
import os
from dotenv import dotenv_values
config = {
**dotenv_values(".env.dev"),
**dotenv_values(".env"),
**os.environ
}

21
app/db.py Normal file
View file

@ -0,0 +1,21 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.config import config
DATABASE_URL = f"postgresql://{config['CGNO_ID_DB_USERNAME']}:{config['CGNO_ID_DB_PASSWORD']}@{config['CGNO_ID_DB_HOST']}/{config['CGNO_ID_DB_NAME']}"
engine = create_engine(DATABASE_URL)
session_factory = sessionmaker(bind=engine)
EntityBase = declarative_base()
def get_db():
db = session_factory()
try:
yield db
finally:
db.close()

25
app/main.py Normal file
View file

@ -0,0 +1,25 @@
from fastapi import FastAPI, APIRouter
from starlette.middleware.cors import CORSMiddleware
from app.user.handlers import router as user_router
from app.auth.handlers import router as auth_router
def main_router() -> APIRouter:
router = APIRouter()
router.include_router(user_router, tags=["users"], prefix="/users")
router.include_router(auth_router, tags=["auth"])
return router
app = FastAPI(title="cognio ID API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"]
)
app.include_router(main_router())

85
app/migrations/env.py Normal file
View file

@ -0,0 +1,85 @@
import pathlib
import sys
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
sys.path.append(str(pathlib.Path(__file__).resolve().parents[2]))
from app.db import DATABASE_URL
from app.user.model import User
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = [User.metadata]
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
config.set_main_option("sqlalchemy.url", DATABASE_URL)
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View file

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1,41 @@
"""Initial
Revision ID: 646ae6f3e17a
Revises:
Create Date: 2021-02-25 00:59:54.287382
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '646ae6f3e17a'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sa.String(length=32), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('given_name', sa.String(length=32), nullable=False),
sa.Column('family_name', sa.String(length=32), nullable=True),
sa.Column('sex', sa.Enum('NOT_KNOWN', 'MALE', 'FEMALE', 'NOT_APPLICABLE', name='usersex'), nullable=False),
sa.Column('birthdate', sa.Date(), nullable=True),
sa.Column('password', sa.String(), nullable=False),
sa.Column('creation_date', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email'),
sa.UniqueConstraint('username')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('users')
# ### end Alembic commands ###

0
app/user/__init__.py Normal file
View file

60
app/user/dto.py Normal file
View file

@ -0,0 +1,60 @@
import calendar
from datetime import datetime, date
from typing import Optional
from pydantic import BaseModel
from app.user.model import User, Sex
class UserCreationModel(BaseModel):
username: str
email: str
password: str
given_name: str
family_name: Optional[str]
sex: str
birthdate: Optional[int]
def to_entity(self) -> User:
birthdate = None
if self.birthdate is not None:
birthdate = date.fromtimestamp(self.birthdate)
return User(
username=self.username,
email=self.email,
password=self.password,
given_name=self.given_name,
family_name=self.family_name,
sex=Sex[self.sex],
birthdate=birthdate
)
class UserResourceModel(BaseModel):
id: int
username: str
email: str
given_name: str
family_name: Optional[str]
sex: str
birthdate: Optional[int]
@staticmethod
def from_entity(user: User):
birthdate = None
if user.birthdate is not None:
birthdate = calendar.timegm(user.birthdate.timetuple())
return UserResourceModel(
id=user.id,
username=user.username,
email=user.email,
given_name=user.given_name,
family_name=user.family_name,
sex=user.sex.name,
birthdate=birthdate
)

59
app/user/handlers.py Normal file
View file

@ -0,0 +1,59 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from starlette.responses import Response
from app.auth.middleware import get_auth_user
from app.user.dto import UserCreationModel, UserResourceModel
from app.user.model import User
import app.user.service as user_service
from app.db import get_db
router = APIRouter()
@router.post("", status_code=201, response_model=UserResourceModel)
async def create_user(model: UserCreationModel, db: Session = Depends(get_db)) -> UserResourceModel:
user = model.to_entity()
created_user = user_service.create_user(db, user)
return UserResourceModel.from_entity(created_user)
@router.get("/{id}", status_code=200, response_model=UserResourceModel)
async def get_user_by_id(
id: int,
auth_user: User = Depends(get_auth_user),
db: Session = Depends(get_db)
) -> UserResourceModel:
check_access(auth_user, id)
user = user_service.get_user_by_id(db, id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
return UserResourceModel.from_entity(user)
@router.delete("/{id}", status_code=204)
async def delete_user_by_id(
id: int,
auth_user: User = Depends(get_auth_user),
db: Session = Depends(get_db)
):
check_access(auth_user, id)
user_service.delete_user_by_id(db, id)
return Response(status_code=204)
def check_access(auth_user: User, param: Any):
access_exception = HTTPException(status_code=403, detail="Forbidden")
if type(param) is int:
if not (auth_user.id == param):
raise access_exception
elif type(param) is User:
if not ((auth_user.username == param.username) and (auth_user.email == param.email)):
raise access_exception

29
app/user/model.py Normal file
View file

@ -0,0 +1,29 @@
import enum
from datetime import datetime
from sqlalchemy import Column, String, Integer, DateTime, Enum, Date
from app.db import EntityBase
class Sex(enum.Enum):
"""Human sex according to ISO/IEC 5218."""
NOT_KNOWN = 0,
MALE = 1,
FEMALE = 2,
NOT_APPLICABLE = 9
class User(EntityBase):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
username = Column(String(length=32), unique=True, nullable=False)
email = Column(String, unique=True, nullable=False)
password = Column(String, nullable=False)
given_name = Column(String(length=32), nullable=False)
family_name = Column(String(length=32))
sex = Column(Enum(Sex), nullable=False, default=Sex.NOT_KNOWN)
birthdate = Column(Date)
creation_date = Column(DateTime, nullable=False, default=datetime.utcnow())

42
app/user/service.py Normal file
View file

@ -0,0 +1,42 @@
from typing import Optional
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from app.user.model import User
pwd_context = CryptContext(schemes=["bcrypt"])
def create_user(db: Session, user: User) -> User:
if get_user_by_username_or_email(db, user.username, user.email) is not None:
raise Exception()
user.password = pwd_context.hash(user.password)
db.add(user)
db.commit()
db.refresh(user)
return user
def get_user_by_id(db: Session, id: int) -> Optional[User]:
return db.query(User).filter(User.id == id).one_or_none()
def get_user_by_username(db: Session, username: str) -> Optional[User]:
return db.query(User).filter(User.username == username).one_or_none()
def get_user_by_username_or_email(db: Session, username: str, email: str) -> Optional[User]:
return db.query(User).filter(User.username == username, User.email == email).one_or_none()
def delete_user_by_id(db: Session, id: int):
user = get_user_by_id(db, id)
if user is not None:
db.delete(user)
db.commit()
def passwords_match(hashed: str, raw: str) -> bool:
return pwd_context.verify(raw, hashed)