Merge pull request #1190 from trheyi/main
Enhance user login functionality with detailed context tracking
This commit is contained in:
commit
fa7dbc6e4a
13 changed files with 680 additions and 464 deletions
282
data/bindata.go
282
data/bindata.go
File diff suppressed because one or more lines are too long
|
|
@ -63,7 +63,8 @@ var (
|
||||||
"id", "user_id", "preferred_username", "email", "email_verified", "name", "given_name", "family_name",
|
"id", "user_id", "preferred_username", "email", "email_verified", "name", "given_name", "family_name",
|
||||||
"middle_name", "nickname", "profile", "picture", "website", "gender", "birthdate", "zoneinfo", "locale",
|
"middle_name", "nickname", "profile", "picture", "website", "gender", "birthdate", "zoneinfo", "locale",
|
||||||
"phone_number", "phone_number_verified", "address", "theme", "status", "role_id", "type_id",
|
"phone_number", "phone_number_verified", "address", "theme", "status", "role_id", "type_id",
|
||||||
"mfa_enabled", "last_login_at", "metadata", "created_at", "updated_at",
|
"mfa_enabled", "last_login_at", "last_login_ip", "last_login_user_agent", "last_login_device",
|
||||||
|
"last_login_platform", "metadata", "created_at", "updated_at",
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultBasicUserFields contains minimal fields for basic user info
|
// DefaultBasicUserFields contains minimal fields for basic user info
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"github.com/yaoapp/gou/model"
|
"github.com/yaoapp/gou/model"
|
||||||
"github.com/yaoapp/kun/log"
|
"github.com/yaoapp/kun/log"
|
||||||
"github.com/yaoapp/kun/maps"
|
"github.com/yaoapp/kun/maps"
|
||||||
|
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -465,11 +466,26 @@ func (u *DefaultUser) DeleteUser(ctx context.Context, userID string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateUserLastLogin updates the user's last login timestamp
|
// UpdateUserLastLogin updates the user's last login timestamp and context
|
||||||
func (u *DefaultUser) UpdateUserLastLogin(ctx context.Context, userID string, ip string) error {
|
func (u *DefaultUser) UpdateUserLastLogin(ctx context.Context, userID string, loginCtx *types.LoginContext) error {
|
||||||
updateData := maps.MapStrAny{
|
updateData := maps.MapStrAny{
|
||||||
"last_login_at": time.Now(),
|
"last_login_at": time.Now(),
|
||||||
"last_login_ip": ip,
|
}
|
||||||
|
|
||||||
|
// Add login context fields if provided
|
||||||
|
if loginCtx != nil {
|
||||||
|
if loginCtx.IP != "" {
|
||||||
|
updateData["last_login_ip"] = loginCtx.IP
|
||||||
|
}
|
||||||
|
if loginCtx.UserAgent != "" {
|
||||||
|
updateData["last_login_user_agent"] = loginCtx.UserAgent
|
||||||
|
}
|
||||||
|
if loginCtx.Device != "" {
|
||||||
|
updateData["last_login_device"] = loginCtx.Device
|
||||||
|
}
|
||||||
|
if loginCtx.Platform != "" {
|
||||||
|
updateData["last_login_platform"] = loginCtx.Platform
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return u.UpdateUser(ctx, userID, updateData)
|
return u.UpdateUser(ctx, userID, updateData)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/yaoapp/kun/maps"
|
"github.com/yaoapp/kun/maps"
|
||||||
|
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestUserBasicOperations(t *testing.T) {
|
func TestUserBasicOperations(t *testing.T) {
|
||||||
|
|
@ -202,13 +203,40 @@ func TestUserBasicOperations(t *testing.T) {
|
||||||
|
|
||||||
// Test UpdateUserLastLogin
|
// Test UpdateUserLastLogin
|
||||||
t.Run("UpdateUserLastLogin", func(t *testing.T) {
|
t.Run("UpdateUserLastLogin", func(t *testing.T) {
|
||||||
err := testProvider.UpdateUserLastLogin(ctx, testUserID, "127.0.0.1")
|
loginCtx := &types.LoginContext{
|
||||||
|
IP: "127.0.0.1",
|
||||||
|
UserAgent: "Mozilla/5.0 (Test Browser)",
|
||||||
|
Device: "desktop",
|
||||||
|
Platform: "web",
|
||||||
|
}
|
||||||
|
err := testProvider.UpdateUserLastLogin(ctx, testUserID, loginCtx)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// Verify last_login_at was updated
|
// Verify last_login_at and context were updated
|
||||||
user, err := testProvider.GetUser(ctx, testUserID)
|
user, err := testProvider.GetUser(ctx, testUserID)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotNil(t, user["last_login_at"])
|
assert.NotNil(t, user["last_login_at"])
|
||||||
|
assert.Equal(t, "127.0.0.1", user["last_login_ip"])
|
||||||
|
assert.Equal(t, "Mozilla/5.0 (Test Browser)", user["last_login_user_agent"])
|
||||||
|
assert.Equal(t, "desktop", user["last_login_device"])
|
||||||
|
assert.Equal(t, "web", user["last_login_platform"])
|
||||||
|
|
||||||
|
// Test with nil loginCtx (should only update timestamp)
|
||||||
|
err = testProvider.UpdateUserLastLogin(ctx, testUserID, nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Test with partial loginCtx (only IP)
|
||||||
|
partialCtx := &types.LoginContext{
|
||||||
|
IP: "192.168.1.1",
|
||||||
|
}
|
||||||
|
err = testProvider.UpdateUserLastLogin(ctx, testUserID, partialCtx)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify IP was updated but other fields remain from previous login
|
||||||
|
user, err = testProvider.GetUser(ctx, testUserID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "192.168.1.1", user["last_login_ip"])
|
||||||
|
assert.Equal(t, "Mozilla/5.0 (Test Browser)", user["last_login_user_agent"])
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test UpdateUserStatus
|
// Test UpdateUserStatus
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,7 @@ type UserProvider interface {
|
||||||
CreateUser(ctx context.Context, userData maps.MapStrAny) (string, error)
|
CreateUser(ctx context.Context, userData maps.MapStrAny) (string, error)
|
||||||
UpdateUser(ctx context.Context, userID string, userData maps.MapStrAny) error
|
UpdateUser(ctx context.Context, userID string, userData maps.MapStrAny) error
|
||||||
DeleteUser(ctx context.Context, userID string) error
|
DeleteUser(ctx context.Context, userID string) error
|
||||||
UpdateUserLastLogin(ctx context.Context, userID string, ip string) error
|
UpdateUserLastLogin(ctx context.Context, userID string, loginCtx *LoginContext) error
|
||||||
UpdateUserStatus(ctx context.Context, userID string, status string) error
|
UpdateUserStatus(ctx context.Context, userID string, status string) error
|
||||||
|
|
||||||
// User List and Search
|
// User List and Search
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,43 @@ func (user OIDCUserInfo) Map() map[string]interface{} {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add Yao custom fields with namespace
|
||||||
|
if user.YaoTenantID != "" {
|
||||||
|
result["yao:tenant_id"] = user.YaoTenantID
|
||||||
|
}
|
||||||
|
if user.YaoTeamID != "" {
|
||||||
|
result["yao:team_id"] = user.YaoTeamID
|
||||||
|
}
|
||||||
|
if user.YaoIsOwner != nil {
|
||||||
|
result["yao:is_owner"] = user.YaoIsOwner
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Yao team info if present and has content
|
||||||
|
if user.YaoTeam != nil {
|
||||||
|
teamMap := make(map[string]interface{})
|
||||||
|
if user.YaoTeam.TeamID != "" {
|
||||||
|
teamMap["team_id"] = user.YaoTeam.TeamID
|
||||||
|
}
|
||||||
|
if user.YaoTeam.Logo != "" {
|
||||||
|
teamMap["logo"] = user.YaoTeam.Logo
|
||||||
|
}
|
||||||
|
if user.YaoTeam.Name != "" {
|
||||||
|
teamMap["name"] = user.YaoTeam.Name
|
||||||
|
}
|
||||||
|
if user.YaoTeam.OwnerID != "" {
|
||||||
|
teamMap["owner_id"] = user.YaoTeam.OwnerID
|
||||||
|
}
|
||||||
|
if user.YaoTeam.Description != "" {
|
||||||
|
teamMap["description"] = user.YaoTeam.Description
|
||||||
|
}
|
||||||
|
if converted := unixToMySQL(user.YaoTeam.UpdatedAt); converted != nil {
|
||||||
|
teamMap["updated_at"] = converted
|
||||||
|
}
|
||||||
|
if len(teamMap) > 0 {
|
||||||
|
result["yao:team"] = teamMap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Include raw data if available
|
// Include raw data if available
|
||||||
// if user.Raw != nil {
|
// if user.Raw != nil {
|
||||||
// // Merge raw data, but let structured fields take precedence
|
// // Merge raw data, but let structured fields take precedence
|
||||||
|
|
@ -207,6 +244,45 @@ func MakeOIDCUserInfo(user map[string]interface{}) *OIDCUserInfo {
|
||||||
userInfo.Address = address
|
userInfo.Address = address
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Yao custom fields with namespace
|
||||||
|
if tenantID, ok := user["yao:tenant_id"].(string); ok {
|
||||||
|
userInfo.YaoTenantID = tenantID
|
||||||
|
}
|
||||||
|
if teamID, ok := user["yao:team_id"].(string); ok {
|
||||||
|
userInfo.YaoTeamID = teamID
|
||||||
|
}
|
||||||
|
if isOwner, ok := user["yao:is_owner"].(bool); ok {
|
||||||
|
userInfo.YaoIsOwner = &isOwner
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yao team info (nested object)
|
||||||
|
if teamData, ok := user["yao:team"].(map[string]interface{}); ok {
|
||||||
|
team := &OIDCTeamInfo{}
|
||||||
|
if teamID, ok := teamData["team_id"].(string); ok {
|
||||||
|
team.TeamID = teamID
|
||||||
|
}
|
||||||
|
if logo, ok := teamData["logo"].(string); ok {
|
||||||
|
team.Logo = logo
|
||||||
|
}
|
||||||
|
if name, ok := teamData["name"].(string); ok {
|
||||||
|
team.Name = name
|
||||||
|
}
|
||||||
|
if ownerID, ok := teamData["owner_id"].(string); ok {
|
||||||
|
team.OwnerID = ownerID
|
||||||
|
}
|
||||||
|
if description, ok := teamData["description"].(string); ok {
|
||||||
|
team.Description = description
|
||||||
|
}
|
||||||
|
if updatedAt, ok := teamData["updated_at"]; ok {
|
||||||
|
if converted := toUnixTimestamp(updatedAt); converted != nil {
|
||||||
|
if unixTime, ok := converted.(int64); ok {
|
||||||
|
team.UpdatedAt = &unixTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
userInfo.YaoTeam = team
|
||||||
|
}
|
||||||
|
|
||||||
return userInfo
|
return userInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,15 @@ import (
|
||||||
"github.com/golang-jwt/jwt/v4"
|
"github.com/golang-jwt/jwt/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// LoginContext represents the context information for login
|
||||||
|
type LoginContext struct {
|
||||||
|
IP string `json:"ip,omitempty"` // Client IP address
|
||||||
|
UserAgent string `json:"user_agent,omitempty"` // Client user agent
|
||||||
|
Device string `json:"device,omitempty"` // Device type (e.g., "mobile", "desktop", "tablet")
|
||||||
|
Platform string `json:"platform,omitempty"` // Platform (e.g., "ios", "android", "web")
|
||||||
|
Location string `json:"location,omitempty"` // Geographic location (optional)
|
||||||
|
}
|
||||||
|
|
||||||
// MFAOptions contains configuration for MFA operations
|
// MFAOptions contains configuration for MFA operations
|
||||||
type MFAOptions struct {
|
type MFAOptions struct {
|
||||||
Issuer string // Issuer name displayed in authenticator app
|
Issuer string // Issuer name displayed in authenticator app
|
||||||
|
|
@ -680,10 +689,26 @@ type OIDCUserInfo struct {
|
||||||
// OIDC Address Claim (structured)
|
// OIDC Address Claim (structured)
|
||||||
Address *OIDCAddress `json:"address,omitempty"` // Physical mailing address
|
Address *OIDCAddress `json:"address,omitempty"` // Physical mailing address
|
||||||
|
|
||||||
|
// Additional custom claims with namespace
|
||||||
|
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
|
||||||
|
|
||||||
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OIDCTeamInfo represents team information based on OIDC standard
|
||||||
|
type OIDCTeamInfo struct {
|
||||||
|
TeamID string `json:"team_id,omitempty"` // Team identifier
|
||||||
|
Logo string `json:"logo,omitempty"` // Team logo
|
||||||
|
Name string `json:"name,omitempty"` // Team name
|
||||||
|
OwnerID string `json:"owner_id,omitempty"` // Team owner ID
|
||||||
|
Description string `json:"description,omitempty"` // Team description
|
||||||
|
UpdatedAt *int64 `json:"updated_at,omitempty"` // Team updated at (seconds since epoch)
|
||||||
|
}
|
||||||
|
|
||||||
// OIDCAddress represents the OIDC address claim structure
|
// OIDCAddress represents the OIDC address claim structure
|
||||||
type OIDCAddress struct {
|
type OIDCAddress struct {
|
||||||
Formatted string `json:"formatted,omitempty"` // Full mailing address
|
Formatted string `json:"formatted,omitempty"` // Full mailing address
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ func getCaptcha(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoginThirdParty is the handler for third party login
|
// LoginThirdParty is the handler for third party login
|
||||||
func LoginThirdParty(providerID string, userinfo *oauthtypes.OIDCUserInfo, ip string) (*LoginResponse, error) {
|
func LoginThirdParty(providerID string, userinfo *oauthtypes.OIDCUserInfo, loginCtx *LoginContext) (*LoginResponse, error) {
|
||||||
|
|
||||||
// Get provider
|
// Get provider
|
||||||
provider, err := GetProvider(providerID)
|
provider, err := GetProvider(providerID)
|
||||||
|
|
@ -135,12 +135,11 @@ func LoginThirdParty(providerID string, userinfo *oauthtypes.OIDCUserInfo, ip st
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return LoginByUserID(userID, ip)
|
return LoginByUserID(userID, loginCtx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoginByUserID is the handler for login
|
// LoginByUserID is the handler for login by user ID
|
||||||
func LoginByUserID(userid string, ip string) (*LoginResponse, error) {
|
func LoginByUserID(userid string, loginCtx *LoginContext) (*LoginResponse, error) {
|
||||||
|
|
||||||
// Get User
|
// Get User
|
||||||
userProvider, err := oauth.OAuth.GetUserProvider()
|
userProvider, err := oauth.OAuth.GetUserProvider()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -172,7 +171,6 @@ func LoginByUserID(userid string, ip string) (*LoginResponse, error) {
|
||||||
|
|
||||||
// If MFA enabled, generate MFA token
|
// If MFA enabled, generate MFA token
|
||||||
if mfaEnabled {
|
if mfaEnabled {
|
||||||
|
|
||||||
// Sign temporary access token for MFA
|
// Sign temporary access token for MFA
|
||||||
var mfaExpire int = 10 * 60 // 10 minutes
|
var mfaExpire int = 10 * 60 // 10 minutes
|
||||||
accessToken, err := oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, ScopeMFAVerification, subject, mfaExpire)
|
accessToken, err := oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, ScopeMFAVerification, subject, mfaExpire)
|
||||||
|
|
@ -192,9 +190,11 @@ func LoginByUserID(userid string, ip string) (*LoginResponse, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update Last Login
|
// Update Last Login
|
||||||
err = userProvider.UpdateUserLastLogin(ctx, userid, ip)
|
if loginCtx != nil {
|
||||||
if err != nil {
|
err = userProvider.UpdateUserLastLogin(ctx, userid, loginCtx)
|
||||||
log.Warn("Failed to update last login: %s", err.Error())
|
if err != nil {
|
||||||
|
log.Warn("Failed to update last login: %s", err.Error())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count User Teams
|
// Count User Teams
|
||||||
|
|
@ -224,24 +224,146 @@ func LoginByUserID(userid string, ip string) (*LoginResponse, error) {
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// OIDC Token
|
// Issue tokens without team context
|
||||||
|
return issueTokens(ctx, userid, "", nil, user, subject, scopes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginByTeamID is the handler for login by team ID (after team selection)
|
||||||
|
func LoginByTeamID(userid string, teamID string, loginCtx *LoginContext) (*LoginResponse, error) {
|
||||||
|
// Get User
|
||||||
|
userProvider, err := oauth.OAuth.GetUserProvider()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Get user data with scopes
|
||||||
|
user, err := userProvider.GetUserWithScopes(ctx, userid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
yaoClientConfig := GetYaoClientConfig()
|
||||||
|
var scopes []string = yaoClientConfig.Scopes
|
||||||
|
if v, ok := user["scopes"].([]string); ok {
|
||||||
|
scopes = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create subject
|
||||||
|
subject, err := oauth.OAuth.Subject(yaoClientConfig.ClientID, userid)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("Failed to store user fingerprint: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle personal account (no team)
|
||||||
|
if teamID == "" || teamID == "personal" {
|
||||||
|
return issueTokens(ctx, userid, "", nil, user, subject, scopes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user is a member of the team and get team details
|
||||||
|
team, err := userProvider.GetTeamByMember(ctx, teamID, userid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("access denied: you are not a member of this team")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Last Login
|
||||||
|
if loginCtx != nil {
|
||||||
|
err = userProvider.UpdateUserLastLogin(ctx, userid, loginCtx)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("Failed to update last login: %s", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue tokens with team context
|
||||||
|
return issueTokens(ctx, userid, teamID, team, user, subject, scopes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// issueTokens is the core function that issues all necessary tokens (ID token, access token, refresh token)
|
||||||
|
func issueTokens(ctx context.Context, userid string, teamID string, team map[string]interface{}, user map[string]interface{}, subject string, scopes []string) (*LoginResponse, error) {
|
||||||
|
yaoClientConfig := GetYaoClientConfig()
|
||||||
|
|
||||||
|
// Prepare OIDC user info
|
||||||
oidcUserInfo := oauthtypes.MakeOIDCUserInfo(user)
|
oidcUserInfo := oauthtypes.MakeOIDCUserInfo(user)
|
||||||
oidcUserInfo.Sub = subject
|
oidcUserInfo.Sub = subject
|
||||||
oidcToken, err := oauth.OAuth.SignIDToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), yaoClientConfig.ExpiresIn, oidcUserInfo)
|
|
||||||
if err != nil {
|
// Prepare extra claims for team context
|
||||||
return nil, err
|
var extraClaims map[string]interface{}
|
||||||
|
if teamID != "" && team != nil {
|
||||||
|
extraClaims = map[string]interface{}{
|
||||||
|
"team_id": teamID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tenant_id if available from the team
|
||||||
|
if tenantID := toString(team["tenant_id"]); tenantID != "" {
|
||||||
|
extraClaims["tenant_id"] = tenantID
|
||||||
|
oidcUserInfo.YaoTenantID = tenantID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add team info to OIDC user info
|
||||||
|
oidcUserInfo.YaoTeamID = teamID
|
||||||
|
teamInfo := &oauthtypes.OIDCTeamInfo{}
|
||||||
|
if teamIDVal := toString(team["team_id"]); teamIDVal != "" {
|
||||||
|
teamInfo.TeamID = teamIDVal
|
||||||
|
}
|
||||||
|
if logo := toString(team["logo"]); logo != "" {
|
||||||
|
teamInfo.Logo = logo
|
||||||
|
}
|
||||||
|
if name := toString(team["name"]); name != "" {
|
||||||
|
teamInfo.Name = name
|
||||||
|
}
|
||||||
|
if description := toString(team["description"]); description != "" {
|
||||||
|
teamInfo.Description = description
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add owner_id if available from the team (only check once)
|
||||||
|
if ownerID := toString(team["owner_id"]); ownerID != "" {
|
||||||
|
extraClaims["owner_id"] = ownerID
|
||||||
|
teamInfo.OwnerID = ownerID
|
||||||
|
|
||||||
|
// Check if user is owner
|
||||||
|
if ownerID == userid {
|
||||||
|
isOwner := true
|
||||||
|
oidcUserInfo.YaoIsOwner = &isOwner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
oidcUserInfo.YaoTeam = teamInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
// Access Token
|
// Sign OIDC Token
|
||||||
accessToken, err := oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.ExpiresIn)
|
var oidcToken string
|
||||||
|
var err error
|
||||||
|
if extraClaims != nil {
|
||||||
|
oidcToken, err = oauth.OAuth.SignIDToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), yaoClientConfig.ExpiresIn, oidcUserInfo, extraClaims)
|
||||||
|
} else {
|
||||||
|
oidcToken, err = oauth.OAuth.SignIDToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), yaoClientConfig.ExpiresIn, oidcUserInfo)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("failed to sign OIDC token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh Token
|
// Sign Access Token
|
||||||
refreshToken, err := oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.RefreshTokenExpiresIn)
|
var accessToken string
|
||||||
|
if extraClaims != nil {
|
||||||
|
accessToken, err = oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.ExpiresIn, extraClaims)
|
||||||
|
} else {
|
||||||
|
accessToken, err = oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.ExpiresIn)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("failed to sign access token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign Refresh Token
|
||||||
|
var refreshToken string
|
||||||
|
if extraClaims != nil {
|
||||||
|
refreshToken, err = oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.RefreshTokenExpiresIn, extraClaims)
|
||||||
|
} else {
|
||||||
|
refreshToken, err = oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.RefreshTokenExpiresIn)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to sign refresh token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &LoginResponse{
|
return &LoginResponse{
|
||||||
|
|
@ -253,7 +375,7 @@ func LoginByUserID(userid string, ip string) (*LoginResponse, error) {
|
||||||
ExpiresIn: yaoClientConfig.ExpiresIn,
|
ExpiresIn: yaoClientConfig.ExpiresIn,
|
||||||
RefreshTokenExpiresIn: yaoClientConfig.RefreshTokenExpiresIn,
|
RefreshTokenExpiresIn: yaoClientConfig.RefreshTokenExpiresIn,
|
||||||
TokenType: "Bearer",
|
TokenType: "Bearer",
|
||||||
MFAEnabled: mfaEnabled,
|
MFAEnabled: toBool(user["mfa_enabled"]),
|
||||||
Scope: strings.Join(scopes, " "),
|
Scope: strings.Join(scopes, " "),
|
||||||
Status: LoginStatusSuccess,
|
Status: LoginStatusSuccess,
|
||||||
}, nil
|
}, nil
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
@ -174,7 +173,8 @@ func authback(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoginThirdParty(providerID, userInfo)
|
// LoginThirdParty(providerID, userInfo)
|
||||||
loginResponse, err := LoginThirdParty(providerID, userInfo, userIPAddress(c))
|
loginCtx := makeLoginContext(c)
|
||||||
|
loginResponse, err := LoginThirdParty(providerID, userInfo, loginCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorResp := &response.ErrorResponse{
|
errorResp := &response.ErrorResponse{
|
||||||
Code: response.ErrInvalidRequest.Code,
|
Code: response.ErrInvalidRequest.Code,
|
||||||
|
|
@ -505,154 +505,3 @@ func validateState(providerID, sid, state string) error {
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getUserRealIP is the function to get the real IP address of the user
|
|
||||||
func userIPAddress(c *gin.Context) string {
|
|
||||||
// Define HTTP headers to check, ordered by priority
|
|
||||||
headers := []string{
|
|
||||||
"X-Real-IP", // Nginx proxy_set_header X-Real-IP
|
|
||||||
"X-Forwarded-For", // Standard proxy header
|
|
||||||
"X-Client-IP", // Apache mod_remoteip, Squid
|
|
||||||
"X-Forwarded", // Legacy proxy standard
|
|
||||||
"X-Cluster-Client-IP", // Cluster environment
|
|
||||||
"Forwarded-For", // Pre-RFC 7239 standard
|
|
||||||
"Forwarded", // RFC 7239 standard
|
|
||||||
"CF-Connecting-IP", // Cloudflare
|
|
||||||
"True-Client-IP", // Akamai, CloudFlare Enterprise
|
|
||||||
"X-Original-Forwarded-For", // Original forwarded
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check each header one by one
|
|
||||||
for _, header := range headers {
|
|
||||||
value := c.GetHeader(header)
|
|
||||||
if value == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle cases that may contain multiple IPs (e.g., X-Forwarded-For: client, proxy1, proxy2)
|
|
||||||
ips := parseIPList(value)
|
|
||||||
for _, ip := range ips {
|
|
||||||
if isValidPublicIP(ip) {
|
|
||||||
return ip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If none found, use the remote address of the connection
|
|
||||||
remoteAddr := c.Request.RemoteAddr
|
|
||||||
if ip := extractIPFromAddr(remoteAddr); ip != "" && isValidPublicIP(ip) {
|
|
||||||
return ip
|
|
||||||
}
|
|
||||||
|
|
||||||
// Final fallback, return RemoteAddr (may include port)
|
|
||||||
return extractIPFromAddr(remoteAddr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseIPList parses IP list string, handles comma-separated multiple IPs
|
|
||||||
func parseIPList(value string) []string {
|
|
||||||
var ips []string
|
|
||||||
|
|
||||||
// Handle RFC 7239 Forwarded header format: for=192.0.2.60;proto=http;by=203.0.113.43
|
|
||||||
if strings.Contains(value, "for=") {
|
|
||||||
parts := strings.Split(value, ";")
|
|
||||||
for _, part := range parts {
|
|
||||||
part = strings.TrimSpace(part)
|
|
||||||
if strings.HasPrefix(part, "for=") {
|
|
||||||
ip := strings.TrimPrefix(part, "for=")
|
|
||||||
// Remove possible quotes and brackets
|
|
||||||
ip = strings.Trim(ip, "\"[]")
|
|
||||||
if ip != "" {
|
|
||||||
ips = append(ips, ip)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Handle comma-separated IP list
|
|
||||||
parts := strings.Split(value, ",")
|
|
||||||
for _, part := range parts {
|
|
||||||
ip := strings.TrimSpace(part)
|
|
||||||
if ip != "" {
|
|
||||||
ips = append(ips, ip)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ips
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractIPFromAddr extracts IP from address (which may include port)
|
|
||||||
func extractIPFromAddr(addr string) string {
|
|
||||||
if addr == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle IPv6 format [::1]:8080
|
|
||||||
if strings.HasPrefix(addr, "[") {
|
|
||||||
if idx := strings.Index(addr, "]:"); idx != -1 {
|
|
||||||
return addr[1:idx]
|
|
||||||
}
|
|
||||||
return strings.Trim(addr, "[]")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle IPv4 format 127.0.0.1:8080
|
|
||||||
if idx := strings.LastIndex(addr, ":"); idx != -1 {
|
|
||||||
return addr[:idx]
|
|
||||||
}
|
|
||||||
|
|
||||||
return addr
|
|
||||||
}
|
|
||||||
|
|
||||||
// isValidPublicIP checks if the IP is a valid public IP
|
|
||||||
func isValidPublicIP(ipStr string) bool {
|
|
||||||
ip := net.ParseIP(ipStr)
|
|
||||||
if ip == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out private IPs, local IPs, etc.
|
|
||||||
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's a private IP range
|
|
||||||
if ip.To4() != nil {
|
|
||||||
// IPv4 private address ranges
|
|
||||||
return !isPrivateIPv4(ip)
|
|
||||||
}
|
|
||||||
// IPv6 private address ranges
|
|
||||||
return !isPrivateIPv6(ip)
|
|
||||||
}
|
|
||||||
|
|
||||||
// isPrivateIPv4 checks if it's an IPv4 private address
|
|
||||||
func isPrivateIPv4(ip net.IP) bool {
|
|
||||||
// 10.0.0.0/8
|
|
||||||
if ip[12] == 10 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// 172.16.0.0/12
|
|
||||||
if ip[12] == 172 && ip[13] >= 16 && ip[13] <= 31 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// 192.168.0.0/16
|
|
||||||
if ip[12] == 192 && ip[13] == 168 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// 169.254.0.0/16 (Link-Local)
|
|
||||||
if ip[12] == 169 && ip[13] == 254 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// isPrivateIPv6 checks if it's an IPv6 private address
|
|
||||||
func isPrivateIPv6(ip net.IP) bool {
|
|
||||||
// fc00::/7 (Unique Local)
|
|
||||||
if ip[0] >= 0xfc && ip[0] <= 0xfd {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// fe80::/10 (Link-Local)
|
|
||||||
if ip[0] == 0xfe && (ip[1]&0xc0) == 0x80 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import (
|
||||||
"github.com/yaoapp/kun/maps"
|
"github.com/yaoapp/kun/maps"
|
||||||
"github.com/yaoapp/yao/openapi/oauth"
|
"github.com/yaoapp/yao/openapi/oauth"
|
||||||
"github.com/yaoapp/yao/openapi/oauth/providers/user"
|
"github.com/yaoapp/yao/openapi/oauth/providers/user"
|
||||||
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
|
|
||||||
"github.com/yaoapp/yao/openapi/response"
|
"github.com/yaoapp/yao/openapi/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -345,138 +344,16 @@ func GinTeamSelection(c *gin.Context) {
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
// Handle personal account selection (no team_id or "personal")
|
// Prepare login context with full device/platform information
|
||||||
var extraClaims map[string]interface{}
|
loginCtx := makeLoginContext(c)
|
||||||
var selectedTeam map[string]interface{}
|
|
||||||
|
|
||||||
if req.TeamID == "" || req.TeamID == "personal" {
|
// Login with selected team
|
||||||
// Personal account - no team_id in token
|
loginResponse, err := LoginByTeamID(authInfo.UserID, req.TeamID, loginCtx)
|
||||||
extraClaims = nil
|
|
||||||
} else {
|
|
||||||
// Team account - verify membership and add team_id to token
|
|
||||||
provider, err := getUserProvider()
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Failed to get user provider: %v", err)
|
|
||||||
errorResp := &response.ErrorResponse{
|
|
||||||
Code: response.ErrServerError.Code,
|
|
||||||
ErrorDescription: "Failed to initialize user provider",
|
|
||||||
}
|
|
||||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify user is a member of the team and get team details in one call
|
|
||||||
selectedTeam, err = provider.GetTeamByMember(ctx, req.TeamID, authInfo.UserID)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Failed to verify team membership: %v", err)
|
|
||||||
errorResp := &response.ErrorResponse{
|
|
||||||
Code: response.ErrAccessDenied.Code,
|
|
||||||
ErrorDescription: "Access denied: you are not a member of this team",
|
|
||||||
}
|
|
||||||
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare extra claims with team_id and tenant_id (if available)
|
|
||||||
extraClaims = map[string]interface{}{
|
|
||||||
"team_id": req.TeamID,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add tenant_id if available from the team
|
|
||||||
if tenantID := toString(selectedTeam["tenant_id"]); tenantID != "" {
|
|
||||||
extraClaims["tenant_id"] = tenantID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user provider and user data
|
|
||||||
provider, err := getUserProvider()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Failed to get user provider: %v", err)
|
log.Error("Failed to login with team: %v", err)
|
||||||
errorResp := &response.ErrorResponse{
|
errorResp := &response.ErrorResponse{
|
||||||
Code: response.ErrServerError.Code,
|
Code: response.ErrServerError.Code,
|
||||||
ErrorDescription: "Failed to initialize user provider",
|
ErrorDescription: "Failed to login with team: " + err.Error(),
|
||||||
}
|
|
||||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user data with scopes
|
|
||||||
user, err := provider.GetUserWithScopes(ctx, authInfo.UserID)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Failed to get user: %v", err)
|
|
||||||
errorResp := &response.ErrorResponse{
|
|
||||||
Code: response.ErrServerError.Code,
|
|
||||||
ErrorDescription: "Failed to retrieve user information",
|
|
||||||
}
|
|
||||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get Yao client config
|
|
||||||
yaoClientConfig := GetYaoClientConfig()
|
|
||||||
var scopes []string = yaoClientConfig.Scopes
|
|
||||||
if v, ok := user["scopes"].([]string); ok {
|
|
||||||
scopes = v
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get subject from auth info (it should be already stored)
|
|
||||||
subject := authInfo.Subject
|
|
||||||
if subject == "" {
|
|
||||||
// Fallback: create new subject if not available
|
|
||||||
subject, err = oauth.OAuth.Subject(yaoClientConfig.ClientID, authInfo.UserID)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn("Failed to get subject: %s", err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sign OIDC Token (with or without team_id based on extraClaims)
|
|
||||||
oidcUserInfo := oauthtypes.MakeOIDCUserInfo(user)
|
|
||||||
oidcUserInfo.Sub = subject
|
|
||||||
var oidcToken string
|
|
||||||
if extraClaims != nil {
|
|
||||||
oidcToken, err = oauth.OAuth.SignIDToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), yaoClientConfig.ExpiresIn, oidcUserInfo, extraClaims)
|
|
||||||
} else {
|
|
||||||
oidcToken, err = oauth.OAuth.SignIDToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), yaoClientConfig.ExpiresIn, oidcUserInfo)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Failed to sign OIDC token: %v", err)
|
|
||||||
errorResp := &response.ErrorResponse{
|
|
||||||
Code: response.ErrServerError.Code,
|
|
||||||
ErrorDescription: "Failed to sign OIDC token",
|
|
||||||
}
|
|
||||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sign Access Token (with or without team_id based on extraClaims)
|
|
||||||
var accessToken string
|
|
||||||
if extraClaims != nil {
|
|
||||||
accessToken, err = oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.ExpiresIn, extraClaims)
|
|
||||||
} else {
|
|
||||||
accessToken, err = oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.ExpiresIn)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Failed to sign access token: %v", err)
|
|
||||||
errorResp := &response.ErrorResponse{
|
|
||||||
Code: response.ErrServerError.Code,
|
|
||||||
ErrorDescription: "Failed to sign access token",
|
|
||||||
}
|
|
||||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sign Refresh Token (with or without team_id based on extraClaims)
|
|
||||||
var refreshToken string
|
|
||||||
if extraClaims != nil {
|
|
||||||
refreshToken, err = oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.RefreshTokenExpiresIn, extraClaims)
|
|
||||||
} else {
|
|
||||||
refreshToken, err = oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.RefreshTokenExpiresIn)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Failed to sign refresh token: %v", err)
|
|
||||||
errorResp := &response.ErrorResponse{
|
|
||||||
Code: response.ErrServerError.Code,
|
|
||||||
ErrorDescription: "Failed to sign refresh token",
|
|
||||||
}
|
}
|
||||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||||
return
|
return
|
||||||
|
|
@ -491,20 +368,6 @@ func GinTeamSelection(c *gin.Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare login response
|
|
||||||
loginResponse := &LoginResponse{
|
|
||||||
UserID: authInfo.UserID,
|
|
||||||
Subject: subject,
|
|
||||||
AccessToken: accessToken,
|
|
||||||
IDToken: oidcToken,
|
|
||||||
RefreshToken: refreshToken,
|
|
||||||
ExpiresIn: yaoClientConfig.ExpiresIn,
|
|
||||||
RefreshTokenExpiresIn: yaoClientConfig.RefreshTokenExpiresIn,
|
|
||||||
TokenType: "Bearer",
|
|
||||||
Scope: strings.Join(scopes, " "),
|
|
||||||
Status: LoginStatusSuccess,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send secure cookies (access token, refresh token, and session ID)
|
// Send secure cookies (access token, refresh token, and session ID)
|
||||||
SendLoginCookies(c, loginResponse, "")
|
SendLoginCookies(c, loginResponse, "")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,9 @@ type LoginSuccessResponse struct {
|
||||||
Status LoginStatus `json:"status,omitempty"`
|
Status LoginStatus `json:"status,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoginContext is an alias for the oauth types LoginContext
|
||||||
|
type LoginContext = oauthtypes.LoginContext
|
||||||
|
|
||||||
// Built-in preset mapping types
|
// Built-in preset mapping types
|
||||||
const (
|
const (
|
||||||
MappingGoogle = "google"
|
MappingGoogle = "google"
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,12 @@ package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/yaoapp/gou/process"
|
"github.com/yaoapp/gou/process"
|
||||||
"github.com/yaoapp/gou/session"
|
"github.com/yaoapp/gou/session"
|
||||||
"github.com/yaoapp/kun/exception"
|
"github.com/yaoapp/kun/exception"
|
||||||
|
|
@ -190,3 +192,210 @@ func maskEmail(email string) string {
|
||||||
|
|
||||||
return masked + "@" + domain
|
return masked + "@" + domain
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseUserAgent extracts device and platform information from User-Agent string
|
||||||
|
// Returns device type ("mobile", "tablet", "desktop") and platform ("ios", "android", "web", etc.)
|
||||||
|
func parseUserAgent(userAgent string) (device string, platform string) {
|
||||||
|
if userAgent == "" {
|
||||||
|
return "unknown", "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
ua := strings.ToLower(userAgent)
|
||||||
|
|
||||||
|
// Detect platform
|
||||||
|
switch {
|
||||||
|
case strings.Contains(ua, "android"):
|
||||||
|
platform = "android"
|
||||||
|
case strings.Contains(ua, "iphone") || strings.Contains(ua, "ipad") || strings.Contains(ua, "ipod"):
|
||||||
|
platform = "ios"
|
||||||
|
case strings.Contains(ua, "windows"):
|
||||||
|
platform = "windows"
|
||||||
|
case strings.Contains(ua, "mac os x") || strings.Contains(ua, "macintosh"):
|
||||||
|
platform = "macos"
|
||||||
|
case strings.Contains(ua, "linux"):
|
||||||
|
platform = "linux"
|
||||||
|
case strings.Contains(ua, "chrome os"):
|
||||||
|
platform = "chromeos"
|
||||||
|
default:
|
||||||
|
platform = "web"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect device type
|
||||||
|
switch {
|
||||||
|
case strings.Contains(ua, "mobile") || strings.Contains(ua, "iphone") || strings.Contains(ua, "ipod"):
|
||||||
|
device = "mobile"
|
||||||
|
case strings.Contains(ua, "tablet") || strings.Contains(ua, "ipad"):
|
||||||
|
device = "tablet"
|
||||||
|
default:
|
||||||
|
device = "desktop"
|
||||||
|
}
|
||||||
|
|
||||||
|
return device, platform
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeLoginContext creates a LoginContext from gin.Context with all fields populated
|
||||||
|
func makeLoginContext(c *gin.Context) *LoginContext {
|
||||||
|
userAgent := c.GetHeader("User-Agent")
|
||||||
|
device, platform := parseUserAgent(userAgent)
|
||||||
|
|
||||||
|
return &LoginContext{
|
||||||
|
IP: userIPAddress(c),
|
||||||
|
UserAgent: userAgent,
|
||||||
|
Device: device,
|
||||||
|
Platform: platform,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network Utilities
|
||||||
|
|
||||||
|
// userIPAddress extracts the real client IP address from various HTTP headers
|
||||||
|
// Handles proxy headers, CDN headers, and direct connections
|
||||||
|
func userIPAddress(c *gin.Context) string {
|
||||||
|
// Define HTTP headers to check, ordered by priority
|
||||||
|
headers := []string{
|
||||||
|
"X-Real-IP", // Nginx proxy_set_header X-Real-IP
|
||||||
|
"X-Forwarded-For", // Standard proxy header
|
||||||
|
"X-Client-IP", // Apache mod_remoteip, Squid
|
||||||
|
"X-Forwarded", // Legacy proxy standard
|
||||||
|
"X-Cluster-Client-IP", // Cluster environment
|
||||||
|
"Forwarded-For", // Pre-RFC 7239 standard
|
||||||
|
"Forwarded", // RFC 7239 standard
|
||||||
|
"CF-Connecting-IP", // Cloudflare
|
||||||
|
"True-Client-IP", // Akamai, CloudFlare Enterprise
|
||||||
|
"X-Original-Forwarded-For", // Original forwarded
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check each header one by one
|
||||||
|
for _, header := range headers {
|
||||||
|
value := c.GetHeader(header)
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle cases that may contain multiple IPs (e.g., X-Forwarded-For: client, proxy1, proxy2)
|
||||||
|
ips := parseIPList(value)
|
||||||
|
for _, ip := range ips {
|
||||||
|
if isValidPublicIP(ip) {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If none found, use the remote address of the connection
|
||||||
|
remoteAddr := c.Request.RemoteAddr
|
||||||
|
if ip := extractIPFromAddr(remoteAddr); ip != "" && isValidPublicIP(ip) {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final fallback, return RemoteAddr (may include port)
|
||||||
|
return extractIPFromAddr(remoteAddr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseIPList parses IP list string, handles comma-separated multiple IPs
|
||||||
|
func parseIPList(value string) []string {
|
||||||
|
var ips []string
|
||||||
|
|
||||||
|
// Handle RFC 7239 Forwarded header format: for=192.0.2.60;proto=http;by=203.0.113.43
|
||||||
|
if strings.Contains(value, "for=") {
|
||||||
|
parts := strings.Split(value, ";")
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if strings.HasPrefix(part, "for=") {
|
||||||
|
ip := strings.TrimPrefix(part, "for=")
|
||||||
|
// Remove possible quotes and brackets
|
||||||
|
ip = strings.Trim(ip, "\"[]")
|
||||||
|
if ip != "" {
|
||||||
|
ips = append(ips, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle comma-separated IP list
|
||||||
|
parts := strings.Split(value, ",")
|
||||||
|
for _, part := range parts {
|
||||||
|
ip := strings.TrimSpace(part)
|
||||||
|
if ip != "" {
|
||||||
|
ips = append(ips, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ips
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractIPFromAddr extracts IP from address (which may include port)
|
||||||
|
func extractIPFromAddr(addr string) string {
|
||||||
|
if addr == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle IPv6 format [::1]:8080
|
||||||
|
if strings.HasPrefix(addr, "[") {
|
||||||
|
if idx := strings.Index(addr, "]:"); idx != -1 {
|
||||||
|
return addr[1:idx]
|
||||||
|
}
|
||||||
|
return strings.Trim(addr, "[]")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle IPv4 format 127.0.0.1:8080
|
||||||
|
if idx := strings.LastIndex(addr, ":"); idx != -1 {
|
||||||
|
return addr[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidPublicIP checks if the IP is a valid public IP
|
||||||
|
func isValidPublicIP(ipStr string) bool {
|
||||||
|
ip := net.ParseIP(ipStr)
|
||||||
|
if ip == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out private IPs, local IPs, etc.
|
||||||
|
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a private IP range
|
||||||
|
if ip.To4() != nil {
|
||||||
|
// IPv4 private address ranges
|
||||||
|
return !isPrivateIPv4(ip)
|
||||||
|
}
|
||||||
|
// IPv6 private address ranges
|
||||||
|
return !isPrivateIPv6(ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPrivateIPv4 checks if it's an IPv4 private address
|
||||||
|
func isPrivateIPv4(ip net.IP) bool {
|
||||||
|
// 10.0.0.0/8
|
||||||
|
if ip[12] == 10 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 172.16.0.0/12
|
||||||
|
if ip[12] == 172 && ip[13] >= 16 && ip[13] <= 31 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 192.168.0.0/16
|
||||||
|
if ip[12] == 192 && ip[13] == 168 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 169.254.0.0/16 (Link-Local)
|
||||||
|
if ip[12] == 169 && ip[13] == 254 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPrivateIPv6 checks if it's an IPv6 private address
|
||||||
|
func isPrivateIPv6(ip net.IP) bool {
|
||||||
|
// fc00::/7 (Unique Local)
|
||||||
|
if ip[0] >= 0xfc && ip[0] <= 0xfd {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// fe80::/10 (Link-Local)
|
||||||
|
if ip[0] == 0xfe && (ip[1]&0xc0) == 0x80 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -348,6 +348,30 @@
|
||||||
"length": 46,
|
"length": 46,
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "last_login_user_agent",
|
||||||
|
"type": "string",
|
||||||
|
"label": "Last Login User Agent",
|
||||||
|
"comment": "Last login user agent string",
|
||||||
|
"length": 512,
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "last_login_device",
|
||||||
|
"type": "string",
|
||||||
|
"label": "Last Login Device",
|
||||||
|
"comment": "Last login device type (e.g., mobile, desktop, tablet)",
|
||||||
|
"length": 50,
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "last_login_platform",
|
||||||
|
"type": "string",
|
||||||
|
"label": "Last Login Platform",
|
||||||
|
"comment": "Last login platform (e.g., ios, android, web)",
|
||||||
|
"length": 50,
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "mfa_last_verified_at",
|
"name": "mfa_last_verified_at",
|
||||||
"type": "timestamp",
|
"type": "timestamp",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue