Commit 1fb5ef2c authored by Markus Scheidgen's avatar Markus Scheidgen
Browse files

Added create/update user to the api.

parent 48cf90b6
......@@ -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"
]
},
{
......
......@@ -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),
......
......@@ -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')
......
......@@ -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
......
......@@ -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:
......
......@@ -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')
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment