Coverage for cas_server/models.py: 99%

367 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-10-17 18:13 +0000

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 import timezone 

19try: 

20 from django.utils.encoding import python_2_unicode_compatible 

21 from django.utils.translation import ugettext_lazy as _ 

22except ImportError: 

23 def python_2_unicode_compatible(func): 

24 """ 

25 We use Django >= 3.0 with Python >= 3.4, we don't need Python 2 compatibility. 

26 """ 

27 return func 

28 from django.utils.translation import gettext_lazy as _ 

29from django.core.mail import send_mail 

30 

31import re 

32import sys 

33import smtplib 

34import logging 

35from datetime import timedelta 

36from concurrent.futures import ThreadPoolExecutor 

37from requests_futures.sessions import FuturesSession 

38 

39from cas_server import utils 

40from . import VERSION 

41 

42#: logger facility 

43logger = logging.getLogger(__name__) 

44 

45 

46class JsonAttributes(models.Model): 

47 """ 

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

49 

50 A base class for models storing attributes as a json 

51 """ 

52 

53 class Meta: 

54 abstract = True 

55 

56 #: The attributes json encoded 

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

58 

59 @property 

60 def attributs(self): 

61 """The attributes""" 

62 if self._attributs is not None: 

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

64 

65 @attributs.setter 

66 def attributs(self, value): 

67 """attributs property setter""" 

68 self._attributs = utils.json_encode(value) 

69 

70 

71@python_2_unicode_compatible 

72class FederatedIendityProvider(models.Model): 

73 """ 

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

75 

76 An identity provider for the federated mode 

77 """ 

78 class Meta: 

79 verbose_name = _(u"identity provider") 

80 verbose_name_plural = _(u"identity providers") 

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

82 #: it must be unique. 

83 suffix = models.CharField( 

84 max_length=30, 

85 unique=True, 

86 verbose_name=_(u"suffix"), 

87 help_text=_( 

88 u"Suffix append to backend CAS returned " 

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

90 ) 

91 ) 

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

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

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

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

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

97 cas_protocol_version = models.CharField( 

98 max_length=30, 

99 choices=[ 

100 ("1", "CAS 1.0"), 

101 ("2", "CAS 2.0"), 

102 ("3", "CAS 3.0"), 

103 ("CAS_2_SAML_1_0", "SAML 1.1") 

104 ], 

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

106 help_text=_( 

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

108 ), 

109 default="3" 

110 ) 

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

112 verbose_name = models.CharField( 

113 max_length=255, 

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

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

116 ) 

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

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

119 pos = models.IntegerField( 

120 default=100, 

121 verbose_name=_(u"position"), 

122 help_text=_( 

123 ( 

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

125 u"Identity provider are sorted using the " 

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

127 ) 

128 ) 

129 ) 

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

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

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

133 display = models.BooleanField( 

134 default=True, 

135 verbose_name=_(u"display"), 

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

137 ) 

138 

139 def __str__(self): 

140 return self.verbose_name 

141 

142 @staticmethod 

143 def build_username_from_suffix(username, suffix): 

144 """ 

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

146 

147 :param unicode username: A CAS backend returned username 

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

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

150 :rtype: unicode 

151 """ 

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

153 

154 def build_username(self, username): 

155 """ 

156 Transform backend username into federated username 

157 

158 :param unicode username: A CAS backend returned username 

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

160 :rtype: unicode 

161 """ 

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

163 

164 

165@python_2_unicode_compatible 

166class FederatedUser(JsonAttributes): 

167 """ 

168 Bases: :class:`JsonAttributes` 

169 

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

171 """ 

172 class Meta: 

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

174 verbose_name = _("Federated user") 

175 verbose_name_plural = _("Federated users") 

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

177 username = models.CharField(max_length=124) 

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

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

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

181 ticket = models.CharField(max_length=255) 

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

183 last_update = models.DateTimeField(default=timezone.now) 

184 

185 def __str__(self): 

186 return self.federated_username 

187 

188 @property 

189 def federated_username(self): 

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

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

192 

193 @classmethod 

194 def get_from_federated_username(cls, username): 

195 """ 

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

197 :rtype: :class:`FederatedUser` 

198 """ 

199 if username is None: 

200 raise cls.DoesNotExist() 

201 else: 

202 component = username.split('@') 

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

204 suffix = component[-1] 

205 try: 

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

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

208 except FederatedIendityProvider.DoesNotExist: 

209 raise cls.DoesNotExist() 

210 

211 @classmethod 

212 def clean_old_entries(cls): 

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

214 federated_users = cls.objects.filter( 

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

216 ) 

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

218 for user in federated_users: 

219 if user.federated_username not in known_users: 

220 user.delete() 

221 

222 

223class FederateSLO(models.Model): 

224 """ 

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

226 

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

228 """ 

229 class Meta: 

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

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

232 username = models.CharField(max_length=30) 

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

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

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

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

237 

238 @classmethod 

239 def clean_deleted_sessions(cls): 

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

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

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

243 federate_slo.delete() 

244 

245 

246@python_2_unicode_compatible 

247class UserAttributes(JsonAttributes): 

248 """ 

249 Bases: :class:`JsonAttributes` 

250 

251 Local cache of the user attributes, used then needed 

252 """ 

253 class Meta: 

254 verbose_name = _("User attributes cache") 

255 verbose_name_plural = _("User attributes caches") 

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

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

258 

259 def __str__(self): 

260 return self.username 

261 

262 @classmethod 

263 def clean_old_entries(cls): 

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

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

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

267 user.delete() 

268 

269 

270@python_2_unicode_compatible 

271class User(models.Model): 

272 """ 

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

274 

275 A user logged into the CAS 

276 """ 

277 class Meta: 

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

279 verbose_name = _("User") 

280 verbose_name_plural = _("Users") 

281 #: The session key of the current authenticated user 

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

283 #: The username of the current authenticated user 

284 username = models.CharField(max_length=250) 

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

286 date = models.DateTimeField(auto_now=True) 

287 #: last time the user logged 

288 last_login = models.DateTimeField(auto_now_add=True) 

289 

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

291 """ 

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

293 the corresponding :class:`FederateSLO` object. 

294 """ 

295 if settings.CAS_FEDERATE: 

296 FederateSLO.objects.filter( 

297 username=self.username, 

298 session_key=self.session_key 

299 ).delete() 

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

301 

302 @classmethod 

303 def clean_old_entries(cls): 

304 """ 

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

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

307 """ 

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

309 if settings.CAS_TGT_VALIDITY is not None: 

310 filter |= Q( 

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

312 ) 

313 users = cls.objects.filter(filter) 

314 for user in users: 

315 user.logout() 

316 users.delete() 

317 

318 @classmethod 

319 def clean_deleted_sessions(cls): 

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

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

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

323 user.logout() 

324 user.delete() 

325 

326 @property 

327 def attributs(self): 

328 """ 

329 Property. 

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

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

332 auth class with bind password check mthode). 

333 """ 

334 try: 

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

336 except NotImplementedError: 

337 try: 

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

339 attributes = user.attributs 

340 if attributes is not None: 

341 return attributes 

342 else: 

343 return {} 

344 except UserAttributes.DoesNotExist: 

345 return {} 

346 

347 def __str__(self): 

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

349 

350 def logout(self, request=None): 

351 """ 

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

353 

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

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

356 """ 

357 ticket_classes = [ProxyGrantingTicket, ServiceTicket, ProxyTicket] 

358 for error in Ticket.send_slos( 

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

360 ): 

361 logger.warning( 

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

363 self.username, 

364 error 

365 ) 

366 ) 

367 if request is not None: 

368 error = utils.unpack_nested_exception(error) 

369 messages.add_message( 

370 request, 

371 messages.WARNING, 

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

373 ) 

374 

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

