1
0
forked from baron/baron-sso

feat(auth): lock affiliation type on frontend based on verified email domain (#500)

This commit is contained in:
2026-04-07 13:01:30 +09:00
parent 4e7f3e7235
commit b3a7f47cf7
3 changed files with 99 additions and 64 deletions

View File

@@ -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.")
}

View File

@@ -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({

View File

@@ -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),