From bf8d82f02295863b83f5bf0632def51ab646a718 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 7 Feb 2026 19:44:10 +0800 Subject: [PATCH] Add Yao authentication source and OAuth email handling - Introduce `YaoAuthSource` and `OAuthEmail` fields in various structures to capture authentication source and OAuth email during user login and registration processes. - Update `SignIDToken`, `GetInfo`, and `Map` functions to include new claims for Yao authentication source. - Modify login context to preserve authentication source and OAuth email across different user flows. - Enhance token issuance to include authentication source and OAuth email in claims for better tracking and user context. - Ensure proper handling of OAuth email for third-party logins without affecting user profile email. This change improves the user experience by providing clearer context on authentication methods used during login and registration processes. --- openapi/oauth/authorized/utils.go | 12 ++ openapi/oauth/signing.go | 3 + openapi/oauth/types/oidc.go | 10 ++ openapi/oauth/types/types.go | 21 ++- openapi/tests/oauth/authorized_test.go | 236 +++++++++++++++++++++++++ openapi/tests/user/utils_test.go | 124 +++++++++++++ openapi/user/account.go | 132 ++++++++++++++ openapi/user/entry.go | 2 + openapi/user/login.go | 30 +++- openapi/user/oauth.go | 1 + openapi/user/team.go | 2 + openapi/user/team_invitation.go | 2 +- openapi/user/types.go | 17 +- openapi/user/user.go | 3 +- openapi/user/utils.go | 4 +- 15 files changed, 578 insertions(+), 21 deletions(-) create mode 100644 openapi/tests/oauth/authorized_test.go create mode 100644 openapi/tests/user/utils_test.go create mode 100644 openapi/user/account.go diff --git a/openapi/oauth/authorized/utils.go b/openapi/oauth/authorized/utils.go index 3ac1c350..ad82a989 100644 --- a/openapi/oauth/authorized/utils.go +++ b/openapi/oauth/authorized/utils.go @@ -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) diff --git a/openapi/oauth/signing.go b/openapi/oauth/signing.go index 0e30899e..371ba481 100644 --- a/openapi/oauth/signing.go +++ b/openapi/oauth/signing.go @@ -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{}) diff --git a/openapi/oauth/types/oidc.go b/openapi/oauth/types/oidc.go index 7a578922..79e4c1cd 100644 --- a/openapi/oauth/types/oidc.go +++ b/openapi/oauth/types/oidc.go @@ -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{} diff --git a/openapi/oauth/types/types.go b/openapi/oauth/types/types.go index d7477b1d..b14a6bbe 100644 --- a/openapi/oauth/types/types.go +++ b/openapi/oauth/types/types.go @@ -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 diff --git a/openapi/tests/oauth/authorized_test.go b/openapi/tests/oauth/authorized_test.go new file mode 100644 index 00000000..1bfd653a --- /dev/null +++ b/openapi/tests/oauth/authorized_test.go @@ -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") + }) +} diff --git a/openapi/tests/user/utils_test.go b/openapi/tests/user/utils_test.go new file mode 100644 index 00000000..a341b380 --- /dev/null +++ b/openapi/tests/user/utils_test.go @@ -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) + }) + } +} diff --git a/openapi/user/account.go b/openapi/user/account.go new file mode 100644 index 00000000..4d5d6466 --- /dev/null +++ b/openapi/user/account.go @@ -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", + }) +} diff --git a/openapi/user/entry.go b/openapi/user/entry.go index 329b25fb..293bf8df 100644 --- a/openapi/user/entry.go +++ b/openapi/user/entry.go @@ -837,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) @@ -1009,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) diff --git a/openapi/user/login.go b/openapi/user/login.go index 545d8ca1..227f190e 100644 --- a/openapi/user/login.go +++ b/openapi/user/login.go @@ -193,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) } @@ -334,11 +339,17 @@ func LoginByUserID(userid string, loginCtx *LoginContext) (*LoginResponse, error // 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 { @@ -579,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{}) diff --git a/openapi/user/oauth.go b/openapi/user/oauth.go index 7b08f071..8bd7bf21 100644 --- a/openapi/user/oauth.go +++ b/openapi/user/oauth.go @@ -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 diff --git a/openapi/user/team.go b/openapi/user/team.go index 953d945e..82d9c4b3 100644 --- a/openapi/user/team.go +++ b/openapi/user/team.go @@ -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) diff --git a/openapi/user/team_invitation.go b/openapi/user/team_invitation.go index c5ec44fd..6eee5892 100644 --- a/openapi/user/team_invitation.go +++ b/openapi/user/team_invitation.go @@ -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"])) } } } diff --git a/openapi/user/types.go b/openapi/user/types.go index 30c6d57c..6a07355e 100644 --- a/openapi/user/types.go +++ b/openapi/user/types.go @@ -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 ==== diff --git a/openapi/user/user.go b/openapi/user/user.go index c59f5a1f..e5f731db 100644 --- a/openapi/user/user.go +++ b/openapi/user/user.go @@ -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) diff --git a/openapi/user/utils.go b/openapi/user/utils.go index 5f97f40b..91b3a446 100644 --- a/openapi/user/utils.go +++ b/openapi/user/utils.go @@ -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 "" }