feat: added JWT-based authentication; introduced config file

* authorization [WIP]
* refactored several sections
* used dict(exclude_unset=True)
* global values are now sourced from a config file (statuspagerc)
This commit is contained in:
surtur 2020-08-11 11:48:46 +02:00
parent 42b7bb69f1
commit 927bdd03ce
Signed by: wanderer
GPG Key ID: 19CE1EC1D9E0486D
7 changed files with 126 additions and 13 deletions

42
app/auth.py Normal file

@ -0,0 +1,42 @@
import bcrypt
import jwt
from fastapi import HTTPException
from datetime import datetime, timedelta
from app import crud
from app.database import SessionLocal
from app.settings import globals as settings
KEY = settings.JWT_SECRET
ALGORITHM = settings.JWT_ALGORITHM
EXPIRY = settings.JWT_EXPIRY
def login(username: str, password: str):
db = SessionLocal()
usr = crud.login_info(db=db, name=username,password=password)
if usr is None:
return {"status": "error", "mesage": "username/password incorrect"}
description = 'access_token'
token = jwt.encode({
'sub': username,
'iat': datetime.utcnow(),
'exp': datetime.utcnow() + timedelta(seconds=EXPIRY),
'des': description
},
KEY, ALGORITHM)
return {"status": "success", "token": token.decode('utf-8')}
def validate(token):
try:
data = jwt.decode(token, KEY)
except Exception as e:
if "expired" in str(e):
raise HTTPException(status_code=401, detail={"status": "error", "message": "Token expired"})
elif "Not enough segments" in str(e):
raise HTTPException(status_code=401, detail={"status": "error", "message": "Invalid token"})
else:
raise HTTPException(status_code=400, detail={"status": "error", "message": "Exception: " + str(e)})
return data

@ -1,6 +1,6 @@
import bcrypt, time import bcrypt, time
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from . import models, schemas from app import models, schemas
User=models.User User=models.User
@ -34,7 +34,7 @@ def create_user(db: Session, user: schemas.UserCreate):
def update_user(db: Session, user_id: int, user: schemas.UserUpdate): def update_user(db: Session, user_id: int, user: schemas.UserUpdate):
updated_user = User(**user.dict()) updated_user = User(**user.dict(exclude_unset=True))
if (updated_user.password is not None): if (updated_user.password is not None):
updated_user.password = bcrypt.hashpw(user.password.encode(), bcrypt.gensalt()) updated_user.password = bcrypt.hashpw(user.password.encode(), bcrypt.gensalt())
db.query(User).filter(User.id==user_id).update({User.password: updated_user.password}) db.query(User).filter(User.id==user_id).update({User.password: updated_user.password})
@ -74,7 +74,7 @@ def del_service(db: Session, service_id: int):
def update_service(db: Session, service_id: int, s: schemas.ServiceUpdate): def update_service(db: Session, service_id: int, s: schemas.ServiceUpdate):
updated_s = Service(**s.dict()) updated_s = Service(**s.dict(exclude_unset=True))
if (updated_s.name is not None): if (updated_s.name is not None):
db.query(Service).filter(Service.id==service_id).update({Service.name: updated_s.name}) db.query(Service).filter(Service.id==service_id).update({Service.name: updated_s.name})
if (updated_s.owner_id is not None): if (updated_s.owner_id is not None):
@ -93,3 +93,19 @@ def update_service(db: Session, service_id: int, s: schemas.ServiceUpdate):
db.query(Service).filter(Service.id==service_id).update({Service.updated_unix: time.time()}) db.query(Service).filter(Service.id==service_id).update({Service.updated_unix: time.time()})
return db.commit() return db.commit()
def login_info(db: Session, name: str, password: str):
try:
usr = db.query(User).filter(User.name == name).first()
if bcrypt.hashpw(password.encode(), usr.password) == usr.password:
print("[*] match")
else:
print("[x] no match")
return None
except Exception as e:
print('exception', e)
return
return usr

