Merge pull request #1079 from trheyi/main

Enhance user management functionality with IP tracking and scope retr…
This commit is contained in:
Max 2025-08-03 18:31:29 +08:00 committed by GitHub
commit 06f19e0a52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 380 additions and 116 deletions

File diff suppressed because one or more lines are too long

View file

@ -3,6 +3,7 @@ package user
import (
"context"
"fmt"
"strings"
"time"
"github.com/yaoapp/gou/model"
@ -35,6 +36,94 @@ func (u *DefaultUser) GetUser(ctx context.Context, userID string) (maps.MapStrAn
return users[0], nil
}
// GetUserWithScopes retrieves user information with scopes
func (u *DefaultUser) GetUserWithScopes(ctx context.Context, userID string) (maps.MapStrAny, error) {
m := model.Select(u.model)
users, err := m.Get(model.QueryParam{
Select: append(u.publicUserFields, "role_id"),
Wheres: []model.QueryWhere{
{Column: "user_id", Value: userID},
},
Limit: 1,
Withs: map[string]model.With{
"role": {
Name: "role",
Query: model.QueryParam{Select: []interface{}{"permissions", "restricted_permissions"}},
},
},
})
if err != nil {
return nil, fmt.Errorf(ErrFailedToGetUser, err)
}
if len(users) == 0 {
return nil, fmt.Errorf(ErrUserNotFound)
}
var scopes []string = []string{}
var restrictedScopes []string = []string{}
// Flatten the user role permissions
if role, ok := users[0]["role"]; ok {
// Flatten the role permissions
if roleMap, ok := role.(maps.MapStrAny); ok {
// Get scopes from permissions
if permissions, ok := roleMap["permissions"]; ok {
if permissionsMap, ok := permissions.(map[string]interface{}); ok {
switch v := permissionsMap["scopes"].(type) {
case []string:
scopes = append(scopes, v...)
case []interface{}:
for _, v := range v {
if str, ok := v.(string); ok {
scopes = append(scopes, str)
}
}
case string:
scopes = append(scopes, strings.Split(v, " ")...)
}
}
}
// Get scopes from restricted_permissions
if restrictedPermissions, ok := roleMap["restricted_permissions"]; ok {
// Get scopes from restricted_permissions
if restrictedPermissionsMap, ok := restrictedPermissions.(map[string]interface{}); ok {
switch v := restrictedPermissionsMap["scopes"].(type) {
case []string:
restrictedScopes = append(restrictedScopes, v...)
case []interface{}:
for _, v := range v {
if str, ok := v.(string); ok {
restrictedScopes = append(restrictedScopes, str)
}
}
case string:
restrictedScopes = append(restrictedScopes, strings.Split(v, " ")...)
}
}
}
delete(users[0], "role")
}
}
// remove scope if it is in restricted_scopes
if len(restrictedScopes) > 0 && len(scopes) > 0 {
for _, scope := range restrictedScopes {
if strings.Contains(strings.Join(scopes, " "), scope) {
delete(users[0], scope)
}
}
}
// Add scopes and restricted_scopes to the user
users[0]["scopes"] = scopes
users[0]["restricted_scopes"] = restrictedScopes
return users[0], nil
}
// UserExists checks if a user exists by user_id (lightweight query)
func (u *DefaultUser) UserExists(ctx context.Context, userID string) (bool, error) {
m := model.Select(u.model)
@ -377,9 +466,10 @@ func (u *DefaultUser) DeleteUser(ctx context.Context, userID string) error {
}
// UpdateUserLastLogin updates the user's last login timestamp
func (u *DefaultUser) UpdateUserLastLogin(ctx context.Context, userID string) error {
func (u *DefaultUser) UpdateUserLastLogin(ctx context.Context, userID string, ip string) error {
updateData := maps.MapStrAny{
"last_login_at": time.Now(),
"last_login_ip": ip,
}
return u.UpdateUser(ctx, userID, updateData)

View file

@ -202,7 +202,7 @@ func TestUserBasicOperations(t *testing.T) {
// Test UpdateUserLastLogin
t.Run("UpdateUserLastLogin", func(t *testing.T) {
err := testProvider.UpdateUserLastLogin(ctx, testUserID)
err := testProvider.UpdateUserLastLogin(ctx, testUserID, "127.0.0.1")
assert.NoError(t, err)
// Verify last_login_at was updated

View file

@ -152,6 +152,7 @@ type UserProvider interface {
// User Basic Operations
GetUser(ctx context.Context, userID string) (maps.MapStrAny, error)
GetUserWithScopes(ctx context.Context, userID string) (maps.MapStrAny, error)
UserExists(ctx context.Context, userID string) (bool, error)
UserExistsByEmail(ctx context.Context, email string) (bool, error)
UserExistsByPreferredUsername(ctx context.Context, preferredUsername string) (bool, error)
@ -166,7 +167,7 @@ type UserProvider interface {
CreateUser(ctx context.Context, userData maps.MapStrAny) (string, error)
UpdateUser(ctx context.Context, userID string, userData maps.MapStrAny) error
DeleteUser(ctx context.Context, userID string) error
UpdateUserLastLogin(ctx context.Context, userID string) error
UpdateUserLastLogin(ctx context.Context, userID string, ip string) error
UpdateUserStatus(ctx context.Context, userID string, status string) error
// User List and Search

View file

@ -2,6 +2,7 @@ package signin
import (
"fmt"
"net"
"net/http"
"net/url"
"regexp"
@ -203,7 +204,7 @@ func authback(c *gin.Context) {
}
// LoginThirdParty(providerID, userInfo)
loginResponse, err := LoginThirdParty(providerID, userInfo)
loginResponse, err := LoginThirdParty(providerID, userInfo, userIPAddress(c))
if err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
@ -510,3 +511,154 @@ func validateState(providerID, sid, state string) error {
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

@ -10,7 +10,7 @@ import (
)
// LoginThirdParty is the handler for third party login
func LoginThirdParty(providerID string, userinfo *oauthtypes.OIDCUserInfo) (*LoginResponse, error) {
func LoginThirdParty(providerID string, userinfo *oauthtypes.OIDCUserInfo, ip string) (*LoginResponse, error) {
// Get provider
provider, err := GetProvider(providerID)
@ -66,11 +66,11 @@ func LoginThirdParty(providerID string, userinfo *oauthtypes.OIDCUserInfo) (*Log
return nil, err
}
return LoginByUserID(userID)
return LoginByUserID(userID, ip)
}
// LoginByUserID is the handler for login
func LoginByUserID(userid string) (*LoginResponse, error) {
func LoginByUserID(userid string, ip string) (*LoginResponse, error) {
// Get User
userProvider, err := oauth.OAuth.GetUserProvider()
@ -82,13 +82,13 @@ func LoginByUserID(userid string) (*LoginResponse, error) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
user, err := userProvider.GetUser(ctx, userid)
user, err := userProvider.GetUserWithScopes(ctx, userid)
if err != nil {
return nil, err
}
// Update Last Login
err = userProvider.UpdateUserLastLogin(ctx, userid)
err = userProvider.UpdateUserLastLogin(ctx, userid, ip)
if err != nil {
log.Warn("Failed to update last login: %s", err.Error())
}

View file

@ -339,6 +339,14 @@
"nullable": true,
"index": true
},
{
"name": "last_login_ip",
"type": "string",
"label": "Last Login IP",
"comment": "Last login IP address",
"length": 46,
"nullable": true
},
{
"name": "mfa_last_verified_at",
"type": "timestamp",
@ -369,7 +377,20 @@
"comment": "Index on verification status for filtering"
}
],
"relations": {},
"relations": {
"role": {
"type": "hasOne",
"model": "__yao.user_role",
"key": "role_id",
"foreign": "role_id"
},
"type": {
"type": "hasOne",
"model": "__yao.user_type",
"key": "type_id",
"foreign": "type_id"
}
},
"values": [],
"option": { "timestamps": true, "soft_deletes": true, "permission": true }
}