From a2b8950d9a859c94477ab76977cb1ca99f5e172d Mon Sep 17 00:00:00 2001 From: TLimoges33 <125313326+TLimoges33@users.noreply.github.com> Date: Thu, 28 Aug 2025 17:26:02 +0000 Subject: [PATCH] auth: add JWT email/password auth + Login UI; security: kms rotate helper; preview sync endpoint + UI --- modern/backend/app.py | 38 ++++++++++++ modern/backend/auth.py | 93 ++++++++++++++++++++++++++++ modern/backend/kms_rotate.py | 37 +++++++++++ modern/frontend/src/App.jsx | 4 +- modern/frontend/src/Integrations.jsx | 9 +++ modern/frontend/src/Login.jsx | 25 ++++++++ 6 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 modern/backend/auth.py create mode 100644 modern/backend/kms_rotate.py create mode 100644 modern/frontend/src/Login.jsx diff --git a/modern/backend/app.py b/modern/backend/app.py index 883bbf7..28e6c11 100644 --- a/modern/backend/app.py +++ b/modern/backend/app.py @@ -2,6 +2,7 @@ from fastapi import FastAPI, Depends, HTTPException from fastapi.middleware.cors import CORSMiddleware from . import models from .oauth import router as oauth_router +from .auth import router as auth_router, get_current_user import os import requests import time @@ -30,6 +31,7 @@ def hello(): return {'message': 'Hello from LifeRPG modern backend (FastAPI)'} app.include_router(oauth_router, prefix='/api/v1') +app.include_router(auth_router, prefix='/api/v1/auth') # Basic user routes (demo) @app.post('/api/v1/users') @@ -77,6 +79,42 @@ def google_events(integration_id: int): db.close() +@app.get('/api/v1/integrations/{integration_id}/events_preview') +def events_preview(integration_id: int): + db = models.SessionLocal() + try: + integration = db.query(models.Integration).filter_by(id=integration_id).first() + if not integration: + raise HTTPException(status_code=404, detail='integration not found') + token_row = db.query(models.OAuthToken).filter_by(integration_id=integration_id).order_by(models.OAuthToken.id.desc()).first() + if not token_row: + raise HTTPException(status_code=404, detail='no token') + from .oauth import refresh_google_token_if_needed + refreshed = refresh_google_token_if_needed(token_row) + if refreshed: + token_row = refreshed + from .crypto import decrypt_text + access = decrypt_text(token_row.access_token) + if not access: + raise HTTPException(status_code=500, detail='unable to decrypt') + headers = {'Authorization': f'Bearer {access}'} + params = {'maxResults': 50, 'singleEvents': True, 'orderBy': 'startTime', 'timeMin': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} + resp = requests.get('https://www.googleapis.com/calendar/v3/calendars/primary/events', headers=headers, params=params, timeout=10) + if resp.status_code != 200: + raise HTTPException(status_code=502, detail='google api error') + items = resp.json().get('items', []) + # Return light preview objects + preview = [{ + 'id': it.get('id'), + 'summary': it.get('summary'), + 'start': it.get('start'), + 'end': it.get('end') + } for it in items] + return {'preview': preview} + finally: + db.close() + + @app.post('/api/v1/guilds') def create_guild(payload: dict = Body({})): name = payload.get('name') diff --git a/modern/backend/auth.py b/modern/backend/auth.py new file mode 100644 index 0000000..0573c9b --- /dev/null +++ b/modern/backend/auth.py @@ -0,0 +1,93 @@ +import os +import time +from fastapi import APIRouter, HTTPException, Depends, Request +from fastapi.responses import JSONResponse +from passlib.hash import bcrypt +import jwt +from . import models + +router = APIRouter() + +JWT_SECRET = os.getenv('LIFERPG_JWT_SECRET', 'dev_jwt_secret_change') +JWT_ALGO = 'HS256' +JWT_EXP_SECONDS = 60 * 60 * 24 # 1 day + + +def create_token(payload: dict) -> str: + now = int(time.time()) + payload_out = {**payload, 'iat': now, 'exp': now + JWT_EXP_SECONDS} + return jwt.encode(payload_out, JWT_SECRET, algorithm=JWT_ALGO) + + +def decode_token(token: str) -> dict: + try: + return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGO]) + except Exception: + return {} + + +@router.post('/signup') +def signup(payload: dict): + email = payload.get('email') + password = payload.get('password') + if not email or not password: + raise HTTPException(status_code=400, detail='email and password required') + db = models.SessionLocal() + try: + existing = db.query(models.User).filter_by(email=email).first() + if existing: + raise HTTPException(status_code=400, detail='email exists') + user = models.User(email=email, password_hash=bcrypt.hash(password), display_name=payload.get('display_name')) + db.add(user) + db.commit() + db.refresh(user) + token = create_token({'sub': user.id}) + resp = JSONResponse({'id': user.id, 'email': user.email}) + resp.set_cookie('session', token, httponly=True, secure=False, samesite='lax') + return resp + finally: + db.close() + + +@router.post('/login') +def login(payload: dict): + email = payload.get('email') + password = payload.get('password') + if not email or not password: + raise HTTPException(status_code=400, detail='email and password required') + db = models.SessionLocal() + try: + user = db.query(models.User).filter_by(email=email).first() + if not user or not user.password_hash or not bcrypt.verify(password, user.password_hash): + raise HTTPException(status_code=401, detail='invalid credentials') + token = create_token({'sub': user.id}) + resp = JSONResponse({'id': user.id, 'email': user.email}) + resp.set_cookie('session', token, httponly=True, secure=False, samesite='lax') + return resp + finally: + db.close() + + +@router.post('/logout') +def logout(): + resp = JSONResponse({'ok': True}) + resp.delete_cookie('session') + return resp + + +def get_current_user(request: Request): + token = request.cookies.get('session') + if not token: + raise HTTPException(status_code=401, detail='not authenticated') + data = decode_token(token) + uid = data.get('sub') + if not uid: + raise HTTPException(status_code=401, detail='invalid token') + db = models.SessionLocal() + try: + user = db.query(models.User).filter_by(id=uid).first() + if not user: + raise HTTPException(status_code=401, detail='user not found') + return user + finally: + db.close() diff --git a/modern/backend/kms_rotate.py b/modern/backend/kms_rotate.py new file mode 100644 index 0000000..e7ba07a --- /dev/null +++ b/modern/backend/kms_rotate.py @@ -0,0 +1,37 @@ +""" +Envelope encryption data key rotation helper (AWS KMS example). + +This script demonstrates how to rotate the data key used by `crypto.py`. +It: + - Generates a new data key via KMS.GenerateDataKey + - Re-encrypts (rewraps) nothing in this example, but writes the new wrapped key to file + - In production, you should re-encrypt existing ciphertext with the new data key or keep using envelope rewrap. + +Usage (dev): ensure AWS credentials are configured and run: + python kms_rotate.py --kms-key-id + +Note: This is a simple operator helper for demo purposes; adapt safely for production. +""" +import argparse +import boto3 +import os + +WRAPPED_PATH = os.path.join(os.path.dirname(__file__), 'modern/backend/.wrapped_data_key') + +def rotate(kms_key_id: str): + kms = boto3.client('kms') + resp = kms.generate_data_key(KeyId=kms_key_id, KeySpec='AES_256') + ciphertext = resp['CiphertextBlob'] + with open(WRAPPED_PATH, 'wb') as f: + f.write(ciphertext) + try: + os.chmod(WRAPPED_PATH, 0o600) + except Exception: + pass + print('Wrote new wrapped key to', WRAPPED_PATH) + +if __name__ == '__main__': + p = argparse.ArgumentParser() + p.add_argument('--kms-key-id', required=True) + args = p.parse_args() + rotate(args.kms_key_id) \ No newline at end of file diff --git a/modern/frontend/src/App.jsx b/modern/frontend/src/App.jsx index 7a3bfc1..7edb30d 100644 --- a/modern/frontend/src/App.jsx +++ b/modern/frontend/src/App.jsx @@ -1,13 +1,15 @@ import React from 'react' import Integrations from './Integrations' import Guilds from './Guilds' +import Login from './Login' export default function App(){ return (

LifeRPG Modern

Welcome — frontend scaffold. Connect to backend at /api/v1.

- + +
) diff --git a/modern/frontend/src/Integrations.jsx b/modern/frontend/src/Integrations.jsx index b0d7457..6618e88 100644 --- a/modern/frontend/src/Integrations.jsx +++ b/modern/frontend/src/Integrations.jsx @@ -30,6 +30,14 @@ export default function Integrations(){ .finally(()=>setLoadingId(null)) } + function previewEvents(integrationId){ + fetch(`/api/v1/integrations/${integrationId}/events_preview`, {credentials:'include'}) + .then(r=>r.json()).then(d=>{ + setEvents(d) + setMsg('Preview loaded') + }).catch(()=>setMsg('Preview failed')) + } + function removeIntegration(integrationId){ if(!confirm('Remove integration?')) return setLoadingId(integrationId) @@ -63,6 +71,7 @@ export default function Integrations(){ {i.provider} — id: {i.id} — user: {i.user_id}
+
diff --git a/modern/frontend/src/Login.jsx b/modern/frontend/src/Login.jsx new file mode 100644 index 0000000..3047c56 --- /dev/null +++ b/modern/frontend/src/Login.jsx @@ -0,0 +1,25 @@ +import React, {useState} from 'react' + +export default function Login(){ + const [email,setEmail]=useState('') + const [pw,setPw]=useState('') + const [msg,setMsg]=useState(null) + + function submit(e){ + e.preventDefault() + fetch('/api/v1/auth/login', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({email, password: pw}), credentials:'include'}) + .then(r=>r.json()).then(()=> setMsg('Logged in')).catch(()=> setMsg('Login failed')) + } + + return ( +
+

Login

+
+
setEmail(e.target.value)} />
+
setPw(e.target.value)} />
+ +
+ {msg &&
{msg}
} +
+ ) +}