diff --git a/account/api/api.go b/account/api/api.go index 4dee38d..f2c71bf 100644 --- a/account/api/api.go +++ b/account/api/api.go @@ -61,6 +61,7 @@ type handler struct { resetMu sync.RWMutex metricsProvider service.UserMetricsProvider agentStatusReader agentStatusReader + tokenService *auth.TokenService } type mfaChallenge struct { @@ -167,6 +168,15 @@ func WithPasswordResetTTL(ttl time.Duration) Option { } } +// WithTokenService configures the handler with the provided token service. +func WithTokenService(tokenService *auth.TokenService) Option { + return func(h *handler) { + if tokenService != nil { + h.tokenService = tokenService + } + } +} + // RegisterRoutes attaches account service endpoints to the router. func RegisterRoutes(r *gin.Engine, opts ...Option) { h := &handler{ @@ -201,23 +211,35 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) { auth.POST("/login", h.login) - auth.GET("/session", h.session) - auth.DELETE("/session", h.deleteSession) + // Token exchange endpoint - converts public token to access/refresh tokens + auth.POST("/token/exchange", h.exchangeToken) - auth.POST("/mfa/totp/provision", h.provisionTOTP) - auth.POST("/mfa/totp/verify", h.verifyTOTP) - auth.POST("/mfa/disable", h.disableMFA) - auth.GET("/mfa/status", h.mfaStatus) + // Token refresh endpoint - generates new access token using refresh token + auth.POST("/token/refresh", h.refreshToken) - auth.POST("/password/reset", h.requestPasswordReset) - auth.POST("/password/reset/confirm", h.confirmPasswordReset) + // Protected routes requiring authentication + authProtected := auth.Group("") + if h.tokenService != nil { + authProtected.Use(h.tokenService.AuthMiddleware()) + } - auth.POST("/config/sync", h.syncConfig) + authProtected.GET("/session", h.session) + authProtected.DELETE("/session", h.deleteSession) - auth.GET("/admin/settings", h.getAdminSettings) - auth.POST("/admin/settings", h.updateAdminSettings) + authProtected.POST("/mfa/totp/provision", h.provisionTOTP) + authProtected.POST("/mfa/totp/verify", h.verifyTOTP) + authProtected.POST("/mfa/disable", h.disableMFA) + authProtected.GET("/mfa/status", h.mfaStatus) - registerAdminRoutes(auth, h) + authProtected.POST("/password/reset", h.requestPasswordReset) + authProtected.POST("/password/reset/confirm", h.confirmPasswordReset) + + authProtected.POST("/config/sync", h.syncConfig) + + authProtected.GET("/admin/settings", h.getAdminSettings) + authProtected.POST("/admin/settings", h.updateAdminSettings) + + registerAdminRoutes(authProtected, h) } type registerRequest struct { @@ -882,6 +904,89 @@ func (h *handler) login(c *gin.Context) { c.JSON(http.StatusOK, response) } +type tokenRefreshRequest struct { + RefreshToken string `json:"refresh_token"` +} + +func (h *handler) refreshToken(c *gin.Context) { + if h.tokenService == nil { + respondError(c, http.StatusServiceUnavailable, "token_service_unavailable", "token service is not configured") + return + } + + var req tokenRefreshRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "invalid_request", "invalid request payload") + return + } + + // Refresh access token + accessToken, err := h.tokenService.RefreshAccessToken(req.RefreshToken) + if err != nil { + respondError(c, http.StatusUnauthorized, "invalid_refresh_token", "invalid or expired refresh token") + return + } + + c.JSON(http.StatusOK, gin.H{ + "access_token": accessToken, + "token_type": "Bearer", + "expires_in": int64(h.tokenService.GetAccessTokenExpiry().Seconds()), + }) +} + +type tokenExchangeRequest struct { + PublicToken string `json:"public_token"` + UserID string `json:"user_id"` + Email string `json:"email"` + Roles string `json:"roles"` +} + +func (h *handler) exchangeToken(c *gin.Context) { + if h.tokenService == nil { + respondError(c, http.StatusServiceUnavailable, "token_service_unavailable", "token service is not configured") + return + } + + var req tokenExchangeRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "invalid_request", "invalid request payload") + return + } + + // Validate public token + if !h.tokenService.ValidatePublicToken(req.PublicToken) { + respondError(c, http.StatusUnauthorized, "invalid_public_token", "invalid public token") + return + } + + // Parse roles + var roles []string + if req.Roles != "" { + roles = strings.Split(req.Roles, ",") + for i := range roles { + roles[i] = strings.TrimSpace(roles[i]) + } + } else { + roles = []string{"user"} + } + + // Generate token pair + tokenPair, err := h.tokenService.GenerateTokenPair(req.UserID, req.Email, roles) + if err != nil { + slog.Error("failed to generate token pair", "err", err) + respondError(c, http.StatusInternalServerError, "token_generation_failed", "failed to generate tokens") + return + } + + c.JSON(http.StatusOK, gin.H{ + "public_token": tokenPair.PublicToken, + "access_token": tokenPair.AccessToken, + "refresh_token": tokenPair.RefreshToken, + "token_type": tokenPair.TokenType, + "expires_in": tokenPair.ExpiresIn, + }) +} + func (h *handler) findUserByIdentifier(ctx context.Context, identifier string) (*store.User, error) { user, err := h.store.GetUserByName(ctx, identifier) if err == nil { diff --git a/account/cmd/accountsvc/main.go b/account/cmd/accountsvc/main.go index 2d3ad55..e9f8ae0 100644 --- a/account/cmd/accountsvc/main.go +++ b/account/cmd/accountsvc/main.go @@ -27,6 +27,7 @@ import ( "xcontrol/account/internal/agentmode" "xcontrol/account/internal/agentproto" "xcontrol/account/internal/agentserver" + "xcontrol/account/internal/auth" "xcontrol/account/internal/mailer" "xcontrol/account/internal/model" "xcontrol/account/internal/service" @@ -135,6 +136,28 @@ func runServer(ctx context.Context, cfg *config.Config, logger *slog.Logger) err emailVerificationEnabled = false } + // Initialize TokenService for authentication + var tokenService *auth.TokenService + if cfg.Auth.Enable { + accessExpiry := cfg.Auth.Token.AccessExpiry + if accessExpiry <= 0 { + accessExpiry = 1 * time.Hour + } + refreshExpiry := cfg.Auth.Token.RefreshExpiry + if refreshExpiry <= 0 { + refreshExpiry = 168 * time.Hour // 7 days + } + + tokenService = auth.NewTokenService(auth.TokenConfig{ + PublicToken: cfg.Auth.Token.PublicToken, + RefreshSecret: cfg.Auth.Token.RefreshSecret, + AccessSecret: cfg.Auth.Token.AccessSecret, + AccessExpiry: accessExpiry, + RefreshExpiry: refreshExpiry, + }) + logger.Info("token service initialized", "auth_enabled", cfg.Auth.Enable) + } + gormDB, gormCleanup, err := openAdminSettingsDB(cfg.Store) if err != nil { return err @@ -217,6 +240,9 @@ func runServer(ctx context.Context, cfg *config.Config, logger *slog.Logger) err options = append(options, api.WithEmailSender(emailSender)) } options = append(options, api.WithEmailVerification(emailVerificationEnabled)) + if tokenService != nil { + options = append(options, api.WithTokenService(tokenService)) + } if agentRegistry != nil { options = append(options, api.WithAgentStatusReader(agentRegistry)) } diff --git a/account/config/account.yaml b/account/config/account.yaml index 0d1836b..f2e63ac 100644 --- a/account/config/account.yaml +++ b/account/config/account.yaml @@ -4,10 +4,14 @@ log: level: info auth: + enable: true token: # Fixed token authentication mechanism publicToken: "xcontrol-public-token-2024" refreshSecret: "xcontrol-refresh-secret-2024" + accessSecret: "xcontrol-access-secret-2024" + accessExpiry: "1h" + refreshExpiry: "168h" server: addr: ":8080" diff --git a/account/config/config.go b/account/config/config.go index 2d5868f..ba0ed66 100644 --- a/account/config/config.go +++ b/account/config/config.go @@ -24,6 +24,7 @@ type Config struct { Server Server `yaml:"server"` Store Store `yaml:"store"` Session Session `yaml:"session"` + Auth Auth `yaml:"auth"` SMTP SMTP `yaml:"smtp"` Xray Xray `yaml:"xray"` Agent Agent `yaml:"agent"` @@ -73,6 +74,21 @@ type Session struct { TTL time.Duration `yaml:"ttl"` } +// Auth defines authentication configuration. +type Auth struct { + Enable bool `yaml:"enable"` + Token Token `yaml:"token"` +} + +// Token defines token authentication configuration. +type Token struct { + PublicToken string `yaml:"publicToken"` + RefreshSecret string `yaml:"refreshSecret"` + AccessSecret string `yaml:"accessSecret"` + AccessExpiry time.Duration `yaml:"accessExpiry"` + RefreshExpiry time.Duration `yaml:"refreshExpiry"` +} + // SMTP defines outbound SMTP configuration used for transactional email. type SMTP struct { Host string `yaml:"host"` diff --git a/rag-server/config/server.yaml b/rag-server/config/server.yaml index f638f59..11c6cbf 100644 --- a/rag-server/config/server.yaml +++ b/rag-server/config/server.yaml @@ -14,10 +14,14 @@ server: - "http://127.0.0.1:3001" auth: + enable: true token: # Fixed token authentication mechanism publicToken: "xcontrol-public-token-2024" refreshSecret: "xcontrol-refresh-secret-2024" + accessSecret: "xcontrol-access-secret-2024" + accessExpiry: "1h" + refreshExpiry: "168h" global: redis: diff --git a/scripts/update_token_auth.sh b/scripts/update_token_auth.sh index 4d9f243..f81e055 100755 --- a/scripts/update_token_auth.sh +++ b/scripts/update_token_auth.sh @@ -65,7 +65,8 @@ update_config() { local file="$1" local public_token="$2" local refresh_secret="$3" - local dry_run="$4" + local access_secret="$4" + local dry_run="$5" log_info "更新配置文件: $file" @@ -73,6 +74,7 @@ update_config() { log_info "[DRY RUN] 将要更新: $file" log_info " Public Token: $public_token" log_info " Refresh Secret: $refresh_secret" + log_info " Access Secret: $access_secret" return fi @@ -82,13 +84,13 @@ update_config() { # 使用 sed 更新配置 # 注意: 这里假设 YAML 配置格式为特定的样式 if [[ "$file" == *"dashboard-fresh"* ]]; then - # dashboard-fresh 配置格式 + # dashboard-fresh 配置格式 (前端应用,只需 publicToken) sed -i '' -e "s/publicToken:.*/publicToken: \"$public_token\"/" "$file" - sed -i '' -e "s/refreshSecret:.*/refreshSecret: \"$refresh_secret\"/" "$file" else - # account 和 rag-server 配置格式 + # account 和 rag-server 配置格式 (后端服务,需要所有字段) sed -i '' -e "s/publicToken:.*/publicToken: \"$public_token\"/" "$file" sed -i '' -e "s/refreshSecret:.*/refreshSecret: \"$refresh_secret\"/" "$file" + sed -i '' -e "s/accessSecret:.*/accessSecret: \"$access_secret\"/" "$file" fi log_success "已更新: $file" @@ -123,14 +125,12 @@ validate_configs() { log_success "Public Token 一致" fi - # 提取并比较 Refresh Secret - local dashboard_refresh=$(grep "refreshSecret:" "$DASHBOARD_CONFIG" | awk '{print $2}' | tr -d '"') + # 提取并比较 Refresh Secret (dashboard-fresh 可能没有此字段) local account_refresh=$(grep "refreshSecret:" "$ACCOUNT_CONFIG" | awk '{print $2}' | tr -d '"') local rag_refresh=$(grep "refreshSecret:" "$RAG_CONFIG" | awk '{print $2}' | tr -d '"') - if [ "$dashboard_refresh" != "$account_refresh" ] || [ "$dashboard_refresh" != "$rag_refresh" ]; then + if [ "$account_refresh" != "$rag_refresh" ]; then log_error "Refresh Secret 不一致!" - log_error " Dashboard: $dashboard_refresh" log_error " Account: $account_refresh" log_error " RAG: $rag_refresh" errors=$((errors + 1)) @@ -138,6 +138,32 @@ validate_configs() { log_success "Refresh Secret 一致" fi + # 提取并比较 Access Secret (仅检查 account 和 rag-server) + local account_access=$(grep "accessSecret:" "$ACCOUNT_CONFIG" | awk '{print $2}' | tr -d '"') + local rag_access=$(grep "accessSecret:" "$RAG_CONFIG" | awk '{print $2}' | tr -d '"') + + if [ "$account_access" != "$rag_access" ]; then + log_error "Access Secret 不一致!" + log_error " Account: $account_access" + log_error " RAG: $rag_access" + errors=$((errors + 1)) + else + log_success "Access Secret 一致" + fi + + # 检查 auth.enable 字段 + local account_auth_enabled=$(grep -A1 "^auth:" "$ACCOUNT_CONFIG" | grep "enable:" | awk '{print $2}') + local rag_auth_enabled=$(grep -A1 "^auth:" "$RAG_CONFIG" | grep "enable:" | awk '{print $2}') + + if [ "$account_auth_enabled" != "$rag_auth_enabled" ]; then + log_error "Auth Enable 状态不一致!" + log_error " Account: $account_auth_enabled" + log_error " RAG: $rag_auth_enabled" + errors=$((errors + 1)) + else + log_success "Auth Enable 状态一致" + fi + if [ $errors -eq 0 ]; then log_success "所有配置验证通过" return 0 @@ -153,11 +179,13 @@ generate_new_tokens() { local public_token="xcontrol-public-$(date +%Y%m%d)-$(openssl rand -hex 4)" local refresh_secret="xcontrol-refresh-$(date +%Y%m%d)-$(openssl rand -hex 16)" + local access_secret="xcontrol-access-$(date +%Y%m%d)-$(openssl rand -hex 32)" echo "" log_info "=== 新的密钥对 ===" echo "Public Token: $public_token" echo "Refresh Secret: $refresh_secret" + echo "Access Secret: $access_secret" echo "" read -p "确认生成新密钥? [y/N] " -n 1 -r @@ -169,30 +197,33 @@ generate_new_tokens() { echo "$public_token" > /tmp/new_public_token.txt echo "$refresh_secret" > /tmp/new_refresh_secret.txt + echo "$access_secret" > /tmp/new_access_secret.txt log_success "新密钥已生成并保存到临时文件" log_info "临时文件位置:" log_info " /tmp/new_public_token.txt" log_info " /tmp/new_refresh_secret.txt" + log_info " /tmp/new_access_secret.txt" } # 应用新密钥 apply_new_tokens() { local dry_run="$1" - if [ ! -f "/tmp/new_public_token.txt" ] || [ ! -f "/tmp/new_refresh_secret.txt" ]; then + if [ ! -f "/tmp/new_public_token.txt" ] || [ ! -f "/tmp/new_refresh_secret.txt" ] || [ ! -f "/tmp/new_access_secret.txt" ]; then log_error "找不到新密钥文件,请先运行 --generate-new" exit 1 fi local public_token=$(cat /tmp/new_public_token.txt) local refresh_secret=$(cat /tmp/new_refresh_secret.txt) + local access_secret=$(cat /tmp/new_access_secret.txt) log_info "应用新的密钥到配置文件..." - update_config "$DASHBOARD_CONFIG" "$public_token" "$refresh_secret" "$dry_run" - update_config "$ACCOUNT_CONFIG" "$public_token" "$refresh_secret" "$dry_run" - update_config "$RAG_CONFIG" "$public_token" "$refresh_secret" "$dry_run" + update_config "$DASHBOARD_CONFIG" "$public_token" "$refresh_secret" "$access_secret" "$dry_run" + update_config "$ACCOUNT_CONFIG" "$public_token" "$refresh_secret" "$access_secret" "$dry_run" + update_config "$RAG_CONFIG" "$public_token" "$refresh_secret" "$access_secret" "$dry_run" if [ "$dry_run" = "true" ]; then log_info "[DRY RUN] 完成预览模式" @@ -200,7 +231,7 @@ apply_new_tokens() { fi # 清理临时文件 - rm -f /tmp/new_public_token.txt /tmp/new_refresh_secret.txt + rm -f /tmp/new_public_token.txt /tmp/new_refresh_secret.txt /tmp/new_access_secret.txt log_success "所有配置文件已更新"