Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1# -*- coding: utf-8 -*- 

2# This program is distributed in the hope that it will be useful, but WITHOUT 

3# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 

4# FOR A PARTICULAR PURPOSE. See the GNU General Public License version 3 for 

5# more details. 

6# 

7# You should have received a copy of the GNU General Public License version 3 

8# along with this program; if not, write to the Free Software Foundation, Inc., 51 

9# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 

10# 

11# (c) 2015-2016 Valentin Samir 

12"""models for the app""" 

13from .default_settings import settings, SessionStore 

14 

15from django.db import models 

16from django.db.models import Q 

17from django.contrib import messages 

18from django.utils.translation import ugettext_lazy as _ 

19from django.utils import timezone 

20from django.utils.encoding import python_2_unicode_compatible 

21from django.core.mail import send_mail 

22 

23import re 

24import sys 

25import smtplib 

26import logging 

27from datetime import timedelta 

28from concurrent.futures import ThreadPoolExecutor 

29from requests_futures.sessions import FuturesSession 

30 

31from cas_server import utils 

32from . import VERSION 

33 

34#: logger facility 

35logger = logging.getLogger(__name__) 

36 

37 

38class JsonAttributes(models.Model): 

39 """ 

40 Bases: :class:`django.db.models.Model` 

41 

42 A base class for models storing attributes as a json 

43 """ 

44 

45 class Meta: 

46 abstract = True 

47 

48 #: The attributes json encoded 

49 _attributs = models.TextField(default=None, null=True, blank=True) 

50 

51 @property 

52 def attributs(self): 

53 """The attributes""" 

54 if self._attributs is not None: 

55 return utils.json.loads(self._attributs) 

56 

57 @attributs.setter 

58 def attributs(self, value): 

59 """attributs property setter""" 

60 self._attributs = utils.json_encode(value) 

61 

62 

63@python_2_unicode_compatible 

64class FederatedIendityProvider(models.Model): 

65 """ 

66 Bases: :class:`django.db.models.Model` 

67 

68 An identity provider for the federated mode 

69 """ 

70 class Meta: 

71 verbose_name = _(u"identity provider") 

72 verbose_name_plural = _(u"identity providers") 

73 #: Suffix append to backend CAS returned username: ``returned_username`` @ ``suffix``. 

74 #: it must be unique. 

75 suffix = models.CharField( 

76 max_length=30, 

77 unique=True, 

78 verbose_name=_(u"suffix"), 

79 help_text=_( 

80 u"Suffix append to backend CAS returned " 

81 u"username: ``returned_username`` @ ``suffix``." 

82 ) 

83 ) 

84 #: URL to the root of the CAS server application. If login page is 

85 #: https://cas.example.net/cas/login then :attr:`server_url` should be 

86 #: https://cas.example.net/cas/ 

87 server_url = models.CharField(max_length=255, verbose_name=_(u"server url")) 

88 #: Version of the CAS protocol to use when sending requests the the backend CAS. 

89 cas_protocol_version = models.CharField( 

90 max_length=30, 

91 choices=[ 

92 ("1", "CAS 1.0"), 

93 ("2", "CAS 2.0"), 

94 ("3", "CAS 3.0"), 

95 ("CAS_2_SAML_1_0", "SAML 1.1") 

96 ], 

97 verbose_name=_(u"CAS protocol version"), 

98 help_text=_( 

99 u"Version of the CAS protocol to use when sending requests the the backend CAS." 

100 ), 

101 default="3" 

102 ) 

103 #: Name for this identity provider displayed on the login page. 

104 verbose_name = models.CharField( 

105 max_length=255, 

106 verbose_name=_(u"verbose name"), 

107 help_text=_(u"Name for this identity provider displayed on the login page.") 

108 ) 

109 #: Position of the identity provider on the login page. Identity provider are sorted using the 

110 #: (:attr:`pos`, :attr:`verbose_name`, :attr:`suffix`) attributes. 

111 pos = models.IntegerField( 

112 default=100, 

113 verbose_name=_(u"position"), 

114 help_text=_( 

115 ( 

116 u"Position of the identity provider on the login page. " 

117 u"Identity provider are sorted using the " 

118 u"(position, verbose name, suffix) attributes." 

119 ) 

120 ) 

121 ) 

122 #: Display the provider on the login page. Beware that this do not disable the identity 

123 #: provider, it just hide it on the login page. User will always be able to log in using this 

124 #: provider by fetching ``/federate/suffix``. 

125 display = models.BooleanField( 

126 default=True, 

127 verbose_name=_(u"display"), 

128 help_text=_("Display the provider on the login page.") 

129 ) 

130 

131 def __str__(self): 

132 return self.verbose_name 

133 

134 @staticmethod 

135 def build_username_from_suffix(username, suffix): 

136 """ 

137 Transform backend username into federated username using ``suffix`` 

138 

139 :param unicode username: A CAS backend returned username 

140 :param unicode suffix: A suffix identifying the CAS backend 

141 :return: The federated username: ``username`` @ ``suffix``. 

142 :rtype: unicode 

143 """ 

144 return u'%s@%s' % (username, suffix) 

145 

146 def build_username(self, username): 

147 """ 

148 Transform backend username into federated username 

149 

150 :param unicode username: A CAS backend returned username 

151 :return: The federated username: ``username`` @ :attr:`suffix`. 

152 :rtype: unicode 

153 """ 

154 return u'%s@%s' % (username, self.suffix) 

155 

156 

157@python_2_unicode_compatible 

158class FederatedUser(JsonAttributes): 

159 """ 

160 Bases: :class:`JsonAttributes` 

161 

162 A federated user as returner by a CAS provider (username and attributes) 

163 """ 

164 class Meta: 

165 unique_together = ("username", "provider") 

166 verbose_name = _("Federated user") 

167 verbose_name_plural = _("Federated users") 

168 #: The user username returned by the CAS backend on successful ticket validation 

169 username = models.CharField(max_length=124) 

170 #: A foreign key to :class:`FederatedIendityProvider` 

171 provider = models.ForeignKey(FederatedIendityProvider, on_delete=models.CASCADE) 

172 #: The last ticket used to authenticate :attr:`username` against :attr:`provider` 

173 ticket = models.CharField(max_length=255) 

174 #: Last update timespampt. Usually, the last time :attr:`ticket` has been set. 

175 last_update = models.DateTimeField(auto_now=True) 

176 

177 def __str__(self): 

178 return self.federated_username 

179 

180 @property 

181 def federated_username(self): 

182 """The federated username with a suffix for the current :class:`FederatedUser`.""" 

183 return self.provider.build_username(self.username) 

184 

185 @classmethod 

186 def get_from_federated_username(cls, username): 

187 """ 

188 :return: A :class:`FederatedUser` object from a federated ``username`` 

189 :rtype: :class:`FederatedUser` 

190 """ 

191 if username is None: 

192 raise cls.DoesNotExist() 

193 else: 

194 component = username.split('@') 

195 username = '@'.join(component[:-1]) 

196 suffix = component[-1] 

197 try: 

198 provider = FederatedIendityProvider.objects.get(suffix=suffix) 

199 return cls.objects.get(username=username, provider=provider) 

200 except FederatedIendityProvider.DoesNotExist: 

201 raise cls.DoesNotExist() 

202 

203 @classmethod 

204 def clean_old_entries(cls): 

205 """remove old unused :class:`FederatedUser`""" 

206 federated_users = cls.objects.filter( 

207 last_update__lt=(timezone.now() - timedelta(seconds=settings.CAS_TICKET_TIMEOUT)) 

208 ) 

209 known_users = {user.username for user in User.objects.all()} 

210 for user in federated_users: 

211 if user.federated_username not in known_users: 

212 user.delete() 

213 

214 

215class FederateSLO(models.Model): 

216 """ 

217 Bases: :class:`django.db.models.Model` 

218 

219 An association between a CAS provider ticket and a (username, session) for processing SLO 

220 """ 

221 class Meta: 

222 unique_together = ("username", "session_key", "ticket") 

223 #: the federated username with the ``@``component 

224 username = models.CharField(max_length=30) 

225 #: the session key for the session :attr:`username` has been authenticated using :attr:`ticket` 

226 session_key = models.CharField(max_length=40, blank=True, null=True) 

227 #: The ticket used to authenticate :attr:`username` 

228 ticket = models.CharField(max_length=255, db_index=True) 

229 

230 @classmethod 

231 def clean_deleted_sessions(cls): 

232 """remove old :class:`FederateSLO` object for which the session do not exists anymore""" 

233 for federate_slo in cls.objects.all(): 

234 if not SessionStore(session_key=federate_slo.session_key).get('authenticated'): 

235 federate_slo.delete() 

236 

237 

238@python_2_unicode_compatible 

239class UserAttributes(JsonAttributes): 

240 """ 

241 Bases: :class:`JsonAttributes` 

242 

243 Local cache of the user attributes, used then needed 

244 """ 

245 class Meta: 

246 verbose_name = _("User attributes cache") 

247 verbose_name_plural = _("User attributes caches") 

248 #: The username of the user for which we cache attributes 

249 username = models.CharField(max_length=155, unique=True) 

250 

251 def __str__(self): 

252 return self.username 

253 

254 @classmethod 

255 def clean_old_entries(cls): 

256 """Remove :class:`UserAttributes` for which no more :class:`User` exists.""" 

257 for user in cls.objects.all(): 

258 if User.objects.filter(username=user.username).count() == 0: 

259 user.delete() 

260 

261 

262@python_2_unicode_compatible 

263class User(models.Model): 

264 """ 

265 Bases: :class:`django.db.models.Model` 

266 

267 A user logged into the CAS 

268 """ 

269 class Meta: 

270 unique_together = ("username", "session_key") 

271 verbose_name = _("User") 

272 verbose_name_plural = _("Users") 

273 #: The session key of the current authenticated user 

274 session_key = models.CharField(max_length=40, blank=True, null=True) 

275 #: The username of the current authenticated user 

276 username = models.CharField(max_length=250) 

277 #: Last time the authenticated user has do something (auth, fetch ticket, etc…) 

278 date = models.DateTimeField(auto_now=True) 

279 #: last time the user logged 

280 last_login = models.DateTimeField(auto_now_add=True) 

281 

282 def delete(self, *args, **kwargs): 

283 """ 

284 Remove the current :class:`User`. If ``settings.CAS_FEDERATE`` is ``True``, also delete 

285 the corresponding :class:`FederateSLO` object. 

286 """ 

287 if settings.CAS_FEDERATE: 

288 FederateSLO.objects.filter( 

289 username=self.username, 

290 session_key=self.session_key 

291 ).delete() 

292 super(User, self).delete(*args, **kwargs) 

293 

294 @classmethod 

295 def clean_old_entries(cls): 

296 """ 

297 Remove :class:`User` objects inactive since more that 

298 :django:setting:`SESSION_COOKIE_AGE` and send corresponding SingleLogOut requests. 

299 """ 

