Remove signin module and refactor OpenAPI to eliminate signin dependencies
- Deleted the entire signin module, including related files and configurations, to streamline the authentication process. - Updated OpenAPI to remove references to signin, including the loading of signin configurations and associated handlers. - Refactored user authentication routes to integrate captcha functionality directly within the user module, enhancing user experience and security. - Adjusted routing structure to reflect the removal of signin, ensuring clarity and consistency in user management operations.
This commit is contained in:
parent
6f3a57cb33
commit
7977b7f48e
10 changed files with 111 additions and 3409 deletions
|
|
@ -15,7 +15,6 @@ import (
|
||||||
"github.com/yaoapp/yao/openapi/kb"
|
"github.com/yaoapp/yao/openapi/kb"
|
||||||
"github.com/yaoapp/yao/openapi/oauth"
|
"github.com/yaoapp/yao/openapi/oauth"
|
||||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
"github.com/yaoapp/yao/openapi/signin"
|
|
||||||
"github.com/yaoapp/yao/openapi/team"
|
"github.com/yaoapp/yao/openapi/team"
|
||||||
"github.com/yaoapp/yao/openapi/user"
|
"github.com/yaoapp/yao/openapi/user"
|
||||||
)
|
)
|
||||||
|
|
@ -57,12 +56,6 @@ func Load(appConfig config.Config) (*OpenAPI, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load signin configurations
|
|
||||||
err = signin.Load(appConfig)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load user configurations
|
// Load user configurations
|
||||||
err = user.Load(appConfig)
|
err = user.Load(appConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -110,9 +103,6 @@ func (openapi *OpenAPI) Attach(router *gin.Engine) {
|
||||||
// Chat handlers
|
// Chat handlers
|
||||||
chat.Attach(group.Group("/chat"), openapi.OAuth)
|
chat.Attach(group.Group("/chat"), openapi.OAuth)
|
||||||
|
|
||||||
// Signin handlers
|
|
||||||
signin.Attach(group, openapi.OAuth)
|
|
||||||
|
|
||||||
// Captcha handlers
|
// Captcha handlers
|
||||||
captcha.Attach(group.Group("/captcha"), openapi.OAuth)
|
captcha.Attach(group.Group("/captcha"), openapi.OAuth)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,693 +0,0 @@
|
||||||
package signin
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"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/oauth/types"
|
|
||||||
"github.com/yaoapp/yao/openapi/response"
|
|
||||||
"github.com/yaoapp/yao/openapi/utils"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Attach attaches the signin handlers to the router
|
|
||||||
func Attach(group *gin.RouterGroup, oauth types.OAuth) {
|
|
||||||
group.GET("/signin", getConfig)
|
|
||||||
group.POST("/signin", signin)
|
|
||||||
group.POST("/signin/oauth/:provider/authback", authback)
|
|
||||||
group.GET("/signin/oauth/:provider/authorize", getOAuthAuthorizationURL)
|
|
||||||
group.POST("/signin/oauth/:provider/authorize/prepare", authbackPrepare) // Receive the post data and forward to the authback handler
|
|
||||||
}
|
|
||||||
|
|
||||||
// getConfig is the handler for get signin configuration
|
|
||||||
func getConfig(c *gin.Context) {
|
|
||||||
// Get locale from query parameter (optional)
|
|
||||||
locale := c.Query("locale")
|
|
||||||
|
|
||||||
// Get public configuration for the specified locale
|
|
||||||
config := GetPublicConfig(locale)
|
|
||||||
|
|
||||||
// Set session id if not exists
|
|
||||||
sid := utils.GetSessionID(c)
|
|
||||||
if sid == "" {
|
|
||||||
sid = generateSessionID()
|
|
||||||
response.SendSessionCookie(c, sid)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no configuration found, return error
|
|
||||||
if config == nil {
|
|
||||||
errorResp := &response.ErrorResponse{
|
|
||||||
Code: response.ErrInvalidRequest.Code,
|
|
||||||
ErrorDescription: "No signin configuration found for the requested locale",
|
|
||||||
}
|
|
||||||
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the public configuration
|
|
||||||
response.RespondWithSuccess(c, response.StatusOK, config)
|
|
||||||
}
|
|
||||||
|
|
||||||
// signin is the handler for signin (password login)
|
|
||||||
func signin(c *gin.Context) {}
|
|
||||||
|
|
||||||
// authback is the handler for authback
|
|
||||||
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 authback
|
|
||||||
func authback(c *gin.Context) {
|
|
||||||
sid := utils.GetSessionID(c)
|
|
||||||
var params OAuthAuthbackRequest
|
|
||||||
providerID := c.Param("provider")
|
|
||||||
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, "/authback") + "/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)
|
|
||||||
loginResponse, err := LoginThirdParty(providerID, userInfo, userIPAddress(c))
|
|
||||||
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)
|
|
||||||
|
|
||||||
// Send IDToken to the client
|
|
||||||
response.RespondWithSuccess(c, response.StatusOK, map[string]interface{}{"id_token": loginResponse.IDToken})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateSessionID generates a session ID
|
|
||||||
func generateSessionID() string {
|
|
||||||
return session.ID()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendLoginCookies sends all necessary cookies for a successful login
|
|
||||||
// This includes access token, refresh token, and session ID cookies with appropriate security settings
|
|
||||||
func SendLoginCookies(c *gin.Context, loginResponse *LoginResponse, sessionID string) {
|
|
||||||
// Format tokens with Bearer prefix
|
|
||||||
accessToken := fmt.Sprintf("%s %s", loginResponse.TokenType, loginResponse.AccessToken)
|
|
||||||
refreshToken := fmt.Sprintf("%s %s", loginResponse.TokenType, loginResponse.RefreshToken)
|
|
||||||
|
|
||||||
// Calculate expiration times
|
|
||||||
expires := time.Now().Add(time.Duration(loginResponse.ExpiresIn) * time.Second)
|
|
||||||
refreshExpires := time.Now().Add(time.Duration(loginResponse.RefreshTokenExpiresIn) * time.Second)
|
|
||||||
|
|
||||||
// Send access token cookie
|
|
||||||
response.SendAccessTokenCookieWithExpiry(c, accessToken, expires)
|
|
||||||
|
|
||||||
// Send refresh token cookie
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
package signin
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/yaoapp/kun/log"
|
|
||||||
"github.com/yaoapp/yao/openapi/oauth"
|
|
||||||
"github.com/yaoapp/yao/openapi/oauth/providers/user"
|
|
||||||
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
// LoginThirdParty is the handler for third party login
|
|
||||||
func LoginThirdParty(providerID string, userinfo *oauthtypes.OIDCUserInfo, ip string) (*LoginResponse, error) {
|
|
||||||
|
|
||||||
// Get provider
|
|
||||||
provider, err := GetProvider(providerID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user exists
|
|
||||||
userProvider, err := oauth.OAuth.GetUserProvider()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto register user if not exists
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
var userID string
|
|
||||||
|
|
||||||
// Auto register user if not exists
|
|
||||||
if provider.Register != nil && provider.Register.Auto {
|
|
||||||
userID, err = userProvider.GetOAuthUserID(ctx, providerID, userinfo.Sub)
|
|
||||||
if err != nil && err.Error() == user.ErrOAuthAccountNotFound {
|
|
||||||
|
|
||||||
userData := map[string]interface{}{
|
|
||||||
"name": userinfo.Name,
|
|
||||||
"given_name": userinfo.GivenName,
|
|
||||||
"family_name": userinfo.FamilyName,
|
|
||||||
"picture": userinfo.Picture,
|
|
||||||
"role_id": provider.Register.Role,
|
|
||||||
"status": "active",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto register user
|
|
||||||
userID, err = userProvider.CreateUser(ctx, userData)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create OAuth account
|
|
||||||
userData = userinfo.Map()
|
|
||||||
userData["provider"] = providerID
|
|
||||||
_, err = userProvider.CreateOAuthAccount(ctx, userID, userData)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get User ID from OAuth account
|
|
||||||
userID, err = userProvider.GetOAuthUserID(ctx, providerID, userinfo.Sub)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return LoginByUserID(userID, ip)
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoginByUserID is the handler for login
|
|
||||||
func LoginByUserID(userid string, ip string) (*LoginResponse, error) {
|
|
||||||
|
|
||||||
// Get User
|
|
||||||
userProvider, err := oauth.OAuth.GetUserProvider()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get User
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
user, err := userProvider.GetUserWithScopes(ctx, userid)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update Last Login
|
|
||||||
err = userProvider.UpdateUserLastLogin(ctx, userid, ip)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn("Failed to update last login: %s", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
var scopes []string = yaoClientConfig.Scopes
|
|
||||||
if v, ok := user["scopes"].([]string); ok {
|
|
||||||
scopes = v
|
|
||||||
}
|
|
||||||
|
|
||||||
subject, err := oauth.OAuth.Subject(yaoClientConfig.ClientID, userid)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn("Failed to store user fingerprint: %s", err.Error())
|
|
||||||
}
|
|
||||||
oidcUserInfo := oauthtypes.MakeOIDCUserInfo(user)
|
|
||||||
oidcUserInfo.Sub = subject
|
|
||||||
|
|
||||||
// OIDC Token
|
|
||||||
oidcToken, err := oauth.OAuth.SignIDToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), yaoClientConfig.ExpiresIn, oidcUserInfo)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Access Token
|
|
||||||
accessToken, err := oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.ExpiresIn)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh Token
|
|
||||||
refreshToken, err := oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.RefreshTokenExpiresIn)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &LoginResponse{
|
|
||||||
AccessToken: accessToken,
|
|
||||||
IDToken: oidcToken,
|
|
||||||
RefreshToken: refreshToken,
|
|
||||||
ExpiresIn: yaoClientConfig.ExpiresIn,
|
|
||||||
RefreshTokenExpiresIn: yaoClientConfig.RefreshTokenExpiresIn,
|
|
||||||
TokenType: "Bearer",
|
|
||||||
Scope: strings.Join(scopes, " "),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,680 +0,0 @@
|
||||||
package signin
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/yaoapp/gou/application"
|
|
||||||
"github.com/yaoapp/kun/log"
|
|
||||||
"github.com/yaoapp/yao/config"
|
|
||||||
"github.com/yaoapp/yao/openapi/oauth"
|
|
||||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Global variables to store loaded configurations
|
|
||||||
var (
|
|
||||||
// Client config
|
|
||||||
yaoClientConfig *YaoClientConfig
|
|
||||||
|
|
||||||
// Full configurations with sensitive data (for backend use)
|
|
||||||
fullConfigs = make(map[string]*Config)
|
|
||||||
// Public configurations without sensitive data (for frontend use)
|
|
||||||
publicConfigs = make(map[string]*Config)
|
|
||||||
// Global providers map (decoupled from locale-specific configs)
|
|
||||||
providers = make(map[string]*Provider)
|
|
||||||
// Default configuration (marked with default: true)
|
|
||||||
defaultConfig *Config
|
|
||||||
// Mutex for thread safety
|
|
||||||
configMutex sync.RWMutex
|
|
||||||
)
|
|
||||||
|
|
||||||
// Load loads all signin configurations from the openapi/signin directory
|
|
||||||
func Load(appConfig config.Config) error {
|
|
||||||
configMutex.Lock()
|
|
||||||
defer configMutex.Unlock()
|
|
||||||
|
|
||||||
// Clear existing configurations
|
|
||||||
fullConfigs = make(map[string]*Config)
|
|
||||||
publicConfigs = make(map[string]*Config)
|
|
||||||
providers = make(map[string]*Provider)
|
|
||||||
defaultConfig = nil
|
|
||||||
|
|
||||||
// Load signin configurations
|
|
||||||
err := loadSigninConfigs(appConfig.Root)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to load signin configs: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load providers first
|
|
||||||
err = loadProviders(appConfig.Root)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to load providers: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load client config
|
|
||||||
err = loadClientConfig()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to load client config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadClientConfig loads the client config from the openapi/signin/client.yao file
|
|
||||||
func loadClientConfig() error {
|
|
||||||
// Check if client config exists
|
|
||||||
exists, err := application.App.Exists("openapi/signin/client.yao")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to check if client config exists: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
return fmt.Errorf("client config not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read client config
|
|
||||||
clientConfigRaw, err := application.App.Read("openapi/signin/client.yao")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read client config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var clientConfig YaoClientConfig
|
|
||||||
err = application.Parse("openapi/signin/client.yao", clientConfigRaw, &clientConfig)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to parse client config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process ENV variables in client config
|
|
||||||
clientConfig.ClientID = replaceENVVar(clientConfig.ClientID)
|
|
||||||
clientConfig.ClientSecret = replaceENVVar(clientConfig.ClientSecret)
|
|
||||||
|
|
||||||
// Validate client config
|
|
||||||
err = validateClientConfig(&clientConfig)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to validate client config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
yaoClientConfig = &clientConfig
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateClientConfig validates the client config
|
|
||||||
func validateClientConfig(clientConfig *YaoClientConfig) error {
|
|
||||||
|
|
||||||
// Validate client ID
|
|
||||||
err := oauth.OAuth.ValidateClientID(clientConfig.ClientID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Validate client is registered
|
|
||||||
c := oauth.OAuth.GetClientProvider()
|
|
||||||
_, err = c.GetClientByID(ctx, clientConfig.ClientID)
|
|
||||||
if err != nil {
|
|
||||||
// If client is not registered, register it
|
|
||||||
if strings.Contains(err.Error(), "Client not found") {
|
|
||||||
yaoClientConfig, err = registerClient(clientConfig.ClientID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to register client: %v", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return fmt.Errorf("failed to get client: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// registerClient registers the client config with the OAuth server
|
|
||||||
func registerClient(clientID string) (*YaoClientConfig, error) {
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Register client
|
|
||||||
response, err := oauth.OAuth.DynamicClientRegistration(ctx, &types.DynamicClientRegistrationRequest{
|
|
||||||
ClientID: clientID,
|
|
||||||
ClientName: "Yao OpenAPI Client",
|
|
||||||
ResponseTypes: []string{"code"},
|
|
||||||
GrantTypes: []string{"client_credentials"},
|
|
||||||
ApplicationType: types.ApplicationTypeWeb,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create client: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var clientConfig *YaoClientConfig = &YaoClientConfig{}
|
|
||||||
clientConfig.ClientID = response.ClientID
|
|
||||||
clientConfig.ClientSecret = response.ClientSecret
|
|
||||||
clientConfig.ExpiresIn = 3600 * 24 // 24 hours
|
|
||||||
clientConfig.RefreshTokenExpiresIn = 3600 * 24 * 30 // 30 days
|
|
||||||
clientConfig.Scopes = []string{"openid", "profile", "email"}
|
|
||||||
return clientConfig, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadProviders loads all provider configurations from the openapi/signin/providers directory
|
|
||||||
func loadProviders(rootPath string) error {
|
|
||||||
// Use Walk to find all provider files in the signin/providers directory
|
|
||||||
err := application.App.Walk("openapi/signin/providers", func(root, filename string, isdir bool) error {
|
|
||||||
if isdir {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only process .yao files
|
|
||||||
if !strings.HasSuffix(filename, ".yao") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip client.yao file
|
|
||||||
if filename == "client.yao" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract provider ID from filename (basename without extension)
|
|
||||||
baseName := filepath.Base(filename)
|
|
||||||
providerID := strings.TrimSuffix(baseName, ".yao")
|
|
||||||
|
|
||||||
// Read provider configuration
|
|
||||||
configRaw, err := application.App.Read(filename)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read provider config %s: %v", filename, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the provider configuration
|
|
||||||
var provider Provider
|
|
||||||
err = application.Parse(filename, configRaw, &provider)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to parse provider config %s: %v", filename, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the provider ID
|
|
||||||
provider.ID = providerID
|
|
||||||
|
|
||||||
// Process ENV variables in provider config
|
|
||||||
processProviderENVVariables(&provider, rootPath)
|
|
||||||
|
|
||||||
// Store the provider
|
|
||||||
providers[providerID] = &provider
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}, "*.yao")
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadSigninConfigs loads all signin configurations from the openapi/signin directory
|
|
||||||
func loadSigninConfigs(rootPath string) error {
|
|
||||||
// Use Walk to find all signin config files in the signin directory (but not subdirectories)
|
|
||||||
err := application.App.Walk("openapi/signin", func(root, filename string, isdir bool) error {
|
|
||||||
if isdir {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip files in subdirectories (like providers/)
|
|
||||||
if filepath.Dir(filename) != "openapi/signin" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only process .yao files
|
|
||||||
if !strings.HasSuffix(filename, ".yao") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract language code from filename
|
|
||||||
baseName := filepath.Base(filename)
|
|
||||||
lang := extractLanguageFromFilename(baseName)
|
|
||||||
|
|
||||||
// Read signin configuration
|
|
||||||
configRaw, err := application.App.Read(filename)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read signin config %s: %v", filename, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the configuration
|
|
||||||
var signinConfig Config
|
|
||||||
err = application.Parse(filename, configRaw, &signinConfig)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to parse signin config %s: %v", filename, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process ENV variables in full config
|
|
||||||
fullConfig := signinConfig
|
|
||||||
processConfigENVVariables(&fullConfig, rootPath)
|
|
||||||
|
|
||||||
// Set as default config if marked as default
|
|
||||||
if fullConfig.Default {
|
|
||||||
defaultConfig = &fullConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create public config (without sensitive data)
|
|
||||||
publicConfig := createPublicConfig(&fullConfig)
|
|
||||||
|
|
||||||
// Store configurations
|
|
||||||
fullConfigs[lang] = &fullConfig
|
|
||||||
publicConfigs[lang] = &publicConfig
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}, "*.yao")
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractLanguageFromFilename extracts language code from filename
|
|
||||||
func extractLanguageFromFilename(filename string) string {
|
|
||||||
// New naming convention:
|
|
||||||
// en.yao -> "en"
|
|
||||||
// zh-cn.yao -> "zh-cn"
|
|
||||||
// default.yao -> "default"
|
|
||||||
|
|
||||||
baseName := strings.TrimSuffix(filename, ".yao")
|
|
||||||
return strings.ToLower(baseName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// processProviderENVVariables processes environment variables in the provider configuration
|
|
||||||
func processProviderENVVariables(provider *Provider, rootPath string) {
|
|
||||||
var missingEnvVars []string
|
|
||||||
|
|
||||||
// Process ClientID
|
|
||||||
if strings.HasPrefix(provider.ClientID, "$ENV.") {
|
|
||||||
envVar := strings.TrimPrefix(provider.ClientID, "$ENV.")
|
|
||||||
if _, exists := os.LookupEnv(envVar); !exists {
|
|
||||||
missingEnvVars = append(missingEnvVars, envVar)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
provider.ClientID = replaceENVVar(provider.ClientID)
|
|
||||||
|
|
||||||
// Process ClientSecret
|
|
||||||
if strings.HasPrefix(provider.ClientSecret, "$ENV.") {
|
|
||||||
envVar := strings.TrimPrefix(provider.ClientSecret, "$ENV.")
|
|
||||||
if _, exists := os.LookupEnv(envVar); !exists {
|
|
||||||
missingEnvVars = append(missingEnvVars, envVar)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
provider.ClientSecret = replaceENVVar(provider.ClientSecret)
|
|
||||||
|
|
||||||
// Process client secret generator
|
|
||||||
if provider.ClientSecretGenerator != nil {
|
|
||||||
// Check PrivateKey
|
|
||||||
if strings.HasPrefix(provider.ClientSecretGenerator.PrivateKey, "$ENV.") {
|
|
||||||
envVar := strings.TrimPrefix(provider.ClientSecretGenerator.PrivateKey, "$ENV.")
|
|
||||||
if _, exists := os.LookupEnv(envVar); !exists {
|
|
||||||
missingEnvVars = append(missingEnvVars, envVar)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
provider.ClientSecretGenerator.PrivateKey = replaceENVVar(provider.ClientSecretGenerator.PrivateKey)
|
|
||||||
|
|
||||||
// Convert relative path to absolute path for private key
|
|
||||||
if provider.ClientSecretGenerator.PrivateKey != "" && !filepath.IsAbs(provider.ClientSecretGenerator.PrivateKey) {
|
|
||||||
provider.ClientSecretGenerator.PrivateKey = filepath.Join(rootPath, "openapi", "certs", provider.ClientSecretGenerator.PrivateKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process and normalize expires_in format
|
|
||||||
if provider.ClientSecretGenerator.ExpiresIn != "" {
|
|
||||||
normalizedDuration, err := normalizeExpiresIn(provider.ClientSecretGenerator.ExpiresIn)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn("Invalid expires_in format '%s' for provider '%s': %v",
|
|
||||||
provider.ClientSecretGenerator.ExpiresIn, provider.ID, err)
|
|
||||||
// Set default to 90 days
|
|
||||||
provider.ClientSecretGenerator.ExpiresIn = "2160h" // 90 * 24 hours
|
|
||||||
} else {
|
|
||||||
provider.ClientSecretGenerator.ExpiresIn = normalizedDuration
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process header values
|
|
||||||
if provider.ClientSecretGenerator.Header != nil {
|
|
||||||
for key, value := range provider.ClientSecretGenerator.Header {
|
|
||||||
if strValue, ok := value.(string); ok {
|
|
||||||
if strings.HasPrefix(strValue, "$ENV.") {
|
|
||||||
envVar := strings.TrimPrefix(strValue, "$ENV.")
|
|
||||||
if _, exists := os.LookupEnv(envVar); !exists {
|
|
||||||
missingEnvVars = append(missingEnvVars, envVar)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
provider.ClientSecretGenerator.Header[key] = replaceENVVar(strValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process payload values
|
|
||||||
if provider.ClientSecretGenerator.Payload != nil {
|
|
||||||
for key, value := range provider.ClientSecretGenerator.Payload {
|
|
||||||
if strValue, ok := value.(string); ok {
|
|
||||||
if strings.HasPrefix(strValue, "$ENV.") {
|
|
||||||
envVar := strings.TrimPrefix(strValue, "$ENV.")
|
|
||||||
if _, exists := os.LookupEnv(envVar); !exists {
|
|
||||||
missingEnvVars = append(missingEnvVars, envVar)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
provider.ClientSecretGenerator.Payload[key] = replaceENVVar(strValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log warning for missing environment variables
|
|
||||||
if len(missingEnvVars) > 0 {
|
|
||||||
log.Warn("The following environment variables are not set for provider '%s': %v", provider.ID, missingEnvVars)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// processConfigENVVariables processes environment variables in the signin configuration
|
|
||||||
func processConfigENVVariables(config *Config, rootPath string) {
|
|
||||||
var missingEnvVars []string
|
|
||||||
|
|
||||||
// Process client_id and client_secret
|
|
||||||
if strings.HasPrefix(config.ClientID, "$ENV.") {
|
|
||||||
envVar := strings.TrimPrefix(config.ClientID, "$ENV.")
|
|
||||||
if _, exists := os.LookupEnv(envVar); !exists {
|
|
||||||
missingEnvVars = append(missingEnvVars, envVar)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
config.ClientID = replaceENVVar(config.ClientID)
|
|
||||||
|
|
||||||
if strings.HasPrefix(config.ClientSecret, "$ENV.") {
|
|
||||||
envVar := strings.TrimPrefix(config.ClientSecret, "$ENV.")
|
|
||||||
if _, exists := os.LookupEnv(envVar); !exists {
|
|
||||||
missingEnvVars = append(missingEnvVars, envVar)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
config.ClientSecret = replaceENVVar(config.ClientSecret)
|
|
||||||
|
|
||||||
// Process form captcha options
|
|
||||||
if config.Form != nil && config.Form.Captcha != nil && config.Form.Captcha.Options != nil {
|
|
||||||
for key, value := range config.Form.Captcha.Options {
|
|
||||||
if strValue, ok := value.(string); ok {
|
|
||||||
// Check if ENV variable exists before replacement
|
|
||||||
if strings.HasPrefix(strValue, "$ENV.") {
|
|
||||||
envVar := strings.TrimPrefix(strValue, "$ENV.")
|
|
||||||
if _, exists := os.LookupEnv(envVar); !exists {
|
|
||||||
missingEnvVars = append(missingEnvVars, envVar)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
config.Form.Captcha.Options[key] = replaceENVVar(strValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: Third party providers are now handled separately in loadProviders()
|
|
||||||
// No need to process provider configurations here anymore
|
|
||||||
|
|
||||||
// Log warning for missing environment variables
|
|
||||||
if len(missingEnvVars) > 0 {
|
|
||||||
log.Warn("The following environment variables are not set in signin configuration: %v", missingEnvVars)
|
|
||||||
log.Warn("Please set these environment variables to avoid exposing placeholder values in configuration")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// replaceENVVar replaces environment variables in the format $ENV.VAR_NAME
|
|
||||||
func replaceENVVar(value string) string {
|
|
||||||
if strings.HasPrefix(value, "$ENV.") {
|
|
||||||
envVar := strings.TrimPrefix(value, "$ENV.")
|
|
||||||
envValue, exists := os.LookupEnv(envVar)
|
|
||||||
if exists {
|
|
||||||
return envValue
|
|
||||||
}
|
|
||||||
// If environment variable doesn't exist, return empty string for security
|
|
||||||
// Never expose ENV placeholder values to prevent configuration leakage
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
// createPublicConfig creates a public version of the configuration without sensitive data
|
|
||||||
func createPublicConfig(fullConfig *Config) Config {
|
|
||||||
// Perform deep copy to avoid modifying the original fullConfig
|
|
||||||
publicConfig := Config{
|
|
||||||
Title: fullConfig.Title,
|
|
||||||
Description: fullConfig.Description,
|
|
||||||
SuccessURL: fullConfig.SuccessURL,
|
|
||||||
FailureURL: fullConfig.FailureURL,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deep copy Form configuration
|
|
||||||
if fullConfig.Form != nil {
|
|
||||||
publicConfig.Form = &FormConfig{
|
|
||||||
ForgotPasswordLink: fullConfig.Form.ForgotPasswordLink,
|
|
||||||
RememberMe: fullConfig.Form.RememberMe,
|
|
||||||
RegisterLink: fullConfig.Form.RegisterLink,
|
|
||||||
TermsOfServiceLink: fullConfig.Form.TermsOfServiceLink,
|
|
||||||
PrivacyPolicyLink: fullConfig.Form.PrivacyPolicyLink,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deep copy Username configuration
|
|
||||||
if fullConfig.Form.Username != nil {
|
|
||||||
publicConfig.Form.Username = &UsernameConfig{
|
|
||||||
Placeholder: fullConfig.Form.Username.Placeholder,
|
|
||||||
Fields: append([]string(nil), fullConfig.Form.Username.Fields...),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deep copy Password configuration
|
|
||||||
if fullConfig.Form.Password != nil {
|
|
||||||
publicConfig.Form.Password = &PasswordConfig{
|
|
||||||
Placeholder: fullConfig.Form.Password.Placeholder,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deep copy Captcha configuration with sensitive data removal
|
|
||||||
if fullConfig.Form.Captcha != nil {
|
|
||||||
publicConfig.Form.Captcha = &CaptchaConfig{
|
|
||||||
Type: fullConfig.Form.Captcha.Type,
|
|
||||||
}
|
|
||||||
|
|
||||||
if fullConfig.Form.Captcha.Options != nil {
|
|
||||||
// Create a new options map without sensitive fields
|
|
||||||
publicOptions := make(map[string]interface{})
|
|
||||||
for key, value := range fullConfig.Form.Captcha.Options {
|
|
||||||
// Only include non-sensitive fields
|
|
||||||
switch key {
|
|
||||||
case "sitekey", "theme", "size", "action", "cdata", "response_mode":
|
|
||||||
// These are safe to expose to frontend
|
|
||||||
publicOptions[key] = value
|
|
||||||
case "secret":
|
|
||||||
// Remove secret field - this should never be exposed to frontend
|
|
||||||
continue
|
|
||||||
default:
|
|
||||||
// For unknown fields, be conservative and exclude them
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
publicConfig.Form.Captcha.Options = publicOptions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deep copy Token configuration
|
|
||||||
if fullConfig.Token != nil {
|
|
||||||
publicConfig.Token = &TokenConfig{
|
|
||||||
ExpiresIn: fullConfig.Token.ExpiresIn,
|
|
||||||
RememberMeExpiresIn: fullConfig.Token.RememberMeExpiresIn,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deep copy ThirdParty configuration with sensitive data removal
|
|
||||||
if fullConfig.ThirdParty != nil {
|
|
||||||
publicConfig.ThirdParty = &ThirdParty{}
|
|
||||||
|
|
||||||
// Deep copy Providers with sensitive data removal
|
|
||||||
if fullConfig.ThirdParty.Providers != nil {
|
|
||||||
publicProviders := make([]*Provider, len(fullConfig.ThirdParty.Providers))
|
|
||||||
for i, provider := range fullConfig.ThirdParty.Providers {
|
|
||||||
publicProvider := Provider{
|
|
||||||
ID: provider.ID,
|
|
||||||
Title: provider.Title,
|
|
||||||
Label: provider.Label,
|
|
||||||
Logo: provider.Logo,
|
|
||||||
Color: provider.Color,
|
|
||||||
TextColor: provider.TextColor,
|
|
||||||
// Only expose display fields for frontend
|
|
||||||
// Remove sensitive fields: ClientID, ClientSecret, ClientSecretGenerator, Scopes, Endpoints, Mapping, Register
|
|
||||||
}
|
|
||||||
|
|
||||||
publicProviders[i] = &publicProvider
|
|
||||||
}
|
|
||||||
publicConfig.ThirdParty.Providers = publicProviders
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return publicConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetFullConfig returns the full configuration for a given language
|
|
||||||
func GetFullConfig(lang string) *Config {
|
|
||||||
configMutex.RLock()
|
|
||||||
defer configMutex.RUnlock()
|
|
||||||
|
|
||||||
// Normalize language code to lowercase
|
|
||||||
if lang != "" {
|
|
||||||
lang = strings.ToLower(lang)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to get specific language config
|
|
||||||
if config, exists := fullConfigs[lang]; exists {
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to default config (marked with default: true)
|
|
||||||
if defaultConfig != nil {
|
|
||||||
return defaultConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return any available config as last resort
|
|
||||||
for _, config := range fullConfigs {
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPublicConfig returns the public configuration for a given language
|
|
||||||
func GetPublicConfig(lang string) *Config {
|
|
||||||
configMutex.RLock()
|
|
||||||
defer configMutex.RUnlock()
|
|
||||||
|
|
||||||
// Normalize language code to lowercase
|
|
||||||
if lang != "" {
|
|
||||||
lang = strings.ToLower(lang)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to get specific language config
|
|
||||||
if config, exists := publicConfigs[lang]; exists {
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to default config's public version
|
|
||||||
if defaultConfig != nil {
|
|
||||||
// Find the public version of the default config
|
|
||||||
for lang, fullConfig := range fullConfigs {
|
|
||||||
if fullConfig == defaultConfig {
|
|
||||||
if publicConfig, exists := publicConfigs[lang]; exists {
|
|
||||||
return publicConfig
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return any available config as last resort
|
|
||||||
for _, config := range publicConfigs {
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAvailableLanguages returns all available language codes
|
|
||||||
func GetAvailableLanguages() []string {
|
|
||||||
configMutex.RLock()
|
|
||||||
defer configMutex.RUnlock()
|
|
||||||
|
|
||||||
var languages []string
|
|
||||||
for lang := range fullConfigs {
|
|
||||||
languages = append(languages, lang)
|
|
||||||
}
|
|
||||||
|
|
||||||
return languages
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDefaultLanguage returns the default language code
|
|
||||||
func GetDefaultLanguage() string {
|
|
||||||
configMutex.RLock()
|
|
||||||
defer configMutex.RUnlock()
|
|
||||||
|
|
||||||
// Find the language code for the default config
|
|
||||||
if defaultConfig != nil {
|
|
||||||
for lang, config := range fullConfigs {
|
|
||||||
if config == defaultConfig {
|
|
||||||
return lang
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the first available language as fallback
|
|
||||||
for lang := range fullConfigs {
|
|
||||||
return lang
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalizeExpiresIn converts custom time units to Go standard duration format
|
|
||||||
func normalizeExpiresIn(expiresIn string) (string, error) {
|
|
||||||
if expiresIn == "" {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try parsing as standard Go duration first
|
|
||||||
if _, err := time.ParseDuration(expiresIn); err == nil {
|
|
||||||
return expiresIn, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom unit conversion patterns
|
|
||||||
patterns := map[string]func(int) string{
|
|
||||||
"d": func(n int) string { return fmt.Sprintf("%dh", n*24) }, // days to hours
|
|
||||||
"w": func(n int) string { return fmt.Sprintf("%dh", n*24*7) }, // weeks to hours
|
|
||||||
"M": func(n int) string { return fmt.Sprintf("%dh", n*24*30) }, // months to hours (approximate)
|
|
||||||
"y": func(n int) string { return fmt.Sprintf("%dh", n*24*365) }, // years to hours (approximate)
|
|
||||||
"ms": func(n int) string { return fmt.Sprintf("%dms", n) }, // milliseconds
|
|
||||||
"s": func(n int) string { return fmt.Sprintf("%ds", n) }, // seconds
|
|
||||||
"m": func(n int) string { return fmt.Sprintf("%dm", n) }, // minutes
|
|
||||||
"h": func(n int) string { return fmt.Sprintf("%dh", n) }, // hours
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract number and unit using regex
|
|
||||||
re := regexp.MustCompile(`^(\d+)(\w+)$`)
|
|
||||||
matches := re.FindStringSubmatch(expiresIn)
|
|
||||||
|
|
||||||
if len(matches) != 3 {
|
|
||||||
return "", fmt.Errorf("invalid duration format: %s", expiresIn)
|
|
||||||
}
|
|
||||||
|
|
||||||
number, err := strconv.Atoi(matches[1])
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("invalid number in duration: %s", matches[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
unit := matches[2]
|
|
||||||
converter, exists := patterns[unit]
|
|
||||||
if !exists {
|
|
||||||
return "", fmt.Errorf("unsupported time unit: %s", unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
normalized := converter(number)
|
|
||||||
|
|
||||||
// Validate the normalized duration
|
|
||||||
if _, err := time.ParseDuration(normalized); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create valid duration: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,190 +0,0 @@
|
||||||
package signin
|
|
||||||
|
|
||||||
import (
|
|
||||||
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Config represents the signin page configuration
|
|
||||||
type Config struct {
|
|
||||||
Title string `json:"title,omitempty"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
Default bool `json:"default,omitempty"`
|
|
||||||
SuccessURL string `json:"success_url,omitempty"`
|
|
||||||
FailureURL string `json:"failure_url,omitempty"`
|
|
||||||
ClientID string `json:"client_id,omitempty"`
|
|
||||||
ClientSecret string `json:"client_secret,omitempty"`
|
|
||||||
Form *FormConfig `json:"form,omitempty"`
|
|
||||||
Token *TokenConfig `json:"token,omitempty"`
|
|
||||||
ThirdParty *ThirdParty `json:"third_party,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormConfig represents the form configuration
|
|
||||||
type FormConfig struct {
|
|
||||||
Username *UsernameConfig `json:"username,omitempty"`
|
|
||||||
Password *PasswordConfig `json:"password,omitempty"`
|
|
||||||
Captcha *CaptchaConfig `json:"captcha,omitempty"`
|
|
||||||
ForgotPasswordLink bool `json:"forgot_password_link,omitempty"`
|
|
||||||
RememberMe bool `json:"remember_me,omitempty"`
|
|
||||||
RegisterLink string `json:"register_link,omitempty"`
|
|
||||||
TermsOfServiceLink string `json:"terms_of_service_link,omitempty"`
|
|
||||||
PrivacyPolicyLink string `json:"privacy_policy_link,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UsernameConfig represents the username field configuration
|
|
||||||
type UsernameConfig struct {
|
|
||||||
Placeholder string `json:"placeholder,omitempty"`
|
|
||||||
Fields []string `json:"fields,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PasswordConfig represents the password field configuration
|
|
||||||
type PasswordConfig struct {
|
|
||||||
Placeholder string `json:"placeholder,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CaptchaConfig represents the captcha configuration
|
|
||||||
type CaptchaConfig struct {
|
|
||||||
Type string `json:"type,omitempty"`
|
|
||||||
Options map[string]interface{} `json:"options,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TokenConfig represents the token configuration
|
|
||||||
type TokenConfig struct {
|
|
||||||
ExpiresIn string `json:"expires_in,omitempty"`
|
|
||||||
RememberMeExpiresIn string `json:"remember_me_expires_in,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ThirdParty represents the third party login configuration
|
|
||||||
type ThirdParty struct {
|
|
||||||
Providers []*Provider `json:"providers,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterConfig represents the auto register configuration
|
|
||||||
type RegisterConfig struct {
|
|
||||||
Auto bool `json:"auto,omitempty"`
|
|
||||||
Role string `json:"role,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// YaoClientConfig represents the Yao OpenAPI Client config
|
|
||||||
type YaoClientConfig struct {
|
|
||||||
ClientID string `json:"client_id,omitempty"`
|
|
||||||
ClientSecret string `json:"client_secret,omitempty"`
|
|
||||||
Scopes []string `json:"scopes,omitempty"` // Default scopes if not set in the provider config
|
|
||||||
ExpiresIn int `json:"expires_in,omitempty"` // Default expires in for the access token (optional) in seconds
|
|
||||||
RefreshTokenExpiresIn int `json:"refresh_token_expires_in,omitempty"` // Default expires in for the refresh token (optional) in seconds
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provider represents a third party login provider
|
|
||||||
type Provider struct {
|
|
||||||
ID string `json:"id,omitempty"`
|
|
||||||
Label string `json:"label,omitempty"`
|
|
||||||
Title string `json:"title,omitempty"`
|
|
||||||
Logo string `json:"logo,omitempty"`
|
|
||||||
Color string `json:"color,omitempty"`
|
|
||||||
TextColor string `json:"text_color,omitempty"`
|
|
||||||
ClientID string `json:"client_id,omitempty"`
|
|
||||||
ClientSecret string `json:"client_secret,omitempty"`
|
|
||||||
ClientSecretGenerator *SecretGenerator `json:"client_secret_generator,omitempty"`
|
|
||||||
Scopes []string `json:"scopes,omitempty"`
|
|
||||||
ResponseMode string `json:"response_mode,omitempty"`
|
|
||||||
UserInfoSource string `json:"user_info_source,omitempty"` // "endpoint" (default) | "id_token" | "access_token"
|
|
||||||
Endpoints *Endpoints `json:"endpoints,omitempty"`
|
|
||||||
Mapping interface{} `json:"mapping,omitempty"` // string (preset) | map[string]string (custom) | nil (generic)
|
|
||||||
Register *RegisterConfig `json:"register,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SecretGenerator represents the client secret generator configuration
|
|
||||||
type SecretGenerator struct {
|
|
||||||
Type string `json:"type,omitempty"`
|
|
||||||
ExpiresIn string `json:"expires_in,omitempty"`
|
|
||||||
PrivateKey string `json:"private_key,omitempty"`
|
|
||||||
Header map[string]interface{} `json:"header,omitempty"`
|
|
||||||
Payload map[string]interface{} `json:"payload,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Endpoints represents the OAuth endpoints
|
|
||||||
type Endpoints struct {
|
|
||||||
Authorization string `json:"authorization,omitempty"`
|
|
||||||
Token string `json:"token,omitempty"`
|
|
||||||
UserInfo string `json:"user_info,omitempty"`
|
|
||||||
JWKS string `json:"jwks,omitempty"` // JSON Web Key Set endpoint for token verification
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==== API Types ====
|
|
||||||
|
|
||||||
// OAuthAuthorizationURLResponse represents the response for OAuth authorization URL
|
|
||||||
type OAuthAuthorizationURLResponse struct {
|
|
||||||
AuthorizationURL string `json:"authorization_url"`
|
|
||||||
State string `json:"state"`
|
|
||||||
Warnings []string `json:"warnings,omitempty"` // Optional warnings about state format or other issues
|
|
||||||
}
|
|
||||||
|
|
||||||
// OAuthCallbackResponse represents the response for OAuth callback
|
|
||||||
type OAuthCallbackResponse struct {
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
RefreshToken string `json:"refresh_token"`
|
|
||||||
ExpiresIn int `json:"expires_in"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// OAuthAuthbackRequest represents the request for OAuth callback
|
|
||||||
type OAuthAuthbackRequest struct {
|
|
||||||
Locale string `json:"locale" form:"locale"`
|
|
||||||
Code string `json:"code" form:"code"`
|
|
||||||
State string `json:"state" form:"state"`
|
|
||||||
Provider string `json:"provider" form:"provider"`
|
|
||||||
Scope string `json:"scope,omitempty" form:"scope,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// OAuthTokenResponse represents the response from OAuth token endpoint
|
|
||||||
type OAuthTokenResponse struct {
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
TokenType string `json:"token_type"`
|
|
||||||
ExpiresIn int `json:"expires_in"`
|
|
||||||
RefreshToken string `json:"refresh_token"`
|
|
||||||
Scope string `json:"scope"`
|
|
||||||
IDToken string `json:"id_token,omitempty"` // JWT token containing user info (Apple, etc.)
|
|
||||||
Error string `json:"error"`
|
|
||||||
ErrorDesc string `json:"error_description"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// OAuthTokenRequest represents the request to OAuth token endpoint
|
|
||||||
type OAuthTokenRequest struct {
|
|
||||||
GrantType string `json:"grant_type" form:"grant_type"`
|
|
||||||
Code string `json:"code" form:"code"`
|
|
||||||
ClientID string `json:"client_id" form:"client_id"`
|
|
||||||
ClientSecret string `json:"client_secret" form:"client_secret"`
|
|
||||||
RedirectURI string `json:"redirect_uri,omitempty" form:"redirect_uri,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// OAuthUserInfoResponse is an alias for OIDC standard user information type
|
|
||||||
type OAuthUserInfoResponse = oauthtypes.OIDCUserInfo
|
|
||||||
|
|
||||||
// OIDCAddress is an alias for OIDC standard address claim type
|
|
||||||
type OIDCAddress = oauthtypes.OIDCAddress
|
|
||||||
|
|
||||||
// LoginResponse represents the response for login
|
|
||||||
type LoginResponse struct {
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
IDToken string `json:"id_token,omitempty"`
|
|
||||||
RefreshToken string `json:"refresh_token,omitempty"`
|
|
||||||
ExpiresIn int `json:"expires_in,omitempty"`
|
|
||||||
RefreshTokenExpiresIn int `json:"refresh_token_expires_in,omitempty"`
|
|
||||||
TokenType string `json:"token_type,omitempty"`
|
|
||||||
Scope string `json:"scope,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Built-in preset mapping types
|
|
||||||
const (
|
|
||||||
MappingGoogle = "google"
|
|
||||||
MappingGitHub = "github"
|
|
||||||
MappingMicrosoft = "microsoft"
|
|
||||||
MappingApple = "apple"
|
|
||||||
MappingWeChat = "wechat"
|
|
||||||
MappingGeneric = "generic"
|
|
||||||
)
|
|
||||||
|
|
||||||
// User info source types
|
|
||||||
const (
|
|
||||||
UserInfoSourceEndpoint = "endpoint" // Default: Get user info from dedicated endpoint
|
|
||||||
UserInfoSourceIDToken = "id_token" // Extract user info from ID token (JWT)
|
|
||||||
UserInfoSourceAccessToken = "access_token" // Extract user info from access token response
|
|
||||||
)
|
|
||||||
|
|
@ -1,510 +0,0 @@
|
||||||
package openapi_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/yaoapp/yao/config"
|
|
||||||
"github.com/yaoapp/yao/openapi"
|
|
||||||
"github.com/yaoapp/yao/openapi/signin"
|
|
||||||
"github.com/yaoapp/yao/openapi/tests/testutils"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSigninLoad(t *testing.T) {
|
|
||||||
// Initialize test environment
|
|
||||||
serverURL := testutils.Prepare(t)
|
|
||||||
defer testutils.Clean()
|
|
||||||
|
|
||||||
_ = serverURL // Server URL not needed for this test
|
|
||||||
|
|
||||||
// Test loading signin configurations
|
|
||||||
err := signin.Load(config.Conf)
|
|
||||||
assert.NoError(t, err, "signin.Load should succeed")
|
|
||||||
|
|
||||||
// Test that we can get available languages
|
|
||||||
languages := signin.GetAvailableLanguages()
|
|
||||||
assert.IsType(t, []string{}, languages, "Should return string slice")
|
|
||||||
t.Logf("Available languages: %v", languages)
|
|
||||||
|
|
||||||
// Test default language
|
|
||||||
defaultLang := signin.GetDefaultLanguage()
|
|
||||||
assert.IsType(t, "", defaultLang, "Should return string")
|
|
||||||
t.Logf("Default language: %s", defaultLang)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSigninGetConfigs(t *testing.T) {
|
|
||||||
// Initialize test environment
|
|
||||||
serverURL := testutils.Prepare(t)
|
|
||||||
defer testutils.Clean()
|
|
||||||
|
|
||||||
_ = serverURL // Server URL not needed for this test
|
|
||||||
|
|
||||||
// Load signin configurations
|
|
||||||
err := signin.Load(config.Conf)
|
|
||||||
assert.NoError(t, err, "signin.Load should succeed")
|
|
||||||
|
|
||||||
// Test getting configs for different languages
|
|
||||||
testCases := []string{"", "en", "zh-cn", "fr"}
|
|
||||||
|
|
||||||
for _, lang := range testCases {
|
|
||||||
t.Run("lang_"+lang, func(t *testing.T) {
|
|
||||||
fullConfig := signin.GetFullConfig(lang)
|
|
||||||
publicConfig := signin.GetPublicConfig(lang)
|
|
||||||
|
|
||||||
if fullConfig != nil {
|
|
||||||
t.Logf("Full config for '%s': %+v", lang, fullConfig.Title)
|
|
||||||
assert.NotNil(t, publicConfig, "Public config should exist if full config exists")
|
|
||||||
|
|
||||||
// Test that public config removes sensitive data from OAuth providers
|
|
||||||
if fullConfig.ThirdParty != nil && fullConfig.ThirdParty.Providers != nil {
|
|
||||||
for i := range fullConfig.ThirdParty.Providers {
|
|
||||||
if publicConfig.ThirdParty != nil && i < len(publicConfig.ThirdParty.Providers) {
|
|
||||||
publicProvider := publicConfig.ThirdParty.Providers[i]
|
|
||||||
|
|
||||||
// In the new structure, providers in ThirdParty only contain display info
|
|
||||||
// Sensitive data is now stored separately in the global providers map
|
|
||||||
|
|
||||||
// Check that only display fields are present in public config
|
|
||||||
assert.NotEmpty(t, publicProvider.ID, "Provider ID should be preserved in public config")
|
|
||||||
assert.NotEmpty(t, publicProvider.Title, "Provider title should be preserved in public config")
|
|
||||||
|
|
||||||
// Sensitive fields should be empty in the ThirdParty providers (they're in global map now)
|
|
||||||
assert.Empty(t, publicProvider.ClientID, "Client ID should be empty in ThirdParty providers")
|
|
||||||
assert.Empty(t, publicProvider.ClientSecret, "Client secret should be empty in ThirdParty providers")
|
|
||||||
assert.Nil(t, publicProvider.ClientSecretGenerator, "Client secret generator should be nil in ThirdParty providers")
|
|
||||||
assert.Empty(t, publicProvider.Scopes, "Scopes should be empty in ThirdParty providers")
|
|
||||||
assert.Nil(t, publicProvider.Endpoints, "Endpoints should be nil in ThirdParty providers")
|
|
||||||
assert.Empty(t, publicProvider.Mapping, "Mapping should be empty in ThirdParty providers")
|
|
||||||
assert.Nil(t, publicProvider.Register, "Register config should be nil in public config (sensitive data)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that public config removes sensitive data from captcha configuration
|
|
||||||
if fullConfig.Form != nil && fullConfig.Form.Captcha != nil && fullConfig.Form.Captcha.Options != nil {
|
|
||||||
if publicConfig.Form != nil && publicConfig.Form.Captcha != nil && publicConfig.Form.Captcha.Options != nil {
|
|
||||||
// Check that secret field is removed
|
|
||||||
_, hasSecret := publicConfig.Form.Captcha.Options["secret"]
|
|
||||||
assert.False(t, hasSecret, "Captcha secret should be removed from public config")
|
|
||||||
|
|
||||||
// Check that safe fields are preserved (if they exist in full config)
|
|
||||||
if _, hasSitekey := fullConfig.Form.Captcha.Options["sitekey"]; hasSitekey {
|
|
||||||
_, publicHasSitekey := publicConfig.Form.Captcha.Options["sitekey"]
|
|
||||||
assert.True(t, publicHasSitekey, "Captcha sitekey should be preserved in public config")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
t.Logf("No config found for language: %s", lang)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSigninLanguageNormalization(t *testing.T) {
|
|
||||||
// Initialize test environment
|
|
||||||
serverURL := testutils.Prepare(t)
|
|
||||||
defer testutils.Clean()
|
|
||||||
|
|
||||||
_ = serverURL // Server URL not needed for this test
|
|
||||||
|
|
||||||
// Load signin configurations
|
|
||||||
err := signin.Load(config.Conf)
|
|
||||||
assert.NoError(t, err, "signin.Load should succeed")
|
|
||||||
|
|
||||||
// Test that language codes are normalized to lowercase
|
|
||||||
config1 := signin.GetFullConfig("EN")
|
|
||||||
config2 := signin.GetFullConfig("en")
|
|
||||||
assert.Equal(t, config1, config2, "Language codes should be normalized to lowercase")
|
|
||||||
|
|
||||||
config3 := signin.GetPublicConfig("ZH-CN")
|
|
||||||
config4 := signin.GetPublicConfig("zh-cn")
|
|
||||||
assert.Equal(t, config3, config4, "Language codes should be normalized to lowercase")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSigninConfigStructure(t *testing.T) {
|
|
||||||
// Initialize test environment
|
|
||||||
serverURL := testutils.Prepare(t)
|
|
||||||
defer testutils.Clean()
|
|
||||||
|
|
||||||
_ = serverURL // Server URL not needed for this test
|
|
||||||
|
|
||||||
// Load signin configurations
|
|
||||||
err := signin.Load(config.Conf)
|
|
||||||
assert.NoError(t, err, "signin.Load should succeed")
|
|
||||||
|
|
||||||
// Get a config to test structure
|
|
||||||
config := signin.GetFullConfig("")
|
|
||||||
if config != nil {
|
|
||||||
t.Logf("Config loaded successfully with title: %s", config.Title)
|
|
||||||
|
|
||||||
// Verify config structure is valid
|
|
||||||
assert.IsType(t, &signin.Config{}, config, "Should return correct config type")
|
|
||||||
|
|
||||||
// Test new configuration fields
|
|
||||||
assert.IsType(t, "", config.ClientID, "ClientID should be string")
|
|
||||||
assert.IsType(t, "", config.ClientSecret, "ClientSecret should be string")
|
|
||||||
assert.IsType(t, false, config.Default, "Default should be boolean")
|
|
||||||
t.Logf("Config has ClientID: %t, ClientSecret: %t, Default: %t",
|
|
||||||
config.ClientID != "", config.ClientSecret != "", config.Default)
|
|
||||||
|
|
||||||
// Test form configuration
|
|
||||||
if config.Form != nil {
|
|
||||||
t.Logf("Form configuration found")
|
|
||||||
if config.Form.Username != nil {
|
|
||||||
assert.IsType(t, []string{}, config.Form.Username.Fields, "Username fields should be string slice")
|
|
||||||
}
|
|
||||||
if config.Form.Captcha != nil {
|
|
||||||
assert.IsType(t, map[string]interface{}{}, config.Form.Captcha.Options, "Captcha options should be map")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test third party configuration
|
|
||||||
if config.ThirdParty != nil {
|
|
||||||
t.Logf("Third party configuration found with %d providers", len(config.ThirdParty.Providers))
|
|
||||||
if config.ThirdParty.Providers != nil {
|
|
||||||
assert.IsType(t, []*signin.Provider{}, config.ThirdParty.Providers, "Providers should be slice of Provider pointers")
|
|
||||||
for i, provider := range config.ThirdParty.Providers {
|
|
||||||
t.Logf("Provider %d: %s", i, provider.ID)
|
|
||||||
|
|
||||||
// In the new structure, ThirdParty providers only contain display information
|
|
||||||
// Sensitive configuration data is stored separately in the global providers map
|
|
||||||
assert.NotEmpty(t, provider.ID, "Provider ID should not be empty")
|
|
||||||
assert.NotEmpty(t, provider.Title, "Provider title should not be empty")
|
|
||||||
|
|
||||||
// These fields should be empty in ThirdParty providers (they're in global map now)
|
|
||||||
assert.Empty(t, provider.Scopes, "Provider scopes should be empty in ThirdParty providers")
|
|
||||||
assert.Nil(t, provider.Mapping, "Provider mapping should be nil in ThirdParty providers")
|
|
||||||
assert.Nil(t, provider.Endpoints, "Provider endpoints should be nil in ThirdParty providers")
|
|
||||||
assert.Nil(t, provider.Register, "Provider register should be nil in ThirdParty providers (it's in global map now)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
t.Log("No signin configuration found")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSigninGlobalProvidersMap(t *testing.T) {
|
|
||||||
// Initialize test environment
|
|
||||||
serverURL := testutils.Prepare(t)
|
|
||||||
defer testutils.Clean()
|
|
||||||
|
|
||||||
_ = serverURL // Server URL not needed for this test
|
|
||||||
|
|
||||||
// Load signin configurations
|
|
||||||
err := signin.Load(config.Conf)
|
|
||||||
assert.NoError(t, err, "signin.Load should succeed")
|
|
||||||
|
|
||||||
// Test that providers can be retrieved from global map (no locale needed)
|
|
||||||
providerIDs := []string{"google", "microsoft", "apple", "github"}
|
|
||||||
|
|
||||||
for _, providerID := range providerIDs {
|
|
||||||
t.Run("provider_"+providerID, func(t *testing.T) {
|
|
||||||
provider, err := signin.GetProvider(providerID)
|
|
||||||
|
|
||||||
// Note: Provider might not be found if configuration files don't exist
|
|
||||||
// or if environment variables are not set, which is normal in test environment
|
|
||||||
if err != nil {
|
|
||||||
t.Logf("Provider '%s' not found (expected in test environment): %v", providerID, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if provider != nil {
|
|
||||||
assert.Equal(t, providerID, provider.ID, "Provider ID should match")
|
|
||||||
t.Logf("Provider '%s' loaded successfully", providerID)
|
|
||||||
|
|
||||||
// Test provider structure
|
|
||||||
assert.IsType(t, "", provider.ClientID, "ClientID should be string")
|
|
||||||
assert.IsType(t, "", provider.ClientSecret, "ClientSecret should be string")
|
|
||||||
assert.IsType(t, []string{}, provider.Scopes, "Scopes should be string slice")
|
|
||||||
|
|
||||||
if provider.Endpoints != nil {
|
|
||||||
assert.IsType(t, "", provider.Endpoints.Authorization, "Authorization endpoint should be string")
|
|
||||||
assert.IsType(t, "", provider.Endpoints.Token, "Token endpoint should be string")
|
|
||||||
assert.IsType(t, "", provider.Endpoints.UserInfo, "UserInfo endpoint should be string")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test register configuration (should be present in global providers)
|
|
||||||
if provider.Register != nil {
|
|
||||||
assert.IsType(t, false, provider.Register.Auto, "Register auto should be boolean")
|
|
||||||
assert.IsType(t, "", provider.Register.Role, "Register role should be string")
|
|
||||||
t.Logf("Provider '%s' has register config: auto=%t, role=%s",
|
|
||||||
providerID, provider.Register.Auto, provider.Register.Role)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test nonexistent provider
|
|
||||||
t.Run("nonexistent_provider", func(t *testing.T) {
|
|
||||||
provider, err := signin.GetProvider("nonexistent")
|
|
||||||
assert.Error(t, err, "Should return error for nonexistent provider")
|
|
||||||
assert.Nil(t, provider, "Should return nil provider for nonexistent provider")
|
|
||||||
assert.Contains(t, err.Error(), "not found", "Error message should indicate provider not found")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSigninAPI(t *testing.T) {
|
|
||||||
// Initialize test environment
|
|
||||||
serverURL := testutils.Prepare(t)
|
|
||||||
defer testutils.Clean()
|
|
||||||
|
|
||||||
// Get base URL from server config
|
|
||||||
baseURL := ""
|
|
||||||
if openapi.Server != nil && openapi.Server.Config != nil {
|
|
||||||
baseURL = openapi.Server.Config.BaseURL
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test API endpoints
|
|
||||||
testCases := []struct {
|
|
||||||
name string
|
|
||||||
endpoint string
|
|
||||||
expectCode int
|
|
||||||
}{
|
|
||||||
{"get config without locale", "/signin", 200},
|
|
||||||
{"get config with en locale", "/signin?locale=en", 200},
|
|
||||||
{"get config with zh-cn locale", "/signin?locale=zh-cn", 200},
|
|
||||||
{"get config with invalid locale", "/signin?locale=invalid", 200}, // should fallback to default
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range testCases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
url := serverURL + baseURL + tc.endpoint
|
|
||||||
resp, err := http.Get(url)
|
|
||||||
assert.NoError(t, err, "HTTP request should succeed")
|
|
||||||
|
|
||||||
if resp != nil {
|
|
||||||
defer resp.Body.Close()
|
|
||||||
assert.Equal(t, tc.expectCode, resp.StatusCode, "Expected status code %d", tc.expectCode)
|
|
||||||
|
|
||||||
if resp.StatusCode == 200 {
|
|
||||||
// Parse response body
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
assert.NoError(t, err, "Should read response body")
|
|
||||||
|
|
||||||
var config signin.Config
|
|
||||||
err = json.Unmarshal(body, &config)
|
|
||||||
assert.NoError(t, err, "Should parse JSON response")
|
|
||||||
|
|
||||||
t.Logf("API response for %s: %s", tc.endpoint, config.Title)
|
|
||||||
|
|
||||||
// Verify it's public config (no sensitive data)
|
|
||||||
if config.ThirdParty != nil && config.ThirdParty.Providers != nil {
|
|
||||||
for _, provider := range config.ThirdParty.Providers {
|
|
||||||
// Check that sensitive OAuth fields are removed from API response
|
|
||||||
assert.Empty(t, provider.ClientID, "Client ID should be empty in API response")
|
|
||||||
assert.Empty(t, provider.ClientSecret, "Client secret should be empty in API response")
|
|
||||||
assert.Nil(t, provider.ClientSecretGenerator, "Client secret generator should be nil in API response")
|
|
||||||
assert.Empty(t, provider.Scopes, "Scopes should be empty in API response")
|
|
||||||
assert.Nil(t, provider.Endpoints, "Endpoints should be nil in API response")
|
|
||||||
assert.Empty(t, provider.Mapping, "Mapping should be empty in API response")
|
|
||||||
|
|
||||||
// Check that display fields are preserved in API response
|
|
||||||
assert.NotEmpty(t, provider.ID, "Provider ID should be preserved in API response")
|
|
||||||
assert.NotEmpty(t, provider.Title, "Provider title should be preserved in API response")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify captcha sensitive data is removed from API response
|
|
||||||
if config.Form != nil && config.Form.Captcha != nil && config.Form.Captcha.Options != nil {
|
|
||||||
_, hasSecret := config.Form.Captcha.Options["secret"]
|
|
||||||
assert.False(t, hasSecret, "Captcha secret should be removed from API response")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSigninOAuthAuthorizationURL(t *testing.T) {
|
|
||||||
// Initialize test environment
|
|
||||||
serverURL := testutils.Prepare(t)
|
|
||||||
defer testutils.Clean()
|
|
||||||
|
|
||||||
// Get base URL from server config
|
|
||||||
baseURL := ""
|
|
||||||
if openapi.Server != nil && openapi.Server.Config != nil {
|
|
||||||
baseURL = openapi.Server.Config.BaseURL
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test OAuth authorization URL endpoints
|
|
||||||
// Note: These should return 200 when OAuth client credentials are properly configured
|
|
||||||
// (which they are in this test environment). Only nonexistent providers should return 404.
|
|
||||||
testCases := []struct {
|
|
||||||
name string
|
|
||||||
provider string
|
|
||||||
query string
|
|
||||||
expectCode int
|
|
||||||
expectErrorMsg string
|
|
||||||
}{
|
|
||||||
{"get google oauth url", "google", "", 200, ""},
|
|
||||||
{"get microsoft oauth url", "microsoft", "", 200, ""},
|
|
||||||
{"get apple oauth url", "apple", "", 200, ""},
|
|
||||||
{"get github oauth url", "github", "", 200, ""},
|
|
||||||
{"get oauth url with redirect_uri", "google", "?redirect_uri=https://example.com/callback", 200, ""},
|
|
||||||
{"get oauth url with state", "google", "?state=test-state-123", 200, ""},
|
|
||||||
{"get oauth url for nonexistent provider", "nonexistent", "", 404, "Failed to get provider"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range testCases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
url := serverURL + baseURL + "/signin/oauth/" + tc.provider + "/authorize" + tc.query
|
|
||||||
resp, err := http.Get(url)
|
|
||||||
assert.NoError(t, err, "HTTP request should succeed")
|
|
||||||
|
|
||||||
if resp != nil {
|
|
||||||
defer resp.Body.Close()
|
|
||||||
assert.Equal(t, tc.expectCode, resp.StatusCode, "Expected status code %d", tc.expectCode)
|
|
||||||
|
|
||||||
// Parse response body
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
assert.NoError(t, err, "Should read response body")
|
|
||||||
|
|
||||||
t.Logf("Response for %s: status=%d, body=%s", tc.provider, resp.StatusCode, string(body))
|
|
||||||
|
|
||||||
var response map[string]interface{}
|
|
||||||
err = json.Unmarshal(body, &response)
|
|
||||||
assert.NoError(t, err, "Should parse JSON response")
|
|
||||||
|
|
||||||
if tc.expectCode == 200 {
|
|
||||||
// Success case - should have authorization_url
|
|
||||||
if authURL, hasAuthURL := response["authorization_url"]; hasAuthURL {
|
|
||||||
authURLStr, ok := authURL.(string)
|
|
||||||
assert.True(t, ok, "authorization_url should be string")
|
|
||||||
assert.NotEmpty(t, authURLStr, "authorization_url should not be empty")
|
|
||||||
t.Logf("Authorization URL generated successfully for %s", tc.provider)
|
|
||||||
} else {
|
|
||||||
t.Errorf("Success response should contain authorization_url field")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Error case - should have error fields
|
|
||||||
if errorDescription, hasError := response["error_description"]; hasError {
|
|
||||||
errorDescStr, ok := errorDescription.(string)
|
|
||||||
assert.True(t, ok, "error_description should be string")
|
|
||||||
assert.Equal(t, tc.expectErrorMsg, errorDescStr, "Error message should match expected")
|
|
||||||
} else {
|
|
||||||
t.Errorf("Error response should contain error_description field")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify error code is present
|
|
||||||
if errorCode, hasErrorCode := response["error"]; hasErrorCode {
|
|
||||||
assert.Equal(t, "invalid_request", errorCode, "Error code should be invalid_request")
|
|
||||||
} else {
|
|
||||||
t.Errorf("Error response should contain error field")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSigninENVVariableReplacement(t *testing.T) {
|
|
||||||
// Initialize test environment
|
|
||||||
serverURL := testutils.Prepare(t)
|
|
||||||
defer testutils.Clean()
|
|
||||||
|
|
||||||
_ = serverURL // Server URL not needed for this test
|
|
||||||
|
|
||||||
// Load signin configurations to trigger ENV variable processing
|
|
||||||
err := signin.Load(config.Conf)
|
|
||||||
assert.NoError(t, err, "signin.Load should succeed")
|
|
||||||
|
|
||||||
// Get full config to check ENV variable replacement
|
|
||||||
fullConfig := signin.GetFullConfig("")
|
|
||||||
assert.NotNil(t, fullConfig, "Should have a signin configuration")
|
|
||||||
|
|
||||||
// Test captcha ENV variable replacement
|
|
||||||
if fullConfig.Form != nil && fullConfig.Form.Captcha != nil && fullConfig.Form.Captcha.Options != nil {
|
|
||||||
if sitekey, hasSitekey := fullConfig.Form.Captcha.Options["sitekey"]; hasSitekey {
|
|
||||||
sitekeyStr, ok := sitekey.(string)
|
|
||||||
assert.True(t, ok, "Sitekey should be string")
|
|
||||||
// Should not contain ENV placeholder (either replaced or empty)
|
|
||||||
assert.NotContains(t, sitekeyStr, "$ENV.", "Sitekey should not contain ENV placeholder")
|
|
||||||
t.Logf("Captcha sitekey after ENV replacement: %s", sitekeyStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if secret, hasSecret := fullConfig.Form.Captcha.Options["secret"]; hasSecret {
|
|
||||||
secretStr, ok := secret.(string)
|
|
||||||
assert.True(t, ok, "Secret should be string")
|
|
||||||
// Should not contain ENV placeholder (either replaced or empty)
|
|
||||||
assert.NotContains(t, secretStr, "$ENV.", "Secret should not contain ENV placeholder")
|
|
||||||
t.Logf("Captcha secret after ENV replacement: %s", secretStr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test OAuth provider ENV variable replacement
|
|
||||||
if fullConfig.ThirdParty != nil && fullConfig.ThirdParty.Providers != nil {
|
|
||||||
for _, provider := range fullConfig.ThirdParty.Providers {
|
|
||||||
// Check ClientID replacement
|
|
||||||
if provider.ClientID != "" {
|
|
||||||
assert.NotContains(t, provider.ClientID, "$ENV.", "ClientID should not contain ENV placeholder")
|
|
||||||
t.Logf("Provider %s ClientID after ENV replacement: %s", provider.ID, provider.ClientID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check ClientSecret replacement
|
|
||||||
if provider.ClientSecret != "" {
|
|
||||||
assert.NotContains(t, provider.ClientSecret, "$ENV.", "ClientSecret should not contain ENV placeholder")
|
|
||||||
t.Logf("Provider %s ClientSecret after ENV replacement: [REDACTED]", provider.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that public config doesn't expose ENV variables or actual sensitive values
|
|
||||||
publicConfig := signin.GetPublicConfig("")
|
|
||||||
assert.NotNil(t, publicConfig, "Should have a public signin configuration")
|
|
||||||
|
|
||||||
// Public config should not contain sensitive data even if ENV variables are set
|
|
||||||
if publicConfig.Form != nil && publicConfig.Form.Captcha != nil && publicConfig.Form.Captcha.Options != nil {
|
|
||||||
_, hasSecret := publicConfig.Form.Captcha.Options["secret"]
|
|
||||||
assert.False(t, hasSecret, "Public config should not contain captcha secret")
|
|
||||||
}
|
|
||||||
|
|
||||||
if publicConfig.ThirdParty != nil && publicConfig.ThirdParty.Providers != nil {
|
|
||||||
for _, provider := range publicConfig.ThirdParty.Providers {
|
|
||||||
assert.Empty(t, provider.ClientID, "Public config should not contain ClientID")
|
|
||||||
assert.Empty(t, provider.ClientSecret, "Public config should not contain ClientSecret")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSigninENVVariableMissingHandling(t *testing.T) {
|
|
||||||
// This test verifies that missing ENV variables are handled securely
|
|
||||||
// by returning empty strings instead of exposing the placeholder
|
|
||||||
|
|
||||||
// Initialize test environment
|
|
||||||
serverURL := testutils.Prepare(t)
|
|
||||||
defer testutils.Clean()
|
|
||||||
|
|
||||||
_ = serverURL // Server URL not needed for this test
|
|
||||||
|
|
||||||
// Load signin configurations
|
|
||||||
err := signin.Load(config.Conf)
|
|
||||||
assert.NoError(t, err, "signin.Load should succeed")
|
|
||||||
|
|
||||||
// Get public config (this is what the API returns)
|
|
||||||
publicConfig := signin.GetPublicConfig("")
|
|
||||||
assert.NotNil(t, publicConfig, "Should have a public signin configuration")
|
|
||||||
|
|
||||||
// Verify that even if ENV variables are missing, no placeholders are exposed
|
|
||||||
if publicConfig.Form != nil && publicConfig.Form.Captcha != nil && publicConfig.Form.Captcha.Options != nil {
|
|
||||||
for key, value := range publicConfig.Form.Captcha.Options {
|
|
||||||
if valueStr, ok := value.(string); ok {
|
|
||||||
assert.NotContains(t, valueStr, "$ENV.", "Public config should not contain ENV placeholders in %s", key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if publicConfig.ThirdParty != nil && publicConfig.ThirdParty.Providers != nil {
|
|
||||||
for _, provider := range publicConfig.ThirdParty.Providers {
|
|
||||||
// These should be empty in public config anyway, but verify no ENV placeholders
|
|
||||||
assert.NotContains(t, provider.ClientID, "$ENV.", "Public config ClientID should not contain ENV placeholders")
|
|
||||||
assert.NotContains(t, provider.ClientSecret, "$ENV.", "Public config ClientSecret should not contain ENV placeholders")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Log("ENV variable security test passed: no ENV placeholders exposed in public configuration")
|
|
||||||
}
|
|
||||||
|
|
@ -216,9 +216,9 @@ func loadProviders(_ string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadSigninConfigs loads all signin configurations from the openapi/user directory
|
// loadSigninConfigs loads all signin configurations from the openapi/signin directory
|
||||||
func loadSigninConfigs(_ string) error {
|
func loadSigninConfigs(_ string) error {
|
||||||
// Use Walk to find all configuration files in the signin directory
|
// Use Walk to find all configuration files in the user directory
|
||||||
err := application.App.Walk("openapi/user", func(root, filename string, isdir bool) error {
|
err := application.App.Walk("openapi/user", func(root, filename string, isdir bool) error {
|
||||||
if isdir {
|
if isdir {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -236,7 +236,7 @@ func loadSigninConfigs(_ string) error {
|
||||||
|
|
||||||
// Extract locale from filename (basename without extension)
|
// Extract locale from filename (basename without extension)
|
||||||
baseName := filepath.Base(filename)
|
baseName := filepath.Base(filename)
|
||||||
locale := strings.TrimSuffix(baseName, ".yao")
|
locale := strings.ToLower(strings.TrimSuffix(baseName, ".yao"))
|
||||||
|
|
||||||
// Read configuration
|
// Read configuration
|
||||||
configRaw, err := application.App.Read(filename)
|
configRaw, err := application.App.Read(filename)
|
||||||
|
|
@ -252,8 +252,7 @@ func loadSigninConfigs(_ string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process ENV variables in the configuration
|
// Process ENV variables in the configuration
|
||||||
config.ClientID = replaceENVVar(config.ClientID)
|
processConfigENVVariables(&config)
|
||||||
config.ClientSecret = replaceENVVar(config.ClientSecret)
|
|
||||||
|
|
||||||
// Store full configuration
|
// Store full configuration
|
||||||
fullConfigs[locale] = &config
|
fullConfigs[locale] = &config
|
||||||
|
|
@ -296,30 +295,26 @@ func GetPublicConfig(locale string) *Config {
|
||||||
configMutex.RLock()
|
configMutex.RLock()
|
||||||
defer configMutex.RUnlock()
|
defer configMutex.RUnlock()
|
||||||
|
|
||||||
|
// Normalize language code to lowercase
|
||||||
|
if locale != "" {
|
||||||
|
locale = strings.ToLower(locale)
|
||||||
|
}
|
||||||
|
|
||||||
// Try to get the specific locale configuration
|
// Try to get the specific locale configuration
|
||||||
if config, exists := publicConfigs[locale]; exists {
|
if config, exists := publicConfigs[locale]; exists {
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to default configuration
|
// Fallback to default config's public version
|
||||||
if defaultConfig != nil {
|
if defaultConfig != nil {
|
||||||
// Create a copy of default config for public use
|
// Find the public version of the default config
|
||||||
publicDefault := *defaultConfig
|
for lang, fullConfig := range fullConfigs {
|
||||||
publicDefault.ClientSecret = "" // Remove sensitive data
|
if fullConfig == defaultConfig {
|
||||||
|
if publicConfig, exists := publicConfigs[lang]; exists {
|
||||||
// Remove captcha secret from public config
|
return publicConfig
|
||||||
if publicDefault.Form != nil && publicDefault.Form.Captcha != nil && publicDefault.Form.Captcha.Options != nil {
|
|
||||||
// Create a copy of captcha options without the secret
|
|
||||||
captchaOptions := make(map[string]interface{})
|
|
||||||
for k, v := range publicDefault.Form.Captcha.Options {
|
|
||||||
if k != "secret" {
|
|
||||||
captchaOptions[k] = v
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
publicDefault.Form.Captcha.Options = captchaOptions
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &publicDefault
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no default, try to get any available configuration
|
// If no default, try to get any available configuration
|
||||||
|
|
@ -417,3 +412,46 @@ func normalizeDuration(expiresIn string) (string, error) {
|
||||||
|
|
||||||
return normalized, nil
|
return normalized, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// processConfigENVVariables processes environment variables in the signin configuration
|
||||||
|
func processConfigENVVariables(config *Config) {
|
||||||
|
var missingEnvVars []string
|
||||||
|
|
||||||
|
// Process client_id and client_secret
|
||||||
|
if strings.HasPrefix(config.ClientID, "$ENV.") {
|
||||||
|
envVar := strings.TrimPrefix(config.ClientID, "$ENV.")
|
||||||
|
if _, exists := os.LookupEnv(envVar); !exists {
|
||||||
|
missingEnvVars = append(missingEnvVars, envVar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config.ClientID = replaceENVVar(config.ClientID)
|
||||||
|
|
||||||
|
if strings.HasPrefix(config.ClientSecret, "$ENV.") {
|
||||||
|
envVar := strings.TrimPrefix(config.ClientSecret, "$ENV.")
|
||||||
|
if _, exists := os.LookupEnv(envVar); !exists {
|
||||||
|
missingEnvVars = append(missingEnvVars, envVar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config.ClientSecret = replaceENVVar(config.ClientSecret)
|
||||||
|
|
||||||
|
// Process form captcha options
|
||||||
|
if config.Form != nil && config.Form.Captcha != nil && config.Form.Captcha.Options != nil {
|
||||||
|
for key, value := range config.Form.Captcha.Options {
|
||||||
|
if strValue, ok := value.(string); ok {
|
||||||
|
// Check if ENV variable exists before replacement
|
||||||
|
if strings.HasPrefix(strValue, "$ENV.") {
|
||||||
|
envVar := strings.TrimPrefix(strValue, "$ENV.")
|
||||||
|
if _, exists := os.LookupEnv(envVar); !exists {
|
||||||
|
missingEnvVars = append(missingEnvVars, envVar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config.Form.Captcha.Options[key] = replaceENVVar(strValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log warning for missing environment variables (optional, can be removed if log package not available)
|
||||||
|
if len(missingEnvVars) > 0 {
|
||||||
|
fmt.Printf("Warning: The following environment variables are not set in user configuration: %v\n", missingEnvVars)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,14 @@ package user
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/yaoapp/gou/session"
|
"github.com/yaoapp/gou/session"
|
||||||
"github.com/yaoapp/kun/log"
|
"github.com/yaoapp/kun/log"
|
||||||
|
"github.com/yaoapp/yao/helper"
|
||||||
"github.com/yaoapp/yao/openapi/oauth"
|
"github.com/yaoapp/yao/openapi/oauth"
|
||||||
"github.com/yaoapp/yao/openapi/oauth/providers/user"
|
"github.com/yaoapp/yao/openapi/oauth/providers/user"
|
||||||
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
|
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
|
|
@ -51,6 +53,56 @@ func login(c *gin.Context) {
|
||||||
// You may need to implement the actual login logic here
|
// You may need to implement the actual login logic here
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getCaptcha is the handler for get captcha image for login
|
||||||
|
func getCaptcha(c *gin.Context) {
|
||||||
|
var option helper.CaptchaOption = helper.NewCaptchaOption()
|
||||||
|
|
||||||
|
err := c.ShouldBindQuery(&option)
|
||||||
|
if err != nil {
|
||||||
|
response.RespondWithError(c, http.StatusBadRequest, &response.ErrorResponse{
|
||||||
|
Code: response.ErrInvalidRequest.Code,
|
||||||
|
ErrorDescription: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the type to image
|
||||||
|
option.Type = "image"
|
||||||
|
id, content := helper.CaptchaMake(option)
|
||||||
|
|
||||||
|
// Return in the format expected by the frontend
|
||||||
|
response.RespondWithSuccess(c, http.StatusOK, gin.H{
|
||||||
|
"captcha_id": id,
|
||||||
|
"captcha_image": content,
|
||||||
|
"expires_in": 300, // 5 minutes
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshCaptcha is the handler for refresh captcha image
|
||||||
|
func refreshCaptcha(c *gin.Context) {
|
||||||
|
var option helper.CaptchaOption = helper.NewCaptchaOption()
|
||||||
|
|
||||||
|
err := c.ShouldBindQuery(&option)
|
||||||
|
if err != nil {
|
||||||
|
response.RespondWithError(c, http.StatusBadRequest, &response.ErrorResponse{
|
||||||
|
Code: response.ErrInvalidRequest.Code,
|
||||||
|
ErrorDescription: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the type to image
|
||||||
|
option.Type = "image"
|
||||||
|
id, content := helper.CaptchaMake(option)
|
||||||
|
|
||||||
|
// Return in the format expected by the frontend
|
||||||
|
response.RespondWithSuccess(c, http.StatusOK, gin.H{
|
||||||
|
"captcha_id": id,
|
||||||
|
"captcha_image": content,
|
||||||
|
"expires_in": 300, // 5 minutes
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// LoginThirdParty is the handler for third party login
|
// 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, ip string) (*LoginResponse, error) {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) {
|
||||||
// User Authentication (migrated from /signin)
|
// User Authentication (migrated from /signin)
|
||||||
group.GET("/login", getLoginConfig) // Get login page config (public) - migrated from /signin
|
group.GET("/login", getLoginConfig) // Get login page config (public) - migrated from /signin
|
||||||
group.POST("/login", login) // User login (public) - migrated from /signin
|
group.POST("/login", login) // User login (public) - migrated from /signin
|
||||||
|
group.GET("/login/captcha", getCaptcha) // Get captcha for login (public)
|
||||||
group.POST("/register", placeholder) // User register (public)
|
group.POST("/register", placeholder) // User register (public)
|
||||||
group.POST("/logout", oauth.Guard, placeholder) // User logout
|
group.POST("/logout", oauth.Guard, placeholder) // User logout
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue