flask-appbuilder 5.0.2rc1__py3-none-any.whl → 5.1.0rc1__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.
- flask_appbuilder/__init__.py +1 -1
- flask_appbuilder/const.py +1 -0
- flask_appbuilder/security/manager.py +283 -2
- flask_appbuilder/security/saml/__init__.py +0 -0
- flask_appbuilder/security/saml/metadata.py +26 -0
- flask_appbuilder/security/saml/types.py +73 -0
- flask_appbuilder/security/saml/utils.py +54 -0
- flask_appbuilder/security/sqla/manager.py +11 -0
- flask_appbuilder/security/views.py +205 -1
- flask_appbuilder/templates/appbuilder/general/security/login_saml.html +45 -0
- {flask_appbuilder-5.0.2rc1.dist-info → flask_appbuilder-5.1.0rc1.dist-info}/METADATA +3 -1
- {flask_appbuilder-5.0.2rc1.dist-info → flask_appbuilder-5.1.0rc1.dist-info}/RECORD +16 -11
- {flask_appbuilder-5.0.2rc1.dist-info → flask_appbuilder-5.1.0rc1.dist-info}/LICENSE +0 -0
- {flask_appbuilder-5.0.2rc1.dist-info → flask_appbuilder-5.1.0rc1.dist-info}/WHEEL +0 -0
- {flask_appbuilder-5.0.2rc1.dist-info → flask_appbuilder-5.1.0rc1.dist-info}/entry_points.txt +0 -0
- {flask_appbuilder-5.0.2rc1.dist-info → flask_appbuilder-5.1.0rc1.dist-info}/top_level.txt +0 -0
flask_appbuilder/__init__.py
CHANGED
flask_appbuilder/const.py
CHANGED
|
@@ -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
|
|
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.
|
|
3
|
+
Version: 5.1.0rc1
|
|
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=
|
|
1
|
+
flask_appbuilder/__init__.py,sha256=lmXrKS-u2dqo1cXjtxLSCtntWDGWif_1FBuVg6-WeiE,707
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
236
|
-
flask_appbuilder-5.
|
|
237
|
-
flask_appbuilder-5.
|
|
238
|
-
flask_appbuilder-5.
|
|
239
|
-
flask_appbuilder-5.
|
|
240
|
-
flask_appbuilder-5.
|
|
240
|
+
flask_appbuilder-5.1.0rc1.dist-info/LICENSE,sha256=pZ-ajm1I_0h91a1peoZFSemkiFE8kK1H_xmV_cxlQlY,1493
|
|
241
|
+
flask_appbuilder-5.1.0rc1.dist-info/METADATA,sha256=MBUIZeUJjLK-p7NTSJ2HyHjWMXvYyta2HAi3RiCYgGs,8862
|
|
242
|
+
flask_appbuilder-5.1.0rc1.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
|
|
243
|
+
flask_appbuilder-5.1.0rc1.dist-info/entry_points.txt,sha256=h5lBK4jb4RFWW8gH7C-0tE5QvQGUA5wx5hlYOhNWy4Q,48
|
|
244
|
+
flask_appbuilder-5.1.0rc1.dist-info/top_level.txt,sha256=Dy4t809z2TNdpjQhDUMEXU8Hc61x1e6WRNaC7hG6r8M,17
|
|
245
|
+
flask_appbuilder-5.1.0rc1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
{flask_appbuilder-5.0.2rc1.dist-info → flask_appbuilder-5.1.0rc1.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|