auth: add JWT email/password auth + Login UI; security: kms rotate helper; preview sync endpoint + UI

This commit is contained in:
TLimoges33 2025-08-28 17:26:02 +00:00
parent 7702d3711b
commit a2b8950d9a
6 changed files with 205 additions and 1 deletions

View File

@ -2,6 +2,7 @@ from fastapi import FastAPI, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from . import models from . import models
from .oauth import router as oauth_router from .oauth import router as oauth_router
from .auth import router as auth_router, get_current_user
import os import os
import requests import requests
import time import time
@ -30,6 +31,7 @@ def hello():
return {'message': 'Hello from LifeRPG modern backend (FastAPI)'} return {'message': 'Hello from LifeRPG modern backend (FastAPI)'}
app.include_router(oauth_router, prefix='/api/v1') app.include_router(oauth_router, prefix='/api/v1')
app.include_router(auth_router, prefix='/api/v1/auth')
# Basic user routes (demo) # Basic user routes (demo)
@app.post('/api/v1/users') @app.post('/api/v1/users')
@ -77,6 +79,42 @@ def google_events(integration_id: int):
db.close() 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') @app.post('/api/v1/guilds')
def create_guild(payload: dict = Body({})): def create_guild(payload: dict = Body({})):
name = payload.get('name') name = payload.get('name')

93
modern/backend/auth.py Normal file
View File

@ -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()

View File

@ -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 <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)

View File

@ -1,12 +1,14 @@
import React from 'react' import React from 'react'
import Integrations from './Integrations' import Integrations from './Integrations'
import Guilds from './Guilds' import Guilds from './Guilds'
import Login from './Login'
export default function App(){ export default function App(){
return ( return (
<div style={{padding:20,fontFamily:'system-ui, sans-serif'}}> <div style={{padding:20,fontFamily:'system-ui, sans-serif'}}>
<h1>LifeRPG Modern</h1> <h1>LifeRPG Modern</h1>
<p>Welcome frontend scaffold. Connect to backend at <code>/api/v1</code>.</p> <p>Welcome frontend scaffold. Connect to backend at <code>/api/v1</code>.</p>
<Login />
<Integrations /> <Integrations />
<Guilds /> <Guilds />
</div> </div>

View File

@ -30,6 +30,14 @@ export default function Integrations(){
.finally(()=>setLoadingId(null)) .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){ function removeIntegration(integrationId){
if(!confirm('Remove integration?')) return if(!confirm('Remove integration?')) return
setLoadingId(integrationId) setLoadingId(integrationId)
@ -63,6 +71,7 @@ export default function Integrations(){
<strong>{i.provider}</strong> id: {i.id} user: {i.user_id} <strong>{i.provider}</strong> id: {i.id} user: {i.user_id}
<div style={{display:'inline-block', marginLeft:12}}> <div style={{display:'inline-block', marginLeft:12}}>
<button onClick={()=>fetchEvents(i.id)} disabled={loadingId===i.id} style={{marginRight:6}}>Fetch Events</button> <button onClick={()=>fetchEvents(i.id)} disabled={loadingId===i.id} style={{marginRight:6}}>Fetch Events</button>
<button onClick={()=>previewEvents(i.id)} disabled={loadingId===i.id} style={{marginRight:6}}>Preview</button>
<button onClick={()=>syncIntegration(i.id)} disabled={loadingId===i.id} style={{marginRight:6}}>Sync Habits</button> <button onClick={()=>syncIntegration(i.id)} disabled={loadingId===i.id} style={{marginRight:6}}>Sync Habits</button>
<button onClick={()=>removeIntegration(i.id)} disabled={loadingId===i.id}>Remove</button> <button onClick={()=>removeIntegration(i.id)} disabled={loadingId===i.id}>Remove</button>
</div> </div>

View File

@ -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 (
<div style={{marginTop:20}}>
<h2>Login</h2>
<form onSubmit={submit}>
<div><input placeholder="email" value={email} onChange={e=>setEmail(e.target.value)} /></div>
<div><input placeholder="password" type="password" value={pw} onChange={e=>setPw(e.target.value)} /></div>
<button type="submit">Login</button>
</form>
{msg && <div style={{marginTop:8}}>{msg}</div>}
</div>
)
}