Lots more functionality - working towards UI

This commit is contained in:
Parker M. 2024-11-06 01:08:04 -06:00
parent eadc928933
commit 4c1dd74db3
Signed by: parker
GPG Key ID: 505ED36FC12B5D5E
9 changed files with 121 additions and 97 deletions

View File

@ -5,11 +5,11 @@ from fastapi.templating import Jinja2Templates
from app.routes.auth_routes import router as auth_router from app.routes.auth_routes import router as auth_router
from app.routes.links_routes import router as links_router from app.routes.links_routes import router as links_router
from app.routes.user_routes import router as user_router from app.routes.user_routes import router as user_router
from typing import Annotated from typing import Annotated, Union
from fastapi.exceptions import HTTPException from fastapi.exceptions import HTTPException
from starlette.status import HTTP_404_NOT_FOUND from starlette.status import HTTP_404_NOT_FOUND
from app.util.authentication import get_current_user_from_cookie from app.util.authentication import get_current_user
from app.util.db_dependency import get_db from app.util.db_dependency import get_db
from app.util.log import log from app.util.log import log
from app.schemas.auth_schemas import User from app.schemas.auth_schemas import User
@ -55,10 +55,8 @@ async def signup(request: Request):
@app.get("/dashboard") @app.get("/dashboard")
async def dashboard( async def dashboard(
response: Annotated[
User, RedirectResponse, Depends(get_current_user_from_cookie)
],
request: Request, request: Request,
response: Union[User, RedirectResponse] = Depends(get_current_user),
): ):
if isinstance(response, RedirectResponse): if isinstance(response, RedirectResponse):
return response return response

View File

@ -1,6 +1,6 @@
from fastapi import Depends, APIRouter, status, HTTPException from fastapi import Depends, APIRouter, status, HTTPException
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from fastapi.responses import Response from fastapi.responses import Response, JSONResponse
from datetime import timedelta from datetime import timedelta
from typing import Annotated from typing import Annotated
@ -21,7 +21,7 @@ async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()], form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
response: Response, response: Response,
db=Depends(get_db), db=Depends(get_db),
) -> Token: ):
""" """
Return an access token for the user, if the given authentication details are correct Return an access token for the user, if the given authentication details are correct
""" """
@ -45,20 +45,19 @@ async def login_for_access_token(
data={"sub": user.id, "username": user.username, "refresh": True}, data={"sub": user.id, "username": user.username, "refresh": True},
expires_delta=refresh_token_expires, expires_delta=refresh_token_expires,
) )
# response = JSONResponse(content={"success": True}) response = JSONResponse(content={"success": True})
# response.set_cookie( response.set_cookie(key="access_token", value=access_token, httponly=True)
# key="access_token", value=access_token, httponly=True, samesite="lax" response.set_cookie(
# ) key="refresh_token", value=refresh_token, httponly=True
# response.set_cookie( )
# key="refresh_token", value=refresh_token, httponly=True, samesite="lax" return response
# )
# For Swagger UI to work, must return the token # For Swagger UI to work, must return the token
return Token( # return Token(
access_token=access_token, # access_token=access_token,
refresh_token=refresh_token, # refresh_token=refresh_token,
token_type="bearer", # token_type="bearer",
) # )
# Full native JWT support is not complete in FastAPI yet :( # Full native JWT support is not complete in FastAPI yet :(

View File