300 filter = Q(date__lt=(timezone.now() - timedelta(seconds=settings.SESSION_COOKIE_AGE))) 

301 if settings.CAS_TGT_VALIDITY is not None: 

302 filter |= Q( 

303 last_login__lt=(timezone.now() - timedelta(seconds=settings.CAS_TGT_VALIDITY)) 

304 ) 

305 users = cls.objects.filter(filter) 

306 for user in users: 

307 user.logout() 

308 users.delete() 

309 

310 @classmethod 

311 def clean_deleted_sessions(cls): 

312 """Remove :class:`User` objects where the corresponding session do not exists anymore.""" 

313 for user in cls.objects.all(): 

314 if not SessionStore(session_key=user.session_key).get('authenticated'): 

315 user.logout() 

316 user.delete() 

317 

318 @property 

319 def attributs(self): 

320 """ 

321 Property. 

322 A fresh :class:`dict` for the user attributes, using ``settings.CAS_AUTH_CLASS`` if 

323 possible, and if not, try to fallback to cached attributes (actually only used for ldap 

324 auth class with bind password check mthode). 

325 """ 

326 try: 

327 return utils.import_attr(settings.CAS_AUTH_CLASS)(self.username).attributs() 

328 except NotImplementedError: 

329 try: 

330 user = UserAttributes.objects.get(username=self.username) 

331 attributes = user.attributs 

332 if attributes is not None: 

333 return attributes 

334 else: 

335 return {} 

336 except UserAttributes.DoesNotExist: 

337 return {} 

338 

339 def __str__(self): 

340 return u"%s - %s" % (self.username, self.session_key) 

341 

342 def logout(self, request=None): 

343 """ 

344 Send SLO requests to all services the user is logged in. 

345 

346 :param request: The current django HttpRequest to display possible failure to the user. 

347 :type request: :class:`django.http.HttpRequest` or :obj:`NoneType<types.NoneType>` 

348 """ 

349 ticket_classes = [ProxyGrantingTicket, ServiceTicket, ProxyTicket] 

350 for error in Ticket.send_slos( 

351 [ticket_class.objects.filter(user=self) for ticket_class in ticket_classes] 

352 ): 

353 logger.warning( 

354 "Error during SLO for user %s: %s" % ( 

355 self.username, 

356 error 

357 ) 

358 ) 

359 if request is not None: 

360 error = utils.unpack_nested_exception(error) 

361 messages.add_message( 

362 request, 

363 messages.WARNING, 

364 _(u'Error during service logout %s') % error 

365 ) 

366 

367 def get_ticket(self, ticket_class, service, service_pattern, renew): 

368 """ 

369 Generate a ticket using ``ticket_class`` for the service 

370 ``service`` matching ``service_pattern`` and asking or not for 

371 authentication renewal with ``renew`` 

372 

373 :param type ticket_class: :class:`ServiceTicket` or :class:`ProxyTicket` or 

374 :class:`ProxyGrantingTicket`. 

375 :param unicode service: The service url for which we want a ticket. 

376 :param ServicePattern service_pattern: The service pattern matching ``service``. 

377 Beware that ``service`` must match :attr:`ServicePattern.pattern` and the current 

378 :class:`User` must pass :meth:`ServicePattern.check_user`. These checks are not done 

379 here and you must perform them before calling this method. 

380 :param bool renew: Should be ``True`` if authentication has been renewed. Must be 

381 ``False`` otherwise. 

382 :return: A :class:`Ticket` object. 

383 :rtype: :class:`ServiceTicket` or :class:`ProxyTicket` or 

384 :class:`ProxyGrantingTicket`. 

385 """ 

386 attributs = dict( 

387 (a.name, a.replace if a.replace else a.name) for a in service_pattern.attributs.all() 

388 ) 

389 replacements = dict( 

390 (a.attribut, (a.pattern, a.replace)) for a in service_pattern.replacements.all() 

391 ) 

392 service_attributs = {} 

393 for (key, value) in self.attributs.items(): 

394 if key in attributs or '*' in attributs: 

395 if key in replacements: 

396 if isinstance(value, list): 

397 for index, subval in enumerate(value): 

398 value[index] = re.sub( 

399 replacements[key][0], 

400 replacements[key][1], 

401 subval 

402 ) 

403 else: 

404 value = re.sub(replacements[key][0], replacements[key][1], value) 

405 service_attributs[attributs.get(key, key)] = value 

406 ticket = ticket_class.objects.create( 

407 user=self, 

408 attributs=service_attributs, 

409 service=service, 

410 renew=renew, 

411 service_pattern=service_pattern, 

412 single_log_out=service_pattern.single_log_out 

413 ) 

414 ticket.save() 

415 self.save() 

416 return ticket 

417 

418 def get_service_url(self, service, service_pattern, renew): 

419 """ 

420 Return the url to which the user must be redirected to 

421 after a Service Ticket has been generated 

422 

423 :param unicode service: The service url for which we want a ticket. 

424 :param ServicePattern service_pattern: The service pattern matching ``service``. 

425 Beware that ``service`` must match :attr:`ServicePattern.pattern` and the current 

426 :class:`User` must pass :meth:`ServicePattern.check_user`. These checks are not done 

427 here and you must perform them before calling this method. 

428 :param bool renew: Should be ``True`` if authentication has been renewed. Must be 

429 ``False`` otherwise. 

430 :return unicode: The service url with the ticket GET param added. 

431 :rtype: unicode 

432 """ 

433 ticket = self.get_ticket(ServiceTicket, service, service_pattern, renew) 

434 url = utils.update_url(service, {'ticket': ticket.value}) 

435 logger.info("Service ticket created for service %s by user %s." % (service, self.username)) 

436 return url 

437 

438 

439class ServicePatternException(Exception): 

440 """ 

441 Bases: :class:`exceptions.Exception` 

442 

443 Base exception of exceptions raised in the ServicePattern model""" 

444 pass 

445 

446 

447class BadUsername(ServicePatternException): 

448 """ 

449 Bases: :class:`ServicePatternException` 

450 

451 Exception raised then an non allowed username try to get a ticket for a service 

452 """ 

453 pass 

454 

455 

456class BadFilter(ServicePatternException): 

457 """ 

458 Bases: :class:`ServicePatternException` 

459 

460 Exception raised then a user try to get a ticket for a service and do not reach a condition 

461 """ 

462 pass 

463 

464 

465class UserFieldNotDefined(ServicePatternException): 

466 """ 

467 Bases: :class:`ServicePatternException` 

468 

469 Exception raised then a user try to get a ticket for a service using as username 

470 an attribut not present on this user 

471 """ 

472 pass 

473 

474 

475@python_2_unicode_compatible 

476class ServicePattern(models.Model): 

477 """ 

478 Bases: :class:`django.db.models.Model` 

479 

480 Allowed services pattern against services are tested to 

481 """ 

482 class Meta: 

483 ordering = ("pos", ) 

484 verbose_name = _("Service pattern") 

485 verbose_name_plural = _("Services patterns") 

486 

487 #: service patterns are sorted using the :attr:`pos` attribute 

488 pos = models.IntegerField( 

489 default=100, 

490 verbose_name=_(u"position"), 

491 help_text=_(u"service patterns are sorted using the position attribute") 

492 ) 

493 #: A name for the service (this can bedisplayed to the user on the login page) 

494 name = models.CharField( 

495 max_length=255, 

496 unique=True, 

497 blank=True, 

498 null=True, 

499 verbose_name=_(u"name"), 

500 help_text=_(u"A name for the service") 

501 ) 

502 #: A regular expression matching services. "Will usually looks like 

503 #: '^https://some\\.server\\.com/path/.*$'. As it is a regular expression, special character 

504 #: must be escaped with a '\\'. 

505 pattern = models.CharField( 

506 max_length=255, 

507 unique=True, 

508 verbose_name=_(u"pattern"), 

509 help_text=_( 

510 "A regular expression matching services. " 

511 "Will usually looks like '^https://some\\.server\\.com/path/.*$'." 

512 "As it is a regular expression, special character must be escaped with a '\\'." 

513 ), 

514 validators=[utils.regexpr_validator] 

515 ) 

516 #: Name of the attribute to transmit as username, if empty the user login is used 

517 user_field = models.CharField( 

518 max_length=255, 

519 default="", 

520 blank=True, 

521 verbose_name=_(u"user field"), 

522 help_text=_("Name of the attribute to transmit as username, empty = login") 

523 ) 

524 #: A boolean allowing to limit username allowed to connect to :attr:`usernames`. 

525 restrict_users = models.BooleanField( 

526 default=False, 

527 verbose_name=_(u"restrict username"), 

528 help_text=_("Limit username allowed to connect to the list provided bellow") 

529 ) 

530 #: A boolean allowing to deliver :class:`ProxyTicket` to the service. 

531 proxy = models.BooleanField( 

532 default=False, 

533 verbose_name=_(u"proxy"), 

534 help_text=_("Proxy tickets can be delivered to the service") 

535 ) 

536 #: A boolean allowing the service to be used as a proxy callback (via the pgtUrl GET param) 

537 #: to deliver :class:`ProxyGrantingTicket`. 

538 proxy_callback = models.BooleanField( 

539 default=False, 

540 verbose_name=_(u"proxy callback"), 

541 help_text=_("can be used as a proxy callback to deliver PGT") 

542 ) 

543 #: Enable SingleLogOut for the service. Old validaed tickets for the service will be kept 

544 #: until ``settings.CAS_TICKET_TIMEOUT`` after what a SLO request is send to the service and 

545 #: the ticket is purged from database. A SLO can be send earlier if the user log-out. 

546 single_log_out = models.BooleanField( 

547 default=False, 

548 verbose_name=_(u"single log out"), 

549 help_text=_("Enable SLO for the service") 

550 ) 

551 #: An URL where the SLO request will be POST. If empty the service url will be used. 

552 #: This is usefull for non HTTP proxied services like smtp or imap. 

553 single_log_out_callback = models.CharField( 

554 max_length=255, 

555 default="", 

556 blank=True, 

557 verbose_name=_(u"single log out callback"), 

558 help_text=_(u"URL where the SLO request will be POST. empty = service url\n" 

559 u"This is usefull for non HTTP proxied services.") 

560 ) 

561 

562 def __str__(self): 

563 return u"%s: %s" % (self.pos, self.pattern) 

564 

565 def check_user(self, user): 

566 """ 

567 Check if ``user`` if allowed to use theses services. If ``user`` is not allowed, 

568 raises one of :class:`BadFilter`, :class:`UserFieldNotDefined`, :class:`BadUsername` 

569 

570 :param User user: a :class:`User` object 

571 :raises BadUsername: if :attr:`restrict_users` if ``True`` and :attr:`User.username` 

572 is not within :attr:`usernames`. 

573 :raises BadFilter: if a :class:`FilterAttributValue` condition of :attr:`filters` 

574 connot be verified. 

575 :raises UserFieldNotDefined: if :attr:`user_field` is defined and its value is not 

576 within :attr:`User.attributs`. 

577 :return: ``True`` 

578 :rtype: bool 

579 """ 

580 if self.restrict_users and not self.usernames.filter(value=user.username): 

581 logger.warning("Username %s not allowed on service %s" % (user.username, self.name)) 

582 raise BadUsername() 

583 for filtre in self.filters.all(): 

584 if isinstance(user.attributs.get(filtre.attribut, []), list): 

585 attrs = user.attributs.get(filtre.attribut, []) 

586 else: 

587 attrs = [user.attributs[filtre.attribut]] 

588 for value in attrs: 

589 if re.match(filtre.pattern, str(value)): 

590 break 

591 else: 

592 bad_filter = (filtre.pattern, filtre.attribut, user.attributs.get(filtre.attribut)) 

593 logger.warning( 

594 "User constraint failed for %s, service %s: %s do not match %s %s." % ( 

595 (user.username, self.name) + bad_filter 

596 ) 

597 ) 

598 raise BadFilter('%s do not match %s %s' % bad_filter) 

599 if self.user_field and not user.attributs.get(self.user_field): 

600 logger.warning( 

601 "Cannot use %s a loggin for user %s on service %s because it is absent" % ( 

602 self.user_field, 

603 user.username, 

604 self.name 

605 ) 

606 ) 

607 raise UserFieldNotDefined() 

608 return True 

609 

610 @classmethod 

611 def validate(cls, service): 

612 """ 

613 Get a :class:`ServicePattern` intance from a service url. 

614 

615 :param unicode service: A service url 

616 :return: A :class:`ServicePattern` instance matching ``service``. 

617 :rtype: :class:`ServicePattern` 

618 :raises ServicePattern.DoesNotExist: if no :class:`ServicePattern` is matching 

619 ``service``. 

620 """ 

621 for service_pattern in cls.objects.all().order_by('pos'): 

622 if re.match(service_pattern.pattern, service): 

623 return service_pattern 

624 logger.warning("Service %s not allowed." % service) 

625 raise cls.DoesNotExist() 

626 

627 

628@python_2_unicode_compatible 

629class Username(models.Model): 

630 """ 

631 Bases: :class:`django.db.models.Model` 

632 

633 A list of allowed usernames on a :class:`ServicePattern` 

634 """ 

635 #: username allowed to connect to the service 

636 value = models.CharField( 

637 max_length=255, 

638 verbose_name=_(u"username"), 

639 help_text=_(u"username allowed to connect to the service") 

640 ) 

641 #: ForeignKey to a :class:`ServicePattern`. :class:`Username` instances for a 

642 #: :class:`ServicePattern` are accessible thought its :attr:`ServicePattern.usernames` 

643 #: attribute. 

644 service_pattern = models.ForeignKey( 

645 ServicePattern, 

646 related_name="usernames", 

647 on_delete=models.CASCADE 

648 ) 

649 

650 def __str__(self): 

651 return self.value 

652 

653 

654@python_2_unicode_compatible 

655class ReplaceAttributName(models.Model): 

656 """ 

657 Bases: :class:`django.db.models.Model` 

658 

659 A replacement of an attribute name for a :class:`ServicePattern`. It also tell to transmit 

660 an attribute of :attr:`User.attributs` to the service. An empty :attr:`replace` mean 

661 to use the original attribute name. 

662 """ 

663 class Meta: 

664 unique_together = ('name', 'replace', 'service_pattern') 

665 #: Name the attribute: a key of :attr:`User.attributs` 

666 name = models.CharField( 

667 max_length=255, 

668 verbose_name=_(u"name"), 

669 help_text=_(u"name of an attribute to send to the service, use * for all attributes") 

670 ) 

671 #: The name of the attribute to transmit to the service. If empty, the value of :attr:`name` 

672 #: is used. 

673 replace = models.CharField( 

674 max_length=255, 

675 blank=True, 

676 verbose_name=_(u"replace"), 

677 help_text=_(u"name under which the attribute will be show " 

678 u"to the service. empty = default name of the attribut") 

679 ) 

680 #: ForeignKey to a :class:`ServicePattern`. :class:`ReplaceAttributName` instances for a 

681 #: :class:`ServicePattern` are accessible thought its :attr:`ServicePattern.attributs` 

682 #: attribute. 

683 service_pattern = models.ForeignKey( 

684 ServicePattern, 

685 related_name="attributs", 

686 on_delete=models.CASCADE 

687 ) 

688 

689 def __str__(self): 

690 if not self.replace: 

691 return self.name 

692 else: 

693 return u"%s → %s" % (self.name, self.replace) 

694 

695 

696@python_2_unicode_compatible 

697class FilterAttributValue(models.Model): 

698 """ 

699 Bases: :class:`django.db.models.Model` 

700 

701 A filter on :attr:`User.attributs` for a :class:`ServicePattern`. If a :class:`User` do not 

702 have an attribute :attr:`attribut` or its value do not match :attr:`pattern`, then 

703 :meth:`ServicePattern.check_user` will raises :class:`BadFilter` if called with that user. 

704 """ 

705 #: The name of a user attribute 

706 attribut = models.CharField( 

707 max_length=255, 

708 verbose_name=_(u"attribute"), 

709 help_text=_(u"Name of the attribute which must verify pattern") 

710 ) 

711 #: A regular expression the attribute :attr:`attribut` value must verify. If :attr:`attribut` 

712 #: if a list, only one of the list values needs to match. 

713 pattern = models.CharField( 

714 max_length=255, 

715 verbose_name=_(u"pattern"), 

716 help_text=_(u"a regular expression"), 

717 validators=[utils.regexpr_validator] 

718 ) 

719 #: ForeignKey to a :class:`ServicePattern`. :class:`FilterAttributValue` instances for a 

720 #: :class:`ServicePattern` are accessible thought its :attr:`ServicePattern.filters` 

721 #: attribute. 

722 service_pattern = models.ForeignKey( 

723 ServicePattern, 

724 related_name="filters", 

725 on_delete=models.CASCADE 

726 ) 

727 

728 def __str__(self): 

729 return u"%s %s" % (self.attribut, self.pattern) 

730 

731 

732@python_2_unicode_compatible 

733class ReplaceAttributValue(models.Model): 

734 """ 

735 Bases: :class:`django.db.models.Model` 

736 

737 A replacement (using a regular expression) of an attribute value for a 

738 :class:`ServicePattern`. 

739 """ 

740 #: Name the attribute: a key of :attr:`User.attributs` 

741 attribut = models.CharField( 

742 max_length=255, 

743 verbose_name=_(u"attribute"), 

744 help_text=_(u"Name of the attribute for which the value must be replace") 

745 ) 

746 #: A regular expression matching the part of the attribute value that need to be changed 

747 pattern = models.CharField( 

748 max_length=255, 

749 verbose_name=_(u"pattern"), 

750 help_text=_(u"An regular expression maching whats need to be replaced"), 

751 validators=[utils.regexpr_validator] 

752 ) 

753 #: The replacement to what is mached by :attr:`pattern`. groups are capture by \\1, \\2 … 

754 replace = models.CharField( 

755 max_length=255, 

756 blank=True, 

757 verbose_name=_(u"replace"), 

758 help_text=_(u"replace expression, groups are capture by \\1, \\2 …") 

759 ) 

760 #: ForeignKey to a :class:`ServicePattern`. :class:`ReplaceAttributValue` instances for a 

761 #: :class:`ServicePattern` are accessible thought its :attr:`ServicePattern.replacements` 

762 #: attribute. 

763 service_pattern = models.ForeignKey( 

764 ServicePattern, 

765 related_name="replacements", 

766 on_delete=models.CASCADE 

767 ) 

768 

769 def __str__(self): 

770 return u"%s %s %s" % (self.attribut, self.pattern, self.replace) 

771 

772 

773@python_2_unicode_compatible 

774class Ticket(JsonAttributes): 

775 """ 

776 Bases: :class:`JsonAttributes` 

777 

778 Generic class for a Ticket 

779 """ 

780 class Meta: 

781 abstract = True 

782 #: ForeignKey to a :class:`User`. 

783 user = models.ForeignKey(User, related_name="%(class)s", on_delete=models.CASCADE) 

784 #: A boolean. ``True`` if the ticket has been validated 

785 validate = models.BooleanField(default=False) 

786 #: The service url for the ticket 

787 service = models.TextField() 

788 #: ForeignKey to a :class:`ServicePattern`. The :class:`ServicePattern` corresponding to 

789 #: :attr:`service`. Use :meth:`ServicePattern.validate` to find it. 

790 service_pattern = models.ForeignKey( 

791 ServicePattern, 

792 related_name="%(class)s", 

793 on_delete=models.CASCADE 

794 ) 

795 #: Date of the ticket creation 

796 creation = models.DateTimeField(auto_now_add=True) 

797 #: A boolean. ``True`` if the user has just renew his authentication 

798 renew = models.BooleanField(default=False) 

799 #: A boolean. Set to :attr:`service_pattern` attribute 

800 #: :attr:`ServicePattern.single_log_out` value. 

801 single_log_out = models.BooleanField(default=False) 

802 

803 #: Max duration between ticket creation and its validation. Any validation attempt for the 

804 #: ticket after :attr:`creation` + VALIDITY will fail as if the ticket do not exists. 

805 VALIDITY = settings.CAS_TICKET_VALIDITY 

806 #: Time we keep ticket with :attr:`single_log_out` set to ``True`` before sending SingleLogOut 

807 #: requests. 

808 TIMEOUT = settings.CAS_TICKET_TIMEOUT 

809 

810 class DoesNotExist(Exception): 

811 """raised in :meth:`Ticket.get` then ticket prefix and ticket classes mismatch""" 

812 pass 

813 

814 def __str__(self): 

815 return u"Ticket-%s" % self.pk 

816 

817 @staticmethod 

818 def send_slos(queryset_list): 

819 """ 

820 Send SLO requests to each ticket of each queryset of ``queryset_list`` 

821 

822 :param list queryset_list: A list a :class:`Ticket` queryset 

823 :return: A list of possibly encoutered :class:`Exception` 

824 :rtype: list 

825 """ 

826 # sending SLO to timed-out validated tickets 

827 async_list = [] 

828 session = FuturesSession( 

829 executor=ThreadPoolExecutor(max_workers=settings.CAS_SLO_MAX_PARALLEL_REQUESTS) 

830 ) 

831 errors = [] 

832 for queryset in queryset_list: 

833 for ticket in queryset: 

834 ticket.logout(session, async_list) 

835 queryset.delete() 

836 for future in async_list: 

837 if future: # pragma: no branch (should always be true) 

838 try: 

839 future.result() 

840 except Exception as error: 

841 errors.append(error) 

842 return errors 

843 

844 @classmethod 

845 def clean_old_entries(cls): 

846 """Remove old ticket and send SLO to timed-out services""" 

847 # removing old validated ticket and non validated expired tickets 

848 cls.objects.filter( 

849 ( 

850 Q(single_log_out=False) & Q(validate=True) 

851 ) | ( 

852 Q(validate=False) & 

853 Q(creation__lt=(timezone.now() - timedelta(seconds=cls.VALIDITY))) 

854 ) 

855 ).delete() 

856 queryset = cls.objects.filter( 

857 creation__lt=(timezone.now() - timedelta(seconds=cls.TIMEOUT)) 

858 ) 

859 for error in cls.send_slos([queryset]): 

860 logger.warning("Error durring SLO %s" % error) 

861 sys.stderr.write("%r\n" % error) 

862 

863 def logout(self, session, async_list=None): 

864 """Send a SLO request to the ticket service""" 

865 # On logout invalidate the Ticket 

866 self.validate = True 

867 self.save() 

868 if self.validate and self.single_log_out: # pragma: no branch (should always be true) 

869 logger.info( 

870 "Sending SLO requests to service %s for user %s" % ( 

871 self.service, 

872 self.user.username 

873 ) 

874 ) 

875 xml = utils.logout_request(self.value) 

876 if self.service_pattern.single_log_out_callback: 

877 url = self.service_pattern.single_log_out_callback 

878 else: 

879 url = self.service 

880 async_list.append( 

881 session.post( 

882 url.encode('utf-8'), 

883 data={'logoutRequest': xml.encode('utf-8')}, 

884 timeout=settings.CAS_SLO_TIMEOUT 

885 ) 

886 ) 

887 

888 @staticmethod 

889 def get_class(ticket, classes=None): 

890 """ 

891 Return the ticket class of ``ticket`` 

892 

893 :param unicode ticket: A ticket 

894 :param list classes: Optinal arguement. A list of possible :class:`Ticket` subclasses 

895 :return: The class corresponding to ``ticket`` (:class:`ServiceTicket` or 

896 :class:`ProxyTicket` or :class:`ProxyGrantingTicket`) if found among ``classes, 

897 ``None`` otherwise. 

898 :rtype: :obj:`type` or :obj:`NoneType<types.NoneType>` 

899 """ 

900 if classes is None: # pragma: no cover (not used) 

901 classes = [ServiceTicket, ProxyTicket, ProxyGrantingTicket] 

902 for ticket_class in classes: 

903 if ticket.startswith(ticket_class.PREFIX): 

904 return ticket_class 

905 

906 def username(self): 

907 """ 

908 The username to send on ticket validation 

909 

910 :return: The value of the corresponding user attribute if 

911 :attr:`service_pattern`.user_field is set, the user username otherwise. 

912 """ 

913 if self.service_pattern.user_field and self.user.attributs.get( 

914 self.service_pattern.user_field 

915 ): 

916 username = self.user.attributs[self.service_pattern.user_field] 

917 if isinstance(username, list): 

918 # the list is not empty because we wont generate a ticket with a user_field 

919 # that evaluate to False 

920 username = username[0] 

921 else: 

922 username = self.user.username 

923 return username 

924 

925 def attributs_flat(self): 

926 """ 

927 generate attributes list for template rendering 

928 

929 :return: An list of (attribute name, attribute value) of all user attributes flatened 

930 (no nested list) 

931 :rtype: :obj:`list` of :obj:`tuple` of :obj:`unicode` 

932 """ 

933 attributes = [] 

934 for key, value in self.attributs.items(): 

935 if isinstance(value, list): 

936 for elt in value: 

937 attributes.append((key, elt)) 

938 else: 

939 attributes.append((key, value)) 

940 return attributes 

941 

942 @classmethod 

943 def get(cls, ticket, renew=False, service=None): 

944 """ 

945 Search the database for a valid ticket with provided arguments 

946 

947 :param unicode ticket: A ticket value 

948 :param bool renew: Is authentication renewal needed 

949 :param unicode service: Optional argument. The ticket service 

950 :raises Ticket.DoesNotExist: if no class is found for the ticket prefix 

951 :raises cls.DoesNotExist: if ``ticket`` value is not found in th database 

952 :return: a :class:`Ticket` instance 

953 :rtype: Ticket 

954 """ 

955 # If the method class is the ticket abstract class, search for the submited ticket 

956 # class using its prefix. Assuming ticket is a ProxyTicket or a ServiceTicket 

957 if cls == Ticket: 

958 ticket_class = cls.get_class(ticket, classes=[ServiceTicket, ProxyTicket]) 

959 # else use the method class 

960 else: 

961 ticket_class = cls 

962 # If ticket prefix is wrong, raise DoesNotExist 

963 if cls != Ticket and not ticket.startswith(cls.PREFIX): 

964 raise Ticket.DoesNotExist() 

965 if ticket_class: 

966 # search for the ticket that is not yet validated and is still valid 

967 ticket_queryset = ticket_class.objects.filter( 

968 value=ticket, 

969 validate=False, 

970 creation__gt=(timezone.now() - timedelta(seconds=ticket_class.VALIDITY)) 

971 ) 

972 # if service is specified, add it the the queryset 

973 if service is not None: 

974 ticket_queryset = ticket_queryset.filter(service=service) 

975 # only require renew if renew is True, otherwise it do not matter if renew is True 

976 # or False. 

977 if renew: 

978 ticket_queryset = ticket_queryset.filter(renew=True) 

979 # fetch the ticket ``MultipleObjectsReturned`` is never raised as the ticket value 

980 # is unique across the database 

981 ticket = ticket_queryset.get() 

