Enhance user login functionality with detailed context tracking

- Updated the UpdateUserLastLogin method to accept a LoginContext, allowing for tracking of last login IP, user agent, device, and platform.
- Modified the LoginThirdParty and LoginByUserID functions to utilize the new LoginContext, improving the accuracy of login tracking.
- Enhanced the user model to include fields for last login details, ensuring comprehensive user activity logging.
- Refactored tests to validate the new login context handling, ensuring robust coverage of the updated functionality.
This commit is contained in:
Max 2025-10-13 09:58:16 +08:00
parent d78ed77b9e
commit 1af0a20015
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",
"middle_name", "nickname", "profile", "picture", "website", "gender", "birthdate", "zoneinfo", "locale",
"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

View file

@ -9,6 +9,7 @@ import (
"github.com/yaoapp/gou/model"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/kun/maps"
"github.com/yaoapp/yao/openapi/oauth/types"
"golang.org/x/crypto/bcrypt"
)
@ -465,11 +466,26 @@ func (u *DefaultUser) DeleteUser(ctx context.Context, userID string) error {
return nil
}
// UpdateUserLastLogin updates the user's last login timestamp
func (u *DefaultUser) UpdateUserLastLogin(ctx context.Context, userID string, ip string) error {
// UpdateUserLastLogin updates the user's last login timestamp and context
func (u *DefaultUser) UpdateUserLastLogin(ctx context.Context, userID string, loginCtx *types.LoginContext) error {
updateData := maps.MapStrAny{
"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)

View file

@ -8,6 +8,7 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/kun/maps"
"github.com/yaoapp/yao/openapi/oauth/types"
)
func TestUserBasicOperations(t *testing.T) {
@ -202,13 +203,40 @@ func TestUserBasicOperations(t *testing.T) {
// Test UpdateUserLastLogin
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)
// Verify last_login_at was updated
// Verify last_login_at and context were updated
user, err := testProvider.GetUser(ctx, testUserID)
assert.NoError(t, err)
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

View file

@ -167,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, ip string) error
UpdateUserLastLogin(ctx context.Context, userID string, loginCtx *LoginContext) error
UpdateUserStatus(ctx context.Context, userID string, status string) error
// 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
// if user.Raw != nil {
// // Merge raw data, but let structured fields take precedence
@ -207,6 +244,45 @@ func MakeOIDCUserInfo(user map[string]interface{}) *OIDCUserInfo {
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
}

View file

@ -6,6 +6,15 @@ import (
"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
type MFAOptions struct {
Issuer string // Issuer name displayed in authenticator app
@ -680,10 +689,26 @@ type OIDCUserInfo struct {
// OIDC Address Claim (structured)
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 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
type OIDCAddress struct {
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
func LoginThirdParty(providerID string, userinfo *oauthtypes.OIDCUserInfo, ip string) (*LoginResponse, error) {
func LoginThirdParty(providerID string, userinfo *oauthtypes.OIDCUserInfo, loginCtx *LoginContext) (*LoginResponse, error) {
// Get provider
provider, err := GetProvider(providerID)
@ -135,12 +135,11 @@ func LoginThirdParty(providerID string, userinfo *oauthtypes.OIDCUserInfo, ip st
return nil, err
}
return LoginByUserID(userID, ip)
return LoginByUserID(userID, loginCtx)
}
// LoginByUserID is the handler for login
func LoginByUserID(userid string, ip string) (*LoginResponse, error) {
// LoginByUserID is the handler for login by user ID
func LoginByUserID(userid string, loginCtx *LoginContext) (*LoginResponse, error) {
// Get User
userProvider, err := oauth.OAuth.GetUserProvider()
if err != nil {
@ -172,7 +171,6 @@ func LoginByUserID(userid string, ip string) (*LoginResponse, error) {
// If MFA enabled, generate MFA token
if mfaEnabled {
// Sign temporary access token for MFA
var mfaExpire int = 10 * 60 // 10 minutes
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
err = userProvider.UpdateUserLastLogin(ctx, userid, ip)
if err != nil {
log.Warn("Failed to update last login: %s", err.Error())
if loginCtx != nil {
err = userProvider.UpdateUserLastLogin(ctx, userid, loginCtx)
if err != nil {
log.Warn("Failed to update last login: %s", err.Error())
}
}
// Count User Teams
@ -224,24 +224,146 @@ func LoginByUserID(userid string, ip string) (*LoginResponse, error) {
}, 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.Sub = subject
oidcToken, err := oauth.OAuth.SignIDToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), yaoClientConfig.ExpiresIn, oidcUserInfo)
if err != nil {
return nil, err
// Prepare extra claims for team context
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
accessToken, err := oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.ExpiresIn)
// Sign OIDC Token
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 {
return nil, err
return nil, fmt.Errorf("failed to sign OIDC token: %w", err)
}
// Refresh Token
refreshToken, err := oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.RefreshTokenExpiresIn)
// Sign Access Token
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 {
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{
@ -253,7 +375,7 @@ func LoginByUserID(userid string, ip string) (*LoginResponse, error) {
ExpiresIn: yaoClientConfig.ExpiresIn,
RefreshTokenExpiresIn: yaoClientConfig.RefreshTokenExpiresIn,
TokenType: "Bearer",
MFAEnabled: mfaEnabled,
MFAEnabled: toBool(user["mfa_enabled"]),
Scope: strings.Join(scopes, " "),
Status: LoginStatusSuccess,
}, nil

View file

@ -2,7 +2,6 @@ package user
import (
"fmt"
"net"
"net/http"
"net/url"
"regexp"
@ -174,7 +173,8 @@ func authback(c *gin.Context) {
}
// LoginThirdParty(providerID, userInfo)
loginResponse, err := LoginThirdParty(providerID, userInfo, userIPAddress(c))
loginCtx := makeLoginContext(c)
loginResponse, err := LoginThirdParty(providerID, userInfo, loginCtx)
if err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
@ -505,154 +505,3 @@ 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

@ -15,7 +15,6 @@ import (
"github.com/yaoapp/kun/maps"
"github.com/yaoapp/yao/openapi/oauth"
"github.com/yaoapp/yao/openapi/oauth/providers/user"
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/openapi/response"
)
@ -345,138 +344,16 @@ func GinTeamSelection(c *gin.Context) {
ctx := c.Request.Context()
// Handle personal account selection (no team_id or "personal")
var extraClaims map[string]interface{}
var selectedTeam map[string]interface{}
// Prepare login context with full device/platform information
loginCtx := makeLoginContext(c)
if req.TeamID == "" || req.TeamID == "personal" {
// Personal account - no team_id in token
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()
// Login with selected team
loginResponse, err := LoginByTeamID(authInfo.UserID, req.TeamID, loginCtx)
if err != nil {
log.Error("Failed to get user provider: %v", err)
log.Error("Failed to login with team: %v", err)
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Failed to initialize user provider",
}
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",
ErrorDescription: "Failed to login with team: " + err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
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)
SendLoginCookies(c, loginResponse, "")

View file

@ -207,6 +207,9 @@ type LoginSuccessResponse struct {
Status LoginStatus `json:"status,omitempty"`
}
// LoginContext is an alias for the oauth types LoginContext
type LoginContext = oauthtypes.LoginContext
// Built-in preset mapping types
const (
MappingGoogle = "google"

View file

@ -2,10 +2,12 @@ package user
import (
"fmt"
"net"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/yaoapp/gou/process"
"github.com/yaoapp/gou/session"
"github.com/yaoapp/kun/exception"
@ -190,3 +192,210 @@ func maskEmail(email string) string {
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,
"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",
"type": "timestamp",