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"})
|
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
|
// VerifySignupCode - Verifies the code for email or phone
|
||||||
func (h *AuthHandler) VerifySignupCode(c *fiber.Ctx) error {
|
func (h *AuthHandler) VerifySignupCode(c *fiber.Ctx) error {
|
||||||
var req domain.VerifySignupCodeRequest
|
var req domain.VerifySignupCodeRequest
|
||||||
@@ -459,11 +477,11 @@ func (h *AuthHandler) VerifySignupCode(c *fiber.Ctx) error {
|
|||||||
state.Verified = true
|
state.Verified = true
|
||||||
h.saveSignupState(key, state, signupStateExpiration)
|
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
|
isAffiliate := false
|
||||||
parts := strings.Split(req.Target, "@")
|
parts := strings.Split(req.Target, "@")
|
||||||
if req.Type == "email" && len(parts) == 2 {
|
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{
|
return c.JSON(fiber.Map{
|
||||||
@@ -498,7 +516,14 @@ func (h *AuthHandler) GetActiveTenants(c *fiber.Ctx) error {
|
|||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
return c.JSON([]interface{}{})
|
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
|
// 3. List and Filter Tenants
|
||||||
tenants, _, err := h.TenantService.ListTenants(c.Context(), 1000, 0, "")
|
tenants, _, err := h.TenantService.ListTenants(c.Context(), 1000, 0, "")
|
||||||
@@ -516,19 +541,8 @@ func (h *AuthHandler) GetActiveTenants(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
var results []tenantResp
|
var results []tenantResp
|
||||||
for _, t := range tenants {
|
for _, t := range tenants {
|
||||||
if t.Status != domain.TenantStatusActive {
|
// [Strict] Only allow choosing defined family company slugs
|
||||||
continue
|
if t.Status != domain.TenantStatusActive || !affiliateSlugs[strings.ToLower(t.Slug)] {
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,10 +613,10 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
domainName := parts[1]
|
domainName := parts[1]
|
||||||
|
|
||||||
// Check if this domain is registered to ANY company
|
// Check if this domain belongs to a predefined family affiliate
|
||||||
isInternal, _ := h.TenantService.IsDomainAllowed(c.Context(), domainName)
|
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 {
|
if isInternal {
|
||||||
req.AffiliationType = "AFFILIATE"
|
req.AffiliationType = "AFFILIATE"
|
||||||
slog.Info("[Signup] Forcing AffiliationType to AFFILIATE", "email", req.Email)
|
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)
|
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 != "" {
|
if req.CompanyCode != "" {
|
||||||
// [Security] Cross-check: If domain is NOT internal, they cannot provide a CompanyCode
|
// [Security] Cross-check: If domain is NOT internal, they cannot provide a CompanyCode
|
||||||
if !isInternal {
|
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.")
|
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)
|
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode)
|
||||||
if err == nil && tenant != nil {
|
if err == nil && tenant != nil {
|
||||||
if tenant.Status == domain.TenantStatusActive {
|
if tenant.Status == domain.TenantStatusActive {
|
||||||
// [Security] Final domain cross-check for the selected tenant
|
// We no longer strictly cross-check if the chosen tenant owns the email domain.
|
||||||
match := false
|
// Being an 'isInternal' (family) email is enough to join ANY family affiliate.
|
||||||
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.")
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("[Signup] Assigning tenant by manual slug", "email", req.Email, "tenant", tenant.Slug)
|
slog.Info("[Signup] Assigning tenant by manual slug", "email", req.Email, "tenant", tenant.Slug)
|
||||||
companyCode = 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.")
|
return errorJSON(c, fiber.StatusNotFound, "The specified organization code was not found.")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If no CompanyCode provided but it's an internal domain, they MUST select one
|
// If it's a family affiliate domain, they MUST select one of the family companies
|
||||||
if isInternal && req.AffiliationType == "AFFILIATE" {
|
if isInternal {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "Please select your organization.")
|
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" {
|
if tenantID == nil && req.AffiliationType == "AFFILIATE" {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "We couldn't verify your organization affiliation. Please check your choice.")
|
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 {
|
static Future<List<Map<String, dynamic>>> getActiveTenants({
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/auth/signup/tenants');
|
String? email,
|
||||||
final response = await http.get(url);
|
}) 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) {
|
if (response.statusCode == 200) {
|
||||||
final List<dynamic> data = jsonDecode(response.body);
|
final List<dynamic> list = jsonDecode(response.body);
|
||||||
return data.cast<Map<String, dynamic>>();
|
return list.cast<Map<String, dynamic>>();
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -953,7 +959,7 @@ class AuthProxyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<bool> verifySignupCode(
|
static Future<Map<String, dynamic>> verifySignupCode(
|
||||||
String target,
|
String target,
|
||||||
String type,
|
String type,
|
||||||
String code,
|
String code,
|
||||||
@@ -967,10 +973,9 @@ class AuthProxyService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final data = jsonDecode(response.body);
|
return jsonDecode(response.body);
|
||||||
return data['success'] ?? false;
|
|
||||||
}
|
}
|
||||||
return false;
|
throw Exception('Verification failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> signup({
|
static Future<void> signup({
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
bool _isEmailVerified = false;
|
bool _isEmailVerified = false;
|
||||||
bool _isPhoneVerified = false;
|
bool _isPhoneVerified = false;
|
||||||
String _affiliationType = 'GENERAL';
|
String _affiliationType = 'GENERAL';
|
||||||
|
bool _isAffiliateLocked = false;
|
||||||
String? _companyCode;
|
String? _companyCode;
|
||||||
bool _termsAccepted = false;
|
bool _termsAccepted = false;
|
||||||
bool _privacyAccepted = false;
|
bool _privacyAccepted = false;
|
||||||
@@ -72,15 +73,23 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadPolicy();
|
_loadPolicy();
|
||||||
_fetchTenants();
|
// initState에서는 _fetchTenants() 호출 제외
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _fetchTenants() async {
|
Future<void> _fetchTenants() async {
|
||||||
|
if (!_isEmailVerified) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final tenants = await AuthProxyService.getActiveTenants();
|
final tenants = await AuthProxyService.getActiveTenants(
|
||||||
|
email: _emailController.text.trim(),
|
||||||
|
);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_tenants = tenants;
|
_tenants = tenants;
|
||||||
|
if (_tenants.isNotEmpty && _affiliationType == 'AFFILIATE') {
|
||||||
|
// 목록이 있는데 아직 아무것도 선택되지 않았다면 자동 할당 가능
|
||||||
|
_companyCode ??= _tenants.first['slug'];
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -195,18 +204,32 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
final code = _emailCodeController.text.trim();
|
final code = _emailCodeController.text.trim();
|
||||||
if (code.length != 6) return;
|
if (code.length != 6) return;
|
||||||
try {
|
try {
|
||||||
final success = await AuthProxyService.verifySignupCode(
|
final res = await AuthProxyService.verifySignupCode(
|
||||||
_emailController.text.trim(),
|
_emailController.text.trim(),
|
||||||
'email',
|
'email',
|
||||||
code,
|
code,
|
||||||
);
|
);
|
||||||
if (success) {
|
if (res['success'] == true) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isEmailVerified = true;
|
_isEmailVerified = true;
|
||||||
_emailTimer?.cancel();
|
_emailTimer?.cancel();
|
||||||
_emailSeconds = 0;
|
_emailSeconds = 0;
|
||||||
_emailError = null;
|
_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 {
|
} else {
|
||||||
setState(
|
setState(
|
||||||
() => _emailError = tr('msg.userfront.signup.email.code_mismatch'),
|
() => _emailError = tr('msg.userfront.signup.email.code_mismatch'),
|
||||||
@@ -248,12 +271,12 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
final code = _phoneCodeController.text.trim();
|
final code = _phoneCodeController.text.trim();
|
||||||
if (code.length != 6) return;
|
if (code.length != 6) return;
|
||||||
try {
|
try {
|
||||||
final success = await AuthProxyService.verifySignupCode(
|
final res = await AuthProxyService.verifySignupCode(
|
||||||
_phoneController.text.trim(),
|
_phoneController.text.trim(),
|
||||||
'phone',
|
'phone',
|
||||||
code,
|
code,
|
||||||
);
|
);
|
||||||
if (success) {
|
if (res['success'] == true) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isPhoneVerified = true;
|
_isPhoneVerified = true;
|
||||||
_phoneTimer?.cancel();
|
_phoneTimer?.cancel();
|
||||||
@@ -1445,17 +1468,19 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
onChanged: (val) {
|
onChanged: _isAffiliateLocked
|
||||||
if (val == null) {
|
? null
|
||||||
return;
|
: (val) {
|
||||||
}
|
if (val == null) {
|
||||||
setState(() {
|
return;
|
||||||
_affiliationType = val;
|
}
|
||||||
if (_affiliationType == 'GENERAL') {
|
setState(() {
|
||||||
_companyCode = null;
|
_affiliationType = val;
|
||||||
}
|
if (_affiliationType == 'GENERAL') {
|
||||||
});
|
_companyCode = null;
|
||||||
},
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
),
|
),
|
||||||
AnimatedSize(
|
AnimatedSize(
|
||||||
duration: const Duration(milliseconds: 180),
|
duration: const Duration(milliseconds: 180),
|
||||||
|
|||||||
Reference in New Issue
Block a user