fix: the shell runs as the console account of each member of admins, a local account or the user's own POSIX account in the directory, which also signs in with SSH and sudo, instead of one web.shell_user.
Release / release (push) Successful in 28s
Release / release (push) Successful in 28s
This commit is contained in:
1 parent
2e1eb89f52
commit
ec93bc6a2d
12 files changed
+432
-51
No files matched your search
@@ -29,7 +29,12 @@ code of it. slapd's otp overlay then takes the password followed by a code at ev
|
||||
A user is disabled when pwdAccountLockedTime holds LOCKED, and locked when the password
|
||||
policy has set it after failed binds, for vpnrouter_ldap.LOCKOUT_SECONDS; unlock ends that.
|
||||
login() checks a user's password and code with a bind. Members of ADMIN_GROUP manage the
|
||||
router through the web service. The module knows only the directory; the callers revoke a
|
||||
router through the web service.
|
||||
|
||||
A member of ADMIN_GROUP may have console login (console): the Shell page of the Router Manager,
|
||||
and SSH and sudo with the directory's password, followed by the code for an enrolled user. The
|
||||
console runs as a local account, or as the user's own posixAccount, which the user entry becomes
|
||||
and nslcd serves to the system. Leaving ADMIN_GROUP switches it off. The module knows only the directory; the callers revoke a
|
||||
user's refresh tokens after the operations of vpnrouter_auth.REVOKING.
|
||||
|
||||
Every operation runs in a session(), which holds the lock that serializes changes, the PKI's
|
||||
@@ -42,7 +47,10 @@ import base64
|
||||
import calendar
|
||||
import contextlib
|
||||
import fcntl
|
||||
import grp
|
||||
import pwd
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
import ldap
|
||||
@@ -69,7 +77,8 @@ PERSON = (('first_name', 'givenName'), ('last_name', 'sn'), ('email', 'mail'))
|
||||
LABELS = (('display_name', 'displayName'), ('description', 'description'))
|
||||
#: The attributes read for a user.
|
||||
ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope',
|
||||
'displayName', 'description', 'userCertificate;binary']
|
||||
'displayName', 'description', 'userCertificate;binary', 'vpnRouterConsoleLogin', 'vpnRouterConsoleAccount',
|
||||
'uidNumber']
|
||||
#: The vpnRouterScope of a service account.
|
||||
SERVICE_SCOPE = 'service'
|
||||
#: The object classes of a VPN user and of a Manager-only account.
|
||||
@@ -234,6 +243,7 @@ def summary(attributes):
|
||||
'locked': lock not in (None, LOCKED) and locked_since(lock) > time.time() - vpnrouter_ldap.LOCKOUT_SECONDS,
|
||||
'certificate': 'userCertificate;binary' in attributes,
|
||||
'otp': 'oathTOTPToken' in attributes,
|
||||
'console': console_of(attributes),
|
||||
}
|
||||
for key, attribute in PERSON + LABELS:
|
||||
values = attributes.get(attribute)
|
||||
@@ -241,6 +251,116 @@ def summary(attributes):
|
||||
return user
|
||||
|
||||
|
||||
def console_of(attributes):
|
||||
"""The console login of an entry as the answers show it: None when it was never set, else {enabled, account, local,
|
||||
uid}: account the system account the console runs as, the local one of vpnRouterConsoleAccount (local true) or the
|
||||
user's own posixAccount, and uid the uidNumber of that posixAccount, None without one."""
|
||||
if 'vpnRouterConsoleLogin' not in attributes:
|
||||
return None
|
||||
local = attributes.get('vpnRouterConsoleAccount')
|
||||
number = attributes.get('uidNumber')
|
||||
return {
|
||||
'enabled': attributes['vpnRouterConsoleLogin'] == [b'TRUE'],
|
||||
'account': local[0].decode() if local else attributes['uid'][0].decode(),
|
||||
'local': bool(local),
|
||||
'uid': int(number[0]) if number else None,
|
||||
}
|
||||
|
||||
|
||||
def classes(attributes):
|
||||
"""The object classes of an entry, in lower case."""
|
||||
return {value.decode().lower() for value in attributes.get('objectClass', [])}
|
||||
|
||||
|
||||
def free_uid(connection):
|
||||
"""The lowest uidNumber from vpnrouter_ldap.CONSOLE_UID_MIN above every posixAccount's that no local account has."""
|
||||
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, '(uidNumber=*)', ['uidNumber'])
|
||||
number = max([vpnrouter_ldap.CONSOLE_UID_MIN - 1] + [int(attributes['uidNumber'][0]) for _, attributes in found]) + 1
|
||||
while True:
|
||||
try:
|
||||
pwd.getpwuid(number)
|
||||
except KeyError:
|
||||
return number
|
||||
number += 1
|
||||
|
||||
|
||||
def console(connection, name, enabled, account=None):
|
||||
"""Switch the console login of a member of ADMIN_GROUP on, as the local account given or, without one, as the
|
||||
user's own posixAccount, or off.
|
||||
|
||||
The posixAccount is added the first time and kept when the console login is switched off, so its uidNumber and the
|
||||
home it owns stay the user's. A local account must exist in the system's passwd with a uid from 1000 to below
|
||||
vpnrouter_ldap.CONSOLE_UID_MIN, and not be nobody; the user's own account needs a name no local account has.
|
||||
Switching off ends the sessions of the user's own account.
|
||||
"""
|
||||
current = entry(connection, name)
|
||||
if not isinstance(enabled, bool):
|
||||
raise Failure('enabled must be true or false', 2)
|
||||
if account is not None and (not isinstance(account, str) or not account):
|
||||
raise Failure('account must be the name of a local account', 2)
|
||||
changes = [] if 'vpnrouteraccount' in classes(current) else [(ldap.MOD_ADD, 'objectClass', [b'vpnRouterAccount'])]
|
||||
if not enabled:
|
||||
if 'vpnRouterConsoleLogin' not in current:
|
||||
return summary(current)
|
||||
connection.modify_s(dn_of(name), changes + [(ldap.MOD_REPLACE, 'vpnRouterConsoleLogin', [b'FALSE'])])
|
||||
end_sessions(current)
|
||||
return summary(entry(connection, name))
|
||||
if service(current):
|
||||
raise Failure(f'{name} is a service account, which has no console', 2)
|
||||
if ADMIN_GROUP not in groups_of(connection, name):
|
||||
raise Failure(f'user {name} is not a member of {ADMIN_GROUP}', 2)
|
||||
if account is not None:
|
||||
try:
|
||||
local = pwd.getpwnam(account)
|
||||
except KeyError:
|
||||
raise Failure(f'there is no local account {account}', 2)
|
||||
if not 1000 <= local.pw_uid < vpnrouter_ldap.CONSOLE_UID_MIN or account == 'nobody':
|
||||
raise Failure(f'{account} is not a local user account with a uid from 1000 to {vpnrouter_ldap.CONSOLE_UID_MIN - 1}', 2)
|
||||
changes.append((ldap.MOD_REPLACE, 'vpnRouterConsoleAccount', [account.encode()]))
|
||||
else:
|
||||
changes.append((ldap.MOD_REPLACE, 'vpnRouterConsoleAccount', []))
|
||||
if 'posixaccount' not in classes(current):
|
||||
try:
|
||||
pwd.getpwnam(name)
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
raise Failure(f'a local account {name} exists; give it as the account, or use another user', 4)
|
||||
try:
|
||||
group = grp.getgrnam(vpnrouter_ldap.CONSOLE_GROUP).gr_gid
|
||||
except KeyError:
|
||||
raise Failure(f'the group {vpnrouter_ldap.CONSOLE_GROUP} does not exist yet; vpn-router-setup creates it')
|
||||
changes += [
|
||||
(ldap.MOD_ADD, 'objectClass', [b'posixAccount']),
|
||||
(ldap.MOD_REPLACE, 'uidNumber', [str(free_uid(connection)).encode()]),
|
||||
(ldap.MOD_REPLACE, 'gidNumber', [str(group).encode()]),
|
||||
(ldap.MOD_REPLACE, 'homeDirectory', [f'{vpnrouter_ldap.CONSOLE_HOMES}/{name}'.encode()]),
|
||||
(ldap.MOD_REPLACE, 'loginShell', [vpnrouter_ldap.CONSOLE_SHELL.encode()]),
|
||||
]
|
||||
if manager_only(current):
|
||||
changes.append((ldap.MOD_REPLACE, 'cn', [name.encode()]))
|
||||
changes.append((ldap.MOD_REPLACE, 'vpnRouterConsoleLogin', [b'TRUE']))
|
||||
connection.modify_s(dn_of(name), changes)
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
def end_sessions(attributes):
|
||||
"""End the login sessions of the user's own posixAccount, when the entry has one; those of a local account stay."""
|
||||
if 'posixaccount' in classes(attributes) and not attributes.get('vpnRouterConsoleAccount'):
|
||||
subprocess.run(['loginctl', 'terminate-user', attributes['uid'][0].decode()], capture_output=True)
|
||||
|
||||
|
||||
def console_off(connection, names):
|
||||
"""Switch the console login off for each of the users that has it on; for those who leave ADMIN_GROUP."""
|
||||
for name in names:
|
||||
try:
|
||||
current = entry(connection, name)
|
||||
except Failure:
|
||||
continue
|
||||
if current.get('vpnRouterConsoleLogin') == [b'TRUE']:
|
||||
console(connection, name, False)
|
||||
|
||||
|
||||
def common_name(attributes, name):
|
||||
"""The cn of an entry: the first and the last name, or the user name without them."""
|
||||
full = ' '.join(value for value in (attributes.get('givenName'), attributes.get('sn')) if value)
|
||||
@@ -441,11 +561,12 @@ def otp_remove(connection, name):
|
||||
|
||||
|
||||
def remove(connection, name):
|
||||
"""Remove the user from its groups and the directory, and revoke the certificate.
|
||||
"""Remove the user from its groups and the directory, revoke the certificate and end the sessions of its own
|
||||
posixAccount.
|
||||
|
||||
A user who is the last member of a group is not removed.
|
||||
"""
|
||||
entry(connection, name)
|
||||
current = entry(connection, name)
|
||||
groups = groups_of(connection, name)
|
||||
alone = sorted(group for group, members in groups.items() if members == [name])
|
||||
if alone:
|
||||
@@ -457,6 +578,7 @@ def remove(connection, name):
|
||||
except vpnrouter_pki.Failure as failure:
|
||||
raise Failure(f'the certificate cannot be revoked: {failure}', failure.status)
|
||||
connection.delete_s(dn_of(name))
|
||||
end_sessions(current)
|
||||
return {'name': name, 'removed': True}
|
||||
|
||||
|
||||
@@ -573,10 +695,11 @@ def group_add(connection, name, description, members):
|
||||
|
||||
|
||||
def group_update(connection, name, new_name=None, description=None):
|
||||
"""Rename the group or change its description ('' removes it); at least one must be given."""
|
||||
"""Rename the group or change its description ('' removes it); at least one must be given. Renaming ADMIN_GROUP
|
||||
switches the console login of its members off."""
|
||||
if new_name is None and description is None:
|
||||
raise Failure('give a new name or a description', 2)
|
||||
group_entry(connection, name)
|
||||
members = member_names(group_entry(connection, name))
|
||||
if description is not None:
|
||||
description = check_description(description)
|
||||
change = (ldap.MOD_REPLACE, 'description', [description.encode()]) if description else \
|
||||
@@ -588,13 +711,18 @@ def group_update(connection, name, new_name=None, description=None):
|
||||
connection.rename_s(group_dn(name), f'cn={new_name}', delold=1)
|
||||
except ldap.ALREADY_EXISTS:
|
||||
raise Failure(f'group {new_name} exists', 4)
|
||||
if name == ADMIN_GROUP:
|
||||
console_off(connection, members)
|
||||
name = new_name
|
||||
return group_summary(group_entry(connection, name))
|
||||
|
||||
|
||||
def group_remove(connection, name):
|
||||
group_entry(connection, name)
|
||||
"""Remove the group; removing ADMIN_GROUP switches the console login of its members off."""
|
||||
members = member_names(group_entry(connection, name))
|
||||
connection.delete_s(group_dn(name))
|
||||
if name == ADMIN_GROUP:
|
||||
console_off(connection, members)
|
||||
return {'name': name, 'removed': True}
|
||||
|
||||
|
||||
@@ -607,7 +735,8 @@ def group_member_add(connection, name, user):
|
||||
|
||||
|
||||
def group_member_remove(connection, name, user):
|
||||
"""Take the user out of the group; the last member cannot leave."""
|
||||
"""Take the user out of the group; the last member cannot leave. Leaving ADMIN_GROUP switches the user's console
|
||||
login off."""
|
||||
check_name(user)
|
||||
members = member_names(group_entry(connection, name))
|
||||
if user not in members:
|
||||
@@ -615,6 +744,8 @@ def group_member_remove(connection, name, user):
|
||||
if members == [user]:
|
||||
raise Failure(f'user {user} is the last member of group {name}; remove the group instead', 2)
|
||||
connection.modify_s(group_dn(name), [(ldap.MOD_DELETE, 'member', [dn_of(user).encode()])])
|
||||
if name == ADMIN_GROUP:
|
||||
console_off(connection, [user])
|
||||
return group_summary(group_entry(connection, name))
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user