Implement MFA token generation and update login response handling

- Removed the previous MFA check from the LoginThirdParty function and integrated MFA token generation within the LoginByUserID function.
- Enhanced the LoginResponse structure to include MFAToken and MFATokenExpiresIn fields for better MFA handling.
- Updated the SendLoginCookies function to manage MFA token cookies appropriately.
- Adjusted the authback function to respond with MFA status and token when MFA is required, improving the login flow for users with MFA enabled.
This commit is contained in:
Max 2025-10-10 14:25:18 +08:00
parent 5e5b633fba
commit 4a93b3580c
3 changed files with 93 additions and 66 deletions

View file

@ -135,16 +135,6 @@ func LoginThirdParty(providerID string, userinfo *oauthtypes.OIDCUserInfo, ip st
return nil, err return nil, err
} }
// If MFA Enabled, should return MFA required response
mfaEnabled, err := userProvider.IsMFAEnabled(ctx, userID)
if err != nil {
return nil, err
}
if mfaEnabled {
return nil, response.ErrMFARequired
}
return LoginByUserID(userID, ip) return LoginByUserID(userID, ip)
} }
@ -166,6 +156,28 @@ func LoginByUserID(userid string, ip string) (*LoginResponse, error) {
return nil, err return nil, err
} }
// Get MFA enabled status from user data
mfaEnabled := toBool(user["mfa_enabled"])
// If MFA enabled, generate MFA token
if mfaEnabled {
// Sign temporary access token for MFA
var mfaExpire int = 10 * 60 // 10 minutes
mfaToken, err := oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, ScopeMFAVerification, userid, mfaExpire)
if err != nil {
return nil, err
}
return &LoginResponse{
UserID: userid,
MFAToken: mfaToken,
MFATokenExpiresIn: mfaExpire,
MFAEnabled: mfaEnabled,
Status: LoginStatusMFA,
}, nil
}
// Update Last Login // Update Last Login
err = userProvider.UpdateUserLastLogin(ctx, userid, ip) err = userProvider.UpdateUserLastLogin(ctx, userid, ip)
if err != nil { if err != nil {
@ -203,8 +215,16 @@ func LoginByUserID(userid string, ip string) (*LoginResponse, error) {
return nil, err return nil, err
} }
// Get MFA enabled status from user data // Count User Teams
mfaEnabled := toBool(user["mfa_enabled"]) numTeams, err := countUserTeams(ctx, userid)
if err != nil {
return nil, err
}
status := LoginStatusSuccess
if numTeams > 0 {
status = LoginStatusTeamSelection
}
return &LoginResponse{ return &LoginResponse{
UserID: userid, UserID: userid,
@ -217,6 +237,7 @@ func LoginByUserID(userid string, ip string) (*LoginResponse, error) {
TokenType: "Bearer", TokenType: "Bearer",
MFAEnabled: mfaEnabled, MFAEnabled: mfaEnabled,
Scope: strings.Join(scopes, " "), Scope: strings.Join(scopes, " "),
Status: status,
}, nil }, nil
} }
@ -228,12 +249,26 @@ func generateSessionID() string {
// SendLoginCookies sends all necessary cookies for a successful login // SendLoginCookies sends all necessary cookies for a successful login
// This includes access token, refresh token, and session ID cookies with appropriate security settings // This includes access token, refresh token, and session ID cookies with appropriate security settings
func SendLoginCookies(c *gin.Context, loginResponse *LoginResponse, sessionID string) { func SendLoginCookies(c *gin.Context, loginResponse *LoginResponse, sessionID string) {
// Format tokens with Bearer prefix
// Send session ID cookie
expires := time.Now().Add(time.Duration(yaoClientConfig.ExpiresIn) * time.Second)
options := response.NewSecureCookieOptions().
WithExpires(expires).
WithSameSite("Strict")
response.SendSecureCookieWithOptions(c, "session_id", sessionID, options)
// MFA Temporary Access Token
if loginResponse.Status == LoginStatusMFA {
mfaToken := fmt.Sprintf("Bearer %s", loginResponse.MFAToken)
response.SendAccessTokenCookieWithExpiry(c, mfaToken, expires)
return
}
// Normal Access Token
accessToken := fmt.Sprintf("%s %s", loginResponse.TokenType, loginResponse.AccessToken) accessToken := fmt.Sprintf("%s %s", loginResponse.TokenType, loginResponse.AccessToken)
refreshToken := fmt.Sprintf("%s %s", loginResponse.TokenType, loginResponse.RefreshToken) refreshToken := fmt.Sprintf("%s %s", loginResponse.TokenType, loginResponse.RefreshToken)
// Calculate expiration times // Calculate expiration times
expires := time.Now().Add(time.Duration(loginResponse.ExpiresIn) * time.Second)
refreshExpires := time.Now().Add(time.Duration(loginResponse.RefreshTokenExpiresIn) * time.Second) refreshExpires := time.Now().Add(time.Duration(loginResponse.RefreshTokenExpiresIn) * time.Second)
// Send access token cookie // Send access token cookie
@ -241,11 +276,4 @@ func SendLoginCookies(c *gin.Context, loginResponse *LoginResponse, sessionID st
// Send refresh token cookie // Send refresh token cookie
response.SendRefreshTokenCookieWithExpiry(c, refreshToken, refreshExpires) response.SendRefreshTokenCookieWithExpiry(c, refreshToken, refreshExpires)
// Send session ID cookie with the same expiration as access token
// Using HTTP-only flag for security
options := response.NewSecureCookieOptions().
WithExpires(expires).
WithSameSite("Strict")
response.SendSecureCookieWithOptions(c, "session_id", sessionID, options)
} }

View file

@ -178,14 +178,6 @@ func authback(c *gin.Context) {
// LoginThirdParty(providerID, userInfo) // LoginThirdParty(providerID, userInfo)
loginResponse, err := LoginThirdParty(providerID, userInfo, userIPAddress(c)) loginResponse, err := LoginThirdParty(providerID, userInfo, userIPAddress(c))
if err != nil { if err != nil {
// Redirect to MFA required page
if err == response.ErrMFARequired {
response.RespondWithError(c, response.StatusUnauthorized, response.ErrMFARequired)
return
}
// Other errors
errorResp := &response.ErrorResponse{ errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code, Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Failed to login: " + err.Error(), ErrorDescription: "Failed to login: " + err.Error(),
@ -197,22 +189,18 @@ func authback(c *gin.Context) {
// Send all login cookies (access token, refresh token, and session ID) // Send all login cookies (access token, refresh token, and session ID)
SendLoginCookies(c, loginResponse, sid) SendLoginCookies(c, loginResponse, sid)
// Get Teams // MFA Response
numTeams, err := countUserTeams(c.Request.Context(), loginResponse.UserID) if loginResponse.Status == LoginStatusMFA {
if err != nil { response.RespondWithSuccess(c, response.StatusOK, LoginSuccessResponse{
errorResp := &response.ErrorResponse{ SessionID: sid,
Code: response.ErrInvalidRequest.Code, MFAEnabled: loginResponse.MFAEnabled,
ErrorDescription: "Failed to count teams: " + err.Error(), Status: loginResponse.Status,
} MFAToken: loginResponse.MFAToken,
response.RespondWithError(c, response.StatusInternalServerError, errorResp) MFATokenExpiresIn: loginResponse.MFATokenExpiresIn,
})
return return
} }
status := LoginStatusSuccess
if numTeams > 0 {
status = LoginStatusTeamSelection
}
// Send IDToken to the client // Send IDToken to the client
response.RespondWithSuccess(c, response.StatusOK, LoginSuccessResponse{ response.RespondWithSuccess(c, response.StatusOK, LoginSuccessResponse{
SessionID: sid, SessionID: sid,
@ -222,7 +210,7 @@ func authback(c *gin.Context) {
ExpiresIn: loginResponse.ExpiresIn, ExpiresIn: loginResponse.ExpiresIn,
RefreshTokenExpiresIn: loginResponse.RefreshTokenExpiresIn, RefreshTokenExpiresIn: loginResponse.RefreshTokenExpiresIn,
MFAEnabled: loginResponse.MFAEnabled, MFAEnabled: loginResponse.MFAEnabled,
Status: status, Status: loginResponse.Status,
}) })
} }

View file

@ -2,16 +2,23 @@ package user
import ( import (
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types" oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/openapi/response"
) )
// LoginStatus represents the login status
type LoginStatus string
const ( const (
// LoginStatusSuccess is the success status // LoginStatusSuccess is the success status
LoginStatusSuccess = "ok" LoginStatusSuccess LoginStatus = "ok"
// LoginStatusMFA is the MFA status // LoginStatusMFA is the MFA status
LoginStatusMFA = "mfa_required" LoginStatusMFA LoginStatus = "mfa_required"
// LoginStatusTeamSelection is the team selection status // LoginStatusTeamSelection is the team selection status
LoginStatusTeamSelection = "team_selection_required" LoginStatusTeamSelection LoginStatus = "team_selection_required"
)
const (
// ScopeMFAVerification is the MFA verification scope for temporary access token
ScopeMFAVerification = "mfa_verification"
) )
// Config represents the signin page configuration // Config represents the signin page configuration
@ -181,21 +188,25 @@ type LoginResponse struct {
ExpiresIn int `json:"expires_in,omitempty"` ExpiresIn int `json:"expires_in,omitempty"`
RefreshTokenExpiresIn int `json:"refresh_token_expires_in,omitempty"` RefreshTokenExpiresIn int `json:"refresh_token_expires_in,omitempty"`
TokenType string `json:"token_type,omitempty"` TokenType string `json:"token_type,omitempty"`
MFAToken string `json:"mfa_token,omitempty"` // MFA token verification code
MFATokenExpiresIn int `json:"mfa_token_expires_in,omitempty"` // MFA token verification code expires in
MFAEnabled bool `json:"mfa_enabled,omitempty"` MFAEnabled bool `json:"mfa_enabled,omitempty"`
Scope string `json:"scope,omitempty"` Scope string `json:"scope,omitempty"`
Status LoginStatus `json:"status,omitempty"`
} }
// LoginSuccessResponse represents the response for login success // LoginSuccessResponse represents the response for login success
type LoginSuccessResponse struct { type LoginSuccessResponse struct {
IDToken string `json:"id_token,omitempty"` IDToken string `json:"id_token,omitempty"`
AccessToken string `json:"access_token,omitempty"` AccessToken string `json:"access_token,omitempty"`
MFAToken string `json:"mfa_token,omitempty"` // MFA token verification code
MFATokenExpiresIn int `json:"mfa_token_expires_in,omitempty"` // MFA token verification code expires in
SessionID string `json:"session_id,omitempty"` SessionID string `json:"session_id,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"` RefreshToken string `json:"refresh_token,omitempty"`
ExpiresIn int `json:"expires_in,omitempty"` ExpiresIn int `json:"expires_in,omitempty"`
MFAEnabled bool `json:"mfa_enabled"` MFAEnabled bool `json:"mfa_enabled"`
RefreshTokenExpiresIn int `json:"refresh_token_expires_in,omitempty"` RefreshTokenExpiresIn int `json:"refresh_token_expires_in,omitempty"`
Status string `json:"status,omitempty"` Status LoginStatus `json:"status,omitempty"`
Error *response.ErrorResponse `json:"error,omitempty"`
} }
// Built-in preset mapping types // Built-in preset mapping types