From 5d4a5deddf59806a691cda8d6933c7049b84db53 Mon Sep 17 00:00:00 2001 From: Kevin Morris Date: Thu, 31 Dec 2020 20:44:59 -0800 Subject: [PATCH] implement login + logout routes and templates + Added route: GET `/login` via `aurweb.routers.auth.login_get` + Added route: POST `/login` via `aurweb.routers.auth.login_post` + Added route: GET `/logout` via `aurweb.routers.auth.logout` + Added route: POST `/logout` via `aurweb.routers.auth.logout_post` * Modify archdev-navbar.html template to toggle displays on auth state + Added login.html template Signed-off-by: Kevin Morris --- aurweb/asgi.py | 3 +- aurweb/routers/auth.py | 85 +++++++++++++++++ templates/login.html | 84 +++++++++++++++++ templates/partials/archdev-navbar.html | 18 +++- test/test_auth_routes.py | 126 +++++++++++++++++++++++++ 5 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 aurweb/routers/auth.py create mode 100644 templates/login.html create mode 100644 test/test_auth_routes.py diff --git a/aurweb/asgi.py b/aurweb/asgi.py index 4d21ad03..b15e5874 100644 --- a/aurweb/asgi.py +++ b/aurweb/asgi.py @@ -11,7 +11,7 @@ import aurweb.config from aurweb.auth import BasicAuthBackend from aurweb.db import get_engine -from aurweb.routers import html, sso, errors +from aurweb.routers import auth, html, sso, errors routes = set() @@ -42,6 +42,7 @@ async def app_startup(): # Add application routes. app.include_router(sso.router) app.include_router(html.router) + app.include_router(auth.router) # Initialize the database engine and ORM. get_engine() diff --git a/aurweb/routers/auth.py b/aurweb/routers/auth.py new file mode 100644 index 00000000..24f5d4e3 --- /dev/null +++ b/aurweb/routers/auth.py @@ -0,0 +1,85 @@ +from datetime import datetime +from http import HTTPStatus + +from fastapi import APIRouter, Form, Request +from fastapi.responses import HTMLResponse, RedirectResponse + +import aurweb.config + +from aurweb.models.user import User +from aurweb.templates import make_context, render_template + +router = APIRouter() + + +def login_template(request: Request, next: str, errors: list = None): + """ Provide login-specific template context to render_template. """ + context = make_context(request, "Login", next) + context["errors"] = errors + context["url_base"] = f"{request.url.scheme}://{request.url.netloc}" + return render_template("login.html", context) + + +@router.get("/login", response_class=HTMLResponse) +async def login_get(request: Request, next: str = "/"): + """ Homepage route. """ + return login_template(request, next) + + +@router.post("/login", response_class=HTMLResponse) +async def login_post(request: Request, + next: str = Form(...), + user: str = Form(default=str()), + passwd: str = Form(default=str()), + remember_me: bool = Form(default=False)): + from aurweb.db import session + + user = session.query(User).filter(User.Username == user).first() + if not user: + return login_template(request, next, + errors=["Bad username or password."]) + + cookie_timeout = 0 + + if remember_me: + cookie_timeout = aurweb.config.getint( + "options", "persistent_cookie_timeout") + + _, sid = user.login(request, passwd, cookie_timeout) + if not _: + return login_template(request, next, + errors=["Bad username or password."]) + + login_timeout = aurweb.config.getint("options", "login_timeout") + + expires_at = int(datetime.utcnow().timestamp() + + max(cookie_timeout, login_timeout)) + + response = RedirectResponse(url=next, + status_code=int(HTTPStatus.SEE_OTHER)) + response.set_cookie("AURSID", sid, expires=expires_at) + return response + + +@router.get("/logout") +async def logout(request: Request, next: str = "/"): + """ A GET and POST route for logging out. + + @param request FastAPI request + @param next Route to redirect to + """ + if request.user.is_authenticated(): + request.user.logout(request) + + # Use 303 since we may be handling a post request, that'll get it + # to redirect to a get request. + response = RedirectResponse(url=next, + status_code=int(HTTPStatus.SEE_OTHER)) + response.delete_cookie("AURSID") + response.delete_cookie("AURTZ") + return response + + +@router.post("/logout") +async def logout_post(request: Request, next: str = "/"): + return await logout(request=request, next=next) diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 00000000..da7bd722 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,84 @@ +{% extends 'partials/layout.html' %} + +{% block pageContent %} + +
+

AUR {% trans %}Login{% endtrans %}

