auth: add JWT email/password auth + Login UI; security: kms rotate helper; preview sync endpoint + UI
This commit is contained in:
parent
7702d3711b
commit
a2b8950d9a
|
|
@ -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')
|
||||
|
|
|
|||
93
modern/backend/auth.py
Normal file
93
modern/backend/auth.py
Normal 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()
|
||||
37
modern/backend/kms_rotate.py
Normal file
37
modern/backend/kms_rotate.py
Normal 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)
|
||||
|
|
@ -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 (
|
||||
<div style={{padding:20,fontFamily:'system-ui, sans-serif'}}>
|
||||
<h1>LifeRPG Modern</h1>
|
||||
<p>Welcome — frontend scaffold. Connect to backend at <code>/api/v1</code>.</p>
|
||||
<Integrations />
|
||||
<Login />
|
||||
<Integrations />
|
||||
<Guilds />
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(){
|
|||
<strong>{i.provider}</strong> — id: {i.id} — user: {i.user_id}
|
||||
<div style={{display:'inline-block', marginLeft:12}}>
|
||||
<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={()=>removeIntegration(i.id)} disabled={loadingId===i.id}>Remove</button>
|
||||
</div>
|
||||
|
|
|
|||
25
modern/frontend/src/Login.jsx
Normal file
25
modern/frontend/src/Login.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user