ソースを参照

Correction of windows' digest authentication, new database migration to fit the needs and adjusting tracim's login/change password/user creation to calculate and insert the new hash

Nonolost 7 年 前
コミット
297c5b3ecd

+ 22 - 0
tracim/migration/versions/b4b8d57b54e5_add_hash_column_for_digest_.py ファイルの表示

@@ -0,0 +1,22 @@
1
+"""add hash column for digest authentication
2
+
3
+Revision ID: b4b8d57b54e5
4
+Revises: 534c4594ed29
5
+Create Date: 2016-08-11 10:27:28.951506
6
+
7
+"""
8
+
9
+# revision identifiers, used by Alembic.
10
+revision = 'b4b8d57b54e5'
11
+down_revision = '534c4594ed29'
12
+
13
+from alembic import op
14
+from sqlalchemy import Column, Unicode
15
+
16
+
17
+def upgrade():
18
+    op.add_column('users', Column('webdav_left_digest_response_hash', Unicode(128)))
19
+
20
+
21
+def downgrade():
22
+    op.drop_column('users', 'webdav_left_digest_response_hash')

+ 3 - 0
tracim/tracim/controllers/admin/user.py ファイルの表示

@@ -328,6 +328,9 @@ class UserRestController(TIMRestController):
328 328
             # Setup a random password to send email at user
329 329
             password = str(uuid.uuid4())
330 330
             user.password = password
331
+
332
+        user.webdav_left_digest_response_hash = '%s:/:%s' % (email, password)
333
+
331 334
         api.save(user)
332 335
 
333 336
         # Now add the user to related groups

+ 1 - 0
tracim/tracim/controllers/user.py ファイルの表示

@@ -123,6 +123,7 @@ class UserPasswordRestController(TIMRestController):
123 123
             tg.redirect(redirect_url)
124 124
 
125 125
         current_user.password = new_password1
126
+        current_user.webdav_left_digest_response_hash = '%s:/:%s' % (current_user.email, new_password1)
126 127
         pm.DBSession.flush()
127 128
 
128 129
         tg.flash(_('Your password has been changed'))

+ 7 - 0
tracim/tracim/lib/auth/internal.py ファイルの表示

@@ -5,6 +5,8 @@ from tg.configuration.auth import TGAuthMetadata
5 5
 from tracim.lib.auth.base import Auth
6 6
 from tracim.model import DBSession, User
7 7
 
8
+# TODO : temporary fix to update DB, to remove
9
+import transaction
8 10
 
9 11
 class InternalAuth(Auth):
10 12
 
@@ -34,6 +36,11 @@ class InternalApplicationAuthMetadata(TGAuthMetadata):
34 36
         )).first()
35 37
 
36 38
         if user and user.validate_password(identity['password']):
39
+            if user.webdav_left_digest_response_hash == '':
40
+                user.webdav_left_digest_response_hash = '%s:/:%s' % (identity['login'], identity['password'])
41
+                DBSession.flush()
42
+                # TODO : temporary fix to update DB, to remove
43
+                transaction.commit()
37 44
             return identity['login']
38 45
 
39 46
     def get_user(self, identity, userid):

+ 22 - 1
tracim/tracim/lib/daemons.py ファイルの表示

@@ -239,6 +239,18 @@ import traceback
239 239
 DEFAULT_CONFIG_FILE = "wsgidav.conf"
240 240
 PYTHON_VERSION = "%s.%s.%s" % (sys.version_info[0], sys.version_info[1], sys.version_info[2])
241 241
 
242
+def _get_checked_path(path, mustExist=True, allowNone=True):
243
+    """Convert path to absolute if not None."""
244
+    if path in (None, ""):
245
+        if allowNone:
246
+            return None
247
+        else:
248
+            raise ValueError("Invalid path %r" % path)
249
+    path = os.path.abspath(path)
250
+    if mustExist and not os.path.exists(path):
251
+        raise ValueError("Invalid path %r" % path)
252
+    return path
253
+
242 254
 class WsgiDavDaemon(Daemon):
243 255
 
244 256
     def __init__(self, *args, **kwargs):
@@ -261,6 +273,12 @@ class WsgiDavDaemon(Daemon):
261 273
         if not useLxml and config["verbose"] >= 1:
262 274
             print(
263 275
                 "WARNING: Could not import lxml: using xml instead (slower). Consider installing lxml from http://codespeak.net/lxml/.")
