Merge pull request #1210 from trheyi/main

Enhance user authentication with Remember Me functionality
This commit is contained in:
Max 2025-10-17 09:31:59 +08:00 committed by GitHub
commit 08744728c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 154 additions and 28 deletions

View file

@ -66,6 +66,12 @@ func GetAuthorizedInfo(c *gin.Context) *types.AuthorizedInfo {
info.TenantID = tenantID.(string)
}
if rememberMe, ok := c.Get("__remember_me"); ok {
if rmBool, ok := rememberMe.(bool); ok {
info.RememberMe = rmBool
}
}
return info
}

View file

@ -8,11 +8,12 @@ import (
// 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)
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)
RememberMe bool `json:"remember_me,omitempty"` // Remember Me flag for extended session
}
// MFAOptions contains configuration for MFA operations
@ -599,8 +600,9 @@ type AuthorizedInfo struct {
UserID string `json:"user_id,omitempty"` // User ID
// Extended fields for multi-tenancy and team support
TeamID string `json:"team_id,omitempty"` // Team identifier
TenantID string `json:"tenant_id,omitempty"` // Tenant identifier
TeamID string `json:"team_id,omitempty"` // Team identifier
TenantID string `json:"tenant_id,omitempty"` // Tenant identifier
RememberMe bool `json:"remember_me,omitempty"` // Remember Me flag preserved from login
}
// JWTClaims represents JWT-specific claims structure

View file

