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:
parent
16a96642f5
commit
bf8d82f022
15 changed files with 578 additions and 21 deletions
|
|
@ -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",
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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{})
|
||||
|
||||
|
|
|
|||
|
|
@ -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