Merge pull request #1454 from trheyi/main
Improve authentication, environment configuration, and team initialization
This commit is contained in:
commit
5f40ccbac4
19 changed files with 1421 additions and 388 deletions
21
Makefile
21
Makefile
|
|
@ -376,6 +376,13 @@ artifacts-linux: clean
|
|||
cd ../yao-init && rm -rf LICENSE
|
||||
# cd ../yao-init rm -rf README.md
|
||||
|
||||
# Switch .env login URLs from dev mode (__yao_admin_root) to release mode (dashboard)
|
||||
sed -i.bak 's|AFTER_LOGIN_SUCCESS_URL="/__yao_admin_root/|# AFTER_LOGIN_SUCCESS_URL="/__yao_admin_root/|g' ../yao-init/.env
|
||||
sed -i.bak 's|AFTER_LOGIN_FAILURE_URL="/__yao_admin_root/|# AFTER_LOGIN_FAILURE_URL="/__yao_admin_root/|g' ../yao-init/.env
|
||||
sed -i.bak 's|# AFTER_LOGIN_SUCCESS_URL="/dashboard/|AFTER_LOGIN_SUCCESS_URL="/dashboard/|g' ../yao-init/.env
|
||||
sed -i.bak 's|# AFTER_LOGIN_FAILURE_URL="/dashboard/|AFTER_LOGIN_FAILURE_URL="/dashboard/|g' ../yao-init/.env
|
||||
rm -f ../yao-init/.env.bak
|
||||
|
||||
# Yao Builder
|
||||
# Remove Yao Builder - DUI PageBuilder component will provide online design for pure HTML pages or SUI pages in the future.
|
||||
# mkdir -p .tmp/data/builder
|
||||
|
|
@ -452,6 +459,13 @@ artifacts-macos: clean
|
|||
cd ../yao-init && rm -rf LICENSE
|
||||
# cd ../yao-init && rm -rf README.md
|
||||
|
||||
# Switch .env login URLs from dev mode (__yao_admin_root) to release mode (dashboard)
|
||||
sed -i.bak 's|AFTER_LOGIN_SUCCESS_URL="/__yao_admin_root/|# AFTER_LOGIN_SUCCESS_URL="/__yao_admin_root/|g' ../yao-init/.env
|
||||
sed -i.bak 's|AFTER_LOGIN_FAILURE_URL="/__yao_admin_root/|# AFTER_LOGIN_FAILURE_URL="/__yao_admin_root/|g' ../yao-init/.env
|
||||
sed -i.bak 's|# AFTER_LOGIN_SUCCESS_URL="/dashboard/|AFTER_LOGIN_SUCCESS_URL="/dashboard/|g' ../yao-init/.env
|
||||
sed -i.bak 's|# AFTER_LOGIN_FAILURE_URL="/dashboard/|AFTER_LOGIN_FAILURE_URL="/dashboard/|g' ../yao-init/.env
|
||||
rm -f ../yao-init/.env.bak
|
||||
|
||||
# Packing
|
||||
mkdir -p .tmp/data/cui
|
||||
cp -r ./ui .tmp/data/ui
|
||||
|
|
@ -535,6 +549,13 @@ prepare: clean
|
|||
rm -rf .tmp/yao-init/LICENSE
|
||||
rm -rf .tmp/yao-init/README.md
|
||||
|
||||
# Switch .env login URLs from dev mode (__yao_admin_root) to release mode (dashboard)
|
||||
sed -i.bak 's|AFTER_LOGIN_SUCCESS_URL="/__yao_admin_root/|# AFTER_LOGIN_SUCCESS_URL="/__yao_admin_root/|g' .tmp/yao-init/.env
|
||||
sed -i.bak 's|AFTER_LOGIN_FAILURE_URL="/__yao_admin_root/|# AFTER_LOGIN_FAILURE_URL="/__yao_admin_root/|g' .tmp/yao-init/.env
|
||||
sed -i.bak 's|# AFTER_LOGIN_SUCCESS_URL="/dashboard/|AFTER_LOGIN_SUCCESS_URL="/dashboard/|g' .tmp/yao-init/.env
|
||||
sed -i.bak 's|# AFTER_LOGIN_FAILURE_URL="/dashboard/|AFTER_LOGIN_FAILURE_URL="/dashboard/|g' .tmp/yao-init/.env
|
||||
rm -f .tmp/yao-init/.env.bak
|
||||
|
||||
# Yao Builder
|
||||
# Remove Yao Builder - DUI PageBuilder component will provide online design for pure HTML pages or SUI pages in the future.
|
||||
# mkdir -p .tmp/data/builder
|
||||
|
|
|
|||
1006
data/bindata.go
1006
data/bindata.go
File diff suppressed because one or more lines are too long
|
|
@ -31,6 +31,7 @@ func processPing(process *process.Process) interface{} {
|
|||
res := map[string]interface{}{
|
||||
"engine": share.BUILDNAME,
|
||||
"version": share.VERSION,
|
||||
"root": config.Conf.Root,
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,18 @@ func GetInfo(c *gin.Context) *types.AuthorizedInfo {
|
|||
}
|
||||
}
|
||||
|
||||
if authSource, ok := c.Get("__auth_source"); ok {
|
||||
if asStr, ok := authSource.(string); ok {
|
||||
info.AuthSource = asStr
|
||||
}
|
||||
}
|
||||
|
||||
if oauthEmail, ok := c.Get("__oauth_email"); ok {
|
||||
if oeStr, ok := oauthEmail.(string); ok {
|
||||
info.OAuthEmail = oeStr
|
||||
}
|
||||
}
|
||||
|
||||
// Get data access constraints (set by ACL enforcement)
|
||||
info.Constraints = GetConstraints(c)
|
||||
|
||||
|
|
|
|||
|
|
@ -582,6 +582,9 @@ func (s *Service) SignIDToken(clientID, scope string, expiresIn int, userdata *t
|
|||
if userdata.YaoTypeID != "" {
|
||||
claims["yao:type_id"] = userdata.YaoTypeID
|
||||
}
|
||||
if userdata.YaoAuthSource != "" {
|
||||
claims["yao:auth_source"] = userdata.YaoAuthSource
|
||||
}
|
||||
// Add Yao team info if present
|
||||
if userdata.YaoTeam != nil {
|
||||
teamMap := make(map[string]interface{})
|
||||
|
|
|
|||
|
|
@ -157,6 +157,11 @@ func (user OIDCUserInfo) Map() map[string]interface{} {
|
|||
}
|
||||
}
|
||||
|
||||
// Add Yao auth source if present
|
||||
if user.YaoAuthSource != "" {
|
||||
result["yao:auth_source"] = user.YaoAuthSource
|
||||
}
|
||||
|
||||
// Add Yao member info if present and has content (for team context)
|
||||
if user.YaoMember != nil {
|
||||
memberMap := make(map[string]interface{})
|
||||
|
|
@ -307,6 +312,11 @@ func MakeOIDCUserInfo(user map[string]interface{}) *OIDCUserInfo {
|
|||
userInfo.YaoTypeID = typeID
|
||||
}
|
||||
|
||||
// Yao auth source
|
||||
if authSource, ok := user["yao:auth_source"].(string); ok {
|
||||
userInfo.YaoAuthSource = authSource
|
||||
}
|
||||
|
||||
// Yao team info (nested object)
|
||||
if teamData, ok := user["yao:team"].(map[string]interface{}); ok {
|
||||
team := &OIDCTeamInfo{}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ type LoginContext struct {
|
|||
Location string `json:"location,omitempty"` // Geographic location (optional)
|
||||
RememberMe bool `json:"remember_me,omitempty"` // Remember Me flag for extended session
|
||||
Locale string `json:"locale,omitempty"` // User's preferred locale (e.g., "en-US", "zh-CN")
|
||||
AuthSource string `json:"auth_source,omitempty"` // Authentication source (password, google, github, etc.)
|
||||
OAuthEmail string `json:"oauth_email,omitempty"` // OAuth account email (from third-party provider, not user profile)
|
||||
}
|
||||
|
||||
// MFAOptions contains configuration for MFA operations
|
||||
|
|
@ -626,6 +628,8 @@ type AuthorizedInfo struct {
|
|||
TeamID string `json:"team_id,omitempty"` // Team identifier
|
||||
TenantID string `json:"tenant_id,omitempty"` // Tenant identifier
|
||||
RememberMe bool `json:"remember_me,omitempty"` // Remember Me flag preserved from login
|
||||
AuthSource string `json:"auth_source,omitempty"` // Authentication source preserved from login
|
||||
OAuthEmail string `json:"oauth_email,omitempty"` // OAuth account email preserved from login
|
||||
|
||||
// Data access constraints (set by ACL enforcement)
|
||||
Constraints DataConstraints `json:"constraints,omitempty"`
|
||||
|
|
@ -776,14 +780,15 @@ type OIDCUserInfo struct {
|
|||
Address *OIDCAddress `json:"address,omitempty"` // Physical mailing address
|
||||
|
||||
// Additional custom claims with namespace
|
||||
YaoUserID string `json:"yao:user_id,omitempty"` // Yao user ID (original user ID)
|
||||
YaoTenantID string `json:"yao:tenant_id,omitempty"` // Yao tenant ID
|
||||
YaoTeamID string `json:"yao:team_id,omitempty"` // Yao team ID
|
||||
YaoTeam *OIDCTeamInfo `json:"yao:team,omitempty"` // Yao team info
|
||||
YaoIsOwner *bool `json:"yao:is_owner,omitempty"` // Yao is owner
|
||||
YaoTypeID string `json:"yao:type_id,omitempty"` // Yao user type ID
|
||||
YaoType *OIDCTypeInfo `json:"yao:type,omitempty"` // Yao user type info
|
||||
YaoMember *OIDCMemberInfo `json:"yao:member,omitempty"` // Yao member profile info (for team context)
|
||||
YaoUserID string `json:"yao:user_id,omitempty"` // Yao user ID (original user ID)
|
||||
YaoTenantID string `json:"yao:tenant_id,omitempty"` // Yao tenant ID
|
||||
YaoTeamID string `json:"yao:team_id,omitempty"` // Yao team ID
|
||||
YaoTeam *OIDCTeamInfo `json:"yao:team,omitempty"` // Yao team info
|
||||
YaoIsOwner *bool `json:"yao:is_owner,omitempty"` // Yao is owner
|
||||
YaoTypeID string `json:"yao:type_id,omitempty"` // Yao user type ID
|
||||
YaoType *OIDCTypeInfo `json:"yao:type,omitempty"` // Yao user type info
|
||||
YaoMember *OIDCMemberInfo `json:"yao:member,omitempty"` // Yao member profile info (for team context)
|
||||
YaoAuthSource string `json:"yao:auth_source,omitempty"` // Authentication source (password, google, github, etc.)
|
||||
|
||||
// Raw response for debugging and custom processing
|
||||
Raw map[string]interface{} `json:"raw,omitempty"` // Original provider response
|
||||
|
|
|
|||
236
openapi/tests/oauth/authorized_test.go
Normal file
236
openapi/tests/oauth/authorized_test.go
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
package openapi_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
||||
)
|
||||
|
||||
// TestGetInfoOAuthEmail tests that GetInfo correctly extracts __oauth_email from gin context
|
||||
func TestGetInfoOAuthEmail(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
t.Run("extracts oauth_email when set", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("GET", "/test", nil)
|
||||
|
||||
// Set context values
|
||||
c.Set("__subject", "test-subject")
|
||||
c.Set("__client_id", "test-client")
|
||||
c.Set("__user_id", "test-user")
|
||||
c.Set("__scope", "openid profile")
|
||||
c.Set("__oauth_email", "user@example.com")
|
||||
|
||||
info := authorized.GetInfo(c)
|
||||
|
||||
assert.NotNil(t, info)
|
||||
assert.Equal(t, "test-subject", info.Subject)
|
||||
assert.Equal(t, "test-client", info.ClientID)
|
||||
assert.Equal(t, "test-user", info.UserID)
|
||||
assert.Equal(t, "openid profile", info.Scope)
|
||||
assert.Equal(t, "user@example.com", info.OAuthEmail)
|
||||
})
|
||||
|
||||
t.Run("oauth_email is empty when not set", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("GET", "/test", nil)
|
||||
|
||||
c.Set("__subject", "test-subject")
|
||||
c.Set("__user_id", "test-user")
|
||||
|
||||
info := authorized.GetInfo(c)
|
||||
|
||||
assert.NotNil(t, info)
|
||||
assert.Equal(t, "test-user", info.UserID)
|
||||
assert.Empty(t, info.OAuthEmail, "OAuthEmail should be empty when __oauth_email is not set in context")
|
||||
})
|
||||
|
||||
t.Run("oauth_email handles wrong type gracefully", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("GET", "/test", nil)
|
||||
|
||||
c.Set("__subject", "test-subject")
|
||||
c.Set("__oauth_email", 12345) // Wrong type (int instead of string)
|
||||
|
||||
info := authorized.GetInfo(c)
|
||||
|
||||
assert.NotNil(t, info)
|
||||
assert.Empty(t, info.OAuthEmail, "OAuthEmail should be empty when __oauth_email has wrong type")
|
||||
})
|
||||
}
|
||||
|
||||
// TestGetInfoAuthSource tests that GetInfo correctly extracts __auth_source from gin context
|
||||
func TestGetInfoAuthSource(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
t.Run("extracts auth_source when set to password", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("GET", "/test", nil)
|
||||
|
||||
c.Set("__subject", "test-subject")
|
||||
c.Set("__user_id", "test-user")
|
||||
c.Set("__auth_source", "password")
|
||||
|
||||
info := authorized.GetInfo(c)
|
||||
|
||||
assert.NotNil(t, info)
|
||||
assert.Equal(t, "password", info.AuthSource)
|
||||
})
|
||||
|
||||
t.Run("extracts auth_source when set to google", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("GET", "/test", nil)
|
||||
|
||||
c.Set("__subject", "test-subject")
|
||||
c.Set("__user_id", "test-user")
|
||||
c.Set("__auth_source", "google")
|
||||
c.Set("__oauth_email", "s***a@gmail.com")
|
||||
|
||||
info := authorized.GetInfo(c)
|
||||
|
||||
assert.NotNil(t, info)
|
||||
assert.Equal(t, "google", info.AuthSource)
|
||||
assert.Equal(t, "s***a@gmail.com", info.OAuthEmail)
|
||||
})
|
||||
|
||||
t.Run("extracts auth_source when set to github", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("GET", "/test", nil)
|
||||
|
||||
c.Set("__subject", "test-subject")
|
||||
c.Set("__user_id", "test-user")
|
||||
c.Set("__auth_source", "github")
|
||||
|
||||
info := authorized.GetInfo(c)
|
||||
|
||||
assert.NotNil(t, info)
|
||||
assert.Equal(t, "github", info.AuthSource)
|
||||
})
|
||||
|
||||
t.Run("auth_source is empty when not set", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("GET", "/test", nil)
|
||||
|
||||
c.Set("__subject", "test-subject")
|
||||
|
||||
info := authorized.GetInfo(c)
|
||||
|
||||
assert.NotNil(t, info)
|
||||
assert.Empty(t, info.AuthSource, "AuthSource should be empty when __auth_source is not set")
|
||||
})
|
||||
|
||||
t.Run("auth_source handles wrong type gracefully", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("GET", "/test", nil)
|
||||
|
||||
c.Set("__subject", "test-subject")
|
||||
c.Set("__auth_source", true) // Wrong type (bool instead of string)
|
||||
|
||||
info := authorized.GetInfo(c)
|
||||
|
||||
assert.NotNil(t, info)
|
||||
assert.Empty(t, info.AuthSource, "AuthSource should be empty when __auth_source has wrong type")
|
||||
})
|
||||
}
|
||||
|
||||
// TestGetInfoRememberMe tests that GetInfo correctly extracts __remember_me from gin context
|
||||
func TestGetInfoRememberMe(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
t.Run("extracts remember_me when true", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("GET", "/test", nil)
|
||||
|
||||
c.Set("__subject", "test-subject")
|
||||
c.Set("__remember_me", true)
|
||||
|
||||
info := authorized.GetInfo(c)
|
||||
|
||||
assert.NotNil(t, info)
|
||||
assert.True(t, info.RememberMe)
|
||||
})
|
||||
|
||||
t.Run("remember_me defaults to false when not set", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("GET", "/test", nil)
|
||||
|
||||
c.Set("__subject", "test-subject")
|
||||
|
||||
info := authorized.GetInfo(c)
|
||||
|
||||
assert.NotNil(t, info)
|
||||
assert.False(t, info.RememberMe)
|
||||
})
|
||||
}
|
||||
|
||||
// TestGetInfoTeamContext tests that GetInfo correctly extracts team-related fields
|
||||
func TestGetInfoTeamContext(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
t.Run("extracts full context for OAuth team member", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("GET", "/test", nil)
|
||||
|
||||
// Simulate a Google-logged-in user who has selected a team
|
||||
c.Set("__subject", "sub-12345")
|
||||
c.Set("__client_id", "client-abc")
|
||||
c.Set("__user_id", "user-67890")
|
||||
c.Set("__scope", "openid profile email")
|
||||
c.Set("__team_id", "team-111")
|
||||
c.Set("__tenant_id", "tenant-222")
|
||||
c.Set("__sid", "session-333")
|
||||
c.Set("__remember_me", true)
|
||||
c.Set("__auth_source", "google")
|
||||
c.Set("__oauth_email", "u***r@gmail.com")
|
||||
|
||||
info := authorized.GetInfo(c)
|
||||
|
||||
assert.NotNil(t, info)
|
||||
assert.Equal(t, "sub-12345", info.Subject)
|
||||
assert.Equal(t, "client-abc", info.ClientID)
|
||||
assert.Equal(t, "user-67890", info.UserID)
|
||||
assert.Equal(t, "openid profile email", info.Scope)
|
||||
assert.Equal(t, "team-111", info.TeamID)
|
||||
assert.Equal(t, "tenant-222", info.TenantID)
|
||||
assert.Equal(t, "session-333", info.SessionID)
|
||||
assert.True(t, info.RememberMe)
|
||||
assert.Equal(t, "google", info.AuthSource)
|
||||
assert.Equal(t, "u***r@gmail.com", info.OAuthEmail)
|
||||
})
|
||||
|
||||
t.Run("extracts context for password login user", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("GET", "/test", nil)
|
||||
|
||||
// Simulate a password-logged-in user
|
||||
c.Set("__subject", "sub-admin")
|
||||
c.Set("__client_id", "client-abc")
|
||||
c.Set("__user_id", "user-admin")
|
||||
c.Set("__scope", "openid profile email")
|
||||
c.Set("__team_id", "team-default")
|
||||
c.Set("__auth_source", "password")
|
||||
// No __oauth_email for password login
|
||||
|
||||
info := authorized.GetInfo(c)
|
||||
|
||||
assert.NotNil(t, info)
|
||||
assert.Equal(t, "password", info.AuthSource)
|
||||
assert.Empty(t, info.OAuthEmail, "OAuthEmail should be empty for password login")
|
||||
})
|
||||
}
|
||||
124
openapi/tests/user/utils_test.go
Normal file
124
openapi/tests/user/utils_test.go
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
package user_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/yao/openapi/user"
|
||||
)
|
||||
|
||||
// TestMaskEmail tests the MaskEmail utility function
|
||||
func TestMaskEmail(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
// Basic cases
|
||||
{
|
||||
"standard email",
|
||||
"john.doe@example.com",
|
||||
"j***e@example.com",
|
||||
},
|
||||
{
|
||||
"short local part (3 chars)",
|
||||
"abc@example.com",
|
||||
"a***c@example.com",
|
||||
},
|
||||
{
|
||||
"two char local part",
|
||||
"ab@example.com",
|
||||
"a***b@example.com",
|
||||
},
|
||||
{
|
||||
"single char local part",
|
||||
"a@example.com",
|
||||
"a***@example.com",
|
||||
},
|
||||
{
|
||||
"long local part",
|
||||
"very.long.email.address@example.com",
|
||||
"v***s@example.com",
|
||||
},
|
||||
|
||||
// Gmail-style emails
|
||||
{
|
||||
"gmail address",
|
||||
"shadow.iqka@gmail.com",
|
||||
"s***a@gmail.com",
|
||||
},
|
||||
{
|
||||
"gmail with numbers",
|
||||
"user123@gmail.com",
|
||||
"u***3@gmail.com",
|
||||
},
|
||||
|
||||
// Edge cases
|
||||
{
|
||||
"empty string",
|
||||
"",
|
||||
"",
|
||||
},
|
||||
{
|
||||
"no at sign",
|
||||
"not-an-email",
|
||||
"",
|
||||
},
|
||||
{
|
||||
"multiple at signs",
|
||||
"user@@example.com",
|
||||
"",
|
||||
},
|
||||
{
|
||||
"empty local part",
|
||||
"@example.com",
|
||||
"",
|
||||
},
|
||||
{
|
||||
"empty domain",
|
||||
"user@",
|
||||
"",
|
||||
},
|
||||
{
|
||||
"only at sign",
|
||||
"@",
|
||||
"",
|
||||
},
|
||||
|
||||
// Special characters in local part
|
||||
{
|
||||
"dots in local part",
|
||||
"first.last@example.com",
|
||||
"f***t@example.com",
|
||||
},
|
||||
{
|
||||
"plus sign in local part",
|
||||
"user+tag@example.com",
|
||||
"u***g@example.com",
|
||||
},
|
||||
{
|
||||
"underscore in local part",
|
||||
"first_last@example.com",
|
||||
"f***t@example.com",
|
||||
},
|
||||
|
||||
// Different domains
|
||||
{
|
||||
"subdomain email",
|
||||
"user@mail.example.com",
|
||||
"u***r@mail.example.com",
|
||||
},
|
||||
{
|
||||
"country code domain",
|
||||
"user@example.co.jp",
|
||||
"u***r@example.co.jp",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := user.MaskEmail(tc.input)
|
||||
assert.Equal(t, tc.expected, result, "MaskEmail(%q) should return %q", tc.input, tc.expected)
|
||||
})
|
||||
}
|
||||
}
|
||||
132
openapi/user/account.go
Normal file
132
openapi/user/account.go
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/yaoapp/kun/log"
|
||||
"github.com/yaoapp/yao/openapi/oauth"
|
||||
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
||||
"github.com/yaoapp/yao/openapi/response"
|
||||
)
|
||||
|
||||
// ChangePasswordRequest represents the request body for changing password
|
||||
type ChangePasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required"`
|
||||
ConfirmPassword string `json:"confirm_password" binding:"required"`
|
||||
}
|
||||
|
||||
// GinChangePassword handles PUT /account/password - Change current user's password
|
||||
func GinChangePassword(c *gin.Context) {
|
||||
// 1. Get authorized user info from Guard
|
||||
authInfo := authorized.GetInfo(c)
|
||||
if authInfo == nil || authInfo.UserID == "" {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidClient.Code,
|
||||
ErrorDescription: "User not authenticated",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusUnauthorized, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Parse request body
|
||||
var req ChangePasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: fmt.Sprintf("Invalid request body: %s", err.Error()),
|
||||
}
|
||||
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Validate new_password == confirm_password
|
||||
if req.NewPassword != req.ConfirmPassword {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "New password and confirm password do not match",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Validate new password format (reuse validatePassword from entry.go)
|
||||
if err := validatePassword(req.NewPassword); err != nil {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: err.Error(),
|
||||
}
|
||||
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Get user provider
|
||||
ctx := c.Request.Context()
|
||||
userProvider, err := oauth.OAuth.GetUserProvider()
|
||||
if err != nil {
|
||||
log.Error("Failed to get user provider: %v", err)
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
ErrorDescription: "Internal server error",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// 6. Get user auth data (includes password_hash)
|
||||
user, err := userProvider.GetUserForAuth(ctx, authInfo.UserID, "user_id")
|
||||
if err != nil {
|
||||
log.Error("Failed to get user for auth: %v", err)
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
ErrorDescription: "Failed to verify current password",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// 7. Get password hash and verify current password
|
||||
// Note: OAuth-only users (Google/GitHub) have no password_hash and no email.
|
||||
// The frontend should not show the change password option for these users.
|
||||
passwordHash, ok := user["password_hash"].(string)
|
||||
if !ok || passwordHash == "" {
|
||||
log.Warn("User %s has no password hash (likely OAuth-only user)", authInfo.UserID)
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "Password change is not available for this account",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
valid, err := userProvider.VerifyPassword(ctx, req.CurrentPassword, passwordHash)
|
||||
if err != nil || !valid {
|
||||
log.Warn("Password verification failed for user %s during password change", authInfo.UserID)
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "Current password is incorrect",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// 8. Update password
|
||||
if err := userProvider.UpdatePassword(ctx, authInfo.UserID, req.NewPassword); err != nil {
|
||||
log.Error("Failed to update password for user %s: %v", authInfo.UserID, err)
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
ErrorDescription: "Failed to update password",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("Password changed successfully for user %s", authInfo.UserID)
|
||||
|
||||
// 9. Return success
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Password changed successfully",
|
||||
})
|
||||
}
|
||||
|
|
@ -269,6 +269,9 @@ func loadTeamConfigs(_ string) error {
|
|||
return fmt.Errorf("failed to parse team config %s: %v", filename, err)
|
||||
}
|
||||
|
||||
// Resolve $ENV. variables in team configuration
|
||||
resolveTeamConfigENV(&teamConfig)
|
||||
|
||||
// Store team configuration
|
||||
teamConfigs[locale] = &teamConfig
|
||||
|
||||
|
|
@ -402,6 +405,49 @@ func extractEnvVarName(value string) string {
|
|||
return "unknown"
|
||||
}
|
||||
|
||||
// resolveTeamConfigENV resolves $ENV. variables in team configuration
|
||||
func resolveTeamConfigENV(config *TeamConfig) {
|
||||
if config == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve robot config
|
||||
if config.Robot != nil {
|
||||
// Resolve email domains
|
||||
for _, domain := range config.Robot.EmailDomains {
|
||||
if domain == nil {
|
||||
continue
|
||||
}
|
||||
domain.Domain = replaceENVVar(domain.Domain)
|
||||
domain.Messenger = replaceENVVar(domain.Messenger)
|
||||
|
||||
// Resolve whitelist
|
||||
if domain.Whitelist != nil {
|
||||
for i, d := range domain.Whitelist.Domains {
|
||||
domain.Whitelist.Domains[i] = replaceENVVar(d)
|
||||
}
|
||||
for i, s := range domain.Whitelist.Senders {
|
||||
domain.Whitelist.Senders[i] = replaceENVVar(s)
|
||||
}
|
||||
for i, ip := range domain.Whitelist.IPs {
|
||||
domain.Whitelist.IPs[i] = replaceENVVar(ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve defaults
|
||||
if config.Robot.Defaults != nil {
|
||||
config.Robot.Defaults.LLM = replaceENVVar(config.Robot.Defaults.LLM)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve invite config
|
||||
if config.Invite != nil {
|
||||
config.Invite.BaseURL = replaceENVVar(config.Invite.BaseURL)
|
||||
config.Invite.Channel = replaceENVVar(config.Invite.Channel)
|
||||
}
|
||||
}
|
||||
|
||||
// replaceENVVar replaces environment variables in a string
|
||||
func replaceENVVar(value string) string {
|
||||
if value == "" {
|
||||
|
|
|
|||
|
|
@ -764,17 +764,6 @@ func GinEntryRegister(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Get user provider
|
||||
userProvider, err := oauth.OAuth.GetUserProvider()
|
||||
if err != nil {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
ErrorDescription: "Failed to get user provider: " + err.Error(),
|
||||
}
|
||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate name if not provided
|
||||
name := req.Name
|
||||
if name == "" {
|
||||
|
|
@ -821,18 +810,17 @@ func GinEntryRegister(c *gin.Context) {
|
|||
userData["status"] = "active"
|
||||
}
|
||||
|
||||
// Create user
|
||||
userID, err := userProvider.CreateUser(ctx, userData)
|
||||
// Create user and default team (with rollback on team creation failure)
|
||||
userID, err := registerUserWithTeam(ctx, userData, req.Locale)
|
||||
if err != nil {
|
||||
log.Error("Failed to create user: %v", err)
|
||||
log.Error("Failed to register user: %v", err)
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
ErrorDescription: "Failed to create user: " + err.Error(),
|
||||
ErrorDescription: err.Error(),
|
||||
}
|
||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("User registered successfully: %s (user_id: %s)", usernameStr, userID)
|
||||
|
||||
// If auto_login is false and invite not required, return success without tokens
|
||||
|
|
@ -849,6 +837,7 @@ func GinEntryRegister(c *gin.Context) {
|
|||
// Auto-login or invite_required: Generate tokens using LoginByUserID
|
||||
// For invite_required, LoginByUserID will detect pending_invite status and return temporary token
|
||||
loginCtx := makeLoginContext(c)
|
||||
loginCtx.AuthSource = "password" // Registered via email+password
|
||||
loginResponse, err := LoginByUserID(userID, loginCtx)
|
||||
if err != nil {
|
||||
log.Error("Failed to auto-login after registration: %v", err)
|
||||
|
|
@ -1021,6 +1010,7 @@ func GinEntryLogin(c *gin.Context) {
|
|||
// Login using LoginByUserID (all status checks are handled inside)
|
||||
loginCtx := makeLoginContext(c)
|
||||
loginCtx.RememberMe = req.RememberMe // Set Remember Me from request
|
||||
loginCtx.AuthSource = "password" // Logged in via email+password
|
||||
loginResponse, err := LoginByUserID(userID, loginCtx)
|
||||
if err != nil {
|
||||
log.Error("Failed to login user %s: %v", userID, err)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/gin-gonic/gin"
|
||||
"github.com/yaoapp/gou/session"
|
||||
"github.com/yaoapp/kun/log"
|
||||
"github.com/yaoapp/kun/maps"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/kb"
|
||||
kbapi "github.com/yaoapp/yao/kb/api"
|
||||
|
|
@ -25,6 +26,71 @@ import (
|
|||
// kbCollectionCreating tracks collections currently being created to avoid duplicate creation
|
||||
var kbCollectionCreating sync.Map
|
||||
|
||||
// registerUserWithTeam creates a new user and automatically creates a default team.
|
||||
// If team creation fails, the user is rolled back (deleted) to ensure data consistency.
|
||||
// This is the single entry point for all user registration paths (email/mobile, OAuth third-party, etc.).
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: context for database operations
|
||||
// - userData: user fields to pass to CreateUser (name, email, status, role_id, type_id, etc.)
|
||||
// - locale: user's locale for determining default team name (e.g. "zh-cn", "en")
|
||||
//
|
||||
// Returns:
|
||||
// - userID: the created user's ID
|
||||
// - error: non-nil if user creation or team creation failed (user is rolled back on team failure)
|
||||
func registerUserWithTeam(ctx context.Context, userData map[string]interface{}, locale string) (string, error) {
|
||||
userProvider, err := oauth.OAuth.GetUserProvider()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get user provider: %w", err)
|
||||
}
|
||||
|
||||
// Create user
|
||||
userID, err := userProvider.CreateUser(ctx, userData)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create user: %w", err)
|
||||
}
|
||||
|
||||
// Auto-create a default team for the new user
|
||||
// Use "<DisplayName>'s Team" / "<DisplayName>的团队" format
|
||||
// Priority: given_name > name (given_name is more natural as display name)
|
||||
userName := ""
|
||||
if v, ok := userData["given_name"].(string); ok && v != "" {
|
||||
userName = v
|
||||
} else if v, ok := userData["name"].(string); ok && v != "" {
|
||||
userName = v
|
||||
}
|
||||
var defaultTeamName string
|
||||
if strings.HasPrefix(strings.ToLower(locale), "zh") {
|
||||
if userName != "" {
|
||||
defaultTeamName = userName + "的团队"
|
||||
} else {
|
||||
defaultTeamName = "我的团队"
|
||||
}
|
||||
} else {
|
||||
if userName != "" {
|
||||
defaultTeamName = userName + "'s Team"
|
||||
} else {
|
||||
defaultTeamName = "My Team"
|
||||
}
|
||||
}
|
||||
teamData := maps.MapStrAny{
|
||||
"name": defaultTeamName,
|
||||
"locale": locale,
|
||||
}
|
||||
defaultTeamID, err := teamCreate(ctx, userID, teamData)
|
||||
if err != nil {
|
||||
log.Error("Failed to create default team for user %s: %v", userID, err)
|
||||
// Rollback: delete the created user since a team is required
|
||||
if delErr := userProvider.DeleteUser(ctx, userID); delErr != nil {
|
||||
log.Error("Failed to rollback user %s after team creation failure: %v", userID, delErr)
|
||||
}
|
||||
return "", fmt.Errorf("registration failed: unable to initialize team: %w", err)
|
||||
}
|
||||
|
||||
log.Info("User registered: %s, default team: %s", userID, defaultTeamID)
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// getCaptcha is the handler for get captcha image for entry (login/register)
|
||||
func getCaptcha(c *gin.Context) {
|
||||
var option captcha.Option = captcha.NewOption()
|
||||
|
|
@ -70,19 +136,17 @@ func LoginThirdParty(providerID string, userinfo *oauthtypes.OIDCUserInfo, login
|
|||
}
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
// Auto register user if not exists
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
userProvider, err := oauth.OAuth.GetUserProvider()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Auto register user if not exists
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var userID string
|
||||
|
||||
// Auto register user if not exists
|
||||
if provider.Register != nil && provider.Register.Auto {
|
||||
userID, err = userProvider.GetOAuthUserID(ctx, providerID, userinfo.Sub)
|
||||
if err != nil && err.Error() == user.ErrOAuthAccountNotFound {
|
||||
|
|
@ -103,18 +167,22 @@ func LoginThirdParty(providerID string, userinfo *oauthtypes.OIDCUserInfo, login
|
|||
"status": status,
|
||||
}
|
||||
|
||||
// Auto register user
|
||||
userID, err = userProvider.CreateUser(ctx, userData)
|
||||
// Register user with default team (with rollback on failure)
|
||||
userID, err = registerUserWithTeam(ctx, userData, locale)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create OAuth account
|
||||
userData = userinfo.Map()
|
||||
userData["provider"] = providerID
|
||||
_, err = userProvider.CreateOAuthAccount(ctx, userID, userData)
|
||||
// Create OAuth account link
|
||||
oauthData := userinfo.Map()
|
||||
oauthData["provider"] = providerID
|
||||
_, err = userProvider.CreateOAuthAccount(ctx, userID, oauthData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Rollback: delete user and team if OAuth account creation fails
|
||||
if delErr := userProvider.DeleteUser(ctx, userID); delErr != nil {
|
||||
log.Error("Failed to rollback user %s after OAuth account creation failure: %v", userID, delErr)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to create OAuth account: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -125,6 +193,11 @@ func LoginThirdParty(providerID string, userinfo *oauthtypes.OIDCUserInfo, login
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// Pass OAuth email to loginCtx for display in token (without polluting user profile email)
|
||||
if loginCtx != nil && userinfo.Email != "" {
|
||||
loginCtx.OAuthEmail = userinfo.Email
|
||||
}
|
||||
|
||||
return LoginByUserID(userID, loginCtx)
|
||||
}
|
||||
|
||||
|
|
@ -246,16 +319,37 @@ func LoginByUserID(userid string, loginCtx *LoginContext) (*LoginResponse, error
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// If user has teams, return team selection status with temporary access token
|
||||
if numTeams > 0 {
|
||||
// If user has exactly one team, auto-select it and skip team selection page
|
||||
if numTeams == 1 {
|
||||
teams, err := getUserTeams(ctx, userid)
|
||||
if err == nil && len(teams) == 1 {
|
||||
teamID := ""
|
||||
if v, ok := teams[0]["team_id"].(string); ok {
|
||||
teamID = v
|
||||
}
|
||||
if teamID != "" {
|
||||
return LoginByTeamID(userid, teamID, loginCtx)
|
||||
}
|
||||
}
|
||||
// Fall through to team selection if we couldn't auto-select
|
||||
}
|
||||
|
||||
// If user has multiple teams, return team selection status with temporary access token
|
||||
if numTeams > 1 {
|
||||
// Sign temporary access token for Team Selection
|
||||
var teamSelectionExpire int = 10 * 60 // 10 minutes
|
||||
|
||||
// Prepare extra claims to preserve Remember Me state
|
||||
// Prepare extra claims to preserve Remember Me and AuthSource state
|
||||
extraClaims := make(map[string]interface{})
|
||||
if loginCtx != nil && loginCtx.RememberMe {
|
||||
extraClaims["remember_me"] = true
|
||||
}
|
||||
if loginCtx != nil && loginCtx.AuthSource != "" {
|
||||
extraClaims["auth_source"] = loginCtx.AuthSource
|
||||
}
|
||||
if loginCtx != nil && loginCtx.OAuthEmail != "" {
|
||||
extraClaims["oauth_email"] = loginCtx.OAuthEmail
|
||||
}
|
||||
|
||||
accessToken, err := oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, ScopeTeamSelection, subject, teamSelectionExpire, extraClaims)
|
||||
if err != nil {
|
||||
|
|
@ -328,8 +422,9 @@ func LoginByTeamID(userid string, teamID string, loginCtx *LoginContext) (*Login
|
|||
log.Warn("Failed to store user fingerprint: %s", err.Error())
|
||||
}
|
||||
|
||||
// Handle personal account (no team)
|
||||
// Handle personal account (no team) - deprecated, all users should use teams
|
||||
if teamID == "" || teamID == "personal" {
|
||||
log.Warn("Personal account login is deprecated. User %s should select a team.", userid)
|
||||
resp, err := issueTokens(ctx, &IssueTokensParams{
|
||||
UserID: userid,
|
||||
TeamID: "",
|
||||
|
|
@ -495,6 +590,23 @@ func issueTokens(ctx context.Context, params *IssueTokensParams) (*LoginResponse
|
|||
oidcUserInfo.Sub = params.Subject
|
||||
oidcUserInfo.YaoUserID = params.UserID // Add original user ID
|
||||
|
||||
// Add authentication source from IssueTokensParams or LoginContext
|
||||
if params.AuthSource != "" {
|
||||
oidcUserInfo.YaoAuthSource = params.AuthSource
|
||||
} else if params.LoginCtx != nil && params.LoginCtx.AuthSource != "" {
|
||||
oidcUserInfo.YaoAuthSource = params.LoginCtx.AuthSource
|
||||
}
|
||||
|
||||
// For third-party login: use OAuth email (masked) if user profile email is empty
|
||||
if oidcUserInfo.YaoAuthSource != "" && oidcUserInfo.YaoAuthSource != "password" {
|
||||
if oidcUserInfo.Email == "" && params.LoginCtx != nil && params.LoginCtx.OAuthEmail != "" {
|
||||
oidcUserInfo.Email = params.LoginCtx.OAuthEmail
|
||||
}
|
||||
if oidcUserInfo.Email != "" {
|
||||
oidcUserInfo.Email = MaskEmail(oidcUserInfo.Email)
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare extra claims for access token
|
||||
extraClaims := make(map[string]interface{})
|
||||
|
||||
|
|
|
|||
|
|
@ -174,6 +174,7 @@ func authback(c *gin.Context) {
|
|||
|
||||
// LoginThirdParty(providerID, userInfo)
|
||||
loginCtx := makeLoginContext(c)
|
||||
loginCtx.AuthSource = providerID // Set auth source to provider name (google, github, etc.)
|
||||
|
||||
// Use locale from params, fallback to "en" if not provided
|
||||
locale := params.Locale
|
||||
|
|
|
|||
|
|
@ -414,6 +414,8 @@ func GinTeamSelection(c *gin.Context) {
|
|||
|
||||
// Preserve Remember Me state from temporary token
|
||||
loginCtx.RememberMe = authInfo.RememberMe
|
||||
loginCtx.AuthSource = authInfo.AuthSource // Preserve auth source from login
|
||||
loginCtx.OAuthEmail = authInfo.OAuthEmail // Preserve OAuth email from login
|
||||
|
||||
// Login with selected team
|
||||
loginResponse, err := LoginByTeamID(authInfo.UserID, req.TeamID, loginCtx)
|
||||
|
|
|
|||
|
|
@ -991,7 +991,7 @@ func teamInvitationGetPublic(ctx context.Context, invitationID, locale string) (
|
|||
}
|
||||
// Fallback to masked email if name is empty (for privacy protection)
|
||||
if inviterInfo.Name == "" {
|
||||
inviterInfo.Name = maskEmail(utils.ToString(inviter["email"]))
|
||||
inviterInfo.Name = MaskEmail(utils.ToString(inviter["email"]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -268,14 +268,15 @@ type LoginContext = oauthtypes.LoginContext
|
|||
|
||||
// IssueTokensParams represents parameters for issueTokens function
|
||||
type IssueTokensParams struct {
|
||||
UserID string // User ID
|
||||
TeamID string // Team ID (empty for personal account)
|
||||
Team map[string]interface{} // Team data (nil for personal account)
|
||||
Member map[string]interface{} // Member profile data (nil for personal account or if not available)
|
||||
User map[string]interface{} // User data
|
||||
Subject string // Token subject
|
||||
Scopes []string // Token scopes
|
||||
LoginCtx *LoginContext // Login context (IP, user agent, etc.)
|
||||
UserID string // User ID
|
||||
TeamID string // Team ID (empty for personal account)
|
||||
Team map[string]interface{} // Team data (nil for personal account)
|
||||
Member map[string]interface{} // Member profile data (nil for personal account or if not available)
|
||||
User map[string]interface{} // User data
|
||||
Subject string // Token subject
|
||||
Scopes []string // Token scopes
|
||||
LoginCtx *LoginContext // Login context (IP, user agent, etc.)
|
||||
AuthSource string // Authentication source (password, google, github, etc.)
|
||||
}
|
||||
|
||||
// ==== Entry Verification Types ====
|
||||
|
|
|
|||
|
|
@ -269,7 +269,8 @@ func attachAccount(group *gin.RouterGroup, oauth types.OAuth) {
|
|||
account.Use(oauth.Guard)
|
||||
|
||||
// Password Management
|
||||
account.PUT("/password", placeholder) // Change password (requires current password or 2FA)
|
||||
account.PUT("/password", GinChangePassword) // Change password (requires current password)
|
||||
|
||||
account.POST("/password/reset/request", placeholder) // Request password reset (public, rate-limited)
|
||||
account.POST("/password/reset/verify", placeholder) // Verify reset token and set new password (public)
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ func GetUserIDFromSession(process *process.Process) string {
|
|||
|
||||
// Security Utilities
|
||||
|
||||
// maskEmail masks an email address for privacy protection
|
||||
// MaskEmail masks an email address for privacy protection
|
||||
// Keeps the first and last character of the local part, masks the middle with ***
|
||||
// Examples:
|
||||
// - "john.doe@example.com" -> "j***e@example.com"
|
||||
|
|
@ -38,7 +38,7 @@ func GetUserIDFromSession(process *process.Process) string {
|
|||
// - "ab@example.com" -> "a***b@example.com"
|
||||
//
|
||||
// Returns empty string for invalid email or empty input
|
||||
func maskEmail(email string) string {
|
||||
func MaskEmail(email string) string {
|
||||
if email == "" {
|
||||
return ""
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue