flask-appbuilder 5.0.2rc2__py3-none-any.whl → 5.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  __author__ = "Daniel Vaz Gaspar"
2
- __version__ = "5.0.2rc2"
2
+ __version__ = "5.1.0"
3
3
 
4
4
  from .actions import action # noqa: F401
5
5
  from .api import ModelRestApi # noqa: F401
flask_appbuilder/const.py CHANGED
@@ -130,6 +130,7 @@ AUTH_DB = 1
130
130
  AUTH_LDAP = 2
131
131
  AUTH_REMOTE_USER = 3
132
132
  AUTH_OAUTH = 4
133
+ AUTH_SAML = 5
133
134
  """ Constants for supported authentication types """
134
135
 
135
136
  # -----------------------------------
@@ -4,9 +4,9 @@ import datetime
4
4
  import importlib
5
5
  import logging
6
6
  import re
7
- from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
7
+ from typing import Any, Callable, Dict, List, Optional, Set, Tuple, TYPE_CHECKING, Union
8
8
 
9
- from flask import current_app, Flask, g, session, url_for
9
+ from flask import current_app, Flask, g, request, session, url_for
10
10
  from flask_appbuilder.exceptions import InvalidLoginAttempt, OAuthProviderUnknown
11
11
  from flask_babel import lazy_gettext as _
12
12
  from flask_jwt_extended import current_user as current_user_jwt
@@ -28,6 +28,7 @@ from .views import (
28
28
  AuthLDAPView,
29
29
  AuthOAuthView,
30
30
  AuthRemoteUserView,
31
+ AuthSAMLView,
31
32
  PermissionModelView,
32
33
  PermissionViewModelView,
33
34
  RegisterUserModelView,
@@ -40,6 +41,7 @@ from .views import (
40
41
  UserLDAPModelView,
41
42
  UserOAuthModelView,
42
43
  UserRemoteUserModelView,
44
+ UserSAMLModelView,
43
45
  UserStatsChartView,
44
46
  ViewMenuModelView,
45
47
  )
@@ -49,6 +51,7 @@ from ..const import (
49
51
  AUTH_LDAP,
50
52
  AUTH_OAUTH,
51
53
  AUTH_REMOTE_USER,
54
+ AUTH_SAML,
52
55
  LOGMSG_ERR_SEC_ADD_REGISTER_USER,
53
56
  LOGMSG_ERR_SEC_AUTH_LDAP,
54
57
  LOGMSG_ERR_SEC_AUTH_LDAP_TLS,
@@ -59,6 +62,9 @@ from ..const import (
59
62
  PERMISSION_PREFIX,
60
63
  )
61
64
 
65
+ if TYPE_CHECKING:
66
+ from flask_appbuilder.security.saml.types import SAMLConfig, SAMLProvider
67
+
62
68
  log = logging.getLogger(__name__)
63
69
 
64
70
 
@@ -186,6 +192,11 @@ class BaseSecurityManager(AbstractSecurityManager):
186
192
  """ Override if you want your own Authentication OAuth view """
187
193
  authremoteuserview = AuthRemoteUserView
188
194
  """ Override if you want your own Authentication REMOTE_USER view """
195
+ authsamlview = AuthSAMLView
196
+ """ Override if you want your own Authentication SAML view """
197
+
198
+ usersamlmodelview = UserSAMLModelView
199
+ """ Override if you want your own user SAML view """
189
200
 
190
201
  registeruserdbview = RegisterUserDBView
191
202
  """ Override if you want your own register user db view """
@@ -519,6 +530,41 @@ class BaseSecurityManager(AbstractSecurityManager):
519
530
  def oauth_providers(self):
520
531
  return current_app.config["OAUTH_PROVIDERS"]
521
532
 
533
+ @property
534
+ def saml_providers(self) -> List["SAMLProvider"]:
535
+ return current_app.config.get("SAML_PROVIDERS", [])
536
+
537
+ @property
538
+ def saml_config(self) -> "SAMLConfig":
539
+ return current_app.config.get("SAML_CONFIG", {})
540
+
541
+ def get_saml_provider(self, name: str) -> Optional["SAMLProvider"]:
542
+ """Return a specific SAML provider by name."""
543
+ for provider in self.saml_providers:
544
+ if provider["name"] == name:
545
+ return provider
546
+ return None
547
+
548
+ def get_saml_settings(self, provider_name: str) -> "SAMLConfig":
549
+ """Build the python3-saml settings dict for a given provider.
550
+
551
+ Merges the global SAML_CONFIG with the provider-specific IdP config.
552
+ """
553
+ import copy
554
+
555
+ provider = self.get_saml_provider(provider_name)
556
+ if not provider:
557
+ raise ValueError(
558
+ f"SAML provider '{provider_name}' not found in configuration"
559
+ )
560
+
561
+ base_config = copy.deepcopy(self.saml_config)
562
+
563
+ if "idp" in provider:
564
+ base_config["idp"] = provider["idp"]
565
+
566
+ return base_config
567
+
522
568
  @property
523
569
  def is_auth_limited(self) -> bool:
524
570
  return current_app.config["AUTH_RATE_LIMITED"]
@@ -807,6 +853,9 @@ class BaseSecurityManager(AbstractSecurityManager):
807
853
  elif self.auth_type == AUTH_REMOTE_USER:
808
854
  self.user_view = self.userremoteusermodelview
809
855
  self.auth_view = self.authremoteuserview()
856
+ elif self.auth_type == AUTH_SAML:
857
+ self.user_view = self.usersamlmodelview
858
+ self.auth_view = self.authsamlview()
810
859
  self.appbuilder.add_view_no_menu(self.auth_view)
811
860
 
812
861
  # this needs to be done after the view is added, otherwise the blueprint
@@ -1448,6 +1497,238 @@ class BaseSecurityManager(AbstractSecurityManager):
1448
1497
  else:
1449
1498
  return None
1450
1499
 
1500
+ @staticmethod
1501
+ def _prepare_saml_request() -> Dict[str, Any]:
1502
+ """Prepare Flask request data in the format expected by python3-saml."""
1503
+ return {
1504
+ "https": "on" if request.scheme == "https" else "off",
1505
+ "http_host": request.host,
1506
+ "server_port": request.environ.get("SERVER_PORT", "443"),
1507
+ "script_name": request.path,
1508
+ "get_data": request.args.copy(),
1509
+ "post_data": request.form.copy(),
1510
+ "query_string": request.query_string.decode("utf-8"),
1511
+ }
1512
+
1513
+ def _get_saml_auth(self, idp: str):
1514
+ """Create a OneLogin_Saml2_Auth instance for the given IdP."""
1515
+ from onelogin.saml2.auth import OneLogin_Saml2_Auth
1516
+
1517
+ return OneLogin_Saml2_Auth(
1518
+ self._prepare_saml_request(), self.get_saml_settings(idp)
1519
+ )
1520
+
1521
+ def get_saml_login_redirect_url(self, idp: str) -> str:
1522
+ """Create a SAML authentication request and return the redirect URL.
1523
+
1524
+ :param idp: The SAML identity provider name.
1525
+ :returns: The IdP redirect URL for SSO.
1526
+ """
1527
+ return self._get_saml_auth(idp).login()
1528
+
1529
+ def get_saml_userinfo(self, idp: str) -> Optional[Dict[str, Any]]:
1530
+ """Process a SAML ACS response and return mapped user info.
1531
+
1532
+ :param idp: The SAML identity provider name.
1533
+ :returns: A dict with mapped user info, session_index, and name_id,
1534
+ or None if authentication failed.
1535
+ """
1536
+ from flask_appbuilder.security.saml.utils import map_saml_attributes
1537
+
1538
+ auth = self._get_saml_auth(idp)
1539
+ auth.process_response()
1540
+ errors = auth.get_errors()
1541
+
1542
+ if errors:
1543
+ error_reason = auth.get_last_error_reason() or ""
1544
+ # Check for issuer mismatch (multi-tab / wrong IdP scenario)
1545
+ if "issuer" in error_reason.lower():
1546
+ log.error(
1547
+ "SAML Issuer mismatch for IdP '%s'. This may happen if you "
1548
+ "initiated login with a different IdP in another tab. "
1549
+ "Error: %s",
1550
+ idp,
1551
+ error_reason,
1552
+ )
1553
+ else:
1554
+ log.error(
1555
+ "SAML ACS errors for IdP '%s': %s (reason: %s)",
1556
+ idp,
1557
+ errors,
1558
+ error_reason,
1559
+ )
1560
+ return None
1561
+
1562
+ if not auth.is_authenticated():
1563
+ return None
1564
+
1565
+ saml_attributes = auth.get_attributes()
1566
+ name_id = auth.get_nameid()
1567
+ log.debug(
1568
+ "SAML attributes from IdP '%s': %s, NameID: %s",
1569
+ idp,
1570
+ saml_attributes,
1571
+ name_id,
1572
+ )
1573
+
1574
+ provider = self.get_saml_provider(idp)
1575
+ attribute_mapping = provider.get("attribute_mapping", {})
1576
+ userinfo = map_saml_attributes(saml_attributes, attribute_mapping, name_id)
1577
+
1578
+ # Include session data needed for SLO
1579
+ userinfo["saml_name_id"] = name_id
1580
+ userinfo["saml_session_index"] = auth.get_session_index()
1581
+
1582
+ log.debug("Mapped SAML userinfo: %s", userinfo)
1583
+ return userinfo
1584
+
1585
+ def get_saml_logout_redirect_url(
1586
+ self,
1587
+ idp: str,
1588
+ name_id: Optional[str] = None,
1589
+ session_index: Optional[str] = None,
1590
+ ) -> Tuple[Optional[str], bool]:
1591
+ """Process SAML SLO or initiate a logout request.
1592
+
1593
+ Handles three cases:
1594
+ - Incoming SLO request from IdP (SAMLRequest)
1595
+ - SLO response from IdP (SAMLResponse)
1596
+ - SP-initiated logout (returns redirect URL to IdP)
1597
+
1598
+ :param idp: The SAML identity provider name.
1599
+ :param name_id: The SAML NameID for the session.
1600
+ :param session_index: The SAML session index.
1601
+ :returns: Tuple of (redirect URL or None, should_logout flag).
1602
+ The caller should call logout_user() if should_logout is True.
1603
+ """
1604
+ auth = self._get_saml_auth(idp)
1605
+ should_logout = False
1606
+
1607
+ def mark_logout():
1608
+ nonlocal should_logout
1609
+ should_logout = True
1610
+
1611
+ # Incoming SLO request from IdP
1612
+ if "SAMLRequest" in request.form or "SAMLRequest" in request.args:
1613
+ url = auth.process_slo(delete_session_cb=mark_logout)
1614
+ return url, should_logout
1615
+
1616
+ # SLO response from IdP
1617
+ if "SAMLResponse" in request.form or "SAMLResponse" in request.args:
1618
+ auth.process_slo(delete_session_cb=mark_logout)
1619
+ return None, should_logout
1620
+
1621
+ # SP-initiated logout
1622
+ return auth.logout(name_id=name_id, session_index=session_index), True
1623
+
1624
+ def _saml_calculate_user_roles(self, userinfo) -> List[str]:
1625
+ user_role_objects = set()
1626
+
1627
+ # apply AUTH_ROLES_MAPPING
1628
+ if len(self.auth_roles_mapping) > 0:
1629
+ user_role_keys = userinfo.get("role_keys", [])
1630
+ user_role_objects.update(self.get_roles_from_keys(user_role_keys))
1631
+
1632
+ # apply AUTH_USER_REGISTRATION_ROLE
1633
+ if self.auth_user_registration:
1634
+ registration_role_name = self.auth_user_registration_role
1635
+
1636
+ if self.auth_user_registration_role_jmespath:
1637
+ import jmespath
1638
+
1639
+ registration_role_name = jmespath.search(
1640
+ self.auth_user_registration_role_jmespath, userinfo
1641
+ )
1642
+
1643
+ fab_role = self.find_role(registration_role_name)
1644
+ if fab_role:
1645
+ user_role_objects.add(fab_role)
1646
+ else:
1647
+ log.warning(
1648
+ "Can't find AUTH_USER_REGISTRATION role: %s", registration_role_name
1649
+ )
1650
+
1651
+ return list(user_role_objects)
1652
+
1653
+ def auth_user_saml(self, userinfo):
1654
+ """
1655
+ Method for authenticating user with SAML.
1656
+
1657
+ :param userinfo: dict with user information extracted from SAML assertion
1658
+ (keys are the same as User model columns)
1659
+ """
1660
+ # extract the username from userinfo
1661
+ if "username" in userinfo:
1662
+ username = userinfo["username"]
1663
+ elif "email" in userinfo:
1664
+ username = userinfo["email"]
1665
+ else:
1666
+ log.error("SAML userinfo does not have username or email %s", userinfo)
1667
+ return None
1668
+
1669
+ if (username is None) or username == "":
1670
+ return None
1671
+
1672
+ # Search the DB for this user by username or email
1673
+ user = self.find_user(username=username)
1674
+ if not user and userinfo.get("email"):
1675
+ user = self.find_user(email=userinfo["email"])
1676
+
1677
+ # If user is not active, go away
1678
+ if user and (not user.is_active):
1679
+ return None
1680
+
1681
+ # If user is not registered, and not self-registration, go away
1682
+ if (not user) and (not self.auth_user_registration):
1683
+ return None
1684
+
1685
+ # Sync the user's roles and info
1686
+ if user:
1687
+ updated = False
1688
+
1689
+ if self.auth_roles_sync_at_login:
1690
+ new_roles = self._saml_calculate_user_roles(userinfo)
1691
+ if new_roles:
1692
+ user.roles = new_roles
1693
+ updated = True
1694
+ log.debug(
1695
+ "Calculated new roles for user='%s' as: %s",
1696
+ username,
1697
+ user.roles,
1698
+ )
1699
+
1700
+ # Update user info from SAML assertion
1701
+ for field in ("first_name", "last_name", "email"):
1702
+ new_val = userinfo.get(field)
1703
+ if new_val and getattr(user, field) != new_val:
1704
+ setattr(user, field, new_val)
1705
+ updated = True
1706
+
1707
+ if updated:
1708
+ self.update_user(user)
1709
+
1710
+ # If the user is new, register them
1711
+ if (not user) and self.auth_user_registration:
1712
+ user = self.add_user(
1713
+ username=username,
1714
+ first_name=userinfo.get("first_name", ""),
1715
+ last_name=userinfo.get("last_name", ""),
1716
+ email=userinfo.get("email", "") or f"{username}@email.notfound",
1717
+ role=self._saml_calculate_user_roles(userinfo),
1718
+ )
1719
+ log.debug("New SAML user registered: %s", user)
1720
+
1721
+ if not user:
1722
+ log.error("Error creating a new SAML user %s", username)
1723
+ return None
1724
+
1725
+ # LOGIN SUCCESS
1726
+ if user:
1727
+ self.update_user_auth_stat(user)
1728
+ return user
1729
+ else:
1730
+ return None
1731
+
1451
1732
  """
1452
1733
  ----------------------------------------
1453
1734
  PERMISSION ACCESS CHECK
File without changes
@@ -0,0 +1,26 @@
1
+ """SP metadata generation for SAML authentication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any, Dict
7
+
8
+ log = logging.getLogger(__name__)
9
+
10
+
11
+ def get_sp_metadata(saml_settings: Dict[str, Any]) -> str:
12
+ """
13
+ Generate SP metadata XML from SAML settings.
14
+
15
+ :param saml_settings: The python3-saml settings dict
16
+ :return: SP metadata XML string
17
+ """
18
+ from onelogin.saml2.settings import OneLogin_Saml2_Settings
19
+
20
+ settings = OneLogin_Saml2_Settings(saml_settings, sp_validation_only=True)
21
+ metadata = settings.get_sp_metadata()
22
+ errors = settings.validate_metadata(metadata)
23
+ if errors:
24
+ log.error("SP Metadata validation errors: %s", errors)
25
+ raise ValueError(f"SP Metadata validation errors: {errors}")
26
+ return metadata
@@ -0,0 +1,73 @@
1
+ """SAML TypedDict definitions for Flask-AppBuilder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List, TypedDict
6
+
7
+
8
+ class SAMLServiceBinding(TypedDict):
9
+ url: str
10
+ binding: str
11
+
12
+
13
+ class SAMLIdPConfig(TypedDict, total=False):
14
+ entityId: str
15
+ singleSignOnService: SAMLServiceBinding
16
+ singleLogoutService: SAMLServiceBinding
17
+ x509cert: str
18
+
19
+
20
+ class SAMLProvider(TypedDict, total=False):
21
+ name: str
22
+ icon: str
23
+ idp: SAMLIdPConfig
24
+ attribute_mapping: Dict[str, str]
25
+
26
+
27
+ class SAMLSPConfig(TypedDict, total=False):
28
+ entityId: str
29
+ assertionConsumerService: SAMLServiceBinding
30
+ singleLogoutService: SAMLServiceBinding
31
+ NameIDFormat: str
32
+ x509cert: str
33
+ privateKey: str
34
+
35
+
36
+ class SAMLSecurityConfig(TypedDict, total=False):
37
+ nameIdEncrypted: bool
38
+ authnRequestsSigned: bool
39
+ logoutRequestSigned: bool
40
+ logoutResponseSigned: bool
41
+ signMetadata: bool
42
+ wantMessagesSigned: bool
43
+ wantAssertionsSigned: bool
44
+ wantAssertionsEncrypted: bool
45
+ wantNameId: bool
46
+ wantNameIdEncrypted: bool
47
+ wantAttributeStatement: bool
48
+
49
+
50
+ class SAMLConfig(TypedDict, total=False):
51
+ strict: bool
52
+ debug: bool
53
+ sp: SAMLSPConfig
54
+ idp: SAMLIdPConfig
55
+ security: SAMLSecurityConfig
56
+
57
+
58
+ class SAMLFlaskRequest(TypedDict):
59
+ https: str
60
+ http_host: str
61
+ server_port: str
62
+ script_name: str
63
+ get_data: Dict[str, Any]
64
+ post_data: Dict[str, Any]
65
+ query_string: str
66
+
67
+
68
+ class SAMLUserInfo(TypedDict, total=False):
69
+ username: str
70
+ email: str
71
+ first_name: str
72
+ last_name: str
73
+ role_keys: List[str]
@@ -0,0 +1,54 @@
1
+ """SAML utility helpers for Flask-AppBuilder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ from .types import SAMLUserInfo
9
+
10
+ log = logging.getLogger(__name__)
11
+
12
+
13
+ def map_saml_attributes(
14
+ saml_attributes: Dict[str, List[str]],
15
+ attribute_mapping: Dict[str, str],
16
+ name_id: Optional[str] = None,
17
+ ) -> SAMLUserInfo:
18
+ """Map SAML assertion attributes to FAB user fields.
19
+
20
+ Args:
21
+ saml_attributes: Raw attributes from the SAML assertion.
22
+ attribute_mapping: Mapping of SAML attribute names to FAB field names.
23
+ name_id: The SAML NameID value (used as fallback for username/email).
24
+
25
+ Returns:
26
+ Dictionary with FAB user field names as keys.
27
+ """
28
+ userinfo: Dict[str, Any] = {}
29
+
30
+ for saml_attr, fab_field in attribute_mapping.items():
31
+ value = saml_attributes.get(saml_attr)
32
+ if value is None:
33
+ continue
34
+ if fab_field == "role_keys":
35
+ userinfo[fab_field] = list(value)
36
+ else:
37
+ userinfo[fab_field] = value[0] if value else ""
38
+
39
+ # Fallback to NameID if no username/email mapped
40
+ if name_id and "username" not in userinfo:
41
+ userinfo["username"] = name_id
42
+ if "@" in name_id and "email" not in userinfo:
43
+ userinfo["email"] = name_id
44
+
45
+ return userinfo
46
+
47
+
48
+ def fetch_idp_metadata(url: str) -> str:
49
+ """Fetch IdP metadata XML from a remote URL."""
50
+ import requests
51
+
52
+ resp = requests.get(url, timeout=10)
53
+ resp.raise_for_status()
54
+ return resp.text
@@ -82,6 +82,8 @@ class SecurityManager(BaseSecurityManager):
82
82
  self.useroauthmodelview.datamodel = user_datamodel
83
83
  elif self.auth_type == c.AUTH_REMOTE_USER:
84
84
  self.userremoteusermodelview.datamodel = user_datamodel
85
+ elif self.auth_type == c.AUTH_SAML:
86
+ self.usersamlmodelview.datamodel = user_datamodel
85
87
 
86
88
  if self.userstatschartview:
87
89
  self.userstatschartview.datamodel = user_datamodel
@@ -282,6 +284,15 @@ class SecurityManager(BaseSecurityManager):
282
284
 
283
285
  def update_user(self, user):
284
286
  try:
287
+ # Load existing user from DB to detect role/group changes
288
+ existing_user = self.session.get(self.user_model, user.id)
289
+
290
+ roles_changed = set(existing_user.roles) != set(user.roles)
291
+ groups_changed = set(existing_user.groups) != set(user.groups)
292
+
293
+ if roles_changed or groups_changed:
294
+ user.changed_on = datetime.utcnow() # pragma: no cover
295
+
285
296
  self.session.merge(user)
286
297
  self.session.commit()
287
298
  log.info(c.LOGMSG_INF_SEC_UPD_USER, user)
@@ -3,7 +3,17 @@ import logging
3
3
  import re
4
4
  from typing import Any, Optional
5
5
 
6
- from flask import abort, current_app, flash, g, redirect, request, session, url_for
6
+ from flask import (
7
+ abort,
8
+ current_app,
9
+ flash,
10
+ g,
11
+ make_response,
12
+ redirect,
13
+ request,
14
+ session,
15
+ url_for,
16
+ )
7
17
  from flask_appbuilder._compat import as_unicode
8
18
  from flask_appbuilder.actions import action
9
19
  from flask_appbuilder.baseviews import BaseView
@@ -21,6 +31,7 @@ from flask_appbuilder.security.forms import (
21
31
  roles_or_groups_required,
22
32
  UserInfoEdit,
23
33
  )
34
+ from flask_appbuilder.security.saml.metadata import get_sp_metadata
24
35
  from flask_appbuilder.security.utils import generate_random_string
25
36
  from flask_appbuilder.utils.base import get_safe_redirect, lazy_formatter_gettext
26
37
  from flask_appbuilder.validators import PasswordComplexityValidator
@@ -732,6 +743,199 @@ class AuthOAuthView(AuthView):
732
743
  return redirect(next_url)
733
744
 
734
745
 
746
+ class UserSAMLModelView(UserModelView):
747
+ """
748
+ View that adds SAML specifics to User view.
749
+ Override to implement your own custom view.
750
+ Then override usersamlmodelview property on SecurityManager.
751
+ """
752
+
753
+ pass
754
+
755
+
756
+ class AuthSAMLView(AuthView):
757
+ """SAML 2.0 Authentication View."""
758
+
759
+ login_template = "appbuilder/general/security/login_saml.html"
760
+
761
+ @expose("/login/")
762
+ @expose("/login/<idp>")
763
+ @no_cache
764
+ def login(self, idp: Optional[str] = None) -> WerkzeugResponse:
765
+ if g.user is not None and g.user.is_authenticated:
766
+ return redirect(self.appbuilder.get_url_for_index)
767
+
768
+ sm = self.appbuilder.sm
769
+ providers = sm.saml_providers
770
+
771
+ if idp is None:
772
+ if len(providers) == 1:
773
+ return redirect(url_for(".login", idp=providers[0]["name"]))
774
+ return self.render_template(
775
+ self.login_template,
776
+ providers=providers,
777
+ title=self.title,
778
+ appbuilder=self.appbuilder,
779
+ )
780
+
781
+ from onelogin.saml2.errors import (
782
+ OneLogin_Saml2_Error,
783
+ OneLogin_Saml2_ValidationError,
784
+ )
785
+
786
+ try:
787
+ session["saml_idp"] = idp
788
+ next_url = get_safe_redirect(request.args.get("next", ""))
789
+ if next_url and next_url != self.appbuilder.get_url_for_index:
790
+ session["saml_next"] = next_url
791
+ return redirect(sm.get_saml_login_redirect_url(idp))
792
+ except (OneLogin_Saml2_Error, OneLogin_Saml2_ValidationError) as e:
793
+ log.error("SAML error initiating login for IdP '%s': %s", idp, e)
794
+ flash(as_unicode(self.invalid_login_message), "warning")
795
+ return redirect(self.appbuilder.get_url_for_index)
796
+ except ValueError as e:
797
+ log.error("SAML configuration error for IdP '%s': %s", idp, e)
798
+ flash(as_unicode(self.invalid_login_message), "warning")
799
+ return redirect(self.appbuilder.get_url_for_index)
800
+
801
+ @expose("/saml/acs/", methods=["POST"])
802
+ @no_cache
803
+ def acs(self) -> WerkzeugResponse:
804
+ """Assertion Consumer Service - receives SAML responses from IdP."""
805
+ from onelogin.saml2.errors import (
806
+ OneLogin_Saml2_Error,
807
+ OneLogin_Saml2_ValidationError,
808
+ )
809
+
810
+ sm = self.appbuilder.sm
811
+ try:
812
+ idp = session.get("saml_idp")
813
+ if not idp:
814
+ providers = sm.saml_providers
815
+ if len(providers) == 1:
816
+ idp = providers[0]["name"]
817
+ else:
818
+ flash("Could not determine identity provider.", "warning")
819
+ return redirect(self.appbuilder.get_url_for_login)
820
+
821
+ userinfo = sm.get_saml_userinfo(idp)
822
+ if userinfo is None:
823
+ flash(as_unicode(self.invalid_login_message), "warning")
824
+ return redirect(self.appbuilder.get_url_for_login)
825
+
826
+ user = sm.auth_user_saml(userinfo)
827
+ if user is None:
828
+ flash(as_unicode(self.invalid_login_message), "warning")
829
+ return redirect(self.appbuilder.get_url_for_login)
830
+
831
+ # Calculate redirect URL before login (so failures don't leave partial state)
832
+ next_url = session.pop("saml_next", "") or ""
833
+ if next_url:
834
+ next_url = get_safe_redirect(next_url)
835
+ else:
836
+ next_url = self.appbuilder.get_url_for_index
837
+
838
+ # Store SAML session info for SLO
839
+ session["saml_name_id"] = userinfo.get("saml_name_id")
840
+ session["saml_session_index"] = userinfo.get("saml_session_index")
841
+ session["saml_idp"] = idp
842
+
843
+ # login_user is the last operation before redirect
844
+ login_user(user, remember=False)
845
+ return redirect(next_url)
846
+
847
+ except (OneLogin_Saml2_Error, OneLogin_Saml2_ValidationError) as e:
848
+ log.error("SAML validation error in ACS: %s", e)
849
+ flash(as_unicode(self.invalid_login_message), "warning")
850
+ return redirect(self.appbuilder.get_url_for_login)
851
+ except ValueError as e:
852
+ # Provider not found or config error
853
+ log.error("SAML configuration error in ACS: %s", e)
854
+ flash(as_unicode(self.invalid_login_message), "warning")
855
+ return redirect(self.appbuilder.get_url_for_login)
856
+
857
+ @expose("/saml/slo/", methods=["GET", "POST"])
858
+ def slo(self) -> WerkzeugResponse:
859
+ """Single Logout endpoint."""
860
+ from onelogin.saml2.errors import (
861
+ OneLogin_Saml2_Error,
862
+ OneLogin_Saml2_ValidationError,
863
+ )
864
+
865
+ try:
866
+ idp = session.get("saml_idp")
867
+ if not idp:
868
+ logout_user()
869
+ return redirect(self.appbuilder.get_url_for_index)
870
+
871
+ url, should_logout = self.appbuilder.sm.get_saml_logout_redirect_url(
872
+ idp,
873
+ name_id=session.get("saml_name_id"),
874
+ session_index=session.get("saml_session_index"),
875
+ )
876
+ if should_logout:
877
+ logout_user()
878
+ return redirect(url or self.appbuilder.get_url_for_index)
879
+
880
+ except (OneLogin_Saml2_Error, OneLogin_Saml2_ValidationError) as e:
881
+ log.error("SAML SLO validation error: %s", e)
882
+ logout_user()
883
+ return redirect(self.appbuilder.get_url_for_index)
884
+ except ValueError as e:
885
+ log.error("SAML SLO configuration error: %s", e)
886
+ logout_user()
887
+ return redirect(self.appbuilder.get_url_for_index)
888
+
889
+ @expose("/logout/")
890
+ def logout(self) -> WerkzeugResponse:
891
+ """Override logout to support SAML SLO."""
892
+ idp = session.get("saml_idp")
893
+ if idp:
894
+ try:
895
+ return redirect(url_for(".slo"))
896
+ except Exception:
897
+ pass
898
+ logout_user()
899
+ return redirect(
900
+ current_app.config.get(
901
+ "LOGOUT_REDIRECT_URL", self.appbuilder.get_url_for_index
902
+ )
903
+ )
904
+
905
+ @expose("/saml/metadata/")
906
+ @expose("/saml/metadata/<idp>")
907
+ def metadata(self, idp: Optional[str] = None) -> WerkzeugResponse:
908
+ """SP Metadata endpoint.
909
+
910
+ Without idp parameter, returns metadata for the first configured provider.
911
+ With idp parameter, returns metadata for that specific provider.
912
+ """
913
+ try:
914
+ sm = self.appbuilder.sm
915
+ providers = sm.saml_providers
916
+ if not providers:
917
+ abort(404)
918
+
919
+ # Use specified IdP or default to first provider
920
+ provider_name = idp if idp else providers[0]["name"]
921
+ if idp and not sm.get_saml_provider(idp):
922
+ log.warning("Metadata requested for unknown IdP: %s", idp)
923
+ abort(404)
924
+
925
+ saml_settings = sm.get_saml_settings(provider_name)
926
+ metadata_xml = get_sp_metadata(saml_settings)
927
+
928
+ resp = make_response(metadata_xml, 200)
929
+ resp.headers["Content-Type"] = "text/xml"
930
+ return resp
931
+ except ValueError as e:
932
+ log.error("SAML metadata configuration error: %s", e)
933
+ abort(404)
934
+ except Exception as e:
935
+ log.error("Error generating SP metadata: %s", e)
936
+ abort(500)
937
+
938
+
735
939
  class AuthRemoteUserView(AuthView):
736
940
  login_template = ""
737
941
 
@@ -0,0 +1,45 @@
1
+ <!-- extend base layout -->
2
+ {% extends "appbuilder/base.html" %}
3
+
4
+ {% block content %}
5
+
6
+ <div class="container">
7
+ <div id="loginbox" style="margin-top:50px;" class="mainbox col-md-6 col-md-offset-3 col-sm-8 col-sm-offset-2">
8
+ <div class="panel panel-primary">
9
+ <div class="panel-heading">
10
+ <div class="panel-title">{{ title }}</div>
11
+ </div>
12
+ <div style="padding-top:30px" class="panel-body">
13
+ <div>
14
+ {% for provider in providers %}
15
+ <a
16
+ id="btn-signin-{{provider.name}}"
17
+ class="btn btn-primary btn-block"
18
+ type="submit"
19
+ >
20
+ {{_('Sign In with ')}}{{ provider.name }}
21
+ <i id="{{provider.name}}" class="provider-select fa {{provider.get('icon', 'fa-sign-in')}} fa-1x"></i>
22
+ </a>
23
+ {% endfor %}
24
+ </div>
25
+ </div>
26
+ </div>
27
+ </div>
28
+ </div>
29
+
30
+ <script nonce="{{ baselib.get_nonce() }}">
31
+ var baseLoginUrl = {{ appbuilder.get_url_for_login | tojson }};
32
+ var nextParam = {{ request.args.get('next', '') | urlencode | tojson }};
33
+ var next = nextParam ? "?next=" + nextParam : "";
34
+
35
+ function signin(provider) {
36
+ window.location.href = baseLoginUrl + provider + next;
37
+ }
38
+
39
+ {% for provider in providers %}
40
+ document.getElementById({{ ("btn-signin-" ~ provider.name) | tojson }})
41
+ .addEventListener("click", function () { signin({{ provider.name | tojson }}) })
42
+ {% endfor %}
43
+
44
+ </script>
45
+ {% endblock %}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: flask-appbuilder
3
- Version: 5.0.2rc2
3
+ Version: 5.1.0
4
4
  Summary: Simple and rapid application development framework, built on top of Flask. includes detailed security, auto CRUD generation for your models, google charts and much more.
5
5
  Home-page: https://github.com/dpgaspar/flask-appbuilder/
6
6
  Author: Daniel Vaz Gaspar
@@ -46,6 +46,8 @@ Provides-Extra: jmespath
46
46
  Requires-Dist: jmespath >=0.9.5 ; extra == 'jmespath'
47
47
  Provides-Extra: oauth
48
48
  Requires-Dist: Authlib <2.0.0,>=0.14 ; extra == 'oauth'
49
+ Provides-Extra: saml
50
+ Requires-Dist: python3-saml >=1.15.0 ; extra == 'saml'
49
51
  Provides-Extra: talisman
50
52
  Requires-Dist: flask-talisman <2.0,>=1.0.0 ; extra == 'talisman'
51
53
 
@@ -1,11 +1,11 @@
1
- flask_appbuilder/__init__.py,sha256=kccl0QS1QRwVjpyVz6r_GntQ2KzHn4Uc357t3s5rOW8,707
1
+ flask_appbuilder/__init__.py,sha256=ETKfC8lr0iG2ejNbDVVZsObKUhRiTF3TNebyr21l34s,704
2
2
  flask_appbuilder/_compat.py,sha256=Ac71GIwVHe9Pq7i2qDsrGUgL63Vz5oeC4rjhWw0kd6g,1969
3
3
  flask_appbuilder/actions.py,sha256=-TqIH159BKCB-ezHJifbubApk2nxjospNYfH_7IgBJ8,1177
4
4
  flask_appbuilder/base.py,sha256=m9TTuOoBiU99qHfCPv27RwhVeXu4IjpS2_RzkQJvnFY,25543
5
5
  flask_appbuilder/basemanager.py,sha256=MdgZ52RhUBxKWRBxp4a2u41cUy9u7FBYWVHo6FW_RG0,342
6
6
  flask_appbuilder/baseviews.py,sha256=z97PnyNnK30MyDU6vnaBdPdZFiXJMK6gMg9oDQJpKfQ,50171
7
7
  flask_appbuilder/cli.py,sha256=2QtMfOOVzdtSRBhZKYe1W3T6oFMchoSRv3ptS3U304w,14347
8
- flask_appbuilder/const.py,sha256=D4ZZN1afeg0OYSiBVqfoFeq0n5ij62e8oYh9kiNmaP0,8378
8
+ flask_appbuilder/const.py,sha256=scpuwc53EgiT7FBbb4eiCFAc1EJn2l6UQBRK0D2xF_w,8392
9
9
  flask_appbuilder/exceptions.py,sha256=nA-l9TNFCuKt6KhS0YAF4Tn9Mf2KgNe2lBXykyChf2M,1798
10
10
  flask_appbuilder/fields.py,sha256=GyM_EjpP0HL1cpqhVjWludv3uj1VdKImM-kj5fazDk8,9328
11
11
  flask_appbuilder/fieldwidgets.py,sha256=XPQPzBsNsTOi848nK2lsQG0knkZzx9kLvujspqL83Qs,6058
@@ -49,13 +49,17 @@ flask_appbuilder/security/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJW
49
49
  flask_appbuilder/security/api.py,sha256=HcVBiiSk9gwsMGJXhvYFSVYdowNOFoDMGyN6DqZOH4s,5229
50
50
  flask_appbuilder/security/decorators.py,sha256=0qiJEN5MiSwsxE_LI2vtGkdEoqMH8fqXarlGRk9SAYo,11011
51
51
  flask_appbuilder/security/forms.py,sha256=PTJlF4-5HOOSBmfZYq_YWC5QSyEPYsvirLt4oeBgvXQ,3452
52
- flask_appbuilder/security/manager.py,sha256=hkf59yDN_yrj6hRk9PNrqqBTdlZdu4bk_WAE8A6eSFk,79414
52
+ flask_appbuilder/security/manager.py,sha256=Hd5msEYmU1_ZiQ0McZnA_vw3Kma84QMDgBnRxiqoVSM,89522
53
53
  flask_appbuilder/security/registerviews.py,sha256=cj1KDR6DiZ4WlDfIdrpvBp1Nd_VJ4gyHn6qc_A4HUTM,7512
54
54
  flask_appbuilder/security/schemas.py,sha256=keManyd6dh-FXBXGkuvVHLWh-BSYoJytv8pTZ2wTHhY,1359
55
55
  flask_appbuilder/security/utils.py,sha256=0PwDO-mB76RLPYlV9tCksChIMRq3zQgwLwiVMJ8jeiU,247
56
- flask_appbuilder/security/views.py,sha256=yABRVGT8A1Xn_c0-Cs-qlcp3VWUV3zN9Aqde8F4DczM,25570
56
+ flask_appbuilder/security/views.py,sha256=crZitKI_DOzMOTwy7Zsjh-7xDFsEQhJTijGQ0u-dpYg,33042
57
+ flask_appbuilder/security/saml/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
58
+ flask_appbuilder/security/saml/metadata.py,sha256=Vvd_OrxdBC8wcX-SJpYoqfZ-zxzgOC7S3fRWjz2g1Tc,793
59
+ flask_appbuilder/security/saml/types.py,sha256=v8EwQSMittRvXCyAWlUSo_77pWeA1XcG-CAjDGxHqOM,1591
60
+ flask_appbuilder/security/saml/utils.py,sha256=PKgFyPi8pxzfmM0h3C42qp6ro-qIdzrtihA3T9aGwEA,1550
57
61
  flask_appbuilder/security/sqla/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
58
- flask_appbuilder/security/sqla/manager.py,sha256=Pj_HsTC0dxm8P3pDGvs1YkSAgB1HjUuc_3T18uUwoV4,28515
62
+ flask_appbuilder/security/sqla/manager.py,sha256=3SZjwcKS4zVuOKipx4QcDUu57ggpbrE31sfermPs6vM,29032
59
63
  flask_appbuilder/security/sqla/models.py,sha256=Za5Ec7qRCGA9WAmwGdTzI1OZLTx_ovoH0hUYf1Q1im0,9497
60
64
  flask_appbuilder/security/sqla/apis/__init__.py,sha256=BEPT-0mp9n48tq72bZQiES80P0L8vdWYqki3kbhTr-s,512
61
65
  flask_appbuilder/security/sqla/apis/group/__init__.py,sha256=6HMo0L15Bw5dZX_1k2eh1zh-3Jg2KuDIlIy8V8R9jCU,40
@@ -167,6 +171,7 @@ flask_appbuilder/templates/appbuilder/general/security/activation.html,sha256=xE
167
171
  flask_appbuilder/templates/appbuilder/general/security/login_db.html,sha256=gJVY9kxEa_-bpxHTAJsVBCVJ2MZmlg0oVnNC18vB4bE,2955
168
172
  flask_appbuilder/templates/appbuilder/general/security/login_ldap.html,sha256=W3pPTRUwFkCKTJEDItipq0E1SvBZ92dP6wekR_qxYrc,2002
169
173
  flask_appbuilder/templates/appbuilder/general/security/login_oauth.html,sha256=-XwSh9NBm3rpd_kryjRThF_mqQTFPgiJu_zOnYGAGtM,1595
174
+ flask_appbuilder/templates/appbuilder/general/security/login_saml.html,sha256=CbIslIhPSXp4mfO32cg4ztxP9Uvufkfkeezp2H66v3w,1644
170
175
  flask_appbuilder/templates/appbuilder/general/security/register_mail.html,sha256=o40VoLzyETkSM7lW5el2Hff-gIAh3i5jMlW0d2Ay-4M,230
171
176
  flask_appbuilder/templates/appbuilder/general/security/register_oauth.html,sha256=Zl-ORB_dCbphWaXYigEGSVIBU6ZrcNwAGDPrUUXSVo4,1057
172
177
  flask_appbuilder/templates/appbuilder/general/widgets/base_list.html,sha256=Fv7FyPoLPanYh_zmFosQnibJb0jTJ4QwToqKAkBCHqU,1153
@@ -232,9 +237,9 @@ flask_appbuilder/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3
232
237
  flask_appbuilder/utils/base.py,sha256=MtrE-rqiigb45HzcJmvz3J_-22mtKXjXYNB9tQ6CcbY,2490
233
238
  flask_appbuilder/utils/legacy.py,sha256=Bz2_imi02_aOE_w7dN_0Igl_fWjD1PTLlu6gx8IMAW4,1001
234
239
  flask_appbuilder/utils/limit.py,sha256=hEwMH2k_bPiqMaaP--alPW5YaiJS21YBgRthKiv0uPo,725
235
- flask_appbuilder-5.0.2rc2.dist-info/LICENSE,sha256=pZ-ajm1I_0h91a1peoZFSemkiFE8kK1H_xmV_cxlQlY,1493
236
- flask_appbuilder-5.0.2rc2.dist-info/METADATA,sha256=vvU0b0IO8EuuFgocLFjBylmdQ1T6wHaB2pozc8s1MCw,8786
237
- flask_appbuilder-5.0.2rc2.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
238
- flask_appbuilder-5.0.2rc2.dist-info/entry_points.txt,sha256=h5lBK4jb4RFWW8gH7C-0tE5QvQGUA5wx5hlYOhNWy4Q,48
239
- flask_appbuilder-5.0.2rc2.dist-info/top_level.txt,sha256=Dy4t809z2TNdpjQhDUMEXU8Hc61x1e6WRNaC7hG6r8M,17
240
- flask_appbuilder-5.0.2rc2.dist-info/RECORD,,
240
+ flask_appbuilder-5.1.0.dist-info/LICENSE,sha256=pZ-ajm1I_0h91a1peoZFSemkiFE8kK1H_xmV_cxlQlY,1493
241
+ flask_appbuilder-5.1.0.dist-info/METADATA,sha256=DXpnxeSzXlEdpX0NY_F6vos-YOHlWZ0P8lOKWdZa3ss,8859
242
+ flask_appbuilder-5.1.0.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
243
+ flask_appbuilder-5.1.0.dist-info/entry_points.txt,sha256=h5lBK4jb4RFWW8gH7C-0tE5QvQGUA5wx5hlYOhNWy4Q,48
244
+ flask_appbuilder-5.1.0.dist-info/top_level.txt,sha256=Dy4t809z2TNdpjQhDUMEXU8Hc61x1e6WRNaC7hG6r8M,17
245
+ flask_appbuilder-5.1.0.dist-info/RECORD,,