feat: validate personal and enterprise auth tenants

This commit is contained in:
richjohnrambo
2026-09-21 21:24:25 +08:00
parent fc393cd685
commit 186cfa21c8
9 changed files with 689 additions and 53 deletions
+8
View File
@@ -152,6 +152,10 @@ type MsgClientAcc struct {
Username string `json:"username,omitempty"`
// Registration mobile number. An 11-digit mainland China number takes precedence over Username.
Phone string `json:"phone,omitempty"`
// Registration type: 0 enterprise, 1 personal. Required when creating a new account.
RegistrationType *int `json:"registration_type,omitempty"`
// Tenant code submitted by client for enterprise registration. Personal registration keeps it empty.
TenantCode string `json:"tenant_code,omitempty"`
// Deprecated compatibility field. New clients put a five-letter invitation
// code in the shared verification input and submit it as the tel cred response.
InviteCode string `json:"invite_code,omitempty"`
@@ -173,6 +177,10 @@ type MsgClientLogin struct {
Scheme string `json:"scheme,omitempty"`
// Shared secret
Secret []byte `json:"secret"`
// Login type: 0 enterprise, 1 personal. Required for password login.
LoginType *int `json:"login_type,omitempty"`
// Tenant code submitted by client for enterprise login. Personal login keeps it empty or default.
TenantCode string `json:"tenant_code,omitempty"`
// Credntials being verified (email or phone or captcha etc.)
Cred []MsgCredClient `json:"cred,omitempty"`
}
+63 -5
View File
@@ -63,6 +63,12 @@ const (
imReasonReviewUnavailable = "CONTENT_REVIEW_UNAVAILABLE"
)
const (
imDefaultTenantCode = "default"
registrationTypeEnterprise = 0
registrationTypePersonal = 1
)
func reviewIMHTTPText(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, scene, topic, text string) bool {
text = strings.TrimSpace(text)
if text == "" {
@@ -291,10 +297,11 @@ type imTenantEntryFeedbackRequest struct {
}
type imVerificationCreateRequest struct {
TenantCode string `json:"tenant_code,omitempty"`
Scene string `json:"scene"`
Phone string `json:"phone"`
Country string `json:"country,omitempty"`
TenantCode string `json:"tenant_code,omitempty"`
RegistrationType *int `json:"registration_type,omitempty"`
Scene string `json:"scene"`
Phone string `json:"phone"`
Country string `json:"country,omitempty"`
}
type imVerificationVerifyRequest struct {
@@ -979,7 +986,13 @@ func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "phone"})
return
}
tenant, ok := resolveIMHTTPTenant(wrt, req, body.TenantCode)
var tenant *types.Tenant
var ok bool
if scene == "register" {
tenant, ok = resolveIMHTTPRegistrationTenant(wrt, req, body)
} else {
tenant, ok = resolveIMHTTPTenant(wrt, req, body.TenantCode)
}
if !ok {
return
}
@@ -9891,6 +9904,51 @@ func resolveIMHTTPTenant(wrt http.ResponseWriter, req *http.Request, fallbackCod
return tenant, true
}
func resolveIMHTTPRegistrationTenant(wrt http.ResponseWriter, req *http.Request, body imVerificationCreateRequest) (*types.Tenant, bool) {
if body.RegistrationType == nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "registration_type"})
return nil, false
}
headerCode := strings.TrimSpace(req.Header.Get("X-Tenant-Code"))
bodyCode := strings.TrimSpace(body.TenantCode)
var tenantCode string
switch *body.RegistrationType {
case registrationTypePersonal:
if bodyCode != "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_code"})
return nil, false
}
if headerCode != "" && !strings.EqualFold(headerCode, imDefaultTenantCode) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_code"})
return nil, false
}
tenantCode = imDefaultTenantCode
case registrationTypeEnterprise:
if bodyCode == "" {
bodyCode = headerCode
} else if headerCode != "" && !strings.EqualFold(headerCode, bodyCode) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_code"})
return nil, false
}
if bodyCode == "" || len(bodyCode) > 64 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_code"})
return nil, false
}
if strings.EqualFold(bodyCode, imDefaultTenantCode) {
writeIMError(wrt, req, http.StatusNotFound, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
return nil, false
}
tenantCode = bodyCode
default:
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "registration_type"})
return nil, false
}
return resolveIMHTTPTenant(wrt, req, tenantCode)
}
func writeIMSuccess(wrt http.ResponseWriter, req *http.Request, data any) {
writeIMJSON(wrt, http.StatusOK, imHTTPResponse{
Code: 0,
+55 -6
View File
@@ -1736,7 +1736,7 @@ func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) {
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
bytes.NewBufferString(`{"scene":"register","registration_type":0,"tenant_code":"acme","phone":"+8613800138000"}`))
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder()
@@ -1786,7 +1786,7 @@ func TestServeIMVerificationCodesSkipsDeliveryWhenRegistrationSMSDisabled(t *tes
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
bytes.NewBufferString(`{"scene":"register","registration_type":0,"tenant_code":"acme","phone":"+8613800138000"}`))
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder()
@@ -1798,6 +1798,55 @@ func TestServeIMVerificationCodesSkipsDeliveryWhenRegistrationSMSDisabled(t *tes
}
}
func TestServeIMVerificationCodesPersonalUsesDefaultTenant(t *testing.T) {
oldTenants := store.Tenants
oldVerifications := store.Verifications
oldSender := sendIMVerificationCode
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 1, Code: imDefaultTenantCode, State: types.TenantStateActive}}
verifications := &testVerificationStore{}
store.Verifications = verifications
sendIMVerificationCode = func(_ types.TenantID, _, _, _, _ string) error {
t.Fatal("registration SMS sender must not be called for fixed-code registration")
return nil
}
defer func() {
store.Tenants = oldTenants
store.Verifications = oldVerifications
sendIMVerificationCode = oldSender
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"register","registration_type":1,"phone":"+8613800138000"}`))
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
rec := httptest.NewRecorder()
serveIMVerificationCodes(rec, req)
if rec.Code != http.StatusOK || verifications.created == nil {
t.Fatalf("status=%d body=%s created=%#v", rec.Code, rec.Body.String(), verifications.created)
}
if verifications.created.TenantID != 1 {
t.Fatalf("tenant: got %d, want 1", verifications.created.TenantID)
}
}
func TestServeIMVerificationCodesRejectsEnterpriseDefaultTenant(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"register","registration_type":0,"tenant_code":"default","phone":"+8613800138000"}`))
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
rec := httptest.NewRecorder()
serveIMVerificationCodes(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusNotFound, rec.Body.String())
}
resp := decodeIMHTTPResponse(t, rec)
if resp.Reason != imReasonTenantUnavailable {
t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonTenantUnavailable)
}
}
func TestServeIMPasswordResetCodeFindsPhoneWithoutTelCredential(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -1883,7 +1932,7 @@ func TestServeIMVerificationCodesKeepsChallengeWhenDeliveryUnavailable(t *testin
func TestServeIMVerificationCodesRequiresTenantCode(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
bytes.NewBufferString(`{"scene":"register","registration_type":0,"phone":"+8613800138000"}`))
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
rec := httptest.NewRecorder()
@@ -1922,7 +1971,7 @@ func TestServeIMVerificationCodesRateLimited(t *testing.T) {
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
bytes.NewBufferString(`{"scene":"register","registration_type":0,"tenant_code":"acme","phone":"+8613800138000"}`))
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder()
@@ -8242,12 +8291,12 @@ func TestIMMessageTypeDraftyEntities(t *testing.T) {
},
{
name: "legacy flat file_id content still works",
msg: &types.Message{Content: map[string]any{"file_id": "file-1", "mime": "image/png"}},
msg: &types.Message{Content: map[string]any{"file_id": "file-1", "mime": "image/png"}},
want: "image",
},
{
name: "legacy flat url content still works",
msg: &types.Message{Content: map[string]any{"url": "https://example.com"}},
msg: &types.Message{Content: map[string]any{"url": "https://example.com"}},
want: "link",
},
{
+6
View File
@@ -1143,6 +1143,12 @@ func (s *Session) login(msg *ClientComMessage) {
return
}
if err := validateLoginTenant(s, msg); err != nil {
logs.Warn.Println("s.login: invalid login tenant", err, s.sid)
s.queueOut(decodeStoreError(types.ErrPolicy, msg.Id, msg.Timestamp, loginTenantErrorParams(err)))
return
}
handler := store.Store.GetLogicalAuthHandler(msg.Login.Scheme)
if handler == nil {
recordSessionLoginFailure(s, msg.Login.Scheme, msg.Login.Secret, nil, msg.Id, types.ErrUnsupported, msg.Timestamp)
+60 -16
View File
@@ -19,6 +19,10 @@ type testTenantStore struct {
err error
}
func ptrInt(v int) *int {
return &v
}
type testLoginDeviceStore struct {
devices map[types.Uid][]types.DeviceDef
updates int
@@ -365,9 +369,11 @@ func TestDispatchLogin(t *testing.T) {
msg := &ClientComMessage{
Login: &MsgClientLogin{
Id: "123",
Scheme: "basic",
Secret: []byte(secret),
Id: "123",
Scheme: "basic",
Secret: []byte(secret),
LoginType: ptrInt(registrationTypeEnterprise),
TenantCode: "test",
},
}
@@ -425,7 +431,7 @@ func TestDispatchLoginRejectsAuthenticatorTenantMismatch(t *testing.T) {
s := &Session{send: make(chan any, 1), ver: 16, tenantID: 1, tenantCode: "acme"}
s.login(&ClientComMessage{
Login: &MsgClientLogin{Id: "login-1", Scheme: "basic", Secret: secret},
Login: &MsgClientLogin{Id: "login-1", Scheme: "basic", Secret: secret, LoginType: ptrInt(registrationTypeEnterprise), TenantCode: "acme"},
Timestamp: time.Now(),
})
@@ -438,6 +444,40 @@ func TestDispatchLoginRejectsAuthenticatorTenantMismatch(t *testing.T) {
}
}
func TestDispatchLoginRejectsEnterpriseDefaultTenant(t *testing.T) {
s := &Session{send: make(chan any, 1), ver: 16, tenantID: 1, tenantCode: imDefaultTenantCode}
s.login(&ClientComMessage{
Login: &MsgClientLogin{Id: "login-1", Scheme: "basic", Secret: []byte("alice:password"), LoginType: ptrInt(registrationTypeEnterprise), TenantCode: imDefaultTenantCode},
Timestamp: time.Now(),
})
resp := (<-s.send).(*ServerComMessage)
if resp.Ctrl == nil || resp.Ctrl.Code != http.StatusUnprocessableEntity {
t.Fatalf("unexpected response: %+v", resp)
}
params, _ := resp.Ctrl.Params.(map[string]any)
if params["reason"] != "TENANT_CODE_FORBIDDEN" {
t.Fatalf("unexpected params: %+v", resp.Ctrl.Params)
}
}
func TestDispatchLoginRequiresTypeForBasic(t *testing.T) {
s := &Session{send: make(chan any, 1), ver: 16, tenantID: 1, tenantCode: imDefaultTenantCode}
s.login(&ClientComMessage{
Login: &MsgClientLogin{Id: "login-1", Scheme: "basic", Secret: []byte("alice:password")},
Timestamp: time.Now(),
})
resp := (<-s.send).(*ServerComMessage)
if resp.Ctrl == nil || resp.Ctrl.Code != http.StatusUnprocessableEntity {
t.Fatalf("unexpected response: %+v", resp)
}
params, _ := resp.Ctrl.Params.(map[string]any)
if params["reason"] != "LOGIN_TYPE_REQUIRED" {
t.Fatalf("unexpected params: %+v", resp.Ctrl.Params)
}
}
func TestPublishSysRequiresRoot(t *testing.T) {
s := &Session{
send: make(chan any, 1),
@@ -1420,12 +1460,14 @@ func TestDispatchAccNew(t *testing.T) {
public := "public name"
msg := &ClientComMessage{
Acc: &MsgClientAcc{
Id: "123",
User: "newXYZ",
Scheme: "basic",
Username: "alice",
Phone: "13800138000",
Secret: []byte(secret),
Id: "123",
User: "newXYZ",
Scheme: "basic",
Username: "alice",
Phone: "13800138000",
RegistrationType: ptrInt(registrationTypeEnterprise),
TenantCode: "test",
Secret: []byte(secret),
Cred: []MsgCredClient{{
Method: "tel", Value: "+8613800138000", Response: "AbCdE",
Params: map[string]any{"challenge_id": "challenge-1"},
@@ -1540,12 +1582,14 @@ func TestDispatchAccNewUsernameOnlyDoesNotRequireInvite(t *testing.T) {
msg := &ClientComMessage{
Acc: &MsgClientAcc{
Id: "username-only",
User: "new",
Scheme: "basic",
Username: "alice",
Secret: []byte(secret),
Desc: &MsgSetDesc{Public: map[string]any{"fn": "Alice"}},
Id: "username-only",
User: "new",
Scheme: "basic",
Username: "alice",
RegistrationType: ptrInt(registrationTypeEnterprise),
TenantCode: "test",
Secret: []byte(secret),
Desc: &MsgSetDesc{Public: map[string]any{"fn": "Alice"}},
},
}
+124
View File
@@ -43,6 +43,12 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
return
}
if err := validateAccountRegistrationTenant(s, msg); err != nil {
logs.Warn.Println("create user: invalid registration tenant fields", err, "sid=", s.sid)
s.queueOut(decodeStoreError(types.ErrPolicy, msg.Id, msg.Timestamp, registrationTenantErrorParams(err)))
return
}
account, phone, secretAccount, phoneRegistration, err := resolveRegistrationAccount(msg.Acc.Username, msg.Acc.Phone)
if err != nil || !registrationSecretMatches(msg.Acc.Secret, secretAccount) {
logs.Warn.Println("create user: invalid registration account fields, sid=", s.sid)
@@ -382,6 +388,124 @@ func resolveRegistrationAccount(username, phone string) (account, canonicalPhone
return username, "", username, false, nil
}
var (
errRegistrationTypeRequired = errors.New("registration_type required")
errRegistrationTypeInvalid = errors.New("registration_type invalid")
errRegistrationTenantForbidden = errors.New("registration tenant forbidden")
errRegistrationTenantRequired = errors.New("registration tenant required")
errRegistrationTenantMismatch = errors.New("registration tenant mismatch")
errLoginTypeRequired = errors.New("login_type required")
errLoginTypeInvalid = errors.New("login_type invalid")
)
func validateAccountRegistrationTenant(s *Session, msg *ClientComMessage) error {
if msg == nil || msg.Acc == nil || msg.Acc.RegistrationType == nil {
return errRegistrationTypeRequired
}
tenantCode := strings.TrimSpace(msg.Acc.TenantCode)
switch *msg.Acc.RegistrationType {
case registrationTypePersonal:
if tenantCode != "" {
return errRegistrationTenantForbidden
}
if !strings.EqualFold(s.tenantCode, imDefaultTenantCode) {
return errRegistrationTenantMismatch
}
case registrationTypeEnterprise:
if tenantCode == "" {
return errRegistrationTenantRequired
}
if strings.EqualFold(tenantCode, imDefaultTenantCode) {
return errRegistrationTenantForbidden
}
if !strings.EqualFold(s.tenantCode, tenantCode) {
return errRegistrationTenantMismatch
}
default:
return errRegistrationTypeInvalid
}
return nil
}
func registrationTenantErrorParams(err error) map[string]any {
params := map[string]any{"what": "registration_type", "reason": "REGISTRATION_TYPE_INVALID"}
switch {
case errors.Is(err, errRegistrationTypeRequired):
params["reason"] = "REGISTRATION_TYPE_REQUIRED"
case errors.Is(err, errRegistrationTypeInvalid):
params["reason"] = "REGISTRATION_TYPE_INVALID"
case errors.Is(err, errRegistrationTenantRequired):
params["what"] = "tenant_code"
params["reason"] = "TENANT_CODE_REQUIRED"
case errors.Is(err, errRegistrationTenantForbidden):
params["what"] = "tenant_code"
params["reason"] = "TENANT_CODE_FORBIDDEN"
case errors.Is(err, errRegistrationTenantMismatch):
params["what"] = "tenant_code"
params["reason"] = "TENANT_CODE_MISMATCH"
}
return params
}
func validateLoginTenant(s *Session, msg *ClientComMessage) error {
if msg == nil || msg.Login == nil {
return nil
}
if msg.Login.Scheme != "basic" && msg.Login.LoginType == nil {
return nil
}
if msg.Login.LoginType == nil {
return errLoginTypeRequired
}
tenantCode := strings.TrimSpace(msg.Login.TenantCode)
switch *msg.Login.LoginType {
case registrationTypePersonal:
if tenantCode != "" && !strings.EqualFold(tenantCode, imDefaultTenantCode) {
return errRegistrationTenantForbidden
}
if !strings.EqualFold(s.tenantCode, imDefaultTenantCode) {
return errRegistrationTenantMismatch
}
case registrationTypeEnterprise:
if tenantCode == "" {
tenantCode = s.tenantCode
}
if tenantCode == "" {
return errRegistrationTenantRequired
}
if strings.EqualFold(tenantCode, imDefaultTenantCode) {
return errRegistrationTenantForbidden
}
if !strings.EqualFold(s.tenantCode, tenantCode) {
return errRegistrationTenantMismatch
}
default:
return errLoginTypeInvalid
}
return nil
}
func loginTenantErrorParams(err error) map[string]any {
params := map[string]any{"what": "login_type", "reason": "LOGIN_TYPE_INVALID"}
switch {
case errors.Is(err, errLoginTypeRequired):
params["reason"] = "LOGIN_TYPE_REQUIRED"
case errors.Is(err, errLoginTypeInvalid):
params["reason"] = "LOGIN_TYPE_INVALID"
case errors.Is(err, errRegistrationTenantRequired):
params["what"] = "tenant_code"
params["reason"] = "TENANT_CODE_REQUIRED"
case errors.Is(err, errRegistrationTenantForbidden):
params["what"] = "tenant_code"
params["reason"] = "TENANT_CODE_FORBIDDEN"
case errors.Is(err, errRegistrationTenantMismatch):
params["what"] = "tenant_code"
params["reason"] = "TENANT_CODE_MISMATCH"
}
return params
}
func validateRegistrationUsername(username string) error {
if username == "" || len(username) > 10 {
return types.ErrPolicy