@ -10,7 +10,7 @@ from app.util.db_dependency import get_db
from models import Link, Log from models import Link, Log
from app.schemas.links_schemas import URLSchema from app.schemas.links_schemas import URLSchema
from app.schemas.auth_schemas import User from app.schemas.auth_schemas import User
from app.util.authentication import get_current_user_from_token from app.util.authentication import get_current_user
router = APIRouter(prefix="/links", tags=["links"]) router = APIRouter(prefix="/links", tags=["links"])
@ -18,7 +18,7 @@ router = APIRouter(prefix="/links", tags=["links"])
@router.get("/", summary="Get all of the links associated with your account") @router.get("/", summary="Get all of the links associated with your account")
async def get_links( async def get_links(
current_user: Annotated[User, Depends(get_current_user_from_token)], current_user: Annotated[User, Depends(get_current_user)],
db=Depends(get_db), db=Depends(get_db),
): ):
links = db.query(Link).filter(Link.owner == current_user.id).all() links = db.query(Link).filter(Link.owner == current_user.id).all()
@ -32,7 +32,7 @@ async def get_links(
@router.post("/", summary="Create a new link") @router.post("/", summary="Create a new link")
async def create_link( async def create_link(
url: URLSchema, url: URLSchema,
current_user: Annotated[User, Depends(get_current_user_from_token)], current_user: Annotated[User, Depends(get_current_user)],
db=Depends(get_db), db=Depends(get_db),
): ):
# Check if the URL is valid # Check if the URL is valid
@ -51,8 +51,6 @@ async def create_link(
link=link_path, link=link_path,
owner=current_user.id, owner=current_user.id,
redirect_link=url.url, redirect_link=url.url,
expire_date=datetime.datetime.now()
+ datetime.timedelta(days=30),
) )
db.add(new_link) db.add(new_link)
db.commit() db.commit()
@ -60,13 +58,13 @@ async def create_link(
except: except:
continue continue
return new_link return {"link": link_path, "expire_date": new_link.expire_date}
@router.delete("/{link}", summary="Delete a link") @router.delete("/{link}", summary="Delete a link")
async def delete_link( async def delete_link(
link: Annotated[str, Path(title="Link to delete")], link: Annotated[str, Path(title="Link to delete")],
current_user: Annotated[User, Depends(get_current_user_from_token)], current_user: Annotated[User, Depends(get_current_user)],
db=Depends(get_db), db=Depends(get_db),
): ):
""" """
@ -99,7 +97,7 @@ async def delete_link(
@router.get("/{link}/logs", summary="Get all logs associated with a link") @router.get("/{link}/logs", summary="Get all logs associated with a link")
async def get_link_logs( async def get_link_logs(
link: Annotated[str, Path(title="Link to get logs for")], link: Annotated[str, Path(title="Link to get logs for")],
current_user: Annotated[User, Depends(get_current_user_from_token)], current_user: Annotated[User, Depends(get_current_user)],
db=Depends(get_db), db=Depends(get_db),
): ):
""" """
@ -118,15 +116,20 @@ async def get_link_logs(
detail="Link not associated with your account", detail="Link not associated with your account",
) )
# Get and return all of the logs # Get and return all of the logs - ordered by timestamp
logs = db.query(Log).filter(Log.link == link.link).all() logs = (
db.query(Log)
.filter(Log.link == link.link)
.order_by(Log.timestamp.desc())
.all()
)
return logs return logs
@router.delete("/{link}/logs", summary="Delete logs associated with a link") @router.delete("/{link}/logs", summary="Delete logs associated with a link")
async def delete_link_logs( async def delete_link_logs(
link: Annotated[str, Path(title="Link to delete logs for")], link: Annotated[str, Path(title="Link to delete logs for")],
current_user: Annotated[User, Depends(get_current_user_from_token)], current_user: Annotated[User, Depends(get_current_user)],
db=Depends(get_db), db=Depends(get_db),
): ):
""" """

View File

@ -13,7 +13,7 @@ from app.schemas.user_schemas import *
from models import User as UserModel from models import User as UserModel
from app.util.authentication import ( from app.util.authentication import (
verify_password, verify_password,
get_current_user_from_token, get_current_user,
) )
@ -23,7 +23,7 @@ router = APIRouter(prefix="/users", tags=["users"])
@router.delete("/{user_id}", summary="Delete your account") @router.delete("/{user_id}", summary="Delete your account")
async def delete_user( async def delete_user(
user_id: Annotated[int, Path(title="Link to delete")], user_id: Annotated[int, Path(title="Link to delete")],
current_user: Annotated[User, Depends(get_current_user_from_token)], current_user: Annotated[User, Depends(get_current_user)],
db=Depends(get_db), db=Depends(get_db),
): ):
""" """
@ -53,7 +53,7 @@ async def delete_user(
async def update_pass( async def update_pass(
user_id: Annotated[int, Path(title="Link to update")], user_id: Annotated[int, Path(title="Link to update")],
update_data: UpdatePasswordSchema, update_data: UpdatePasswordSchema,
current_user: Annotated[User, Depends(get_current_user_from_token)], current_user: Annotated[User, Depends(get_current_user)],
db=Depends(get_db), db=Depends(get_db),
): ):
""" """

View File

@ -7,9 +7,15 @@
</head> </head>
<body> <body>
<div> <div>
<!-- Create a small box that will hold the text for the users api key, next to the box should be a regenerate button --> <!-- Create a table with 5 columns with a total of 750px width -->
<p>Your Username: <span id="api-key">{{ user }}</span></p> <table style="width: 750px; margin: 0 auto;">
<button onclick="window.location.href='logout'">Logout</button> <tr>
<th style="width: 150px;">ID</th>
<th style="width: 150px;">Timestamp</th>
<th style="width: 150px;">IP</th>
<th style="width: 150px;">Location</th>
<th style="width: 150px;">ISP</th>
</tr>
</div> </div>
</body> </body>
</html> </html>
@ -31,17 +37,55 @@
font-size: 25px; font-size: 25px;
color: #ccc; color: #ccc;
} }
button {
display: block;
margin: 10px auto;
width: 200px;
border-radius: 5px;
padding: 15px;
color: #ccc;
background-color: #415eac;
border: none;
font-size: 17px;
cursor: pointer;
}
</style> </style>
<script>
// Function to get a cookie by name
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
// On window load
window.onload = async () => {
const data = await fetch('/api/links/3MY70/logs', {
method: 'GET',
});
const logs = await data.json();
const table = document.querySelector('table');
// For every log, add a row to the table
counter = 1;
logs.forEach(log => {
const row = document.createElement('tr');
let date = new Date(log.timestamp);
let readableDate = date.toLocaleTimeString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
}
);
row.innerHTML = `
<td>${counter++}</td>
<td>${readableDate}</td>
<td>${log.ip}</td>
<td>${log.location}</td>
<td>${log.isp}</td>
`;
table.appendChild(row);
});
}
</script>