276
+        from wsgidav.dir_browser import WsgiDavDirBrowser
277
+        from wsgidav.debug_filter import WsgiDavDebugFilter
278
+        from tracim.lib.webdav.tracim_http_authenticator import TracimHTTPAuthenticator
279
+        from wsgidav.error_printer import ErrorPrinter
280
+
281
+        config['middleware_stack'] = [ WsgiDavDirBrowser, TracimHTTPAuthenticator, ErrorPrinter, WsgiDavDebugFilter ]
264 282
 
265 283
         config['provider_mapping'] = \
266 284
             {
@@ -273,7 +291,9 @@ class WsgiDavDaemon(Daemon):
273 291
             }
274 292
 
275 293
         config['domaincontroller'] = SQLDomainController(presetdomain=None, presetserver=None)
276
-        config['defaultdigest'] = False
294
+        config['defaultdigest'] = True
295
+        config['acceptdigest'] = True
296
+
277 297
         return config
278 298
 
279 299
     def _readConfigFile(self, config_file, verbose):
@@ -323,6 +343,7 @@ class WsgiDavDaemon(Daemon):
323 343
                 PYTHON_VERSION)
324 344
 
325 345
             wsgiserver.CherryPyWSGIServer.version = version
346
+
326 347
             protocol = "http"
327 348
 
328 349
             if config["verbose"] >= 1:

+ 10 - 10
tracim/tracim/lib/webdav/__init__.py ファイルの表示

@@ -6,6 +6,7 @@ from tracim.model.data import ActionDescription, ContentType, Content, Workspace
6 6
 from wsgidav import util
7 7
 
8 8
 import transaction
9
+from wsgidav import compat
9 10
 
10 11
 class HistoryType(object):
11 12
     Deleted = 'deleted'
@@ -27,7 +28,8 @@ class FakeFileStream(object):
27 28
         :param workspace: content's workspace, necessary if the file is new as we've got no other way to get it
28 29
         :param content: either the content to be updated or None if it's a new file
29 30
         """
30
-        self._buffer = []
31
+        self._buff = compat.BytesIO()
32
+
31 33
         self._file_name = file_name if file_name != '' else self._content.file_name
32 34
         self._content = content
33 35
         self._api = content_api
@@ -44,20 +46,18 @@ class FakeFileStream(object):
44 46
 
45 47
     def write(self, s: str):
46 48
         """Called by request_server when writing content to files, we stock it in our file"""
47
-        self._buffer.append(s)
49
+        self._buff.write(s)
48 50
 
49 51
     def close(self):
50 52
         """Called by request_server when everything has been written and we either update the file or
51 53
         create a new file"""
52
-        item_content = b''
53 54
 
54
-        for part in self._buffer:
55
-            item_content += part
55
+        self._buff.seek(0)
56 56
 
57 57
         if self._content is None:
58
-            self.create_file(item_content)
58
+            self.create_file(self._buff)
59 59
         else:
60
-            self.update_file(item_content)
60
+            self.update_file(self._buff)
61 61
 
62 62
     def create_file(self, item_content):
63 63
         file = self._api.create(
@@ -70,20 +70,20 @@ class FakeFileStream(object):
70 70
             file,
71 71
             self._file_name,
72 72
             util.guessMimeType(self._file_name),
73
-            item_content
73
+            item_content.read()
74 74
         )
75 75
 
76 76
         self._api.save(file, ActionDescription.CREATION)
77 77
 
78 78
         transaction.commit()
79 79
 
80
-    def update_file(self, item_content: bytes):
80
+    def update_file(self, item_content):
81 81
         with new_revision(self._content):
82 82
             self._api.update_file_data(
83 83
                 self._content,
84 84
                 self._file_name,
85 85
                 util.guessMimeType(self._content.file_name),
86
-                item_content
86
+                item_content.read()
87 87
             )
88 88
             self._api.save(self._content, ActionDescription.EDITION)
89 89
 

+ 3 - 0
tracim/tracim/lib/webdav/sql_dav_provider.py ファイルの表示

@@ -61,6 +61,7 @@ class Provider(DAVProvider):
61 61
     #########################################################
62 62
     # Everything override from DAVProvider
63 63
     def getResourceInst(self, path, environ):
64
+        print("ok : ", path)
64 65
         #if not self.exists(path, environ):
65 66
         #    return None
66 67
         if not self.exists(path, environ):
@@ -178,6 +179,7 @@ class Provider(DAVProvider):
178 179
             return None
179 180
 
180 181
     def exists(self, path, environ):
182
+        print("ok (exist) : ", path)
181 183
         uapi = UserApi(None)
182 184
         environ['user'] = uapi.get_one_by_email(environ['http_authenticator.username'])
183 185
 
@@ -193,6 +195,7 @@ class Provider(DAVProvider):
193 195
         )
194 196
 
195 197
         if path == root_path:
198
+            print("ok (pass ici) : ", path)
196 199
             return True
197 200
         elif parent_path == root_path:
198 201
             return self.get_workspace_from_path(

+ 16 - 9
tracim/tracim/lib/webdav/sql_domain_controller.py ファイルの表示

@@ -8,18 +8,18 @@ class SQLDomainController(object):
8 8
         self._api = UserApi(None)
9 9
 
10 10
     def getDomainRealm(self, inputURL, environ):
11
-        """
12
-        On va récupérer le workspace de travail pour travailler sur les droits
13
-        """
11
+
12
+        '''On va récupérer le workspace de travail pour travailler sur les droits'''
13
+
14 14
         return '/'
15 15
 
16 16
     def requireAuthentication(self, realmname, environ):
17 17
         return True
18 18
 
19 19
     def isRealmUser(self, realmname, username, environ):
20
-        """
21
-        travailler dans la bdd pour vérifier si utilisateur existe
22
-        """
20
+
21
+        '''travailler dans la bdd pour vérifier si utilisateur existe'''
22
+
23 23
         try:
24 24
             self._api.get_one_by_email(username)
25 25
             return True
@@ -27,14 +27,21 @@ class SQLDomainController(object):
27 27
             return False
28 28
 
29 29
     def getRealmUserPassword(self, realmname, username, environ):
30
-        """Retourne le mdp pour l'utilisateur pour ce real"""
30
+        '''Retourne le mdp pour l'utilisateur pour ce real'''
31 31
         try:
32 32
             user = self._api.get_one_by_email(username)
33 33
             return user.password
34 34
         except:
35 35
             return None
36 36
 
37
+    def get_left_digest_response_hash(self, realmname, username, environ):
38
+        try:
39
+            user = self._api.get_one_by_email(username)
40
+            return user.webdav_left_digest_response_hash
41
+        except:
42
+            return None
43
+
37 44
     def authDomainUser(self, realmname, username, password, environ):
38
-        """Vérifier que l'utilisateur est valide pour ce domaine"""
45
+        '''Vérifier que l'utilisateur est valide pour ce domaine'''
39 46
         return self.isRealmUser(realmname, username, environ) and \
40
-            self._api.get_one_by_email(username).validate_password(password)
47
+            self._api.get_one_by_email(username).validate_password(password)

+ 15 - 9
tracim/tracim/lib/webdav/sql_resources.py ファイルの表示

@@ -379,6 +379,11 @@ class Folder(Workspace):
379 379
             self.content_api,
380 380
             WorkspaceApi(self.environ['user']))
381 381
 
382
+        workspace = self.provider.get_workspace_from_path(
383
+            normpath(destpath),
384
+            WorkspaceApi(self.environ['user'])
385
+        )
386
+
382 387
         with new_revision(self.content):
383 388
             if basename(destpath) != self.content.label:
384 389
                 self.content_api.update_content(self.content, basename(destpath), self.content.description)
@@ -397,14 +402,7 @@ class Folder(Workspace):
397 402
                 try:
398 403
                     self.content_api.move_recursively(self.content, parent, parent.workspace)
399 404
                 except AttributeError:
400
-                    self.content_api.move_recursively(
401
-                        self.content,
402
-                        parent,
403
-                        self.provider.get_workspace_from_path(
404
-                            destpath,
405
-                            WorkspaceApi(self.environ['user'])
406
-                        )
407
-                    )
405
+                    self.content_api.move_recursively(self.content, parent, workspace)
408 406
 
409 407
         transaction.commit()
410 408
 
@@ -818,6 +816,11 @@ class File(DAVNonCollection):
818 816
             WorkspaceApi(self.environ['user'])
819 817
         )
820 818
 
819
+        workspace = self.provider.get_workspace_from_path(
820
+            normpath(destpath),
821
+            WorkspaceApi(self.environ['user'])
822
+        )
823
+
821 824
         with new_revision(self.content):
822 825
             if basename(destpath) != self.content.label:
823 826
                 self.content_api.update_content(self.content, basename(destpath), self.content.description)
@@ -827,7 +830,7 @@ class File(DAVNonCollection):
827 830
                 item=self.content,
828 831
                 new_parent=parent,
829 832
                 must_stay_in_same_workspace=False,
830
-                new_workspace=parent.workspace
833
+                new_workspace=workspace
831 834
             )
832 835
 
833 836
         transaction.commit()
@@ -893,6 +896,9 @@ class OtherFile(File):
893 896
     def getDisplayName(self) -> str:
894 897
         return self.content.get_label()
895 898
 
