|
1 # |
|
2 # Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net> |
|
3 # All Rights Reserved. |
|
4 # |
|
5 # This software is subject to the provisions of the Zope Public License, |
|
6 # Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. |
|
7 # THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED |
|
8 # WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED |
|
9 # WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS |
|
10 # FOR A PARTICULAR PURPOSE. |
|
11 # |
|
12 |
|
13 __docformat__ = 'restructuredtext' |
|
14 |
|
15 |
|
16 # import standard library |
|
17 from datetime import datetime |
|
18 |
|
19 # import interfaces |
|
20 from pyams_security.interfaces import ISocialUser, IPrincipalInfo, ISocialUsersFolderPlugin, ISecurityManager, \ |
|
21 IAuthenticatedPrincipalEvent, ISocialLoginConfiguration, ISocialLoginProviderInfo, ISocialLoginProviderConnection |
|
22 from zope.annotation.interfaces import IAnnotations |
|
23 from zope.schema.interfaces import IVocabularyRegistry, IVocabularyFactory |
|
24 from zope.traversing.interfaces import ITraversable |
|
25 |
|
26 # import packages |
|
27 from authomatic.providers import oauth1, oauth2 |
|
28 from persistent import Persistent |
|
29 from pyams_security.principal import PrincipalInfo |
|
30 from pyams_utils.adapter import adapter_config, ContextAdapter |
|
31 from pyams_utils.registry import query_utility |
|
32 from pyams_utils.request import check_request |
|
33 from pyramid.events import subscriber |
|
34 from zope.container.contained import Contained |
|
35 from zope.container.folder import Folder |
|
36 from zope.interface import implementer, provider |
|
37 from zope.lifecycleevent import ObjectCreatedEvent |
|
38 from zope.location import locate |
|
39 from zope.schema.fieldproperty import FieldProperty |
|
40 from zope.schema.vocabulary import SimpleVocabulary, SimpleTerm, getVocabularyRegistry |
|
41 |
|
42 |
|
43 @implementer(ISocialUser) |
|
44 class SocialUser(Persistent, Contained): |
|
45 """Social user persistent class""" |
|
46 |
|
47 user_id = FieldProperty(ISocialUser['user_id']) |
|
48 provider_name = FieldProperty(ISocialUser['provider_name']) |
|
49 username = FieldProperty(ISocialUser['username']) |
|
50 name = FieldProperty(ISocialUser['name']) |
|
51 first_name = FieldProperty(ISocialUser['first_name']) |
|
52 last_name = FieldProperty(ISocialUser['last_name']) |
|
53 nickname = FieldProperty(ISocialUser['nickname']) |
|
54 email = FieldProperty(ISocialUser['email']) |
|
55 timezone = FieldProperty(ISocialUser['timezone']) |
|
56 country = FieldProperty(ISocialUser['country']) |
|
57 city = FieldProperty(ISocialUser['city']) |
|
58 postal_code = FieldProperty(ISocialUser['postal_code']) |
|
59 locale = FieldProperty(ISocialUser['locale']) |
|
60 picture = FieldProperty(ISocialUser['picture']) |
|
61 birth_date = FieldProperty(ISocialUser['birth_date']) |
|
62 registration_date = FieldProperty(ISocialUser['registration_date']) |
|
63 |
|
64 @property |
|
65 def title(self): |
|
66 if self.name: |
|
67 result = self.name |
|
68 elif self.first_name: |
|
69 result = '{first} {last}'.format(self.first_name, self.last_name or '') |
|
70 elif self.username: |
|
71 result = self.username |
|
72 else: |
|
73 result = self.nickname or self.user_id |
|
74 return result |
|
75 |
|
76 @property |
|
77 def title_with_source(self): |
|
78 return '{title} ({provider})'.format(title=self.title, |
|
79 provider=self.provider_name.capitalize()) |
|
80 |
|
81 |
|
82 @adapter_config(context=ISocialUser, provides=IPrincipalInfo) |
|
83 def SocialUserPrincipalInfoAdapter(user): |
|
84 """Social user principal info adapter""" |
|
85 return PrincipalInfo(id="{0}:{1}".format(user.__parent__.prefix, user.user_id), |
|
86 title=user.name) |
|
87 |
|
88 |
|
89 @implementer(ISocialUsersFolderPlugin) |
|
90 class SocialUsersFolder(Folder): |
|
91 """Social users folder""" |
|
92 |
|
93 prefix = FieldProperty(ISocialUsersFolderPlugin['prefix']) |
|
94 title = FieldProperty(ISocialUsersFolderPlugin['title']) |
|
95 enabled = FieldProperty(ISocialUsersFolderPlugin['enabled']) |
|
96 |
|
97 def get_principal(self, principal_id): |
|
98 if not self.enabled: |
|
99 return None |
|
100 if not principal_id.startswith(self.prefix + ':'): |
|
101 return None |
|
102 prefix, login = principal_id.split(':', 1) |
|
103 user = self.get(login) |
|
104 if user is not None: |
|
105 return PrincipalInfo(id='{prefix}:{user_id}'.format(prefix=self.prefix, |
|
106 user_id=user.user_id), |
|
107 title=user.title) |
|
108 |
|
109 def get_all_principals(self, principal_id): |
|
110 if not self.enabled: |
|
111 return set() |
|
112 if self.get_principal(principal_id) is not None: |
|
113 return {principal_id} |
|
114 return set() |
|
115 |
|
116 def find_principals(self, query): |
|
117 if not self.enabled: |
|
118 raise StopIteration |
|
119 # TODO: use inner text catalog for more efficient search? |
|
120 if not query: |
|
121 return None |
|
122 query = query.lower() |
|
123 for user in self.values(): |
|
124 if (query == user.user_id or |
|
125 query in (user.name or '').lower() or |
|
126 query in (user.email or '').lower()): |
|
127 yield PrincipalInfo(id='{0}:{1}'.format(self.prefix, user.user_id), |
|
128 title=user.title_with_source) |
|
129 |
|
130 def get_search_results(self, data): |
|
131 # TODO: use inner text catalog for more efficient search? |
|
132 query = data.get('query') |
|
133 if not query: |
|
134 return () |
|
135 query = query.lower() |
|
136 for user in self.values(): |
|
137 if (query == user.user_id or |
|
138 query in (user.name or '').lower() or |
|
139 query in (user.email or '').lower()): |
|
140 yield user |
|
141 |
|
142 |
|
143 @provider(IVocabularyRegistry) |
|
144 class SocialUsersFolderVocabulary(SimpleVocabulary): |
|
145 """'PyAMS users folders' vocabulary""" |
|
146 |
|
147 def __init__(self, *args, **kwargs): |
|
148 terms = [] |
|
149 manager = query_utility(ISecurityManager) |
|
150 if manager is not None: |
|
151 for name, plugin in manager.items(): |
|
152 if ISocialUsersFolderPlugin.providedBy(plugin): |
|
153 terms.append(SimpleTerm(name, title=plugin.title)) |
|
154 super(SocialUsersFolderVocabulary, self).__init__(terms) |
|
155 |
|
156 getVocabularyRegistry().register('PyAMS social users folders', SocialUsersFolderVocabulary) |
|
157 |
|
158 |
|
159 @subscriber(IAuthenticatedPrincipalEvent, plugin_selector='oauth') |
|
160 def handle_authenticated_principal(event): |
|
161 """Handle authenticated social principal""" |
|
162 manager = query_utility(ISecurityManager) |
|
163 social_folder = manager.get(manager.social_users_folder) |
|
164 if social_folder is not None: |
|
165 infos = event.infos |
|
166 if not (infos and |
|
167 'provider_name' in infos and |
|
168 'user' in infos): |
|
169 return |
|
170 user = infos['user'] |
|
171 principal_id = event.principal_id |
|
172 if principal_id not in social_folder: |
|
173 social_user = SocialUser() |
|
174 check_request().registry.notify(ObjectCreatedEvent(social_user)) |
|
175 social_user.user_id = principal_id |
|
176 social_user.provider_name = infos['provider_name'] |
|
177 social_user.username = user.username |
|
178 social_user.name = user.name |
|
179 social_user.first_name = user.first_name |
|
180 social_user.last_name = user.last_name |
|
181 social_user.nickname = user.nickname |
|
182 social_user.email = user.email |
|
183 social_user.timezone = str(user.timezone) |
|
184 social_user.country = user.country |
|
185 social_user.city = user.city |
|
186 social_user.postal_code = user.postal_code |
|
187 social_user.locale = user.locale |
|
188 social_user.picture = user.picture |
|
189 if isinstance(user.birth_date, datetime): |
|
190 social_user.birth_date = user.birth_date |
|
191 social_user.registration_date = datetime.utcnow() |
|
192 social_folder[principal_id] = social_user |
|
193 |
|
194 |
|
195 # |
|
196 # OAuth providers configuration |
|
197 # |
|
198 |
|
199 @implementer(ISocialLoginProviderInfo) |
|
200 class SocialLoginProviderInfo(object): |
|
201 """Social login provider info""" |
|
202 |
|
203 name = FieldProperty(ISocialLoginProviderInfo['name']) |
|
204 provider = None |
|
205 icon_class = FieldProperty(ISocialLoginProviderInfo['icon_class']) |
|
206 icon_filename = FieldProperty(ISocialLoginProviderInfo['icon_filename']) |
|
207 scope = FieldProperty(ISocialLoginProviderInfo['scope']) |
|
208 |
|
209 def __init__(self, name, provider, **kwargs): |
|
210 self.name = name |
|
211 self.provider = provider |
|
212 for k, v in kwargs.items(): |
|
213 setattr(self, k, v) |
|
214 |
|
215 |
|
216 PROVIDERS_INFO = {'behance': SocialLoginProviderInfo(name=oauth2.Behance.__name__, |
|
217 provider=oauth2.Behance, |
|
218 icon_class='fa fa-fw fa-behance-square', |
|
219 icon_filename='behance.ico', |
|
220 scope=oauth2.Behance.user_info_scope), |
|
221 'bitbucket': SocialLoginProviderInfo(name=oauth1.Bitbucket.__name__, |
|
222 provider=oauth1.Bitbucket, |
|
223 icon_class='fa fa-fw fa-bitbucket', |
|
224 icon_filename='bitbucket.ico'), |
|
225 'bitly': SocialLoginProviderInfo(name=oauth2.Bitly.__name__, |
|
226 provider=oauth2.Bitly, |
|
227 icon_class='fa fa-fw fa-share-alt', |
|
228 icon_filename='bitly.ico', |
|
229 scope=oauth2.Bitly.user_info_scope), |
|
230 'cosm': SocialLoginProviderInfo(name=oauth2.Cosm.__name__, |
|
231 provider=oauth2.Cosm, |
|
232 icon_class='fa fa-fw fa-share-alt', |
|
233 icon_filename='cosm.ico', |
|
234 scope=oauth2.Cosm.user_info_scope), |
|
235 'devianart': SocialLoginProviderInfo(name=oauth2.DeviantART.__name__, |
|
236 provider=oauth2.DeviantART, |
|
237 icon_class='fa fa-fw fa-deviantart', |
|
238 icon_filename='deviantart.ico', |
|
239 scope=oauth2.DeviantART.user_info_scope), |
|
240 'facebook': SocialLoginProviderInfo(name=oauth2.Facebook.__name__, |
|
241 provider=oauth2.Facebook, |
|
242 icon_class='fa fa-fw fa-facebook-square', |
|
243 icon_filename='facebook.ico', |
|
244 scope=oauth2.Facebook.user_info_scope), |
|
245 'foursquare': SocialLoginProviderInfo(name=oauth2.Foursquare.__name__, |
|
246 provider=oauth2.Foursquare, |
|
247 icon_class='fa fa-fw fa-foursquare', |
|
248 icon_filename='foursquare.ico', |
|
249 scope=oauth2.Foursquare.user_info_scope), |
|
250 'flickr': SocialLoginProviderInfo(name=oauth1.Flickr.__name__, |
|
251 provider=oauth1.Flickr, |
|
252 icon_class='fa fa-fw fa-flickr', |
|
253 icon_filename='flickr.ico'), |
|
254 'github': SocialLoginProviderInfo(name=oauth2.GitHub.__name__, |
|
255 provider=oauth2.GitHub, |
|
256 icon_class='fa fa-fw fa-github', |
|
257 icon_filename='github.ico', |
|
258 scope=oauth2.GitHub.user_info_scope), |
|
259 'google': SocialLoginProviderInfo(name=oauth2.Google.__name__, |
|
260 provider=oauth2.Google, |
|
261 icon_class='fa fa-fw fa-google-plus', |
|
262 icon_filename='google.ico', |
|
263 scope=oauth2.Google.user_info_scope), |
|
264 'linkedin': SocialLoginProviderInfo(name=oauth2.LinkedIn.__name__, |
|
265 provider=oauth2.LinkedIn, |
|
266 icon_class='fa fa-fw fa-linkedin-square', |
|
267 icon_filename='linkedin.ico', |
|
268 scope=oauth2.LinkedIn.user_info_scope), |
|
269 'meetup': SocialLoginProviderInfo(name=oauth1.Meetup.__name__, |
|
270 provider=oauth1.Meetup, |
|
271 icon_class='fa fa-fw fa-share-alt', |
|
272 icon_filename='meetup.ico'), |
|
273 'paypal': SocialLoginProviderInfo(name=oauth2.PayPal.__name__, |
|
274 provider=oauth2.PayPal, |
|
275 icon_class='fa fa-fw fa-paypal', |
|
276 icon_filename='paypal.ico', |
|
277 scope=oauth2.PayPal.user_info_scope), |
|
278 'plurk': SocialLoginProviderInfo(name=oauth1.Plurk.__name__, |
|
279 provider=oauth1.Plurk, |
|
280 icon_class='fa fa-fw fa-share-alt', |
|
281 icon_filename='plurk.ico'), |
|
282 'reddit': SocialLoginProviderInfo(name=oauth2.Reddit.__name__, |
|
283 provider=oauth2.Reddit, |
|
284 icon_class='fa fa-fw fa-reddit', |
|
285 icon_filename='reddit.ico', |
|
286 scope=oauth2.Reddit.user_info_scope), |
|
287 'twitter': SocialLoginProviderInfo(name=oauth1.Twitter.__name__, |
|
288 provider=oauth1.Twitter, |
|
289 icon_class='fa fa-fw fa-twitter', |
|
290 icon_filename='twitter.ico'), |
|
291 'tumblr': SocialLoginProviderInfo(name=oauth1.Tumblr.__name__, |
|
292 provider=oauth1.Tumblr, |
|
293 icon_class='fa fa-fw fa-tumblr-square', |
|
294 icon_filename='tumblr.ico'), |
|
295 'ubuntuone': SocialLoginProviderInfo(name=oauth1.UbuntuOne.__name__, |
|
296 provider=oauth1.UbuntuOne, |
|
297 icon_class='fa fa-fw fa-share-alt', |
|
298 icon_filename='ubuntuone.ico'), |
|
299 'viadeo': SocialLoginProviderInfo(name=oauth2.Viadeo.__name__, |
|
300 provider=oauth2.Viadeo, |
|
301 icon_class='fa fa-fw fa-share-alt', |
|
302 icon_filename='viadeo.ico', |
|
303 scope=oauth2.Viadeo.user_info_scope), |
|
304 'vimeo': SocialLoginProviderInfo(name=oauth1.Vimeo.__name__, |
|
305 provider=oauth1.Vimeo, |
|
306 icon_class='fa fa-fw fa-vimeo-square', |
|
307 icon_filename='vimeo.ico'), |
|
308 'vk': SocialLoginProviderInfo(name=oauth2.VK.__name__, |
|
309 provider=oauth2.VK, |
|
310 icon_class='fa fa-fw fa-vk', |
|
311 icon_filename='vk.ico', |
|
312 scope=oauth2.VK.user_info_scope), |
|
313 'windowlive': SocialLoginProviderInfo(name=oauth2.WindowsLive.__name__, |
|
314 provider=oauth2.WindowsLive, |
|
315 icon_class='fa fa-fw fa-windows', |
|
316 icon_filename='windows_live.ico', |
|
317 scope=oauth2.WindowsLive.user_info_scope), |
|
318 'xero': SocialLoginProviderInfo(name=oauth1.Xero.__name__, |
|
319 provider=oauth1.Xero, |
|
320 icon_class='fa fa-fw fa-share-alt', |
|
321 icon_filename='xero.ico'), |
|
322 'xing': SocialLoginProviderInfo(name=oauth1.Xing.__name__, |
|
323 provider=oauth1.Xing, |
|
324 icon_class='fa fa-fw fa-xing', |
|
325 icon_filename='xing.ico'), |
|
326 'yahoo': SocialLoginProviderInfo(name=oauth1.Yahoo.__name__, |
|
327 provider=oauth1.Yahoo, |
|
328 icon_class='fa fa-fw fa-yahoo', |
|
329 icon_filename='yahoo.ico'), |
|
330 'yammer': SocialLoginProviderInfo(name=oauth2.Yammer.__name__, |
|
331 provider=oauth2.Yammer, |
|
332 icon_class='fa fa-fw fa-share-alt', |
|
333 icon_filename='yammer.ico', |
|
334 scope=oauth2.Yammer.user_info_scope), |
|
335 'yandex': SocialLoginProviderInfo(name=oauth2.Yandex.__name__, |
|
336 provider=oauth2.Yandex, |
|
337 icon_class='fa fa-fw fa-share-alt', |
|
338 icon_filename='yandex.ico', |
|
339 scope=oauth2.Yandex.user_info_scope)} |
|
340 |
|
341 |
|
342 def get_provider_info(provider_name): |
|
343 """Get provider info matching given provider name""" |
|
344 return PROVIDERS_INFO.get(provider_name) |
|
345 |
|
346 |
|
347 @provider(IVocabularyFactory) |
|
348 class OAuthProvidersVocabulary(SimpleVocabulary): |
|
349 """OAuth providers vocabulary""" |
|
350 |
|
351 def __init__(self, *args, **kwargs): |
|
352 terms = [] |
|
353 for key, provider in PROVIDERS_INFO.items(): |
|
354 terms.append(SimpleTerm(key, title=provider.name)) |
|
355 terms.sort(key=lambda x: x.title) |
|
356 super(OAuthProvidersVocabulary, self).__init__(terms) |
|
357 |
|
358 getVocabularyRegistry().register('PyAMS OAuth providers', OAuthProvidersVocabulary) |
|
359 |
|
360 |
|
361 @implementer(ISocialLoginConfiguration) |
|
362 class SocialLoginConfiguration(Folder): |
|
363 """Social login configuration""" |
|
364 |
|
365 def get_oauth_configuration(self): |
|
366 result = {} |
|
367 for provider in self.values(): |
|
368 provider_info = get_provider_info(provider.provider_name) |
|
369 result[provider.provider_name] = {'id': provider.provider_id, |
|
370 'class_': provider_info.provider, |
|
371 'consumer_key': provider.consumer_key, |
|
372 'consumer_secret': provider.consumer_secret, |
|
373 'scope': provider_info.scope} |
|
374 return result |
|
375 |
|
376 |
|
377 SOCIAL_LOGIN_CONFIGURATION_KEY = 'pyams_security.plugin.social' |
|
378 |
|
379 |
|
380 @adapter_config(context=ISecurityManager, provides=ISocialLoginConfiguration) |
|
381 def SocialLoginConfigurationAdapter(context): |
|
382 """Social login configuration adapter""" |
|
383 annotations = IAnnotations(context) |
|
384 configuration = annotations.get(SOCIAL_LOGIN_CONFIGURATION_KEY) |
|
385 if configuration is None: |
|
386 configuration = annotations[SOCIAL_LOGIN_CONFIGURATION_KEY] = SocialLoginConfiguration() |
|
387 check_request().registry.notify(ObjectCreatedEvent(configuration)) |
|
388 locate(configuration, context, '++social-configuration++') |
|
389 return configuration |
|
390 |
|
391 |
|
392 @adapter_config(name='social-configuration', context=ISecurityManager, provides=ITraversable) |
|
393 class SecurityManagerSocialTraverser(ContextAdapter): |
|
394 """++social-configuration++ namespace traverser""" |
|
395 |
|
396 def traverse(self, name, furtherpath=None): |
|
397 return ISocialLoginConfiguration(self.context) |
|
398 |
|
399 |
|
400 @implementer(ISocialLoginProviderConnection) |
|
401 class SocialLoginProviderConnection(Persistent): |
|
402 """Social login provider connection""" |
|
403 |
|
404 provider_name = FieldProperty(ISocialLoginProviderConnection['provider_name']) |
|
405 provider_id = FieldProperty(ISocialLoginProviderConnection['provider_id']) |
|
406 consumer_key = FieldProperty(ISocialLoginProviderConnection['consumer_key']) |
|
407 consumer_secret = FieldProperty(ISocialLoginProviderConnection['consumer_secret']) |
|
408 |
|
409 def get_configuration(self): |
|
410 return get_provider_info(self.provider_name) |