Repos / pytaku / c94ffc8188
commit c94ffc818876ed3e534a17435f59400a511ed6c4
Author: Bùi Thành Nhân <hi@imnhan.com>
Date:   Tue Aug 4 23:22:18 2020 +0700

    implement register/login

diff --git a/poetry.lock b/poetry.lock
index da61531..76c4725 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,3 +1,20 @@
+[[package]]
+name = "argon2-cffi"
+version = "20.1.0"
+description = "The secure Argon2 password hashing algorithm."
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.extras]
+dev = ["coverage (>=5.0.2)", "hypothesis", "pytest", "sphinx", "wheel", "pre-commit"]
+docs = ["sphinx"]
+tests = ["coverage (>=5.0.2)", "hypothesis", "pytest"]
+
+[package.dependencies]
+cffi = ">=1.0.0"
+six = "*"
+
 [[package]]
 name = "certifi"
 version = "2020.6.20"
@@ -6,6 +23,17 @@ category = "main"
 optional = false
 python-versions = "*"
 
+[[package]]
+name = "cffi"
+version = "1.14.1"
+description = "Foreign Function Interface for Python calling C code."
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+pycparser = "*"
+
 [[package]]
 name = "chardet"
 version = "3.0.4"
@@ -109,6 +137,14 @@ category = "main"
 optional = false
 python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*"
 
+[[package]]
+name = "pycparser"
+version = "2.20"
+description = "C parser in Python"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
 [[package]]
 name = "requests"
 version = "2.24.0"
@@ -127,6 +163,14 @@ chardet = ">=3.0.2,<4"
 idna = ">=2.5,<3"
 urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26"
 
+[[package]]
+name = "six"
+version = "1.15.0"
+description = "Python 2 and 3 compatibility utilities"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
+
 [[package]]
 name = "urllib3"
 version = "1.25.10"
@@ -155,13 +199,61 @@ watchdog = ["watchdog"]
 [metadata]
 lock-version = "1.0"
 python-versions = "^3.7"
-content-hash = "4c5557f7eb25c9e75ce9983a3ba5cabcddb59a6d26ff11185732aa13f954a326"
+content-hash = "5efa8d3aa125259ad269866dce833dcfd765044a16533d68c65897d73d9eaff4"
 
 [metadata.files]