View File

@ -91,21 +91,22 @@
// Prevent default form submission // Prevent default form submission
event.preventDefault(); event.preventDefault();
// Get form data
const formData = new FormData(this); const formData = new FormData(this);
// Send POST request to /signup containing form data
// Send POST request
const response = await fetch('/api/users/register', { const response = await fetch('/api/users/register', {
method: 'POST', method: 'POST',
body: formData body: formData
}); });
if (response.status != 200) { if (response.status != 200) {
const data = await response.json(); const data = await response.json()
document.getElementById('error').style.display = 'block'; document.getElementById('error').style.display = 'block';
document.getElementById('error').innerText = data.detail; document.getElementById('error').innerText = data.detail;
} } else {
else { window.location.href = '/dashboard';
window.location.href = '/login';
} }
}); });
</script> </script>

View File

@ -1,15 +1,15 @@
import random import random
import bcrypt import bcrypt
from fastapi import Depends, HTTPException, status, Cookie from fastapi import Depends, HTTPException, status, Request, Cookie
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from jwt.exceptions import InvalidTokenError from jwt.exceptions import InvalidTokenError
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Annotated from typing import Annotated, Optional
import jwt import jwt
from app.util.db_dependency import get_db from app.util.db_dependency import get_db
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import Session
from app.schemas.auth_schemas import * from app.schemas.auth_schemas import *
from models import User as UserModel from models import User as UserModel
@ -62,30 +62,6 @@ def create_access_token(data: dict, expires_delta: timedelta):
return encoded_jwt return encoded_jwt
async def get_current_user_from_cookie(
access_token: str = Cookie(None), db=Depends(get_db)
):
"""
Return the user based on the access token in the cookie
Used for authentication into UI pages - so if no cookie
exists, redirect to login page rather than returning a 401
Also pass is_ui=True to alert get_current_user that we need
to use RedirectResponse rather than raising an HTTPException
"""
if access_token:
return await get_current_user(access_token, is_ui=True, db=db)
return RedirectResponse(url="/login")
async def get_current_user_from_token(
token: Annotated[str, Depends(oauth2_scheme)],
db=Depends(get_db),
):
return await get_current_user(token, db=db)
# Backwards kind of way to get refresh token support # Backwards kind of way to get refresh token support
# `refresh_get_current_user` is only called from /refresh # `refresh_get_current_user` is only called from /refresh
# and alerts `get_current_user` that it should expect a refresh token # and alerts `get_current_user` that it should expect a refresh token
@ -97,10 +73,8 @@ async def refresh_get_current_user(
async def get_current_user( async def get_current_user(
token: str, request: Request,
is_refresh: bool = False, db=Depends(get_db),
is_ui: bool = False,
db: sessionmaker = None,
): ):
""" """
Return the current user based on the token Return the current user based on the token
@ -110,9 +84,16 @@ async def get_current_user(
Otherwise, the request is from an API and we should return a 401 Otherwise, the request is from an API and we should return a 401
""" """
# If the request is from /api/auth/refresh, it is a request to get
# a new access token using a refresh token
if request.url.path == "/api/auth/refresh":
token = request.cookies.get("refresh_token")
is_refresh = True
else:
token = request.cookies.get("access_token")
is_refresh = False
def raise_unauthorized(): def raise_unauthorized():
if is_ui:
return RedirectResponse(url="/login")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials", detail="Could not validate credentials",
@ -126,12 +107,8 @@ async def get_current_user(
refresh: bool = payload.get("refresh") refresh: bool = payload.get("refresh")
if not id or not username: if not id or not username:
return raise_unauthorized() return raise_unauthorized()
# For some reason, an access token was passed when a refresh
# token was expected - some likely malicious activity # Make sure that a refresh token was not passed to any other endpoint
if not refresh and is_refresh:
return raise_unauthorized()
# If the token passed is a refresh token and the function
# is not expecting a refresh token, raise an error
if refresh and not is_refresh: if refresh and not is_refresh:
return raise_unauthorized() return raise_unauthorized()

View File

@ -60,7 +60,6 @@ def log(link, ip, user_agent):
# Get the location and ISP of the user # Get the location and ISP of the user
location, isp = ip_to_location(ip) location, isp = ip_to_location(ip)
timestamp = datetime.datetime.now()
ua_string = user_agent_parser.Parse(user_agent) ua_string = user_agent_parser.Parse(user_agent)
browser = ua_string["user_agent"]["family"] browser = ua_string["user_agent"]["family"]
os = f'{ua_string["os"]["family"]} {ua_string["os"]["major"]}' os = f'{ua_string["os"]["family"]} {ua_string["os"]["major"]}'
@ -69,7 +68,6 @@ def log(link, ip, user_agent):
new_log = Log( new_log = Log(
owner=owner, owner=owner,
link=link, link=link,
timestamp=timestamp,
ip=ip, ip=ip,
location=location, location=location,
browser=browser, browser=browser,

View File

@ -6,6 +6,7 @@ from sqlalchemy import (
Text, Text,
DateTime, DateTime,
) )
import datetime
from database import Base from database import Base
@ -23,7 +24,10 @@ class Link(Base):
link = Column(String, primary_key=True) link = Column(String, primary_key=True)
owner = Column(Integer, ForeignKey("users.id"), nullable=False) owner = Column(Integer, ForeignKey("users.id"), nullable=False)
redirect_link = Column(String, nullable=False) redirect_link = Column(String, nullable=False)
expire_date = Column(DateTime, nullable=False) expire_date = Column(
DateTime,
default=datetime.datetime.utcnow() + datetime.timedelta(days=30),
)
class Log(Base): class Log(Base):
@ -31,7 +35,7 @@ class Log(Base):
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
owner = Column(Integer, ForeignKey("users.id"), nullable=False) owner = Column(Integer, ForeignKey("users.id"), nullable=False)
link = Column(String, ForeignKey("links.link"), nullable=False) link = Column(String, ForeignKey("links.link"), nullable=False)
timestamp = Column(DateTime, nullable=False) timestamp = Column(DateTime, default=datetime.datetime.utcnow())
ip = Column(String, nullable=False) ip = Column(String, nullable=False)
location = Column(String, nullable=False) location = Column(String, nullable=False)
browser = Column(String, nullable=False) browser = Column(String, nullable=False)