From 691aa744a0398f185b3ca98a36fbd83806c7786c Mon Sep 17 00:00:00 2001 From: Parker Date: Sun, 10 Nov 2024 16:36:16 -0600 Subject: TOO MUCH STUFF --- api/main.py | 93 ++++++++++++++++++++++ api/routes/auth_routes.py | 46 +++++++++++ api/routes/links_routes.py | 172 ++++++++++++++++++++++++++++++++++++++++ api/routes/user_routes.py | 129 ++++++++++++++++++++++++++++++ api/schemas/auth_schemas.py | 15 ++++ api/schemas/links_schemas.py | 5 ++ api/schemas/user_schemas.py | 11 +++ api/static/js/api.js | 26 ++++++ api/templates/dashboard.html | 160 +++++++++++++++++++++++++++++++++++++ api/templates/login.html | 105 ++++++++++++++++++++++++ api/templates/signup.html | 112 ++++++++++++++++++++++++++ api/util/authentication.py | 97 ++++++++++++++++++++++ api/util/check_password_reqs.py | 26 ++++++ api/util/db_dependency.py | 9 +++ api/util/log.py | 84 ++++++++++++++++++++ 15 files changed, 1090 insertions(+) create mode 100644 api/main.py create mode 100644 api/routes/auth_routes.py create mode 100644 api/routes/links_routes.py create mode 100644 api/routes/user_routes.py create mode 100644 api/schemas/auth_schemas.py create mode 100644 api/schemas/links_schemas.py create mode 100644 api/schemas/user_schemas.py create mode 100644 api/static/js/api.js create mode 100644 api/templates/dashboard.html create mode 100644 api/templates/login.html create mode 100644 api/templates/signup.html create mode 100644 api/util/authentication.py create mode 100644 api/util/check_password_reqs.py create mode 100644 api/util/db_dependency.py create mode 100644 api/util/log.py (limited to 'api') diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..c8c1296 --- /dev/null +++ b/api/main.py @@ -0,0 +1,93 @@ +from fastapi import FastAPI, Depends, Request, Path +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse, RedirectResponse +from api.routes.auth_routes import router as auth_router +from api.routes.links_routes import router as links_router +from api.routes.user_routes import router as user_router +from typing import Annotated +from fastapi.exceptions import HTTPException +from starlette.status import HTTP_404_NOT_FOUND + +from api.util.db_dependency import get_db +from api.util.log import log +from models import Link + + +app = FastAPI( + title="LinkLogger API", + version="2.0", + summary="Public API for a combined link shortener and IP logger", + license_info={ + "name": "The Unlicense", + "identifier": "Unlicense", + "url": "https://unlicense.org", + }, +) + +origins = [ + "http://localhost:3000", + "http://127.0.0.1:3000", + "localhost:3000", + "127.0.0.1:3000", + # f"{CUSTOM_DOMAIN}" +] + +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Import routes +app.include_router(auth_router, prefix="/api") +app.include_router(links_router, prefix="/api") +app.include_router(user_router, prefix="/api") + + +@app.get("/c/{link}") +async def log_redirect( + link: Annotated[str, Path(title="Redirect link")], + request: Request, + db=Depends(get_db), +): + link = link.upper() + # Links must be 5 characters long + if len(link) != 5: + return RedirectResponse(url="/login") + + # Make sure the link exists in the database + link_record: Link = db.query(Link).filter(Link.link == link).first() + if not link_record: + db.close() + return RedirectResponse(url="/login") + else: + # Get the IP and log the request + if request.headers.get("X-Real-IP"): + ip = request.headers.get("X-Real-IP").split(",")[0] + else: + ip = request.client.host + user_agent = request.headers.get("User-Agent") + log(link, ip, user_agent) + db.close() + return RedirectResponse(url=link_record.redirect_link) + + +# Redirect /api -> /api/docs +@app.get("/api") +async def redirect_to_docs(): + return RedirectResponse(url="/docs") + + +# Custom handler for 404 errors +@app.exception_handler(HTTP_404_NOT_FOUND) +async def custom_404_handler(request: Request, exc: HTTPException): + # If the request is from /api, return a JSON response + if request.url.path.startswith("/api"): + return JSONResponse( + status_code=404, + content={"message": "Resource not found"}, + ) + # Otherwise, redirect to the login page + return RedirectResponse(url="/login") diff --git a/api/routes/auth_routes.py b/api/routes/auth_routes.py new file mode 100644 index 0000000..c51557f --- /dev/null +++ b/api/routes/auth_routes.py @@ -0,0 +1,46 @@ +from fastapi import Depends, APIRouter, status, HTTPException +from fastapi.security import OAuth2PasswordRequestForm +from fastapi.responses import Response, JSONResponse +from datetime import timedelta +from typing import Annotated + +from api.util.authentication import ( + create_access_token, + authenticate_user, +) +from api.util.db_dependency import get_db + + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/token", summary="Authenticate and get an access token") +async def login_for_access_token( + form_data: Annotated[OAuth2PasswordRequestForm, Depends()], + response: Response, + db=Depends(get_db), +): + """ + Return an access token for the user, if the given authentication details are correct + """ + user = authenticate_user(db, form_data.username, form_data.password) + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + access_token_expires = timedelta(minutes=1) + access_token = create_access_token( + data={"sub": user.id, "username": user.username}, + expires_delta=access_token_expires, + ) + response = JSONResponse(content={"success": True}) + response.set_cookie( + key="access_token", + value=access_token, + httponly=True, # Prevents client-side access + # secure=True, # Cookies are only sent over HTTPS + ) + return response diff --git a/api/routes/links_routes.py b/api/routes/links_routes.py new file mode 100644 index 0000000..5ed565b --- /dev/null +++ b/api/routes/links_routes.py @@ -0,0 +1,172 @@ +from fastapi import APIRouter, status, Path, Depends +from fastapi.exception_handlers import HTTPException +from typing import Annotated +import string +import random +import datetime +import validators + +from api.util.db_dependency import get_db +from models import Link, Log +from api.schemas.links_schemas import URLSchema +from api.schemas.auth_schemas import User +from api.util.authentication import get_current_user + + +router = APIRouter(prefix="/links", tags=["links"]) + + +@router.get("", summary="Get all of the links associated with your account") +async def get_links( + current_user: Annotated[User, Depends(get_current_user)], + db=Depends(get_db), +): + # Get and sort links by expiration date descending + links = ( + db.query(Link) + .filter(Link.owner == current_user.id) + .order_by(Link.expire_date.desc()) + .all() + ) + if not links: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="No links found" + ) + return links + + +@router.post("", summary="Create a new link") +async def create_link( + url: URLSchema, + current_user: Annotated[User, Depends(get_current_user)], + db=Depends(get_db), +): + # Check if the URL is valid + if not validators.url(url.url): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Invalid URL", + ) + # Create the new link and add it to the database + while True: + try: + link_path = "".join( + random.choices(string.ascii_uppercase + "1234567890", k=5) + ).upper() + new_link = Link( + link=link_path, + owner=current_user.id, + redirect_link=url.url, + expire_date=datetime.datetime.utcnow() + + datetime.timedelta(days=30), + ) + db.add(new_link) + db.commit() + break + except: + continue + + return {"link": link_path, "expire_date": new_link.expire_date} + + +@router.delete("/{link}", summary="Delete a link and all associated logs") +async def delete_link( + link: Annotated[str, Path(title="Link to delete")], + current_user: Annotated[User, Depends(get_current_user)], + db=Depends(get_db), +): + """ + Delete a link and all of the logs associated with it + """ + link = link.upper() + # Get the link and check the owner + link = db.query(Link).filter(Link.link == link).first() + if not link: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Link not found" + ) + if link.owner != current_user.id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Link not associated with your account", + ) + + # Get and delete all logsk + logs = db.query(Log).filter(Log.link == link.link).all() + for log in logs: + db.delete(log) + # Delete the link + db.delete(link) + db.commit() + + return status.HTTP_204_NO_CONTENT + + +@router.get("/{link}/logs", summary="Get all logs associated with a link") +async def get_link_logs( + link: Annotated[str, Path(title="Link to get logs for")], + current_user: Annotated[User, Depends(get_current_user)], + db=Depends(get_db), +): + """ + Get all of the IP logs associated with a link + """ + link = link.upper() + # Get the link and check the owner + link = db.query(Link).filter(Link.link == link).first() + if not link: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Link not found" + ) + if link.owner != current_user.id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Link not associated with your account", + ) + + # Get and return all of the logs - ordered by timestamp + logs = ( + db.query(Log) + .filter(Log.link == link.link) + .order_by(Log.timestamp.desc()) + .all() + ) + return logs + + +@router.delete( + "/{link}/logs/{log_id}", + summary="Delete a specific log associated with a link", +) +async def delete_single_log( + link: Annotated[str, Path(title="Link associated with the log to delete")], + log_id: Annotated[int, Path(title="Log ID to delete")], + current_user: Annotated[User, Depends(get_current_user)], + db=Depends(get_db), +): + """ + Delete the specified log associated with a link + """ + link = link.upper() + # Get the link and check the owner + link = db.query(Link).filter(Link.link == link).first() + if not link: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Link not found" + ) + if link.owner != current_user.id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Link not associated with your account", + ) + + # Get the log and delete it + log = db.query(Log).filter(Log.id == log_id).first() + if not log: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Log not found" + ) + db.delete(log) + db.commit() + + return status.HTTP_204_NO_CONTENT diff --git a/api/routes/user_routes.py b/api/routes/user_routes.py new file mode 100644 index 0000000..cf9be52 --- /dev/null +++ b/api/routes/user_routes.py @@ -0,0 +1,129 @@ +from fastapi import APIRouter, status, Path, Depends +from fastapi.exception_handlers import HTTPException +from fastapi.security import OAuth2PasswordRequestForm +from typing import Annotated +import string +import bcrypt +import random + +from api.util.db_dependency import get_db +from api.util.check_password_reqs import check_password_reqs +from api.schemas.auth_schemas import User +from api.schemas.user_schemas import * +from models import User as UserModel +from api.util.authentication import ( + verify_password, + get_current_user, +) + + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.delete("/{user_id}", summary="Delete your account") +async def delete_user( + user_id: Annotated[int, Path(title="Link to delete")], + current_user: Annotated[User, Depends(get_current_user)], + db=Depends(get_db), +): + """ + Delete the user account associated with the current user + """ + # No editing others accounts + if user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You can only delete your own account", + ) + + # Get the user and delete them + user = db.query(UserModel).filter(UserModel.id == current_user.id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + db.delete(user) + db.commit() + return status.HTTP_204_NO_CONTENT + + +@router.post("/{user_id}/password", summary="Update your account password") +async def update_pass( + user_id: Annotated[int, Path(title="Link to update")], + update_data: UpdatePasswordSchema, + current_user: Annotated[User, Depends(get_current_user)], + db=Depends(get_db), +): + """ + Update the pass of the current user account + """ + if user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You can only update your own account", + ) + + # Make sure that they entered the correct current password + if not verify_password( + update_data.current_password, current_user.hashed_password + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect current password", + ) + + # Make sure the password meets all of the requirements + check_password_reqs(update_data.new_password) + + # Get the user and update the password + user = db.query(UserModel).filter(UserModel.id == current_user.id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + user.hashed_password = bcrypt.hashpw( + update_data.new_password.encode("utf-8"), bcrypt.gensalt() + ).decode("utf-8") + db.commit() + return status.HTTP_204_NO_CONTENT + + +@router.post("/register", summary="Register a new user") +async def get_links( + form_data: Annotated[OAuth2PasswordRequestForm, Depends()], + db=Depends(get_db), +): + """ + Given the login data (username, password) process the registration of a new + user account and return either the user or an error message + """ + username = form_data.username + password = form_data.password + + # Make sure the password meets all of the requirements + check_password_reqs(password) + + # Make sure the username isn't taken + user = db.query(UserModel).filter(UserModel.username == username).first() + if user: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Username not available", + ) + # Otherwise, hash the password, create the api key, and add the new user + hashed_password = bcrypt.hashpw( + password.encode("utf-8"), bcrypt.gensalt() + ).decode("utf-8") + api_key = "".join( + random.choices(string.ascii_letters + string.digits, k=20) + ) + new_user = UserModel( + username=username, hashed_password=hashed_password, api_key=api_key + ) + db.add(new_user) + db.commit() + + return status.HTTP_201_CREATED diff --git a/api/schemas/auth_schemas.py b/api/schemas/auth_schemas.py new file mode 100644 index 0000000..48745ee --- /dev/null +++ b/api/schemas/auth_schemas.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel + + +class Token(BaseModel): + access_token: str + token_type: str + + +class User(BaseModel): + username: str + id: int + + +class UserInDB(User): + hashed_password: str diff --git a/api/schemas/links_schemas.py b/api/schemas/links_schemas.py new file mode 100644 index 0000000..e2812fb --- /dev/null +++ b/api/schemas/links_schemas.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class URLSchema(BaseModel): + url: str diff --git a/api/schemas/user_schemas.py b/api/schemas/user_schemas.py new file mode 100644 index 0000000..949b9a5 --- /dev/null +++ b/api/schemas/user_schemas.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + + +class LoginDataSchema(BaseModel): + username: str + password: str + + +class UpdatePasswordSchema(BaseModel): + current_password: str + new_password: str diff --git a/api/static/js/api.js b/api/static/js/api.js new file mode 100644 index 0000000..243edf7 --- /dev/null +++ b/api/static/js/api.js @@ -0,0 +1,26 @@ +// Description: This file contains functions to access the API with JWT authentication. + +/** + * Accept an API endpoint, method, and body to send to the API. + * - If successful, return the response + * - If not, return false + * @param {*} endpoint API endpoint + * @param {*} method String (GET, POST, PUT, DELETE) + * @param {*} body Data to send to the API + * @returns response.json or false + */ +async function accessAPI(endpoint, method, body) { + let response = await fetch(`/api${endpoint}`, { + method: method, + body: body, + }); + + if (response.ok) { + let data = await response.json(); + data = await data; + return data; + + } + + return false; +} \ No newline at end of file diff --git a/api/templates/dashboard.html b/api/templates/dashboard.html new file mode 100644 index 0000000..25372bd --- /dev/null +++ b/api/templates/dashboard.html @@ -0,0 +1,160 @@ + + + + + + LinkLogger | Dashboard + + + + + + +
+ + + + + + + + +
LinkVisitsRedirectExpire Date
+
+ + + \ No newline at end of file diff --git a/api/templates/login.html b/api/templates/login.html new file mode 100644 index 0000000..8e59481 --- /dev/null +++ b/api/templates/login.html @@ -0,0 +1,105 @@ + + + + + + LinkLogger | Login + + +
+

Incorrect username/password. Please try again.

+
+ + + +
+
+

Don't have an account? Create one now

+
+ + + + + + \ No newline at end of file diff --git a/api/templates/signup.html b/api/templates/signup.html new file mode 100644 index 0000000..32962b7 --- /dev/null +++ b/api/templates/signup.html @@ -0,0 +1,112 @@ + + + + + + LinkLogger | Signup + + +
+

+
+ + + +
+
+

Already have an account? Log in now

+

Passwords must be at least 8 characters long and contain a number, special character, and uppercase character.

+
+ + + + + + \ No newline at end of file diff --git a/api/util/authentication.py b/api/util/authentication.py new file mode 100644 index 0000000..43b147a --- /dev/null +++ b/api/util/authentication.py @@ -0,0 +1,97 @@ +import random +import bcrypt +from fastapi import Depends, HTTPException, status, Request, Cookie +from fastapi.security import OAuth2PasswordBearer +from fastapi.responses import Response +from jwt.exceptions import InvalidTokenError, ExpiredSignatureError +from datetime import datetime, timedelta +import jwt + +from api.util.db_dependency import get_db +from sqlalchemy.orm import Session +from api.schemas.auth_schemas import * +from models import User as UserModel + +secret_key = random.randbytes(32) +algorithm = "HS256" +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/token") + +""" +Helper functions for authentication +""" + + +def verify_password(plain_password, hashed_password): + return bcrypt.checkpw( + plain_password.encode("utf-8"), hashed_password.encode("utf-8") + ) + + +def get_user(db, username: str): + """ + Get the user object from the database + """ + user = db.query(UserModel).filter(UserModel.username == username).first() + if user: + return UserInDB(**user.__dict__) + + +def authenticate_user(db, username: str, password: str): + """ + Determine if the correct username and password were provided + If so, return the user object + """ + user = get_user(db, username) + if not user: + return False + if not verify_password(password, user.hashed_password): + print("WHY") + return False + return user + + +def create_access_token(data: dict, expires_delta: timedelta): + """ + Return an encoded JWT token with the given data + """ + to_encode = data.copy() + expire = datetime.utcnow() + expires_delta + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, secret_key, algorithm=algorithm) + return encoded_jwt + + +def raise_unauthorized(): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +async def get_current_user( + request: Request, + db=Depends(get_db), +): + """ + Return the current user object if the access token is valid + + All failed attempts will return a 401 + """ + token = request.cookies.get("access_token") + + try: + payload = jwt.decode(token, secret_key, algorithms=[algorithm]) + id: int = payload.get("sub") + username: str = payload.get("username") + if not id or not username: + return raise_unauthorized() + + except InvalidTokenError: + return raise_unauthorized() + + user = get_user(db, username) + if user is None: + return raise_unauthorized() + + return user diff --git a/api/util/check_password_reqs.py b/api/util/check_password_reqs.py new file mode 100644 index 0000000..dcb9bf8 --- /dev/null +++ b/api/util/check_password_reqs.py @@ -0,0 +1,26 @@ +from fastapi import HTTPException, status + + +def check_password_reqs(password: str): + """ + Make sure the entered password meets the security requirements: + 1. At least 8 characters + 2. At least one digit + 3. At least one uppercase letter + """ + if len(password) < 8: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Password must be at least 8 characters", + ) + if not any(char.isdigit() for char in password): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Password must contain at least one digit", + ) + if not any(char.isupper() for char in password): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Password must contain at least one uppercase letter", + ) + return diff --git a/api/util/db_dependency.py b/api/util/db_dependency.py new file mode 100644 index 0000000..a6734ea --- /dev/null +++ b/api/util/db_dependency.py @@ -0,0 +1,9 @@ +from database import SessionLocal + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/api/util/log.py b/api/util/log.py new file mode 100644 index 0000000..58a56f9 --- /dev/null +++ b/api/util/log.py @@ -0,0 +1,84 @@ +import requests +import datetime +from ua_parser import user_agent_parser + +from database import SessionLocal +import config +from models import Link, Log + +""" +Create a new log whenever a link is visited +""" + + +def ip_to_location(ip): + if not config.IP_TO_LOCATION: + return "-, -", "-" + + url = f"https://api.ip2location.io/?key={config.API_KEY}&ip={ip}" + response = requests.get(url) + data = response.json() + + if response.status_code != 200: + config.LOG.error( + "Error with IP2Location API. Perhaps the API is down." + ) + return "-, -", "-" + + if "error" in data: + config.LOG.error( + "Error with IP2Location API. Likely wrong API key or insufficient" + " funds." + ) + return "-, -", "-" + + location = "" + # Sometimes a certain name may not be present, so always check + if "city_name" in data: + location += data["city_name"] + + if "region_name" in data: + location += f', {data["region_name"]}' + + if "country_name" in data: + location += f', {data["country_name"]}' + + isp = data["as"] + return location, isp + + +def log(link, ip, user_agent): + db = SessionLocal() + + # Get the redirect link and owner of the link + redirect_link, owner = ( + db.query(Link.redirect_link, Link.owner) + .filter(Link.link == link) + .first() + ) + + # Get the location and ISP of the user + location, isp = ip_to_location(ip) + + ua_string = user_agent_parser.Parse(user_agent) + browser = ua_string["user_agent"]["family"] + os = f'{ua_string["os"]["family"]} {ua_string["os"]["major"]}' + + # Create the log and commit it to the database + new_log = Log( + owner=owner, + link=link, + timestamp=datetime.datetime.utcnow(), + ip=ip, + location=location, + browser=browser, + os=os, + user_agent=user_agent, + isp=isp, + ) + db.add(new_log) + db.commit() + db.close() + + # Return the redirect link in order to properly redirect the user + return redirect_link -- cgit v1.2.3-70-g09d2