+ + {% if request.url.scheme == "http" and config.getboolean("options", "disable_http_login") %} + {% set https_login = url_base.replace("http://", "https://") + "/login/" %} +

+ {{ "HTTP login is disabled. Please %sswitch to HTTPs%s if you want to login." + | tr + | format( + '' | format(https_login), + "") + | safe + }} +

+ {% else %} + {% if request.user.is_authenticated() %} +

+ {{ "Logged-in as: %s" + | tr + | format("%s" | format(request.user.Username)) + | safe + }} + [{% trans %}Logout{% endtrans %}] +

+ {% else %} +
+
+ {% trans %}Enter login credentials{% endtrans %} + + {% if errors %} +
    + {% for error in errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} + +

+ + + +

+ +

+ + +

+ +

+ + +

+ +

+ + + [{% trans %}Forgot Password{% endtrans %}] + + + +

+ +
+
+ {% endif %} + {% endif %} + +
+ +{% endblock %} diff --git a/templates/partials/archdev-navbar.html b/templates/partials/archdev-navbar.html index 55338bc4..7662e3a4 100644 --- a/templates/partials/archdev-navbar.html +++ b/templates/partials/archdev-navbar.html @@ -1,8 +1,22 @@ diff --git a/test/test_auth_routes.py b/test/test_auth_routes.py new file mode 100644 index 00000000..adf75329 --- /dev/null +++ b/test/test_auth_routes.py @@ -0,0 +1,126 @@ +from datetime import datetime +from http import HTTPStatus + +import pytest + +from fastapi.testclient import TestClient + +import aurweb.config + +from aurweb.asgi import app +from aurweb.db import query +from aurweb.models.account_type import AccountType +from aurweb.models.session import Session +from aurweb.testing import setup_test_db +from aurweb.testing.models import make_user + +client = TestClient(app) + +user = None + + +@pytest.fixture(autouse=True) +def setup(): + global user + + setup_test_db("Users", "Sessions", "Bans") + + account_type = query(AccountType, + AccountType.AccountType == "User").first() + user = make_user(Username="test", Email="test@example.org", + RealName="Test User", Passwd="testPassword", + AccountType=account_type) + + +def test_login_logout(): + post_data = { + "user": "test", + "passwd": "testPassword", + "next": "/" + } + + with client as request: + response = client.get("/login") + assert response.status_code == int(HTTPStatus.OK) + + response = request.post("/login", data=post_data, + allow_redirects=False) + assert response.status_code == int(HTTPStatus.SEE_OTHER) + + response = request.get(response.headers.get("location"), cookies={ + "AURSID": response.cookies.get("AURSID") + }) + assert response.status_code == int(HTTPStatus.OK) + + response = request.post("/logout", data=post_data, + allow_redirects=False) + assert response.status_code == int(HTTPStatus.SEE_OTHER) + + response = request.post("/logout", data=post_data, cookies={ + "AURSID": response.cookies.get("AURSID") + }, allow_redirects=False) + assert response.status_code == int(HTTPStatus.SEE_OTHER) + assert "AURSID" not in response.cookies + + +def test_login_missing_username(): + post_data = { + "passwd": "testPassword", + "next": "/" + } + + with client as request: + response = request.post("/login", data=post_data) + assert "AURSID" not in response.cookies + + +def test_login_remember_me(): + from aurweb.db import session + + post_data = { + "user": "test", + "passwd": "testPassword", + "next": "/", + "remember_me": True + } + + with client as request: + response = request.post("/login", data=post_data, + allow_redirects=False) + assert response.status_code == int(HTTPStatus.SEE_OTHER) + assert "AURSID" in response.cookies + + cookie_timeout = aurweb.config.getint( + "options", "persistent_cookie_timeout") + expected_ts = datetime.utcnow().timestamp() + cookie_timeout + + _session = session.query(Session).filter( + Session.UsersID == user.ID).first() + + # Expect that LastUpdateTS was within 5 seconds of the expected_ts, + # which is equal to the current timestamp + persistent_cookie_timeout. + assert _session.LastUpdateTS > expected_ts - 5 + assert _session.LastUpdateTS < expected_ts + 5 + + +def test_login_missing_password(): + post_data = { + "user": "test", + "next": "/" + } + + with client as request: + response = request.post("/login", data=post_data) + assert "AURSID" not in response.cookies + + +def test_login_incorrect_password(): + post_data = { + "user": "test", + "passwd": "badPassword", + "next": "/" + } + + with client as request: + response = request.post("/login", data=post_data) + assert "AURSID" not in response.cookies