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:
parent
d78ed77b9e
commit
1af0a20015
13 changed files with 680 additions and 464 deletions
282
data/bindata.go
282
data/bindata.go
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, "")
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue