diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index 78c077e..2ec46aa 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -12,6 +12,7 @@ import ( identitydomain "git.cin.su/m/Assistant/modules/identity/domain" situationdomain "git.cin.su/m/Assistant/modules/situation/domain" "git.cin.su/m/Assistant/runtime/health" + "io" "log" "net/http" "os" @@ -19,12 +20,15 @@ import ( "time" ) +const maxCreatorCredentialsBytes = 4 << 10 + func main() { addr := os.Getenv("LISTEN_ADDR") if addr == "" { addr = ":8080" } bootstrapToken, sessionKey := os.Getenv("CREATOR_BOOTSTRAP_TOKEN"), []byte(os.Getenv("CREATOR_SESSION_SIGNING_KEY")) + creatorUsername, creatorPassword := os.Getenv("CREATOR_USERNAME"), os.Getenv("CREATOR_PASSWORD") repository, err := postgres.OpenSituation(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("POSTGRES_USER"), Password: os.Getenv("POSTGRES_PASSWORD")}) if err != nil { log.Fatal(err) @@ -63,22 +67,7 @@ func main() { return situationdomain.NewResolver(bindings).Resolve(runtime) } mux.Handle("/healthz", health.Handler{Runtime: "core-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1}) - mux.HandleFunc("POST /v1/creator/sessions", func(w http.ResponseWriter, r *http.Request) { - if !identitydomain.BootstrapMatches(bootstrapToken, bearerToken(r)) { - http.Error(w, "creator authentication denied", http.StatusUnauthorized) - return - } - token, session, err := identitydomain.IssueCreatorSession(sessionKey, time.Now(), 8*time.Hour) - if err != nil { - http.Error(w, "creator session unavailable", http.StatusServiceUnavailable) - return - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(struct { - Token string - Session identity.CreatorSession - }{token, session}) - }) + mux.HandleFunc("POST /v1/creator/sessions", creatorSessionHandler(bootstrapToken, creatorUsername, creatorPassword, sessionKey, time.Now)) mux.HandleFunc("GET /v1/creator/sessions/current", func(w http.ResponseWriter, r *http.Request) { session, ok := authenticateCreator(w, r) if !ok { @@ -273,6 +262,36 @@ func main() { } } +func creatorSessionHandler(bootstrapToken, creatorUsername, creatorPassword string, sessionKey []byte, now func() time.Time) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + authenticated := identitydomain.BootstrapMatches(bootstrapToken, bearerToken(r)) + if !authenticated { + var credentials identity.CreatorCredentials + decoder := json.NewDecoder(io.LimitReader(r.Body, maxCreatorCredentialsBytes+1)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&credentials); err != nil || decoder.Decode(&struct{}{}) != io.EOF { + http.Error(w, "creator authentication denied", http.StatusUnauthorized) + return + } + authenticated = identitydomain.CreatorCredentialsMatch(creatorUsername, creatorPassword, credentials) + } + if !authenticated { + http.Error(w, "creator authentication denied", http.StatusUnauthorized) + return + } + token, session, err := identitydomain.IssueCreatorSession(sessionKey, now(), 8*time.Hour) + if err != nil { + http.Error(w, "creator session unavailable", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(struct { + Token string + Session identity.CreatorSession + }{token, session}) + } +} + func bearerToken(r *http.Request) string { value := r.Header.Get("Authorization") if !strings.HasPrefix(value, "Bearer ") { diff --git a/cmd/core-runtime/main_test.go b/cmd/core-runtime/main_test.go index f6a3f39..76def19 100644 --- a/cmd/core-runtime/main_test.go +++ b/cmd/core-runtime/main_test.go @@ -1,8 +1,12 @@ package main import ( + "encoding/json" + "net/http" "net/http/httptest" + "strings" "testing" + "time" ) func TestBearerTokenRequiresBearerScheme(t *testing.T) { @@ -16,3 +20,50 @@ func TestBearerTokenRequiresBearerScheme(t *testing.T) { t.Fatalf("token = %q", token) } } + +func TestCreatorSessionHandlerAcceptsConfiguredCredentials(t *testing.T) { + handler := creatorSessionHandler("recovery", "m", "password", []byte("01234567890123456789012345678901"), func() time.Time { return time.Unix(1000, 0) }) + request := httptest.NewRequest(http.MethodPost, "/v1/creator/sessions", strings.NewReader(`{"username":"m","password":"password"}`)) + response := httptest.NewRecorder() + handler(response, request) + if response.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) + } + var issued struct { + Token string + } + if err := json.NewDecoder(response.Body).Decode(&issued); err != nil || issued.Token == "" { + t.Fatalf("session response = %+v, err=%v", issued, err) + } +} + +func TestCreatorSessionHandlerFailsClosed(t *testing.T) { + handler := creatorSessionHandler("recovery", "m", "password", []byte("01234567890123456789012345678901"), time.Now) + tests := []string{ + `{"username":"wrong","password":"password"}`, + `{"username":"m","password":"wrong"}`, + `{"username":"","password":""}`, + `{"username":"m","password":"password","actor":"attacker"}`, + `{`, + `{"username":"m","password":"password"}{}`, + } + for _, body := range tests { + request := httptest.NewRequest(http.MethodPost, "/v1/creator/sessions", strings.NewReader(body)) + response := httptest.NewRecorder() + handler(response, request) + if response.Code != http.StatusUnauthorized || strings.TrimSpace(response.Body.String()) != "creator authentication denied" { + t.Fatalf("body %q: status=%d response=%q", body, response.Code, response.Body.String()) + } + } +} + +func TestCreatorSessionHandlerPreservesBootstrapRecovery(t *testing.T) { + handler := creatorSessionHandler("recovery", "", "", []byte("01234567890123456789012345678901"), time.Now) + request := httptest.NewRequest(http.MethodPost, "/v1/creator/sessions", nil) + request.Header.Set("Authorization", "Bearer recovery") + response := httptest.NewRecorder() + handler(response, request) + if response.Code != http.StatusOK { + t.Fatalf("bootstrap recovery status = %d", response.Code) + } +} diff --git a/cmd/creator-web/server_test.go b/cmd/creator-web/server_test.go index a20025a..95294b8 100644 --- a/cmd/creator-web/server_test.go +++ b/cmd/creator-web/server_test.go @@ -33,6 +33,34 @@ func TestGatewayForwardsOnlyCreatorAllowlist(t *testing.T) { } } +func TestGatewayForwardsCreatorCredentialsOnlyInBody(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/creator/sessions" || r.Method != http.MethodPost { + t.Fatalf("unexpected session target %s %s", r.Method, r.URL.Path) + } + if r.Header.Get("Authorization") != "" || r.Header.Get("Content-Type") != "application/json" { + t.Fatalf("unexpected session headers Authorization=%q Content-Type=%q", r.Header.Get("Authorization"), r.Header.Get("Content-Type")) + } + body, _ := io.ReadAll(r.Body) + if string(body) != `{"username":"m","password":"secret"}` { + t.Fatalf("session body = %s", body) + } + _, _ = io.WriteString(w, `{"Token":"bounded"}`) + })) + defer upstream.Close() + handler, err := newHandler(upstream.URL, upstream.URL, upstream.URL, upstream.Client()) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodPost, "/api/session", strings.NewReader(`{"username":"m","password":"secret"}`)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("session response = %d %s", response.Code, response.Body.String()) + } +} + func TestGatewayDeniesInternalAndMutationRoutes(t *testing.T) { handler, err := newHandler("http://core-runtime:8080", "http://continuity-runtime:8081", "http://commit-runtime:8082", http.DefaultClient) if err != nil { @@ -139,7 +167,9 @@ func TestConsoleAndSecurityHeaders(t *testing.T) { response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "Домовой") || - !strings.Contains(response.Body.String(), "satisfaction-dialog") || !strings.Contains(response.Body.String(), "Подтверждаю переход") { + !strings.Contains(response.Body.String(), "satisfaction-dialog") || !strings.Contains(response.Body.String(), "Подтверждаю переход") || + !strings.Contains(response.Body.String(), "creator-username") || !strings.Contains(response.Body.String(), "creator-password") || + strings.Contains(response.Body.String(), "bootstrap-token") { t.Fatalf("console response = %d", response.Code) } if response.Header().Get("Content-Security-Policy") == "" { diff --git a/cmd/creator-web/static/assets/console.css b/cmd/creator-web/static/assets/console.css index 2a81a58..8ee36f1 100644 --- a/cmd/creator-web/static/assets/console.css +++ b/cmd/creator-web/static/assets/console.css @@ -42,6 +42,7 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px .login-form input, .context-form input { width: 100%; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: #0b110e; transition: border-color .18s, background .18s; } .login-form input:hover, .context-form input:hover { border-color: var(--faint); } .login-form input { height: 52px; padding: 0 50px 0 14px; font-family: var(--mono); } +.login-form input:not(#creator-password) { padding-right: 14px; margin-bottom: 16px; } .secret-row { position: relative; } .secret-row .icon-button { position: absolute; right: 7px; top: 7px; } .icon-button { width: 38px; height: 38px; display: grid; place-items: center; border: 1px solid var(--line); background: transparent; border-radius: var(--radius); cursor: pointer; transition: background .18s, border-color .18s; } diff --git a/cmd/creator-web/static/assets/console.js b/cmd/creator-web/static/assets/console.js index 6b38bbc..e78173e 100644 --- a/cmd/creator-web/static/assets/console.js +++ b/cmd/creator-web/static/assets/console.js @@ -99,18 +99,23 @@ function endSession(message = "Сессия завершена.") { loginForm.addEventListener("submit", async (event) => { event.preventDefault(); const button = byId("login-button"); - const bootstrap = byId("bootstrap-token").value.trim(); + const username = byId("creator-username").value.trim(); + const password = byId("creator-password").value; loginError.textContent = ""; button.disabled = true; - button.firstElementChild.textContent = "Проверяю ключ…"; + button.firstElementChild.textContent = "Проверяю данные…"; try { - const result = await request("/api/session", {method: "POST", headers: {Authorization: `Bearer ${bootstrap}`}}); + const result = await request("/api/session", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({username, password}) + }); sessionToken = result.Token; sessionStorage.setItem("domovoy.creator.session", sessionToken); - byId("bootstrap-token").value = ""; + byId("creator-password").value = ""; await showConsole(); } catch (error) { - loginError.textContent = error.message === "creator authentication denied" ? "Ключ не принят." : error.message; + loginError.textContent = error.message === "creator authentication denied" ? "Логин или пароль не принят." : error.message; } finally { button.disabled = false; button.firstElementChild.textContent = "Открыть консоль"; @@ -118,9 +123,10 @@ loginForm.addEventListener("submit", async (event) => { }); byId("toggle-secret").addEventListener("click", () => { - const input = byId("bootstrap-token"); + const input = byId("creator-password"); input.type = input.type === "password" ? "text" : "password"; - byId("toggle-secret").setAttribute("aria-label", input.type === "password" ? "Показать ключ" : "Скрыть ключ"); + byId("toggle-secret").setAttribute("aria-label", input.type === "password" ? "Показать пароль" : "Скрыть пароль"); + byId("toggle-secret").setAttribute("title", input.type === "password" ? "Показать пароль" : "Скрыть пароль"); }); contextForm.addEventListener("submit", (event) => { diff --git a/cmd/creator-web/static/index.html b/cmd/creator-web/static/index.html index 12c0ea4..cbc106a 100644 --- a/cmd/creator-web/static/index.html +++ b/cmd/creator-web/static/index.html @@ -16,12 +16,14 @@
защищённый вход · creator
Личная операторская консоль. Ключ используется только для выпуска ограниченной сессии и не сохраняется браузером.
+Личная операторская консоль. Учётные данные используются только для выпуска ограниченной сессии и не сохраняются браузером.