diff options
Diffstat (limited to 'api')
-rw-r--r-- | api/main.py | 93 | ||||
-rw-r--r-- | api/routes/auth_routes.py | 46 | ||||
-rw-r--r-- | api/routes/links_routes.py | 172 | ||||
-rw-r--r-- | api/routes/user_routes.py | 129 | ||||
-rw-r--r-- | api/schemas/auth_schemas.py | 15 | ||||
-rw-r--r-- | api/schemas/links_schemas.py | 5 | ||||
-rw-r--r-- | api/schemas/user_schemas.py | 11 | ||||
-rw-r--r-- | api/static/js/api.js | 26 | ||||
-rw-r--r-- | api/templates/dashboard.html | 160 | ||||
-rw-r--r-- | api/templates/login.html | 105 | ||||
-rw-r--r-- | api/templates/signup.html | 112 | ||||
-rw-r--r-- | api/util/authentication.py | 97 | ||||
-rw-r--r-- | api/util/check_password_reqs.py | 26 | ||||
-rw-r--r-- | api/util/db_dependency.py | 9 | ||||
-rw-r--r-- | api/util/log.py | 84 |
15 files changed, 1090 insertions, 0 deletions
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 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>LinkLogger | Dashboard</title> + + <link rel="stylesheet" href="/static/css/dashboard.css"> + <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css" rel="stylesheet"> + <script src="/static/js/jwt.js"></script> +</head> +<body> + <div> + <!-- Create a table with 4 columns with a total of 1000px width --> + <table> + <tr style="border: 2px solid #ccc;"> + <th>Link</th> + <th>Visits</th> + <th>Redirect</th> + <th>Expire Date</th> + </tr> + </table> + </div> +</body> +</html> +<script> + function createRow(index, link, logs) { + // Create the sub-table with the logs + let subTable = ` + <table> + <tr> + <th>ID</th> + <th>Timestamp</th> + <th>IP</th> + <th>Location</th> + <th colspan="2">ISP</th> + </tr> + `; + // Loop through the logs and create a row for each one + logs.forEach((log, index) => { + let logTimestamp = new Date(log.timestamp).toLocaleString('en-US', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }).replace(',', ''); + + let row = ` + <tr id="${log.id}-log"> + <td>${logs.length - index}</td> + <td>${logTimestamp}</td> + <td>${log.ip}</td> + <td>${log.location}</td> + <td>${log.isp}</td> + <td><i class="fa-solid fa-trash" id="${log.id}/${link.link}""></i></td> + </tr> + `; + subTable += row; + }); + subTable += '</table>'; + + // Convert the link expire timestamp to a readable date + let date = new Date(link.expire_date); + let expireDate = date.toLocaleTimeString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }); + + // Create the HTML for the row with sub-table + let row = ` + <tr class="link-table-row"> + <td> + <button class="link-button" id="${index}">${link.link}</button> + </td> + <td>${logs.length}</td> + <td>${link.redirect_link}</td> + <td>${expireDate}</td> + </tr> + <tr class="log-table-row" id="${index}-logTR" style="display: none;"> + <td colspan="6"> + ${subTable} + </td> + </tr> + `; + return row; + } + + + + async function getData() { + const links = await accessAPI(`/links`, 'GET') + if (!links) { + throw new Error('Failed to fetch links'); + } + // Links is an Array of objects with the link data + // Loop through the links and create a row for each one + // Do not use async because then the order or data in the + // table will change from time to time + for (let i = 0; i < links.length; i++) { + let link = links[i]; + let logs = await accessAPI(`/links/${link.link}/logs`, 'GET') + if (!logs) { + throw new Error('Failed to fetch logs'); + } + let row = createRow(i, link, logs); + document.querySelector('table').innerHTML += row; + } + } + + // hideLogRows to all log-table-rows + function hideLogRows() { + let logTRs = document.querySelectorAll('.log-table-row'); + logTRs.forEach(row => { + row.style.display = 'none'; + }); + } + + // Add event listener to all link buttons + document.addEventListener('click', (event) => { + if (event.target.classList.contains('link-button')) { + let id = event.target.id; + let logTR = document.getElementById(`${id}-logTR`); + if (logTR.style.display === 'none') { + // Hide any open log tables + hideLogRows(); + logTR.style.display = 'table-row'; + } else { + logTR.style.display = 'none'; + } + } + }); + + // Add an event listen to all trash bins + document.addEventListener('click', (event) => { + if (event.target.classList.contains('fa-trash')) { + // Confirm the user wants to delete the log + let confirmDelete = confirm('Are you sure you want to delete this log?'); + if (confirmDelete) { + let id = event.target.id; + let link = id.split('/')[1]; + let logId = id.split('/')[0]; + fetch(`/api/links/${link}/logs/${logId}`, { + method: 'DELETE' + }); + let logRow = document.getElementById(`${logId}-log`) + logRow.remove(); + } + } + }); + + getData(); +</script>
\ 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 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>LinkLogger | Login</title> +</head> +<body> + <div> + <p id="error">Incorrect username/password. Please try again.</p> + <form action="/login" method="POST"> + <input type="text" name="username" placeholder="Username" required> + <input type="password" name="password" placeholder="Password" required> + <button type="submit">Login</button> + </form> + <hr> + <p>Don't have an account? <a href="/signup">Create one now</a></p> + </div> +</body> +</html> + +<style> + body { + margin: 0; + padding: 0; + font-family: Arial, sans-serif; + background-color: #2c3338; + } + + div { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; + } + + input { + display: block; + margin: 10px auto; + width: 300px; + border-radius: 5px; + padding: 15px; + color: #ccc; + background-color: #3b4148; + border: none; + font-size: 17px; + } + + button { + display: block; + margin: 10px auto; + width: 100%; + border-radius: 5px; + padding: 15px; + color: #ccc; + background-color: #415eac; + border: none; + font-size: 17px; + cursor: pointer; + } + + hr { + color: #606468; + } + + p { + color: #606468; + } + + #error { + font-size: 15px; + color: #f55757; + display: none; + } + + a { + color: #ccc; + text-decoration: none; + } + + a:hover { + text-decoration: underline; + } +</style> + +<script> + document.querySelector('form').addEventListener('submit', async function(event) { + // Prevent default form submission + event.preventDefault(); + + const formData = new FormData(this); + // Send POST request to /token containing form data + const response = await fetch('/api/auth/token', { + method: 'POST', + body: formData + }); + + if (response.status != 200) { + document.getElementById('error').style.display = 'block'; + } else { + window.location.href = '/dashboard'; + } + }); +</script>
\ 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 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>LinkLogger | Signup</title> +</head> +<body> + <div> + <p id="error"></p> + <form action="/signup" method="POST"> + <input type="text" name="username" placeholder="Username" required> + <input type="password" name="password" placeholder="Password" required> + <button type="submit">Signup</button> + </form> + <hr> + <p>Already have an account? <a href="/login">Log in now</a></p> + <p>Passwords must be at least 8 characters long and contain a number, special character, and uppercase character.</p> + </div> +</body> +</html> + +<style> + body { + margin: 0; + padding: 0; + font-family: Arial, sans-serif; + background-color: #2c3338; + } + + div { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; + max-width: 330px; + } + + input { + display: block; + margin: 10px auto; + width: 300px; + border-radius: 5px; + padding: 15px; + color: #ccc; + background-color: #3b4148; + border: none; + font-size: 17px; + } + + button { + display: block; + margin: 10px auto; + width: 100%; + border-radius: 5px; + padding: 15px; + color: #ccc; + background-color: #415eac; + border: none; + font-size: 17px; + cursor: pointer; + } + + hr { + color: #606468; + } + + p { + color: #606468; + } + + #error { + font-size: 15px; + color: #f55757; + display: none; + } + + a { + color: #ccc; + text-decoration: none; + } + + a:hover { + text-decoration: underline; + } +</style> + +<script> + document.querySelector('form').addEventListener('submit', async function(event) { + // Prevent default form submission + event.preventDefault(); + + // Get form data + const formData = new FormData(this); + + // Send POST request + const response = await fetch('/api/users/register', { + method: 'POST', + body: formData + }); + + if (response.status != 200) { + const data = await response.json() + + document.getElementById('error').style.display = 'block'; + document.getElementById('error').innerText = data.detail; + } else { + window.location.href = '/dashboard'; + } + }); +</script>
\ 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 |