- Introduce `YaoAuthSource` and `OAuthEmail` fields in various structures to capture authentication source and OAuth email during user login and registration processes. - Update `SignIDToken`, `GetInfo`, and `Map` functions to include new claims for Yao authentication source. - Modify login context to preserve authentication source and OAuth email across different user flows. - Enhance token issuance to include authentication source and OAuth email in claims for better tracking and user context. - Ensure proper handling of OAuth email for third-party logins without affecting user profile email. This change improves the user experience by providing clearer context on authentication methods used during login and registration processes.
516 lines
16 KiB
Go
516 lines
16 KiB
Go
package user
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/yaoapp/gou/session"
|
|
"github.com/yaoapp/kun/log"
|
|
"github.com/yaoapp/yao/openapi/oauth"
|
|
"github.com/yaoapp/yao/openapi/response"
|
|
"github.com/yaoapp/yao/openapi/utils"
|
|
)
|
|
|
|
// authbackPrepare receives the post data and forwards to the authback handler
|
|
func authbackPrepare(c *gin.Context) {
|
|
code := c.PostForm("code")
|
|
state := c.PostForm("state")
|
|
user := c.PostForm("user") // form_post may include user info
|
|
providerID := c.Param("provider")
|
|
redirectURI, err := getRedirectURI(providerID, state)
|
|
if err != nil {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Failed to get redirect URI",
|
|
}
|
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
return
|
|
}
|
|
|
|
// Cache user info if provided (form_post mode)
|
|
if user != "" {
|
|
saveUserInfo(providerID, state, user)
|
|
}
|
|
|
|
params := url.Values{}
|
|
params.Add("code", code)
|
|
params.Add("state", state)
|
|
c.Redirect(http.StatusFound, redirectURI+"?"+params.Encode())
|
|
}
|
|
|
|
// authback is the handler for OAuth callback
|
|
func authback(c *gin.Context) {
|
|
sid := utils.GetSessionID(c)
|
|
var params OAuthAuthbackRequest
|
|
providerID := c.Param("provider")
|
|
|
|
// Check if provider exists first
|
|
provider, err := GetProvider(providerID)
|
|
if err != nil || provider == nil {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: fmt.Sprintf("OAuth provider '%s' not found", providerID),
|
|
}
|
|
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
|
return
|
|
}
|
|
|
|
if err := c.ShouldBind(¶ms); err != nil {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Invalid request",
|
|
}
|
|
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
|
return
|
|
}
|
|
|
|
if params.State == "" {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "State is required",
|
|
}
|
|
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
|
return
|
|
}
|
|
|
|
if err := validateState(providerID, sid, params.State); err != nil {
|
|
log.With(log.F{"sid": sid, "state": params.State}).Error("Invalid state")
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Invalid state",
|
|
}
|
|
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
|
return
|
|
}
|
|
|
|
// Get redirect URI
|
|
redirectURI, err := getRedirectURI(providerID, params.State)
|
|
if err != nil {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Failed to get redirect URI",
|
|
}
|
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
return
|
|
}
|
|
|
|
// Get provider
|
|
provider, err = GetProvider(providerID)
|
|
if err != nil {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: fmt.Sprintf("Failed to get provider: %v", err),
|
|
}
|
|
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
|
return
|
|
}
|
|
|
|
// if response mode is form_post
|
|
if provider.ResponseMode == "form_post" {
|
|
// Replace the redirectURI to
|
|
pathname := strings.TrimSuffix(c.Request.URL.Path, "/callback") + "/authorize/prepare"
|
|
newRedirectURI, err := reconstructRedirectURI(redirectURI, pathname, c)
|
|
if err != nil {
|
|
log.Error("Failed to reconstruct redirectURI: %v", err)
|
|
response.RespondWithError(c, response.StatusBadRequest, &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Invalid redirect URI format",
|
|
})
|
|
return
|
|
}
|
|
redirectURI = newRedirectURI
|
|
}
|
|
|
|
// Get AccessToken
|
|
tokenResponse, err := provider.AccessToken(params.Code, redirectURI)
|
|
if err != nil {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: fmt.Sprintf("Failed to get user info: %v", err),
|
|
}
|
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
return
|
|
}
|
|
|
|
// Read cached user info before cleaning up (for form_post mode)
|
|
cachedUserInfo, _ := getUserInfo(providerID, params.State)
|
|
|
|
// Remove the state from the session and cache (also cleans up user cache automatically)
|
|
err = removeState(providerID, sid)
|
|
if err != nil {
|
|
log.With(log.F{"sid": sid, "providerID": providerID}).Error("Failed to remove state")
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Failed to remove state",
|
|
}
|
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
return
|
|
}
|
|
|
|
// Get UserInfo - use different method based on user_info_source
|
|
var userInfo *OAuthUserInfoResponse
|
|
if provider.UserInfoSource == UserInfoSourceIDToken {
|
|
// For OAuth providers that use id_token, pass cached user info for merging
|
|
userInfo, err = provider.GetUserInfoFromTokenResponse(tokenResponse, cachedUserInfo)
|
|
} else {
|
|
// For standard OAuth providers that use userinfo endpoint
|
|
userInfo, err = provider.GetUserInfo(tokenResponse.AccessToken, tokenResponse.TokenType)
|
|
}
|
|
|
|
if err != nil {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: fmt.Sprintf("Failed to get user info: %v", err),
|
|
}
|
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
return
|
|
}
|
|
|
|
// LoginThirdParty(providerID, userInfo)
|
|
loginCtx := makeLoginContext(c)
|
|
loginCtx.AuthSource = providerID // Set auth source to provider name (google, github, etc.)
|
|
|
|
// Use locale from params, fallback to "en" if not provided
|
|
locale := params.Locale
|
|
if locale == "" {
|
|
locale = "en"
|
|
}
|
|
|
|
loginResponse, err := LoginThirdParty(providerID, userInfo, loginCtx, locale)
|
|
if err != nil {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Failed to login: " + err.Error(),
|
|
}
|
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
return
|
|
}
|
|
|
|
// Send all login cookies (access token, refresh token, and session ID)
|
|
SendLoginCookies(c, loginResponse, sid)
|
|
|
|
// Handle different login statuses
|
|
switch loginResponse.Status {
|
|
case LoginStatusInviteVerification, LoginStatusMFA, LoginStatusTeamSelection:
|
|
// Return temporary token for next step verification
|
|
response.RespondWithSuccess(c, response.StatusOK, LoginSuccessResponse{
|
|
SessionID: sid,
|
|
Status: loginResponse.Status,
|
|
AccessToken: loginResponse.AccessToken,
|
|
ExpiresIn: loginResponse.ExpiresIn,
|
|
MFAEnabled: loginResponse.MFAEnabled,
|
|
})
|
|
case LoginStatusSuccess:
|
|
// Send IDToken to the client (Success)
|
|
response.RespondWithSuccess(c, response.StatusOK, LoginSuccessResponse{
|
|
SessionID: sid,
|
|
IDToken: loginResponse.IDToken,
|
|
AccessToken: loginResponse.AccessToken,
|
|
RefreshToken: loginResponse.RefreshToken,
|
|
ExpiresIn: loginResponse.ExpiresIn,
|
|
RefreshTokenExpiresIn: loginResponse.RefreshTokenExpiresIn,
|
|
MFAEnabled: loginResponse.MFAEnabled,
|
|
Status: loginResponse.Status,
|
|
})
|
|
}
|
|
}
|
|
|
|
// getOAuthAuthorizationURL generates OAuth authorization URL for a provider
|
|
func getOAuthAuthorizationURL(c *gin.Context) {
|
|
providerID := c.Param("provider")
|
|
if providerID == "" {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Provider ID is required",
|
|
}
|
|
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
|
return
|
|
}
|
|
|
|
// Get optional parameters
|
|
redirectURI := c.Query("redirect_uri")
|
|
state := c.Query("state")
|
|
|
|
provider, err := GetProvider(providerID)
|
|
if err != nil {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Failed to get provider",
|
|
}
|
|
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
|
return
|
|
}
|
|
|
|
if provider == nil {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: fmt.Sprintf("OAuth provider '%s' not found", providerID),
|
|
}
|
|
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
|
return
|
|
}
|
|
|
|
// Validate required provider configuration
|
|
if provider.ClientID == "" || provider.Endpoints == nil || provider.Endpoints.Authorization == "" {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Provider configuration is incomplete",
|
|
}
|
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
return
|
|
}
|
|
|
|
// Check if state is provided by user and validate format
|
|
var warnings []string
|
|
|
|
// Generate state if not provided
|
|
if state == "" {
|
|
var err error
|
|
state, err = generateRandomState()
|
|
if err != nil {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Failed to generate OAuth state",
|
|
}
|
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
return
|
|
}
|
|
} else {
|
|
// User provided state - check if it's in UUID format
|
|
if !isValidUUID(state) {
|
|
warnings = append(warnings, "State parameter is not in UUID format. For better uniqueness and security, consider using UUID format.")
|
|
}
|
|
}
|
|
|
|
// Set default redirect URI if not provided
|
|
if redirectURI == "" {
|
|
redirectURI = fmt.Sprintf("%s://%s/auth/callback", getScheme(c), c.Request.Host)
|
|
}
|
|
|
|
// Build authorization URL
|
|
params := url.Values{}
|
|
params.Add("client_id", provider.ClientID)
|
|
params.Add("response_type", "code")
|
|
params.Add("redirect_uri", redirectURI)
|
|
params.Add("state", state)
|
|
|
|
// Add scopes
|
|
if len(provider.Scopes) > 0 {
|
|
params.Add("scope", strings.Join(provider.Scopes, " "))
|
|
}
|
|
|
|
// Add response_mode if specified (required for Apple with name/email scopes)
|
|
if provider.ResponseMode != "" {
|
|
params.Add("response_mode", provider.ResponseMode)
|
|
}
|
|
|
|
// Set session id if not exists
|
|
sid := utils.GetSessionID(c)
|
|
if sid == "" {
|
|
sid = generateSessionID()
|
|
response.SendSessionCookie(c, sid)
|
|
}
|
|
|
|
// Save the state to the session for 20 minutes
|
|
err = saveState(providerID, sid, state)
|
|
if err != nil {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Failed to save OAuth state",
|
|
}
|
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
return
|
|
}
|
|
|
|
// if response mode is form_post
|
|
if provider.ResponseMode == "form_post" {
|
|
// Replace the redirectURI to
|
|
pathname := c.Request.URL.Path + "/prepare"
|
|
newRedirectURI, err := reconstructRedirectURI(redirectURI, pathname, c)
|
|
if err != nil {
|
|
log.Error("Failed to reconstruct redirectURI: %v", err)
|
|
response.RespondWithError(c, response.StatusBadRequest, &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Invalid redirect URI format",
|
|
})
|
|
return
|
|
}
|
|
|
|
params.Set("redirect_uri", newRedirectURI)
|
|
}
|
|
|
|
// Save the redirect URI to the cache
|
|
err = saveRedirectURI(providerID, state, redirectURI)
|
|
if err != nil {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Failed to save OAuth redirect URI",
|
|
}
|
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
return
|
|
}
|
|
|
|
// Build the authorization URL
|
|
authorizationURL := fmt.Sprintf("%s?%s", provider.Endpoints.Authorization, params.Encode())
|
|
|
|
// Return the authorization URL and state
|
|
response.RespondWithSuccess(c, response.StatusOK, &OAuthAuthorizationURLResponse{
|
|
AuthorizationURL: authorizationURL,
|
|
State: state,
|
|
Warnings: warnings,
|
|
})
|
|
}
|
|
|
|
// Helper functions for OAuth state management
|
|
|
|
// generateRandomState generates a UUID-based state parameter for better uniqueness
|
|
func generateRandomState() (string, error) {
|
|
u := uuid.New()
|
|
return u.String(), nil
|
|
}
|
|
|
|
// isValidUUID checks if a string is a valid UUID format
|
|
func isValidUUID(s string) bool {
|
|
// UUID v4 format: 8-4-4-4-12 hexadecimal characters
|
|
uuidRegex := regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
|
|
return uuidRegex.MatchString(strings.ToLower(s))
|
|
}
|
|
|
|
// getScheme returns the request scheme (http or https)
|
|
func getScheme(c *gin.Context) string {
|
|
if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" {
|
|
return "https"
|
|
}
|
|
return "http"
|
|
}
|
|
|
|
// reconstructRedirectURI reconstructs redirectURI with new path while preserving the original host
|
|
func reconstructRedirectURI(originalRedirectURI, newPath string, c *gin.Context) (string, error) {
|
|
// Parse the original redirectURI to extract host
|
|
parsedURL, err := url.Parse(originalRedirectURI)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to parse redirectURI: %v", err)
|
|
}
|
|
|
|
// Reconstruct with the original host and new path
|
|
newRedirectURI := fmt.Sprintf("%s://%s%s", getScheme(c), parsedURL.Host, newPath)
|
|
return newRedirectURI, nil
|
|
}
|
|
|
|
// Cache management functions
|
|
|
|
// userInfoKey returns the key for the user info
|
|
func userInfoKey(providerID, state string) string {
|
|
return fmt.Sprintf("signin:user_info:%s:%s", providerID, state)
|
|
}
|
|
|
|
// stateKey returns the key for the state
|
|
func stateKey(providerID string) string {
|
|
return fmt.Sprintf("signin:state:%s", providerID)
|
|
}
|
|
|
|
// redirectURIKey returns the key for the redirect URI
|
|
func redirectURIKey(providerID, state string) string {
|
|
return fmt.Sprintf("signin:redirect_uri:%s:%s", providerID, state)
|
|
}
|
|
|
|
// saveState saves the state to the session
|
|
func saveState(providerID, sid, state string) error {
|
|
return session.Global().ID(sid).SetWithEx(stateKey(providerID), state, 20*time.Minute)
|
|
}
|
|
|
|
// saveRedirectURI saves the redirect URI to the session
|
|
func saveRedirectURI(providerID, state, redirectURI string) error {
|
|
key := redirectURIKey(providerID, state)
|
|
store := oauth.OAuth.GetCache()
|
|
return store.Set(key, redirectURI, 20*time.Minute)
|
|
}
|
|
|
|
// getRedirectURI gets the redirect URI from the session
|
|
func getRedirectURI(providerID, state string) (string, error) {
|
|
key := redirectURIKey(providerID, state)
|
|
store := oauth.OAuth.GetCache()
|
|
value, ok := store.Get(key)
|
|
if !ok || value == nil {
|
|
return "", fmt.Errorf("redirect URI not found")
|
|
}
|
|
return value.(string), nil
|
|
}
|
|
|
|
func removeRedirectURI(providerID, state string) error {
|
|
key := redirectURIKey(providerID, state)
|
|
store := oauth.OAuth.GetCache()
|
|
return store.Del(key)
|
|
}
|
|
|
|
// saveUserInfo saves the user info to cache (for form_post mode)
|
|
func saveUserInfo(providerID, state, userInfo string) error {
|
|
key := userInfoKey(providerID, state)
|
|
store := oauth.OAuth.GetCache()
|
|
return store.Set(key, userInfo, 20*time.Minute)
|
|
}
|
|
|
|
// getUserInfo gets the user info from cache
|
|
func getUserInfo(providerID, state string) (string, error) {
|
|
key := userInfoKey(providerID, state)
|
|
store := oauth.OAuth.GetCache()
|
|
value, ok := store.Get(key)
|
|
if !ok || value == nil {
|
|
return "", fmt.Errorf("user info not found")
|
|
}
|
|
return value.(string), nil
|
|
}
|
|
|
|
// removeUserInfo removes the user info from cache
|
|
func removeUserInfo(providerID, state string) error {
|
|
key := userInfoKey(providerID, state)
|
|
store := oauth.OAuth.GetCache()
|
|
return store.Del(key)
|
|
}
|
|
|
|
// removeState removes the state from the session
|
|
func removeState(providerID, sid string) error {
|
|
// Get the state from the session
|
|
state, err := session.Global().ID(sid).Get(stateKey(providerID))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Safely convert state to string
|
|
stateStr, ok := state.(string)
|
|
if !ok {
|
|
return fmt.Errorf("invalid state type: expected string, got %T", state)
|
|
}
|
|
|
|
// Remove all related cached data
|
|
removeRedirectURI(providerID, stateStr)
|
|
removeUserInfo(providerID, stateStr)
|
|
|
|
return session.Global().ID(sid).Del(stateKey(providerID))
|
|
}
|
|
|
|
// validateState validates the state from the session
|
|
func validateState(providerID, sid, state string) error {
|
|
value, err := session.Global().ID(sid).Get(stateKey(providerID))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Safely convert value to string
|
|
stateStr, ok := value.(string)
|
|
if !ok {
|
|
return fmt.Errorf("invalid state type: expected string, got %T", value)
|
|
}
|
|
|
|
if stateStr != state {
|
|
return fmt.Errorf("invalid state")
|
|
}
|
|
|
|
return nil
|
|
}
|