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>