@ -3,16 +3,18 @@
from typing import List, Optional from typing import List, Optional
from fastapi import FastAPI, Depends, HTTPException from fastapi import FastAPI, Depends, Security, HTTPException
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import crud, models, schemas from app import crud, models, schemas, auth
from app.database import SessionLocal, engine from app.database import SessionLocal, engine
models.Base.metadata.create_all(bind=engine) models.Base.metadata.create_all(bind=engine)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl='/api/v1/auth/get_token')
app = FastAPI() app = FastAPI(title="Statuspage API",description="This documentation describes the Statuspage API.",version="0.0.1")
def get_db(): def get_db():
db = SessionLocal() db = SessionLocal()
@ -21,7 +23,10 @@ def get_db():
finally: finally:
db.close() db.close()
def is_auth(token: str = Security(oauth2_scheme)):
if not token:
raise HTTPException(status_code=401, detail={"status": "error", "message": "Token not provided "})
return auth.validate(token)
def get_usr(user_id: int, db: Session): def get_usr(user_id: int, db: Session):
@ -49,7 +54,7 @@ async def read_services(skip: int = 0, limit: int = 100, db: Session = Depends(g
return crud.get_services(db, skip=skip, limit=limit) return crud.get_services(db, skip=skip, limit=limit)
@app.post("/api/v1/service", response_model=schemas.Service) @app.post("/api/v1/service", response_model=schemas.Service)
async def create_service(service: schemas.ServiceCreate, db: Session = Depends(get_db)): async def create_service(service: schemas.ServiceCreate, db: Session = Depends(get_db), token: str = Security(is_auth)):
"""Service types: icmp = 0, http = 1""" """Service types: icmp = 0, http = 1"""
if service.service_type not in [0, 1]: if service.service_type not in [0, 1]:
raise HTTPException(status_code=422, detail="Invalid service type provided") raise HTTPException(status_code=422, detail="Invalid service type provided")
@ -66,7 +71,7 @@ async def read_service(service_id: int, db: Session = Depends(get_db)):
return db_service return db_service
@app.patch("/api/v1/service/{service_id}") @app.patch("/api/v1/service/{service_id}")
async def update_service(service_id: int, service: schemas.ServiceUpdate, db: Session = Depends(get_db)): async def update_service(service_id: int, service: schemas.ServiceUpdate, db: Session = Depends(get_db), token: str = Security(is_auth)):
if ((service.name is None) and (service.owner_id is None) and (service.is_private is None) and (service.description is None) and (service.service_type is None) and (service.url is None) and (service.is_active is None)): if ((service.name is None) and (service.owner_id is None) and (service.is_private is None) and (service.description is None) and (service.service_type is None) and (service.url is None) and (service.is_active is None)):
raise HTTPException(status_code=400, detail="No data provided") raise HTTPException(status_code=400, detail="No data provided")
db_service = get_srv(service_id, db) db_service = get_srv(service_id, db)
@ -76,7 +81,7 @@ async def update_service(service_id: int, service: schemas.ServiceUpdate, db: Se
return {"update":"success"} return {"update":"success"}
@app.delete("/api/v1/service/{service_id}") @app.delete("/api/v1/service/{service_id}")
async def delete_service(service_id: int, db: Session = Depends(get_db)): async def delete_service(service_id: int, db: Session = Depends(get_db), token: str = Security(is_auth)):
db_service = get_srv(service_id, db) db_service = get_srv(service_id, db)
if db_service is None: if db_service is None:
raise HTTPException(status_code=422, detail="Unprocessable entity") raise HTTPException(status_code=422, detail="Unprocessable entity")
@ -86,7 +91,7 @@ async def delete_service(service_id: int, db: Session = Depends(get_db)):
@app.post("/api/v1/users", response_model=schemas.User) @app.post("/api/v1/users", response_model=schemas.User)
async def create_user(user: schemas.UserCreate, db: Session = Depends(get_db)): async def create_user(user: schemas.UserCreate, db: Session = Depends(get_db), token: str = Security(is_auth)):
db_user = crud.get_user_by_username(db, name=user.name) db_user = crud.get_user_by_username(db, name=user.name)
if db_user: if db_user:
raise HTTPException(status_code=400, detail="Username already registered") raise HTTPException(status_code=400, detail="Username already registered")
@ -105,7 +110,7 @@ async def read_user(user_id: int, db: Session = Depends(get_db)):
return db_user return db_user
@app.patch("/api/v1/users/{user_id}") @app.patch("/api/v1/users/{user_id}")
async def update_user(user_id: int, user: schemas.UserUpdate, db: Session = Depends(get_db)): async def update_user(user_id: int, user: schemas.UserUpdate, db: Session = Depends(get_db), token: str = Security(is_auth)):
if ((user.name is None) and (user.password is None) and (user.full_name is None) and (user.is_active is None)): if ((user.name is None) and (user.password is None) and (user.full_name is None) and (user.is_active is None)):
raise HTTPException(status_code=400, detail="No data provided") raise HTTPException(status_code=400, detail="No data provided")
db_user = get_usr(user_id, db) db_user = get_usr(user_id, db)
@ -115,10 +120,25 @@ async def update_user(user_id: int, user: schemas.UserUpdate, db: Session = Depe
return {"update":"success"} return {"update":"success"}
@app.delete("/api/v1/users/{user_id}") @app.delete("/api/v1/users/{user_id}")
async def delete_user(user_id: int, db: Session = Depends(get_db)): async def delete_user(user_id: int, db: Session = Depends(get_db), token: str = Security(is_auth)):
db_user = get_usr(user_id, db) db_user = get_usr(user_id, db)
if db_user is None: if db_user is None:
raise HTTPException(status_code=422, detail="Unprocessable entity") raise HTTPException(status_code=422, detail="Unprocessable entity")
crud.del_user(db=db, user_id=user_id) crud.del_user(db=db, user_id=user_id)
return {"delete":"success"} return {"delete":"success"}
@app.post('/api/v1/auth/get_token',response_model=schemas.Token,response_description="Returns user access token",summary="Authenticate API user",description="Authenticate an API user and return a token for subsequent requests")
async def get_token(form_data: OAuth2PasswordRequestForm = Depends()):
a = auth.login(form_data.username, form_data.password)
if a and a["status"] == "error":
raise HTTPException(status_code=400, detail={"status": "error", "mesage": "username/password incorrect"})
return {"access_token": a["token"], "token_type": "bearer"}
@app.post('/api/v1/auth/validate')
async def validate_token(token: str):
a = auth.validate(token=token)
if a:
return a

@ -70,3 +70,7 @@ class APISimpleErrorResponse(BaseModel):
success: bool = False success: bool = False
errors: Optional[List[str]] errors: Optional[List[str]]
class Token(BaseModel):
access_token: str
token_type: str

0
app/settings/__init__.py Normal file

19
app/settings/globals.py Normal file

@ -0,0 +1,19 @@
from pathlib import Path
from typing import Optional
from starlette.config import Config
p: Path = Path(__file__).parents[2] / "statuspagerc"
config: Config = Config(p if p.exists() else None)
DATABASE: str = config("DATABASE", cast=str)
ALEMBIC_CONFIG: str = (
DATABASE
)
JWT_SECRET: str = config("JWT_SECRET", cast=str)
JWT_ALGORITHM: str = config("JWT_ALGORITHM", cast=str)
JWT_EXPIRY: int = config("JWT_EXPIRY", cast=int)

12
statuspagerc Normal file

@ -0,0 +1,12 @@
# statuspage configuration file
# database file
DATABASE=sqlite:///sql_app.db
# [JWT related parameters]
# it's imperative you CHANGE JWT_SECRET to a unique value
# you can generate a random one with 'openssl rand -hex 64'
JWT_SECRET = 'deadbeefdeafbeefd34db33fdeadbeefdeafbeefd34db33fdeadbeefdeafbeefd34db33f'
JWT_ALGORITHM = "HS256"
# token expiration time in seconds
JWT_EXPIRY = 7200