Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

users: add user creation endpoint #146

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ MIT License
Copyright (C) 2022 TU Wien.
Copyright (C) 2022 European Union.
Copyright (C) 2022 CERN.
Copyright (C) 2024 Ubiquity Press.

Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
Expand Down
33 changes: 25 additions & 8 deletions invenio_users_resources/records/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#
# Copyright (C) 2022 TU Wien.
# Copyright (C) 2022 CERN.
# Copyright (C) 2024 Ubiquity Press.
#
# Invenio-Users-Resources is free software; you can redistribute it and/or
# modify it under the terms of the MIT License; see LICENSE file for more
Expand All @@ -14,14 +15,15 @@
from datetime import datetime

from flask import current_app
from invenio_accounts.models import Domain
from invenio_accounts.models import Domain, User
from invenio_accounts.proxies import current_datastore
from invenio_db import db
from invenio_records.dumpers import SearchDumper, SearchDumperExt
from invenio_records.dumpers.indexedat import IndexedAtDumperExt
from invenio_records.systemfields import ModelField
from invenio_records_resources.records.api import Record
from invenio_records_resources.records.systemfields import IndexField
from marshmallow import ValidationError
from sqlalchemy.exc import NoResultFound

from .dumpers import EmailFieldDumperExt
Expand Down Expand Up @@ -214,13 +216,28 @@ def avatar_color(self):
@classmethod
def create(cls, data, id_=None, validator=None, format_checker=None, **kwargs):
"""Create a new User and store it in the database."""
# NOTE: we don't use an actual database table, and as such can't
# use db.session.add(record.model)
with db.session.begin_nested():
# create_user() will already take care of creating the profile
# for us, if it's specified in the data
user = current_datastore.create_user(**data)
return cls.from_model(user)
try:
# Check if email and username already exists by another account.
errors = {}
existing_email = (
db.session.query(User).filter_by(email=data["email"]).first()
)
if existing_email:
errors["email"] = ["Email already used by another account."]
existing_username = (
db.session.query(User).filter_by(username=data.get("username")).first()
)
if existing_username:
errors["username"] = ["Username already used by another account."]
if errors:
raise ValidationError(errors)
# Create User
account_user = current_datastore.create_user(**data)
return cls.from_model(account_user)
except ValidationError:
raise
except Exception as e:
raise ValidationError(message=f"Unexpected Issue: {str(e)}", data=data)

