fix(docker-watcher): address final review findings
Security: - Move config export behind auth middleware - Validate OIDC callback token before storing in localStorage - Use constant-time comparison for webhook secret - Encrypt OIDC client secret at rest (like registry tokens) Performance: - Make TriggerDeploy async from HTTP handlers (return deploy ID immediately, run pipeline in background goroutine) Robustness: - Wrap api.ts res.json() in try/catch for non-JSON responses i18n: - Replace ~20 hardcoded English validation messages with $t() calls - Localize ConfirmDialog cancel button, InstanceCard confirm titles, ProjectCard instance/instances pluralization - Add validation keys to both en.json and ru.json
This commit is contained in:
+13
-1
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/auth"
|
||||
"github.com/alexei/docker-watcher/internal/crypto"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
)
|
||||
|
||||
@@ -207,12 +208,23 @@ func (s *Server) updateAuthSettings(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// If client secret is masked, preserve the existing value.
|
||||
// If client secret is masked, preserve the existing encrypted value.
|
||||
if req.OIDCClientSecret == "********" || req.OIDCClientSecret == "" {
|
||||
existing, err := s.store.GetAuthSettings()
|
||||
if err == nil {
|
||||
req.OIDCClientSecret = existing.OIDCClientSecret
|
||||
}
|
||||
} else {
|
||||
// Encrypt the new client secret before storage.
|
||||
encrypted, err := crypto.Encrypt(s.encKey, req.OIDCClientSecret)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to encrypt OIDC client secret")
|
||||
return
|
||||
}
|
||||
// Keep plaintext for OIDC init below, store encrypted.
|
||||
plaintextSecret := req.OIDCClientSecret
|
||||
req.OIDCClientSecret = encrypted
|
||||
defer func() { req.OIDCClientSecret = plaintextSecret }()
|
||||
}
|
||||
|
||||
if err := s.store.UpdateAuthSettings(req); err != nil {
|
||||
|
||||
@@ -137,16 +137,18 @@ func (s *Server) quickDeploy(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger deploy.
|
||||
if err := s.deployer.TriggerDeploy(r.Context(), project.ID, stage.ID, req.Tag); err != nil {
|
||||
// Trigger deploy asynchronously.
|
||||
deployID, err := s.deployer.AsyncTriggerDeploy(r.Context(), project.ID, stage.ID, req.Tag)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to trigger deploy: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusAccepted, map[string]any{
|
||||
"project": project,
|
||||
"stage": stage,
|
||||
"tag": req.Tag,
|
||||
"project": project,
|
||||
"stage": stage,
|
||||
"tag": req.Tag,
|
||||
"deploy_id": deployID,
|
||||
"status": "deploying",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -74,12 +74,14 @@ func (s *Server) deployInstance(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deployer.TriggerDeploy(r.Context(), projectID, stageID, req.ImageTag); err != nil {
|
||||
deployID, err := s.deployer.AsyncTriggerDeploy(r.Context(), projectID, stageID, req.ImageTag)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to trigger deploy: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusAccepted, map[string]string{
|
||||
"status": "deploying",
|
||||
"deploy_id": deployID,
|
||||
"project_id": projectID,
|
||||
"stage_id": stageID,
|
||||
"image_tag": req.ImageTag,
|
||||
@@ -188,4 +190,5 @@ func (s *Server) controlInstance(w http.ResponseWriter, r *http.Request, action
|
||||
// DeployTriggerer is the interface for triggering deployments.
|
||||
type DeployTriggerer interface {
|
||||
TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error
|
||||
AsyncTriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) (string, error)
|
||||
}
|
||||
|
||||
+13
-4
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/auth"
|
||||
"github.com/alexei/docker-watcher/internal/crypto"
|
||||
"github.com/alexei/docker-watcher/internal/docker"
|
||||
"github.com/alexei/docker-watcher/internal/events"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
@@ -57,10 +58,18 @@ func NewServer(
|
||||
|
||||
// initOIDCProvider creates an OIDC provider from settings. Errors are logged, not fatal.
|
||||
func (s *Server) initOIDCProvider(ctx context.Context, as store.AuthSettings) {
|
||||
// Decrypt the OIDC client secret if it's encrypted.
|
||||
clientSecret := as.OIDCClientSecret
|
||||
if clientSecret != "" {
|
||||
if decrypted, err := crypto.Decrypt(s.encKey, clientSecret); err == nil {
|
||||
clientSecret = decrypted
|
||||
}
|
||||
// If decrypt fails, assume it's already plaintext (migration scenario).
|
||||
}
|
||||
provider, err := auth.NewOIDCProvider(ctx, auth.OIDCConfig{
|
||||
IssuerURL: as.OIDCIssuerURL,
|
||||
ClientID: as.OIDCClientID,
|
||||
ClientSecret: as.OIDCClientSecret,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: as.OIDCRedirectURL,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -90,13 +99,13 @@ func (s *Server) Router() chi.Router {
|
||||
// Webhook handler (uses its own secret-based auth).
|
||||
r.Mount("/webhook", s.webhook.Route())
|
||||
|
||||
// Config export (public endpoint, useful for backup).
|
||||
r.Get("/config/export", s.exportConfig)
|
||||
|
||||
// Protected routes: require valid JWT.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(auth.Middleware(s.localAuth))
|
||||
|
||||
// Config export (protected — reveals project/infra details).
|
||||
r.Get("/config/export", s.exportConfig)
|
||||
|
||||
// Auth management.
|
||||
r.Get("/auth/me", s.currentUser)
|
||||
r.Get("/auth/settings", s.getAuthSettings)
|
||||
|
||||
Reference in New Issue
Block a user