Merge pull request #1454 from trheyi/main

Improve authentication, environment configuration, and team initialization
This commit is contained in:
Max 2026-02-07 20:23:59 +08:00 committed by GitHub
commit 5f40ccbac4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1421 additions and 388 deletions

View file

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

File diff suppressed because one or more lines are too long

View file

@ -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
}

View file

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

View file

@ -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{})

View file

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

View file

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

View 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")
})
}

View 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
View 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",
})
}

View file

@ -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 == "" {

View file

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

View file

@ -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{})

View file

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

View file

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

View file

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

View file

@ -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 ====

View file

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

View file

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