Commit existing codebase
This commit is contained in:
commit
49bc902bb9
24 changed files with 1208 additions and 0 deletions
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/auth/__init__.py
Normal file
0
app/auth/__init__.py
Normal file
10
app/auth/dto.py
Normal file
10
app/auth/dto.py
Normal 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
17
app/auth/handlers.py
Normal 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
17
app/auth/middleware.py
Normal 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
39
app/auth/service.py
Normal 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
9
app/config.py
Normal 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
21
app/db.py
Normal 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
25
app/main.py
Normal 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
85
app/migrations/env.py
Normal 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()
|
||||
24
app/migrations/script.py.mako
Normal file
24
app/migrations/script.py.mako
Normal 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"}
|
||||
41
app/migrations/versions/646ae6f3e17a_initial.py
Normal file
41
app/migrations/versions/646ae6f3e17a_initial.py
Normal 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
0
app/user/__init__.py
Normal file
60
app/user/dto.py
Normal file
60
app/user/dto.py
Normal 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
59
app/user/handlers.py
Normal 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
29
app/user/model.py
Normal 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
42
app/user/service.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue