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:
Haitao Pan 2025-11-05 20:11:23 +08:00
parent c1a26852ae
commit 8edeb75391
6 changed files with 211 additions and 25 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 "所有配置文件已更新"