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