+argon2-cffi = [
+    {file = "argon2-cffi-20.1.0.tar.gz", hash = "sha256:d8029b2d3e4b4cea770e9e5a0104dd8fa185c1724a0f01528ae4826a6d25f97d"},
+    {file = "argon2_cffi-20.1.0-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:6ea92c980586931a816d61e4faf6c192b4abce89aa767ff6581e6ddc985ed003"},
+    {file = "argon2_cffi-20.1.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:05a8ac07c7026542377e38389638a8a1e9b78f1cd8439cd7493b39f08dd75fbf"},
+    {file = "argon2_cffi-20.1.0-cp27-cp27m-win32.whl", hash = "sha256:0bf066bc049332489bb2d75f69216416329d9dc65deee127152caeb16e5ce7d5"},
+    {file = "argon2_cffi-20.1.0-cp27-cp27m-win_amd64.whl", hash = "sha256:57358570592c46c420300ec94f2ff3b32cbccd10d38bdc12dc6979c4a8484fbc"},
+    {file = "argon2_cffi-20.1.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:7d455c802727710e9dfa69b74ccaab04568386ca17b0ad36350b622cd34606fe"},
+    {file = "argon2_cffi-20.1.0-cp35-abi3-manylinux1_x86_64.whl", hash = "sha256:b160416adc0f012fb1f12588a5e6954889510f82f698e23ed4f4fa57f12a0647"},
+    {file = "argon2_cffi-20.1.0-cp35-cp35m-win32.whl", hash = "sha256:9bee3212ba4f560af397b6d7146848c32a800652301843df06b9e8f68f0f7361"},
+    {file = "argon2_cffi-20.1.0-cp35-cp35m-win_amd64.whl", hash = "sha256:392c3c2ef91d12da510cfb6f9bae52512a4552573a9e27600bdb800e05905d2b"},
+    {file = "argon2_cffi-20.1.0-cp36-cp36m-win32.whl", hash = "sha256:ba7209b608945b889457f949cc04c8e762bed4fe3fec88ae9a6b7765ae82e496"},
+    {file = "argon2_cffi-20.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:da7f0445b71db6d3a72462e04f36544b0de871289b0bc8a7cc87c0f5ec7079fa"},
+    {file = "argon2_cffi-20.1.0-cp37-abi3-macosx_10_6_intel.whl", hash = "sha256:cc0e028b209a5483b6846053d5fd7165f460a1f14774d79e632e75e7ae64b82b"},
+    {file = "argon2_cffi-20.1.0-cp37-cp37m-win32.whl", hash = "sha256:18dee20e25e4be86680b178b35ccfc5d495ebd5792cd00781548d50880fee5c5"},
+    {file = "argon2_cffi-20.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:6678bb047373f52bcff02db8afab0d2a77d83bde61cfecea7c5c62e2335cb203"},
+    {file = "argon2_cffi-20.1.0-cp38-cp38-win32.whl", hash = "sha256:77e909cc756ef81d6abb60524d259d959bab384832f0c651ed7dcb6e5ccdbb78"},
+    {file = "argon2_cffi-20.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:9dfd5197852530294ecb5795c97a823839258dfd5eb9420233c7cfedec2058f2"},
+]
 certifi = [
     {file = "certifi-2020.6.20-py2.py3-none-any.whl", hash = "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"},
     {file = "certifi-2020.6.20.tar.gz", hash = "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3"},
 ]
