diff --git a/datameta/alembic/versions/20211029_98911b881230.py b/datameta/alembic/versions/20211029_98911b881230.py new file mode 100644 index 00000000..bcd99797 --- /dev/null +++ b/datameta/alembic/versions/20211029_98911b881230.py @@ -0,0 +1,37 @@ +"""added failed_login_attempt tracking + +Revision ID: 98911b881230 +Revises: 7fdc829db18d +Create Date: 2021-10-29 15:01:29.844727 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '98911b881230' +down_revision = '6a9f0bec38e6' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + 'loginattempts', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('uuid', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('timestamp', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_loginattempts_user_id_users')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_loginattempts')), + sa.UniqueConstraint('uuid', name=op.f('uq_loginattempts_uuid')) + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('loginattempts') + # ### end Alembic commands ### diff --git a/datameta/api/apikeys.py b/datameta/api/apikeys.py index bb7cb580..e95c8042 100644 --- a/datameta/api/apikeys.py +++ b/datameta/api/apikeys.py @@ -18,7 +18,7 @@ from typing import Optional, List from .. import models from .. import security -from ..security import authz +from ..security import authz, tokenz from pyramid.httpexceptions import HTTPOk, HTTPUnauthorized, HTTPForbidden from ..resource import resource_by_id, get_identifier from ..errors import get_validation_error @@ -95,8 +95,8 @@ def generate_api_key(request: Request, user: models.User, label: str, expires: O """ Generate API Token and store unsalted hash in db. """ - token = security.generate_token() - token_hash = security.hash_token(token) + token = tokenz.generate_token() + token_hash = tokenz.hash_token(token) db = request.dbsession apikey = models.ApiKey( diff --git a/datameta/api/appsettings.py b/datameta/api/appsettings.py index d22aff01..73ed3fe1 100644 --- a/datameta/api/appsettings.py +++ b/datameta/api/appsettings.py @@ -23,8 +23,7 @@ from .. import resource, security, errors from ..security import authz from ..resource import resource_by_id -from ..settings import get_setting_value_type -import datetime +from ..settings import get_setting_value_type, set_setting, SettingUpdateError @dataclass @@ -89,43 +88,11 @@ def put(request: Request): raise HTTPForbidden() target_setting = resource_by_id(db, ApplicationSetting, settings_id) - - temp, value_type = get_setting_value_type(target_setting) value = request.openapi_validated.body["value"] - if value_type == 'int': - try: - value_int = int(value) - except ValueError: - raise errors.get_validation_error(["You have to provide an integer."]) - - target_setting.int_value = value_int - - elif value_type == 'string': - target_setting.str_value = value - - elif value_type == 'float': - try: - value_float = float(value) - except ValueError: - raise errors.get_validation_error(["You have to provide a float."]) - - target_setting.float_value = value_float - - elif value_type == 'date': - try: - datetime.datetime.strptime(value, "%Y-%m-%d") - except ValueError: - raise errors.get_validation_error(["The date value has to specified in the form '%Y-%m-%d'."]) - - target_setting.date_value = value - - elif value_type == 'time': - try: - datetime.datetime.strptime(value, "%H:%M:%S") - except ValueError: - raise errors.get_validation_error(["The time value has to specified in the form '%H:%M:%S'."]) - - target_setting.date_value = value + try: + set_setting(db, target_setting.key, value) + except SettingUpdateError as e: + raise errors.get_validation_error([str(e)]) return HTTPNoContent() diff --git a/datameta/api/download.py b/datameta/api/download.py index 40821e48..9489088f 100644 --- a/datameta/api/download.py +++ b/datameta/api/download.py @@ -18,6 +18,7 @@ from pyramid.response import FileResponse from datetime import datetime from .. import security, models, storage +from ..security import tokenz from ..resource import get_identifier from .files import access_file_by_user from sqlalchemy import and_ @@ -75,7 +76,7 @@ def download_by_token(request: Request) -> HTTPOk: Usage: /download/{download_token} """ token = request.matchdict['token'] - hashed_token = security.hash_token(token) + hashed_token = tokenz.hash_token(token) # get download token from db db = request.dbsession diff --git a/datameta/api/password.py b/datameta/api/password.py index 6c80c566..5259fa38 100644 --- a/datameta/api/password.py +++ b/datameta/api/password.py @@ -17,7 +17,7 @@ from pyramid.view import view_config from .. import security, errors -from ..security import authz +from ..security import authz, tokenz, pwdz from ..models import User from datetime import datetime @@ -41,7 +41,7 @@ def put(request): token = None if request_id == '0': # Try to find the token - token = security.get_password_reset_token(db, request_credential) + token = tokenz.get_password_reset_token(db, request_credential) if token is None: raise HTTPNotFound() # 404 Token not found @@ -64,16 +64,16 @@ def put(request): raise HTTPForbidden() # 403 User ID not found, hidden from the user intentionally # Only changing the user's own password is allowed - if not authz.update_user_password(auth_user, target_user) or not security.check_password_by_hash(request_credential, auth_user.pwhash): + if not authz.update_user_password(auth_user, target_user) or not pwdz.check_password_by_hash(request_credential, auth_user.pwhash): raise HTTPForbidden() # 403 Not authorized to change this user's password # Verify the password quality - error = security.verify_password(db, auth_user.id, request_newPassword) + error = pwdz.verify_password(db, auth_user.id, request_newPassword) if error: raise errors.get_validation_error([error]) # Set the new password - auth_user.pwhash = security.register_password(db, auth_user.id, request_newPassword) + auth_user.pwhash = pwdz.register_password(db, auth_user.id, request_newPassword) # Delete the password token if any if token: diff --git a/datameta/api/ui/admin.py b/datameta/api/ui/admin.py index 18e83edd..d902db9d 100644 --- a/datameta/api/ui/admin.py +++ b/datameta/api/ui/admin.py @@ -18,6 +18,7 @@ from ...models import User, Group, RegRequest from ...settings import get_setting from ... import email, security, siteid, errors +from ...security import tokenz from ...resource import resource_by_id, get_identifier from sqlalchemy.exc import IntegrityError @@ -98,7 +99,7 @@ def v_admin_put_request(request): db.delete(reg_req) # Obtain a new token - _, clear_token = security.get_new_password_reset_token(db, new_user) + _, clear_token = tokenz.get_new_password_reset_token(db, new_user) # Generate the token url token_url = request.route_url('setpass', token = clear_token) diff --git a/datameta/api/ui/forgot.py b/datameta/api/ui/forgot.py index dcdd0d8b..602cf89b 100644 --- a/datameta/api/ui/forgot.py +++ b/datameta/api/ui/forgot.py @@ -14,7 +14,8 @@ from pyramid.view import view_config -from ... import security, email +from ... import email +from ...security import tokenz from ...settings import get_setting import re @@ -51,7 +52,7 @@ def v_forgot_api(request): return { 'success' : False, 'error' : 'MALFORMED_EMAIL' } try: - db_token_obj, clear_token = security.get_new_password_reset_token_from_email(db, req_email) + db_token_obj, clear_token = tokenz.get_new_password_reset_token_from_email(db, req_email) except KeyError: # User not found log.debug(f"DURING RECOVERY TOKEN REQUEST: USER COULD NOT BE RESOLVED FROM EMAIL: {req_email}") diff --git a/datameta/api/upload.py b/datameta/api/upload.py index 0925e4f2..770b64d2 100644 --- a/datameta/api/upload.py +++ b/datameta/api/upload.py @@ -18,7 +18,8 @@ import webob import logging from datetime import datetime -from .. import resource, models, storage, security +from .. import resource, models, storage +from ..security import tokenz log = logging.getLogger(__name__) @@ -53,7 +54,7 @@ def post(request) -> HTTPNoContent: db_file = resource.resource_by_id(db, models.File, req_file_id) # Validate access token match - db_file = db_file if db_file and req_token and db_file.upload_expires > datetime.now() and db_file.access_token == security.hash_token(req_token) else None + db_file = db_file if db_file and req_token and db_file.upload_expires > datetime.now() and db_file.access_token == tokenz.hash_token(req_token) else None if db_file is None: # Includes token mismatch raise HTTPNotFound(json=None) diff --git a/datameta/defaults/appsettings.yaml b/datameta/defaults/appsettings.yaml index 027d05cb..dc6be429 100644 --- a/datameta/defaults/appsettings.yaml +++ b/datameta/defaults/appsettings.yaml @@ -41,7 +41,6 @@ template_welcome_token: Best regards, The Support Team - }, subject_reject: str_value : Your registration request was rejected @@ -85,6 +84,9 @@ logo_html: user_agreement: str_value: "" +security_max_failed_login_attempts: + int_value: 5 + security_password_minimum_length: int_value: 10 diff --git a/datameta/models/__init__.py b/datameta/models/__init__.py index ab7d49fb..5d717fa0 100644 --- a/datameta/models/__init__.py +++ b/datameta/models/__init__.py @@ -36,7 +36,8 @@ DownloadToken, Service, ServiceExecution, - UsedPassword + LoginAttempt, + UsedPassword, ) # run configure_mappers after defining all of the models to ensure diff --git a/datameta/models/db.py b/datameta/models/db.py index ff1a7697..2f50e51c 100644 --- a/datameta/models/db.py +++ b/datameta/models/db.py @@ -91,6 +91,17 @@ class User(Base): services = relationship('Service', secondary=user_service_table, back_populates='users') service_executions = relationship('ServiceExecution', back_populates='user') used_passwords = relationship('UsedPassword', back_populates='user') + login_attempts = relationship("LoginAttempt", back_populates='user', cascade="all, delete-orphan") + + +class LoginAttempt(Base): + __tablename__ = 'loginattempts' + id = Column(Integer, primary_key=True) + uuid = Column(UUID(as_uuid=True), unique=True, default=uuid.uuid4, nullable=False) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) + timestamp = Column(DateTime, nullable=False) + # Relationships + user = relationship('User', back_populates='login_attempts') class ApiKey(Base): diff --git a/datameta/scripts/initialize_db.py b/datameta/scripts/initialize_db.py index 6fc56787..e6c2eb8a 100644 --- a/datameta/scripts/initialize_db.py +++ b/datameta/scripts/initialize_db.py @@ -18,7 +18,8 @@ from pyramid.paster import bootstrap, setup_logging from sqlalchemy.exc import OperationalError -from ..security import register_password, hash_token +from ..security.pwdz import register_password +from ..security.tokenz import hash_token from ..models import User, Group, MetaDatum, DateTimeMode, ApiKey diff --git a/datameta/security/__init__.py b/datameta/security/__init__.py index a042aee1..3fcb5f87 100644 --- a/datameta/security/__init__.py +++ b/datameta/security/__init__.py @@ -12,97 +12,34 @@ # See the License for the specific language governing permissions and # limitations under the License. -from pyramid.httpexceptions import HTTPFound, HTTPUnauthorized +import logging + from datetime import datetime, timedelta from typing import Optional -from random import choice -from string import ascii_letters, digits, punctuation + +from pyramid.httpexceptions import HTTPFound, HTTPUnauthorized from sqlalchemy import and_ -from ..models import User, ApiKey, PasswordToken, Session, UsedPassword +from ..models import User, ApiKey, LoginAttempt from ..settings import get_setting -import bcrypt -import hashlib -import logging -import secrets -log = logging.getLogger(__name__) - - -def register_password(db, user_id, password): - """ Hashes an accepted password string, adds the hash to the user's password hash history. - - Returns: - - the hashed password - """ - hashed_pw = hash_password(password) - db.add(UsedPassword(user_id=user_id, pwhash=hashed_pw)) - - return hashed_pw - - -def is_used_password(db, user_id, password): - """ Checks a password string against a user's password hash history. - - Returns: - - True, if password has been used before by the user - - False, otherwise - """ - - used_passwords = db.query(UsedPassword).filter(UsedPassword.user_id == user_id).all() - for pw in used_passwords: - if check_password_by_hash(password, pw.pwhash): - return True - - return False - - -def generate_token(): - return "".join(choice(ascii_letters + digits) for _ in range(64) ) - - -def get_new_password_reset_token(db: Session, user: User, expires=None): - """Clears all password recovery tokens for user identified by the supplied - email address, generates a new one and returns it. - - Returns: - - PasswordToken object (with hashed token value) - - unhashed token value - """ - - # Delete existing tokens - db.query(PasswordToken).filter(PasswordToken.user_id == user.id).delete() - - # Create new token value - clear_token = secrets.token_urlsafe(40) - - # Add hashed token to db - db_token_obj = PasswordToken( - user=user, - value=hash_token(clear_token), - expires=expires if expires else datetime.now() + timedelta(minutes=10) +from .pwdz import ( # noqa: F401 + register_password, + is_used_password, + verify_password, + hash_password, + check_password_by_hash ) - db.add(db_token_obj) - - return db_token_obj, clear_token - -def get_new_password_reset_token_from_email(db: Session, email: str): - """Clears all password recovery tokens for user identified by the supplied - email address, generates a new one and returns it. - - Returns: - - PasswordToken with hashed token value - - cleartext token for user notification - Raises: - KeyError - if user not found/not enabled""" - user = db.query(User).filter(User.enabled.is_(True), User.email == email).one_or_none() - - # User not found or disabled - if not user: - raise KeyError(f"Could not find active user with email={email}") +from .tokenz import ( # noqa: F401 + generate_token, + get_new_password_reset_token, + get_new_password_reset_token_from_email, + hash_token, get_bearer_token, + get_password_reset_token + ) - return get_new_password_reset_token(db, user) +log = logging.getLogger(__name__) def check_expiration(expiration_datetime: Optional[datetime]): @@ -112,127 +49,116 @@ def check_expiration(expiration_datetime: Optional[datetime]): return expiration_datetime is not None and datetime.now() >= expiration_datetime -def verify_password(db, user_id, password): - if is_used_password(db, user_id, password): - return "The password has already been used." - - pw_min_length = get_setting(db, "security_password_minimum_length") - pw_min_ucase = get_setting(db, "security_password_minimum_uppercase_characters") - pw_min_lcase = get_setting(db, "security_password_minimum_lowercase_characters") - pw_min_digits = get_setting(db, "security_password_minimum_digits") - pw_min_punctuation = get_setting(db, "security_password_minimum_punctuation_characters") +def register_failed_login_attempt(db, user): + """ Registers a failed login attempt and disables user if this has happened too often in the last hour.""" - if len(password) < pw_min_length: - return f"The password must be at least {pw_min_length} characters long." + now = datetime.utcnow() + max_allowed_failed_logins = get_setting(db, "security_max_failed_login_attempts") - alphas = [c for c in password if c.isalpha()] - digits = [c for c in password if c.isdigit()] - puncs = [c for c in password if c in punctuation] - n_upper = sum(c.isupper() for c in alphas) - n_lower = len(alphas) - n_upper - - if n_upper < pw_min_ucase or n_lower < pw_min_lcase or len(digits) < pw_min_digits or len(puncs) < pw_min_punctuation: - return f"Password must contain at least {pw_min_ucase} uppercase characters, " \ - f"{pw_min_lcase} lowercase character(s), " \ - f"{pw_min_punctuation} punctuation mark(s), and " \ - f"{pw_min_digits} digit(s)." - - invalid_chars = set(password).difference(alphas).difference(digits).difference(puncs) + db.add(LoginAttempt(user_id=user.id, timestamp=now)) + db.flush() + n_failed_logins = sum( + now - attempt.timestamp <= timedelta(hours=1) + for attempt in db.query(LoginAttempt).filter(LoginAttempt.user_id == user.id).all() + ) - if invalid_chars: - return f"Invalid password characters: {','.join(invalid_chars)}" + log.warning(f"FAILED LOGIN ATTEMPT USER id={user.id} n={n_failed_logins} within one hour.") + if n_failed_logins >= max_allowed_failed_logins: + db.query(User).filter(user.id == User.id).update({User.enabled: False}) + db.flush() + log.warning(f"BLOCKED USER id={user.id} enabled={user.enabled} reason={n_failed_logins} failed login attempts within one hour.") return None -def hash_password(pw): - """Hash a password and return the salted hash""" - pwhash = bcrypt.hashpw(pw.encode('utf8'), bcrypt.gensalt()) - return pwhash.decode('utf8') - - -def check_password_by_hash(pw, hashed_pw): - """Check a password against a salted hash""" - expected_hash = hashed_pw.encode('utf8') - return bcrypt.checkpw(pw.encode('utf8'), expected_hash) - - -def hash_token(token): - """Hash a token and return the unsalted hash""" - hashed_token = hashlib.sha256(token.encode('utf-8')).hexdigest() - return hashed_token - - def get_user_by_credentials(request, email: str, password: str): + """Check a combination of email and password, returns a user object if valid""" + db = request.dbsession user = db.query(User).filter(and_(User.email == email, User.enabled.is_(True))).one_or_none() - if user and check_password_by_hash(password, user.pwhash): - return user - return None + if user: + if check_password_by_hash(password, user.pwhash): + log.warning(f"CLEARING FAILED LOGIN ATTEMPTS FOR gubc USER {user}") + user.login_attempts.clear() + return user + register_failed_login_attempt(db, user) -def get_bearer_token(request): - """Extracts a Bearer authentication token from the request and returns it if present, None - otherwise.""" - auth = request.headers.get("Authorization") - if auth is not None: - try: - method, content = auth.split(" ") - if method == "Bearer": - return content - except Exception: - pass return None -def get_password_reset_token(db: Session, token: str): - """Tries to find the corresponding password reset token in the database. - Returns the token only if it can be found and if the corresponding user is - enabled, otherwise no checking is performed, most importantly expiration - checks are not performed""" - return db.query(PasswordToken).join(User).filter(and_( - PasswordToken.value == hash_token(token), +def revalidate_user_token_based(request, token): + db = request.dbsession + + token_hash = hash_token(token) + apikey = db.query(ApiKey).join(User).filter(and_( + ApiKey.value == token_hash, User.enabled.is_(True) - )).one_or_none() + )).one_or_none() + + if apikey is not None: + apikey_expired = check_expiration(apikey.expires) + user = apikey.user + + if apikey_expired: + request.tm.abort() + request.tm.begin() + register_failed_login_attempt(db, user) + request.tm.commit() + request.tm.begin() + else: + log.warning(f"CLEARING FAILED LOGIN ATTEMPTS FOR APIKEY.USER {user.id}") + user.login_attempts.clear() + return user + raise HTTPUnauthorized() -def revalidate_user(request): - """Revalidate the currently logged in user and return the corresponding user object. On failure, - raise a 401""" - db = request.dbsession - # Check for token based auth - token = get_bearer_token(request) - if token is not None: - token_hash = hash_token(token) - apikey = db.query(ApiKey).join(User).filter(and_( - ApiKey.value == token_hash, - User.enabled.is_(True) - )).one_or_none() - if apikey is not None: - if check_expiration(apikey.expires): - raise HTTPUnauthorized() - return apikey.user - else: - raise HTTPUnauthorized() +def revalidate_user_session_based(request): # Check for session based auth if 'user_uid' not in request.session: request.session.invalidate() raise HTTPUnauthorized() - user = request.dbsession.query(User).filter(and_( + + db = request.dbsession + + user = db.query(User).filter(and_( User.id == request.session['user_uid'], User.enabled.is_(True) )).one_or_none() + # Check if the user still exists and their group hasn't changed if user is None or user.group_id != request.session['user_gid']: + if user.group_id != request.session['user_gid']: + request.tm.abort() + request.tm.begin() + register_failed_login_attempt(db, user) + request.tm.commit() + request.tm.begin() request.session.invalidate() raise HTTPUnauthorized() + request.session['site_admin'] = user.site_admin request.session['group_admin'] = user.group_admin + + log.warning(f"CLEARING FAILED LOGIN ATTEMPTS FOR USER {user}") + user.login_attempts.clear() return user +def revalidate_user(request): + """Revalidate the currently logged in user and return the corresponding user object. On failure, + raise a 401""" + + # Check for token based auth + token = get_bearer_token(request) + if token is not None: + return revalidate_user_token_based(request, token) + + return revalidate_user_session_based(request) + + def revalidate_user_or_login(request): """Revalidate and return the currently logged in user, on failure redirect to the login page""" try: diff --git a/datameta/security/pwdz.py b/datameta/security/pwdz.py new file mode 100644 index 00000000..c0ae1697 --- /dev/null +++ b/datameta/security/pwdz.py @@ -0,0 +1,96 @@ +# Copyright 2021 Universität Tübingen, DKFZ and EMBL for the German Human Genome-Phenome Archive (GHGA) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import bcrypt +import logging + +from string import punctuation + +from ..models import UsedPassword +from ..settings import get_setting + +log = logging.getLogger(__name__) + + +def register_password(db, user_id, password): + """ Hashes an accepted password string, adds the hash to the user's password hash history. + + Returns: + - the hashed password + """ + hashed_pw = hash_password(password) + db.add(UsedPassword(user_id=user_id, pwhash=hashed_pw)) + + return hashed_pw + + +def is_used_password(db, user_id, password): + """ Checks a password string against a user's password hash history. + + Returns: + - True, if password has been used before by the user + - False, otherwise + """ + + used_passwords = db.query(UsedPassword).filter(UsedPassword.user_id == user_id).all() + for pw in used_passwords: + if check_password_by_hash(password, pw.pwhash): + return True + + return False + + +def verify_password(db, user_id, password): + if is_used_password(db, user_id, password): + return "The password has already been used." + + pw_min_length = get_setting(db, "security_password_minimum_length") + pw_min_ucase = get_setting(db, "security_password_minimum_uppercase_characters") + pw_min_lcase = get_setting(db, "security_password_minimum_lowercase_characters") + pw_min_digits = get_setting(db, "security_password_minimum_digits") + pw_min_punctuation = get_setting(db, "security_password_minimum_punctuation_characters") + + if len(password) < pw_min_length: + return f"The password must be at least {pw_min_length} characters long." + + alphas = [c for c in password if c.isalpha()] + digits = [c for c in password if c.isdigit()] + puncs = [c for c in password if c in punctuation] + n_upper = sum(c.isupper() for c in alphas) + n_lower = len(alphas) - n_upper + + if n_upper < pw_min_ucase or n_lower < pw_min_lcase or len(digits) < pw_min_digits or len(puncs) < pw_min_punctuation: + return f"Password must contain at least {pw_min_ucase} uppercase characters, " \ + f"{pw_min_lcase} lowercase character(s), " \ + f"{pw_min_punctuation} punctuation mark(s), and " \ + f"{pw_min_digits} digit(s)." + + invalid_chars = set(password).difference(alphas).difference(digits).difference(puncs) + + if invalid_chars: + return f"Invalid password characters: {','.join(invalid_chars)}" + + return None + + +def hash_password(pw): + """Hash a password and return the salted hash""" + pwhash = bcrypt.hashpw(pw.encode('utf8'), bcrypt.gensalt()) + return pwhash.decode('utf8') + + +def check_password_by_hash(pw, hashed_pw): + """Check a password against a salted hash""" + expected_hash = hashed_pw.encode('utf8') + return bcrypt.checkpw(pw.encode('utf8'), expected_hash) diff --git a/datameta/security/tokenz.py b/datameta/security/tokenz.py new file mode 100644 index 00000000..4c1c1882 --- /dev/null +++ b/datameta/security/tokenz.py @@ -0,0 +1,107 @@ +# Copyright 2021 Universität Tübingen, DKFZ and EMBL for the German Human Genome-Phenome Archive (GHGA) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import hashlib +import logging +import secrets + +from datetime import datetime, timedelta +from random import choice +from string import ascii_letters, digits + +from sqlalchemy import and_ + +from ..models import User, PasswordToken, Session + + +log = logging.getLogger(__name__) + + +def generate_token(): + return "".join(choice(ascii_letters + digits) for _ in range(64) ) + + +def get_new_password_reset_token(db: Session, user: User, expires=None): + """Clears all password recovery tokens for user identified by the supplied + email address, generates a new one and returns it. + + Returns: + - PasswordToken object (with hashed token value) + - unhashed token value + """ + + # Delete existing tokens + db.query(PasswordToken).filter(PasswordToken.user_id == user.id).delete() + + # Create new token value + clear_token = secrets.token_urlsafe(40) + + # Add hashed token to db + db_token_obj = PasswordToken( + user=user, + value=hash_token(clear_token), + expires=expires if expires else datetime.now() + timedelta(minutes=10) + ) + db.add(db_token_obj) + + return db_token_obj, clear_token + + +def get_new_password_reset_token_from_email(db: Session, email: str): + """Clears all password recovery tokens for user identified by the supplied + email address, generates a new one and returns it. + + Returns: + - PasswordToken with hashed token value + - cleartext token for user notification + Raises: + KeyError - if user not found/not enabled""" + user = db.query(User).filter(User.enabled.is_(True), User.email == email).one_or_none() + + # User not found or disabled + if not user: + raise KeyError(f"Could not find active user with email={email}") + + return get_new_password_reset_token(db, user) + + +def hash_token(token): + """Hash a token and return the unsalted hash""" + hashed_token = hashlib.sha256(token.encode('utf-8')).hexdigest() + return hashed_token + + +def get_bearer_token(request): + """Extracts a Bearer authentication token from the request and returns it if present, None + otherwise.""" + auth = request.headers.get("Authorization") + if auth is not None: + try: + method, content = auth.split(" ") + if method == "Bearer": + return content + except Exception: + pass + return None + + +def get_password_reset_token(db: Session, token: str): + """Tries to find the corresponding password reset token in the database. + Returns the token only if it can be found and if the corresponding user is + enabled, otherwise no checking is performed, most importantly expiration + checks are not performed""" + return db.query(PasswordToken).join(User).filter(and_( + PasswordToken.value == hash_token(token), + User.enabled.is_(True) + )).one_or_none() diff --git a/datameta/settings.py b/datameta/settings.py index cc45f88c..b2ca3e7b 100644 --- a/datameta/settings.py +++ b/datameta/settings.py @@ -12,11 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -import transaction -from .models import ApplicationSetting, get_engine, get_tm_session, get_session_factory +import datetime import pkgutil import yaml +import transaction +from .models import ApplicationSetting, get_engine, get_tm_session, get_session_factory + import logging log = logging.getLogger(__name__) @@ -65,6 +67,58 @@ def get_setting(db, name): return None +class SettingUpdateError(RuntimeError): + pass + + +VALUE_CASTS = { + "int": { + "function": int, + "error_msg": "You have to provide an integer.", + "target": "int_value" + }, + "string": { + "function": lambda value: value, + "error_msg": "", + "target": "str_value" + }, + "float": { + "function": float, + "error_msg": "You have to provide an integer.", + "target": "float_value" + }, + "date": { + "function": lambda value: datetime.datetime.strptime(value, "%Y-%m-%d"), + "error_msg": "The date value has to specified in the form '%Y-%m-%d'.", + "target": "date_value" + }, + "time": { + "function": lambda value: datetime.datetime.strptime(value, "%H:%M:%S"), + "error_msg": "The time value has to specified in the form '%H:%M:%S'.", + "target": "time_value" + } +} + + +def set_setting(db, name, value): + + target_setting = db.query(ApplicationSetting).filter(ApplicationSetting.key == name).one_or_none() + _, value_type = get_setting_value_type(target_setting) + + cast_params = VALUE_CASTS.get(value_type) + + if cast_params is not None: + + try: + casted_value = cast_params["function"](value) + except ValueError: + raise SettingUpdateError(cast_params["error_msg"]) + + setattr(target_setting, cast_params["target"], casted_value) + + return None + + def includeme(config): """Initializes the default values for applications settings""" # Load the app setting defaults from the packaged yaml file diff --git a/datameta/storage.py b/datameta/storage.py index d2a19d5f..70717481 100644 --- a/datameta/storage.py +++ b/datameta/storage.py @@ -19,7 +19,8 @@ from datetime import datetime, timedelta from pyramid.request import Request from typing import Optional -from . import security, models +from . import models +from .security import tokenz from .api import base_url log = logging.getLogger(__name__) @@ -77,8 +78,8 @@ def create_and_annotate_storage(request, db_file): # Currently, only local storage is supported db_file.storage_uri = f"file://{db_file.uuid}__{db_file.checksum}" - token = security.generate_token() - db_file.access_token = security.hash_token(token) + token = tokenz.generate_token() + db_file.access_token = tokenz.hash_token(token) # Create empty file open(get_local_storage_path(request, db_file.storage_uri), 'w').close() @@ -162,8 +163,8 @@ def _get_download_url_local(request: Request, db_file: models.File, expires_afte if expires_after is None: expires_after = 1 - token = security.generate_token() - token_hash = security.hash_token(token) + token = tokenz.generate_token() + token_hash = tokenz.hash_token(token) expires = datetime.utcnow() + timedelta(minutes = float(expires_after)) db = request.dbsession diff --git a/datameta/views/setpass.py b/datameta/views/setpass.py index 8cd2013d..240ebb4a 100644 --- a/datameta/views/setpass.py +++ b/datameta/views/setpass.py @@ -14,7 +14,7 @@ from pyramid.view import view_config -from .. import security +from ..security import tokenz from ..api.ui.forgot import send_forgot_token import datetime @@ -23,14 +23,14 @@ @view_config(route_name='setpass', renderer='../templates/setpass.pt') def v_setpass(request): # Validate token - dbtoken = security.get_password_reset_token(request.dbsession, request.matchdict['token']) + dbtoken = tokenz.get_password_reset_token(request.dbsession, request.matchdict['token']) unknown_token = dbtoken is None expired_token = dbtoken is not None and dbtoken.expires < datetime.datetime.now() # Token expired? Send new one. if expired_token: - db_token_obj, clear_token = security.get_new_password_reset_token(request.dbsession, dbtoken.user) + db_token_obj, clear_token = tokenz.get_new_password_reset_token(request.dbsession, dbtoken.user) send_forgot_token(request, db_token_obj, clear_token) return { diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index 40bf0376..9fc81da4 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -9,7 +9,8 @@ from datameta.models import ( get_engine, - get_session_factory + get_session_factory, + get_tm_session ) from datameta.models.meta import Base @@ -22,6 +23,8 @@ from .utils import get_auth_header +from datameta.settings import set_setting + class BaseIntegrationTest(unittest.TestCase): """Base TestCase to inherit from""" @@ -70,6 +73,11 @@ def tearDown(self): del self.testapp self.storage_path_obj.cleanup() + def set_application_setting(self, name, value): + with transaction.manager: + db = get_tm_session(self.session_factory, transaction.manager) + set_setting(db, name, value) + def apikey_auth(self, user: holders.UserFixture) -> dict: apikey = self.fixture_manager.get_fixture('apikeys', user.site_id) return get_auth_header(apikey.value_plain) diff --git a/tests/integration/test_login_tracking.py b/tests/integration/test_login_tracking.py new file mode 100644 index 00000000..42bbdac3 --- /dev/null +++ b/tests/integration/test_login_tracking.py @@ -0,0 +1,87 @@ +# Copyright 2021 Universität Tübingen, DKFZ and EMBL for the German Human Genome-Phenome Archive (GHGA) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Testing tracking of failed login attempts +""" +from parameterized import parameterized + +from . import BaseIntegrationTest +from datameta.api import base_url +from .utils import get_auth_header + + +class TestLoginTracking(BaseIntegrationTest): + + def setUp(self): + super().setUp() + self.fixture_manager.load_fixtureset('groups') + self.fixture_manager.load_fixtureset('users') + self.fixture_manager.load_fixtureset('apikeys') + + @parameterized.expand([ + # TEST_NAME EXEC_USER SUCCESS? EXPECTED_OUTCOME + ("login_failure_with_block" , "user_a" , False , (302, "login", False)), + ("login_success_after_n-1" , "user_a" , True , (302, "home", True)), + ]) + def test_login_tracking_via_login(self, _, executing_user: str, success: bool, expected_outcome): + + user = self.fixture_manager.get_fixture('users', executing_user) + n_attempts = 5 + self.set_application_setting("security_max_failed_login_attempts", n_attempts) + + for attempt in range(1, n_attempts + 1): + form = self.testapp.get("/login").forms[0] + form["input_email"] = user.email + form["input_password"] = user.password + ("_somenonsense" if attempt < n_attempts or not success else "") + response = form.submit("form.submitted") + + form = self.testapp.get("/login").forms[0] + form["input_email"] = user.email + form["input_password"] = user.password + response = form.submit("form.submitted") + + db_user = self.fixture_manager.get_fixture_db("users", executing_user) + + assert (response.status_int, response.location.split("/")[-1], db_user.enabled) == expected_outcome + + @parameterized.expand([ + # TEST_NAME EXEC_USER SUCCESS? EXPECTED_OUTCOME + ("login_failure_with_block" , "user_a" , False , (401, False)), + ("login_success_after_n-1" , "user_a" , True , (200, True)), + ]) + def test_login_tracking_via_apikey(self, _, executing_user: str, success: bool, expected_outcome): + + n_attempts = 5 + self.set_application_setting("security_max_failed_login_attempts", n_attempts) + + for attempt in range(1, n_attempts + 1): + failed_attempt = attempt < n_attempts or not success + apikey = self.fixture_manager.get_fixture('apikeys', executing_user + ("_expired" if failed_attempt else "")) + + self.testapp.get( + f"{base_url}/rpc/whoami", + headers=get_auth_header(apikey.value_plain), + status=401 if failed_attempt else 200 + ) + + apikey = self.fixture_manager.get_fixture('apikeys', executing_user) + response = self.testapp.get( + f"{base_url}/rpc/whoami", + status=expected_outcome[0], + headers=get_auth_header(apikey.value_plain) + ) + + db_user = self.fixture_manager.get_fixture_db("users", executing_user) + + assert (response.status_int, db_user.enabled) == expected_outcome