Repos / pytaku / 5afdd3d626
commit 5afdd3d6269ae25d4efba229c40142aa831fe324
Author: Bùi Thành Nhân <hi@imnhan.com>
Date: Sun Aug 16 23:42:58 2020 +0700
implement db-backed tokens auth
TODOs:
- require_token decorator queries "token" table, passes user_id to view func
- new scheduler worker to clean up expired tokens.
With all that done hopefully we'll be able to get rid of cookies.
diff --git a/README.md b/README.md
index 9b3df83..dd287a0 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@ # Pytaku
phone experience may not be as polished.
- KISSFFS, or **K**eep **I**t rea**S**onably **S**imple you **F**-ing
- architecture **F**etishi**S**ts! Oftentimes I have enough practice on
+ architecture/tooling **F**etishi**S**ts! Oftentimes I have enough practice on
industrial grade power tools at work so at home I want a change of pace.
Flask + raw SQL has been surprisingly comfy. On the other side, mithril.js
seems to be a no-frills, stable SPA lib made by a person who knows what
diff --git a/src/pytaku/database/common.py b/src/pytaku/database/common.py
index dcb443f..15ffeec 100644
--- a/src/pytaku/database/common.py
+++ b/src/pytaku/database/common.py
@@ -28,8 +28,11 @@ def get_conn():
return _conn
-def run_sql(*args, **kwargs):
- return list(run_sql_on_demand(*args, **kwargs))
+def run_sql(*args, return_num_affected=False, **kwargs):
+ cursor = run_sql_on_demand(*args, **kwargs)
+ if return_num_affected:
+ return cursor.execute("select changes();").fetchone()
+ return list(cursor)
def run_sql_on_demand(*args, **kwargs):
diff --git a/src/pytaku/database/migrations/latest_schema.sql b/src/pytaku/database/migrations/latest_schema.sql
index 1af6b03..7883803 100644
--- a/src/pytaku/database/migrations/latest_schema.sql
+++ b/src/pytaku/database/migrations/latest_schema.sql
@@ -61,3 +61,13 @@ CREATE TABLE IF NOT EXISTS "read" (
foreign key (site, title_id, chapter_id) references chapter (site, title_id, id),
unique(user_id, site, title_id, chapter_id)
);
+CREATE TABLE token (
+ user_id integer not null,
+ token text not null,
+ created_at text not null default (datetime('now')),
+ last_accessed_at text not null default (datetime('now')),
+ lifespan text not null, -- '+1 day', '+365 days', etc.
+
+ foreign key (user_id) references user (id),
+ unique(user_id, token)
+);
diff --git a/src/pytaku/database/migrations/m0004.sql b/src/pytaku/database/migrations/m0004.sql
new file mode 100644
index 0000000..a1641ea
--- /dev/null
+++ b/src/pytaku/database/migrations/m0004.sql
@@ -0,0 +1,14 @@
+begin transaction;
+
+create table token (
+ user_id integer not null,
+ token text not null,
+ created_at text not null default (datetime('now')),
+ last_accessed_at text not null default (datetime('now')),
+ lifespan text not null, -- '+1 day', '+365 days', etc.
+
+ foreign key (user_id) references user (id),
+ unique(user_id, token)
+);
+
+commit;
diff --git a/src/pytaku/decorators.py b/src/pytaku/decorators.py
index 388731f..6cc1422 100644
--- a/src/pytaku/decorators.py
+++ b/src/pytaku/decorators.py
@@ -1,7 +1,9 @@
from functools import wraps
-from flask import redirect, request, session, url_for
+from flask import jsonify, redirect, request, session, url_for
+from itsdangerous import SignatureExpired, URLSafeTimedSerializer
+from .conf import config
from .persistence import read, unread
@@ -15,6 +17,25 @@ def decorated_function(*args, **kwargs):
return decorated_function
+def require_token(f):
+ @wraps(f)
+ def decorated_function(*args, **kwargs):
+ token = request.headers.get("Pytaku-Token")
+ if not token:
+ return jsonify({"message": "Please provide Pytaku-Token header."}), 401
+ s = URLSafeTimedSerializer(config.FLASK_SECRET_KEY, salt="access_token")
+ try:
+ user_id = s.loads(token)
+ except SignatureExpired:
+ return jsonify({"message": "Token expired."}), 401
+ except Exception:
+ return jsonify({"message": "Malformed token."}), 401
+
+ return f(*args, user_id=user_id, **kwargs)
+
+ return decorated_function
+
+
def toggle_has_read(f):
"""
Augments a view with the ability to toggle a chapter's read status if there's a
diff --git a/src/pytaku/main.py b/src/pytaku/main.py
index 2e37c3a..3bb6018 100644
--- a/src/pytaku/main.py
+++ b/src/pytaku/main.py
@@ -6,17 +6,45 @@
from typing import List, Tuple
import requests
-from flask import (Flask, flash, jsonify, make_response, redirect,
- render_template, request, session, url_for)
+from flask import (
+ Flask,
+ flash,
+ jsonify,
+ make_response,
+ redirect,
+ render_template,
+ request,
+ session,
+ url_for,
+)
from .conf import config
from .decorators import require_login, toggle_has_read
-from .persistence import (follow, get_followed_titles, get_prev_next_chapters,
- import_follows, load_chapter, load_title,
- register_user, save_chapter, save_title, unfollow,
- verify_username_password)
-from .source_sites import (get_chapter, get_title, search_title_all_sites,
- title_cover, title_source_url, title_thumbnail)
+from .persistence import (
+ create_token,
+ delete_token,
+ follow,
+ get_followed_titles,
+ get_prev_next_chapters,
+ get_username,
+ import_follows,
+ load_chapter,
+ load_title,
+ register_user,
+ save_chapter,
+ save_title,
+ unfollow,
+ verify_token,
+ verify_username_password,
+)
+from .source_sites import (
+ get_chapter,
+ get_title,
+ search_title_all_sites,
+ title_cover,
+ title_source_url,
+ title_thumbnail,
+)
config.load()
@@ -383,7 +411,29 @@ def api_login():
)
user_id = verify_username_password(username, password)
- if user_id:
- return jsonify({"user_id": user_id}), 200
- else:
- return jsonify({"message": "Wrong username/password combination."}), 400
+ if not user_id:
+ return jsonify({"message": "Wrong username/password combination."}), 401
+
+ token = create_token(user_id, remember)
+ return jsonify({"user_id": user_id, "token": token}), 200
+
+
+@app.route("/api/verify-token", methods=["POST"])
+def api_verify_token():
+ user_id = request.json["user_id"]
+ token = request.json["token"]
+ is_valid = verify_token(user_id, token)
+ if not is_valid:
+ return jsonify({"message": "Invalid token."}), 401
+ return {"username": get_username(user_id)}, 200
+
+
+@app.route("/api/logout", methods=["POST"])
+def api_logout():
+ # TODO: should probably be using auth http header like other APIs
+ user_id = request.json["user_id"]
+ token = request.json["token"]
+ num_deleted = delete_token(user_id, token)
+ if num_deleted != 1:
+ return jsonify({"message": "Invalid token."}), 401
+ return "{}", 200
diff --git a/src/pytaku/persistence.py b/src/pytaku/persistence.py
index 7fba2d5..ec545b5 100644
--- a/src/pytaku/persistence.py
+++ b/src/pytaku/persistence.py
@@ -1,9 +1,11 @@
import json
+import secrets
from typing import List, Tuple
-import apsw
import argon2
+import apsw
+
from .database.common import run_sql, run_sql_many, run_sql_on_demand
@@ -335,3 +337,56 @@ def import_follows(user_id: int, site_title_pairs: List[Tuple[str, str]]):
""",
((user_id, site, title_id) for site, title_id in site_title_pairs),
)
+
+
+def create_token(user_id, remember=False):
+ lifespan = "+365 days" if remember else "+1 day"
+ token = secrets.token_urlsafe(64)
+ run_sql(
+ """
+ INSERT INTO token (user_id, token, lifespan) VALUES (?,?,?);
+ """,
+ (user_id, token, lifespan),
+ )
+ return token
+
+
+def verify_token(user_id, token):
+ """
+ Checks if there's a matching token that hasn't exceeded its lifespan.
+ If there's a match, refreshes its last_accessed_at value, effectively expanding
+ its life.
+ """
+ result = run_sql(
+ """
+ SELECT 1 FROM token
+ WHERE user_id=? AND token=?
+ AND datetime(last_accessed_at, lifespan) > datetime('now');
+ """,
+ (user_id, token),
+ )
+ is_success = len(result) == 1
+ if is_success:
+ run_sql(
+ """
+ UPDATE token SET last_accessed_at = datetime('now')
+ WHERE user_id=? AND token=?;
+ """,
+ (user_id, token),
+ )
+ return is_success
+
+
+def get_username(user_id):
+ result = run_sql("SELECT username FROM user WHERE id=?;", (user_id,))
+ assert len(result) == 1
+ return result[0]
+
+
+def delete_token(user_id, token):
+ num_deleted = run_sql(
+ "DELETE FROM token WHERE user_id=? AND token=?",
+ (user_id, token),
+ return_num_affected=True,
+ )
+ return num_deleted
diff --git a/src/pytaku/static/js/models.js b/src/pytaku/static/js/models.js
new file mode 100644
index 0000000..ecaee29
--- /dev/null
+++ b/src/pytaku/static/js/models.js
@@ -0,0 +1,73 @@
+const Auth = {
+ username: sessionStorage.getItem("username"),
+ token: localStorage.getItem("token"),
+ userId: localStorage.getItem("user_id"),
+ isLoggedIn: () => Auth.username !== null,
+ init: () => {
+ // Already logged in, probably from another tab:
+ if (Auth.username !== null) {
+ return;
+ }
+
+ // No previous login session:
+ if (Auth.token === null || Auth.userId === null) {
+ return;
+ }
+
+ // Verify token & user_id saved from previous login session:
+ return m
+ .request({
+ method: "POST",
+ url: "/api/verify-token",
+ body: { token: Auth.token, user_id: Auth.userId },
+ })
+ .then((result) => {
+ // Success! Set username for this session now
+ sessionStorage.setItem("username", result.username);
+ Auth.username = result.username;
+ })
+ .catch((err) => {
+ // If server responded with 401 Unauthorized, clear any local trace of
+ // these invalid credentials.
+ if (err.code == 401) {
+ Auth.clearCredentials();
+ }
+ });
+ },
+
+ saveLoginResults: ({ userId, username, token }) => {
+ sessionStorage.setItem("username", username);
+ localStorage.setItem("user_id", userId);
+ localStorage.setItem("token", token);
+ Auth.userId = userId;
+ Auth.username = username;
+ Auth.token = token;
+ },
+
+ logout: () => {
+ return m
+ .request({
+ method: "POST",
+ url: "/api/logout",
+ body: { token: Auth.token, user_id: Auth.userId },
+ })
+ .then(Auth.clearCredentials)
+ .catch((err) => {
+ if (err.code == 401) {
+ Auth.clearCredentials();
+ } else {
+ console.log(err);
+ }
+ });
+ },
+
+ clearCredentials: () => {
+ Auth.username = null;
+ Auth.token = null;
+ Auth.userId = null;
+ localStorage.clear();
+ sessionStorage.clear();
+ },
+};
+
+export { Auth };
diff --git a/src/pytaku/static/spa.js b/src/pytaku/static/js/spa.js
similarity index 73%
rename from src/pytaku/static/spa.js
rename to src/pytaku/static/js/spa.js
index b7cecb8..5776261 100644
--- a/src/pytaku/static/spa.js
+++ b/src/pytaku/static/js/spa.js
@@ -1,6 +1,10 @@
/* Top-level Components */
+import { Auth } from "./models.js";
const Home = {
+ oncreate: (vnode) => {
+ document.title = "Pytaku";
+ },
view: (vnode) => {
return m("div.main", [
m(Navbar),
@@ -13,21 +17,23 @@ const Home = {
};
function Authentication(initialVNode) {
- let loginUsername = "admin";
- let loginPassword = "admin";
- let rememberMe = false;
- let loginErrorMessage = "";
-
- let registerUsername = "admin";
- let registerPassword = "admin";
- let confirmPassword = "admin";
- let registerMessage = "";
- let registerSuccess = false;
+ let loginUsername;
+ let loginPassword;
+ let rememberMe;
+ let loginErrorMessage;
+ let registerUsername;
+ let registerPassword;
+ let confirmPassword;
+ let registerMessage;
+ let registerSuccess;
let registering = false;
let loggingIn = false;
return {
+ oncreate: (vnode) => {
+ document.title = "Authentication - Pytaku";
+ },
view: (vnode) => {
return m("div.main", [
m(Navbar),
@@ -52,10 +58,14 @@ function Authentication(initialVNode) {
})
.then((result) => {
loggingIn = false;
- console.log("Success:", result);
- alert("TODO");
+ let userId = result.user_id;
+ let token = result.token;
+ let username = loginUsername;
+ Auth.saveLoginResults({ userId, username, token });
+ m.route.set("/");
})
.catch((e) => {
+ console.log(e);
loggingIn = false;
loginErrorMessage = e.response.message;
});
@@ -191,32 +201,62 @@ function Authentication(initialVNode) {
};
}
-const Navbar = {
- view: (vnode) => {
- return m("nav", [
- m(m.route.Link, { class: "nav--logo", href: "/" }, [
- m("img.nav--logo--img", {
- src: "/static/pytaku.svg",
- alt: "home",
- }),
- ]),
- m("form.nav--search-form", [
- m("input", { placeholder: "search title name" }),
- m("button", { type: "submit" }, [m("i.icon.icon-search")]),
- ]),
- m(m.route.Link, { class: "nav--link", href: "/a" }, [
- m("i.icon.icon-log-in"),
- "login / register",
- ]),
- ]);
- },
-};
+function Navbar(initialVNode) {
+ let isLoggingOut = false;
+ return {
+ view: (vnode) => {
+ let userLink;
+ if (Auth.isLoggedIn()) {
+ userLink = m("span.nav--greeting", [
+ "Welcome, ",
+ m("b", Auth.username),
+ " ",
+ m(
+ "button",
+ {
+ onclick: (ev) => {
+ isLoggingOut = true;
+ m.redraw();
+ Auth.logout();
+ },
+ disabled: isLoggingOut ? "disabled" : null,
+ },
+ [
+ m("i.icon.icon-log-out"),
+ isLoggingOut ? " logging out" : " logout",
+ ]
+ ),
+ ]);
+ } else {
+ userLink = m(m.route.Link, { class: "nav--link", href: "/a" }, [
+ m("i.icon.icon-log-in"),
+ "login / register",
+ ]);
+ }
+
+ return m("nav", [
+ m(m.route.Link, { class: "nav--logo", href: "/" }, [
+ m("img.nav--logo--img", {
+ src: "/static/pytaku.svg",
+ alt: "home",
+ }),
+ ]),
+ m("form.nav--search-form", [
+ m("input", { placeholder: "search title name" }),
+ m("button", { type: "submit" }, [m("i.icon.icon-search")]),
+ ]),
+ userLink,
+ ]);
+ },
+ };
+}
/* Entry point */
-root = document.getElementById("spa-root");
+const root = document.getElementById("spa-root");
m.route.prefix = "";
m.route(root, "/h", {
"/h": Home,
"/a": Authentication,
});
+Auth.init();
diff --git a/src/pytaku/static/spa.css b/src/pytaku/static/spa.css
index 34ad26f..ec92e72 100644
--- a/src/pytaku/static/spa.css
+++ b/src/pytaku/static/spa.css
@@ -24,6 +24,13 @@ .nav--search-form {
.nav--search-form > input {
width: 15rem;
}
+.nav--greeting {
+ color: white;
+ align-items: center;
+ margin-left: auto;
+ margin-top: auto;
+ margin-bottom: auto;
+}
.nav--link {
color: white;
text-decoration: none;
@@ -93,6 +100,7 @@ .auth--form {
display: inline-flex;
flex-direction: column;
margin: 0.5rem;
+ width: 300px;
max-width: 100%;
}
.auth--form > * {
diff --git a/src/pytaku/templates/spa.html b/src/pytaku/templates/spa.html
index 62ad43b..3d43b80 100644
--- a/src/pytaku/templates/spa.html
+++ b/src/pytaku/templates/spa.html
@@ -44,6 +44,6 @@
<script>const initialState = "{{ initial_state }}";</script>
<script src="{{ url_for('static', filename='vendored/mithril.min.js') }}"></script>
- <script src="{{ url_for('static', filename='spa.js') }}"></script>
+ <script src="{{ url_for('static', filename='js/spa.js') }}" type="module"></script>
</body>
</html>