auth: add JWT email/password auth + Login UI; security: kms rotate helper; preview sync endpoint + UI
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user