Source code for gavo.protocols.creds

"""
Code for checking against our user db.

Todo: evaluate using twisted.cred for this; but then I guess all this needs
a thorough shakeup looking towards OAuth2 anyway.

We store the passwords hashed with scrypt with 16 bytes of salt.
Of course, since we only support http basic auth at this point, this
level of security really only makes sense if credential transmission
is restricted to https; and with current DaCHS, this means disabling
http altogether.
"""

#c Copyright 2008-2023, the GAVO project <gavo@ari.uni-heidelberg.de>
#c
#c This program is free software, covered by the GNU GPL.  See the
#c COPYING file in the source distribution.


import base64
import functools
import hashlib
import os

from gavo import base
from gavo import svcs

from gavo.utils import AllEncompassingSet

# we're limiting the password length to thwart DoS with endless passwords.
# (and because we don't think overlong passwords make any sense at all)
MAX_PASSWORD_LENGTH = 64

# this should only be changed for unit tests
adminProfile = "admin"


# We fix the scrypt parameters to what looks reasonable in 2020.
# If the default arguments are ever changed, use a different hash prefix.
scryptN4 = functools.partial(hashlib.scrypt, n=4, r=8, p=1)


[docs]def hashPassword(pw): """returns pw hashed and encoded with the salt. Our storage format is: "scrypt:"+b64encode(<16 bytes of salt><hash> """ if len(pw)>MAX_PASSWORD_LENGTH: raise base.ReportableError("Passwords in DaCHS must be shorter than" " %d characters") salt = os.urandom(16) payload = pw.encode("utf-8") hash = scryptN4(payload, salt=salt) return "scrypt:"+base64.b64encode(salt+hash).decode("ascii")
[docs]def hashMatches(pwIn, storedHash): """returns true if pwIn matches the encoded hash value computed with hashPassword. """ if len(pwIn)>MAX_PASSWORD_LENGTH: raise svcs.ForbiddenURI("You passed in an overlong password." " The server will not even look at it.") if not storedHash.startswith("scrypt:"): raise ValueError(f"Bad hash serialisation: '{storedHash}'") saltAndHash = base64.b64decode(storedHash[7:]) salt, hash = saltAndHash[:16], saltAndHash[16:] return hash==scryptN4(pwIn.encode("utf-8"), salt=salt)
[docs]@functools.lru_cache(None) def getHashedAdminPassword(): password = base.getConfig("web", "adminpasswd") if password.startswith("scrypt:"): return password else: return hashPassword(password)
[docs]def isAdmin(username, password): """returns True if username and password match what's configured in gavorc. """ if (username=='gavoadmin' and password and hashMatches(password, getHashedAdminPassword())): return True return False
[docs]def getGroupsForUser(username, password): """returns a set of all groups user username belongs to. If username and password don't match, you'll get an empty set. """ # see below on this sore if isinstance(username, bytes): username = username.decode("utf-8") if isinstance(password, bytes): password = password.decode("utf-8") if username is None: return set() if isAdmin(username, password): return AllEncompassingSet() query = ("SELECT groupname, password" " FROM dc.groups" " NATURAL JOIN dc.users" " WHERE username=%(username)s") res = set() storedHash = None with base.getAdminConn() as conn: for row in conn.query(query, locals()): storedHash = row[1] res.add(row[0]) # we only need to check the password once because user is primary in # dc.users. if storedHash and hashMatches(password, storedHash): return res else: return set()
[docs]def hasCredentials(user, password, reqGroup): """returns true if user and password match the db entry and the user is in the reqGroup. If reqGroup is None, true will be returned if the user/password pair is in the user table. """ # sometimes my request.getUser returns an empty string (it should be # bytes, I guess). I won't hunt this down and just work around it if isinstance(user, bytes): user = user.decode("utf-8") if isinstance(password, bytes): password = password.decode("utf-8") if isAdmin(user, password): return True with base.getAdminConn() as conn: dbRes = list(conn.query("select password from dc.users where" " username=%(user)s", {"user": user})) if not dbRes or not dbRes[0]: return False storedForm = dbRes[0][0] if not hashMatches(password, storedForm): return False if reqGroup: dbRes = list(conn.query("select groupname from dc.groups where" " username=%(user)s and groupname=%(group)s", {"user": user, "group": reqGroup,})) return not not dbRes else: return True
[docs]def addUser(conn, username, password, remarks): """Adds a user to the users table. This will always also create a like-named group. It will raise an IntegrityError if the user already exists. This will commit conn in order to catch integrity problems early. """ storedForm = hashPassword(password) conn.execute("INSERT INTO dc.users (username, password, remarks)" " VALUES (%(username)s, %(storedForm)s, %(remarks)s)", locals()) conn.commit() conn.execute("INSERT INTO dc.groups (username, groupname)" " VALUES (%(username)s, %(username)s)", locals()) conn.commit()
[docs]def changeUser(conn, username, password, remarks=None): """Changes a user's password and remarks. This will raise an error if no such user exists. """ storedForm = hashPassword(password) with conn.cursor() as c: if remarks is None: c.execute("UPDATE dc.users SET password=%(storedForm)s" " WHERE username=%(username)s", locals()) else: c.execute("UPDATE dc.users SET password=%(storedForm)s," " remarks=%(remarks)s WHERE username=%(username)s", locals()) if not c.rowcount: raise base.ReportableError(f"User {username} does not exist.")
[docs]def addToGroup(conn, username, groupname): """Adds a user to a group. A group would come into being by this operation if it didn't exist before. Adding a non-existent user will raise an IntegrityError. This will commit conn in order to catch integrity problems early. """ conn.execute("INSERT INTO dc.groups (username, groupname)" " VALUES (%(username)s, %(groupname)s)", locals()) conn.commit()
[docs]def removeFromGroup(conn, username, groupname): """Removes a user from a group. It is not an error to remove a user from a group they are not in. This returns the number of rows removed in the operation (which should be 1 when the user has been a member of the group). """ c = conn.cursor() c.execute("DELETE FROM dc.groups WHERE groupname=%(groupname)s" " and username=%(username)s", locals()) return c.rowcount conn.execute("INSERT INTO dc.groups (username, groupname)" " VALUES (%(username)s, %(group)s)", locals())
[docs]def delUser(conn, username): """Removes a user and their associated group memberships from the users and groups tables. This returns then number of database rows affected; if this is 0, nothing was removed. """ cursor = conn.cursor() cursor.execute("DELETE FROM dc.users WHERE username=%(username)s", locals()) rowsAffected = cursor.rowcount cursor.execute("DELETE FROM dc.groups WHERE username=%(username)s", locals()) rowsAffected += cursor.rowcount cursor.close() return rowsAffected