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.
This commit is contained in:
Max 2026-02-07 19:44:10 +08:00
parent 16a96642f5
commit bf8d82f022
15 changed files with 578 additions and 21 deletions

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) // Get data access constraints (set by ACL enforcement)
info.Constraints = GetConstraints(c) info.Constraints = GetConstraints(c)

View file

@ -582,6 +582,9 @@ func (s *Service) SignIDToken(clientID, scope string, expiresIn int, userdata *t
if userdata.YaoTypeID != "" { if userdata.YaoTypeID != "" {
claims["yao:type_id"] = userdata.YaoTypeID claims["yao:type_id"] = userdata.YaoTypeID
} }
if userdata.YaoAuthSource != "" {
claims["yao:auth_source"] = userdata.YaoAuthSource
}
// Add Yao team info if present // Add Yao team info if present
if userdata.YaoTeam != nil { if userdata.YaoTeam != nil {
teamMap := make(map[string]interface{}) 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) // Add Yao member info if present and has content (for team context)
if user.YaoMember != nil { if user.YaoMember != nil {
memberMap := make(map[string]interface{}) memberMap := make(map[string]interface{})
@ -307,6 +312,11 @@ func MakeOIDCUserInfo(user map[string]interface{}) *OIDCUserInfo {
userInfo.YaoTypeID = typeID userInfo.YaoTypeID = typeID
} }
// Yao auth source
if authSource, ok := user["yao:auth_source"].(string); ok {
userInfo.YaoAuthSource = authSource
}
// Yao team info (nested object) // Yao team info (nested object)
if teamData, ok := user["yao:team"].(map[string]interface{}); ok { if teamData, ok := user["yao:team"].(map[string]interface{}); ok {
team := &OIDCTeamInfo{} team := &OIDCTeamInfo{}

View file

@ -15,6 +15,8 @@ type LoginContext struct {
Location string `json:"location,omitempty"` // Geographic location (optional) Location string `json:"location,omitempty"` // Geographic location (optional)
RememberMe bool `json:"remember_me,omitempty"` // Remember Me flag for extended session 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") 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 // MFAOptions contains configuration for MFA operations
@ -626,6 +628,8 @@ type AuthorizedInfo struct {
TeamID string `json:"team_id,omitempty"` // Team identifier TeamID string `json:"team_id,omitempty"` // Team identifier
TenantID string `json:"tenant_id,omitempty"` // Tenant identifier TenantID string `json:"tenant_id,omitempty"` // Tenant identifier
RememberMe bool `json:"remember_me,omitempty"` // Remember Me flag preserved from login 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) // Data access constraints (set by ACL enforcement)
Constraints DataConstraints `json:"constraints,omitempty"` Constraints DataConstraints `json:"constraints,omitempty"`
@ -776,14 +780,15 @@ type OIDCUserInfo struct {
Address *OIDCAddress `json:"address,omitempty"` // Physical mailing address Address *OIDCAddress `json:"address,omitempty"` // Physical mailing address
// Additional custom claims with namespace // Additional custom claims with namespace
YaoUserID string `json:"yao:user_id,omitempty"` // Yao user ID (original user ID) YaoUserID string `json:"yao:user_id,omitempty"` // Yao user ID (original user ID)
YaoTenantID string `json:"yao:tenant_id,omitempty"` // Yao tenant ID YaoTenantID string `json:"yao:tenant_id,omitempty"` // Yao tenant ID
YaoTeamID string `json:"yao:team_id,omitempty"` // Yao team ID YaoTeamID string `json:"yao:team_id,omitempty"` // Yao team ID
YaoTeam *OIDCTeamInfo `json:"yao:team,omitempty"` // Yao team info YaoTeam *OIDCTeamInfo `json:"yao:team,omitempty"` // Yao team info
YaoIsOwner *bool `json:"yao:is_owner,omitempty"` // Yao is owner YaoIsOwner *bool `json:"yao:is_owner,omitempty"` // Yao is owner
YaoTypeID string `json:"yao:type_id,omitempty"` // Yao user type ID YaoTypeID string `json:"yao:type_id,omitempty"` // Yao user type ID
YaoType *OIDCTypeInfo `json:"yao:type,omitempty"` // Yao user type info YaoType *OIDCTypeInfo `json:"yao:type,omitempty"` // Yao user type info
YaoMember *OIDCMemberInfo `json:"yao:member,omitempty"` // Yao member profile info (for team context) 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 response for debugging and custom processing
Raw map[string]interface{} `json:"raw,omitempty"` // Original provider response 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

@ -837,6 +837,7 @@ func GinEntryRegister(c *gin.Context) {
// Auto-login or invite_required: Generate tokens using LoginByUserID // Auto-login or invite_required: Generate tokens using LoginByUserID
// For invite_required, LoginByUserID will detect pending_invite status and return temporary token // For invite_required, LoginByUserID will detect pending_invite status and return temporary token
loginCtx := makeLoginContext(c) loginCtx := makeLoginContext(c)
loginCtx.AuthSource = "password" // Registered via email+password
loginResponse, err := LoginByUserID(userID, loginCtx) loginResponse, err := LoginByUserID(userID, loginCtx)
if err != nil { if err != nil {
log.Error("Failed to auto-login after registration: %v", err) 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) // Login using LoginByUserID (all status checks are handled inside)
loginCtx := makeLoginContext(c) loginCtx := makeLoginContext(c)
loginCtx.RememberMe = req.RememberMe // Set Remember Me from request loginCtx.RememberMe = req.RememberMe // Set Remember Me from request
loginCtx.AuthSource = "password" // Logged in via email+password
loginResponse, err := LoginByUserID(userID, loginCtx) loginResponse, err := LoginByUserID(userID, loginCtx)
if err != nil { if err != nil {
log.Error("Failed to login user %s: %v", userID, err) log.Error("Failed to login user %s: %v", userID, err)

View file

@ -193,6 +193,11 @@ func LoginThirdParty(providerID string, userinfo *oauthtypes.OIDCUserInfo, login
return nil, err 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) return LoginByUserID(userID, loginCtx)
} }
@ -334,11 +339,17 @@ func LoginByUserID(userid string, loginCtx *LoginContext) (*LoginResponse, error
// Sign temporary access token for Team Selection // Sign temporary access token for Team Selection
var teamSelectionExpire int = 10 * 60 // 10 minutes 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{}) extraClaims := make(map[string]interface{})
if loginCtx != nil && loginCtx.RememberMe { if loginCtx != nil && loginCtx.RememberMe {
extraClaims["remember_me"] = true 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) accessToken, err := oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, ScopeTeamSelection, subject, teamSelectionExpire, extraClaims)
if err != nil { if err != nil {
@ -579,6 +590,23 @@ func issueTokens(ctx context.Context, params *IssueTokensParams) (*LoginResponse
oidcUserInfo.Sub = params.Subject oidcUserInfo.Sub = params.Subject
oidcUserInfo.YaoUserID = params.UserID // Add original user ID 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 // Prepare extra claims for access token
extraClaims := make(map[string]interface{}) extraClaims := make(map[string]interface{})

View file

@ -174,6 +174,7 @@ func authback(c *gin.Context) {
// LoginThirdParty(providerID, userInfo) // LoginThirdParty(providerID, userInfo)
loginCtx := makeLoginContext(c) 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 // Use locale from params, fallback to "en" if not provided
locale := params.Locale locale := params.Locale

View file

@ -414,6 +414,8 @@ func GinTeamSelection(c *gin.Context) {
// Preserve Remember Me state from temporary token // Preserve Remember Me state from temporary token
loginCtx.RememberMe = authInfo.RememberMe 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 // Login with selected team
loginResponse, err := LoginByTeamID(authInfo.UserID, req.TeamID, loginCtx) 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) // Fallback to masked email if name is empty (for privacy protection)
if inviterInfo.Name == "" { 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 // IssueTokensParams represents parameters for issueTokens function
type IssueTokensParams struct { type IssueTokensParams struct {
UserID string // User ID UserID string // User ID
TeamID string // Team ID (empty for personal account) TeamID string // Team ID (empty for personal account)
Team map[string]interface{} // Team data (nil 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) Member map[string]interface{} // Member profile data (nil for personal account or if not available)
User map[string]interface{} // User data User map[string]interface{} // User data
Subject string // Token subject Subject string // Token subject
Scopes []string // Token scopes Scopes []string // Token scopes
LoginCtx *LoginContext // Login context (IP, user agent, etc.) LoginCtx *LoginContext // Login context (IP, user agent, etc.)
AuthSource string // Authentication source (password, google, github, etc.)
} }
// ==== Entry Verification Types ==== // ==== Entry Verification Types ====

View file

@ -269,7 +269,8 @@ func attachAccount(group *gin.RouterGroup, oauth types.OAuth) {
account.Use(oauth.Guard) account.Use(oauth.Guard)
// Password Management // 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/request", placeholder) // Request password reset (public, rate-limited)
account.POST("/password/reset/verify", placeholder) // Verify reset token and set new password (public) 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 // 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 *** // Keeps the first and last character of the local part, masks the middle with ***
// Examples: // Examples:
// - "john.doe@example.com" -> "j***e@example.com" // - "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" // - "ab@example.com" -> "a***b@example.com"
// //
// Returns empty string for invalid email or empty input // Returns empty string for invalid email or empty input
func maskEmail(email string) string { func MaskEmail(email string) string {
if email == "" { if email == "" {
return "" return ""
} }