|
@@ -0,0 +1,315 @@
|
|
1
|
+# -*- coding: utf-8 -*-
|
|
2
|
+"""
|
|
3
|
+Auth* related model.
|
|
4
|
+
|
|
5
|
+This is where the models used by the authentication stack are defined.
|
|
6
|
+
|
|
7
|
+It's perfectly fine to re-use this definition in the tracim application,
|
|
8
|
+though.
|
|
9
|
+"""
|
|
10
|
+import os
|
|
11
|
+import time
|
|
12
|
+import uuid
|
|
13
|
+
|
|
14
|
+from datetime import datetime
|
|
15
|
+from hashlib import md5
|
|
16
|
+from hashlib import sha256
|
|
17
|
+from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+from sqlalchemy import Column
|
|
20
|
+from sqlalchemy import ForeignKey
|
|
21
|
+from sqlalchemy import Sequence
|
|
22
|
+from sqlalchemy import Table
|
|
23
|
+from sqlalchemy.ext.hybrid import hybrid_property
|
|
24
|
+from sqlalchemy.orm import relation
|
|
25
|
+from sqlalchemy.orm import relationship
|
|
26
|
+from sqlalchemy.orm import synonym
|
|
27
|
+from sqlalchemy.types import Boolean
|
|
28
|
+from sqlalchemy.types import DateTime
|
|
29
|
+from sqlalchemy.types import Integer
|
|
30
|
+from sqlalchemy.types import Unicode
|
|
31
|
+
|
|
32
|
+from tracim.lib.utils import lazy_ugettext as l_
|
|
33
|
+from tracim.model import DBSession
|
|
34
|
+from tracim.model import DeclarativeBase
|
|
35
|
+from tracim.model import metadata
|
|
36
|
+if TYPE_CHECKING:
|
|
37
|
+ from tracim.model.data import Workspace
|
|
38
|
+
|
|
39
|
+__all__ = ['User', 'Group', 'Permission']
|
|
40
|
+
|
|
41
|
+# This is the association table for the many-to-many relationship between
|
|
42
|
+# groups and permissions.
|
|
43
|
+group_permission_table = Table('group_permission', metadata,
|
|
44
|
+ Column('group_id', Integer, ForeignKey('groups.group_id',
|
|
45
|
+ onupdate="CASCADE", ondelete="CASCADE"), primary_key=True),
|
|
46
|
+ Column('permission_id', Integer, ForeignKey('permissions.permission_id',
|
|
47
|
+ onupdate="CASCADE", ondelete="CASCADE"), primary_key=True)
|
|
48
|
+)
|
|
49
|
+
|
|
50
|
+# This is the association table for the many-to-many relationship between
|
|
51
|
+# groups and members - this is, the memberships.
|
|
52
|
+user_group_table = Table('user_group', metadata,
|
|
53
|
+ Column('user_id', Integer, ForeignKey('users.user_id',
|
|
54
|
+ onupdate="CASCADE", ondelete="CASCADE"), primary_key=True),
|
|
55
|
+ Column('group_id', Integer, ForeignKey('groups.group_id',
|
|
56
|
+ onupdate="CASCADE", ondelete="CASCADE"), primary_key=True)
|
|
57
|
+)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+class Group(DeclarativeBase):
|
|
61
|
+
|
|
62
|
+ TIM_NOBODY = 0
|
|
63
|
+ TIM_USER = 1
|
|
64
|
+ TIM_MANAGER = 2
|
|
65
|
+ TIM_ADMIN = 3
|
|
66
|
+
|
|
67
|
+ TIM_NOBODY_GROUPNAME = 'nobody'
|
|
68
|
+ TIM_USER_GROUPNAME = 'users'
|
|
69
|
+ TIM_MANAGER_GROUPNAME = 'managers'
|
|
70
|
+ TIM_ADMIN_GROUPNAME = 'administrators'
|
|
71
|
+
|
|
72
|
+ __tablename__ = 'groups'
|
|
73
|
+
|
|
74
|
+ group_id = Column(Integer, Sequence('seq__groups__group_id'), autoincrement=True, primary_key=True)
|
|
75
|
+ group_name = Column(Unicode(16), unique=True, nullable=False)
|
|
76
|
+ display_name = Column(Unicode(255))
|
|
77
|
+ created = Column(DateTime, default=datetime.utcnow)
|
|
78
|
+
|
|
79
|
+ users = relationship('User', secondary=user_group_table, backref='groups')
|
|
80
|
+
|
|
81
|
+ def __repr__(self):
|
|
82
|
+ return '<Group: name=%s>' % repr(self.group_name)
|
|
83
|
+
|
|
84
|
+ def __unicode__(self):
|
|
85
|
+ return self.group_name
|
|
86
|
+
|
|
87
|
+ @classmethod
|
|
88
|
+ def by_group_name(cls, group_name):
|
|
89
|
+ """Return the user object whose email address is ``email``."""
|
|
90
|
+ return DBSession.query(cls).filter_by(group_name=group_name).first()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+class Profile(object):
|
|
94
|
+ """This model is the "max" group associated to a given user."""
|
|
95
|
+
|
|
96
|
+ _NAME = [Group.TIM_NOBODY_GROUPNAME,
|
|
97
|
+ Group.TIM_USER_GROUPNAME,
|
|
98
|
+ Group.TIM_MANAGER_GROUPNAME,
|
|
99
|
+ Group.TIM_ADMIN_GROUPNAME]
|
|
100
|
+
|
|
101
|
+ _LABEL = [l_('Nobody'),
|
|
102
|
+ l_('Users'),
|
|
103
|
+ l_('Global managers'),
|
|
104
|
+ l_('Administrators')]
|
|
105
|
+
|
|
106
|
+ def __init__(self, profile_id):
|
|
107
|
+ assert isinstance(profile_id, int)
|
|
108
|
+ self.id = profile_id
|
|
109
|
+ self.name = Profile._NAME[profile_id]
|
|
110
|
+ self.label = Profile._LABEL[profile_id]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+class User(DeclarativeBase):
|
|
114
|
+ """
|
|
115
|
+ User definition.
|
|
116
|
+
|
|
117
|
+ This is the user definition used by :mod:`repoze.who`, which requires at
|
|
118
|
+ least the ``email`` column.
|
|
119
|
+ """
|
|
120
|
+
|
|
121
|
+ __tablename__ = 'users'
|
|
122
|
+
|
|
123
|
+ user_id = Column(Integer, Sequence('seq__users__user_id'), autoincrement=True, primary_key=True)
|
|
124
|
+ email = Column(Unicode(255), unique=True, nullable=False)
|
|
125
|
+ display_name = Column(Unicode(255))
|
|
126
|
+ _password = Column('password', Unicode(128))
|
|
127
|
+ created = Column(DateTime, default=datetime.utcnow)
|
|
128
|
+ is_active = Column(Boolean, default=True, nullable=False)
|
|
129
|
+ imported_from = Column(Unicode(32), nullable=True)
|
|
130
|
+ timezone = Column(Unicode(255), nullable=False, server_default='')
|
|
131
|
+ auth_token = Column(Unicode(255))
|
|
132
|
+ auth_token_created = Column(DateTime)
|
|
133
|
+
|
|
134
|
+ @hybrid_property
|
|
135
|
+ def email_address(self):
|
|
136
|
+ return self.email
|
|
137
|
+
|
|
138
|
+ def __repr__(self):
|
|
139
|
+ return '<User: email=%s, display=%s>' % (
|
|
140
|
+ repr(self.email), repr(self.display_name))
|
|
141
|
+
|
|
142
|
+ def __unicode__(self):
|
|
143
|
+ return self.display_name or self.email
|
|
144
|
+
|
|
145
|
+ @property
|
|
146
|
+ def permissions(self):
|
|
147
|
+ """Return a set with all permissions granted to the user."""
|
|
148
|
+ perms = set()
|
|
149
|
+ for g in self.groups:
|
|
150
|
+ perms = perms | set(g.permissions)
|
|
151
|
+ return perms
|
|
152
|
+
|
|
153
|
+ @property
|
|
154
|
+ def profile(self) -> Profile:
|
|
155
|
+ profile_id = 0
|
|
156
|
+ if len(self.groups) > 0:
|
|
157
|
+ profile_id = max(group.group_id for group in self.groups)
|
|
158
|
+ return Profile(profile_id)
|
|
159
|
+
|
|
160
|
+ @property
|
|
161
|
+ def calendar_url(self) -> str:
|
|
162
|
+ # TODO - 20160531 - Bastien: Cyclic import if import in top of file
|
|
163
|
+ from tracim.lib.calendar import CalendarManager
|
|
164
|
+ calendar_manager = CalendarManager(None)
|
|
165
|
+
|
|
166
|
+ return calendar_manager.get_user_calendar_url(self.user_id)
|
|
167
|
+
|
|
168
|
+ @classmethod
|
|
169
|
+ def by_email_address(cls, email):
|
|
170
|
+ """Return the user object whose email address is ``email``."""
|
|
171
|
+ return DBSession.query(cls).filter_by(email=email).first()
|
|
172
|
+
|
|
173
|
+ @classmethod
|
|
174
|
+ def by_user_name(cls, username):
|
|
175
|
+ """Return the user object whose user name is ``username``."""
|
|
176
|
+ return DBSession.query(cls).filter_by(email=username).first()
|
|
177
|
+
|
|
178
|
+ @classmethod
|
|
179
|
+ def _hash_password(cls, cleartext_password: str) -> str:
|
|
180
|
+ salt = sha256()
|
|
181
|
+ salt.update(os.urandom(60))
|
|
182
|
+ salt = salt.hexdigest()
|
|
183
|
+
|
|
184
|
+ hash = sha256()
|
|
185
|
+ # Make sure password is a str because we cannot hash unicode objects
|
|
186
|
+ hash.update((cleartext_password + salt).encode('utf-8'))
|
|
187
|
+ hash = hash.hexdigest()
|
|
188
|
+
|
|
189
|
+ ciphertext_password = salt + hash
|
|
190
|
+
|
|
191
|
+ # Make sure the hashed password is a unicode object at the end of the
|
|
192
|
+ # process because SQLAlchemy _wants_ unicode objects for Unicode cols
|
|
193
|
+ # FIXME - D.A. - 2013-11-20 - The following line has been removed since using python3. Is this normal ?!
|
|
194
|
+ # password = password.decode('utf-8')
|
|
195
|
+
|
|
196
|
+ return ciphertext_password
|
|
197
|
+
|
|
198
|
+ def _set_password(self, cleartext_password: str) -> None:
|
|
199
|
+ """
|
|
200
|
+ Set ciphertext password from cleartext password.
|
|
201
|
+
|
|
202
|
+ Hash cleartext password on the fly,
|
|
203
|
+ Store its ciphertext version,
|
|
204
|
+ """
|
|
205
|
+ self._password = self._hash_password(cleartext_password)
|
|
206
|
+
|
|
207
|
+ def _get_password(self) -> str:
|
|
208
|
+ """Return the hashed version of the password."""
|
|
209
|
+ return self._password
|
|
210
|
+
|
|
211
|
+ password = synonym('_password', descriptor=property(_get_password,
|
|
212
|
+ _set_password))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+ def validate_password(self, cleartext_password: str) -> bool:
|
|
216
|
+ """
|
|
217
|
+ Check the password against existing credentials.
|
|
218
|
+
|
|
219
|
+ :param cleartext_password: the password that was provided by the user
|
|
220
|
+ to try and authenticate. This is the clear text version that we
|
|
221
|
+ will need to match against the hashed one in the database.
|
|
222
|
+ :type cleartext_password: unicode object.
|
|
223
|
+ :return: Whether the password is valid.
|
|
224
|
+ :rtype: bool
|
|
225
|
+
|
|
226
|
+ """
|
|
227
|
+ result = False
|
|
228
|
+ if self.password:
|
|
229
|
+ hash = sha256()
|
|
230
|
+ hash.update((cleartext_password + self.password[:64]).encode('utf-8'))
|
|
231
|
+ result = self.password[64:] == hash.hexdigest()
|
|
232
|
+ return result
|
|
233
|
+
|
|
234
|
+ def get_display_name(self, remove_email_part: bool=False) -> str:
|
|
235
|
+ """
|
|
236
|
+ Get a name to display from corresponding member or email.
|
|
237
|
+
|
|
238
|
+ :param remove_email_part: If True and display name based on email,
|
|
239
|
+ remove @xxx.xxx part of email in returned value
|
|
240
|
+ :return: display name based on user name or email.
|
|
241
|
+ """
|
|
242
|
+ if self.display_name:
|
|
243
|
+ return self.display_name
|
|
244
|
+ else:
|
|
245
|
+ if remove_email_part:
|
|
246
|
+ at_pos = self.email.index('@')
|
|
247
|
+ return self.email[0:at_pos]
|
|
248
|
+ return self.email
|
|
249
|
+
|
|
250
|
+ def get_role(self, workspace: 'Workspace') -> int:
|
|
251
|
+ for role in self.roles:
|
|
252
|
+ if role.workspace == workspace:
|
|
253
|
+ return role.role
|
|
254
|
+
|
|
255
|
+ from tracim.model.data import UserRoleInWorkspace
|
|
256
|
+ return UserRoleInWorkspace.NOT_APPLICABLE
|
|
257
|
+
|
|
258
|
+ def get_active_roles(self) -> ['UserRoleInWorkspace']:
|
|
259
|
+ """
|
|
260
|
+ :return: list of roles of the user for all not-deleted workspaces
|
|
261
|
+ """
|
|
262
|
+ roles = []
|
|
263
|
+ for role in self.roles:
|
|
264
|
+ if not role.workspace.is_deleted:
|
|
265
|
+ roles.append(role)
|
|
266
|
+ return roles
|
|
267
|
+
|
|
268
|
+ def ensure_auth_token(self) -> None:
|
|
269
|
+ """
|
|
270
|
+ Create auth_token if None, regenerate auth_token if too much old.
|
|
271
|
+
|
|
272
|
+ auth_token validity is set in
|
|
273
|
+ :return:
|
|
274
|
+ """
|
|
275
|
+ from tracim.config.app_cfg import CFG
|
|
276
|
+ validity_seconds = CFG.get_instance().USER_AUTH_TOKEN_VALIDITY
|
|
277
|
+
|
|
278
|
+ if not self.auth_token or not self.auth_token_created:
|
|
279
|
+ self.auth_token = str(uuid.uuid4())
|
|
280
|
+ self.auth_token_created = datetime.utcnow()
|
|
281
|
+ DBSession.flush()
|
|
282
|
+ return
|
|
283
|
+
|
|
284
|
+ now_seconds = time.mktime(datetime.utcnow().timetuple())
|
|
285
|
+ auth_token_seconds = time.mktime(self.auth_token_created.timetuple())
|
|
286
|
+ difference = now_seconds - auth_token_seconds
|
|
287
|
+
|
|
288
|
+ if difference > validity_seconds:
|
|
289
|
+ self.auth_token = str(uuid.uuid4())
|
|
290
|
+ self.auth_token_created = datetime.utcnow()
|
|
291
|
+ DBSession.flush()
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+class Permission(DeclarativeBase):
|
|
295
|
+ """
|
|
296
|
+ Permission definition.
|
|
297
|
+
|
|
298
|
+ Only the ``permission_name`` column is required.
|
|
299
|
+
|
|
300
|
+ """
|
|
301
|
+
|
|
302
|
+ __tablename__ = 'permissions'
|
|
303
|
+
|
|
304
|
+ permission_id = Column(Integer, Sequence('seq__permissions__permission_id'), autoincrement=True, primary_key=True)
|
|
305
|
+ permission_name = Column(Unicode(63), unique=True, nullable=False)
|
|
306
|
+ description = Column(Unicode(255))
|
|
307
|
+
|
|
308
|
+ groups = relation(Group, secondary=group_permission_table,
|
|
309
|
+ backref='permissions')
|
|
310
|
+
|
|
311
|
+ def __repr__(self):
|
|
312
|
+ return '<Permission: name=%s>' % repr(self.permission_name)
|
|
313
|
+
|
|
314
|
+ def __unicode__(self):
|
|
315
|
+ return self.permission_name
|