Заняття 07 · місяць 2

Хто має право змінювати

90 хвилин

Мета заняття: розділити «дивитися можна всім» і «редагувати можу тільки я», не зберігаючи при цьому пароля у відкритому вигляді.

Мій сайт
http://91.219.61.4/u/<логін>/
Мій кабінет
http://91.219.61.4/k/<логін>/
Вхід на сервер
ssh <логін>@91.219.61.4

Замість <логін> підставляєш свій логін виду bohdan.g9: ім’я, крапка, номер групи. Кутові дужки не пишуться. Кабінет — сторінка, де проти кожного критерію стоїть відмітка, зарахований він чи ні. У прикладах на цій сторінці скрізь написано bohdan.g9 — читай це як свій логін і виправляй у кожній команді, яку копіюєш.

Що ти зробиш сьогодні

Зараз твій сервіс приймає команди від будь-кого. Будь-хто, хто знає адресу, може відкрити термінал і однією командою додати або стерти запис у твоїй базі — і сервіс не спитає, хто це. Сьогодні ти закриєш зміни: POST і DELETE працюватимуть тільки після входу з паролем, а GET лишиться відкритим, щоб сторінку могли дивитися всі.

Пароль ти не зберігатимеш. У базі опиниться його хеш — рядок, з якого сам пароль не відновлюється, але звірити введений пароль із ним можна. Секретний ключ, яким сервіс підтверджує, що ти вже ввів пароль, зберігатиметься в окремому файлі .env, і ти перевіриш, що цей файл не потрапив у git.

Теорія

1. Автентифікація і авторизація — дві різні перевірки

Ці два слова звучать схоже, їх постійно плутають, а означають вони різні речі.

Автентифікація
Відповідь на питання «хто ти». Людина показує щось, що є тільки в неї: пароль, код із застосунку, відбиток пальця. Система звіряє це й каже: так, це Богдан.
Авторизація
Відповідь на питання «що тобі можна». Система вже знає, що це Богдан, і тепер вирішує: свої записи він редагувати може, чужі — ні.

У школі це видно щодня. Пропуск на вході — автентифікація: охоронець упізнає, що ти учень цієї школи. Але з тим самим пропуском тебе не пустять у кабінет директора й до серверної: це вже авторизація, і вона в кожного своя.

В електронному щоденнику так само. Ти входиш зі своїм паролем — це автентифікація. Далі бачиш свої оцінки й не бачиш чужих, а вчитель тим самим щоденником оцінки виставляє. Логін однаково перевірений в обох, права різні.

Сьогодні ми робимо просту схему: у сервісу є один власник — ти. Автентифікація — перевірка твого пароля. Авторизація — правило «змінювати дані може лише власник, дивитися може будь-хто».

Питання на розуміння

Марта увійшла в шкільний електронний щоденник зі своїм паролем. Вона відкрила свої оцінки, а коли спробувала відкрити сторінку «оцінки всього класу», система написала «доступ заборонено». Яка з двох перевірок спрацювала в другому випадку?

2. Чому сервіс не зберігає паролів

Найпростіше рішення — записати пароль у стовпець password і при вході порівняти рядки. Так робити не можна, і причина не в теорії. Бази витікають: помилка в коді, украдений бекап, забутий відкритий порт — так називають номер, за яким сервер розрізняє програми, що приймають запити з мережі; якщо номер, на якому працює база, доступний з інтернету, під’єднатися до неї може будь-хто. Якщо в базі лежали паролі, той, хто її отримав, має готовий список «логін — пароль». Найгірше не те, що він зайде у твій сервіс. Найгірше те, що більшість людей ставить той самий пароль у пошті й соцмережах, і тепер він відкриє їх теж.

Тому зберігають не пароль, а хеш — результат перетворення тексту на рядок фіксованої довжини за жорстким правилом. Перетворення працює тільки в один бік: з пароля хеш обчислити легко, з хеша пароль — ні.

Подивись, як це виглядає. Виконай ці три команди на сервері одну за одною. Python тут запускається довгим шляхом /opt/club/venv/bin/python, а не коротким python3: це окремо встановлений Python, спільний для всієї групи, і саме в нього адміністратор поставив бібліотеки курсу. Далі на цій сторінці буде тільки цей шлях, бо твій сервіс на сервері працює з тим самим Python.

/opt/club/venv/bin/python -c "import hashlib; print(hashlib.sha256('sonechko7'.encode()).hexdigest())"
/opt/club/venv/bin/python -c "import hashlib; print(hashlib.sha256('sonechko7'.encode()).hexdigest())"
/opt/club/venv/bin/python -c "import hashlib; print(hashlib.sha256('Sonechko7'.encode()).hexdigest())"

Перші дві команди дадуть однаковий рядок із 64 символів — правило жорстке, той самий текст завжди дає той самий результат. Третя відрізняється від них однією великою літерою, а рядок вийде зовсім інший, без жодної схожості. Саме на цих двох властивостях і будується перевірка пароля.

Вхід відбувається так: людина надсилає пароль, сервер обчислює його хеш і порівнює з тим, що лежить у базі. Збіглося — пароль правильний. При цьому сам пароль сервер ніде не залишає: у базі як лежав хеш, так і лежить.

Питання на розуміння

У новинах написали, що з форуму, де ти зареєстрований, витекла таблиця користувачів. У стовпці пароля там рядки виду $2b$12$Kx... завдовжки рівно 60 символів. Що це означає для того, хто цю таблицю отримав?

3. Сіль і чому sha256 для паролів мало

З sha256, який ти щойно запускав, є дві проблеми, і обидві практичні.

Перша: він швидкий. Звичайний комп’ютер рахує сотні мільйонів таких хешів за секунду. Для перевірки цілості файлів, задля якої sha256 і зроблений, це добре. Для паролів швидкість шкідлива: той, хто перебирає варіанти, перевіряє їх із такою самою швидкістю.

Друга: однаковий пароль дає однаковий хеш завжди й у всіх. Тобто в украденій таблиці видно, що двадцять людей мають один і той самий пароль. А ще заздалегідь порахований словник популярних паролів разом із їхніми хешами відкриває всі ці записи одним пошуком.

Обидві проблеми розв’язує bcrypt — алгоритм, зроблений спеціально для паролів. Він робить дві речі:

Запусти двічі й порівняй результат:

/opt/club/venv/bin/python -c "import bcrypt; print(bcrypt.hashpw(b'sonechko7', bcrypt.gensalt()).decode())"
/opt/club/venv/bin/python -c "import bcrypt; print(bcrypt.hashpw(b'sonechko7', bcrypt.gensalt()).decode())"

Пароль той самий, а рядки різні — сіль щоразу нова. При цьому перевірка спрацює для обох, бо сіль лежить усередині самого рядка. Ось із чого він складається:

$2b$12$eImiTXuWVxfM37uY4JANjQ.O5AbcDefGhIjKlMnOpQrStUvWxYz01

$2b$              назва алгоритму — bcrypt
12                скільки разів рахувати: 2 у 12-му степені, тобто 4096 повторів
наступні 22       сіль
останній 31       сам хеш

Разом рівно 60 символів. Початок $2b$ — це позначка версії bcrypt: її ставить та бібліотека, з якою ми працюємо. У старіших рядках трапляються ще $2a$ і $2y$ — той самий алгоритм із дрібними відмінностями. Автоматика курсу перевіряє саме $2b$, бо саме його дає наша бібліотека; за цим початком і довжиною видно, що в базі лежить хеш.

Питання «а чи не можна написати хешування самому» має коротку відповідь: ні. bcrypt роками перевіряли фахівці з безпеки, усі знайдені помилки в ньому виправлені, і власна реалізація до цієї перевіреності нічого не додасть.

Питання на розуміння

Андрій і Соломія домовилися й поставили у своїх сервісах абсолютно однаковий пароль. Вони відкрили свої бази, подивилися стовпець password_hash і побачили два різні рядки, хоча обидва починаються з $2b$12$. Чому рядки не збіглися?

4. Як сервер пам’ятає, що ти вже увійшов: кука і сесія

Є деталь, через яку тема здається складнішою, ніж вона є. HTTP — набір правил, за якими браузер спілкується з сервером, — не має пам’яті. Кожен запит приходить окремо, сам по собі, і сервер не знає, чи бачив він цього відвідувача хвилину тому. Відкрив сторінку — запит. Натиснув кнопку — новий запит, і для сервера він ніяк не пов’язаний із попереднім.

Якби пам’яті не було зовсім, пароль довелося б надсилати з кожним запитом. Тому роблять інакше: пароль перевіряють один раз, а далі сервер видає браузеру короткий рядок, який діє обмежений час і замінює пароль у наступних запитах.

Кука
Короткий рядок, який сервер надсилає браузеру у відповіді, а браузер зберігає й сам додає до кожного наступного запиту на цю адресу. Писати для цього нічого не треба — браузер робить це самостійно.
Сесія
Проміжок часу, поки сервер вважає відвідувача тим, хто вже ввів пароль. У нас це 12 годин: увійшов уранці — до вечора сервіс тебе впізнає, наступного дня доведеться ввести пароль знову.

Тепер головне питання: що покласти в куку, щоб її не можна було підробити. Якщо записати просто bohdan.g9, будь-хто відкриє інструменти розробника, впише туди свій рядок — і сервер повірить.

Тому в куку кладуть три частини: логін, час закінчення сесії й підпис. Підпис — це хеш від перших двох частин разом із секретним ключем, який знає тільки твій сервер:

bohdan.g9:1800000000:9Kx2p_wQ7hRt4mLdVzB1cYsNfGjE8aUoI3rTnP0qXeM

Середнє число — час закінчення сесії, записаний так, як його зберігає комп’ютер: скільки секунд минуло від 1 січня 1970 року. 1800000000 — це 15 січня 2027 року.

Отримавши таку куку, сервер бере перші дві частини, рахує підпис заново своїм ключем і порівнює. Хтось поміняв логін на чужий або відсунув час на місяць уперед — підпис перестає збігатися, і сервер відповідає «увійди». Підібрати правильний підпис без ключа не вийде: для цього треба знати сам ключ.

Ще одна деталь, яку ми ввімкнемо: кука позначається як HttpOnly. Це означає, що код на сторінці її не прочитає — вона доступна лише браузеру, який надсилає її серверу. Якщо на сторінку колись потрапить чужий скрипт, прочитати цю куку він не зможе.

Питання на розуміння

Олег увійшов у свій сервіс на уроці, закрив вкладку, а ввечері вдома відкрив сайт знову з того самого ноутбука — пароля не питали, кнопки «видалити» на місці. Його сусід у той самий вечір відкрив цю саму адресу зі свого телефона й кнопок не побачив. Що відрізняє ці два випадки?

5. Що закривати, а що лишити відкритим

Закрити треба зміни. Перегляд лишається доступним усім: якщо пароль питати й за читання, сайт не побачить ніхто, крім тебе, і показувати його буде нікому.

Перед розподілом варто знати, як запит узагалі доходить до твого коду. nginx — програма на сервері, яка приймає всі запити з інтернету й передає кожен потрібній програмі за адресою: усе, що починається на /u/bohdan.g9/, вона віддає твоєму сервісу. Сам сервіс працює на сервері як служба — програма, яку система запускає сама, тримає ввімкненою постійно й перезапускає за командою. Усе, що служба друкує під час роботи, система складає в журнал: там видно рядки про запуск і текст помилок. У практиці ти й перезапускатимеш службу, і читатимеш журнал.

Ендпоінт — пара «метод плюс адреса», яку вміє обробляти твій сервіс. GET /api/habits і POST /api/habits — два різні ендпоінти з однаковою адресою, і закривати їх можна окремо. Розподіл на сьогодні такий:

ЕндпоінтХто можеЧому
GET /api/habitsбудь-хтоЦе вміст сайту. Його дивляться друзі, рідні й автоматика курсу.
POST /api/habitsлише власникДодає запис у базу.
DELETE /api/habits/{id}лише власникСтирає запис назавжди.
POST /api/loginбудь-хтоЦе і є вхід. Якби він був закритий, увійти не міг би ніхто.
GET /api/pingбудь-хтоПеревірка, що сервіс відповідає. Її робить автоматика курсу без пароля.

Тепер про те, як перевірку прикріплюють до ендпоінта. У FastAPI для цього є залежність — функція, яку фреймворк викликає перед тілом ендпоінта. Якщо вона відмовляє, тіло ендпоінта не виконується взагалі: запит зупиняється на вході. Нашу залежність зватимуть require_owner; у практиці ти додаси її до POST і DELETE, а GET лишиш без неї.

Коли гість намагається зробити те, на що не має права, сервер відповідає кодом. Коди відповідей ти бачив на занятті 05, тут додаються три нові:

401
«Не авторизовано». Дослівно означає: я не знаю, хто ти — увійди й спробуй ще. Саме цю відповідь отримає той, хто надсилає POST без куки.
403
«Заборонено». Означає: я знаю, хто ти, і саме тобі цього не можна. Ця відповідь знадобиться, коли в сервісі буде більше одного користувача. Сьогодні власник один, тому в нас скрізь 401.
422
«Тіло запиту не підходить». Так FastAPI відповідає, коли в надісланих даних інші назви полів або інші типи значень, ніж описано в моделі. До входу цей код стосунку не має, але в практиці він трапляється, тому знай, що він означає.
Питання на розуміння

Ліза закрила автентифікацією всі ендпоінти підряд, включно з GET /api/habits. Сама вона входить і все бачить. Мама відкрила сайт зі свого телефона й побачила порожню сторінку, а в журналі служби проти її запиту стоїть 401. Що тут не так?

Сховати кнопку — це не захист

Далі в практиці ти зробиш так, щоб гість не бачив форми додавання й кнопок «видалити»: вони будуть сховані стилем CSS. Це зручність, а не захист. Стилі й розмітка виконуються в браузері відвідувача, і він може змінити там усе: відкрити інструменти розробника (клавіша F12), прибрати правило, що ховає кнопку, і натиснути її.

Відмовити у видаленні може перевірка на сервері: до неї відвідувач не дістає, бо код сервера виконується не в його браузері. Це та сама думка, що й на занятті 05 із валідацією: атрибут required у формі підказує людині, поки вона заповнює поля, а рішення про запис ухвалює сервер.

Питання на розуміння

Тарас відкрив сайт однокласниці. Кнопок «видалити» не видно — вони сховані через CSS. Він натиснув F12, знайшов правило, яке їх ховає, вимкнув його, кнопка з’явилася, і він на неї натиснув. Ендпоінт DELETE у неї закритий залежністю require_owner. Що станеться?

6. Секрет і файл .env

Секрет — будь-яке значення, яке дає доступ і тому не має бути відоме нікому, крім твоєї програми. Сьогодні це ключ, яким підписуються куки. Далі в житті це паролі до баз даних, ключі до платіжних систем, токени ботів. Токен тут — довгий випадковий рядок, який програма надсилає сервісу замість пароля, щоб той її впустив.

Секрет не пишуть у коді, і причина конкретна: код лежить у git, а git зберігає всю історію. Нагадаю два слова з заняття 03. Репозиторій — тека проєкту разом із прихованою текою .git, у якій git тримає всі попередні стани файлів. Коміт — збережений стан усіх файлів проєкту на певний момент, із датою, автором і твоїм описом зміни; історія проєкту складається саме з комітів, і кожен із них можна відкрити пізніше.

Тому файл, який хоч раз потрапив у коміт, читається звідти назавжди — навіть якщо наступним комітом ти його прибрав. Один рядок дістає стару версію:

git show HEAD~5:main.py

Тому секрети виносять у файл .env поруч із кодом, а сам файл додають до .gitignore — списку того, що git не відстежує. Назва .env — від слова environment, «оточення»: усередині звичайний текст, по рядку на значення.

SESSION_SECRET=8f3c1a94e07b25d6f8a1c0b73e9d4526a7f0c8b1d2e3f4a596071829304a5b6c

Крапка на початку назви означає, що файл прихований: ls його не покаже, потрібно ls -a. Це не захист, просто традиція для службових файлів.

Порядок дій тут важливий, і саме на ньому найчастіше помиляються. Спочатку рядок у .gitignore, потім створення файлу з секретом. Навпаки — і є всі шанси зробити git add ., не подивившись, що саме додається.

Якщо секрет усе-таки потрапив у коміт Прибрати файл наступним комітом недостатньо: у старому коміті він лишається. Перше, що треба зробити, — вважати цей секрет відомим стороннім і згенерувати новий. Чистити історію теж треба, але це друга дія, а не перша. Як саме — у розділі «Якщо щось не працює».
Питання на розуміння

Ігор написав ключ прямо в main.py і зробив коміт. Через тиждень він переніс ключ у .env, додав .env до .gitignore і закомітив зміну з повідомленням «прибрав секрет із коду». Ключ лишився тим самим. Чи досить цього?

Практика

У прикладах — таблиця habit з полями id, title, days і ендпоінти з заняття 05. Підставляй свої назви: вони записані у project.yaml у розділах database й endpoints.

1

Спершу закрити .env у .gitignore

Робимо це до того, як файл із секретом узагалі з’явиться.

ssh bohdan.g9@91.219.61.4
cd ~/app
git check-ignore -v .env

git check-ignore -v питає в git: «чи ігноруєш ти цей файл, і через яке правило». Якщо в заготовці все на місці, у відповідь буде рядок на кшталт .gitignore:11:.env .env — тобто правило знайдено в одинадцятому рядку файлу. Якщо у відповідь порожньо, правила немає, додай його:

echo ".env" >> .gitignore
git add .gitignore
git commit -m "gitignore: закрив .env до того, як його створив"

>> дописує рядок у кінець файлу. Одна кутова дужка > замість двох стерла б увесь файл і лишила там один рядок, тому дужок тут саме дві.

2

Створити .env із секретом

cd ~/app
/opt/club/venv/bin/python -c "import secrets; print('SESSION_SECRET=' + secrets.token_hex(32))" > .env
chmod 600 .env
cat .env

secrets.token_hex(32) дає 64 випадкові символи. Модуль secrets — це стандартний модуль Python саме для таких значень: на відміну від звичного random, його результат не можна передбачити, знаючи попередні. Ключ вигадувати самому не треба, і брати щось «легше запам’ятати» теж не треба — цей рядок ніхто не вводитиме руками.

chmod 600 ставить права «читати й писати може лише власник файлу». Інші користувачі сервера — а на ньому вся твоя група — не прочитають його навіть із цікавості.

Тепер перевір найважливіше: git не має бачити цього файлу.

git status --short

У списку не повинно бути жодного рядка з .env. Якщо він там є з позначкою ?? — вернись до кроку 1, правило в .gitignore не спрацювало.

3

Додати таблицю власника в базу

sqlite3 ~/data/app.db "CREATE TABLE IF NOT EXISTS owner (
  id            INTEGER PRIMARY KEY,
  login         TEXT NOT NULL UNIQUE,
  password_hash TEXT NOT NULL
);"
sqlite3 ~/data/app.db ".schema owner"

Стовпець називається password_hash, а не password — щоб, коли через місяць відкриєш цей код, не було жодного сумніву, що там лежить. UNIQUE означає, що двох рядків з однаковим логіном база не дозволить. IF NOT EXISTS дозволяє виконати команду вдруге: якщо таблиця вже створена, sqlite3 просто нічого не робить і помилки не буде. Це знадобиться, якщо доведеться повернутися до цього кроку.

Друга команда друкує те, що реально створилося. Якщо у відповідь порожньо — таблиця не створилася, дивись текст помилки вище.

4

Створити файл auth.py

Це готова заготовка. Створи файл і встав текст цілком, нічого в ньому не змінюючи.

nano ~/app/auth.py
"""Автентифікація. Готова заготовка — цей файл не редагуємо.

Дає чотири речі:
  hash_password / check_password — зробити хеш пароля і звірити з ним введений
  make_session                   — вміст куки після успішного входу
  current_owner                  — хто увійшов, або None; помилки не викидає
  require_owner                  — залежність, що закриває ендпоінт: гість дістає 401
"""
import base64
import hashlib
import hmac
import os
import time
from pathlib import Path

import bcrypt
from fastapi import Cookie, HTTPException

COOKIE_NAME = "club_session"
SESSION_HOURS = 12


def _load_env(path):
    """.env — звичайний текстовий файл, по рядку «КЛЮЧ=значення»."""
    if not path.exists():
        return
    for line in path.read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, value = line.split("=", 1)
        os.environ[key.strip()] = value.strip()


_load_env(Path(__file__).parent / ".env")
SECRET = os.environ.get("SESSION_SECRET", "")
if not SECRET:
    raise RuntimeError("У файлі ~/app/.env немає рядка SESSION_SECRET")


def hash_password(plain):
    """З пароля робить хеш, який можна зберігати в базі."""
    return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt()).decode("ascii")


def check_password(plain, stored):
    """Звіряє введений пароль із хешем із бази. Повертає True або False."""
    try:
        return bcrypt.checkpw(plain.encode("utf-8"), stored.encode("ascii"))
    except (ValueError, AttributeError):
        return False


def _sign(data):
    """Підпис рядка секретом із .env."""
    mac = hmac.new(SECRET.encode("utf-8"), data.encode("utf-8"), hashlib.sha256)
    return base64.urlsafe_b64encode(mac.digest()).decode("ascii").rstrip("=")


def make_session(login):
    """Вміст куки: логін, час закінчення і підпис."""
    until = int(time.time()) + SESSION_HOURS * 3600
    data = login + ":" + str(until)
    return data + ":" + _sign(data)


def _read_session(value):
    """Розбирає куку. Повертає логін або None, якщо кука підроблена чи стара."""
    parts = (value or "").split(":")
    if len(parts) != 3:
        return None
    login, until, sign = parts
    expected = _sign(login + ":" + until)
    if not hmac.compare_digest(sign.encode("utf-8"), expected.encode("ascii")):
        return None
    try:
        if int(until) < time.time():
            return None
    except ValueError:
        return None
    return login


def current_owner(club_session: str = Cookie(default="")):
    """Логін того, хто увійшов, або None."""
    return _read_session(club_session)


def require_owner(club_session: str = Cookie(default="")):
    """Залежність FastAPI: гість сюди не проходить."""
    login = _read_session(club_session)
    if not login:
        raise HTTPException(status_code=401, detail="Потрібно увійти")
    return login

Що тут відбувається

5

Записати свій пароль у базу

Пароль ніде не набирається як частина команди. Причин дві. Усе, що ти набрав у терміналі, записується у файл історії ~/.bash_history і читається звідти будь-коли. І поки команда виконується, її рядок разом з усіма значеннями видно в списку процесів (ps aux) кожному, хто зайшов на цей сервер, — а на ньому вся твоя група. Тому пишемо маленький скрипт, який питає пароль окремо й не показує його на екрані.

nano ~/app/set-password.py
"""Записує власника і хеш його пароля в базу. Запускати з теки ~/app."""
import getpass
import sys

from auth import hash_password
from db import query, execute

login = input("Логін власника: ").strip()
first = getpass.getpass("Пароль: ")
again = getpass.getpass("Ще раз: ")

if first != again:
    sys.exit("Паролі не збігаються, нічого не записано")
if len(first) < 8:
    sys.exit("Пароль коротший за 8 символів, візьми довший")
if len(first.encode("utf-8")) > 72:
    sys.exit("Пароль довший за 72 байти — bcrypt такий не бере, візьми коротший")

stored = hash_password(first)

if query("SELECT id FROM owner WHERE login = ?", (login,)):
    execute("UPDATE owner SET password_hash = ? WHERE login = ?", (stored, login))
    print("Пароль оновлено для", login)
else:
    execute("INSERT INTO owner (login, password_hash) VALUES (?, ?)", (login, stored))
    print("Власника додано:", login)

Запускаємо. Шлях до Python той самий, що й у теорії: /opt/club/venv/bin/python, бо bcrypt лежить саме в цьому оточенні. Команду cd ~/app пропускати не можна: скрипт бере auth.py і db.py з поточної теки, а db.py шукає там-таки project.yaml зі шляхом до бази.

cd ~/app
/opt/club/venv/bin/python set-password.py

getpass.getpass не показує символи під час набору — це нормально, курсор просто стоїть на місці. Логін бери свій, bohdan.g9. Пароль вигадай новий: не той, що від пошти чи від акаунта в грі. Вісім символів — це нижня межа, яку приймає скрипт; бери довше, фразу з кількох слів. Верхня межа теж є: bcrypt працює щонайбільше з 72 байтами пароля, а українська літера займає два байти, тож 36 українських літер — це вже межа. Скрипт про це попередить і нічого не запише.

Перевір, що в базі опинилося:

sqlite3 ~/data/app.db "SELECT login, password_hash FROM owner;"

Ти маєш побачити свій логін і рядок, що починається з $2b$12$. Якщо там видно сам пароль — щось пішло не так, повернись до кроку 4.

6

Підключити вхід у main.py

nano ~/app/main.py

Спочатку імпорти у верхній частині файлу. Було:

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field

from db import query, execute

Стало:

from fastapi import FastAPI, HTTPException, Depends, Response
from pydantic import BaseModel, Field

from db import query, execute
from auth import (COOKIE_NAME, SESSION_HOURS, check_password, current_owner,
                  make_session, require_owner)

Depends — те, чим позначають залежність. Response — об’єкт відповіді; він потрібен, щоб покласти в неї куку.

Далі додай нижче в файлі три ендпоінти. Рядок із COOKIE_PATH постав перед ними:

COOKIE_PATH = "/u/bohdan.g9/"        # ← свій логін


class LoginData(BaseModel):
    login: str = Field(min_length=1, max_length=64)
    password: str = Field(min_length=1, max_length=200)


@app.post("/api/login")
def login(data: LoginData, response: Response):
    """Перевіряє пароль і видає куку сесії."""
    rows = query("SELECT * FROM owner WHERE login = ?", (data.login,))
    if not rows or not check_password(data.password, rows[0]["password_hash"]):
        raise HTTPException(status_code=401, detail="Невірний логін або пароль")

    response.set_cookie(
        COOKIE_NAME,
        make_session(data.login),
        max_age=SESSION_HOURS * 3600,
        httponly=True,
        samesite="lax",
        path=COOKIE_PATH,
    )
    return {"ok": True, "login": data.login}


@app.post("/api/logout")
def logout(response: Response):
    """Прибирає куку — далі сервіс бачить звичайного гостя."""
    response.delete_cookie(COOKIE_NAME, path=COOKIE_PATH)
    return {"ok": True}


@app.get("/api/me")
def me(owner=Depends(current_owner)):
    """Сторінка питає це при відкритті: показувати кнопки чи форму входу."""
    return {"logged_in": owner is not None, "login": owner}

Чотири деталі, які тут не випадкові

Про secure У списку немає параметра secure=True, який велить браузеру надсилати куку лише через захищене з’єднання. Зараз сайт працює по звичайному http, і з цим параметром кука не дійшла б узагалі. Повернемося до нього на занятті 10, коли адреса стане https.
7

Закрити POST і DELETE

Знайди свої ендпоінти з заняття 05 і додай в кожен один аргумент. Було:

@app.post("/api/habits")
def create_habit(item: NewHabit):

Стало:

@app.post("/api/habits")
def create_habit(item: NewHabit, owner: str = Depends(require_owner)):

Так само для видалення:

@app.delete("/api/habits/{habit_id}")
def delete_habit(habit_id: int, owner: str = Depends(require_owner)):

Тіло функцій не змінюється, змінна owner усередині поки не потрібна. Коли запит приходить без правильної куки, require_owner зупиняє його раніше, ніж почне виконуватися перший рядок функції.

Ендпоінт списку не чіпай. GET /api/habits, GET /api/ping і POST /api/login лишаються без залежності — інакше сайт перестане відкриватися для всіх, включно з автоматикою курсу.

8

Записати нове у project.yaml

Автоматика не вгадує назв — вона читає їх звідси. Заповни два місця:

nano ~/app/project.yaml
endpoints:
  list:   "GET /api/habits"           # уже є з заняття 05
  create: "POST /api/habits"          # уже є з заняття 05
  delete: "DELETE /api/habits/{id}"   # уже є з заняття 05
  login:  "POST /api/login"           # додаєш сьогодні

auth:                                 # цього розділу ще немає, додаєш цілком
  table: owner
  password_field: password_hash

Це той самий файл, який ти заповнював на заняттях 01, 04 і 05. Розділ endpoints у ньому вже є: у нього дописуєш один рядок login. Другий розділ із назвою endpoints створювати не можна — два однакові ключі верхнього рівня ламають файл, і служба після цього не запуститься. Розділ auth новий, його додаєш цілком.

Рядки list, create і delete показані тут тому, що автоматика бере адреси саме з них: у критеріях нижче вони згадуються як endpoints.create і endpoints.list. Якщо якогось із цих рядків у тебе немає, допиши й його.

Відступи в YAML робляться пробілами; табуляція ламає файл. Текст після # — коментар, копіювати його не обов’язково.

9

Перезапустити службу

Твій сервіс запущено на сервері як службу з назвою club-api. Керує службами команда systemctl, а --user означає «служба належить моєму обліковому запису»: така служба працює від твого імені, і прав адміністратора для неї не потрібно. Поки ти не перезапустиш службу, вона виконує старий код — правки в main.py самі собою не застосовуються.

systemctl --user restart club-api
systemctl --user status club-api

У відповіді має бути active (running). Якщо там failed, читай журнал. journalctl друкує те, що служба вивела під час роботи; -u club-api каже, про яку саме службу, а -n 30 — скільки останніх рядків показати. Причину зупинки завжди видно в останніх рядках:

journalctl --user -u club-api -n 30

Швидка перевірка, що служба справді відповідає:

curl -s http://127.0.0.1/u/bohdan.g9/api/ping
10

Перевірити все командою curl

Тут ти повториш руками рівно те, що робитиме автоматика курсу. Параметр -i друкує заголовки відповіді разом із кодом, тому він стоїть майже в кожній команді.

Перше: анонімний POST має отримати 401.

curl -i -X POST http://127.0.0.1/u/bohdan.g9/api/habits \
  -H "Content-Type: application/json" \
  -d '{"title":"Спроба зайти без пароля","days":3}'

Заголовок Content-Type: application/json каже серверу, у якому вигляді записане тіло запиту. JSON — текстовий формат для передавання даних: значення пишуться парами «назва: значення» у фігурних дужках, текст — у подвійних лапках, числа — без них. Саме в цьому форматі твій сервіс приймає й повертає дані з заняття 05.

Перший рядок відповіді має бути HTTP/1.1 401 Unauthorized. Якщо там 200 — залежність не додалася, вернись до кроку 7. Якщо 422 — сервер устиг перевірити поля тіла запиту, а отже залежності на ендпоінті немає: коли вона є, вона зупиняє анонімний запит раніше, ніж черга доходить до полів, і код буде 401 навіть при неправильних назвах полів. Ще 422 буває, якщо JSON у команді набрано з помилкою — наприклад, загублено лапку.

Друге: GET має працювати без входу.

curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1/u/bohdan.g9/api/habits

Тут -o /dev/null викидає тіло відповіді, а -w "%{http_code}" друкує самий лише код. Має надрукуватися 200.

Третє: вхід. Пароль не має стати частиною команди curl: розгорнутий рядок команди видно в ps aux усім, хто зайшов на цей сервер. Тому тіло запиту складає Python і передає його curl напряму:

read -s -p "Пароль: " PW; echo
PW="$PW" /opt/club/venv/bin/python -c "import json, os; print(json.dumps({'login': 'bohdan.g9', 'password': os.environ['PW']}))" \
  | curl -i -c ~/cookies.txt -X POST http://127.0.0.1/u/bohdan.g9/api/login \
      -H "Content-Type: application/json" -d @-

Розберемо по частинах. read -s читає рядок і не показує його на екрані. PW="$PW" перед командою передає значення тільки цьому запуску Python. json.dumps складає з логіна й пароля правильне тіло запиту — воно лишиться правильним, навіть якщо в паролі є лапки або зворотна коса риска. Вертикальна риска | віддає цей текст наступній команді, а -d @- означає «взяти тіло запиту з того, що прийшло по цьому каналу». -c ~/cookies.txt велить curl зберегти отриману куку у файл. У відповіді має бути 200 і заголовок set-cookie: club_session=... — саме його браузер у звичайній роботі запам’ятовує сам.

Четверте: той самий POST, але з кукою.

curl -i -b ~/cookies.txt -X POST http://127.0.0.1/u/bohdan.g9/api/habits \
  -H "Content-Type: application/json" \
  -d '{"title":"Запис від власника","days":3}'

-b — це «взяти куки з файлу й додати до запиту». Тепер має бути 200 і новий запис у відповіді. Прибери за собою:

unset PW
rm ~/cookies.txt
11

Форма входу на сторінці

Сторінка лежить у ~/www/index.html. Редагувати її можна двома способами. Перший — прямо на сервері командою nano ~/www/index.html. Другий — у звичному редакторі на своєму комп’ютері, під’єднавшись до сервера по FTP. FTP — набір правил для передавання файлів між комп’ютером і сервером; редактор, під’єднаний по FTP, показує файли сервера так, ніби вони лежать у тебе на комп’ютері. У налаштуваннях з’єднання постав адресу 91.219.61.4, порт 21 і шифрування «явний FTP через TLS». TLS — те саме шифрування, що працює в адресах https: воно закриває від сторонніх у мережі і пароль, і вміст файлів. Логін і пароль ті самі, якими ти заходиш на сервер по SSH, тобто командою ssh bohdan.g9@91.219.61.4 з кроку 1.

Розмітка форми. Кожне поле має свій <label> — вимога з заняття 06 діє й тут:

<section id="owner-box">
  <form id="login-form">
    <label for="f-login">Логін</label>
    <input id="f-login" type="text" autocomplete="username">

    <label for="f-pass">Пароль</label>
    <input id="f-pass" type="password" autocomplete="current-password">

    <button type="submit">Увійти</button>
  </form>
  <button type="button" id="logout-btn" class="only-owner">Вийти</button>
  <p id="login-msg"></p>
</section>

type="password" ховає символи крапками під час набору. autocomplete підказує браузеру, що це за поле, — тоді він пропонує зберегти пароль і підставити його наступного разу.

Тепер правила стилю. Додай їх у <style> своєї сторінки:

.only-owner { display: none; }
body.is-owner .only-owner { display: inline-block; }
body.is-owner form.only-owner { display: block; }
body.is-owner #login-form { display: none; }

Класом only-owner познач те, що має бачити лише власник: форму додавання запису й кнопки «видалити». Третє правило потрібне саме для форми. Тег <form> — блоковий, він займає всю ширину рядка; з display: inline-block із другого правила форма після входу стиснулася б по вмісту й стала поруч із сусіднім текстом. У третьому правилі названо ще й сам тег form, тому браузер вважає його точнішим і застосовує до форми саме його.

Кнопки «видалити» створюються кодом, тож у блоці з заняття 05 до рядка del.textContent = 'видалити'; додай ще один: del.classList.add('only-owner');. Тут потрібен саме classList.add: присвоєння del.className = 'only-owner' замінило б усі класи кнопки, зокрема ті, які ти поставив їй на занятті 05.

Готовий блок для входу. Додається в кінці сторінки, після блоку з заняття 05, перед закривальним </body>:

<script>
const AUTH = '/u/bohdan.g9/api';          // ← свій логін

const loginForm = document.getElementById('login-form');
const loginMsg  = document.getElementById('login-msg');
const logoutBtn = document.getElementById('logout-btn');

async function refreshOwner() {
  const res = await fetch(AUTH + '/me');
  const data = await res.json();
  document.body.classList.toggle('is-owner', data.logged_in);
  loginMsg.textContent = data.logged_in ? 'Ви увійшли як ' + data.login : '';
}

loginForm.addEventListener('submit', async function (event) {
  event.preventDefault();
  const res = await fetch(AUTH + '/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      login: document.getElementById('f-login').value,
      password: document.getElementById('f-pass').value
    })
  });
  document.getElementById('f-pass').value = '';
  if (res.ok) {
    await refreshOwner();
  } else {
    loginMsg.textContent = 'Невірний логін або пароль';
  }
});

logoutBtn.addEventListener('click', async function () {
  await fetch(AUTH + '/logout', { method: 'POST' });
  await refreshOwner();
});

refreshOwner();
</script>

Поясни цей блок

Перевір у браузері: відкрий http://91.219.61.4/u/bohdan.g9/. До входу форми додавання й кнопок «видалити» немає, список видно. Після входу вони з’являються без перезавантаження. Потім відкрий цю саму адресу в приватному вікні: там куки немає, тож сторінка має виглядати як для гостя.

12

Зберегти зміни в git і ще раз перевірити .env

cd ~/app
git status --short
git add auth.py set-password.py main.py project.yaml .gitignore
git commit -m "auth: вхід за паролем, POST і DELETE лише для власника"
git push origin main

У git status файлу .env бути не повинно взагалі. Файли перелічуй по одному, як тут: git add . додає все підряд, і саме так секрети найчастіше й потрапляють у коміти.

Остаточна перевірка — та сама, яку зробить автоматика. Команда шукає .env в усій історії, у всіх гілках:

git log --all --oneline -- .env

Порожня відповідь — усе гаразд, файл ніколи не був у комітах. Якщо вивелися рядки з комітами, переходь до розділу «Якщо щось не працює», пункт про секрет в історії.