376 """ 

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

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

379 authentication renewal with ``renew`` 

380 

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

382 :class:`ProxyGrantingTicket`. 

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

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

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

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

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

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

389 ``False`` otherwise. 

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

391 :rtype: :class:`ServiceTicket` or :class:`ProxyTicket` or 

392 :class:`ProxyGrantingTicket`. 

393 """ 

394 attributs = dict( 

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

396 ) 

397 replacements = dict( 

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

399 ) 

400 service_attributs = {} 

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

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

403 if key in replacements: 

404 if isinstance(value, list): 

405 for index, subval in enumerate(value): 

406 value[index] = re.sub( 

407 replacements[key][0], 

408 replacements[key][1], 

409 subval 

410 ) 

411 else: 

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

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

414 ticket = ticket_class.objects.create( 

415 user=self, 

416 attributs=service_attributs, 

417 service=service, 

418 renew=renew, 

419 service_pattern=service_pattern, 

420 single_log_out=service_pattern.single_log_out 

421 ) 

422 ticket.save() 

423 self.save() 

424 return ticket 

425 

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

427 """ 

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

429 after a Service Ticket has been generated 

430 

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

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

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

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

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

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

437 ``False`` otherwise. 

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

439 :rtype: unicode 

440 """ 

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

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

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

444 return url 

445 

446 

447class ServicePatternException(Exception): 

448 """ 

449 Bases: :class:`exceptions.Exception` 

450 

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

452 pass 

453 

454 

455class BadUsername(ServicePatternException): 

456 """ 

457 Bases: :class:`ServicePatternException` 

458 

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

460 """ 

461 pass 

462 

463 

464class BadFilter(ServicePatternException): 

465 """ 

466 Bases: :class:`ServicePatternException` 

467 

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

469 """ 

470 pass 

471 

472 

473class UserFieldNotDefined(ServicePatternException): 

474 """ 

475 Bases: :class:`ServicePatternException` 

476 

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

478 an attribut not present on this user 

479 """ 

480 pass 

481 

482 

483@python_2_unicode_compatible 

484class ServicePattern(models.Model): 

485 """ 

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

487 

488 Allowed services pattern against services are tested to 

489 """ 

490 class Meta: 

491 ordering = ("pos", ) 

492 verbose_name = _("Service pattern") 

493 verbose_name_plural = _("Services patterns") 

494 

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

496 pos = models.IntegerField( 

497 default=100, 

498 verbose_name=_(u"position"), 

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

500 ) 

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

502 name = models.CharField( 

503 max_length=255, 

504 unique=True, 

505 blank=True, 

506 null=True, 

507 verbose_name=_(u"name"), 

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

509 ) 

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

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

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

513 pattern = models.CharField( 

514 max_length=255, 

515 unique=True, 

516 verbose_name=_(u"pattern"), 

517 help_text=_( 

518 "A regular expression matching services. " 

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

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

521 ), 

522 validators=[utils.regexpr_validator] 

523 ) 

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

525 user_field = models.CharField( 

526 max_length=255, 

527 default="", 

528 blank=True, 

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

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

531 ) 

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

533 restrict_users = models.BooleanField( 

534 default=False, 

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

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

537 ) 

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

539 proxy = models.BooleanField( 

540 default=False, 

541 verbose_name=_(u"proxy"), 

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

543 ) 

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

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

546 proxy_callback = models.BooleanField( 

547 default=False, 

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

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

550 ) 

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

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

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

554 single_log_out = models.BooleanField( 

555 default=False, 

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

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

558 ) 

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

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

561 single_log_out_callback = models.CharField( 

562 max_length=255, 

563 default="", 

564 blank=True, 

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

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

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

568 ) 

569 

570 def __str__(self): 

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

572 

573 def check_user(self, user): 

574 """ 

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

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

577 

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

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

580 is not within :attr:`usernames`. 

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

582 connot be verified. 

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

584 within :attr:`User.attributs`. 

585 :return: ``True`` 

586 :rtype: bool 

587 """ 

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

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

590 raise BadUsername() 

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

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

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

594 else: 

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

596 for value in attrs: 

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

598 break 

599 else: 

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

601 logger.warning( 

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

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

604 ) 

605 ) 

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

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

608 logger.warning( 

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

610 self.user_field, 

611 user.username, 

612 self.name 

613 ) 

614 ) 

615 raise UserFieldNotDefined() 

616 return True 

617 

618 @classmethod 

619 def validate(cls, service): 

620 """ 

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

622 

623 :param unicode service: A service url 

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

625 :rtype: :class:`ServicePattern` 

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

627 ``service``. 

628 """ 

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

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

631 return service_pattern 

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

633 raise cls.DoesNotExist() 

634 

635 

636@python_2_unicode_compatible 

637class Username(models.Model): 

638 """ 

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

640 

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

642 """ 

643 #: username allowed to connect to the service 

644 value = models.CharField( 

645 max_length=255, 

646 verbose_name=_(u"username"), 

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

648 ) 

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

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

651 #: attribute. 

652 service_pattern = models.ForeignKey( 

653 ServicePattern, 

654 related_name="usernames", 

655 on_delete=models.CASCADE 

656 ) 

657 

658 def __str__(self): 

659 return self.value 

660 

661 

662@python_2_unicode_compatible 

663class ReplaceAttributName(models.Model): 

664 """ 

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

666 

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

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

669 to use the original attribute name. 

670 """ 

671 class Meta: 

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

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

674 name = models.CharField( 

675 max_length=255, 

676 verbose_name=_(u"name"), 

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

678 ) 

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

680 #: is used. 

681 replace = models.CharField( 

682 max_length=255, 

683 blank=True, 

684 verbose_name=_(u"replace"), 

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

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

687 ) 

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

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

690 #: attribute. 

691 service_pattern = models.ForeignKey( 

692 ServicePattern, 

693 related_name="attributs", 

694 on_delete=models.CASCADE 

695 ) 

696 

697 def __str__(self): 

698 if not self.replace: 

699 return self.name 

700 else: 

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

702 

703 

704@python_2_unicode_compatible 

705class FilterAttributValue(models.Model): 

706 """ 

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

708 

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

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

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

712 """ 

713 #: The name of a user attribute 

714 attribut = models.CharField( 

715 max_length=255, 

716 verbose_name=_(u"attribute"), 

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

718 ) 

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

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

721 pattern = models.CharField( 

722 max_length=255, 

723 verbose_name=_(u"pattern"), 

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

725 validators=[utils.regexpr_validator] 

726 ) 

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

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

729 #: attribute. 

730 service_pattern = models.ForeignKey( 

731 ServicePattern, 

732 related_name="filters", 

733 on_delete=models.CASCADE 

734 ) 

735 

736 def __str__(self): 

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

738 

739 

740@python_2_unicode_compatible 

741class ReplaceAttributValue(models.Model): 

742 """ 

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

744 

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

746 :class:`ServicePattern`. 

747 """ 

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

749 attribut = models.CharField( 

750 max_length=255, 

751 verbose_name=_(u"attribute"), 

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

753 ) 

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

755 pattern = models.CharField( 

756 max_length=255, 

757 verbose_name=_(u"pattern"), 

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

759 validators=[utils.regexpr_validator] 

760 ) 

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

762 replace = models.CharField( 

763 max_length=255, 

764 blank=True, 

765 verbose_name=_(u"replace"), 

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

767 ) 

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

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

770 #: attribute. 

771 service_pattern = models.ForeignKey( 

772 ServicePattern, 

773 related_name="replacements", 

774 on_delete=models.CASCADE 

775 ) 

776 

777 def __str__(self): 

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

779 

780 

781@python_2_unicode_compatible 

782class Ticket(JsonAttributes): 

783 """ 

784 Bases: :class:`JsonAttributes` 

785 

786 Generic class for a Ticket 

