Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions backend/src/zango/api/app_auth/flows/v1/password.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,9 +233,9 @@ def handle(self, request, *args, **kwargs):
return super().handle(request, *args, **kwargs)

def post(self, request, *args, **kwargs):
# catpcha_check, msg = self.verify_captcha(request, *args, **kwargs)
# if not catpcha_check:
# return get_api_response(success=False, response_content=msg, status=400)
catpcha_check, msg = self.verify_captcha(request, *args, **kwargs)
if not catpcha_check:
return get_api_response(success=False, response_content=msg, status=400)
resp = super().post(request, *args, **kwargs)
data = json.loads(resp.content.decode("utf-8"))
password_policy = get_auth_priority(policy="password_policy", request=request)
Expand Down Expand Up @@ -295,10 +295,11 @@ def post(self, request, *args, **kwargs):
"is_pending": True,
"metadata": {"login_methods": login_methods},
}
success = resp.status_code in (200, 401)
# Normalize successful password reset responses to HTTP 200.
status = 200 if success else resp.status_code
return get_api_response(
success=True
if resp.status_code == 200 or resp.status_code == 401
else False,
success=success,
response_content=resp_data,
status=resp.status_code,
status=status,
)
59 changes: 52 additions & 7 deletions backend/src/zango/api/app_auth/profile/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,54 @@
from zango.core.utils import get_auth_priority, get_datetime_str_in_tenant_timezone


def _get_twofa_can_enable(user, auth_config, tenant):
"""
Returns (can_enable, issue_message) for enabling 2FA.
Checks that a cross-channel 2FA method is available:
the required 2FA channel must not be the same as the user's login channel,
must have its config key set on the tenant, and the user must have
the corresponding contact info.
"""
twofa_config = auth_config.get("two_factor_auth", {})
login_methods = getattr(tenant, "auth_config", {}).get("login_methods", {})

otp = login_methods.get("otp", {})
password = login_methods.get("password", {})

otp_enabled = otp.get("enabled", False)
otp_methods = otp.get("allowed_methods", [])

email_is_login = (otp_enabled and "email" in otp_methods) or (
password.get("enabled", False) and "email" in password.get("allowed_usernames", [])
)
sms_is_login = otp_enabled and "sms" in otp_methods

if email_is_login and not sms_is_login:
required_channels = ["sms"]
elif sms_is_login and not email_is_login:
required_channels = ["email"]
else:
# both channels used for login, SSO-only, or password-only — require both
required_channels = ["sms", "email"]

issues = []
for channel in required_channels:
if channel == "sms":
if not twofa_config.get("sms_config_key"):
issues.append("SMS two-factor authentication is not configured.")
elif not user.mobile:
issues.append("Your mobile number is required to enable two-factor authentication.")
elif channel == "email":
if not twofa_config.get("email_config_key"):
issues.append("Email two-factor authentication is not configured.")
elif not user.email:
issues.append("Your email is required to enable two-factor authentication.")

if issues:
return False, " ".join(issues)
return True, None


class ProfileSerializer(serializers.ModelSerializer):
profile_pic = serializers.SerializerMethodField()
roles = UserRoleSerializerModel(many=True)
Expand Down Expand Up @@ -58,13 +106,10 @@ def to_representation(self, instance):
break
auth_config["two_factor_auth"]["required"] = twofa_enabled
if not twofa_enabled:
if not self.instance.mobile or not self.instance.email:
auth_config["two_factor_auth"]["can_enable"] = False
auth_config["two_factor_auth"]["issue"] = (
"Mobile number and email are required to enable two-factor authentication."
)
else:
auth_config["two_factor_auth"]["can_enable"] = True
can_enable, issue = _get_twofa_can_enable(self.instance, auth_config, tenant)
auth_config["two_factor_auth"]["can_enable"] = can_enable
if issue:
auth_config["two_factor_auth"]["issue"] = issue
else:
can_disable_twofa = True
try:
Expand Down
3 changes: 3 additions & 0 deletions backend/src/zango/api/platform/tenancy/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ def get_date_format_display(self, obj):
return obj.get_date_format_display()

def validate_auth_config(self, value: TenantAuthConfigSchema):
reset = value.get("password_policy", {}).get("reset", {})
if reset.get("by_code") and reset.get("by_email"):
reset["by_email"] = False
return value

def update(self, instance, validated_data):
Expand Down

Large diffs are not rendered by default.

19 changes: 17 additions & 2 deletions backend/src/zango/core/api/mixin.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import requests

from django.conf import settings
Expand All @@ -13,11 +14,25 @@ def get_tenant(self, **kwargs):

class CaptchaMixin:
def verify_captcha(self, request, *args, **kwargs):
# Support both DRF Request (.data) and plain Django HttpRequest.
recaptcha_response = None
if hasattr(request, "data"):
recaptcha_response = request.data.get("g-recaptcha-response")
if not recaptcha_response:
recaptcha_response = request.POST.get("g-recaptcha-response")
if not recaptcha_response and request.body:
try:
recaptcha_response = json.loads(request.body).get(
"g-recaptcha-response"
)
except Exception:
recaptcha_response = None

r = requests.post(
"https://www.google.com/recaptcha/api/siteverify",
data={
"secret": settings.RECAPTCHA_SECRET_KEY,
"response": request.data["g-recaptcha-response"],
"secret": settings.RECAPTCHA_PRIVATE_KEY,
"response": recaptcha_response,
},
)

Expand Down
10 changes: 9 additions & 1 deletion backend/src/zango/core/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,15 @@ def get_current_request_url(request, domain=None):
# Get the hostname (domain) from the request.
if not domain:
domain = request.get_host()
if not secure:

def _domain_has_port(host: str) -> bool:
# If there's exactly one colon and the suffix is numeric, treat as host:port.
if host.count(":") == 1:
_, maybe_port = host.rsplit(":", 1)
return maybe_port.isdigit()
return False

if not secure and not _domain_has_port(str(domain)):
port = request.META.get("SERVER_PORT", "")
if (protocol == "http" and port == "80") or (
protocol == "https" and port == "443"
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/metadata.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"buildMajor":0,"buildMinor":3,"buildPatch":7,"buildTag":""}
{"buildMajor":0,"buildMinor":3,"buildPatch":8,"buildTag":""}
Loading
Loading