Сторінку в ~/www зберігай окремо, якщо ти підключав цю теку до git на минулих заняттях.

13

Перевірка сусідом

Обміняйтеся адресами сайтів. Сусід зі свого місця виконує дві команди проти твого сервісу — підставляючи твій логін в адресу:

curl -s -o /dev/null -w "GET  %{http_code}\n" http://91.219.61.4/u/bohdan.g9/api/habits
curl -s -o /dev/null -w "POST %{http_code}\n" -X POST http://91.219.61.4/u/bohdan.g9/api/habits \
  -H "Content-Type: application/json" -d '{"title":"сусід","days":1}'

Правильний результат: GET 200 і POST 401. Якщо POST повернув 200 — сусід щойно додав запис у твою базу, і критерій не зарахується.

Ще одна перевірка, вже цікавіша. Хай сусід увійде у свій сервіс, візьме свою куку й спробує з нею твій POST. Відповідь усе одно буде 401: підпис у його куці зроблено його ключем із його .env, а твій сервер рахує підпис своїм. Саме тому ключ у кожного власний і нікому не показується.

Що перевіряє автоматика

Чотири критерії заняття 07, усі чотири перевіряє програма. Перевірка запускається за розкладом, тому відмітка в кабінеті з’являється не в ту саму секунду, коли ти виконав команду. Адреси ендпоінтів програма бере з project.yaml, а назву таблиці власника й поле з паролем — із розділу auth.

КодКритерійЩо це означаєНе зараховано — що робити
L07.1 анонімний POST дає 401 Програма надсилає POST на адресу з endpoints.create без жодної куки й дивиться на код відповіді. Має бути рівно 401. Відповідь 200 означає, що будь-хто в мережі досі може дописувати у твою базу. Повтори перший запит із кроку 10 практики. Якщо прийшло 200 — в ендпоінті немає owner: str = Depends(require_owner) або правку не зберегли й службу не перезапустили. Якщо 422 — сервер дійшов до перевірки полів тіла запиту, а отже залежності на ендпоінті немає: із нею анонімний запит зупинився б раніше з кодом 401. Якщо 500 — читай journalctl --user -u club-api -n 30, найчастіша причина тут ImportError у рядку from auth import ....
L07.2 пароль у базі має вигляд хеша Програма читає стовпець, указаний у auth.password_field, і дивиться на формат значення: 60 символів, початок $2b$. Крім того, вона перевіряє, що значення не збігається з жодним із простих варіантів на кшталт самого логіна. Подивись, що лежить у базі: sqlite3 ~/data/app.db "SELECT login, password_hash FROM owner;". Якщо там видно пароль як текст — запис зроблено повз set-password.py, наприклад руками через INSERT. Перезапиши: /opt/club/venv/bin/python set-password.py. Якщо стовпець порожній, перевір, що в project.yaml у auth.password_field написана та сама назва, що в таблиці.
L07.3 .env відсутній у git-історії Програма виконує в твоєму репозиторії пошук по всій історії, а не лише по поточному стану файлів. Достатньо одного давнього коміта з .env, щоб критерій не зарахувався: секрет із такого коміта читається однією командою. Перевір сам: git log --all --oneline -- .env. Порожньо — усе гаразд, і справа в чомусь іншому: подивись, чи не потрапив ключ у main.py (git log -p -S SESSION_SECRET покаже коміти, де цей рядок з’являвся). Якщо коміти з .env є, дій за пунктом «Секрет потрапив у коміт» нижче.
L07.4 GET публічний і працює без входу Програма робить звичайний GET на адресу з endpoints.list без куки і чекає 200 із твоїм списком. Ця перевірка навмисно стоїть поруч із першою: закрити зміни легко, важче закрити тільки їх. Перевір: curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1/u/bohdan.g9/api/habits. Якщо 401 — на ендпоінті списку стоїть зайва залежність require_owner, прибери її. Якщо 404 — адреса в endpoints.list не збігається з тією, що в коді. Якщо 500 — служба запустилася, але зупиняється з помилкою вже на самому запиті; причину шукай у журналі.

Домашнє завдання

Основне. Спробуй зайти у свій сервіс так, як це зробила б стороння людина, і переконайся, що змінити нічого не вдається. Найпростіший спосіб — відкрити свій сайт у приватному вікні браузера або в іншому браузері: там немає твоєї куки, тож ти для сервісу звичайний гість.

Зроби щонайменше чотири спроби й після кожної подивись на код відповіді у вкладці Network (клавіша F12):

  1. Відкрити сторінку й переглянути список — має працювати.
  2. Прибрати через F12 правило, що ховає кнопку «видалити», і натиснути її.
  3. Надіслати POST командою curl без куки.
  4. Увійти з неправильним паролем і подивитися, що відповідає сервер.

Створи файл ~/app/SECURITY.md і запиши туди результати. Формат такий — по рядку на спробу, обов’язково з кодом відповіді:

# Що я перевіряв

## Спроба 1. Переглянути список без входу
Що робив: відкрив http://91.219.61.4/u/bohdan.g9/ у приватному вікні.
Відповідь сервера: GET /api/habits — 200, список видно.
Висновок: так і має бути, читання відкрите для всіх.

## Спроба 2. Натиснути «видалити», прибравши стиль через F12
Що робив: ...
Відповідь сервера: DELETE /api/habits/4 — 401.
Висновок: ...

Пиши те, що справді побачив, а не те, що мало статися. Якщо десь прийшло 200 там, де ти чекав 401, — це знахідка, і саме її треба записати. На занятті 08 ви шукатимете такі знахідки в сервісах одне одного, і цей файл — твій список того, що вже перевірено.

Додай файл у git:

cd ~/app
git add SECURITY.md
git commit -m "SECURITY: чотири спроби доступу без входу та відповіді сервера"

Друге. Заміни пароль на довший — фразу з трьох-чотирьох слів замість восьми символів. Тримайся межі bcrypt: 72 байти, а українська літера займає два байти, тож фраза має вкластися приблизно в 35 літер разом із пробілами. Якщо перебереш, скрипт скаже про це й нічого не запише. Переконайся, що після заміни старий пароль більше не підходить, а новий працює: так ти заразом перевіриш, що set-password.py оновлює наявний запис, а не додає другий.

cd ~/app
/opt/club/venv/bin/python set-password.py
sqlite3 ~/data/app.db "SELECT COUNT(*) FROM owner;"

Команда cd ~/app тут обов’язкова з тієї самої причини, що й у кроці 5: скрипт бере auth.py, db.py і project.yaml з поточної теки. Без неї він або зупиниться з помилкою, або звернеться не до тієї бази.

Друга команда має надрукувати 1. Якщо 2 — ти ввів інший логін, і в базі тепер два власники; зайвий рядок прибери.

Якщо щось не працює

Служба не запускається після підключення auth.py

systemctl --user status club-api показує failed. Причина завжди в останніх рядках журналу:

journalctl --user -u club-api -n 30

Вхід через форму проходить, але наступний POST усе одно 401

Кука видана, але не повертається назад. Відкрий F12, вкладку Application (у Firefox — Storage), розділ Cookies. Якщо club_session там є, дивись його стовпець Path.

Найчастіша причина — у COOKIE_PATH написано не те. Значення має точно збігатися з адресою сторінки: /u/bohdan.g9/, зі своїм логіном і з косими рисками з обох боків. Якщо там лишилося bohdan.g9 зі зразка, кука зберігається під чужий шлях, і до запитів твого сервісу браузер її не додає.

Якщо куки в списку немає взагалі, подивись у вкладці Network відповідь на /api/login: чи є в ній заголовок set-cookie. Якщо заголовка немає, у коді ендпоінта login немає аргументу response: Response або рядка response.set_cookie(...).

curl каже 401 на вхід, хоча пароль правильний

Перевір по черзі три речі. Перша — чи є взагалі запис у базі й чи той у ньому логін:

sqlite3 ~/data/app.db "SELECT id, login FROM owner;"

Логін має збігатися символ у символ із тим, що ти надсилаєш, — без пробілів на кінці й з тією самою крапкою перед номером групи.

Друга — чи справді в базі хеш:

sqlite3 ~/data/app.db "SELECT password_hash FROM owner;"

Рядок має починатися з $2b$12$ і мати 60 символів. Якщо там текст пароля, запис робили не через set-password.py.

Третя — чи не помилився ти в самому паролі. Перевір його окремо, без сервісу:

cd ~/app
read -s -p "Пароль: " PW; echo
PW="$PW" /opt/club/venv/bin/python -c "
import os, bcrypt
from db import query
row = query('SELECT password_hash FROM owner')[0]
print(bcrypt.checkpw(os.environ['PW'].encode(), row['password_hash'].encode()))
"
unset PW

PW="$PW" перед командою передає значення тільки цьому запуску Python і нікуди більше. Має надрукуватися True. Якщо False — пароль інший, ніж ти думаєш.

Простіше й надійніше — просто перезаписати пароль скриптом set-password.py ще раз.

Секрет потрапив у коміт

Порядок дій саме такий, і перший пункт найважливіший.

1. Замінити секрет. Старий вважай відомим стороннім, він більше не секрет. У .env може бути й інше, крім SESSION_SECRET, тому файл не перезаписуємо цілком, а міняємо в ньому один рядок:

cd ~/app
touch ~/env.new
chmod 600 ~/env.new
grep -v '^SESSION_SECRET=' .env >> ~/env.new
/opt/club/venv/bin/python -c "import secrets; print('SESSION_SECRET=' + secrets.token_hex(32))" >> ~/env.new
mv ~/env.new .env
cat .env
systemctl --user restart club-api

grep -v переписує в новий файл усі рядки, крім старого SESSION_SECRET; далі туди дописується новий ключ, і новий файл стає на місце старого. Проста команда ... > .env з однією кутовою дужкою стерла б решту рядків .env разом зі старим ключем.

Після заміни всі раніше видані куки перестають діяти — доведеться увійти заново. Так і має бути.

2. Прибрати файл з історії. Якщо він потрапив тільки в останній коміт:

git rm --cached .env
git commit --amend --no-edit

--cached прибирає файл з-під нагляду git, не видаляючи його з диска. --amend переписує останній коміт замість того, щоб робити новий.

Якщо цей коміт ти вже відправляв на сервер командою git push origin main (крок 12), то після --amend звичайний git push сервер відхилить: на сервері лежить попередня версія цього коміта, і git не замінює її мовчки. Тоді потрібно:

git push --force origin main

--force велить серверу взяти твою версію історії замість тієї, що в нього збережена. Робити так можна лише у своєму репозиторії й лише коли з ним більше ніхто не працює.

Якщо коміт із .env глибше в історії, треба пройти по всіх комітах:

FILTER_BRANCH_SQUELCH_WARNING=1 git filter-branch --force --index-filter \
  "git rm --cached --ignore-unmatch .env" --prune-empty -- --all
rm -rf .git/refs/original
git push --force origin --all

Команду filter-branch сучасний git вважає застарілою. Без рядка FILTER_BRANCH_SQUELCH_WARNING=1 він спершу друкує довге попередження й чекає близько десяти секунд, перш ніж почати роботу; це не зависання, але з цим рядком ні попередження, ні паузи не буде.

Команда переписує історію, тому номери всіх комітів зміняться. Роби її лише у своєму репозиторії й лише коли впевнений, що .env справді там є. Після цього ще раз перевір: git log --all --oneline -- .env — команда має нічого не вивести.

Сторінка показує кнопки гостю або ховає їх від власника

Відкрий F12, вкладку Network, знайди запит me і подивись його відповідь.

Якщо там {"logged_in": false}, а ти щойно увійшов, — проблема в куці, дивись пункт про 401 після входу вище.

Якщо відповідь правильна, а кнопки все одно не такі, як треба, справа в класі. Перевір три речі: клас only-owner справді стоїть на формі додавання; рядок del.classList.add('only-owner'); додано в блок із заняття 05; у стилях є всі три правила — .only-owner { display: none; }, body.is-owner .only-owner { ... } і body.is-owner form.only-owner { ... }. Якщо в консолі (вкладка Console) є рядок Cannot read properties of null, код не знайшов елемент: id у розмітці написано інакше, ніж у getElementById.

sqlite3 пише «no such table: owner»

Таблиця не створилася або створилася в іншому файлі бази. Подивись, що взагалі є в твоїй базі:

sqlite3 ~/data/app.db ".tables"

Якщо owner у списку немає, повтори крок 3 практики й уважно прочитай, що відповість команда. Якщо база називається інакше, дивись project.yaml, розділ database, поле file: db.py бере шлях саме звідти, тобто ~/data/ плюс ця назва.