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

80
test/test_auth.py Normal file
View file

@ -0,0 +1,80 @@
from datetime import datetime
import pytest
from starlette.authentication import AuthenticationError
from aurweb.db import query
from aurweb.auth import BasicAuthBackend
from aurweb.models.account_type import AccountType
from aurweb.testing import setup_test_db
from aurweb.testing.models import make_session, make_user
from aurweb.testing.requests import Request
# Persistent user object, initialized in our setup fixture.
user = None
backend = None
request = None
@pytest.fixture(autouse=True)
def setup():
global user, backend, request
setup_test_db("Users", "Sessions")
from aurweb.db import session
account_type = query(AccountType,
AccountType.AccountType == "User").first()
user = make_user(Username="test", Email="test@example.com",
RealName="Test User", Passwd="testPassword",
AccountType=account_type)
session.add(user)
session.commit()
backend = BasicAuthBackend()
request = Request()
@pytest.mark.asyncio
async def test_auth_backend_missing_sid():
# The request has no AURSID cookie, so authentication fails, and
# AnonymousUser is returned.
_, result = await backend.authenticate(request)
assert not result.is_authenticated()
@pytest.mark.asyncio
async def test_auth_backend_invalid_sid():
# Provide a fake AURSID that won't be found in the database.
# This results in our path going down the invalid sid route,
# which gives us an AnonymousUser.
request.cookies["AURSID"] = "fake"
_, result = await backend.authenticate(request)
assert not result.is_authenticated()
@pytest.mark.asyncio
async def test_auth_backend_invalid_user_id():
# Create a new session with a fake user id.
now_ts = datetime.utcnow().timestamp()
make_session(UsersID=666, SessionID="realSession",
LastUpdateTS=now_ts + 5)
# Here, we specify a real SID; but it's user is not there.
request.cookies["AURSID"] = "realSession"
with pytest.raises(AuthenticationError, match="Invalid User ID: 666"):
await backend.authenticate(request)
@pytest.mark.asyncio
async def test_basic_auth_backend():
# This time, everything matches up. We expect the user to
# equal the real_user.
now_ts = datetime.utcnow().timestamp()
make_session(UsersID=user.ID, SessionID="realSession",
LastUpdateTS=now_ts + 5)
_, result = await backend.authenticate(request)
assert result == user

View file

@ -1,48 +1,86 @@
import hashlib
from datetime import datetime, timedelta
import bcrypt
import pytest
import aurweb.auth
import aurweb.config
from aurweb.db import query
from aurweb.models.account_type import AccountType
from aurweb.models.ban import Ban
from aurweb.models.session import Session
from aurweb.models.user import User
from aurweb.testing import setup_test_db
from aurweb.testing.models import make_session, make_user
from aurweb.testing.requests import Request
account_type, user = None, None
@pytest.fixture(autouse=True)
def setup():
setup_test_db("Users")
def test_user():
""" Test creating a user and reading its columns. """
from aurweb.db import session
# First, grab our target AccountType.
global account_type, user
setup_test_db("Users", "Sessions", "Bans")
account_type = session.query(AccountType).filter(
AccountType.AccountType == "User").first()
user = User(
AccountType=account_type,
RealName="Test User", Username="test",
Email="test@example.org", Passwd="abcd",
IRCNick="tester",
Salt="efgh", ResetKey="blahblah")
session.add(user)
session.commit()
user = make_user(Username="test", Email="test@example.org",
RealName="Test User", Passwd="testPassword",
AccountType=account_type)
def test_user_login_logout():
""" Test creating a user and reading its columns. """
from aurweb.db import session
# Assert that make_user created a valid user.
assert bool(user.ID)
# Test authentication.
assert user.valid_password("testPassword")
assert not user.valid_password("badPassword")
assert user in account_type.users
# Make sure the user was created and given an ID.
assert bool(user.ID)
# Make a raw request.
request = Request()
assert not user.login(request, "badPassword")
assert not user.is_authenticated()
sid = user.login(request, "testPassword")
assert sid is not None
assert user.is_authenticated()
assert "AURSID" in request.cookies
# Expect that User session relationships work right.
user_session = session.query(Session).filter(
Session.UsersID == user.ID).first()
assert user_session == user.session
assert user.session.SessionID == sid
assert user.session.User == user
# Search for the user via query API.
result = session.query(User).filter(User.ID == user.ID).first()
# Compare the result and our original user.
assert result == user
assert result.ID == user.ID
assert result.AccountType.ID == user.AccountType.ID
assert result.Username == user.Username
assert result.Email == user.Email
# Test result authenticate methods to ensure they work the same.
assert not result.valid_password("badPassword")
assert result.valid_password("testPassword")
assert result.is_authenticated()
# Ensure we've got the correct account type.
assert user.AccountType.ID == account_type.ID
assert user.AccountType.AccountType == account_type.AccountType
@ -51,4 +89,74 @@ def test_user():
assert repr(user) == f"<User(ID='{user.ID}', " + \
"AccountType='User', Username='test')>"
session.delete(user)
# Test logout.
user.logout(request)
assert "AURSID" not in request.cookies
assert not user.is_authenticated()
def test_user_login_twice():
request = Request()
assert user.login(request, "testPassword")
assert user.login(request, "testPassword")
def test_user_login_banned():
from aurweb.db import session
# Add ban for the next 30 seconds.
banned_timestamp = datetime.utcnow() + timedelta(seconds=30)
ban = Ban(IPAddress="127.0.0.1", BanTS=banned_timestamp)
session.add(ban)
session.commit()
request = Request()
request.client.host = "127.0.0.1"
assert not user.login(request, "testPassword")
def test_user_login_suspended():
from aurweb.db import session
user.Suspended = True
session.commit()
assert not user.login(Request(), "testPassword")
def test_legacy_user_authentication():
from aurweb.db import session
user.Salt = bcrypt.gensalt().decode()
user.Passwd = hashlib.md5(f"{user.Salt}testPassword".encode()).hexdigest()
session.commit()
assert not user.valid_password("badPassword")
assert user.valid_password("testPassword")
# Test by passing a password of None value in.
assert not user.valid_password(None)
def test_user_login_with_outdated_sid():
from aurweb.db import session
# Make a session with a LastUpdateTS 5 seconds ago, causing
# user.login to update it with a new sid.
_session = make_session(UsersID=user.ID, SessionID="stub",
LastUpdateTS=datetime.utcnow().timestamp() - 5)
sid = user.login(Request(), "testPassword")
assert sid and user.is_authenticated()
assert sid != "stub"
session.delete(_session)
session.commit()
def test_user_update_password():
user.update_password("secondPassword")
assert not user.valid_password("testPassword")
assert user.valid_password("secondPassword")
def test_user_minimum_passwd_length():
passwd_min_len = aurweb.config.getint("options", "passwd_min_len")
assert User.minimum_passwd_length() == passwd_min_len