557 lines
18 KiB
Python
557 lines
18 KiB
Python
#
|
|
# users.py: Code for creating user accounts and setting the root password
|
|
#
|
|
# Copyright (C) 2006, 2007, 2008 Red Hat, Inc. All rights reserved.
|
|
#
|
|
# This program is free software; you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation; either version 2 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
#
|
|
|
|
import os
|
|
import os.path
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
from pyanaconda.core import util
|
|
from pyanaconda.core.configuration.anaconda import conf
|
|
from pyanaconda.core.path import make_directories, open_with_perm
|
|
from pyanaconda.core.string import strip_accents
|
|
from pyanaconda.core.regexes import GROUPLIST_FANCY_PARSE, NAME_VALID, PORTABLE_FS_CHARS, \
|
|
GROUPLIST_SIMPLE_VALID
|
|
import crypt # pylint: disable=deprecated-module
|
|
from pyanaconda.core.i18n import _
|
|
import re
|
|
from random import SystemRandom as sr
|
|
|
|
from pyanaconda.anaconda_loggers import get_module_logger
|
|
log = get_module_logger(__name__)
|
|
|
|
|
|
def crypt_password(password):
|
|
"""Crypt a password.
|
|
|
|
Process a password with appropriate salted one-way algorithm.
|
|
|
|
:param str password: password to be crypted
|
|
:returns: crypted representation of the original password
|
|
:rtype: str
|
|
"""
|
|
# yescrypt is not supported by Python's crypt module,
|
|
# so we need to generate the setting ourselves
|
|
b64 = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
setting = "$y$j9T$" + "".join(sr().choice(b64) for _sc in range(24))
|
|
|
|
# and try to compute the password hash using our yescrypt setting
|
|
try:
|
|
cryptpw = crypt.crypt(password, setting)
|
|
|
|
# Fallback to sha512crypt, if yescrypt is not supported
|
|
except OSError:
|
|
log.info("yescrypt is not supported, falling back to sha512crypt")
|
|
try:
|
|
cryptpw = crypt.crypt(password, crypt.METHOD_SHA512)
|
|
except OSError as exc:
|
|
raise RuntimeError(_(
|
|
"Unable to encrypt password: unsupported "
|
|
"algorithm {}").format(crypt.METHOD_SHA512)
|
|
) from exc
|
|
|
|
return cryptpw
|
|
|
|
|
|
def check_username(name):
|
|
"""Check if given username is valid.
|
|
|
|
:param: user or group name to check
|
|
:returns: a (success, translated-error-message) tuple
|
|
:rtype: (bool, str or None)
|
|
"""
|
|
|
|
# Check reserved names.
|
|
reserved_names = [
|
|
# passwd contents from setup.rpm
|
|
"root",
|
|
"bin",
|
|
"daemon",
|
|
"adm",
|
|
"lp",
|
|
"sync",
|
|
"shutdown",
|
|
"halt",
|
|
"mail",
|
|
"operator",
|
|
"games",
|
|
"ftp",
|
|
"nobody",
|
|
# from older version of the function
|
|
"home",
|
|
"system",
|
|
]
|
|
if name in os.listdir("/") + reserved_names:
|
|
return False, _("User name is reserved for system: %s") % name
|
|
|
|
return is_valid_name(name)
|
|
|
|
|
|
def check_grouplist(group_list):
|
|
"""Check a group list for validity.
|
|
|
|
:param str group_list: a string representation of a group list to be checked
|
|
:returns: a (success, translated-error-message) tuple
|
|
:rtype: (bool, str or None)
|
|
"""
|
|
# Check empty list.
|
|
if group_list == "":
|
|
return True, None
|
|
|
|
# Check the group names.
|
|
for group_name in group_list.split(","):
|
|
valid, message = check_groupname(group_name.strip())
|
|
if not valid:
|
|
return valid, message
|
|
|
|
# Check the regexp to be sure
|
|
if not GROUPLIST_SIMPLE_VALID.match(group_list):
|
|
return False, _("Either a group name in the group list is invalid "
|
|
"or groups are not separated by a comma.")
|
|
|
|
return True, None
|
|
|
|
|
|
def check_groupname(name):
|
|
"""Check if group name is valid.
|
|
|
|
:param: group name to check
|
|
:returns: a (success, translated-error-message) tuple
|
|
:rtype: (bool, str or None)
|
|
"""
|
|
return is_valid_name(name)
|
|
|
|
|
|
def is_valid_name(name):
|
|
"""Check if given name is valid for either a group or user.
|
|
|
|
This method basically checks all the rules that are the same
|
|
for both user and group names.
|
|
|
|
There is a separate check_username() method, that adds some
|
|
username specific checks on top of this.
|
|
|
|
:param: user or group name to check
|
|
:returns: a (success, translated-error-message) tuple
|
|
:rtype: (bool, str or None)
|
|
"""
|
|
|
|
# Check shadow-utils rules.
|
|
if name.startswith("-"):
|
|
return False, _("Name cannot start with '-' character.")
|
|
|
|
if name in [".", ".."]:
|
|
return False, _("Name '%s' is not allowed.") % name
|
|
|
|
if name.isdigit():
|
|
return False, _("Fully numeric name is not allowed.")
|
|
|
|
# Final '$' allowed for Samba
|
|
if name == "$":
|
|
return False, _("Name '$' is not allowed.")
|
|
|
|
if name.endswith("$"):
|
|
sname = name[:-1]
|
|
else:
|
|
sname = name
|
|
|
|
match = re.search(r'[^' + PORTABLE_FS_CHARS + r']', sname)
|
|
|
|
if match:
|
|
return False, _("Name cannot contain character: '%s'") % match.group()
|
|
|
|
if len(name) > 32:
|
|
return False, _("Name must be shorter than 33 characters.")
|
|
|
|
# Check also with THE regexp to be sure
|
|
if not NAME_VALID.match(name):
|
|
return False, _("Name '%s' is invalid.") % name
|
|
|
|
return True, None
|
|
|
|
|
|
def guess_username(fullname):
|
|
"""Guess username from full user name.
|
|
|
|
:param str fullname: full user name
|
|
:returns: guessed, hopefully suitable, username
|
|
:rtype: str
|
|
"""
|
|
fullname = fullname.split()
|
|
|
|
# use last name word (at the end in most of the western countries..)
|
|
if len(fullname) > 0:
|
|
username = fullname[-1].lower()
|
|
else:
|
|
username = ""
|
|
|
|
# and prefix it with the first name initial
|
|
if len(fullname) > 1:
|
|
username = fullname[0][0].lower() + username
|
|
|
|
username = strip_accents(username)
|
|
return username
|
|
|
|
|
|
def _getpwnam(user_name, root):
|
|
"""Like pwd.getpwnam, but is able to use a different root.
|
|
|
|
Also just returns the pwd structure as a list, because of laziness.
|
|
|
|
:param str user_name: user name
|
|
:param str root: filesystem root for the operation
|
|
"""
|
|
with open(root + "/etc/passwd", "r") as f:
|
|
for line in f:
|
|
fields = line.split(":")
|
|
if fields[0] == user_name:
|
|
return fields
|
|
|
|
return None
|
|
|
|
|
|
def _getgrnam(group_name, root):
|
|
"""Like grp.getgrnam, but able to use a different root.
|
|
|
|
Just returns the grp structure as a list, same reason as above.
|
|
|
|
:param str group_name: group name
|
|
:param str root: filesystem root for the operation
|
|
"""
|
|
with open(root + "/etc/group", "r") as f:
|
|
for line in f:
|
|
fields = line.split(":")
|
|
if fields[0] == group_name:
|
|
return fields
|
|
|
|
return None
|
|
|
|
|
|
def _getgrgid(gid, root):
|
|
"""Like grp.getgrgid, but able to use a different root.
|
|
|
|
Just returns the fields as a list of strings.
|
|
|
|
:param int git: group id
|
|
:param str root: filesystem root for the operation
|
|
"""
|
|
# Convert the probably-int GID to a string
|
|
gid = str(gid)
|
|
|
|
with open(root + "/etc/group", "r") as f:
|
|
for line in f:
|
|
fields = line.split(":")
|
|
if fields[2] == gid:
|
|
return fields
|
|
|
|
return None
|
|
|
|
|
|
def create_group(group_name, gid=None, root=None):
|
|
"""Create a new user on the system with the given name.
|
|
|
|
:param int gid: The GID for the new user. If none is given, the next available one is used.
|
|
:param str root: The directory of the system to create the new user in.
|
|
homedir will be interpreted relative to this. Defaults
|
|
to conf.target.system_root.
|
|
"""
|
|
if root is None:
|
|
root = conf.target.system_root
|
|
|
|
if _getgrnam(group_name, root):
|
|
raise ValueError("Group %s already exists" % group_name)
|
|
|
|
args = ["-R", root]
|
|
if gid is not None:
|
|
args.extend(["-g", str(gid)])
|
|
|
|
args.append(group_name)
|
|
status = util.execWithRedirect("groupadd", args)
|
|
|
|
if status == 4:
|
|
raise ValueError("GID %s already exists" % gid)
|
|
elif status == 9:
|
|
raise ValueError("Group %s already exists" % group_name)
|
|
elif status != 0:
|
|
raise OSError("Unable to create group %s: status=%s" % (group_name, status))
|
|
|
|
|
|
def _reown_homedir(root, homedir, username):
|
|
"""Home directory already existed, change owner of it properly.
|
|
|
|
Change owner (uid and gid) of the files and directories under the given
|
|
directory tree (recursively).
|
|
|
|
:param str root: path to the system root (eg. /mnt/sysroot)
|
|
:param str homedir: path to the user's home dir within root (eg. /home/tom)
|
|
:param str username: name of the user (eg. tom)
|
|
"""
|
|
try:
|
|
# Get the UID and GID of user on previous system
|
|
stats = os.stat(root + homedir)
|
|
orig_uid = stats.st_uid
|
|
orig_gid = stats.st_gid
|
|
|
|
# Get the UID and GID of the created user on new system
|
|
pwent = _getpwnam(username, root)
|
|
uid = int(pwent[2])
|
|
gid = int(pwent[3])
|
|
|
|
# Change owner UID and GID where matching
|
|
from_ids = "--from={}:{}".format(orig_uid, orig_gid)
|
|
to_ids = "{}:{}".format(uid, gid)
|
|
util.execWithRedirect("chown", ["--recursive", "--no-dereference",
|
|
from_ids, to_ids, root + homedir])
|
|
|
|
# Restore also SELinux contexts
|
|
util.restorecon([homedir], root=root)
|
|
|
|
except OSError as e:
|
|
log.critical("Unable to change owner of existing home directory: %s", e.strerror)
|
|
raise
|
|
|
|
|
|
def create_user(username, password=False, is_crypted=False, lock=False,
|
|
homedir=None, uid=None, gid=None, groups=None, shell=None, gecos="",
|
|
root=None):
|
|
"""Create a new user on the system with the given name.
|
|
|
|
:param str username: The username for the new user to be created.
|
|
:param str password: The password. See is_crypted for how this is interpreted.
|
|
If the password is "" then the account is created
|
|
with a blank password. If None or False the account will
|
|
be left in its initial state (locked)
|
|
:param bool is_crypted: Is the password already encrypted? Defaults to False.
|
|
:param bool lock: Is the new account locked by default?
|
|
Defaults to False.
|
|
:param str homedir: The home directory for the new user.
|
|
Defaults to /home/<name>.
|
|
:param int uid: The UID for the new user.
|
|
If none is given, the next available one is used.
|
|
:param int gid: The GID for the new user.
|
|
If none is given, the next available one is used.
|
|
:param groups: A list of group names the user should be added to.
|
|
Each group name can contain an optional GID in parenthesis,
|
|
such as "groupName(5000)".
|
|
Defaults to [].
|
|
:type groups: list of str
|
|
:param str shell: The shell for the new user.
|
|
If none is given, the login.defs default is used.
|
|
:param str gecos: The GECOS information (full name, office, phone, etc.).
|
|
Defaults to "".
|
|
:param str root: The directory of the system to create the new user in.
|
|
The homedir option will be interpreted relative to this.
|
|
Defaults to conf.target.system_root.
|
|
"""
|
|
|
|
# resolve the optional arguments that need a default that can't be
|
|
# reasonably set in the function signature
|
|
if not homedir:
|
|
homedir = "/home/" + username
|
|
|
|
if groups is None:
|
|
groups = []
|
|
|
|
if root is None:
|
|
root = conf.target.system_root
|
|
|
|
if check_user_exists(username, root):
|
|
raise ValueError("User %s already exists" % username)
|
|
|
|
args = ["-R", root]
|
|
|
|
# Split the groups argument into a list of (username, gid or None) tuples
|
|
# the gid, if any, is a string since that makes things simpler
|
|
group_gids = [GROUPLIST_FANCY_PARSE.match(group).groups() for group in groups]
|
|
|
|
# If a specific gid is requested:
|
|
# - check if a group already exists with that GID. i.e., the user's
|
|
# GID should refer to a system group, such as users. If so, just set
|
|
# the GID.
|
|
# - check if a new group is requested with that GID. If so, set the GID
|
|
# and let the block below create the actual group.
|
|
# - if neither of those are true, create a new user group with the requested
|
|
# GID
|
|
# otherwise use -U to create a new user group with the next available GID.
|
|
if gid:
|
|
if not _getgrgid(gid, root) and not any(one_gid[1] == str(gid) for one_gid in group_gids):
|
|
create_group(username, gid=gid, root=root)
|
|
|
|
args.extend(['-g', str(gid)])
|
|
else:
|
|
args.append('-U')
|
|
|
|
# If any requested groups do not exist, create them.
|
|
group_list = []
|
|
for group_name, gid in group_gids:
|
|
existing_group = _getgrnam(group_name, root)
|
|
|
|
# Check for a bad GID request
|
|
if gid and existing_group and gid != existing_group[2]:
|
|
raise ValueError("Group %s already exists with GID %s" % (group_name, gid))
|
|
|
|
# Otherwise, create the group if it does not already exist
|
|
if not existing_group:
|
|
create_group(group_name, gid=gid, root=root)
|
|
group_list.append(group_name)
|
|
|
|
if group_list:
|
|
args.extend(['-G', ",".join(group_list)])
|
|
|
|
# useradd expects the parent directory tree to exist.
|
|
parent_dir = Path(root + homedir).resolve().parent
|
|
|
|
# If root + homedir came out to "/", such as if we're creating the sshpw user,
|
|
# parent_dir will be empty. Don't create that.
|
|
if parent_dir != Path("/"):
|
|
make_directories(str(parent_dir))
|
|
|
|
args.extend(["-d", homedir])
|
|
|
|
# Check whether the directory exists or if useradd should create it
|
|
mk_homedir = not os.path.exists(root + homedir)
|
|
if mk_homedir:
|
|
args.append("-m")
|
|
else:
|
|
args.append("-M")
|
|
|
|
if shell:
|
|
args.extend(["-s", shell])
|
|
|
|
if uid:
|
|
args.extend(["-u", str(uid)])
|
|
|
|
if gecos:
|
|
args.extend(["-c", gecos])
|
|
|
|
args.append(username)
|
|
status = util.execWithRedirect("useradd", args)
|
|
|
|
if status == 4:
|
|
raise ValueError("UID %s already exists" % uid)
|
|
elif status == 6:
|
|
raise ValueError("Invalid groups %s" % groups)
|
|
elif status == 9:
|
|
raise ValueError("User %s already exists" % username)
|
|
elif status != 0:
|
|
raise OSError("Unable to create user %s: status=%s" % (username, status))
|
|
|
|
if not mk_homedir:
|
|
log.info("Home directory for the user %s already existed, "
|
|
"fixing the owner and SELinux context.", username)
|
|
_reown_homedir(root, homedir, username)
|
|
|
|
set_user_password(username, password, is_crypted, lock, root)
|
|
|
|
|
|
def check_user_exists(username, root=None):
|
|
"""Check a user exists.
|
|
|
|
:param str username: username to check
|
|
:param str root: target system sysroot path
|
|
"""
|
|
if root is None:
|
|
root = conf.target.system_root
|
|
|
|
if _getpwnam(username, root):
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def set_user_password(username, password, is_crypted, lock, root="/"):
|
|
"""Set user password.
|
|
|
|
:param str username: username of the user
|
|
:param str password: user password
|
|
:param bool is_crypted: is the password already crypted ?
|
|
:param bool lock: should the password for this username be locked ?
|
|
:param str root: target system sysroot path
|
|
"""
|
|
|
|
# Only set the password if it is a string, including the empty string.
|
|
# Otherwise leave it alone (defaults to locked for new users) and reset sp_lstchg
|
|
if password or password == "":
|
|
if password == "":
|
|
log.info("user account %s setup with no password", username)
|
|
elif not is_crypted:
|
|
password = crypt_password(password)
|
|
|
|
if lock:
|
|
password = "!" + password
|
|
log.info("user account %s locked", username)
|
|
|
|
proc = util.startProgram(["chpasswd", "-R", root, "-e"], stdin=subprocess.PIPE)
|
|
proc.communicate(("%s:%s\n" % (username, password)).encode("utf-8"))
|
|
if proc.returncode != 0:
|
|
raise OSError("Unable to set password for new user: status=%s" % proc.returncode)
|
|
|
|
# Reset sp_lstchg to an empty string. On systems with no rtc, this
|
|
# field can be set to 0, which has a special meaning that the password
|
|
# must be reset on the next login.
|
|
util.execWithRedirect("chage", ["-R", root, "-d", "", username])
|
|
|
|
|
|
def set_root_password(password, is_crypted=False, lock=False, root="/"):
|
|
"""Set root password.
|
|
|
|
:param str password: root password
|
|
:param bool is_crypted: is the password already crypted ?
|
|
:param bool lock: should the root password be locked ?
|
|
:param str root: target system sysroot path
|
|
"""
|
|
return set_user_password("root", password, is_crypted, lock, root)
|
|
|
|
|
|
def set_user_ssh_key(username, key, root=None):
|
|
"""Set an SSH key for a given username.
|
|
|
|
:param str username: a username
|
|
:param str key: the SSH key to set
|
|
:param str root: target system sysroot path
|
|
"""
|
|
if root is None:
|
|
root = conf.target.system_root
|
|
|
|
pwent = _getpwnam(username, root)
|
|
if not pwent:
|
|
raise ValueError("set_user_ssh_key: user %s does not exist" % username)
|
|
|
|
homedir = root + pwent[5]
|
|
if not os.path.exists(homedir):
|
|
log.error("set_user_ssh_key: home directory for %s does not exist", username)
|
|
raise ValueError("set_user_ssh_key: home directory for %s does not exist" % username)
|
|
|
|
uid = pwent[2]
|
|
gid = pwent[3]
|
|
|
|
sshdir = os.path.join(homedir, ".ssh")
|
|
if not os.path.isdir(sshdir):
|
|
os.mkdir(sshdir, 0o700)
|
|
os.chown(sshdir, int(uid), int(gid))
|
|
|
|
authfile = os.path.join(sshdir, "authorized_keys")
|
|
authfile_existed = os.path.exists(authfile)
|
|
with open_with_perm(authfile, "a", 0o600) as f:
|
|
f.write(key + "\n")
|
|
|
|
# Only change ownership if we created it
|
|
if not authfile_existed:
|
|
os.chown(authfile, int(uid), int(gid))
|
|
util.restorecon([sshdir.removeprefix(root)], root=root)
|