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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/yaoapp/gou/model"
|
"github.com/yaoapp/gou/model"
|
||||||
|
|
@ -35,6 +36,94 @@ func (u *DefaultUser) GetUser(ctx context.Context, userID string) (maps.MapStrAn
|
||||||
return users[0], nil
|
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)
|
// UserExists checks if a user exists by user_id (lightweight query)
|
||||||
func (u *DefaultUser) UserExists(ctx context.Context, userID string) (bool, error) {
|
func (u *DefaultUser) UserExists(ctx context.Context, userID string) (bool, error) {
|
||||||
m := model.Select(u.model)
|
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
|
// 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{
|
updateData := maps.MapStrAny{
|
||||||
"last_login_at": time.Now(),
|
"last_login_at": time.Now(),
|
||||||
|
"last_login_ip": ip,
|
||||||
}
|
}
|
||||||
|
|
||||||
return u.UpdateUser(ctx, userID, updateData)
|
return u.UpdateUser(ctx, userID, updateData)
|
||||||
|
|
|
||||||
|
|
@ -202,7 +202,7 @@ 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)
|
err := testProvider.UpdateUserLastLogin(ctx, testUserID, "127.0.0.1")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// Verify last_login_at was updated
|
// Verify last_login_at was updated
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,7 @@ type UserProvider interface {
|
||||||
|
|
||||||
// User Basic Operations
|
// User Basic Operations
|
||||||
GetUser(ctx context.Context, userID string) (maps.MapStrAny, error)
|
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)
|
UserExists(ctx context.Context, userID string) (bool, error)
|
||||||
UserExistsByEmail(ctx context.Context, email string) (bool, error)
|
UserExistsByEmail(ctx context.Context, email string) (bool, error)
|
||||||
UserExistsByPreferredUsername(ctx context.Context, preferredUsername 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)
|
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) error
|
UpdateUserLastLogin(ctx context.Context, userID string, ip string) 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
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package signin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
@ -203,7 +204,7 @@ func authback(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoginThirdParty(providerID, userInfo)
|
// LoginThirdParty(providerID, userInfo)
|
||||||
loginResponse, err := LoginThirdParty(providerID, userInfo)
|
loginResponse, err := LoginThirdParty(providerID, userInfo, userIPAddress(c))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorResp := &response.ErrorResponse{
|
errorResp := &response.ErrorResponse{
|
||||||
Code: response.ErrInvalidRequest.Code,
|
Code: response.ErrInvalidRequest.Code,
|
||||||
|
|
@ -510,3 +511,154 @@ 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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// LoginThirdParty is the handler for third party login
|
// 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
|
// Get provider
|
||||||
provider, err := GetProvider(providerID)
|
provider, err := GetProvider(providerID)
|
||||||
|
|
@ -66,11 +66,11 @@ func LoginThirdParty(providerID string, userinfo *oauthtypes.OIDCUserInfo) (*Log
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return LoginByUserID(userID)
|
return LoginByUserID(userID, ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoginByUserID is the handler for login
|
// LoginByUserID is the handler for login
|
||||||
func LoginByUserID(userid string) (*LoginResponse, error) {
|
func LoginByUserID(userid string, ip string) (*LoginResponse, error) {
|
||||||
|
|
||||||
// Get User
|
// Get User
|
||||||
userProvider, err := oauth.OAuth.GetUserProvider()
|
userProvider, err := oauth.OAuth.GetUserProvider()
|
||||||
|
|
@ -82,13 +82,13 @@ func LoginByUserID(userid string) (*LoginResponse, error) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
user, err := userProvider.GetUser(ctx, userid)
|
user, err := userProvider.GetUserWithScopes(ctx, userid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update Last Login
|
// Update Last Login
|
||||||
err = userProvider.UpdateUserLastLogin(ctx, userid)
|
err = userProvider.UpdateUserLastLogin(ctx, userid, ip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("Failed to update last login: %s", err.Error())
|
log.Warn("Failed to update last login: %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -339,6 +339,14 @@
|
||||||
"nullable": true,
|
"nullable": true,
|
||||||
"index": 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",
|
"name": "mfa_last_verified_at",
|
||||||
"type": "timestamp",
|
"type": "timestamp",
|
||||||
|
|
@ -369,7 +377,20 @@
|
||||||
"comment": "Index on verification status for filtering"
|
"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": [],
|
"values": [],
|
||||||
"option": { "timestamps": true, "soft_deletes": true, "permission": true }
|
"option": { "timestamps": true, "soft_deletes": true, "permission": true }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue