10.9. אימות עבור לקוחות תוכנתיים

אסימוני Bearer הם הדרך שבה טלפונים, מיקרו-בקרים נלווים, ועובדי ענן מאמתים את עצמם מול שרת – הלקוח שם אסימון בכותרת Authorization בכל בקשה והשרת בודק אותו. אפליקציית הטלפון הנלווית שולחת POST של ”ack“ כשהבעלים מקיש על ”נצפה“ בהתראת תנועה; הבקשות האלה זקוקות לאסימון.

10.9.1. סוד החתימה

הסוד הוא המפתח הפרטי של המצלמה לחתימה ואימות של אסימונים. צרו 32 בתים אקראיים מ-RNG החומרה באתחול הראשון של המצלמה, שמרו אותם במערכת הקבצים, ושתמשו באותם בתים בכל אתחול עוקב:

# auth/tokens.py
import os

try:
    with open('secret.bin', 'rb') as f:
        SECRET = f.read()
except OSError:
    SECRET = os.urandom(32)
    with open('secret.bin', 'wb') as f:
        f.write(SECRET)

os.urandom() הוא מקור האקראיות המתאים מבחינה קריפטוגרפית בכל פורט מצלמה – ברוב הפורטים הוא קורא ישירות את מחולל המספרים האקראיים של החומרה בשבב. הקובץ שוכן בספריית העבודה של המצלמה (/sdcard אם כרטיס SD מותקן, /flash אחרת) ולעולם אינו עוזב את המצלמה. מחיקת secret.bin ואתחול מחדש מסובבים את הסוד ומבטלים כל אסימון שהונפק תחת הסוד הישן.

הערה

machine.unique_id() אינו תחליף. בחלק מפורטי המצלמה הוא משמש גם לגזירת כתובת ה-MAC של הרשת, מה שאומר שערכו נוסע עם כל חבילה שהמצלמה שולחת – לא התכונה שסוד חתימה זקוק לה.

10.9.2. הנפקת אסימונים

הטלפון זקוק לדרך לקבל אסימון מלכתחילה. JSON Web Token (JWT) קצר-מועד – מטען JSON מקודד base64 עם חתימה מצורפת – מספיק למעבר ראשון: הבעלים מחליף סיסמה באסימון, ואז משתמש באסימון עד שהוא פג:

import jwt
import time

@app.post('/api/login')
async def api_login(request):
    creds = request.json
    if creds.get('user') != 'owner' or creds.get('pass') != load_password():
        abort(401)
    token = jwt.encode({
        'sub': 'owner',
        'exp': int(time.time()) + 3600,
    }, SECRET)
    return {'token': token}

הטלפון שולח POST של {user, pass} פעם אחת, מאחסן את האסימון המוחזר באחסון מקומי, וכולל אותו כ-Authorization: Bearer <token> בכל בקשה עוקבת.

התביעה exp היא חותמת זמן של Unix. המאמת בחלק הבא מסתמך על התביעה הזו כדי לדחות אסימונים שפג תוקפם באופן אוטומטי – מה שאומר שצריך להגדיר את שעון הקיר של המצלמה, כי אחרת כל אסימון ייראה או הרחק בעתיד או כבר פג. ראו זמן ו-NTP למתכון סנכרון NTP.

10.9.3. אימות אסימונים עם TokenAuth

microdot.auth.TokenAuth הוא מפעל המעטרים עבור נתיבים שדורשים כותרת Authorization: Bearer <token>. פונקציית ה-callback של המאמת מקבלת את מחרוזת האסימון ומחזירה את אובייקט הזהות שיש לצרף לבקשה – או None כדי לדחות:

from microdot.auth import TokenAuth

tokens = TokenAuth()

@tokens.authenticate
async def check_token(request, token):
    try:
        claims = jwt.decode(token, SECRET)
    except jwt.exceptions.PyJWTError:
        return None
    return claims['sub']

@app.post('/api/ack')
@tokens
async def ack(request):
    body = request.json
    if not body or 'count' not in body:
        abort(400, 'missing count')
    return {'ok': True, 'user': request.g.current_user}

jwt.decode() מעלה jwt.exceptions.PyJWTError (או תת-מחלקה) כאשר החתימה שגויה, האסימון פגום, או התביעה exp חלפה. המאמת בולע את כל אלה ומחזיר None, כך שמיקרודוט מגיב 401 מבלי שהמטפל רואה דבר.

כאשר המאמת מחזיר ערך שאינו None, מיקרודוט מאחסן אותו ב-request.g.current_user כך שהמטפל יכול לקרוא אותו – במקרה זה זו התביעה sub (נושא) של ה-JWT.

10.9.4. BasicAuth עבור רשתות סגורות

עבור נקודת קצה ניהולית שמגיעים אליה רק מרשת מהימנה, microdot.auth.BasicAuth פשוט יותר – הדפדפן מקפיץ תיבת דו-שיח מובנית של שם משתמש/סיסמה ושולח אותם בכותרת Authorization: Basic ... בכל בקשה:

from microdot.auth import BasicAuth

basic = BasicAuth(realm='Backyard cam admin')

@basic.authenticate
async def check_basic(request, username, password):
    if username == 'owner' and password == load_password():
        return 'owner'
    return None

@app.get('/admin')
@basic
async def admin(request):
    return 'hi ' + request.g.current_user

פרטי הכניסה נוסעים בטקסט גלוי אלא אם החיבור הוא HTTPS, ולכן גישה זו הגיונית רק ברשת מהימנה או כאשר HTTPS קיים.

/api/ack ו-/api/login מבינים כעת אסימונים; /admin דורש אימות Basic.