feat: complete registration invitation and auth redesign

This commit is contained in:
richjohnrambo
2026-08-19 00:30:11 +08:00
parent e063e7879e
commit 219ecadf4c
47 changed files with 9014 additions and 224 deletions
+117 -15
View File
@@ -37,6 +37,7 @@ const (
imReasonChallengeExpired = "CHALLENGE_EXPIRED"
imReasonCodeInvalid = "CODE_INVALID"
imReasonTooManyAttempts = "TOO_MANY_ATTEMPTS"
imReasonSMSDeliveryFailed = "SMS_DELIVERY_FAILED"
imReasonPasswordPolicy = "PASSWORD_POLICY_VIOLATION"
imReasonAuthRequired = "AUTH_REQUIRED"
imReasonRealNameRequired = "REALNAME_REQUIRED"
@@ -51,7 +52,6 @@ const (
const (
imVerificationCodeLength = 6
imVerificationTestCode = "123456"
imVerificationExpiresInSeconds = 300
imVerificationResendAfterSeconds = 60
imVerificationMaxAttempts = 5
@@ -523,6 +523,7 @@ func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) {
return
}
endpoints := imPublicEndpoints(req)
writeIMSuccess(wrt, req, map[string]any{
"tenant": map[string]any{
"code": tenant.Code,
@@ -530,11 +531,7 @@ func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) {
"tenant_desc": tenant.TenantDesc,
"branding": map[string]any{},
},
"endpoints": map[string]any{
"websocket": strings.TrimRight(globals.servingAt, "/") + "/v0/channels",
"api": strings.TrimRight(globals.servingAt, "/") + "/v1/im",
"file": strings.TrimRight(globals.servingAt, "/") + "/v0/file",
},
"endpoints": endpoints,
"auth_methods": []string{"password", "sms"},
"password_policy": map[string]any{
"min_length": 8,
@@ -544,6 +541,75 @@ func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) {
})
}
func imPublicEndpoints(req *http.Request) map[string]any {
base := imPublicHTTPBase(req)
wsBase := base
if strings.HasPrefix(wsBase, "https://") {
wsBase = "wss://" + strings.TrimPrefix(wsBase, "https://")
} else if strings.HasPrefix(wsBase, "http://") {
wsBase = "ws://" + strings.TrimPrefix(wsBase, "http://")
}
return map[string]any{
"websocket": strings.TrimRight(wsBase, "/") + "/v0/channels",
"api": strings.TrimRight(base, "/") + "/v1/im",
"file": strings.TrimRight(base, "/") + "/v0/file",
}
}
func imPublicHTTPBase(req *http.Request) string {
configured := strings.TrimRight(strings.TrimSpace(globals.servingAt), "/")
parsed, _ := url.Parse(configured)
basePath := ""
if parsed != nil {
basePath = strings.TrimRight(parsed.Path, "/")
if parsed.Scheme != "" && isPublicEndpointHost(parsed.Host) {
return strings.TrimRight(parsed.Scheme+"://"+parsed.Host+basePath, "/")
}
}
if basePath == "" && req != nil && req.URL != nil {
if idx := strings.Index(req.URL.Path, "/v1/im/"); idx > 0 {
basePath = strings.TrimRight(req.URL.Path[:idx], "/")
}
}
scheme := "http"
if req != nil && req.TLS != nil {
scheme = "https"
}
if req != nil {
if forwarded := firstForwardedHeader(req.Header.Get("X-Forwarded-Proto")); forwarded == "http" || forwarded == "https" {
scheme = forwarded
}
}
host := ""
if req != nil {
host = firstForwardedHeader(req.Header.Get("X-Forwarded-Host"))
if host == "" {
host = strings.TrimSpace(req.Host)
}
}
if host == "" && parsed != nil {
host = parsed.Host
}
if host == "" {
return configured
}
return strings.TrimRight(scheme+"://"+host+basePath, "/")
}
func isPublicEndpointHost(host string) bool {
host = strings.TrimSpace(host)
return host != "" && !strings.HasPrefix(host, ":") && !strings.HasPrefix(host, "0.0.0.0:")
}
func firstForwardedHeader(value string) string {
if idx := strings.Index(value, ","); idx >= 0 {
value = value[:idx]
}
return strings.TrimSpace(strings.ToLower(value))
}
func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
@@ -598,8 +664,12 @@ func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
// Temporary fixed code for app integration until the SMS provider is connected.
code := imVerificationTestCode
code, err := randomIMNumericCode(imVerificationCodeLength)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
deliveryEnabled := scene != "register" || registrationSMSEnabled(now)
vc := &types.VerificationCode{
TenantID: tenant.ID,
ChallengeID: challengeID,
@@ -613,8 +683,8 @@ func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) {
ResendAfter: now.Add(imVerificationResendAfterSeconds * time.Second),
}
if scene == "reset_password" {
if uid, err := store.Users.GetByCred(tenant.ID, "tel", phone); err != nil {
logs.Warn.Println("im verification create: credential lookup failed", tenant.Code, err)
if uid, err := store.Users.GetByPhone(tenant.ID, phone); err != nil {
logs.Warn.Println("im verification create: phone lookup failed", tenant.Code, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
} else {
@@ -626,8 +696,18 @@ func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if deliveryEnabled {
if err := sendIMVerificationCode(tenant.ID, phone, "", scene, code); err != nil {
_ = store.Verifications.Fail(tenant.ID, vc.ChallengeID, types.VerificationStateExpired)
logs.Warn.Println("im verification delivery failed:", tenant.Code, scene, vc.PhoneMask, err)
writeIMError(wrt, req, http.StatusBadGateway, 30033, imReasonSMSDeliveryFailed, "SMS delivery failed", nil)
return
}
} else {
logs.Info.Println("im verification delivery suppressed:", tenant.Code, scene, vc.PhoneMask)
}
logs.Info.Println("im verification code generated:", tenant.Code, scene, vc.PhoneMask)
logs.Info.Println("im verification challenge created:", tenant.Code, scene, vc.PhoneMask)
writeIMSuccess(wrt, req, map[string]any{
"challenge_id": vc.ChallengeID,
"phone_mask": vc.PhoneMask,
@@ -794,7 +874,7 @@ func serveIMPasswordResetConfirm(wrt http.ResponseWriter, req *http.Request) {
writeIMSuccess(wrt, req, map[string]any{"status": "ok"})
return
}
login, authLevel, _, _, err := store.Users.GetAuthRecord(tenant.ID, vc.TargetUser, "basic")
login, authLevel, _, _, err := store.Users.GetAuthRecord(tenant.ID, vc.TargetUser)
if err != nil || login == "" {
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
@@ -830,6 +910,19 @@ func serveIMPasswordResetConfirm(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
}
if account, getErr := store.Users.Get(tenant.ID, vc.TargetUser); getErr == nil && account != nil && account.Phone != "" {
if _, credErr := store.Users.UpsertCred(tenant.ID, &types.Credential{
ObjHeader: types.ObjHeader{CreatedAt: now, UpdatedAt: now},
User: vc.TargetUser.String(),
Method: "tel",
Value: account.Phone,
Done: true,
}); credErr != nil {
logs.Warn.Println("im password reset confirm: failed to backfill phone credential", tenant.Code, credErr)
}
} else if getErr != nil {
logs.Warn.Println("im password reset confirm: failed to load account phone", tenant.Code, getErr)
}
if globals.sessionStore != nil {
globals.sessionStore.EvictUser(vc.TargetUser, "")
}
@@ -4892,8 +4985,11 @@ func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, princi
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
// Temporary fixed code for app integration until the SMS provider is connected.
code := imVerificationTestCode
code, err := randomIMNumericCode(imVerificationCodeLength)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
vc := &types.VerificationCode{
TenantID: principal.TenantID,
ChallengeID: challengeID,
@@ -4912,7 +5008,13 @@ func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, princi
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
logs.Info.Println("im security verification code generated:", action, vc.PhoneMask)
if err := sendIMVerificationCode(principal.TenantID, phone, "", "sensitive_action", code); err != nil {
_ = store.Verifications.Fail(principal.TenantID, vc.ChallengeID, types.VerificationStateExpired)
logs.Warn.Println("im security verification delivery failed:", action, vc.PhoneMask, err)
writeIMError(wrt, req, http.StatusBadGateway, 30033, imReasonSMSDeliveryFailed, "SMS delivery failed", nil)
return
}
logs.Info.Println("im security verification challenge created:", action, vc.PhoneMask)
writeIMSuccess(wrt, req, map[string]any{
"challenge_id": vc.ChallengeID,
"phone_mask": vc.PhoneMask,