787 """ 

788 class Meta: 

789 abstract = True 

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

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

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

793 validate = models.BooleanField(default=False) 

794 #: The service url for the ticket 

795 service = models.TextField() 

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

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

798 service_pattern = models.ForeignKey( 

799 ServicePattern, 

800 related_name="%(class)s", 

801 on_delete=models.CASCADE 

802 ) 

803 #: Date of the ticket creation 

804 creation = models.DateTimeField(auto_now_add=True) 

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

806 renew = models.BooleanField(default=False) 

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

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

809 single_log_out = models.BooleanField(default=False) 

810 

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

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

813 VALIDITY = settings.CAS_TICKET_VALIDITY 

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

815 #: requests. 

816 TIMEOUT = settings.CAS_TICKET_TIMEOUT 

817 

818 class DoesNotExist(Exception): 

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

820 pass 

821 

822 def __str__(self): 

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

824 

825 @staticmethod 

826 def send_slos(queryset_list): 

827 """ 

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

829 

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

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

832 :rtype: list 

833 """ 

834 # sending SLO to timed-out validated tickets 

835 async_list = [] 

836 session = FuturesSession( 

837 executor=ThreadPoolExecutor(max_workers=settings.CAS_SLO_MAX_PARALLEL_REQUESTS) 

838 ) 

839 errors = [] 

840 for queryset in queryset_list: 

841 for ticket in queryset: 

842 ticket.logout(session, async_list) 

843 queryset.delete() 

844 for future in async_list: 

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

846 try: 

847 future.result() 

848 except Exception as error: 

849 errors.append(error) 

850 return errors 

851 

852 @classmethod 

853 def clean_old_entries(cls): 

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

855 # removing old validated ticket and non validated expired tickets 

856 cls.objects.filter( 

857 ( 

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

859 ) | ( 

860 Q(validate=False) & 

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

862 ) 

863 ).delete() 

864 queryset = cls.objects.filter( 

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

866 ) 

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

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

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

870 

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

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

873 # On logout invalidate the Ticket 

874 self.validate = True 

875 self.save() 

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

877 logger.info( 

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

879 self.service, 

880 self.user.username 

881 ) 

882 ) 

883 xml = utils.logout_request(self.value) 

884 if self.service_pattern.single_log_out_callback: 

885 url = self.service_pattern.single_log_out_callback 

886 else: 

887 url = self.service 

888 async_list.append( 

889 session.post( 

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

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

892 timeout=settings.CAS_SLO_TIMEOUT 

893 ) 

894 ) 

895 

896 @staticmethod 

897 def get_class(ticket, classes=None): 

898 """ 

899 Return the ticket class of ``ticket`` 

900 

901 :param unicode ticket: A ticket 

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

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

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

905 ``None`` otherwise. 

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

907 """ 

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

909 classes = [ServiceTicket, ProxyTicket, ProxyGrantingTicket] 

910 for ticket_class in classes: 

911 if ticket.startswith(ticket_class.PREFIX): 

912 return ticket_class 

913 

914 def username(self): 

915 """ 

916 The username to send on ticket validation 

917 

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

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

920 """ 

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

922 self.service_pattern.user_field 

923 ): 

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

925 if isinstance(username, list): 

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

927 # that evaluate to False 

928 username = username[0] 

929 else: 

930 username = self.user.username 

931 return username 

932 

933 def attributs_flat(self): 

934 """ 

935 generate attributes list for template rendering 

936 

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

938 (no nested list) 

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

940 """ 

941 attributes = [] 

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

943 if isinstance(value, list): 

944 for elt in value: 

945 attributes.append((key, elt)) 

946 else: 

947 attributes.append((key, value)) 

948 return attributes 

949 

950 @classmethod 

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

952 """ 

953 Search the database for a valid ticket with provided arguments 

954 

955 :param unicode ticket: A ticket value 

956 :param bool renew: Is authentication renewal needed 

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

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

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

960 :return: a :class:`Ticket` instance 

