Merge pull request #1190 from trheyi/main

Enhance user login functionality with detailed context tracking
This commit is contained in:
Max 2025-10-13 09:59:01 +08:00 committed by GitHub
commit fa7dbc6e4a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 680 additions and 464 deletions

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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