diff --git a/README.md b/README.md
index d621ee93..8d357d64 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,7 @@ by [rdiff-backup](https://rdiff-backup.net/). The purpose of this
application is to ease the management of backups and quickly restore your data
with a rich and powerful web interface.
-Rdiffweb is written in Python and is released as open source project under the
+Rdiffweb is written in Python and is released as open source project under the
GNU GENERAL PUBLIC LICENSE (GPL). All source code and documentation are
Copyright Rdiffweb contributors.
@@ -36,7 +36,7 @@ since November 2014.
The Rdiffweb source code is hosted on [Gitlab](https://gitlab.com/ikus-soft/rdiffweb)
and mirrored to [Github](https://github.com/ikus060/rdiffweb).
-The Rdiffweb website is https://rdiffweb.org/.
+The Rdiffweb website is .
## Features
@@ -100,7 +100,7 @@ Rdiffweb users should use the [Rdiffweb mailing list](https://groups.google.com/
### Bug Reports
-Bug reports should be reported on the Rdiffweb Gitlab at https://gitlab.com/ikus-soft/rdiffweb/-/issues
+Bug reports should be reported on the Rdiffweb Gitlab at
### Professional support
@@ -114,6 +114,11 @@ Professional support for Rdiffweb is available by contacting [IKUS Soft](https:/
* Ensure Gmail and other mail client doesn't create hyperlink automatically for any nodification sent by Rdiffweb to avoid phishing - credit to [Nehal Pillai](https://www.linkedin.com/in/nehal-pillai-02a854172)
* Sent email notification to user when a new SSH Key get added - credit to [Nehal Pillai](https://www.linkedin.com/in/nehal-pillai-02a854172)
* Ratelimit "Resend code to my email" in Two-Factor Authentication view - credit to [Nehal Pillai](https://www.linkedin.com/in/nehal-pillai-02a854172)
+* Username are not case-insensitive - credits to [raiders0786](https://www.linkedin.com/in/chirag-agrawal-770488144/)
+
+Breaking changes:
+
+* Username with different cases (e.g.: admin vs Ammin) are not supported. If your database contains such username make sure to remove them before upgrading otherwise Rdiffweb will not start.
## 2.5.4 (2022-12-19)
@@ -378,7 +383,7 @@ Maintenance release to fix minor issues
## 2.1.0 (2021-01-15)
-* Debian package: Remove dh-systemd from Debian build dependencies (https://bugs.debian.org/871312we)
+* Debian package: Remove dh-systemd from Debian build dependencies ()
* Improve Quota management:
* `QuotaSetCmd`, `QuotaGetCmd` and `QuotaUsedCmd` options could be used to customize how to set the quota for your environment.
* Display user's quota in User View
diff --git a/rdiffweb/controller/tests/test_page_login.py b/rdiffweb/controller/tests/test_page_login.py
index 1b3d0024..99780452 100644
--- a/rdiffweb/controller/tests/test_page_login.py
+++ b/rdiffweb/controller/tests/test_page_login.py
@@ -64,6 +64,15 @@ def test_login_success(self):
self.assertEqual('admin', session.get(SESSION_KEY))
self.assertIsNotNone(session.get(LOGIN_TIME))
+ def test_login_case_insensitive(self):
+ # When authenticating with valid credentials with all uppercase username
+ self.getPage('/login/', method='POST', body={'login': self.USERNAME.upper(), 'password': self.PASSWORD})
+ # Then a new session_id is generated
+ self.assertStatus('303 See Other')
+ self.assertHeaderItemValue('Location', self.baseurl + '/')
+ self.getPage('/')
+ self.assertStatus(200)
+
def test_cookie_http_only(self):
# Given a request made to rdiffweb
# When receiving the response
diff --git a/rdiffweb/controller/tests/test_page_prefs_general.py b/rdiffweb/controller/tests/test_page_prefs_general.py
index e7bba666..029c168b 100644
--- a/rdiffweb/controller/tests/test_page_prefs_general.py
+++ b/rdiffweb/controller/tests/test_page_prefs_general.py
@@ -85,8 +85,8 @@ def test_change_username_noop(self):
self.assertStatus(303)
self.getPage(self.PREFS)
self.assertInBody("Profile updated successfully.")
- # Then database is updated with fullname
- user = UserObject.query.filter(UserObject.username == self.USERNAME).first()
+ # Then database is not updated with new username.
+ user = UserObject.get_user(self.USERNAME)
self.assertIsNotNone(user)
self.assertEqual("test@test.com", user.email)
@@ -112,7 +112,7 @@ def test_change_fullname(self, new_fullname, expected_valid):
self.assertInBody("Profile updated successfully.")
# Then database is updated with fullname
self.assertInBody(new_fullname)
- user = UserObject.query.filter(UserObject.username == self.USERNAME).first()
+ user = UserObject.get_user(self.USERNAME)
self.assertEqual(new_fullname, user.fullname)
else:
self.assertStatus(200)
@@ -125,7 +125,7 @@ def test_change_fullname_method_get(self):
# Then nothing happen
self.assertStatus(200)
self.assertNotInBody("Profile updated successfully.")
- user = UserObject.query.filter(UserObject.username == self.USERNAME).first()
+ user = UserObject.get_user(self.USERNAME)
self.assertEqual("", user.fullname)
def test_change_fullname_too_long(self):
@@ -137,7 +137,7 @@ def test_change_fullname_too_long(self):
self.assertNotInBody("Profile updated successfully.")
self.assertInBody("Fullname too long.")
# Then database is not updated
- user = UserObject.query.filter(UserObject.username == self.USERNAME).first()
+ user = UserObject.get_user(self.USERNAME)
self.assertEqual("", user.fullname)
def test_change_email(self):
diff --git a/rdiffweb/core/login.py b/rdiffweb/core/login.py
index 607c8e68..2fa9fea5 100644
--- a/rdiffweb/core/login.py
+++ b/rdiffweb/core/login.py
@@ -50,7 +50,7 @@ def authenticate(self, username, password):
"""
Only verify the user's credentials using the database store.
"""
- user = UserObject.query.filter_by(username=username).first()
+ user = UserObject.get_user(username)
if user and user.validate_password(password):
return username, {}
return False
@@ -69,7 +69,7 @@ def login(self, username, password):
fullname = extra_attrs.get('_fullname', None)
email = extra_attrs.get('_email', None)
# When enabled, create missing userobj in database.
- userobj = UserObject.query.filter_by(username=username).first()
+ userobj = UserObject.get_user(username)
if userobj is None and self.add_missing_user:
try:
# At this point, we need to create a new user in database.
diff --git a/rdiffweb/core/model/__init__.py b/rdiffweb/core/model/__init__.py
index ea057b06..df13f84c 100644
--- a/rdiffweb/core/model/__init__.py
+++ b/rdiffweb/core/model/__init__.py
@@ -15,17 +15,57 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
+import logging
+import sys
+
import cherrypy
from sqlalchemy import event
+from sqlalchemy.exc import IntegrityError
from ._repo import RepoObject # noqa
from ._session import DbSession, SessionObject # noqa
from ._sshkey import SshKey # noqa
from ._token import Token # noqa
-from ._user import DuplicateSSHKeyError, UserObject # noqa
+from ._user import DuplicateSSHKeyError, UserObject, user_username_index # noqa
Base = cherrypy.tools.db.get_base()
+logger = logging.getLogger(__name__)
+
+
+def _column_add(connection, column):
+ if _column_exists(connection, column):
+ return
+ table_name = column.table.fullname
+ column_name = column.name
+ column_type = column.type.compile(connection.engine.dialect)
+ connection.engine.execute('ALTER TABLE %s ADD COLUMN %s %s' % (table_name, column_name, column_type))
+
+
+def _column_exists(connection, column):
+ table_name = column.table.fullname
+ column_name = column.name
+ if 'SQLite' in connection.engine.dialect.__class__.__name__:
+ sql = "SELECT COUNT(*) FROM pragma_table_info('%s') WHERE LOWER(name)=LOWER('%s')" % (
+ table_name,
+ column_name,
+ )
+ else:
+ sql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name='%s' and column_name='%s'" % (
+ table_name,
+ column_name,
+ )
+ data = connection.engine.execute(sql).first()
+ return data[0] >= 1
+
+
+def _index_exists(connection, index_name):
+ if 'SQLite' in connection.engine.dialect.__class__.__name__:
+ sql = "SELECT name FROM sqlite_master WHERE type = 'index' AND name = '%s';" % (index_name)
+ else:
+ sql = "SELECT * FROM pg_indexes WHERE indexname = '%s'" % (index_name)
+ return connection.engine.execute(sql).first() is not None
+
@event.listens_for(Base.metadata, 'after_create')
def db_after_create(target, connection, **kw):
@@ -33,56 +73,33 @@ def db_after_create(target, connection, **kw):
Called on database creation to update database schema.
"""
- def exists(column):
- table_name = column.table.fullname
- column_name = column.name
- if 'SQLite' in connection.engine.dialect.__class__.__name__:
- sql = "SELECT COUNT(*) FROM pragma_table_info('%s') WHERE LOWER(name)=LOWER('%s')" % (
- table_name,
- column_name,
- )
- else:
- sql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name='%s' and column_name='%s'" % (
- table_name,
- column_name,
- )
- data = connection.engine.execute(sql).first()
- return data[0] >= 1
-
- def add_column(column):
- if exists(column):
- return
- table_name = column.table.fullname
- column_name = column.name
- column_type = column.type.compile(connection.engine.dialect)
- connection.engine.execute('ALTER TABLE %s ADD COLUMN %s %s' % (table_name, column_name, column_type))
-
if getattr(connection, '_transaction', None):
connection._transaction.commit()
# Add repo's Encoding
- add_column(RepoObject.__table__.c.Encoding)
- add_column(RepoObject.__table__.c.keepdays)
+ _column_add(connection, RepoObject.__table__.c.Encoding)
+ _column_add(connection, RepoObject.__table__.c.keepdays)
# Create column for roles using "isadmin" column. Keep the
# original column in case we need to revert to previous version.
- if not exists(UserObject.__table__.c.role):
- add_column(UserObject.__table__.c.role)
+ if not _column_exists(connection, UserObject.__table__.c.role):
+ _column_add(connection, UserObject.__table__.c.role)
UserObject.query.filter(UserObject._is_admin == 1).update({UserObject.role: UserObject.ADMIN_ROLE})
# Add user's fullname column
- add_column(UserObject.__table__.c.fullname)
+ _column_add(connection, UserObject.__table__.c.fullname)
# Add user's mfa column
- add_column(UserObject.__table__.c.mfa)
+ _column_add(connection, UserObject.__table__.c.mfa)
# Re-create session table if Number column is missing
- if not exists(SessionObject.__table__.c.Number):
+ if not _column_exists(connection, SessionObject.__table__.c.Number):
SessionObject.__table__.drop()
SessionObject.__table__.create()
if getattr(connection, '_transaction', None):
connection._transaction.commit()
+
# Remove preceding and leading slash (/) generated by previous
# versions. Also rename '.' to ''
result = RepoObject.query.all()
@@ -101,3 +118,22 @@ def add_column(column):
row.delete()
else:
prev_repo = (row.userid, row.repopath)
+
+ # Fix username case insensitive unique
+ if not _index_exists(connection, 'user_username_index'):
+ duplicate_users = (
+ UserObject.query.with_entities(func.lower(UserObject.username))
+ .group_by(func.lower(UserObject.username))
+ .having(func.count(UserObject.username) > 1)
+ ).all()
+ try:
+ user_username_index.create()
+ except IntegrityError:
+ msg = (
+ 'Failure to upgrade your database to make Username case insensitive. '
+ 'You must downgrade and deleted duplicate Username. '
+ '%s' % '\n'.join([str(k) for k in duplicate_users]),
+ )
+ logger.error(msg)
+ print(msg, file=sys.stderr)
+ raise SystemExit(12)
diff --git a/rdiffweb/core/model/_user.py b/rdiffweb/core/model/_user.py
index 49cf0893..9b06363c 100644
--- a/rdiffweb/core/model/_user.py
+++ b/rdiffweb/core/model/_user.py
@@ -20,7 +20,7 @@
import string
import cherrypy
-from sqlalchemy import Column, Integer, SmallInteger, String, and_, event, inspect, or_
+from sqlalchemy import Column, Index, Integer, SmallInteger, String, and_, event, func, inspect, or_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import deferred, relationship, validates
@@ -74,7 +74,7 @@ class UserObject(Base):
PATTERN_USERNAME = r"[a-zA-Z0-9_.\-]+$"
userid = Column('UserID', Integer, primary_key=True)
- username = Column('Username', String, nullable=False, unique=True)
+ username = Column('Username', String, nullable=False)
hash_password = Column('Password', String, nullable=False, default="")
user_root = Column('UserRoot', String, nullable=False, default="")
_is_admin = deferred(
@@ -110,8 +110,8 @@ class UserObject(Base):
@classmethod
def get_user(cls, user):
- """Return a user object."""
- return UserObject.query.filter(UserObject.username == user).first()
+ """Return a user object with username case-insensitive"""
+ return UserObject.query.filter(func.lower(UserObject.username) == user.lower()).first()
@classmethod
def create_admin_user(cls, default_username, default_password):
@@ -413,6 +413,10 @@ def validate_password(self, password):
return check_password(password, self.hash_password)
+# Username should be case insensitive
+user_username_index = Index('user_username_index', func.lower(UserObject.username), unique=True)
+
+
@event.listens_for(UserObject.hash_password, "set")
def hash_password_set(target, value, oldvalue, initiator):
if value and value != oldvalue:
diff --git a/rdiffweb/core/model/tests/test_user.py b/rdiffweb/core/model/tests/test_user.py
index 9044e11f..569ac52a 100644
--- a/rdiffweb/core/model/tests/test_user.py
+++ b/rdiffweb/core/model/tests/test_user.py
@@ -103,6 +103,16 @@ def test_add_user_with_duplicate(self):
# Check if listener called
self.listener.user_added.assert_not_called()
+ def test_add_user_with_duplicate_caseinsensitive(self):
+ """Add user to database."""
+ user = UserObject.add_user('denise')
+ user.commit()
+ self.listener.user_added.reset_mock()
+ with self.assertRaises(ValueError):
+ UserObject.add_user('dEnIse')
+ # Check if listener called
+ self.listener.user_added.assert_not_called()
+
def test_add_user_with_password(self):
"""Add user to database with password."""
userobj = UserObject.add_user('jo', 'password')
@@ -158,6 +168,13 @@ def test_get_user(self):
self.assertEqual('testcases', obj.repo_objs[1].name)
self.assertEqual(3, obj.repo_objs[1].maxage)
+ def test_get_user_case_insensitive(self):
+ userobj1 = UserObject.get_user(self.USERNAME)
+ userobj2 = UserObject.get_user(self.USERNAME.lower())
+ userobj3 = UserObject.get_user(self.USERNAME.upper())
+ self.assertEqual(userobj1, userobj2)
+ self.assertEqual(userobj2, userobj3)
+
def test_get_user_with_invalid_user(self):
self.assertIsNone(UserObject.get_user('invalid'))
diff --git a/rdiffweb/core/tests/test_rdw_templating.py b/rdiffweb/core/tests/test_rdw_templating.py
index c8d8bab3..4dd08629 100644
--- a/rdiffweb/core/tests/test_rdw_templating.py
+++ b/rdiffweb/core/tests/test_rdw_templating.py
@@ -110,7 +110,7 @@ def test_list_parents_with_root_subdir(self):
class UrlForTest(WebCase):
@property
def repo_obj(self):
- user = UserObject.query.filter(UserObject.username == 'admin').first()
+ user = UserObject.get_user('admin')
return RepoObject.query.filter(RepoObject.user == user, RepoObject.repopath == self.REPO).first()
def test_url_for_absolute_path(self):