mirror of
https://gitlab.archlinux.org/archlinux/aurweb.git
synced 2025-02-03 10:43:03 +01:00
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:
parent
137c050f99
commit
56f2798279
5 changed files with 412 additions and 20 deletions
|
@ -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
77
aurweb/auth.py
Normal 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
|
|
@ -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)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue