Merge pull request #1079 from trheyi/main
Enhance user management functionality with IP tracking and scope retr…
This commit is contained in:
commit
06f19e0a52
7 changed files with 380 additions and 116 deletions
212
data/bindata.go
212
data/bindata.go
File diff suppressed because one or more lines are too long
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue