Development

This commit is contained in:
Matthew Grotke 2026-06-10 22:57:55 -04:00
parent edeb05acf7
commit a886a56982
4 changed files with 142 additions and 108 deletions

View file

@ -42,26 +42,31 @@ def init_accounts_db():
con.executescript(''' con.executescript('''
CREATE TABLE IF NOT EXISTS accounts ( CREATE TABLE IF NOT EXISTS accounts (
account_id TEXT PRIMARY KEY, account_id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL, email TEXT NOT NULL UNIQUE,
access_level INTEGER NOT NULL DEFAULT 1, access_level INTEGER NOT NULL,
hashed_password TEXT NOT NULL DEFAULT '', hashed_password TEXT,
created_ts INTEGER NOT NULL DEFAULT 0, created_ts INTEGER NOT NULL,
created_by TEXT NOT NULL DEFAULT '' created_by TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS clients (
cookie_unique_token TEXT PRIMARY KEY,
email TEXT UNIQUE,
hashed_password TEXT,
tz_offset_seconds INTEGER,
verification_code TEXT,
code_sent_ts INTEGER,
flashes_json TEXT
); );
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY, session_id TEXT PRIMARY KEY,
account_id TEXT NOT NULL, account_id TEXT NOT NULL,
tz_offset_seconds INTEGER NOT NULL DEFAULT 0, tz_offset_seconds INTEGER NOT NULL,
apply_changes_immediately INTEGER NOT NULL DEFAULT 0, preferences_json TEXT NOT NULL,
flashes_json TEXT,
session_started_ts INTEGER NOT NULL, session_started_ts INTEGER NOT NULL,
last_seen_ts INTEGER NOT NULL last_seen_ts INTEGER NOT NULL,
); FOREIGN KEY (session_id) REFERENCES clients(cookie_unique_token),
CREATE TABLE IF NOT EXISTS pending_verifications ( FOREIGN KEY (account_id) REFERENCES accounts(account_id)
email TEXT PRIMARY KEY,
hashed_password TEXT NOT NULL,
tz_offset_seconds INTEGER NOT NULL DEFAULT 0,
code TEXT NOT NULL,
expires_ts INTEGER NOT NULL
); );
''') ''')
con.commit() con.commit()

View file

@ -89,7 +89,6 @@ def form_create():
salt = bcrypt.gensalt() salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8') hashed = bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
code = f'{secrets.randbelow(1000000):06d}' code = f'{secrets.randbelow(1000000):06d}'
expires_ts = int(time.time()) + CODE_TTL_SECS
tz_offset = _tz_to_offset_seconds(tz) tz_offset = _tz_to_offset_seconds(tz)
try: try:
@ -101,10 +100,16 @@ def form_create():
try: try:
con = config_utils.open_accounts_db() con = config_utils.open_accounts_db()
con.execute( con.execute(
'''INSERT OR REPLACE INTO pending_verifications '''INSERT INTO clients
(email, hashed_password, tz_offset_seconds, code, expires_ts) (cookie_unique_token, email, hashed_password, tz_offset_seconds, verification_code, code_sent_ts)
VALUES (?,?,?,?,?)''', VALUES (?,?,?,?,?,?)
(account['email_address'].lower(), hashed, tz_offset, code, expires_ts) ON CONFLICT(cookie_unique_token) DO UPDATE SET
email=excluded.email,
hashed_password=excluded.hashed_password,
tz_offset_seconds=excluded.tz_offset_seconds,
verification_code=excluded.verification_code,
code_sent_ts=excluded.code_sent_ts''',
(session.sid, account['email_address'].lower(), hashed, tz_offset, code, int(time.time()))
) )
con.commit() con.commit()
con.close() con.close()
@ -112,6 +117,4 @@ def form_create():
flash(f'Could not store verification: {exc}', 'error') flash(f'Could not store verification: {exc}', 'error')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
session['pending_verify_email'] = account['email_address']
return redirect('/accountverifyemail') return redirect('/accountverifyemail')

View file

@ -3,7 +3,6 @@ from flask import Blueprint, request, session, redirect, flash
import time, secrets import time, secrets
import auth import auth
import config_utils import config_utils
import settings
_PAGE = Path(__file__).parent.name _PAGE = Path(__file__).parent.name
@ -16,49 +15,50 @@ def email_verify():
if session.get('access_level', 'nothing') != 'nothing': if session.get('access_level', 'nothing') != 'nothing':
return redirect('/overview') return redirect('/overview')
pending_email = session.get('pending_verify_email', '').lower() from pages.accountcreate.action import CODE_TTL_SECS
if not pending_email:
flash('No pending account creation found. Please start over.', 'error')
return redirect('/accountcreate')
token = session.sid
try: try:
con = config_utils.open_accounts_db() con = config_utils.open_accounts_db()
row = con.execute( client = con.execute(
'SELECT * FROM pending_verifications WHERE email=?', (pending_email,) 'SELECT * FROM clients WHERE cookie_unique_token=?', (token,)
).fetchone() ).fetchone()
con.close() con.close()
except Exception: except Exception:
row = None client = None
if not row: if not client or not client['email']:
flash('No pending account creation found. Please start over.', 'error') flash('No pending account creation found. Please start over.', 'error')
return redirect('/accountcreate') return redirect('/accountcreate')
if int(time.time()) > row['expires_ts']: if int(time.time()) > client['code_sent_ts'] + CODE_TTL_SECS:
try: try:
con = config_utils.open_accounts_db() con = config_utils.open_accounts_db()
con.execute('DELETE FROM pending_verifications WHERE email=?', (pending_email,)) con.execute(
'''UPDATE clients SET email=NULL, hashed_password=NULL,
tz_offset_seconds=NULL, verification_code=NULL, code_sent_ts=NULL
WHERE cookie_unique_token=?''',
(token,)
)
con.commit() con.commit()
con.close() con.close()
except Exception: except Exception:
pass pass
session.pop('pending_verify_email', None)
flash('Verification code has expired. Please start over.', 'error') flash('Verification code has expired. Please start over.', 'error')
return redirect('/accountcreate') return redirect('/accountcreate')
submitted = request.form.get('code', '').strip() submitted = request.form.get('code', '').strip()
if submitted != row['code']: if submitted != client['verification_code']:
flash('Incorrect verification code.', 'error') flash('Incorrect verification code.', 'error')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
pending_email = client['email']
account = config_utils.get_account_by_email(pending_email) account = config_utils.get_account_by_email(pending_email)
if account is None: if account is None:
session.pop('pending_verify_email', None)
flash('Account no longer exists. Contact your manager.', 'error') flash('Account no longer exists. Contact your manager.', 'error')
return redirect('/accountcreate') return redirect('/accountcreate')
if account.get('hashed_password'): if account.get('hashed_password'):
session.pop('pending_verify_email', None)
flash('This account is already set up. Please log in.', 'error') flash('This account is already set up. Please log in.', 'error')
return redirect('/accountlogin') return redirect('/accountlogin')
@ -67,18 +67,22 @@ def email_verify():
con = config_utils.open_accounts_db() con = config_utils.open_accounts_db()
con.execute( con.execute(
'UPDATE accounts SET hashed_password=?, created_ts=?, created_by=? WHERE account_id=?', 'UPDATE accounts SET hashed_password=?, created_ts=?, created_by=? WHERE account_id=?',
(row['hashed_password'], now, 'self', account['account_id']) (client['hashed_password'], now, 'self', account['account_id'])
)
con.execute(
'''UPDATE clients SET email=NULL, hashed_password=NULL,
tz_offset_seconds=NULL, verification_code=NULL, code_sent_ts=NULL
WHERE cookie_unique_token=?''',
(token,)
) )
con.execute('DELETE FROM pending_verifications WHERE email=?', (pending_email,))
con.commit() con.commit()
con.close() con.close()
except Exception as exc: except Exception as exc:
flash(f'Could not complete account setup: {exc}', 'error') flash(f'Could not complete account setup: {exc}', 'error')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
session.pop('pending_verify_email', None)
session['account_id'] = account['account_id'] session['account_id'] = account['account_id']
session['tz_offset_seconds'] = int(row['tz_offset_seconds']) session['tz_offset_seconds'] = int(client['tz_offset_seconds'])
session['apply_changes_immediately'] = False session['apply_changes_immediately'] = False
session.permanent = True session.permanent = True
@ -91,18 +95,27 @@ def email_resend():
if session.get('access_level', 'nothing') != 'nothing': if session.get('access_level', 'nothing') != 'nothing':
return redirect('/overview') return redirect('/overview')
from pages.accountcreate.action import _send_verification_email, CODE_TTL_SECS from pages.accountcreate.action import _send_verification_email
pending_email = session.get('pending_verify_email', '').lower() token = session.sid
if not pending_email: try:
con = config_utils.open_accounts_db()
client = con.execute(
'SELECT * FROM clients WHERE cookie_unique_token=?', (token,)
).fetchone()
con.close()
except Exception:
client = None
if not client or not client['email']:
flash('No pending account creation found. Please start over.', 'error') flash('No pending account creation found. Please start over.', 'error')
return redirect('/accountcreate') return redirect('/accountcreate')
code = f'{secrets.randbelow(1000000):06d}' code = f'{secrets.randbelow(1000000):06d}'
expires_ts = int(time.time()) + CODE_TTL_SECS code_sent_ts = int(time.time())
try: try:
_send_verification_email(pending_email, code) _send_verification_email(client['email'], code)
except Exception as exc: except Exception as exc:
flash(f'Could not resend verification email: {exc}', 'error') flash(f'Could not resend verification email: {exc}', 'error')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
@ -110,8 +123,8 @@ def email_resend():
try: try:
con = config_utils.open_accounts_db() con = config_utils.open_accounts_db()
con.execute( con.execute(
'UPDATE pending_verifications SET code=?, expires_ts=? WHERE email=?', 'UPDATE clients SET verification_code=?, code_sent_ts=? WHERE cookie_unique_token=?',
(code, expires_ts, pending_email) (code, code_sent_ts, token)
) )
con.commit() con.commit()
con.close() con.close()

View file

@ -1,3 +1,4 @@
import json
import sqlite3 import sqlite3
import time import time
import uuid import uuid
@ -30,39 +31,49 @@ class SqliteSessionInterface(SessionInterface):
def open_session(self, app, request): def open_session(self, app, request):
name = app.config.get('SESSION_COOKIE_NAME', 'session') name = app.config.get('SESSION_COOKIE_NAME', 'session')
sid = request.cookies.get(name) sid = request.cookies.get(name)
if sid: if not sid:
return SqliteSession(sid=str(uuid.uuid4()), new=True)
try: try:
con = self._connect() con = self._connect()
row = con.execute( row = con.execute(
'''SELECT s.session_id, s.account_id, s.tz_offset_seconds, '''SELECT s.session_id, s.account_id, s.tz_offset_seconds,
s.apply_changes_immediately, s.preferences_json, s.flashes_json,
a.email, a.access_level a.email, a.access_level
FROM sessions s FROM sessions s
JOIN accounts a ON a.account_id = s.account_id JOIN accounts a ON a.account_id = s.account_id
WHERE s.session_id=?''', WHERE s.session_id=?''',
(sid,) (sid,)
).fetchone() ).fetchone()
con.close()
if row: if row:
prefs = json.loads(row['preferences_json'] or '{}')
flashes = json.loads(row['flashes_json'] or '[]')
data = { data = {
'account_id': str(row['account_id']), 'account_id': str(row['account_id']),
'email_address': str(row['email']), 'email_address': str(row['email']),
'access_level': _LEVEL_INT_TO_STR.get(row['access_level'], 'viewer'), 'access_level': _LEVEL_INT_TO_STR.get(row['access_level'], 'viewer'),
'tz_offset_seconds': int(row['tz_offset_seconds']), 'tz_offset_seconds': int(row['tz_offset_seconds']),
'apply_changes_immediately': bool(row['apply_changes_immediately']), 'apply_changes_immediately': bool(prefs.get('apply_changes_immediately', False)),
'_flashes': flashes,
} }
con.close()
return SqliteSession(data, sid=sid, new=False)
client = con.execute(
'SELECT flashes_json FROM clients WHERE cookie_unique_token=?', (sid,)
).fetchone()
con.close()
flashes = json.loads(client['flashes_json'] or '[]') if client else []
data = {'_flashes': flashes} if flashes else {}
return SqliteSession(data, sid=sid, new=False) return SqliteSession(data, sid=sid, new=False)
except Exception: except Exception:
pass pass
return SqliteSession(sid=str(uuid.uuid4()), new=True) return SqliteSession(sid=sid, new=False)
def save_session(self, app, session, response): def save_session(self, app, session, response):
name = app.config.get('SESSION_COOKIE_NAME', 'session') name = app.config.get('SESSION_COOKIE_NAME', 'session')
domain = self.get_cookie_domain(app) domain = self.get_cookie_domain(app)
path = self.get_cookie_path(app) path = self.get_cookie_path(app)
if not session: if not session and session.modified and not session.new:
if not session.new:
try: try:
con = self._connect() con = self._connect()
con.execute('DELETE FROM sessions WHERE session_id=?', (session.sid,)) con.execute('DELETE FROM sessions WHERE session_id=?', (session.sid,))
@ -74,33 +85,35 @@ class SqliteSessionInterface(SessionInterface):
return return
account_id = session.get('account_id') account_id = session.get('account_id')
if not account_id: flashes_json = json.dumps(session.get('_flashes', []))
return
now = int(time.time()) now = int(time.time())
tz_offset = int(session.get('tz_offset_seconds', 0))
apply_changes = 1 if session.get('apply_changes_immediately') else 0
try: try:
con = self._connect() con = self._connect()
if session.new: if account_id:
prefs = json.dumps({'apply_changes_immediately': bool(session.get('apply_changes_immediately', False))})
tz_offset = int(session.get('tz_offset_seconds', 0))
con.execute('INSERT OR IGNORE INTO clients (cookie_unique_token) VALUES (?)', (session.sid,))
con.execute( con.execute(
'''INSERT INTO sessions '''INSERT INTO sessions
(session_id, account_id, tz_offset_seconds, apply_changes_immediately, (session_id, account_id, tz_offset_seconds, preferences_json,
session_started_ts, last_seen_ts) flashes_json, session_started_ts, last_seen_ts)
VALUES (?,?,?,?,?,?)''', VALUES (?,?,?,?,?,?,?)
(session.sid, account_id, tz_offset, apply_changes, now, now) ON CONFLICT(session_id) DO UPDATE SET
) account_id=excluded.account_id,
elif session.modified: tz_offset_seconds=excluded.tz_offset_seconds,
con.execute( preferences_json=excluded.preferences_json,
'''UPDATE sessions SET tz_offset_seconds=?, apply_changes_immediately=?, flashes_json=excluded.flashes_json,
last_seen_ts=? WHERE session_id=?''', last_seen_ts=excluded.last_seen_ts''',
(tz_offset, apply_changes, now, session.sid) (session.sid, account_id, tz_offset, prefs, flashes_json, now, now)
) )
else: else:
con.execute( con.execute(
'UPDATE sessions SET last_seen_ts=? WHERE session_id=?', '''INSERT INTO clients (cookie_unique_token, flashes_json)
(now, session.sid) VALUES (?,?)
ON CONFLICT(cookie_unique_token) DO UPDATE SET
flashes_json=excluded.flashes_json''',
(session.sid, flashes_json)
) )
con.commit() con.commit()
con.close() con.close()