diff --git a/.github/workflows/build_backend.yaml b/.github/workflows/build_backend.yaml index 9d51fc6..f9cfe74 100644 --- a/.github/workflows/build_backend.yaml +++ b/.github/workflows/build_backend.yaml @@ -10,65 +10,52 @@ on: - .github/** jobs: Lint: - runs-on: - ubuntu-latest + runs-on: ubuntu-latest defaults: # todo: DRY this to work with multiple lambdas run: working-directory: ./lambdas/backend_api steps: - - - name: checkout - uses: actions/checkout@v4 - - - name: Set up Python - id: setup-python - uses: actions/setup-python@v5 - with: - python-version: '3.10' - - - name: Install Poetry - uses: snok/install-poetry@v1 - - - name: install dependencies - run: poetry install --no-interaction - - - name: Python lint - run: poetry run ruff check - - - name: Python format check - run : poetry run ruff format + - name: checkout + uses: actions/checkout@v4 + - name: Set up Python + id: setup-python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Install Poetry + uses: snok/install-poetry@v1 + - name: install dependencies + run: poetry install --no-interaction + - name: Python lint + run: poetry run ruff check + - name: Python format check + run: poetry run ruff format Test: needs: Lint strategy: matrix: - lambda_name: ["backend_api", "dummy_lambda"] + lambda_name: ["backend_api"] runs-on: ubuntu-latest defaults: run: working-directory: lambdas/${{matrix.lambda_name}} steps: - - - name: checkout + - name: checkout uses: actions/checkout@v4 - - - name: Set up Python + - name: Set up Python id: setup-python uses: actions/setup-python@v5 with: - python-version: '3.10' - - - name: Install Poetry + python-version: "3.10" + - name: Install Poetry uses: snok/install-poetry@v1 - - - name: install dependencies + - name: install dependencies run: poetry install --no-interaction - - - name: Type Check + - name: Type Check run: poetry run mypy . - - - name: Unit Test + - name: Unit Test run: poetry run pytest . DeployStaging: @@ -76,21 +63,18 @@ jobs: - Test strategy: matrix: - lambda_name: ["backend_api", "dummy_lambda"] + lambda_name: ["backend_api"] runs-on: ubuntu-latest steps: - - - name: checkout + - name: checkout uses: actions/checkout@v4 - - - name: Configure AWS Credentials + - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@v4 with: aws-region: ${{vars.AWS_REGION}} aws-access-key-id: ${{secrets.AWS_ACCESS_KEY_ID}} aws-secret-access-key: ${{secrets.AWS_SECRET_ACCESS_KEY}} - - - name: Build and push Docker image + - name: Build and push Docker image run: | make build_and_push_docker_image \ region=${{vars.AWS_REGION}} \ @@ -98,8 +82,7 @@ jobs: prefix=${{env.APP_NAME}}-staging- \ lambda_name=${{matrix.lambda_name}} \ build_no=${{github.run_id}} - - - name: Update Lambda function + - name: Update Lambda function run: | make update_lambda_with_latest_image \ prefix=${{env.APP_NAME}}-staging- \ @@ -115,21 +98,18 @@ jobs: - DeployStaging strategy: matrix: - lambda_name: ["backend_api", "dummy_lambda"] + lambda_name: ["backend_api"] runs-on: ubuntu-latest steps: - - - name: checkout + - name: checkout uses: actions/checkout@v4 - - - name: Configure AWS Credentials + - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@v4 with: aws-region: ${{vars.AWS_REGION}} aws-access-key-id: ${{secrets.AWS_ACCESS_KEY_ID}} aws-secret-access-key: ${{secrets.AWS_SECRET_ACCESS_KEY}} - - - name: Build and push Docker image + - name: Build and push Docker image run: | make build_and_push_docker_image \ region=${{vars.AWS_REGION}} \ @@ -137,8 +117,7 @@ jobs: prefix=${{env.APP_NAME}}-production- \ lambda_name=${{matrix.lambda_name}} \ build_no=${{github.run_id}} - - - name: Update Lambda function + - name: Update Lambda function run: | make update_lambda_with_latest_image \ prefix=${{env.APP_NAME}}-production- \ diff --git a/Makefile b/Makefile index 9b2f5ac..c866a54 100644 --- a/Makefile +++ b/Makefile @@ -55,8 +55,8 @@ initialise_all_ecrs: outputs_to_env: cd $(TERRAFORM_DIR) && \ - terraform output >> ../lambdas/resources.env && \ - terraform output >> ../frontend/.env + terraform output > ../lambdas/backend_api/resources.env && \ + terraform output > ../frontend/.env # ============================================================================= # Docker diff --git a/frontend/.env b/frontend/.env index f940844..e1bb3f6 100644 --- a/frontend/.env +++ b/frontend/.env @@ -1 +1,7 @@ -BACKEND_API_URL = 'http://redacted.website' +backend_api_ecr_url = "979058445641.dkr.ecr.us-west-2.amazonaws.com/tumpr-staging-backend_api" +BACKEND_API_URL = "https://3eo5qtv2dln5khtesgthtmntiy0asccs.lambda-url.us-west-2.on.aws/" +frontend_ui_url = "string" +object_lock_table = "string" +object_storage_s3 = "string" +user_notifications_sns_arn = "arn:aws:sns:us-west-2:979058445641:user-notifications-topic" +users_table = "string" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1060b72..b2a5f36 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,8 +23,8 @@ "@tanstack/router-devtools": "^1.65.0", "@tanstack/router-plugin": "^1.65.0", "@types/node": "^22.7.5", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", "@typescript-eslint/eslint-plugin": "^8.7.0", "@typescript-eslint/parser": "^8.7.0", "@vitejs/plugin-react": "^4.3.1", @@ -819,6 +819,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -1848,18 +1849,18 @@ "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==" }, "node_modules/@types/react": { - "version": "18.3.8", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.8.tgz", - "integrity": "sha512-syBUrW3/XpnW4WJ41Pft+I+aPoDVbrBVQGEnbD7NijDGlVC+8gV/XKRY+7vMDlfPpbwYt0l1vd/Sj8bJGMbs9Q==", + "version": "18.3.12", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz", + "integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==", "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "node_modules/@types/react-dom": { - "version": "18.3.0", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", - "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==", "dev": true, "dependencies": { "@types/react": "*" @@ -3475,6 +3476,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -5299,6 +5301,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "aix" @@ -5314,6 +5317,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "android" @@ -5329,6 +5333,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "android" @@ -5344,6 +5349,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "android" @@ -5359,6 +5365,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -5374,6 +5381,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -5389,6 +5397,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -5404,6 +5413,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -5419,6 +5429,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" @@ -5434,6 +5445,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -5449,6 +5461,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "linux" @@ -5464,6 +5477,7 @@ "cpu": [ "loong64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -5479,6 +5493,7 @@ "cpu": [ "mips64el" ], + "dev": true, "optional": true, "os": [ "linux" @@ -5494,6 +5509,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -5509,6 +5525,7 @@ "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -5524,6 +5541,7 @@ "cpu": [ "s390x" ], + "dev": true, "optional": true, "os": [ "linux" @@ -5539,6 +5557,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -5554,6 +5573,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "netbsd" @@ -5569,6 +5589,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -5584,6 +5605,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "sunos" @@ -5599,6 +5621,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -5614,6 +5637,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "win32" @@ -5629,6 +5653,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" diff --git a/frontend/package.json b/frontend/package.json index ed203f0..c009921 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,8 +28,8 @@ "@tanstack/router-devtools": "^1.65.0", "@tanstack/router-plugin": "^1.65.0", "@types/node": "^22.7.5", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", "@typescript-eslint/eslint-plugin": "^8.7.0", "@typescript-eslint/parser": "^8.7.0", "@vitejs/plugin-react": "^4.3.1", diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 9318d08..4c148d8 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,4 +1,12 @@ import axios, { AxiosRequestConfig, AxiosInstance } from "axios"; +import { CurrentUser } from "../routes/__root"; + +type LoginResponse = { + auth_token: string; + session_token: { email: string; message: string }; +}; + +export const AUTH_TOKEN_KEY = "auth_token"; class ApiClient { url: string; @@ -8,9 +16,14 @@ class ApiClient { this.url = process.env.BACKEND_API_URL || ""; this.client = axios.create({ baseURL: this.url, + validateStatus: () => true, }); - // this.client.defaults.headers.common["Authorization"] = - // `Bearer ${localStorage.getItem("access_token")}`; + this.setAuthToken(); + } + + setAuthToken() { + this.client.defaults.headers.common["auth_token"] = + window.localStorage.getItem(AUTH_TOKEN_KEY); } async healthCheck() { @@ -29,18 +42,26 @@ class ApiClient { const _ = await this.client.post("/auth/otp", { email: email }); } - async login(email: string, passCode: string): Promise { - const result = await this.client.post("/auth/login", { - email: email, - otp: passCode, - }); - console.log(result.data); - return result.data; + async login(email: string, passCode: string): Promise { + try { + const result = await this.client.post("/auth/login", { + email: email, + otp: passCode, + }); + return result.data; + } catch { + return { + auth_token: "", + session_token: { email: email, message: "Failed to login!" }, + }; + } } - async check() { - const result = await this.client.get("/auth/check-login"); - console.log(result); + async checkLogin(): Promise { + const result = await this.client.get("/auth/check-login", { + headers: { auth_token: window.localStorage.getItem(AUTH_TOKEN_KEY) }, + }); + return result.data; } } diff --git a/frontend/src/features/login.tsx b/frontend/src/features/login.tsx index 8dc6757..a4ecee0 100644 --- a/frontend/src/features/login.tsx +++ b/frontend/src/features/login.tsx @@ -1,8 +1,9 @@ import { TextField } from "@mui/material"; import { useState } from "react"; -import { apiClient } from "../api/client"; +import { apiClient, AUTH_TOKEN_KEY } from "../api/client"; import { Typography } from "@mui/material"; import { DebouncedButton } from "../components/DebouncedButton"; +import { Link } from "@tanstack/react-router"; enum LoginState { NeedsEmail, @@ -48,7 +49,7 @@ function Login({ break; } case LoginState.NeedsPasscode: { - component = ; + component = ; break; } case LoginState.NeedsAuth: { @@ -84,7 +85,7 @@ function EmailInput({ return ( <> - Enter some stuff in here. + Enter your email address to request a passcode setInputText(e.target.value)} /> handleSubmit()}> - Click me! + Request Password ); } -function RequestPasscode({ setState }: { setState: StateUpdater }) { +function RequestPasscode({ + userEmail, + setState, +}: { + userEmail: string; + setState: StateUpdater; +}) { const requestOtp = async () => { - await apiClient.requestOtp("jozeftkocz@gmail.com"); + await apiClient.requestOtp(userEmail); setState(LoginState.NeedsAuth); }; return ( - - Get Login Code - + <> + + If you havent already done so, you will need to accept the email + subscription request from AWS SNS + + + Get Login Code + + ); } @@ -120,9 +133,9 @@ function EnterPasscode({ }) { const [passCode, setPassCode] = useState(""); const sendLogin = async () => { - const loggedIn: boolean = await apiClient.login(email, passCode); - console.log(loggedIn); - if (loggedIn) { + const loginResponse = await apiClient.login(email, passCode); + if (loginResponse.auth_token) { + window.localStorage.setItem(AUTH_TOKEN_KEY, loginResponse.auth_token); setState(LoginState.Success); } else { setState(LoginState.Failed); @@ -144,11 +157,19 @@ function EnterPasscode({ } function Success(_: StateUpdater) { - console.log("success"); - return

Success

; + const pageContent = ( + <> +

You are now logged in

+ Return Home + + ); + return pageContent; } function Failed(_: StateUpdater) { - console.log("failed"); - return

Failure

; + return ( + <> +

Login Failed!

+ + ); } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 01c1d34..2c074fc 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -8,7 +8,9 @@ import { routeTree } from "./routeTree.gen"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; // Create a new router instance -const router = createRouter({ routeTree }); +const router = createRouter({ + routeTree, +}); // Register the router instance for type safety declare module "@tanstack/react-router" { diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index 8e071b7..9120d4b 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -2,15 +2,27 @@ import { createRootRoute, Link, Outlet } from "@tanstack/react-router"; import { TanStackRouterDevtools } from "@tanstack/router-devtools"; // import ".././App.css"; import { Box } from "@mui/material"; +import { createContext } from "react"; import { useQuery } from "@tanstack/react-query"; -import { apiClient } from "../api/client"; +import { apiClient, AUTH_TOKEN_KEY } from "../api/client"; + +export type CurrentUser = { + email: string; + isLoggedIn: boolean; +}; + +export const CurrentUserContext = createContext({ + email: "", + isLoggedIn: false, +}); export const Route = createRootRoute({ component: () => { - const { isPending } = useQuery({ - queryKey: ["health"], - queryFn: apiClient.healthCheck, + const { data: currentUser } = useQuery({ + queryKey: [window.localStorage.getItem(AUTH_TOKEN_KEY)], + queryFn: () => apiClient.checkLogin(), }); + return ( <> Login - {isPending && "Loading"} - + + + + + ); diff --git a/frontend/src/routes/index.lazy.tsx b/frontend/src/routes/index.lazy.tsx index 3b672b2..cc06daf 100644 --- a/frontend/src/routes/index.lazy.tsx +++ b/frontend/src/routes/index.lazy.tsx @@ -1,21 +1,25 @@ -import { Button } from "@mui/material"; +import { Button, Typography } from "@mui/material"; import { createLazyFileRoute } from "@tanstack/react-router"; import { apiClient } from "../api/client"; +import { useContext } from "react"; +import { CurrentUserContext } from "./__root"; export const Route = createLazyFileRoute("/")({ component: Index, }); function Index() { - console.log("cookies"); - console.log(document.cookie); const onClick = async () => { - apiClient.check(); + apiClient.checkLogin(); }; + + const user = useContext(CurrentUserContext); + console.log("///"); + console.log(user); return (
-

Tumpr 2.0

- + {user && Hello {user.email}} +
); } diff --git a/lambdas/backend_api/app.py b/lambdas/backend_api/app.py index d0ce9f9..283768c 100644 --- a/lambdas/backend_api/app.py +++ b/lambdas/backend_api/app.py @@ -17,7 +17,7 @@ tracer = Tracer() logger = Logger() -cors_config = CORSConfig(allow_origin="*", max_age=300) +cors_config = CORSConfig(allow_origin="*", allow_headers=["auth_token"], max_age=300) app = LambdaFunctionUrlResolver(cors=cors_config, enable_validation=True) app.include_router(endpoints.auth.router, prefix="/auth") @@ -33,7 +33,7 @@ def handle_error(ex: Exception) -> Response[Error]: # receives exception raised return Response( status_code=400, - content_type=content_types.TEXT_PLAIN, + content_type=content_types.APPLICATION_JSON, body=Error(reason="Something went wrong!"), ) @@ -45,16 +45,6 @@ def health_check() -> bool: return True -""" -Todo: - - JSON config file in S3 (for e.g. JWT secrets) - - Logged in user from JWT - - Use dynamodb for distributed lock on S3 objects - - SQLite files in S3 - - Database migrations on SQLite -""" - - @app.get("/script") @tracer.capture_method def script() -> bool: diff --git a/lambdas/backend_api/config.py b/lambdas/backend_api/config.py index d05a238..a89eba8 100644 --- a/lambdas/backend_api/config.py +++ b/lambdas/backend_api/config.py @@ -7,7 +7,7 @@ from email_client.client import EmailClient from object_store.object_store import ConfigRepo -RESET_DYNAMIC_CONFIG = True +RESET_DYNAMIC_CONFIG = False # Read in env vars diff --git a/lambdas/backend_api/endpoints/auth.py b/lambdas/backend_api/endpoints/auth.py index 43594f1..7e2ea4a 100644 --- a/lambdas/backend_api/endpoints/auth.py +++ b/lambdas/backend_api/endpoints/auth.py @@ -1,14 +1,23 @@ +import uuid from pydantic import BaseModel import random import string +from http import HTTPStatus from aws_lambda_powertools import Tracer, Logger + from aws_lambda_powertools.event_handler.api_gateway import Router -from config import users_table, email_client, dynamic_config -import jwt +from aws_lambda_powertools.event_handler import Response, content_types +from config import users_table, email_client import datetime as dt +from dynamodb.users import User +from services.auth import SessionToken +from services.auth import SessionInfo +from services.auth import AuthTokenService +from middleware.auth import get_current_user + tracer = Tracer() router = Router() logger = Logger() @@ -27,16 +36,6 @@ class OtpCredentials(BaseModel): otp: str -class SessionToken(BaseModel): - email: str - message: str - - -class SessionInfo(BaseModel): - email: str - message: str - - class AuthResponse(BaseModel): auth_token: str session_token: SessionInfo @@ -73,65 +72,78 @@ def request_otp(email: Email) -> bool: user = users_table.update(user) logger.info("Sending OTP email") - email_client.send_email(email=user.email, subject="TUMPR OTP", body=otp) + email_client.send_email( + email=user.email, subject="Your Tumpr Temporary Password", body=otp + ) return True @router.post("/login") @tracer.capture_method -def login(credentials: OtpCredentials) -> AuthResponse: - now = int(round(dt.datetime.now(dt.timezone.utc).timestamp())) +def login(credentials: OtpCredentials) -> Response[AuthResponse]: + now_datetime = dt.datetime.now(dt.timezone.utc) + now = int(round(now_datetime.timestamp())) user = users_table.get(email=credentials.email) - if not user: - logger.info(f"User {credentials.email} not found") - return AuthResponse( + invalid_login_response = Response( + status_code=HTTPStatus.FORBIDDEN, + content_type=content_types.APPLICATION_JSON, + body=AuthResponse( auth_token="", session_token=SessionInfo( email=credentials.email, message="You are not logged in!" ), - ) + ), + ) + + if not user: + logger.info(f"User {credentials.email} not found") + return invalid_login_response if user.otp != credentials.otp or now > user.otp_expires: logger.info(f"User {credentials.email} attempted login with invalid OTP") - return AuthResponse( - auth_token="", - session_token=SessionInfo( - email=user.email, message="You are not logged in!" - ), - ) + return invalid_login_response # Figure out how to set JWT auth cookie # Invalidate the OTP now it has been used user.otp = "" user.otp_expires = 0 + + user.auth_token = str(uuid.uuid4()) + user.auth_token_expires = int( + round((now_datetime + dt.timedelta(days=30)).timestamp()) + ) users_table.update(user) logger.info(f"User {credentials.email} authorised, setting token") token_payload = SessionToken( - email=user.email, message=f"Hello {user.email} -this is secret!" + email=user.email, + auth_token=user.auth_token, ) - session_token = SessionInfo( - email=user.email, message=f"Hello {user.email} -this isn't a secret!" + session_token = SessionInfo(email=user.email, token_expires=user.auth_token_expires) + + return Response( + status_code=HTTPStatus.ACCEPTED, + content_type=content_types.APPLICATION_JSON, + body=AuthResponse( + auth_token=AuthTokenService.encode_token(token_payload), + session_token=session_token, + ), ) - encoded_jwt = jwt.encode( - token_payload.model_dump(), dynamic_config.jwt_secret, algorithm="HS256" - ) - return AuthResponse(auth_token=encoded_jwt, session_token=session_token) -@router.get("/check-login") +@router.get("/check-login", middlewares=[get_current_user]) @tracer.capture_method -def refresh_login() -> bool: - headers = router.current_event.headers - print(headers) - print( - jwt.decode( - headers["auth_token"], dynamic_config.jwt_secret, algorithms=["HS256"] - ) +def refresh_login() -> SessionInfo: + print("In the handler") + print(router.context) + current_user: User = router.context["current_user"] + return SessionInfo( + email=current_user.email, + token_expires=current_user.auth_token_expires, + message="You are logged in", ) - return True @router.get("/logout") diff --git a/lambdas/resources.env b/lambdas/backend_api/middleware/__init__.py similarity index 100% rename from lambdas/resources.env rename to lambdas/backend_api/middleware/__init__.py diff --git a/lambdas/backend_api/middleware/auth.py b/lambdas/backend_api/middleware/auth.py index c65c66a..784e03b 100644 --- a/lambdas/backend_api/middleware/auth.py +++ b/lambdas/backend_api/middleware/auth.py @@ -1,30 +1,39 @@ -from typing import Callable - -from aws_lambda_powertools.middleware_factory import lambda_handler_decorator -from aws_lambda_powertools.utilities.jmespath_utils import ( - envelopes, - query, +from aws_lambda_powertools.event_handler.exceptions import ( + UnauthorizedError, ) -from aws_lambda_powertools.utilities.typing import LambdaContext + +from services.auth import AuthTokenService +import datetime as dt +from config import users_table + +from aws_lambda_powertools.event_handler import APIGatewayRestResolver, Response +from aws_lambda_powertools.event_handler.middlewares import NextMiddleware # Middleware to check the user is authenticated and provide the current user # to the endpoint -@lambda_handler_decorator -def middleware_before( - handler: Callable[[dict, LambdaContext], dict], - # Will be the lambda URL API call event - event: dict, - context: LambdaContext, -) -> dict: - # extract cookie information from request to determine the user - detail: dict = query(data=event, envelope=envelopes.EVENTBRIDGE) +def get_current_user( + app: APIGatewayRestResolver, next_middleware: NextMiddleware +) -> Response: + headers: dict[str, str] = app.current_event.headers + + if not (token_string := headers.get("auth_token")): + raise UnauthorizedError("Unauthorized") + + provided_token = AuthTokenService.decode_token(token_string) + current_user = users_table.get(provided_token.email) + + if not current_user: + raise UnauthorizedError("Unauthorized") - # pass the user to the incoming event - if "status_id" not in detail: - event["detail"]["status_id"] = "pending" + now_datetime = dt.datetime.now(dt.timezone.utc) + now = int(round(now_datetime.timestamp())) - return handler(event, context) + if current_user.auth_token_expires < now: + raise UnauthorizedError("Token expired") + if not (current_user.auth_token == provided_token.auth_token): + raise UnauthorizedError("Unauthorized") -# Possibly also a middleware to handle roles? + app.append_context(current_user=current_user) + return next_middleware(app) diff --git a/lambdas/backend_api/resources.env b/lambdas/backend_api/resources.env index e69de29..dc6fe48 100644 --- a/lambdas/backend_api/resources.env +++ b/lambdas/backend_api/resources.env @@ -0,0 +1,7 @@ +backend_api_ecr_url = "979058445641.dkr.ecr.us-west-2.amazonaws.com/tumpr-staging-backend_api" +backend_api_url = "https://3eo5qtv2dln5khtesgthtmntiy0asccs.lambda-url.us-west-2.on.aws/" +frontend_ui_url = "string" +object_lock_table = "string" +object_storage_s3 = "string" +user_notifications_sns_arn = "arn:aws:sns:us-west-2:979058445641:user-notifications-topic" +users_table = "string" diff --git a/lambdas/backend_api/services/__init__.py b/lambdas/backend_api/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lambdas/backend_api/services/auth.py b/lambdas/backend_api/services/auth.py new file mode 100644 index 0000000..6a103d3 --- /dev/null +++ b/lambdas/backend_api/services/auth.py @@ -0,0 +1,36 @@ +import jwt +from pydantic import BaseModel + +from config import dynamic_config + + +class SessionToken(BaseModel): + """ + Data encoded into the JWT -not accessible to the user + """ + + email: str + auth_token: str + + +class SessionInfo(BaseModel): + """ + Data sent back to the user after authenticating + """ + + email: str + message: str = "" + token_expires: int | None = None + + +class AuthTokenService: + @staticmethod + def encode_token(token: SessionToken) -> str: + return jwt.encode( + token.model_dump(), dynamic_config.jwt_secret, algorithm="HS256" + ) + + @staticmethod + def decode_token(token_str: str) -> SessionToken: + token = jwt.decode(token_str, dynamic_config.jwt_secret, algorithms=["HS256"]) + return SessionToken.model_validate(token) diff --git a/terraform/main.tf b/terraform/main.tf index af298c7..e4a6372 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -31,14 +31,6 @@ module "backend_api" { lambda_name = "backend_api" } -module "dummy_lambda" { - source = "./lambda" - - app_name = "tumpr" - env = terraform.workspace - lambda_name = "dummy_lambda" -} - resource "aws_dynamodb_table" "users" { // todo: staging and production tables name = "Users"