Skip to content

Commit

Permalink
Merge pull request #6 from elokapina/jaywink/invite-users
Browse files Browse the repository at this point in the history
Add "users invite" command
  • Loading branch information
jaywink authored Jun 19, 2021
2 parents 5bf4b63 + 5871fce commit acba8c2
Show file tree
Hide file tree
Showing 6 changed files with 122 additions and 10 deletions.
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@
required power.

* Add command `users` to interact with an identity provider. Currently only Keycloak
is supported and the only functionality is to list usernames found in the configured
realm.
is supported. Currently supported features is listing users and inviting new users.
Invited users will be sent a password reset email and their email will be
marked as verified.

### Fixed

Expand Down
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,16 @@ The `users` command requires `admin` level bot privileges and currently just lis
usernames in the configured realm. See `sample.config.yaml` for how to configure
a Keycloak client.

Future functionality will include registering users and sending them password reset
emails, as some examples.
Subcommands:

* `list` (or no subcommand)

List currently registered users.

* `invite`

Creates users for the given emails and sends them a password reset email. The users
email will be marked as verified. Give one or more emails as parameters.

### Room power levels

Expand Down
62 changes: 59 additions & 3 deletions bot_commands.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import csv
import logging
import re

from email_validator import validate_email, EmailNotValidError
# noinspection PyPackageRequirements
from nio import RoomPutStateError
# noinspection PyPackageRequirements
Expand All @@ -10,7 +12,7 @@
from chat_functions import send_text_to_room, invite_to_room
from communities import ensure_community_exists
from rooms import ensure_room_exists, create_breakout_room, set_user_power
from users import list_users
from users import list_users, get_user_by_attr, create_user, send_password_reset

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -392,12 +394,66 @@ async def _users(self):
text = None
if self.args:
if self.args[0] == "list":
users = await list_users(self.config)
users = list_users(self.config)
text = f"The following usernames were found: {', '.join([user['username'] for user in users])}"
elif self.args[0] == "help":
text = help_strings.HELP_USERS
elif self.args[0] == "invite":
if len(self.args) == 1 or self.args[1] == "help":
text = help_strings.HELP_USERS_INVITE
else:
emails = self.args[1:]
emails = {email.strip() for email in emails}
texts = []
for email in emails:
try:
validated = validate_email(email)
email = validated.email
logger.debug("users invite - Email %s is valid", email)
except EmailNotValidError as ex:
texts.append(f"The email {email} looks invalid: {ex}")
continue
try:
existing_user = get_user_by_attr(self.config, "email", email)
except Exception as ex:
texts.append(f"Error looking up existing users by email {email}: {ex}")
continue
if existing_user:
texts.append(f"Found an existing user by email {email} - ignoring")
continue
logger.debug("users invite - No existing user for %s found", email)
username = None
username_candidate = email.split('@')[0]
username_candidate = username_candidate.lower()
username_candidate = re.sub(r'[^a-z0-9._\-]', '', username_candidate)
candidate = username_candidate
counter = 0
while not username:
logger.debug("users invite - candidate: %s", candidate)
# noinspection PyBroadException
try:
existing_user = get_user_by_attr(self.config, "username", candidate)
except Exception:
existing_user = True
if existing_user:
logger.debug("users invite - Found existing user with candidate %s", existing_user)
counter += 1
candidate = f"{username_candidate}{counter}"
continue
username = candidate
logger.debug("Username is %s", username)
user_id = create_user(self.config, username, email)
logger.debug("Created user: %s", user_id)
if not user_id:
texts.append(f"Failed to create user for email {email}")
logger.warning("users invite - Failed to create user for email %s", email)
continue
send_password_reset(self.config, user_id)
logger.info("users invite - Successfully invited user with email %s", email)
texts.append(f"Successfully invited {email}!")
text = '\n'.join(texts)
else:
users = await list_users(self.config)
users = list_users(self.config)
text = f"The following usernames were found: {', '.join([user['username'] for user in users])}"
if not text:
text = help_strings.HELP_USERS
Expand Down
12 changes: 11 additions & 1 deletion help_strings.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
HELP_USERS = """List or manage users.
Without any subcommands, lists users. Actually that's the only thing it does for now :P
Without any subcommands, lists users. Other subcommands:
* `invite` - Invite one or more users.
For help on subcommands, give the subcommand with a "help" parameter.
"""

HELP_USERS_INVITE = """Invite one or more users.
Takes one or more email address as parameters. Creates the users in the identity provider,
marking their emails as verified. Then sends them an email with a password reset link.
"""
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
email-validator>=1.1.2
matrix-nio[e2e]>=0.8.0
Markdown>=3.1.1
python-keycloak>=0.24.0
Expand Down
40 changes: 38 additions & 2 deletions users.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import List, Dict
import json
from typing import List, Dict, Optional

# noinspection PyPackageRequirements
from keycloak import KeycloakAdmin
Expand All @@ -16,7 +17,42 @@ def get_admin_client(config: Config) -> KeycloakAdmin:
return KeycloakAdmin(**params)


async def list_users(config: Config) -> List[Dict]:
def create_user(config: Config, username: str, email: str) -> Optional[str]:
if not config.users.get('provider'):
return
keycloak_admin = get_admin_client(config)
return keycloak_admin.create_user({
"email": email,
"emailVerified": True,
"enabled": True,
"username": username,
})


def send_password_reset(config: Config, user_id: str) -> Dict:
if not config.users.get('provider'):
return {}
keycloak_admin = get_admin_client(config)
keycloak_admin.send_update_account(
user_id=user_id,
payload=json.dumps(['UPDATE_PASSWORD']),
)


def get_user_by_attr(config: Config, attr: str, value: str) -> Optional[Dict]:
if not config.users.get('provider'):
return
keycloak_admin = get_admin_client(config)
users = keycloak_admin.get_users({
attr: value,
})
if len(users) == 1:
return users[0]
elif len(users) > 1:
raise Exception(f"More than one user found with the same {attr} = {value}")


def list_users(config: Config) -> List[Dict]:
if not config.users.get('provider'):
return []
keycloak_admin = get_admin_client(config)
Expand Down

0 comments on commit acba8c2

Please sign in to comment.