add aurweb.auth and authentication to User

+ Added aurweb.auth.AnonymousUser
    * An instance of this model is returned as the request user
      when the request is not authenticated
+ Added aurweb.auth.BasicAuthBackend
+ Add starlette's AuthenticationMiddleware to app middleware,
  which uses our BasicAuthBackend facility
+ Added User.is_authenticated()
+ Added User.authenticate(password)
+ Added User.login(request, password)
+ Added User.logout(request)
+ Added repr(User(...)) representation
+ Added aurweb.auth.auth_required decorator.

This change uses the same AURSID logic in the PHP implementation.

Additionally, introduce a few helpers for authentication,
one of which being `User.update_password(password, rounds = 12)`
where `rounds` is a configurable number of salt rounds.

Signed-off-by: Kevin Morris <kevr@0cost.org>
This commit is contained in:
Kevin Morris 2020-12-25 20:54:53 -08:00
parent 137c050f99
commit 56f2798279
5 changed files with 412 additions and 20 deletions

View file

@ -1,12 +1,15 @@
import http
import os
from fastapi import FastAPI, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.middleware.sessions import SessionMiddleware
import aurweb.config
from aurweb.auth import BasicAuthBackend
from aurweb.db import get_engine
from aurweb.routers import html, sso, errors
@ -32,10 +35,15 @@ async def app_startup():
StaticFiles(directory="web/html/images"),
name="static_images")
# Add application middlewares.
app.add_middleware(AuthenticationMiddleware, backend=BasicAuthBackend())
app.add_middleware(SessionMiddleware, secret_key=session_secret)
# Add application routes.
app.include_router(sso.router)
app.include_router(html.router)
# Initialize the database engine and ORM.
get_engine()
# NOTE: Always keep this dictionary updated with all routes

77
aurweb/auth.py Normal file
View file

@ -0,0 +1,77 @@
import functools
from datetime import datetime
from http import HTTPStatus
from fastapi.responses import RedirectResponse
from starlette.authentication import AuthCredentials, AuthenticationBackend, AuthenticationError
from starlette.requests import HTTPConnection
from aurweb.models.session import Session
from aurweb.models.user import User
from aurweb.templates import make_context, render_template
class AnonymousUser:
@staticmethod
def is_authenticated():
return False
class BasicAuthBackend(AuthenticationBackend):
async def authenticate(self, conn: HTTPConnection):
from aurweb.db import session
sid = conn.cookies.get("AURSID")
if not sid:
return None, AnonymousUser()
now_ts = datetime.utcnow().timestamp()
record = session.query(Session).filter(
Session.SessionID == sid, Session.LastUpdateTS >= now_ts).first()
if not record:
return None, AnonymousUser()
user = session.query(User).filter(User.ID == record.UsersID).first()
if not user:
raise AuthenticationError(f"Invalid User ID: {record.UsersID}")
user.authenticated = True
return AuthCredentials(["authenticated"]), user
def auth_required(is_required: bool = True,
redirect: str = "/",
template: tuple = None):
""" Authentication route decorator.
If redirect is given, the user will be redirected if the auth state
does not match is_required.
If template is given, it will be rendered with Unauthorized if
is_required does not match and take priority over redirect.
:param is_required: A boolean indicating whether the function requires auth
:param redirect: Path to redirect to if is_required isn't True
:param template: A template tuple: ("template.html", "Template Page")
"""
def decorator(func):
@functools.wraps(func)
async def wrapper(request, *args, **kwargs):
if request.user.is_authenticated() != is_required:
status_code = int(HTTPStatus.UNAUTHORIZED)
url = "/"
if redirect:
status_code = int(HTTPStatus.SEE_OTHER)
url = redirect
if template:
path, title = template
context = make_context(request, title)
return render_template(request, path, context,
status_code=int(HTTPStatus.UNAUTHORIZED))
return RedirectResponse(url=url, status_code=status_code)
return await func(request, *args, **kwargs)
return wrapper
return decorator

View file

@ -1,13 +1,25 @@
import hashlib
from datetime import datetime
import bcrypt
from fastapi import Request
from sqlalchemy.orm import backref, mapper, relationship
import aurweb.config
from aurweb.models.account_type import AccountType
from aurweb.models.ban import is_banned
from aurweb.schema import Users
class User:
""" An ORM model of a single Users record. """
authenticated = False
def __init__(self, **kwargs):
# Set AccountTypeID if it was passed.
self.AccountTypeID = kwargs.get("AccountTypeID")
account_type = kwargs.get("AccountType")
@ -15,22 +27,129 @@ class User:
self.AccountType = account_type
self.Username = kwargs.get("Username")
self.ResetKey = kwargs.get("ResetKey")
self.Email = kwargs.get("Email")
self.BackupEmail = kwargs.get("BackupEmail")
self.Passwd = kwargs.get("Passwd")
self.Salt = kwargs.get("Salt")
self.RealName = kwargs.get("RealName")
self.LangPreference = kwargs.get("LangPreference")
self.Timezone = kwargs.get("Timezone")
self.Homepage = kwargs.get("Homepage")
self.IRCNick = kwargs.get("IRCNick")
self.PGPKey = kwargs.get("PGPKey")
self.RegistrationTS = kwargs.get("RegistrationTS")
self.RegistrationTS = datetime.utcnow()
self.CommentNotify = kwargs.get("CommentNotify")
self.UpdateNotify = kwargs.get("UpdateNotify")
self.OwnershipNotify = kwargs.get("OwnershipNotify")
self.SSOAccountID = kwargs.get("SSOAccountID")
self.Salt = None
self.Passwd = str()
passwd = kwargs.get("Passwd")
if passwd:
self.update_password(passwd)
def update_password(self, password, salt_rounds=12):
from aurweb.db import session
self.Passwd = bcrypt.hashpw(
password.encode(),
bcrypt.gensalt(rounds=salt_rounds)).decode()
session.commit()
@staticmethod
def minimum_passwd_length():
return aurweb.config.getint("options", "passwd_min_len")
def is_authenticated(self):
""" Return internal authenticated state. """
return self.authenticated
def valid_password(self, password: str):
""" Check authentication against a given password. """
from aurweb.db import session
if password is None:
return False
password_is_valid = False
try:
password_is_valid = bcrypt.checkpw(password.encode(),
self.Passwd.encode())
except ValueError:
pass
# If our Salt column is not empty, we're using a legacy password.
if not password_is_valid and self.Salt != str():
# Try to login with legacy method.
password_is_valid = hashlib.md5(
f"{self.Salt}{password}".encode()
).hexdigest() == self.Passwd
# We got here, we passed the legacy authentication.
# Update the password to our modern hash style.
if password_is_valid:
self.update_password(password)
return password_is_valid
def _login_approved(self, request: Request):
return not is_banned(request) and not self.Suspended
def login(self, request: Request, password: str, session_time=0):
""" Login and authenticate a request. """
from aurweb.db import session
from aurweb.models.session import Session, generate_unique_sid
if not self._login_approved(request):
return None
self.authenticated = self.valid_password(password)
if not self.authenticated:
return None
self.LastLogin = now_ts = datetime.utcnow().timestamp()
self.LastLoginIPAddress = request.client.host
session.commit()
session_ts = now_ts + (
session_time if session_time
else aurweb.config.getint("options", "login_timeout")
)
sid = None
if not self.session:
sid = generate_unique_sid()
self.session = Session(UsersID=self.ID, SessionID=sid,
LastUpdateTS=session_ts)
session.add(self.session)
else:
last_updated = self.session.LastUpdateTS
if last_updated and last_updated < now_ts:
self.session.SessionID = sid = generate_unique_sid()
else:
# Session is still valid; retrieve the current SID.
sid = self.session.SessionID
self.session.LastUpdateTS = session_ts
session.commit()
request.cookies["AURSID"] = self.session.SessionID
return self.session.SessionID
def logout(self, request):
from aurweb.db import session
del request.cookies["AURSID"]
self.authenticated = False
if self.session:
session.delete(self.session)
session.commit()
def __repr__(self):
return "<User(ID='%s', AccountType='%s', Username='%s')>" % (
self.ID, str(self.AccountType), self.Username)