899
+    def getPreferredPath(self):
900
+        return self.path + '.html'
901
+
896 902
     def __repr__(self) -> str:
897 903
         return "<DAVNonCollection: OtherFile (%s)" % self.content.file_name
898 904
 

+ 144 - 0
tracim/tracim/lib/webdav/tracim_http_authenticator.py ファイルの表示

@@ -0,0 +1,144 @@
1
+from wsgidav.http_authenticator import HTTPAuthenticator
2
+from wsgidav import util
3
+import re
4
+
5
+_logger = util.getModuleLogger(__name__, True)
6
+HOTFIX_WINXP_AcceptRootShareLogin = True
7
+
8
+class TracimHTTPAuthenticator(HTTPAuthenticator):
9
+    def __init__(self, application, config):
10
+        super(TracimHTTPAuthenticator, self).__init__(application, config)
11
+        self._headerfixparser = re.compile(r'([\w]+)=("[^"]*,[^"]*"),')
12
+
13
+    def authDigestAuthRequest(self, environ, start_response):
14
+        realmname = self._domaincontroller.getDomainRealm(environ["PATH_INFO"], environ)
15
+
16
+        isinvalidreq = False
17
+
18
+        authheaderdict = dict([])
19
+        authheaders = environ["HTTP_AUTHORIZATION"] + ","
20
+        if not authheaders.lower().strip().startswith("digest"):
21
+            isinvalidreq = True
22
+            # Hotfix for Windows file manager and OSX Finder:
23
+        # Some clients don't urlencode paths in auth header, so uri value may
24
+        # contain commas, which break the usual regex headerparser. Example:
25
+        # Digest username="user",realm="/",uri="a,b.txt",nc=00000001, ...
26
+        # -> [..., ('uri', '"a'), ('nc', '00000001'), ...]
27
+        # Override any such values with carefully extracted ones.
28
+        authheaderlist = self._headerparser.findall(authheaders)
29
+        authheaderfixlist = self._headerfixparser.findall(authheaders)
30
+        if authheaderfixlist:
31
+            _logger.info("Fixing authheader comma-parsing: extend %s with %s" \
32
+                         % (authheaderlist, authheaderfixlist))
33
+            authheaderlist += authheaderfixlist
34
+        for authheader in authheaderlist:
35
+            authheaderkey = authheader[0]
36
+            authheadervalue = authheader[1].strip().strip("\"")
37
+            authheaderdict[authheaderkey] = authheadervalue
38
+
39
+        _logger.debug("authDigestAuthRequest: %s" % environ["HTTP_AUTHORIZATION"])
40
+        _logger.debug("  -> %s" % authheaderdict)
41
+
42
+        if "username" in authheaderdict:
43
+            req_username = authheaderdict["username"]
44
+            req_username_org = req_username
45
+            # Hotfix for Windows XP:
46
+            #   net use W: http://127.0.0.1/dav /USER:DOMAIN\tester tester
47
+            # will send the name with double backslashes ('DOMAIN\\tester')
48
+            # but send the digest for the simple name ('DOMAIN\tester').
49
+            if r"\\" in req_username:
50
+                req_username = req_username.replace("\\\\", "\\")
51
+                _logger.info("Fixing Windows name with double backslash: '%s' --> '%s'" % (req_username_org, req_username))
52
+
53
+            if not self._domaincontroller.isRealmUser(realmname, req_username, environ):
54
+                isinvalidreq = True
55
+        else:
56
+            isinvalidreq = True
57
+
58
+            # TODO: Chun added this comments, but code was commented out
59
+            # Do not do realm checking - a hotfix for WinXP using some other realm's
60
+            # auth details for this realm - if user/password match
61
+        print(authheaderdict.get("realm"), realmname)
62
+        if 'realm' in authheaderdict:
63
+            if authheaderdict["realm"].upper() != realmname.upper():
64
+                if HOTFIX_WINXP_AcceptRootShareLogin:
65
+                    # Hotfix: also accept '/'
66
+                    if authheaderdict["realm"].upper() != "/":
67
+                        isinvalidreq = True
68
+                else:
69
+                    isinvalidreq = True
70
+
71
+        if "algorithm" in authheaderdict:
72
+            if authheaderdict["algorithm"].upper() != "MD5":
73
+                isinvalidreq = True  # only MD5 supported
74
+
75
+        if "uri" in authheaderdict:
76
+            req_uri = authheaderdict["uri"]
77
+
78
+        if "nonce" in authheaderdict:
79
+            req_nonce = authheaderdict["nonce"]
80
+        else:
81
+            isinvalidreq = True
82
+
83
+        req_hasqop = False
84
+        if "qop" in authheaderdict:
85
+            req_hasqop = True
86
+            req_qop = authheaderdict["qop"]
87
+            if req_qop.lower() != "auth":
88
+                isinvalidreq = True  # only auth supported, auth-int not supported
89
+        else:
90
+            req_qop = None
91
+
92
+        if "cnonce" in authheaderdict:
93
+            req_cnonce = authheaderdict["cnonce"]
94
+        else:
95
+            req_cnonce = None
96
+            if req_hasqop:
97
+                isinvalidreq = True
98
+
99
+        if "nc" in authheaderdict:  # is read but nonce-count checking not implemented
100
+            req_nc = authheaderdict["nc"]
101
+        else:
102
+            req_nc = None
103
+            if req_hasqop:
104
+                isinvalidreq = True
105
+
106
+        if "response" in authheaderdict:
107
+            req_response = authheaderdict["response"]
108
+        else:
109
+            isinvalidreq = True
110
+
111
+        if not isinvalidreq:
112
+            left_digest_response_hash = self._domaincontroller.get_left_digest_response_hash(realmname, req_username, environ)
113
+
114
+            req_method = environ["REQUEST_METHOD"]
115
+
116
+            required_digest = self.tracim_compute_digest_response(left_digest_response_hash, req_method, req_uri, req_nonce,
117
+                                                         req_cnonce, req_qop, req_nc)
118
+
119
+            if required_digest != req_response:
120
+                _logger.warning("computeDigestResponse('%s', '%s', ...): %s != %s" % (
121
+                realmname, req_username, required_digest, req_response))
122
+                isinvalidreq = True
123
+            else:
124
+                _logger.debug("digest succeeded for realm '%s', user '%s'" % (realmname, req_username))
125
+            pass
126
+
127
+        if isinvalidreq:
128
+            _logger.warning("Authentication failed for user '%s', realm '%s'" % (req_username, realmname))
129
+            return self.sendDigestAuthResponse(environ, start_response)
130
+
131
+        environ["http_authenticator.realm"] = realmname
132
+        environ["http_authenticator.username"] = req_username
133
+        return self._application(environ, start_response)
134
+
135
+    def tracim_compute_digest_response(self, left_digest_response_hash, method, uri, nonce, cnonce, qop, nc):
136
+        # A1 : username:realm:password
137
+        A2 = method + ":" + uri
138
+        if qop:
139
+            digestresp = self.md5kd(left_digest_response_hash, nonce + ":" + nc + ":" + cnonce + ":" + qop + ":" + self.md5h(A2))
140
+        else:
141
+            digestresp = self.md5kd(left_digest_response_hash, nonce + ":" + self.md5h(A2))
142
+            # print(A1, A2)
143
+        # print(digestresp)
144
+        return digestresp

+ 16 - 0
tracim/tracim/model/auth.py ファイルの表示

@@ -14,6 +14,7 @@ from hashlib import sha256
14 14
 from slugify import slugify
15 15
 from sqlalchemy.ext.hybrid import hybrid_property
16 16
 from tg.i18n import lazy_ugettext as l_
17
+from hashlib import md5
17 18
 
18 19
 __all__ = ['User', 'Group', 'Permission']
19 20
 
@@ -121,6 +122,7 @@ class User(DeclarativeBase):
121 122
     created = Column(DateTime, default=datetime.now)
122 123
     is_active = Column(Boolean, default=True, nullable=False)
123 124
     imported_from = Column(Unicode(32), nullable=True)
125
+    _webdav_left_digest_response_hash = Column('webdav_left_digest_response_hash', Unicode(128))
124 126
 
125 127
     @hybrid_property
126 128
     def email_address(self):
@@ -197,6 +199,20 @@ class User(DeclarativeBase):
197 199
     password = synonym('_password', descriptor=property(_get_password,
198 200
                                                         _set_password))
199 201
 
202
+    @classmethod
203
+    def _hash_digest(cls, digest):
204
+        return md5(bytes(digest, 'utf-8')).hexdigest()
205
+
206
+    def _set_hash_digest(self, digest):
207
+        self._webdav_left_digest_response_hash = self._hash_digest(digest)
208
+
209
+    def _get_hash_digest(self):
210
+        return self._webdav_left_digest_response_hash
211
+
212
+    webdav_left_digest_response_hash = synonym('_webdav_left_digest_response_hash',
213
+                                               descriptor=property(_get_hash_digest,
214
+                                                                    _set_hash_digest))
215
+
200 216
     def validate_password(self, password):
201 217
         """
202 218
         Check the password against existing credentials.