"""
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-2025, 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)


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


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)


@functools.lru_cache(None)
def getHashedAdminPassword():
	password = base.getConfig("web", "adminpasswd")
	if password.startswith("scrypt:"):
		return password
	else:
		return hashPassword(password)


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


def getGroupsForAuthUser(username):
	"""returns the groups username is in.
	"""
	with base.getAdminConn() as conn:
		return set(r[0] for r in
			conn.query(
				"SELECT groupname"
				" FROM dc.groups"
				" WHERE username=%(username)s", locals()))


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()


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


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()


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


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()


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())


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
