80 lines
4.1 KiB
Python
80 lines
4.1 KiB
Python
"""Device registration only. This module deliberately contains no push sender."""
|
|
|
|
import hashlib
|
|
from uuid import UUID
|
|
|
|
from fastapi import Request
|
|
from fastapi.responses import JSONResponse
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
class DeviceRegistration(BaseModel):
|
|
device_id: UUID
|
|
token: str = Field(min_length=20, max_length=4096, pattern=r'^[A-Za-z0-9_:.-]+$')
|
|
platform: str = Field(pattern=r'^android$')
|
|
app_version: str = Field(default='', max_length=32, pattern=r'^[0-9A-Za-z.+_-]*$')
|
|
session_tag: str = Field(pattern=r'^[a-f0-9]{64}$')
|
|
|
|
|
|
def session_hash(request, cookie_name):
|
|
return hashlib.sha256(request.cookies.get(cookie_name, '').encode()).hexdigest()
|
|
|
|
|
|
def register_routes(app, get_db, get_user, cookie_name):
|
|
@app.get('/api/push/session')
|
|
def push_session(request: Request):
|
|
user = get_user(request)
|
|
payload = {'authenticated': bool(user)}
|
|
if user:
|
|
payload.update(user_id=user['id'], session_tag=session_hash(request, cookie_name))
|
|
return JSONResponse(payload, headers={'Cache-Control': 'no-store'})
|
|
|
|
@app.post('/api/push/devices')
|
|
def register_device(request: Request, data: DeviceRegistration):
|
|
user = get_user(request)
|
|
if not user:
|
|
return JSONResponse({'error': 'authentication_required'}, status_code=401)
|
|
token_hash = session_hash(request, cookie_name)
|
|
if data.session_tag != token_hash:
|
|
return JSONResponse({'error': 'session_changed'}, status_code=409)
|
|
with get_db() as connection:
|
|
with connection.cursor() as cursor:
|
|
# The row lock serializes registration against logout/session deletion.
|
|
cursor.execute('SELECT id FROM sessions WHERE token_hash=%s AND user_id=%s '
|
|
'AND expires_at>CURRENT_TIMESTAMP FOR UPDATE', (token_hash, user['id']))
|
|
session = cursor.fetchone()
|
|
if not session:
|
|
return JSONResponse({'error': 'session_expired'}, status_code=401)
|
|
# Serialize token/device transfers across users as well as token rotations.
|
|
cursor.execute('SELECT pg_advisory_xact_lock(71001)')
|
|
cursor.execute('DELETE FROM push_devices WHERE token=%s AND device_id<>%s',
|
|
(data.token, data.device_id))
|
|
cursor.execute('''
|
|
INSERT INTO push_devices(user_id, session_id, device_id, token, platform, app_version)
|
|
VALUES (%s,%s,%s,%s,%s,%s)
|
|
ON CONFLICT(device_id) DO UPDATE SET
|
|
user_id=EXCLUDED.user_id, session_id=EXCLUDED.session_id,
|
|
token=EXCLUDED.token, platform=EXCLUDED.platform, app_version=EXCLUDED.app_version,
|
|
updated_at=CURRENT_TIMESTAMP, last_seen_at=CURRENT_TIMESTAMP
|
|
RETURNING id
|
|
''', (user['id'], session[0], data.device_id, data.token, data.platform, data.app_version))
|
|
device_id = cursor.fetchone()[0]
|
|
cursor.execute('DELETE FROM push_devices WHERE session_id IN '
|
|
'(SELECT id FROM sessions WHERE expires_at<=CURRENT_TIMESTAMP) '
|
|
'OR last_seen_at<CURRENT_TIMESTAMP - INTERVAL \'90 days\'')
|
|
connection.commit()
|
|
return JSONResponse({'registered': True, 'id': device_id}, headers={'Cache-Control': 'no-store'})
|
|
|
|
@app.delete('/api/push/devices/{device_id}')
|
|
def unregister_device(request: Request, device_id: UUID):
|
|
user = get_user(request)
|
|
if not user:
|
|
return JSONResponse({'error': 'authentication_required'}, status_code=401)
|
|
with get_db() as connection:
|
|
with connection.cursor() as cursor:
|
|
cursor.execute('DELETE FROM push_devices WHERE device_id=%s AND user_id=%s '
|
|
'AND session_id IN (SELECT id FROM sessions WHERE token_hash=%s)',
|
|
(device_id, user['id'], session_hash(request, cookie_name)))
|
|
connection.commit()
|
|
return JSONResponse({'registered': False}, headers={'Cache-Control': 'no-store'})
|