982 # For ServiceTicket and Proxyticket, mark it as validated before returning 

983 if ticket_class != ProxyGrantingTicket: 

984 ticket.validate = True 

985 ticket.save() 

986 return ticket 

987 # If no class found for the ticket, raise DoesNotExist 

988 else: 

989 raise Ticket.DoesNotExist() 

990 

991 

992@python_2_unicode_compatible 

993class ServiceTicket(Ticket): 

994 """ 

995 Bases: :class:`Ticket` 

996 

997 A Service Ticket 

998 """ 

999 #: The ticket prefix used to differentiate it from other tickets types 

1000 PREFIX = settings.CAS_SERVICE_TICKET_PREFIX 

1001 #: The ticket value 

1002 value = models.CharField(max_length=255, default=utils.gen_st, unique=True) 

1003 

1004 def __str__(self): 

1005 return u"ServiceTicket-%s" % self.pk 

1006 

1007 

1008@python_2_unicode_compatible 

1009class ProxyTicket(Ticket): 

1010 """ 

1011 Bases: :class:`Ticket` 

1012 

1013 A Proxy Ticket 

1014 """ 

1015 #: The ticket prefix used to differentiate it from other tickets types 

1016 PREFIX = settings.CAS_PROXY_TICKET_PREFIX 

1017 #: The ticket value 

1018 value = models.CharField(max_length=255, default=utils.gen_pt, unique=True) 

1019 

1020 def __str__(self): 

1021 return u"ProxyTicket-%s" % self.pk 

1022 

1023 

1024@python_2_unicode_compatible 

1025class ProxyGrantingTicket(Ticket): 

1026 """ 

1027 Bases: :class:`Ticket` 

1028 

1029 A Proxy Granting Ticket 

1030 """ 

1031 #: The ticket prefix used to differentiate it from other tickets types 

1032 PREFIX = settings.CAS_PROXY_GRANTING_TICKET_PREFIX 

1033 #: ProxyGranting ticket are never validated. However, they can be used during :attr:`VALIDITY` 

1034 #: to get :class:`ProxyTicket` for :attr:`user` 

1035 VALIDITY = settings.CAS_PGT_VALIDITY 

1036 #: The ticket value 

1037 value = models.CharField(max_length=255, default=utils.gen_pgt, unique=True) 

1038 

1039 def __str__(self): 

1040 return u"ProxyGrantingTicket-%s" % self.pk 

1041 

1042 

1043@python_2_unicode_compatible 

1044class Proxy(models.Model): 

1045 """ 

1046 Bases: :class:`django.db.models.Model` 

1047 

1048 A list of proxies on :class:`ProxyTicket` 

1049 """ 

1050 class Meta: 

1051 ordering = ("-pk", ) 

1052 #: Service url of the PGT used for getting the associated :class:`ProxyTicket` 

1053 url = models.CharField(max_length=255) 

1054 #: ForeignKey to a :class:`ProxyTicket`. :class:`Proxy` instances for a 

1055 #: :class:`ProxyTicket` are accessible thought its :attr:`ProxyTicket.proxies` 

1056 #: attribute. 

1057 proxy_ticket = models.ForeignKey(ProxyTicket, related_name="proxies", on_delete=models.CASCADE) 

1058 

1059 def __str__(self): 

1060 return self.url 

1061 

1062 

1063class NewVersionWarning(models.Model): 

1064 """ 

1065 Bases: :class:`django.db.models.Model` 

1066 

1067 The last new version available version sent 

1068 """ 

1069 version = models.CharField(max_length=255) 

1070 

1071 @classmethod 

1072 def send_mails(cls): 

1073 """ 

1074 For each new django-cas-server version, if the current instance is not up to date 

1075 send one mail to ``settings.ADMINS``. 

1076 """ 

1077 if settings.CAS_NEW_VERSION_EMAIL_WARNING and settings.ADMINS: 

1078 try: 

1079 obj = cls.objects.get() 

1080 except cls.DoesNotExist: 

1081 obj = NewVersionWarning.objects.create(version=VERSION) 

1082 LAST_VERSION = utils.last_version() 

1083 if LAST_VERSION is not None and LAST_VERSION != obj.version: 

1084 if utils.decode_version(VERSION) < utils.decode_version(LAST_VERSION): 

1085 try: 

1086 send_mail( 

1087 ( 

1088 '%sA new version of django-cas-server is available' 

1089 ) % settings.EMAIL_SUBJECT_PREFIX, 

1090 u''' 

1091A new version of the django-cas-server is available. 

1092 

1093Your version: %s 

1094New version: %s 

1095 

1096Upgrade using: 

1097 * pip install -U django-cas-server 

1098 * fetching the last release on 

1099 https://github.com/nitmir/django-cas-server/ or on 

1100 https://pypi.org/project/django-cas-server/ 

1101 

1102After upgrade, do not forget to run: 

1103 * ./manage.py migrate 

1104 * ./manage.py collectstatic 

1105and to reload your wsgi server (apache2, uwsgi, gunicord, etc…) 

1106 

1107--\u0020 

1108django-cas-server 

1109'''.strip() % (VERSION, LAST_VERSION), 

1110 settings.SERVER_EMAIL, 

1111 ["%s <%s>" % admin for admin in settings.ADMINS], 

1112 fail_silently=False, 

1113 ) 

1114 obj.version = LAST_VERSION 

1115 obj.save() 

1116 except smtplib.SMTPException as error: # pragma: no cover (should not happen) 

1117 logger.error("Unable to send new version mail: %s" % error)