diff --git a/.vscode/launch.json b/.vscode/launch.json index 7b934870170c205e77a6d7f3bb3aaa7d31b5d6e2..f4224315413ab7810e6cf9310a8e06717210c1a7 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -44,7 +44,7 @@ "cwd": "${workspaceFolder}", "program": "${workspaceFolder}/.pyenv/bin/pytest", "args": [ - "-sv", "tests/test_api.py::TestUploads::test_post[tests/data/proc/empty.zip]" + "-sv", "tests/test_api.py::TestAuth::test_put_user" ] }, { diff --git a/nomad/api/auth.py b/nomad/api/auth.py index ddb68b2204519a6f132f97bd19e310af72e26a3c..f6db27ae3e644acb00523337b33798b05141817c 100644 --- a/nomad/api/auth.py +++ b/nomad/api/auth.py @@ -135,6 +135,7 @@ user_model = api.model('User', { 'last_name': fields.String(description='The user\'s last name'), 'email': fields.String(description='Guess what, the user\'s email'), 'affiliation': fields.String(description='The user\'s affiliation'), + 'password': fields.String(description='The bcrypt 2y-indented password for initial and changed password'), 'token': fields.String( description='The access token that authenticates the user with the API. ' 'User the HTTP header "X-Token" to provide it in API requests.') @@ -161,6 +162,54 @@ class UserResource(Resource): 401, message='User not logged in, provide credentials via Basic HTTP authentication.') + @api.doc('create_user') + @api.expect(user_model) + @api.marshal_with(user_model, skip_none=True, code=200, description='User created') + @login_really_required + def put(self): + """ + Creates a new user account. Currently only the admin user is allows. The + NOMAD-CoE repository GUI should be used to create user accounts for now. + Passwords have to be encrypted by the client with bcrypt and 2y indent. + """ + if not g.user.is_admin: + abort(401, message='Only the admin user can perform create user.') + + data = request.get_json() + if data is None: + data = {} + + for required_key in ['last_name', 'first_name', 'password', 'email']: + if required_key not in data: + abort(400, message='The %s is missing' % required_key) + + user = coe_repo.User.create_user( + email=data['email'], password=data.get('password', None), crypted=True, + first_name=data['first_name'], last_name=data['last_name'], + affiliation=data.get('affiliation', None)) + + return user, 200 + + @api.doc('update_user') + @api.expect(user_model) + @api.marshal_with(user_model, skip_none=True, code=200, description='User updated') + @login_really_required + def post(self): + """ + Allows to edit the authenticated user and change his password. Password + have to be encrypted by the client with bcrypt and 2y indent. + """ + data = request.get_json() + if data is None: + data = {} + + if 'email' in data: + abort(400, message='Cannot change the users email.') + + g.user.update(crypted=True, **data) + + return g.user, 200 + token_model = api.model('Token', { 'user': fields.Nested(user_model), diff --git a/nomad/coe_repo/user.py b/nomad/coe_repo/user.py index f3c8244fc458aff5a56a6e9825fe9fffb71b8d48..18309eff7590d51eb92ad7a264e2ded116036b2a 100644 --- a/nomad/coe_repo/user.py +++ b/nomad/coe_repo/user.py @@ -13,9 +13,12 @@ # limitations under the License. from passlib.hash import bcrypt -from sqlalchemy import Column, Integer, String +from sqlalchemy import Column, Integer, String, ForeignKey +from sqlalchemy.orm import relationship import datetime import jwt +import random +import string from nomad import infrastructure, config, utils @@ -25,8 +28,9 @@ from .base import Base class Session(Base): # type: ignore __tablename__ = 'sessions' - token = Column(String, primary_key=True) - user_id = Column(String) + token = Column(String) + user_id = Column(String, ForeignKey('users.user_id'), primary_key=True) + user = relationship('User') class LoginException(Exception): @@ -52,20 +56,51 @@ class User(Base): # type: ignore affiliation = Column(String) password = Column(String) + _token_chars = string.ascii_uppercase + string.ascii_lowercase + string.digits + def __repr__(self): return '<User(email="%s")>' % self.email - def _hash_password(self, password): - assert False, 'Login functions are done by the NOMAD-coe repository GUI' - # password_hash = bcrypt.encrypt(password, ident='2y') - # self.password = password_hash + @staticmethod + def create_user(email: str, password: str, crypted: bool, **kwargs): + repo_db = infrastructure.repository_db + repo_db.begin() + try: + user = User(email=email, **kwargs) + repo_db.add(user) + user.set_password(password, crypted) + + # TODO this has to change, e.g. trade for JWTs + token = ''.join(random.choices(User._token_chars, k=64)) + repo_db.add(Session(token=token, user=user)) + + repo_db.commit() + return user + except Exception as e: + repo_db.rollback() + utils.get_logger('__name__').error('could not create user', email=email, exc_info=e) + raise e + + def update(self, crypted: bool = True, password: str = None, **kwargs): + repo_db = infrastructure.repository_db + repo_db.begin() + try: + if password is not None: + self.set_password(password, crypted=crypted) + + for key in kwargs: + setattr(self, key, kwargs.get(key)) + + repo_db.commit() + except Exception as e: + repo_db.rollback() + utils.get_logger('__name__').error( + 'could not edit user', email=self.email, user_id=self.user_id, exc_info=e) + raise e def _verify_password(self, password): return bcrypt.verify(password, self.password) - def _generate_auth_token(self, expiration=600): - assert False, 'Login functions are done by the NOMAD-coe repository GUI' - @staticmethod def from_user_id(user_id) -> 'User': return infrastructure.repository_db.query(User).get(user_id) @@ -90,6 +125,20 @@ class User(Base): # type: ignore config.services.api_secret, 'HS256').decode('utf-8') return token, expires_at + def set_password(self, password: str, crypted: bool): + """ + Sets the users password. With ``crypted=True`` password is supposed to + be already bcrypted and 2y-indented. + """ + if password is None: + return + + if crypted: + self.password = password + else: + password_hash = bcrypt.encrypt(password, ident='2y') + self.password = password_hash + @property def token(self): return self.get_auth_token().decode('utf-8') diff --git a/tests/conftest.py b/tests/conftest.py index 0788ed9852aebba639f6be843a19b8adac205239..02350c7c5b706b10eed2e5f0d29d6178ca4b8007 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -264,6 +264,8 @@ def postgres(postgres_infra): """ Provides a clean coe repository db per function. Clears db before test. """ # do not wonder, this will not setback the id counters postgres_infra.execute('TRUNCATE uploads CASCADE;') + postgres_infra.execute('DELETE FROM sessions WHERE user_id >= 4;') + postgres_infra.execute('DELETE FROM users WHERE user_id >= 4;') yield postgres_infra diff --git a/tests/test_api.py b/tests/test_api.py index 1d382784edf7639e70278f4bb8acdae164b41e12..48e5c61aff6a5492b54f57c2a3fb51517dd3dcfa 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -19,6 +19,7 @@ import base64 import zipfile import io import inspect +from passlib.hash import bcrypt from nomad import config, coe_repo from nomad.files import UploadFiles, PublicUploadFiles @@ -112,7 +113,9 @@ class TestAuth: def test_get_user(self, client, test_user_auth, test_user: User, no_warn): rv = client.get('/auth/user', headers=test_user_auth) assert rv.status_code == 200 - user = json.loads(rv.data) + self.assert_user(client, json.loads(rv.data)) + + def assert_user(self, client, user): for key in ['first_name', 'last_name', 'email', 'token']: assert key in user @@ -125,6 +128,49 @@ class TestAuth: def test_signature_token(self, test_user_signature_token, no_warn): assert test_user_signature_token is not None + def test_put_user(self, client, postgres, admin_user_auth): + rv = client.put( + '/auth/user', headers=admin_user_auth, + content_type='application/json', data=json.dumps(dict( + email='test@email.com', last_name='Tester', first_name='Testi', + password=bcrypt.encrypt('test_password', ident='2y')))) + + assert rv.status_code == 200 + self.assert_user(client, json.loads(rv.data)) + + def test_put_user_admin_only(self, client, test_user_auth): + rv = client.put( + '/auth/user', headers=test_user_auth, + content_type='application/json', data=json.dumps(dict( + email='test@email.com', last_name='Tester', first_name='Testi', + password=bcrypt.encrypt('test_password', ident='2y')))) + assert rv.status_code == 401 + + def test_put_user_required_field(self, client, admin_user_auth): + rv = client.put( + '/auth/user', headers=admin_user_auth, + content_type='application/json', data=json.dumps(dict( + email='test@email.com', password=bcrypt.encrypt('test_password', ident='2y')))) + assert rv.status_code == 400 + + def test_post_user(self, client, postgres, admin_user_auth): + rv = client.put( + '/auth/user', headers=admin_user_auth, + content_type='application/json', data=json.dumps(dict( + email='test@email.com', last_name='Tester', first_name='Testi', + password=bcrypt.encrypt('test_password', ident='2y')))) + + assert rv.status_code == 200 + user = json.loads(rv.data) + + rv = client.post( + '/auth/user', headers={'X-Token': user['token']}, + content_type='application/json', data=json.dumps(dict( + last_name='Tester', first_name='Testi v.', + password=bcrypt.encrypt('test_password_changed', ident='2y')))) + assert rv.status_code == 200 + self.assert_user(client, json.loads(rv.data)) + class TestUploads: diff --git a/tests/test_coe_repo.py b/tests/test_coe_repo.py index 20a481af81fd38ef691ef2858259a845c53d5d68..927af0b15b7d06c2ebcecc2bc8ff408c5005e4dd 100644 --- a/tests/test_coe_repo.py +++ b/tests/test_coe_repo.py @@ -13,6 +13,7 @@ # limitations under the License. import pytest +from passlib.hash import bcrypt from nomad.coe_repo import User, Calc, Upload from nomad import processing, parsing, datamodel @@ -117,6 +118,20 @@ def test_add_upload_with_metadata(processed, example_user_metadata): processed.upload_id, upload_with_metadata) +@pytest.mark.parametrize('crypted', [True, False]) +def test_create_user(postgres, crypted): + password = bcrypt.encrypt('test_password', ident='2y') if crypted else 'test_password' + data = dict( + email='test@email.com', last_name='Teser', first_name='testi', password=password) + + user = User.create_user(**data, crypted=crypted) + + authenticated_user = User.verify_user_password('test@email.com', 'test_password') + assert authenticated_user is not None + assert user.user_id == authenticated_user.user_id + assert user.get_auth_token() is not None + + class TestDataSets: @pytest.fixture(scope='function')