136 lines
5.1 KiB
Python
136 lines
5.1 KiB
Python
"""
|
|
app/models/api_token.py
|
|
-----------------------
|
|
Persistent storage for JWT refresh tokens and APNs device tokens.
|
|
|
|
RefreshToken
|
|
One row per active mobile session. When the access token expires the
|
|
app presents its refresh token here; a new access token is issued and
|
|
the refresh token is rotated (old one deleted, new one inserted).
|
|
Revocation is instant: delete the row.
|
|
|
|
DeviceToken
|
|
One row per (user, device) pair. Stores device info so the admin can
|
|
see all installed devices and their versions. Updated on every app
|
|
launch because APNs tokens can rotate.
|
|
"""
|
|
|
|
import secrets
|
|
from app import db
|
|
from app.utils.time_utils import now_eastern
|
|
from datetime import timedelta
|
|
|
|
|
|
class RefreshToken(db.Model):
|
|
"""
|
|
Opaque refresh token stored server-side.
|
|
|
|
The token value itself is a 64-character hex string generated with
|
|
secrets.token_hex(32). Only the SHA-256 hash is stored so that a DB
|
|
breach does not expose live tokens.
|
|
"""
|
|
__tablename__ = 'api_refresh_tokens'
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
user_id = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey('users.id', ondelete='CASCADE'),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
# SHA-256 hex digest of the raw token — never store the raw value
|
|
token_hash = db.Column(db.String(64), nullable=False, unique=True, index=True)
|
|
# Device identifier supplied by the app (UIDevice.identifierForVendor)
|
|
device_id = db.Column(db.String(64), nullable=True)
|
|
device_name = db.Column(db.String(100), nullable=True) # e.g. "John's iPhone"
|
|
created_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
|
|
expires_at = db.Column(db.DateTime, nullable=False)
|
|
revoked = db.Column(db.Boolean, nullable=False, default=False)
|
|
|
|
user = db.relationship('User', foreign_keys=[user_id],
|
|
backref=db.backref('refresh_tokens', lazy='dynamic',
|
|
cascade='all, delete-orphan'))
|
|
|
|
@classmethod
|
|
def create_for(cls, user, device_id=None, device_name=None,
|
|
lifetime_days=30):
|
|
"""
|
|
Generate a new refresh token, persist it, and return the raw token
|
|
string (only time it is ever available in plaintext).
|
|
"""
|
|
import hashlib
|
|
raw = secrets.token_hex(32) # 64-char hex, 256 bits entropy
|
|
hashed = hashlib.sha256(raw.encode()).hexdigest()
|
|
token = cls(
|
|
user_id = user.id,
|
|
token_hash = hashed,
|
|
device_id = device_id,
|
|
device_name = device_name,
|
|
expires_at = now_eastern() + timedelta(days=lifetime_days),
|
|
)
|
|
db.session.add(token)
|
|
return raw, token # caller must db.session.commit()
|
|
|
|
@classmethod
|
|
def verify(cls, raw_token):
|
|
"""
|
|
Look up a refresh token by its raw value.
|
|
Returns the RefreshToken row if valid and unexpired, else None.
|
|
|
|
Expired rows are not deleted here — passive cleanup runs in the
|
|
login route (api/auth.py) each time a user authenticates, removing
|
|
all expired/revoked tokens for that user. This keeps the table tidy
|
|
without requiring a dedicated cron job.
|
|
"""
|
|
import hashlib
|
|
hashed = hashlib.sha256(raw_token.encode()).hexdigest()
|
|
row = cls.query.filter_by(token_hash=hashed, revoked=False).first()
|
|
if row is None:
|
|
return None
|
|
if row.expires_at < now_eastern():
|
|
return None
|
|
return row
|
|
|
|
def revoke(self):
|
|
self.revoked = True
|
|
|
|
def __repr__(self):
|
|
return f'<RefreshToken user={self.user_id} device={self.device_id}>'
|
|
|
|
|
|
class DeviceToken(db.Model):
|
|
"""
|
|
Device record for admin tracking and optional APNs push delivery.
|
|
|
|
One row per (user, device_id) pair — upserted on every app launch.
|
|
apns_token is optional (empty string when APNs push is not configured).
|
|
ios_version and last_seen_at added in phase31 for the admin Devices page.
|
|
"""
|
|
__tablename__ = 'api_device_tokens'
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
user_id = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey('users.id', ondelete='CASCADE'),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
device_id = db.Column(db.String(64), nullable=False)
|
|
apns_token = db.Column(db.String(200), nullable=False, default='')
|
|
device_name = db.Column(db.String(100), nullable=True)
|
|
app_version = db.Column(db.String(20), nullable=True)
|
|
ios_version = db.Column(db.String(20), nullable=True)
|
|
registered_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
|
|
last_seen_at = db.Column(db.DateTime, nullable=True)
|
|
|
|
__table_args__ = (
|
|
db.UniqueConstraint('user_id', 'device_id', name='uq_device_token_user_device'),
|
|
)
|
|
|
|
user = db.relationship('User', foreign_keys=[user_id],
|
|
backref=db.backref('device_tokens', lazy='dynamic',
|
|
cascade='all, delete-orphan'))
|
|
|
|
def __repr__(self):
|
|
return f'<DeviceToken user={self.user_id} device={self.device_id}>'
|