+cffi = [
+    {file = "cffi-1.14.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:66dd45eb9530e3dde8f7c009f84568bc7cac489b93d04ac86e3111fb46e470c2"},
+    {file = "cffi-1.14.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:4f53e4128c81ca3212ff4cf097c797ab44646a40b42ec02a891155cd7a2ba4d8"},
+    {file = "cffi-1.14.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:833401b15de1bb92791d7b6fb353d4af60dc688eaa521bd97203dcd2d124a7c1"},
+    {file = "cffi-1.14.1-cp27-cp27m-win32.whl", hash = "sha256:26f33e8f6a70c255767e3c3f957ccafc7f1f706b966e110b855bfe944511f1f9"},
+    {file = "cffi-1.14.1-cp27-cp27m-win_amd64.whl", hash = "sha256:b87dfa9f10a470eee7f24234a37d1d5f51e5f5fa9eeffda7c282e2b8f5162eb1"},
+    {file = "cffi-1.14.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:effd2ba52cee4ceff1a77f20d2a9f9bf8d50353c854a282b8760ac15b9833168"},
+    {file = "cffi-1.14.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bac0d6f7728a9cc3c1e06d4fcbac12aaa70e9379b3025b27ec1226f0e2d404cf"},
+    {file = "cffi-1.14.1-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:d6033b4ffa34ef70f0b8086fd4c3df4bf801fee485a8a7d4519399818351aa8e"},
+    {file = "cffi-1.14.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:8416ed88ddc057bab0526d4e4e9f3660f614ac2394b5e019a628cdfff3733849"},
+    {file = "cffi-1.14.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:892daa86384994fdf4856cb43c93f40cbe80f7f95bb5da94971b39c7f54b3a9c"},
+    {file = "cffi-1.14.1-cp35-cp35m-win32.whl", hash = "sha256:c991112622baee0ae4d55c008380c32ecfd0ad417bcd0417ba432e6ba7328caa"},
+    {file = "cffi-1.14.1-cp35-cp35m-win_amd64.whl", hash = "sha256:fcf32bf76dc25e30ed793145a57426064520890d7c02866eb93d3e4abe516948"},
+    {file = "cffi-1.14.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f960375e9823ae6a07072ff7f8a85954e5a6434f97869f50d0e41649a1c8144f"},
+    {file = "cffi-1.14.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a6d28e7f14ecf3b2ad67c4f106841218c8ab12a0683b1528534a6c87d2307af3"},
+    {file = "cffi-1.14.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:cda422d54ee7905bfc53ee6915ab68fe7b230cacf581110df4272ee10462aadc"},
+    {file = "cffi-1.14.1-cp36-cp36m-win32.whl", hash = "sha256:4a03416915b82b81af5502459a8a9dd62a3c299b295dcdf470877cb948d655f2"},
+    {file = "cffi-1.14.1-cp36-cp36m-win_amd64.whl", hash = "sha256:4ce1e995aeecf7cc32380bc11598bfdfa017d592259d5da00fc7ded11e61d022"},
+    {file = "cffi-1.14.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e23cb7f1d8e0f93addf0cae3c5b6f00324cccb4a7949ee558d7b6ca973ab8ae9"},
+    {file = "cffi-1.14.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:ddff0b2bd7edcc8c82d1adde6dbbf5e60d57ce985402541cd2985c27f7bec2a0"},
+    {file = "cffi-1.14.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f90c2267101010de42f7273c94a1f026e56cbc043f9330acd8a80e64300aba33"},
+    {file = "cffi-1.14.1-cp37-cp37m-win32.whl", hash = "sha256:3cd2c044517f38d1b577f05927fb9729d3396f1d44d0c659a445599e79519792"},
+    {file = "cffi-1.14.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fa72a52a906425416f41738728268072d5acfd48cbe7796af07a923236bcf96"},
+    {file = "cffi-1.14.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:267adcf6e68d77ba154334a3e4fc921b8e63cbb38ca00d33d40655d4228502bc"},
+    {file = "cffi-1.14.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:d3148b6ba3923c5850ea197a91a42683f946dba7e8eb82dfa211ab7e708de939"},
+    {file = "cffi-1.14.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:98be759efdb5e5fa161e46d404f4e0ce388e72fbf7d9baf010aff16689e22abe"},
+    {file = "cffi-1.14.1-cp38-cp38-win32.whl", hash = "sha256:6923d077d9ae9e8bacbdb1c07ae78405a9306c8fd1af13bfa06ca891095eb995"},
+    {file = "cffi-1.14.1-cp38-cp38-win_amd64.whl", hash = "sha256:b1d6ebc891607e71fd9da71688fcf332a6630b7f5b7f5549e6e631821c0e5d90"},
+    {file = "cffi-1.14.1.tar.gz", hash = "sha256:b2a2b0d276a136146e012154baefaea2758ef1f56ae9f4e01c612b0831e0bd2f"},
+]
 chardet = [
     {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"},
     {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"},
@@ -229,10 +321,18 @@ markupsafe = [
     {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"},
     {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"},
 ]
+pycparser = [
+    {file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"},
+    {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"},
+]
 requests = [
     {file = "requests-2.24.0-py2.py3-none-any.whl", hash = "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"},
     {file = "requests-2.24.0.tar.gz", hash = "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b"},
 ]
+six = [
+    {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"},
+    {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"},
+]
 urllib3 = [
     {file = "urllib3-1.25.10-py2.py3-none-any.whl", hash = "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"},
     {file = "urllib3-1.25.10.tar.gz", hash = "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a"},
diff --git a/pyproject.toml b/pyproject.toml
index ed83127..0ec75e8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -19,6 +19,7 @@ flask = "^1.1.2"
 gunicorn = "^20.0.4"
 requests = "^2.24.0"
 goodconf = "^1.0.0"
+argon2-cffi = "^20.1.0"
 
 [tool.poetry.dev-dependencies]
 
diff --git a/src/pytaku/conf.py b/src/pytaku/conf.py
index dc1786e..1004b16 100644
--- a/src/pytaku/conf.py
+++ b/src/pytaku/conf.py
@@ -1,3 +1,5 @@
+from secrets import token_urlsafe
+
 from goodconf import GoodConf, Value
 
 
@@ -5,5 +7,7 @@ class Config(GoodConf):
     MANGADEX_USERNAME = Value()
     MANGADEX_PASSWORD = Value()
 
+    FLASK_SECRET_KEY = Value(initial=lambda: token_urlsafe(50))
+
 
 config = Config(default_files=["pytaku.conf.json"], load=True)
diff --git a/src/pytaku/database/migrations/latest_schema.sql b/src/pytaku/database/migrations/latest_schema.sql
index 92857ab..cdff562 100644
--- a/src/pytaku/database/migrations/latest_schema.sql
+++ b/src/pytaku/database/migrations/latest_schema.sql
@@ -28,3 +28,19 @@ CREATE TABLE chapter (
     unique(id, title_id, site),
     unique(num_major, num_minor, title_id)
 );
+CREATE TABLE user (
+    id integer primary key,
+    username text unique,
+    password text,
+    created_at text default (datetime('now'))
+);
+CREATE TABLE follow (
+    user_id integer not null,
+    title_id text not null,
+    site text not null,
+    created_at text default (datetime('now')),
+
+    foreign key (title_id, site) references title (id, site),
+    foreign key (user_id) references user (id),
+    unique(user_id, title_id, site)
+);
diff --git a/src/pytaku/database/migrations/m0003.sql b/src/pytaku/database/migrations/m0003.sql
new file mode 100644
index 0000000..1cd8c9b
--- /dev/null
+++ b/src/pytaku/database/migrations/m0003.sql
@@ -0,0 +1,18 @@
+create table user (
+    id integer primary key,
+    username text unique,
+    password text,
+    created_at text default (datetime('now'))
+);
+
+
+create table follow (
+    user_id integer not null,
+    title_id text not null,
+    site text not null,
+    created_at text default (datetime('now')),
+
+    foreign key (title_id, site) references title (id, site),
+    foreign key (user_id) references user (id),
+    unique(user_id, title_id, site)
+);
diff --git a/src/pytaku/decorators.py b/src/pytaku/decorators.py
new file mode 100644
index 0000000..ff5f4e2
--- /dev/null
+++ b/src/pytaku/decorators.py
@@ -0,0 +1,13 @@
+from functools import wraps
+
+from flask import redirect, request, session, url_for
+
+
+def require_login(f):
+    @wraps(f)
+    def decorated_function(*args, **kwargs):
+        if session.get("user") is None:
+            return redirect(url_for("auth_view", next=request.url))
+        return f(*args, **kwargs)
+
+    return decorated_function
diff --git a/src/pytaku/main.py b/src/pytaku/main.py
index 0a23275..bbfb064 100644
--- a/src/pytaku/main.py
+++ b/src/pytaku/main.py
@@ -1,21 +1,36 @@
 import base64
 import re
+from datetime import timedelta
 
 import requests
-from flask import Flask, make_response, render_template, request, url_for
+from flask import (
+    Flask,
+    make_response,
+    redirect,
+    render_template,
+    request,
+    session,
+    url_for,
+)
 
 from mangoapi import get_chapter, get_title, search_title
 
 from . import mangadex
+from .conf import config
 from .persistence import (
     get_prev_next_chapters,
     load_chapter,
     load_title,
+    register_user,
     save_chapter,
     save_title,
+    verify_username_password,
 )
 
 app = Flask(__name__)
+app.config.update(
+    SECRET_KEY=config.FLASK_SECRET_KEY, PERMANENT_SESSION_LIFETIME=timedelta(days=365),
+)
 
 
 @app.route("/")
@@ -23,6 +38,93 @@ def home_view():
     return render_template("home.html")
 
 
+@app.route("/logout", methods=["POST"])
+def logout_view():
+    session.pop("user")
+    return redirect("/")
+
+
+@app.route("/auth", methods=["GET", "POST"])
+def auth_view():
+    if session.get("user"):
+        return redirect(url_for("home_view"))
+
+    if request.method == "POST":
+
+        if request.form["action"] == "register":
+            username = request.form["username"].strip()
+            password = request.form["password"]
+            confirm_password = request.form["confirm-password"]
+            message = None
+            if password != confirm_password:
+                message = "Password confirmation didn't match."
+                status_code = 400
+            elif not (username and password and confirm_password):
+                message = "Empty field(s) spotted. Protip: spaces don't count."
+                status_code = 400
+            elif (
+                len(username) < 2
+                or len(username) > 15
+                or len(password) < 5
+                or len(password) > 50
+            ):
+                message = "Invalid username/password length. Username length should be 2~15, password 5~50."
+                status_code = 400
+            else:  # success!
+                err = register_user(username, password)
+                if err:
+                    message = err
+                    status_code = 400
+                else:
+                    username = ""
+                    password = ""
+                    confirm_password = ""
+                    message = "Registration successful! You can login now."
+                    status_code = 200
+            return (
+                render_template(
+                    "auth.html",
+                    register_username=username,
+                    register_password=password,
+                    register_confirm_password=confirm_password,
+                    register_message=message,
+                    register_has_error=status_code != 200,
+                ),
+                status_code,
+            )
+
+        else:  # action == 'login'
+            username = request.form["username"].strip()
+            password = request.form["password"]
+            remember = request.form.get("remember") == "on"
+            if not (username and password):
+                message = "Empty field(s) spotted. Protip: spaces don't count."
+                status_code = 400
+            elif not verify_username_password(username, password):
+                message = "Wrong username/password combination."
+                status_code = 400
+            else:  # success!
+                resp = redirect(request.args.get("next", url_for("home_view")))
+                session.permanent = remember
+                session["user"] = {"username": username}
+                return resp
+
+            return (
+                render_template(
+                    "auth.html",
+                    login_username=username,
+                    login_password=password,
+                    login_remember=remember,
+                    login_message=message,
+                    login_has_error=status_code != 200,
+                ),
+                status_code,
+            )
+
+    # Just a plain ol' GET request:
+    return render_template("auth.html")
+
+
 @app.route("/title/mangadex/<title_id>")
 def title_view(title_id):
     title = load_title(title_id)
diff --git a/src/pytaku/persistence.py b/src/pytaku/persistence.py
index e12c15e..61bfde4 100644
--- a/src/pytaku/persistence.py
+++ b/src/pytaku/persistence.py
@@ -1,5 +1,8 @@
 import json
 
+import apsw
+import argon2
+
 from .database.common import get_conn
 
 
@@ -161,3 +164,38 @@ def get_prev_next_chapters(title, chapter):
                 prev_chapter = chapters[i + 1]
 
     return prev_chapter, next_chapter
+
+
+def register_user(username, password):
+    hasher = argon2.PasswordHasher()
+    hashed_password = hasher.hash(password)
+    try:
+        get_conn().cursor().execute(
+            "INSERT INTO user (username, password) VALUES (?, ?);",
+            (username, hashed_password),
+        )
+        return None
+    except apsw.ConstraintError as e:
+        if "UNIQUE" in str(e):
+            return "Username already exists."
+        raise
+
+
+def verify_username_password(username, password):
+    data = list(
+        get_conn()
+        .cursor()
+        .execute("SELECT password FROM user WHERE username = ?;", (username,))
+    )
+    if len(data) != 1:
+        print(f"User {username} doesn't exist.")
+        return False
+
+    hasher = argon2.PasswordHasher()
+    hash = data[0][0]
+    try:
+        hasher.verify(hash, password)
+        return True
+    except argon2.exceptions.VerifyMismatchError:
+        print(f"User {username} exists but password doesn't match.")
+        return False
diff --git a/src/pytaku/static/base.css b/src/pytaku/static/base.css
index e366545..6b9f446 100644
--- a/src/pytaku/static/base.css
+++ b/src/pytaku/static/base.css
@@ -131,6 +131,7 @@ .links {
   display: inline-flex;
   justify-content: center;
   align-items: center;
+  color: white;
 }
 .links > * {
   margin-right: 1rem;
diff --git a/src/pytaku/static/icons/log-in.svg b/src/pytaku/static/icons/log-in.svg
new file mode 100644
index 0000000..7bfc566
--- /dev/null
+++ b/src/pytaku/static/icons/log-in.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-log-in"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path><polyline points="10 17 15 12 10 7"></polyline><line x1="15" y1="12" x2="3" y2="12"></line></svg>
\ No newline at end of file
diff --git a/src/pytaku/static/icons/log-out.svg b/src/pytaku/static/icons/log-out.svg
new file mode 100644
index 0000000..628a96d
--- /dev/null
+++ b/src/pytaku/static/icons/log-out.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-log-out"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
\ No newline at end of file
diff --git a/src/pytaku/templates/auth.html b/src/pytaku/templates/auth.html
new file mode 100644
index 0000000..96b042a
--- /dev/null
+++ b/src/pytaku/templates/auth.html
@@ -0,0 +1,67 @@
+{% extends 'base.html' %}
+
+{% block title %}
+Login / Register
+{% endblock %}
+
+{% block head %}
+<style>
+.content {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  margin: auto;
+  max-width: 900px;
+  padding: 0;
+}
+
+.content > form {
+  display: flex;
+  flex-direction: column;
+  flex: 1;
+  margin: 0 1rem;
+}
+
+.content input,
+.content button {
+  margin: .5rem 0;
+}
+
+.success {
+  color: inherit;
+}
+.error {
+  color: red;
+}
+
+</style>
+{% endblock %}
+
+{% block content %}
+<form method="POST">
+  <h1>Login</h1>
+  <input type="hidden" name="action" value="login">
+  <input name="username" placeholder="Username" required value="{{ login_username }}">
+  <input name="password" type="password" placeholder="Password" required value="{{ login_password }}">
+  <label for="remember">
+    <input name="remember" id="remember" type="checkbox" {% if login_remember %}checked{% endif %}>
+    Remember me
+  </label>
+  <button class="green button" type="submit">Login</button>
+  <p class="{{ 'error' if login_has_error else 'success'}}">
+  {{ login_message }}
+  </p>
+</form>
+
+<form method="POST">
+  <h1>Register</h1>
+  <input type="hidden" name="action" value="register">
+  <input name="username" placeholder="Username (2~15 chars)" required value="{{ register_username }}">
+  <input name="password" type="password" placeholder="Password (5~50 chars)" required value="{{ register_password }}">
+  <input name="confirm-password" type="password" placeholder="Confirm password" required value="{{ register_confirm_password }}">
+  <button class="blue button" type="submit">Register</button>
+  <p class="{{ 'error' if register_has_error else 'success'}}">
+  {{ register_message }}
+  </p>
+</form>
+{% endblock %}
diff --git a/src/pytaku/templates/base.html b/src/pytaku/templates/base.html
index 022fefc..6fef79e 100644
--- a/src/pytaku/templates/base.html
+++ b/src/pytaku/templates/base.html
@@ -47,14 +47,17 @@
         </button>
       </form>
       <span class="links">
-        <a href="/bookmarks">
-          <img src="{{ url_for('static', filename='icons/bookmark.svg')}}" alt="bookmarks" />
-          <span>Bookmarks</span>
-        </a>
-        <a href="/account">
-          <img src="{{ url_for('static', filename='icons/user.svg')}}" alt="account" />
-          <span>Account</span>
+        {% if session.get('user') %}
+        <span>Hi there <strong>{{ session.user['username'] }}</strong>!</span>
+        <form method="POST" action="{{ url_for('logout_view') }}">
+          {{ ibutton(left_icon='log-out', text='Logout') }}
+        </form>
+        {% else %}
+        <a href="/auth">
+          <img src="{{ url_for('static', filename='icons/log-in.svg')}}" alt="login/register" />
+          <span>Login/Register</span>
         </a>
+        {% endif %}
       </span>
       </ul>
     </nav>