"""
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