forked from baron/baron-sso
feat(auth): lock affiliation type on frontend based on verified email domain (#500)
This commit is contained in:
@@ -406,6 +406,24 @@ func (h *AuthHandler) SendSignupSmsCode(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"message": "Verification code sent"})
|
||||
}
|
||||
|
||||
var affiliateSlugs = map[string]bool{
|
||||
"hanmac": true,
|
||||
"saman": true,
|
||||
"ptc": true,
|
||||
"jangheon": true,
|
||||
"baron": true,
|
||||
"halla": true,
|
||||
}
|
||||
|
||||
func (h *AuthHandler) isAffiliateTenant(ctx context.Context, domainName string) (bool, *domain.Tenant) {
|
||||
tenant, err := h.TenantService.GetTenantByDomain(ctx, domainName)
|
||||
if err != nil || tenant == nil {
|
||||
return false, nil
|
||||
}
|
||||
// [Strict] Check if the slug belongs to the predefined family company slugs
|
||||
return affiliateSlugs[strings.ToLower(tenant.Slug)], tenant
|
||||
}
|
||||
|
||||
// VerifySignupCode - Verifies the code for email or phone
|
||||
func (h *AuthHandler) VerifySignupCode(c *fiber.Ctx) error {
|
||||
var req domain.VerifySignupCodeRequest
|
||||
@@ -459,11 +477,11 @@ func (h *AuthHandler) VerifySignupCode(c *fiber.Ctx) error {
|
||||
state.Verified = true
|
||||
h.saveSignupState(key, state, signupStateExpiration)
|
||||
|
||||
// [New] Check if this is an affiliate domain to let frontend lock the choice
|
||||
// [New] Check if this is a family affiliate domain to let frontend lock the choice
|
||||
isAffiliate := false
|
||||
parts := strings.Split(req.Target, "@")
|
||||
if req.Type == "email" && len(parts) == 2 {
|
||||
isAffiliate, _ = h.TenantService.IsDomainAllowed(c.Context(), parts[1])
|
||||
isAffiliate, _ = h.isAffiliateTenant(c.Context(), parts[1])
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
@@ -498,7 +516,14 @@ func (h *AuthHandler) GetActiveTenants(c *fiber.Ctx) error {
|
||||
if len(parts) != 2 {
|
||||
return c.JSON([]interface{}{})
|
||||
}
|
||||
domainFilter := parts[1]
|
||||
domainName := parts[1]
|
||||
|
||||
// [Policy] Verify if the email belongs to any family affiliate domain
|
||||
isInternal, _ := h.isAffiliateTenant(c.Context(), domainName)
|
||||
if !isInternal {
|
||||
// If not an affiliate email, do not show any tenants
|
||||
return c.JSON([]interface{}{})
|
||||
}
|
||||
|
||||
// 3. List and Filter Tenants
|
||||
tenants, _, err := h.TenantService.ListTenants(c.Context(), 1000, 0, "")
|
||||
@@ -516,19 +541,8 @@ func (h *AuthHandler) GetActiveTenants(c *fiber.Ctx) error {
|
||||
|
||||
var results []tenantResp
|
||||
for _, t := range tenants {
|
||||
if t.Status != domain.TenantStatusActive {
|
||||
continue
|
||||
}
|
||||
|
||||
// Strictly filter by the domain of the verified email
|
||||
match := false
|
||||
for _, d := range t.Domains {
|
||||
if strings.EqualFold(d.Domain, domainFilter) {
|
||||
match = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !match {
|
||||
// [Strict] Only allow choosing defined family company slugs
|
||||
if t.Status != domain.TenantStatusActive || !affiliateSlugs[strings.ToLower(t.Slug)] {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -599,10 +613,10 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
||||
}
|
||||
domainName := parts[1]
|
||||
|
||||
// Check if this domain is registered to ANY company
|
||||
isInternal, _ := h.TenantService.IsDomainAllowed(c.Context(), domainName)
|
||||
// Check if this domain belongs to a predefined family affiliate
|
||||
isInternal, _ := h.isAffiliateTenant(c.Context(), domainName)
|
||||
|
||||
// [Strict Policy] Force AffiliationType based on domain (User cannot choose)
|
||||
// [Strict Policy] Force AffiliationType based on predefined family slugs (User cannot choose)
|
||||
if isInternal {
|
||||
req.AffiliationType = "AFFILIATE"
|
||||
slog.Info("[Signup] Forcing AffiliationType to AFFILIATE", "email", req.Email)
|
||||
@@ -611,7 +625,7 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
||||
slog.Info("[Signup] Forcing AffiliationType to GENERAL", "email", req.Email)
|
||||
}
|
||||
|
||||
// If user provided a CompanyCode, verify it exists and is active
|
||||
// If user provided a CompanyCode, verify it exists and is a family affiliate
|
||||
if req.CompanyCode != "" {
|
||||
// [Security] Cross-check: If domain is NOT internal, they cannot provide a CompanyCode
|
||||
if !isInternal {
|
||||
@@ -619,21 +633,16 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusForbidden, "Only affiliate members can join an organization.")
|
||||
}
|
||||
|
||||
// Verify the selected company code exists and is indeed a family company
|
||||
if !affiliateSlugs[strings.ToLower(req.CompanyCode)] {
|
||||
return errorJSON(c, fiber.StatusForbidden, "The selected organization is not a valid family affiliate.")
|
||||
}
|
||||
|
||||
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode)
|
||||
if err == nil && tenant != nil {
|
||||
if tenant.Status == domain.TenantStatusActive {
|
||||
// [Security] Final domain cross-check for the selected tenant
|
||||
match := false
|
||||
for _, d := range tenant.Domains {
|
||||
if strings.EqualFold(d.Domain, domainName) {
|
||||
match = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !match {
|
||||
slog.Warn("[Signup] Domain mismatch for selected organization", "email", req.Email, "targetTenant", tenant.Slug)
|
||||
return errorJSON(c, fiber.StatusForbidden, "Your email domain does not match the selected organization.")
|
||||
}
|
||||
// We no longer strictly cross-check if the chosen tenant owns the email domain.
|
||||
// Being an 'isInternal' (family) email is enough to join ANY family affiliate.
|
||||
|
||||
slog.Info("[Signup] Assigning tenant by manual slug", "email", req.Email, "tenant", tenant.Slug)
|
||||
companyCode = tenant.Slug
|
||||
@@ -646,16 +655,12 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusNotFound, "The specified organization code was not found.")
|
||||
}
|
||||
} else {
|
||||
// If no CompanyCode provided but it's an internal domain, they MUST select one
|
||||
if isInternal && req.AffiliationType == "AFFILIATE" {
|
||||
// If it's a family affiliate domain, they MUST select one of the family companies
|
||||
if isInternal {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "Please select your organization.")
|
||||
}
|
||||
|
||||
// If they chose GENERAL even with internal email, or it's a completely external domain
|
||||
// Here we might assign a "GENERAL" pool or a personal tenant.
|
||||
}
|
||||
|
||||
// [Strict] If no tenantID but trying to be an affiliate, it's an error
|
||||
if tenantID == nil && req.AffiliationType == "AFFILIATE" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "We couldn't verify your organization affiliation. Please check your choice.")
|
||||
}
|
||||
|
||||
@@ -923,13 +923,19 @@ class AuthProxyService {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> getActiveTenants() async {
|
||||
final url = Uri.parse('$_baseUrl/api/v1/auth/signup/tenants');
|
||||
final response = await http.get(url);
|
||||
static Future<List<Map<String, dynamic>>> getActiveTenants({
|
||||
String? email,
|
||||
}) async {
|
||||
var uriString = '$_baseUrl/api/v1/auth/signup/tenants';
|
||||
if (email != null && email.isNotEmpty) {
|
||||
uriString += '?email=${Uri.encodeComponent(email)}';
|
||||
}
|
||||
|
||||
final url = Uri.parse(uriString);
|
||||
final response = await http.get(url);
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> data = jsonDecode(response.body);
|
||||
return data.cast<Map<String, dynamic>>();
|
||||
final List<dynamic> list = jsonDecode(response.body);
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -953,7 +959,7 @@ class AuthProxyService {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> verifySignupCode(
|
||||
static Future<Map<String, dynamic>> verifySignupCode(
|
||||
String target,
|
||||
String type,
|
||||
String code,
|
||||
@@ -967,10 +973,9 @@ class AuthProxyService {
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
return data['success'] ?? false;
|
||||
return jsonDecode(response.body);
|
||||
}
|
||||
return false;
|
||||
throw Exception('Verification failed');
|
||||
}
|
||||
|
||||
static Future<void> signup({
|
||||
|
||||
@@ -44,6 +44,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
bool _isEmailVerified = false;
|
||||
bool _isPhoneVerified = false;
|
||||
String _affiliationType = 'GENERAL';
|
||||
bool _isAffiliateLocked = false;
|
||||
String? _companyCode;
|
||||
bool _termsAccepted = false;
|
||||
bool _privacyAccepted = false;
|
||||
@@ -72,15 +73,23 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPolicy();
|
||||
_fetchTenants();
|
||||
// initState에서는 _fetchTenants() 호출 제외
|
||||
}
|
||||
|
||||
Future<void> _fetchTenants() async {
|
||||
if (!_isEmailVerified) return;
|
||||
|
||||
try {
|
||||
final tenants = await AuthProxyService.getActiveTenants();
|
||||
final tenants = await AuthProxyService.getActiveTenants(
|
||||
email: _emailController.text.trim(),
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_tenants = tenants;
|
||||
if (_tenants.isNotEmpty && _affiliationType == 'AFFILIATE') {
|
||||
// 목록이 있는데 아직 아무것도 선택되지 않았다면 자동 할당 가능
|
||||
_companyCode ??= _tenants.first['slug'];
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -195,18 +204,32 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
final code = _emailCodeController.text.trim();
|
||||
if (code.length != 6) return;
|
||||
try {
|
||||
final success = await AuthProxyService.verifySignupCode(
|
||||
final res = await AuthProxyService.verifySignupCode(
|
||||
_emailController.text.trim(),
|
||||
'email',
|
||||
code,
|
||||
);
|
||||
if (success) {
|
||||
if (res['success'] == true) {
|
||||
setState(() {
|
||||
_isEmailVerified = true;
|
||||
_emailTimer?.cancel();
|
||||
_emailSeconds = 0;
|
||||
_emailError = null;
|
||||
|
||||
if (res['isAffiliate'] == true) {
|
||||
_affiliationType = 'AFFILIATE';
|
||||
_isAffiliateLocked = true;
|
||||
} else {
|
||||
_affiliationType = 'GENERAL';
|
||||
_companyCode = null;
|
||||
_isAffiliateLocked = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Only fetch tenants if it's an affiliate domain
|
||||
if (res['isAffiliate'] == true) {
|
||||
_fetchTenants();
|
||||
}
|
||||
} else {
|
||||
setState(
|
||||
() => _emailError = tr('msg.userfront.signup.email.code_mismatch'),
|
||||
@@ -248,12 +271,12 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
final code = _phoneCodeController.text.trim();
|
||||
if (code.length != 6) return;
|
||||
try {
|
||||
final success = await AuthProxyService.verifySignupCode(
|
||||
final res = await AuthProxyService.verifySignupCode(
|
||||
_phoneController.text.trim(),
|
||||
'phone',
|
||||
code,
|
||||
);
|
||||
if (success) {
|
||||
if (res['success'] == true) {
|
||||
setState(() {
|
||||
_isPhoneVerified = true;
|
||||
_phoneTimer?.cancel();
|
||||
@@ -1445,17 +1468,19 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (val) {
|
||||
if (val == null) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_affiliationType = val;
|
||||
if (_affiliationType == 'GENERAL') {
|
||||
_companyCode = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
onChanged: _isAffiliateLocked
|
||||
? null
|
||||
: (val) {
|
||||
if (val == null) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_affiliationType = val;
|
||||
if (_affiliationType == 'GENERAL') {
|
||||
_companyCode = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
|
||||
Reference in New Issue
Block a user