feat(auth): implement dual-layer token authentication with config support
- Add auth.enable configuration field (default: true) - Add TokenService initialization in account service - Implement token exchange endpoint (/api/auth/token/exchange) - Implement token refresh endpoint (/api/auth/token/refresh) - Protect routes with JWT authentication middleware - Update configuration structures for all services - Support accessSecret with configurable expiry times - Update token management script to handle all token types - Validate auth configuration consistency across services
This commit is contained in:
parent
c1a26852ae
commit
8edeb75391
@ -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 {
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 "所有配置文件已更新"
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user