Repos / pytaku / 90dc7eec3f
commit 90dc7eec3f408e1070d0603089aca48d830b2169
Author: Bùi Thành Nhân <hi@imnhan.com>
Date: Tue Aug 18 22:15:53 2020 +0700
revamp login
- Use standard issue bearer token
- Implement require_token decorator & use on new API views
- Client-side Auth state management
- Use layout pattern on client side to use Navbar in one place
diff --git a/src/pytaku/database/migrations/latest_schema.sql b/src/pytaku/database/migrations/latest_schema.sql
index 7883803..c9bf35d 100644
--- a/src/pytaku/database/migrations/latest_schema.sql
+++ b/src/pytaku/database/migrations/latest_schema.sql
@@ -63,11 +63,10 @@ CREATE TABLE IF NOT EXISTS "read" (
);
CREATE TABLE token (
user_id integer not null,
- token text not null,
+ token text unique 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)
+ foreign key (user_id) references user (id)
);
diff --git a/src/pytaku/database/migrations/m0004.sql b/src/pytaku/database/migrations/m0004.sql
index a1641ea..ca0b019 100644
--- a/src/pytaku/database/migrations/m0004.sql
+++ b/src/pytaku/database/migrations/m0004.sql
@@ -2,13 +2,12 @@ begin transaction;
create table token (
user_id integer not null,
- token text not null,
+ token text unique 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)
+ foreign key (user_id) references user (id)
);
commit;
diff --git a/src/pytaku/decorators.py b/src/pytaku/decorators.py
index 6cc1422..8bcaa84 100644
--- a/src/pytaku/decorators.py
+++ b/src/pytaku/decorators.py
@@ -1,10 +1,8 @@
from functools import wraps
from flask import jsonify, redirect, request, session, url_for
-from itsdangerous import SignatureExpired, URLSafeTimedSerializer
-from .conf import config
-from .persistence import read, unread
+from .persistence import read, unread, verify_token
def require_login(f):
@@ -20,18 +18,22 @@ def decorated_function(*args, **kwargs):
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)
+ header = request.headers.get("Authorization")
+ if not header or not header.startswith("Bearer "):
+ return (
+ jsonify({"message": "Missing `Authorization: Bearer <token>` header."}),
+ 401,
+ )
+
+ token = header[len("Bearer ") :]
+ user_id = verify_token(token)
+ if user_id is None:
+ return jsonify({"message": "Invalid token."}), 401
+
+ request.token = token
+ request.user_id = user_id
+
+ return f(*args, **kwargs)
return decorated_function
diff --git a/src/pytaku/main.py b/src/pytaku/main.py
index 3bb6018..b73dc0e 100644
--- a/src/pytaku/main.py
+++ b/src/pytaku/main.py
@@ -19,7 +19,7 @@
)
from .conf import config
-from .decorators import require_login, toggle_has_read
+from .decorators import require_login, require_token, toggle_has_read
from .persistence import (
create_token,
delete_token,
@@ -34,7 +34,6 @@
save_chapter,
save_title,
unfollow,
- verify_token,
verify_username_password,
)
from .source_sites import (
@@ -418,22 +417,22 @@ def api_login():
return jsonify({"user_id": user_id, "token": token}), 200
-@app.route("/api/verify-token", methods=["POST"])
+@app.route("/api/verify-token", methods=["GET"])
+@require_token
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
+ return {"user_id": request.user_id, "username": get_username(request.user_id)}, 200
@app.route("/api/logout", methods=["POST"])
+@require_token
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)
+ num_deleted = delete_token(request.token)
if num_deleted != 1:
return jsonify({"message": "Invalid token."}), 401
return "{}", 200
+
+
+@app.route("/api/follows", methods=["GET"])
+@require_token
+def api_follows():
+ return jsonify({"message": "TODO"})
diff --git a/src/pytaku/persistence.py b/src/pytaku/persistence.py
index 57a581d..7cb0a81 100644
--- a/src/pytaku/persistence.py
+++ b/src/pytaku/persistence.py
@@ -350,7 +350,7 @@ def create_token(user_id, remember=False):
return token
-def verify_token(user_id, token):
+def verify_token(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
@@ -358,22 +358,19 @@ def verify_token(user_id, token):
"""
result = run_sql(
"""
- SELECT 1 FROM token
- WHERE user_id=? AND token=?
+ SELECT user_id FROM token
+ WHERE token=?
AND datetime(last_accessed_at, lifespan) > datetime('now');
""",
- (user_id, token),
+ (token,),
)
- is_success = len(result) == 1
- if is_success:
+ user_id = result[0] if len(result) == 1 else None
+ if user_id:
run_sql(
- """
- UPDATE token SET last_accessed_at = datetime('now')
- WHERE user_id=? AND token=?;
- """,
- (user_id, token),
+ "UPDATE token SET last_accessed_at = datetime('now') WHERE token=?;",
+ (token,),
)
- return is_success
+ return user_id
def get_username(user_id):
@@ -382,11 +379,9 @@ def get_username(user_id):
return result[0]
-def delete_token(user_id, token):
+def delete_token(token):
num_deleted = run_sql(
- "DELETE FROM token WHERE user_id=? AND token=?",
- (user_id, token),
- return_num_affected=True,
+ "DELETE FROM token WHERE token=?;", (token,), return_num_affected=True,
)
return num_deleted
diff --git a/src/pytaku/static/js/common-components.js b/src/pytaku/static/js/layout.js
similarity index 64%
rename from src/pytaku/static/js/common-components.js
rename to src/pytaku/static/js/layout.js
index 22852ee..7807b0f 100644
--- a/src/pytaku/static/js/common-components.js
+++ b/src/pytaku/static/js/layout.js
@@ -16,7 +16,13 @@ function Navbar(initialVNode) {
onclick: (ev) => {
isLoggingOut = true;
m.redraw();
- Auth.logout();
+ Auth.logout()
+ .then(() => {
+ m.route.set("/");
+ })
+ .finally(() => {
+ isLoggingOut = false;
+ });
},
disabled: isLoggingOut ? "disabled" : null,
},
@@ -34,12 +40,16 @@ function Navbar(initialVNode) {
}
return m("nav", [
- m(m.route.Link, { class: "nav--logo", href: "/" }, [
- m("img.nav--logo--img", {
- src: "/static/pytaku.svg",
- alt: "home",
- }),
- ]),
+ m(
+ m.route.Link,
+ { class: "nav--logo", href: Auth.isLoggedIn() ? "/f" : "/" },
+ [
+ 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")]),
@@ -49,4 +59,11 @@ function Navbar(initialVNode) {
},
};
}
-export { Navbar };
+
+const Layout = {
+ view: (vnode) => {
+ return m("div.main", [m(Navbar), vnode.children]);
+ },
+};
+
+export default Layout;
diff --git a/src/pytaku/static/js/main.js b/src/pytaku/static/js/main.js
index cf63ec1..2dde99a 100644
--- a/src/pytaku/static/js/main.js
+++ b/src/pytaku/static/js/main.js
@@ -1,11 +1,43 @@
import { Auth } from "./models.js";
+import Layout from "./layout.js";
import Authentication from "./routes/authentication.js";
import Home from "./routes/home.js";
+import Follows from "./routes/follows.js";
-const root = document.getElementById("spa-root");
-m.route.prefix = "";
-m.route(root, "/h", {
- "/h": Home,
- "/a": Authentication,
+Auth.init().then(() => {
+ const root = document.getElementById("spa-root");
+ m.route.prefix = "";
+ m.route(root, "/", {
+ "/": {
+ onmatch: () => {
+ if (Auth.isLoggedIn()) {
+ m.route.set("/f", null, { replace: true });
+ } else {
+ return Home;
+ }
+ },
+ render: () => m(Layout, m(Home)),
+ },
+ "/a": {
+ onmatch: () => {
+ if (Auth.isLoggedIn()) {
+ m.route.set("/f", null, { replace: true });
+ } else {
+ return Authentication;
+ }
+ },
+ render: () => m(Layout, m(Authentication)),
+ },
+ "/f": {
+ onmatch: () => {
+ if (Auth.isLoggedIn()) {
+ return Follows;
+ } else {
+ //m.route.set("/a", null, { replace: true });
+ return m("h1", "waiting");
+ }
+ },
+ render: () => m(Layout, m(Follows)),
+ },
+ });
});
-Auth.init();
diff --git a/src/pytaku/static/js/models.js b/src/pytaku/static/js/models.js
index ecaee29..e1dc2b4 100644
--- a/src/pytaku/static/js/models.js
+++ b/src/pytaku/static/js/models.js
@@ -1,30 +1,34 @@
const Auth = {
username: sessionStorage.getItem("username"),
+ userId: sessionStorage.getItem("userId"),
token: localStorage.getItem("token"),
- userId: localStorage.getItem("user_id"),
- isLoggedIn: () => Auth.username !== null,
+ isLoggedIn: () => Auth.username !== null && Auth.userId !== null,
init: () => {
// Already logged in, probably from another tab:
- if (Auth.username !== null) {
- return;
+ if (Auth.isLoggedIn()) {
+ console.log("Already logged in");
+ return Promise.resolve();
}
// No previous login session:
- if (Auth.token === null || Auth.userId === null) {
- return;
+ if (Auth.token === null) {
+ console.log("No saved token found");
+ return Promise.resolve();
}
- // Verify token & user_id saved from previous login session:
+ // Verify token saved from previous login session:
return m
.request({
- method: "POST",
+ method: "GET",
url: "/api/verify-token",
- body: { token: Auth.token, user_id: Auth.userId },
+ headers: { Authorization: `Bearer ${Auth.token}` },
})
.then((result) => {
- // Success! Set username for this session now
+ // Success! Set user info for this session now
sessionStorage.setItem("username", result.username);
+ sessionStorage.setItem("userId", result.user_id);
Auth.username = result.username;
+ Auth.userId = result.user_id;
})
.catch((err) => {
// If server responded with 401 Unauthorized, clear any local trace of
@@ -32,12 +36,25 @@ const Auth = {
if (err.code == 401) {
Auth.clearCredentials();
}
- });
+ })
+ .finally(m.redraw);
},
- saveLoginResults: ({ userId, username, token }) => {
+ saveLoginResults: ({ userId, username, token, remember }) => {
+ // FIXME: currently, even when remember=false we're still storing the token
+ // in localStorage, simply because sessionStorage isn't shared across tabs.
+ // Unfortunately this means when user logs in without checking "remember
+ // me", the token will still linger in localstorage after browser is
+ // closed, and if an adversary reopens browser within the token's
+ // server-enforced lifespan (1 day), then user is pwned.
+ //
+ // Either that or we stick to sessionStorage and forget multitab support.
+ // _OR_ we do a convoluted song and dance with storage events:
+ // > https://stackoverflow.com/a/32766809
+ //
+ // 0 days since web APIs last made me sad.
+ sessionStorage.setItem("userId", userId);
sessionStorage.setItem("username", username);
- localStorage.setItem("user_id", userId);
localStorage.setItem("token", token);
Auth.userId = userId;
Auth.username = username;
@@ -45,12 +62,7 @@ const Auth = {
},
logout: () => {
- return m
- .request({
- method: "POST",
- url: "/api/logout",
- body: { token: Auth.token, user_id: Auth.userId },
- })
+ return Auth.request({ method: "POST", url: "/api/logout" })
.then(Auth.clearCredentials)
.catch((err) => {
if (err.code == 401) {
@@ -68,6 +80,13 @@ const Auth = {
localStorage.clear();
sessionStorage.clear();
},
+
+ request: (options) => {
+ if (Auth.isLoggedIn()) {
+ options.headers = { Authorization: `Bearer ${Auth.token}` };
+ }
+ return m.request(options);
+ },
};
export { Auth };
diff --git a/src/pytaku/static/js/routes/authentication.js b/src/pytaku/static/js/routes/authentication.js
index 1e6743a..bb37d16 100644
--- a/src/pytaku/static/js/routes/authentication.js
+++ b/src/pytaku/static/js/routes/authentication.js
@@ -1,10 +1,9 @@
-import { Navbar } from "../common-components.js";
import { Auth } from "../models.js";
function Authentication(initialVNode) {
let loginUsername;
let loginPassword;
- let rememberMe;
+ let rememberMe = false;
let loginErrorMessage;
let registerUsername;
let registerPassword;
@@ -20,167 +19,170 @@ function Authentication(initialVNode) {
document.title = "Authentication - Pytaku";
},
view: (vnode) => {
- return m("div.main", [
- m(Navbar),
- m("div.content.auth", [
- m(
- "form.auth--form",
- {
- onsubmit: (e) => {
- e.preventDefault();
- loginErrorMessage = "";
- loggingIn = true;
- m.redraw();
+ return m("div.content.auth", [
+ m(
+ "form.auth--form",
+ {
+ onsubmit: (e) => {
+ e.preventDefault();
+ loginErrorMessage = "";
+ loggingIn = true;
+ m.redraw();
- m.request({
- method: "POST",
- url: "/api/login",
- body: {
- username: loginUsername,
- password: loginPassword,
+ m.request({
+ method: "POST",
+ url: "/api/login",
+ body: {
+ username: loginUsername,
+ password: loginPassword,
+ remember: rememberMe,
+ },
+ })
+ .then((result) => {
+ let userId = result.user_id;
+ let token = result.token;
+ let username = loginUsername;
+ Auth.saveLoginResults({
+ userId,
+ username,
+ token,
remember: rememberMe,
- },
- })
- .then((result) => {
- loggingIn = false;
- 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;
});
- },
+ m.route.set("/f");
+ })
+ .catch((e) => {
+ loginErrorMessage = e.response.message;
+ })
+ .finally(() => {
+ loggingIn = false;
+ });
},
- [
- m("h1", "Login"),
- m("input[placeholder=username][name=username][required]", {
- value: loginUsername,
+ },
+ [
+ m("h1", "Login"),
+ m("input[placeholder=username][name=username][required]", {
+ value: loginUsername,
+ oninput: (e) => {
+ loginUsername = e.target.value;
+ },
+ }),
+ m(
+ "input[placeholder=password][name=password][type=password][required]",
+ {
+ value: loginPassword,
oninput: (e) => {
- loginUsername = e.target.value;
+ loginPassword = e.target.value;
},
- }),
- m(
- "input[placeholder=password][name=password][type=password][required]",
- {
- value: loginPassword,
- oninput: (e) => {
- loginPassword = e.target.value;
- },
- }
- ),
- m("label[for=auth--remember].auth--checkbox-label", [
- m("input[type=checkbox][name=remember][id=auth--remember]", {
- checked: rememberMe,
- onchange: (e) => {
- rememberMe = e.target.checked;
- },
- }),
- " Remember me",
- ]),
- m(
- "button[type=submit]",
- {
- disabled: loggingIn ? "disabled" : null,
+ }
+ ),
+ m("label[for=auth--remember].auth--checkbox-label", [
+ m("input[type=checkbox][name=remember][id=auth--remember]", {
+ checked: rememberMe,
+ onchange: (e) => {
+ rememberMe = e.target.checked;
},
- [
- m("i.icon.icon-log-in"),
- loggingIn ? " Logging in..." : " Log in",
- ]
- ),
- m("p.auth--form--error-message", loginErrorMessage),
- ]
- ),
- m(
- "form.auth--form",
- {
- onsubmit: (e) => {
- e.preventDefault();
- registerMessage = "";
- m.redraw();
+ }),
+ " Remember me",
+ ]),
+ m(
+ "button[type=submit]",
+ {
+ disabled: loggingIn ? "disabled" : null,
+ },
+ [
+ m("i.icon.icon-log-in"),
+ loggingIn ? " Logging in..." : " Log in",
+ ]
+ ),
+ m("p.auth--form--error-message", loginErrorMessage),
+ ]
+ ),
+ m(
+ "form.auth--form",
+ {
+ onsubmit: (e) => {
+ e.preventDefault();
+ registerMessage = "";
+ m.redraw();
- if (registerPassword !== confirmPassword) {
- registerMessage = "Password confirmation didn't match!";
- registerSuccess = false;
- return;
- }
+ if (registerPassword !== confirmPassword) {
+ registerMessage = "Password confirmation didn't match!";
+ registerSuccess = false;
+ return;
+ }
- registering = true;
- m.redraw();
- m.request({
- method: "POST",
- url: "/api/register",
- body: {
- username: registerUsername,
- password: registerPassword,
- },
+ registering = true;
+ m.redraw();
+ m.request({
+ method: "POST",
+ url: "/api/register",
+ body: {
+ username: registerUsername,
+ password: registerPassword,
+ },
+ })
+ .then((result) => {
+ registerSuccess = true;
+ registerMessage = result.message;
+ loginUsername = registerUsername;
+ loginPassword = registerPassword;
})
- .then((result) => {
- registering = false;
- registerSuccess = true;
- registerMessage = result.message;
- loginUsername = registerUsername;
- loginPassword = registerPassword;
- })
- .catch((e) => {
- registering = false;
- registerMessage = e.response.message;
- registerSuccess = false;
- });
- },
+ .catch((e) => {
+ registerMessage = e.response.message;
+ registerSuccess = false;
+ })
+ .finally(() => {
+ registering = false;
+ });
},
- [
- m("h1", "Register"),
- m("input[placeholder=username][name=username][required]", {
- value: registerUsername,
+ },
+ [
+ m("h1", "Register"),
+ m("input[placeholder=username][name=username][required]", {
+ value: registerUsername,
+ oninput: (e) => {
+ registerUsername = e.target.value;
+ },
+ }),
+ m(
+ "input[placeholder=password][name=password][type=password][required]",
+ {
+ value: registerPassword,
oninput: (e) => {
- registerUsername = e.target.value;
+ registerPassword = e.target.value;
},
- }),
- m(
- "input[placeholder=password][name=password][type=password][required]",
- {
- value: registerPassword,
- oninput: (e) => {
- registerPassword = e.target.value;
- },
- }
- ),
- m(
- "input[placeholder=confirm password][name=confirm][type=password][required]",
- {
- value: confirmPassword,
- oninput: (e) => {
- confirmPassword = e.target.value;
- },
- }
- ),
- m(
- "button[type=submit]",
- {
- disabled: registering ? "disabled" : null,
- },
- [
- m("i.icon.icon-user-plus"),
- registering ? " Registering..." : " Register",
- ]
- ),
- m(
- "p",
- {
- class:
- "auth--form--message-" +
- (registerSuccess ? "success" : "error"),
+ }
+ ),
+ m(
+ "input[placeholder=confirm password][name=confirm][type=password][required]",
+ {
+ value: confirmPassword,
+ oninput: (e) => {
+ confirmPassword = e.target.value;
},
- registerMessage
- ),
- ]
- ),
- ]),
+ }
+ ),
+ m(
+ "button[type=submit]",
+ {
+ disabled: registering ? "disabled" : null,
+ },
+ [
+ m("i.icon.icon-user-plus"),
+ registering ? " Registering..." : " Register",
+ ]
+ ),
+ m(
+ "p",
+ {
+ class:
+ "auth--form--message-" +
+ (registerSuccess ? "success" : "error"),
+ },
+ registerMessage
+ ),
+ ]
+ ),
]);
},
};
diff --git a/src/pytaku/static/js/routes/follows.js b/src/pytaku/static/js/routes/follows.js
new file mode 100644
index 0000000..cd4e18f
--- /dev/null
+++ b/src/pytaku/static/js/routes/follows.js
@@ -0,0 +1,31 @@
+import { Auth } from "../models.js";
+
+function Follows(initialVNode) {
+ let titles = [];
+ return {
+ oninit: () => {
+ Auth.request({
+ method: "GET",
+ url: "/api/follows",
+ });
+ },
+ oncreate: (vnode) => {
+ document.title = "Stuff I follow - Pytaku";
+ },
+ view: (vnode) => {
+ return m("div.content", [
+ titles.map((title) =>
+ m("div.title", [
+ m("div", [
+ m("a", { href: "TODO" }, [
+ m("img.cover", { src: title.thumbnail, alt: title.name }),
+ ]),
+ ]),
+ ])
+ ),
+ ]);
+ },
+ };
+}
+
+export default Follows;
diff --git a/src/pytaku/static/js/routes/home.js b/src/pytaku/static/js/routes/home.js
index df0ce6d..5005492 100644
--- a/src/pytaku/static/js/routes/home.js
+++ b/src/pytaku/static/js/routes/home.js
@@ -1,16 +1,13 @@
-import { Navbar } from "../common-components.js";
+import { Auth } from "../models.js";
const Home = {
oncreate: (vnode) => {
document.title = "Pytaku";
},
view: (vnode) => {
- return m("div.main", [
- m(Navbar),
- m("div.content", [
- m("p", "Try searching for some manga title using the box above."),
- m("p", "Logging in allows you to follow manga titles."),
- ]),
+ return m("div.content", [
+ m("p", "Try searching for some manga title using the box above."),
+ m("p", "Logging in allows you to follow manga titles."),
]);
},
};
diff --git a/src/pytaku/templates/spa.html b/src/pytaku/templates/spa.html
index ce36cfa..dabbf90 100644
--- a/src/pytaku/templates/spa.html
+++ b/src/pytaku/templates/spa.html
@@ -7,7 +7,7 @@
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="alternate icon" type="image/png" href="/static/favicon.png">
- <title>{% block title %} {% endblock %} - Pytaku</title>
+ <title>{% block title %}Pytaku{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='minireset.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='feathericons/iconfont.css') }}">