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:
Max 2025-09-22 11:04:00 +08:00
parent 6f3a57cb33
commit 7977b7f48e
10 changed files with 111 additions and 3409 deletions

View file

@ -15,7 +15,6 @@ import (
"github.com/yaoapp/yao/openapi/kb"
"github.com/yaoapp/yao/openapi/oauth"
"github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/openapi/signin"
"github.com/yaoapp/yao/openapi/team"
"github.com/yaoapp/yao/openapi/user"
)
@ -57,12 +56,6 @@ func Load(appConfig config.Config) (*OpenAPI, error) {
return nil, err
}
// Load signin configurations
err = signin.Load(appConfig)
if err != nil {
return nil, err
}
// Load user configurations
err = user.Load(appConfig)
if err != nil {
@ -110,9 +103,6 @@ func (openapi *OpenAPI) Attach(router *gin.Engine) {
// Chat handlers
chat.Attach(group.Group("/chat"), openapi.OAuth)
// Signin handlers
signin.Attach(group, openapi.OAuth)
// Captcha handlers
captcha.Attach(group.Group("/captcha"), openapi.OAuth)

View file

@ -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(&params); 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)
}

View file

@ -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

View file

@ -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
}

View file

@ -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
)

View file

@ -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")
}

View file

@ -216,9 +216,9 @@ func loadProviders(_ string) error {
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 {
// 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 {
if isdir {
return nil
@ -236,7 +236,7 @@ func loadSigninConfigs(_ string) error {
// Extract locale from filename (basename without extension)
baseName := filepath.Base(filename)
locale := strings.TrimSuffix(baseName, ".yao")
locale := strings.ToLower(strings.TrimSuffix(baseName, ".yao"))
// Read configuration
configRaw, err := application.App.Read(filename)
@ -252,8 +252,7 @@ func loadSigninConfigs(_ string) error {
}
// Process ENV variables in the configuration
config.ClientID = replaceENVVar(config.ClientID)
config.ClientSecret = replaceENVVar(config.ClientSecret)
processConfigENVVariables(&config)
// Store full configuration
fullConfigs[locale] = &config
@ -296,30 +295,26 @@ func GetPublicConfig(locale string) *Config {
configMutex.RLock()
defer configMutex.RUnlock()
// Normalize language code to lowercase
if locale != "" {
locale = strings.ToLower(locale)
}
// Try to get the specific locale configuration
if config, exists := publicConfigs[locale]; exists {
return config
}
// Fallback to default configuration
// Fallback to default config's public version
if defaultConfig != nil {
// Create a copy of default config for public use
publicDefault := *defaultConfig
publicDefault.ClientSecret = "" // Remove sensitive data
// Remove captcha secret from public config
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
// Find the public version of the default config
for lang, fullConfig := range fullConfigs {
if fullConfig == defaultConfig {
if publicConfig, exists := publicConfigs[lang]; exists {
return publicConfig
}
}
publicDefault.Form.Captcha.Options = captchaOptions
}
return &publicDefault
}
// If no default, try to get any available configuration
@ -417,3 +412,46 @@ func normalizeDuration(expiresIn string) (string, error) {
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)
}
}

View file

@ -3,12 +3,14 @@ package user
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/yaoapp/gou/session"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/helper"
"github.com/yaoapp/yao/openapi/oauth"
"github.com/yaoapp/yao/openapi/oauth/providers/user"
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
}
// 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
func LoginThirdParty(providerID string, userinfo *oauthtypes.OIDCUserInfo, ip string) (*LoginResponse, error) {

View file

@ -13,6 +13,7 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) {
// User Authentication (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.GET("/login/captcha", getCaptcha) // Get captcha for login (public)
group.POST("/register", placeholder) // User register (public)
group.POST("/logout", oauth.Guard, placeholder) // User logout