961 :rtype: Ticket 

962 """ 

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

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

965 if cls == Ticket: 

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

967 # else use the method class 

968 else: 

969 ticket_class = cls 

970 # If ticket prefix is wrong, raise DoesNotExist 

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

972 raise Ticket.DoesNotExist() 

973 if ticket_class: 

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

975 ticket_queryset = ticket_class.objects.filter( 

976 value=ticket, 

977 validate=False, 

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

979 ) 

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

981 if service is not None: 

982 ticket_queryset = ticket_queryset.filter(service=service) 

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

984 # or False. 

985 if renew: 

986 ticket_queryset = ticket_queryset.filter(renew=True) 

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

988 # is unique across the database 

989 ticket = ticket_queryset.get() 

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

991 if ticket_class != ProxyGrantingTicket: 

992 ticket.validate = True 

993 ticket.save() 

994 return ticket 

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

996 else: 

997 raise Ticket.DoesNotExist() 

998 

999 

1000@python_2_unicode_compatible 

1001class ServiceTicket(Ticket): 

1002 """ 

1003 Bases: :class:`Ticket` 

1004 

1005 A Service Ticket 

1006 """ 

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

1008 PREFIX = settings.CAS_SERVICE_TICKET_PREFIX 

1009 #: The ticket value 

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

1011 

1012 def __str__(self): 

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

1014 

1015 

1016@python_2_unicode_compatible 

1017class ProxyTicket(Ticket): 

1018 """ 

1019 Bases: :class:`Ticket` 

1020 

1021 A Proxy Ticket 

1022 """ 

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

1024 PREFIX = settings.CAS_PROXY_TICKET_PREFIX 

1025 #: The ticket value 

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

1027 

1028 def __str__(self): 

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

1030 

1031 

1032@python_2_unicode_compatible 

1033class ProxyGrantingTicket(Ticket): 

1034 """ 

1035 Bases: :class:`Ticket` 

1036 

1037 A Proxy Granting Ticket 

1038 """ 

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

1040 PREFIX = settings.CAS_PROXY_GRANTING_TICKET_PREFIX 

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

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

1043 VALIDITY = settings.CAS_PGT_VALIDITY 

1044 #: The ticket value 

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

1046 

1047 def __str__(self): 

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

1049 

1050 

1051@python_2_unicode_compatible 

1052class Proxy(models.Model): 

1053 """ 

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

1055 

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

1057 """ 

1058 class Meta: 

1059 ordering = ("-pk", ) 

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

1061 url = models.CharField(max_length=255) 

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

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

1064 #: attribute. 

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

1066 

1067 def __str__(self): 

1068 return self.url 

1069 

1070 

1071class NewVersionWarning(models.Model): 

1072 """ 

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

1074 

1075 The last new version available version sent 

1076 """ 

1077 version = models.CharField(max_length=255) 

1078 

1079 @classmethod 

1080 def send_mails(cls): 

1081 """ 

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

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

1084 """ 

1085 if settings.CAS_NEW_VERSION_EMAIL_WARNING and settings.ADMINS: 

1086 try: 

1087 obj = cls.objects.get() 

1088 except cls.DoesNotExist: 

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

1090 LAST_VERSION = utils.last_version() 

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

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

1093 try: 

1094 send_mail( 

1095 ( 

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

1097 ) % settings.EMAIL_SUBJECT_PREFIX, 

1098 u''' 

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

1100 

1101Your version: %s 

1102New version: %s 

1103 

1104Upgrade using: 

1105 * pip install -U django-cas-server 

1106 * fetching the last release on 

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

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

1109 

1110After upgrade, do not forget to run: 

1111 * ./manage.py migrate 

1112 * ./manage.py collectstatic 

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

1114 

1115--\u0020 

1116django-cas-server 

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

1118 settings.SERVER_EMAIL, 

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

1120 fail_silently=False, 

1121 ) 

1122 obj.version = LAST_VERSION 

1123 obj.save() 

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

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