def verify(self):
"""Activates the current user.
Expand Down
15 changes: 15 additions & 0 deletions invenio_users_resources/resources/users/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# Copyright (C) 2022 TU Wien.
# Copyright (C) 2022 CERN.
# Copyright (C) 2022 European Union.
# Copyright (C) 2024 Ubiquity Press.
#
# Invenio-Users-Resources is free software; you can redistribute it and/or
# modify it under the terms of the MIT License; see LICENSE file for more
Expand All @@ -15,6 +16,8 @@
from flask_security import impersonate_user
from invenio_records_resources.resources import RecordResource
from invenio_records_resources.resources.records.resource import (
request_data,
request_extra_args,
request_search_args,
request_view_args,
)
Expand All @@ -36,6 +39,7 @@ def create_url_rules(self):
routes = self.config.routes
return [
route("GET", routes["list"], self.search),
route("POST", routes["list"], self.create),
route("GET", routes["item"], self.read),
route("GET", routes["item-avatar"], self.avatar),
route("POST", routes["approve"], self.approve),
Expand Down Expand Up @@ -152,3 +156,14 @@ def impersonate(self):
if user:
impersonate_user(user, g.identity)
return "", 200

@request_extra_args
@request_data
@response_handler()
def create(self):
"""Create a user."""
item = self.service.create(
g.identity,
resource_requestctx.data or {},
)
return item.to_dict(), 201
3 changes: 2 additions & 1 deletion invenio_users_resources/services/permissions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2022 TU Wien.
# Copyright (C) 2024 Ubiquity Press.
#
# Invenio-Users-Resources is free software; you can redistribute it and/or
# modify it under the terms of the MIT License; see LICENSE file for more
Expand Down Expand Up @@ -32,7 +33,7 @@
class UsersPermissionPolicy(BasePermissionPolicy):
"""Permission policy for users and user groups."""

can_create = [SystemProcess()]
can_create = [UserManager, SystemProcess()]
can_read = [
UserManager,
IfPublicUser(then_=[AnyUser()], else_=[Self()]),
Expand Down
3 changes: 2 additions & 1 deletion invenio_users_resources/services/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#
# Copyright (C) 2022 TU Wien.
# Copyright (C) 2023 Graz University of Technology.
# Copyright (C) 2024 Ubiquity Press.
#
# Invenio-Users-Resources is free software; you can redistribute it and/or
# modify it under the terms of the MIT License; see LICENSE file for more
Expand Down Expand Up @@ -100,7 +101,7 @@ class UserSchema(BaseRecordSchema, FieldPermissionsMixin):
visibility = fields.Str(dump_only=True)
is_current_user = fields.Method("is_self", dump_only=True)

email = fields.String()
email = fields.Email(required=True)
domain = fields.String()
domaininfo = fields.Nested(DomainInfoSchema)
identities = fields.Nested(IdentitiesSchema, default={})
Expand Down
60 changes: 48 additions & 12 deletions invenio_users_resources/services/users/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,20 @@
# Copyright (C) 2022 TU Wien.
# Copyright (C) 2022 European Union.
# Copyright (C) 2022 CERN.
# Copyright (C) 2024 Ubiquity Press.
#
# Invenio-Users-Resources is free software; you can redistribute it and/or
# modify it under the terms of the MIT License; see LICENSE file for more
# details.

"""Users service."""
import secrets
import string

from flask_security.utils import hash_password
from invenio_accounts.models import User
from invenio_accounts.proxies import current_datastore
from invenio_accounts.utils import default_reset_password_link_func
from invenio_db import db
from invenio_records_resources.resources.errors import PermissionDeniedError
from invenio_records_resources.services import RecordService
Expand All @@ -20,7 +26,10 @@
from marshmallow import ValidationError

from invenio_users_resources.services.results import AvatarResult
from invenio_users_resources.services.users.tasks import execute_moderation_actions
from invenio_users_resources.services.users.tasks import (
execute_moderation_actions,
execute_reset_password_email,
)

from ...records.api import UserAggregate
from .lock import ModerationMutex
Expand All @@ -36,18 +45,18 @@ def user_cls(self):

@unit_of_work()
def create(self, identity, data, raise_errors=True, uow=None):
"""Create a user."""
"""Create a user from users admin."""
self.require_permission(identity, "create")

# validate data
# Remove None values to avoid validation issues
data = {k: v for k, v in data.items() if v}
# validate new user data
data, errors = self.schema.load(
data,
context={"identity": identity},
raise_errors=raise_errors,
)

# create the user with the specified data
user = self.user_cls.create(data)

# create user
user = self._create(data)
# run components
self.run_components(
"create",
Expand All @@ -57,14 +66,41 @@ def create(self, identity, data, raise_errors=True, uow=None):
errors=errors,
uow=uow,
)

# persist user to DB (indexing is done in the session hooks, see ext)
uow.register(RecordCommitOp(user))

uow.register(RecordCommitOp(user, indexer=self.indexer, index_refresh=True))
# get email token and reset info
account_user = current_datastore.get_user(user.id)
token, reset_link = default_reset_password_link_func(account_user)
# trigger celery task to send email.
uow.register(
TaskOp(
execute_reset_password_email,
user_id=user.id,
token=token,
reset_link=reset_link,
)
)
return self.result_item(
self, identity, user, links_tpl=self.links_item_tpl, errors=errors
)

def _generate_password(self, length=12):
"""Generate password of a specific length."""
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length))

def _create(self, user_info: dict):
"""Create a new active and verified user with auto-generated password."""
# Generate password and add to user_info dict
user_info["password"] = hash_password(self._generate_password())

# Create the user with the specified data
user = self.user_cls.create(user_info)
# Activate and verify user
# FIXME: maybe we want to allow for selection, email sent will be different!
user.activate()
user.verify()
Comment on lines +100 to +101
Copy link
Member

@Samk13 Samk13 Oct 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question:
The _create_user_as_admin method automatically activates and verifies the user without additional confirmation steps. Should we consider verification steps, such as sending a confirmation email or letting the user log in and self-activate their account as it might be necessary to ensure the user's email is valid and belongs to them?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We thought/discussed this internally and concluded that, because an email to change the password has already been sent, the email would be verified and correct once the new user changed their password. Does it make sense?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's defer to the CERN team on this decision. However, I think it's important to consider that if an admin enters an incorrect email address "admins are still humans :) ", it could result in an active and verified account that isn't linked to the intended user.
Implementing a verification step during the user's first login would ensure that the email is valid and belongs to the correct individual, thereby enhancing account security.

return user

def search(self, identity, params=None, search_preference=None, **kwargs):
"""Search for active and confirmed users, matching the query."""
return super().search(
Expand Down
20 changes: 20 additions & 0 deletions invenio_users_resources/services/users/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#
# Copyright (C) 2022 CERN.
# Copyright (C) 2022 TU Wien.
# Copyright (C) 2024 Ubiquity Press.
#
# Invenio-Users-Resources is free software; you can redistribute it and/or
# modify it under the terms of the MIT License; see LICENSE file for more
Expand All @@ -11,6 +12,9 @@

from celery import shared_task
from flask import current_app
from flask_security.signals import reset_password_instructions_sent
from flask_security.utils import config_value, send_mail
from invenio_accounts.proxies import current_datastore
from invenio_records_resources.services.uow import UnitOfWork
from invenio_records_resources.tasks import send_change_notifications
from invenio_search.engine import search
Expand Down Expand Up @@ -94,3 +98,19 @@ def execute_moderation_actions(user_id=None, action=None):
)
# If a callback fails, rollback the operation and stop processing callbacks
uow.rollback()


@shared_task(ignore_result=True, acks_late=True, retry=True)
def execute_reset_password_email(user_id=None, token=None, reset_link=None):
"""Send email to email address of new user to reset password."""
account_user = current_datastore.get_user(user_id)
send_mail(
config_value("EMAIL_SUBJECT_PASSWORD_RESET"),
account_user.email,
"reset_instructions",
user=account_user,
reset_link=reset_link,
)
reset_password_instructions_sent.send(
current_app._get_current_object(), user=account_user, token=token
)
18 changes: 18 additions & 0 deletions tests/resources/test_resources_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# Copyright (C) 2022 European Union.
# Copyright (C) 2022 CERN.
# Copyright (C) 2024 KTH Royal Institute of Technology.
# Copyright (C) 2024 Ubiquity Press.
#
# Invenio-Users-Resources is free software; you can redistribute it and/or
# modify it under the terms of the MIT License; see LICENSE file for more
Expand Down Expand Up @@ -115,6 +116,23 @@ def test_user_avatar(client, user_pub):
#
# Management / moderation
#
def test_create_user(client, headers, user_moderator, db):
"""Tests approve user endpoint."""
client = user_moderator.login(client)
res = client.post(
"/users",
json={
"username": "newuser",
"email": "[email protected]",
},
headers=headers,
)
assert res.status_code == 201

res = client.get(f"/users/{res.json['id']}")
assert res.status_code == 200
assert res.json["active"] == True
assert res.json["email"] == "[email protected]"


def test_approve_user(client, headers, user_pub, user_moderator, db):
Expand Down
Loading