@ -466,8 +466,10 @@ func createPublicEntryConfig(config *EntryConfig) *EntryConfig {
// Deep copy Token config
if config.Token != nil {
publicConfig.Token = &TokenConfig{
ExpiresIn: config.Token.ExpiresIn,
RememberMeExpiresIn: config.Token.RememberMeExpiresIn,
ExpiresIn: config.Token.ExpiresIn,
RefreshTokenExpiresIn: config.Token.RefreshTokenExpiresIn,
RememberMeExpiresIn: config.Token.RememberMeExpiresIn,
RememberMeRefreshTokenExpiresIn: config.Token.RememberMeRefreshTokenExpiresIn,
}
}
@ -1015,6 +1017,7 @@ func GinEntryLogin(c *gin.Context) {
// Login using LoginByUserID (all status checks are handled inside)
loginCtx := makeLoginContext(c)
loginCtx.RememberMe = req.RememberMe // Set Remember Me from request
loginResponse, err := LoginByUserID(userID, loginCtx)
if err != nil {
log.Error("Failed to login user %s: %v", userID, err)
@ -1142,6 +1145,9 @@ func GinVerifyInvite(c *gin.Context) {
// Generate login context
loginCtx := makeLoginContext(c)
// Preserve Remember Me state from temporary token (authInfo is already available from above)
loginCtx.RememberMe = authInfo.RememberMe
// Generate full login token
loginResponse, err := LoginByUserID(userID, loginCtx)
if err != nil {

View file

@ -162,7 +162,14 @@ func LoginByUserID(userid string, loginCtx *LoginContext) (*LoginResponse, error
case "pending_invite":
// User needs to verify invitation code, generate temporary token
var inviteExpire int = 10 * 60 // 10 minutes
accessToken, err := oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, ScopeInviteVerification, subject, inviteExpire)
// Prepare extra claims to preserve Remember Me state
extraClaims := make(map[string]interface{})
if loginCtx != nil && loginCtx.RememberMe {
extraClaims["remember_me"] = true
}
accessToken, err := oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, ScopeInviteVerification, subject, inviteExpire, extraClaims)
if err != nil {
return nil, err
}
@ -188,7 +195,14 @@ func LoginByUserID(userid string, loginCtx *LoginContext) (*LoginResponse, error
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)
// Prepare extra claims to preserve Remember Me state
extraClaims := make(map[string]interface{})
if loginCtx != nil && loginCtx.RememberMe {
extraClaims["remember_me"] = true
}
accessToken, err := oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, ScopeMFAVerification, subject, mfaExpire, extraClaims)
if err != nil {
return nil, err
}
@ -222,7 +236,14 @@ func LoginByUserID(userid string, loginCtx *LoginContext) (*LoginResponse, error
if numTeams > 0 {
// Sign temporary access token for Team Selection
var teamSelectionExpire int = 10 * 60 // 10 minutes
accessToken, err := oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, ScopeTeamSelection, subject, teamSelectionExpire)
// Prepare extra claims to preserve Remember Me state
extraClaims := make(map[string]interface{})
if loginCtx != nil && loginCtx.RememberMe {
extraClaims["remember_me"] = true
}
accessToken, err := oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, ScopeTeamSelection, subject, teamSelectionExpire, extraClaims)
if err != nil {
return nil, err
}
@ -240,7 +261,7 @@ func LoginByUserID(userid string, loginCtx *LoginContext) (*LoginResponse, error
}
// Issue tokens without team context
return issueTokens(ctx, userid, "", nil, user, subject, scopes)
return issueTokens(ctx, userid, "", nil, user, subject, scopes, loginCtx)
}
// LoginByTeamID is the handler for login by team ID (after team selection)
@ -274,7 +295,7 @@ func LoginByTeamID(userid string, teamID string, loginCtx *LoginContext) (*Login
// Handle personal account (no team)
if teamID == "" || teamID == "personal" {
return issueTokens(ctx, userid, "", nil, user, subject, scopes)
return issueTokens(ctx, userid, "", nil, user, subject, scopes, loginCtx)
}
// Verify user is a member of the team and get team details
@ -292,13 +313,98 @@ func LoginByTeamID(userid string, teamID string, loginCtx *LoginContext) (*Login
}
// Issue tokens with team context
return issueTokens(ctx, userid, teamID, team, user, subject, scopes)
return issueTokens(ctx, userid, teamID, team, user, subject, scopes, loginCtx)
}
// 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) {
func issueTokens(ctx context.Context, userid string, teamID string, team map[string]interface{}, user map[string]interface{}, subject string, scopes []string, loginCtx *LoginContext) (*LoginResponse, error) {
yaoClientConfig := GetYaoClientConfig()
// Determine token expiration times based on Remember Me setting
var expiresIn, refreshTokenExpiresIn int
// Try to get token config from entry config first
locale := ""
entryConfig := GetEntryConfig(locale)
if loginCtx != nil && loginCtx.RememberMe {
// Remember Me mode: use extended token durations
if entryConfig != nil && entryConfig.Token != nil {
// Parse Remember Me access token expires_in
if entryConfig.Token.RememberMeExpiresIn != "" {
normalized, err := normalizeDuration(entryConfig.Token.RememberMeExpiresIn)
if err != nil {
log.Warn("Failed to parse remember_me_expires_in: %s, using default", err.Error())
} else {
duration, err := time.ParseDuration(normalized)
if err == nil {
expiresIn = int(duration.Seconds())
}
}
}
// Parse Remember Me refresh token expires_in
if entryConfig.Token.RememberMeRefreshTokenExpiresIn != "" {
normalized, err := normalizeDuration(entryConfig.Token.RememberMeRefreshTokenExpiresIn)
if err != nil {
log.Warn("Failed to parse remember_me_refresh_token_expires_in: %s, using default", err.Error())
} else {
duration, err := time.ParseDuration(normalized)
if err == nil {
refreshTokenExpiresIn = int(duration.Seconds())
}
}
}
// If refresh token not configured, default to 2x the access token duration
if refreshTokenExpiresIn == 0 && expiresIn > 0 {
refreshTokenExpiresIn = expiresIn * 2
}
}
} else {
// Normal login: use standard token durations from entry config
if entryConfig != nil && entryConfig.Token != nil {
// Parse access token expires_in
if entryConfig.Token.ExpiresIn != "" {
normalized, err := normalizeDuration(entryConfig.Token.ExpiresIn)
if err != nil {
log.Warn("Failed to parse expires_in: %s, using default", err.Error())
} else {
duration, err := time.ParseDuration(normalized)
if err == nil {
expiresIn = int(duration.Seconds())
}
}
}
// Parse refresh token expires_in
if entryConfig.Token.RefreshTokenExpiresIn != "" {
normalized, err := normalizeDuration(entryConfig.Token.RefreshTokenExpiresIn)
if err != nil {
log.Warn("Failed to parse refresh_token_expires_in: %s, using default", err.Error())
} else {
duration, err := time.ParseDuration(normalized)
if err == nil {
refreshTokenExpiresIn = int(duration.Seconds())
}
}
}
// If refresh token not configured, default to 24x the access token duration
if refreshTokenExpiresIn == 0 && expiresIn > 0 {
refreshTokenExpiresIn = expiresIn * 24
}
}
}
// Fall back to YaoClientConfig defaults if not set from entry config
if expiresIn == 0 {
expiresIn = yaoClientConfig.ExpiresIn
}
if refreshTokenExpiresIn == 0 {
refreshTokenExpiresIn = yaoClientConfig.RefreshTokenExpiresIn
}
// Prepare OIDC user info
oidcUserInfo := oauthtypes.MakeOIDCUserInfo(user)
oidcUserInfo.Sub = subject
@ -388,9 +494,9 @@ func issueTokens(ctx context.Context, userid string, teamID string, team map[str
var oidcToken string
var err error
if len(extraClaims) > 0 {
oidcToken, err = oauth.OAuth.SignIDToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), yaoClientConfig.ExpiresIn, oidcUserInfo, extraClaims)
oidcToken, err = oauth.OAuth.SignIDToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), expiresIn, oidcUserInfo, extraClaims)
} else {
oidcToken, err = oauth.OAuth.SignIDToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), yaoClientConfig.ExpiresIn, oidcUserInfo)
oidcToken, err = oauth.OAuth.SignIDToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), expiresIn, oidcUserInfo)
}
if err != nil {
return nil, fmt.Errorf("failed to sign OIDC token: %w", err)
@ -399,9 +505,9 @@ func issueTokens(ctx context.Context, userid string, teamID string, team map[str
// Sign Access Token
var accessToken string
if len(extraClaims) > 0 {
accessToken, err = oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.ExpiresIn, extraClaims)
accessToken, err = oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, expiresIn, extraClaims)
} else {
accessToken, err = oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.ExpiresIn)
accessToken, err = oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, expiresIn)
}
if err != nil {
return nil, fmt.Errorf("failed to sign access token: %w", err)
@ -410,9 +516,9 @@ func issueTokens(ctx context.Context, userid string, teamID string, team map[str
// Sign Refresh Token
var refreshToken string
if len(extraClaims) > 0 {
refreshToken, err = oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.RefreshTokenExpiresIn, extraClaims)
refreshToken, err = oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, refreshTokenExpiresIn, extraClaims)
} else {
refreshToken, err = oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.RefreshTokenExpiresIn)
refreshToken, err = oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, refreshTokenExpiresIn)
}
if err != nil {
return nil, fmt.Errorf("failed to sign refresh token: %w", err)
@ -424,8 +530,8 @@ func issueTokens(ctx context.Context, userid string, teamID string, team map[str
AccessToken: accessToken,
IDToken: oidcToken,
RefreshToken: refreshToken,
ExpiresIn: yaoClientConfig.ExpiresIn,
RefreshTokenExpiresIn: yaoClientConfig.RefreshTokenExpiresIn,
ExpiresIn: expiresIn,
RefreshTokenExpiresIn: refreshTokenExpiresIn,
TokenType: "Bearer",
MFAEnabled: toBool(user["mfa_enabled"]),
Scope: strings.Join(scopes, " "),

View file

@ -347,6 +347,9 @@ func GinTeamSelection(c *gin.Context) {
// Prepare login context with full device/platform information
loginCtx := makeLoginContext(c)
// Preserve Remember Me state from temporary token
loginCtx.RememberMe = authInfo.RememberMe
// Login with selected team
loginResponse, err := LoginByTeamID(authInfo.UserID, req.TeamID, loginCtx)
if err != nil {

View file

@ -76,8 +76,10 @@ type CaptchaConfig struct {
// TokenConfig represents the token configuration
type TokenConfig struct {
ExpiresIn string `json:"expires_in,omitempty"`
RememberMeExpiresIn string `json:"remember_me_expires_in,omitempty"`
ExpiresIn string `json:"expires_in,omitempty"`
RefreshTokenExpiresIn string `json:"refresh_token_expires_in,omitempty"`
RememberMeExpiresIn string `json:"remember_me_expires_in,omitempty"`
RememberMeRefreshTokenExpiresIn string `json:"remember_me_refresh_token_expires_in,omitempty"`
}
// ThirdParty represents the third party login configuration
@ -297,8 +299,9 @@ type EntryRegisterRequest struct {
// EntryLoginRequest represents the request to login with username and password
type EntryLoginRequest struct {
Password string `json:"password" binding:"required"`
Locale string `json:"locale,omitempty"`
Password string `json:"password" binding:"required"`
RememberMe bool `json:"remember_me,omitempty"`
Locale string `json:"locale,omitempty"`
}
// EntrySendOTPResponse represents the response for sending OTP verification code