This commit is contained in:
+35
-16
@@ -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 ") {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") == "" {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -16,12 +16,14 @@
|
||||
<div class="brand-mark" aria-hidden="true">Д</div>
|
||||
<p class="eyebrow">защищённый вход · creator</p>
|
||||
<h1 id="login-title">Домовой</h1>
|
||||
<p class="login-copy">Личная операторская консоль. Ключ используется только для выпуска ограниченной сессии и не сохраняется браузером.</p>
|
||||
<p class="login-copy">Личная операторская консоль. Учётные данные используются только для выпуска ограниченной сессии и не сохраняются браузером.</p>
|
||||
<form id="login-form" class="login-form">
|
||||
<label for="bootstrap-token">Ключ создателя</label>
|
||||
<label for="creator-username">Логин</label>
|
||||
<input id="creator-username" name="username" type="text" autocomplete="username" required autofocus>
|
||||
<label for="creator-password">Пароль</label>
|
||||
<div class="secret-row">
|
||||
<input id="bootstrap-token" name="token" type="password" autocomplete="current-password" required autofocus>
|
||||
<button class="icon-button" id="toggle-secret" type="button" aria-label="Показать ключ" title="Показать ключ">
|
||||
<input id="creator-password" name="password" type="password" autocomplete="current-password" required>
|
||||
<button class="icon-button" id="toggle-secret" type="button" aria-label="Показать пароль" title="Показать пароль">
|
||||
<span aria-hidden="true">◉</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package identity
|
||||
|
||||
type CreatorCredentials struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type CreatorSession struct {
|
||||
ActorID ActorID
|
||||
PrincipalID PrincipalID
|
||||
|
||||
@@ -44,6 +44,8 @@ services:
|
||||
IDENTITY_POSTGRES_PASSWORD: ${IDENTITY_DB_PASSWORD:-assistant_identity_dev_only}
|
||||
CREATOR_BOOTSTRAP_TOKEN: ${CREATOR_BOOTSTRAP_TOKEN:-creator_dev_only_replace_me}
|
||||
CREATOR_SESSION_SIGNING_KEY: ${CREATOR_SESSION_SIGNING_KEY:-creator_dev_only_signing_key_32_bytes}
|
||||
CREATOR_USERNAME: ${CREATOR_USERNAME:-creator}
|
||||
CREATOR_PASSWORD: ${CREATOR_PASSWORD:-creator_dev_only_replace_me}
|
||||
WORK_POSTGRES_USER: work_role
|
||||
WORK_POSTGRES_PASSWORD: ${WORK_DB_PASSWORD:-assistant_work_dev_only}
|
||||
CAPABILITY_POSTGRES_USER: capability_role
|
||||
|
||||
@@ -44,6 +44,8 @@ services:
|
||||
IDENTITY_POSTGRES_PASSWORD: ${IDENTITY_DB_PASSWORD}
|
||||
CREATOR_BOOTSTRAP_TOKEN: ${CREATOR_BOOTSTRAP_TOKEN}
|
||||
CREATOR_SESSION_SIGNING_KEY: ${CREATOR_SESSION_SIGNING_KEY}
|
||||
CREATOR_USERNAME: ${CREATOR_USERNAME}
|
||||
CREATOR_PASSWORD: ${CREATOR_PASSWORD}
|
||||
WORK_POSTGRES_USER: work_role
|
||||
WORK_POSTGRES_PASSWORD: ${WORK_DB_PASSWORD}
|
||||
CAPABILITY_POSTGRES_USER: capability_role
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"task_file": "dev/tasks/ARCH-000004-implement-diff-scope-guardrail.json",
|
||||
"task_id": "ARCH-000004",
|
||||
"task_revision": 1
|
||||
"task_file": "dev/tasks/DEV-000033-creator-username-password.json",
|
||||
"task_id": "DEV-000033",
|
||||
"task_revision": 2
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"task_id": "DEV-000033",
|
||||
"task_revision": 2,
|
||||
"architecture_revision": "ADR-0003 and ADR-0004",
|
||||
"base_repo_revision": "111ad75deaf20a6822f5440ad232d782479ce79c",
|
||||
"risk_tier": "E",
|
||||
"intent": "Add a typed username/password Creator authentication flow for the single Domovoy Creator account while preserving bounded sessions, bootstrap recovery access, and all existing authority boundaries.",
|
||||
"requirement_ids": ["INV-AUTH-001", "CONSTITUTION-1.3.28"],
|
||||
"allowed_change_scope": {
|
||||
"modules": ["identity"],
|
||||
"paths": [
|
||||
"contracts/identity/session.go",
|
||||
"modules/identity/domain/session.go",
|
||||
"cmd/core-runtime/main.go",
|
||||
"cmd/core-runtime/main_test.go",
|
||||
"cmd/creator-web/server_test.go",
|
||||
"cmd/creator-web/static/index.html",
|
||||
"cmd/creator-web/static/assets/console.js",
|
||||
"cmd/creator-web/static/assets/console.css",
|
||||
"deploy/docker-compose.dev.yml",
|
||||
"deploy/docker-compose.prod.yml",
|
||||
"docs/DEPLOYMENT.md",
|
||||
"tests/creator_auth/**",
|
||||
"tests/creator_web/**",
|
||||
"dev/ACTIVE_TASK.json",
|
||||
"dev/tasks/DEV-000033-creator-username-password.json"
|
||||
]
|
||||
},
|
||||
"forbidden_change_scope": {
|
||||
"paths": [
|
||||
"architecture/**",
|
||||
"ci/**",
|
||||
".gitea/**",
|
||||
"security/**",
|
||||
"database/**",
|
||||
"modules/authority/**",
|
||||
"modules/commit/**",
|
||||
"modules/continuity/**",
|
||||
"adapters/**"
|
||||
]
|
||||
},
|
||||
"allowed_architecture_delta": {
|
||||
"new_cross_module_dependencies": 0,
|
||||
"new_canonical_owners": 0,
|
||||
"new_privileges": 0
|
||||
},
|
||||
"contracts": {
|
||||
"consume": ["contracts.identity@v1", "Creator bootstrap recovery credential"],
|
||||
"may_change": ["Creator session issuance request adds typed username/password credentials"]
|
||||
},
|
||||
"semantic_owner": "identity",
|
||||
"canonical_state": ["no new canonical state; the single Creator identity remains domovoy-creator-actor/domovoy-creator-principal"],
|
||||
"state_transitions": ["valid configured credentials issue the existing eight-hour bounded Creator session"],
|
||||
"side_effects_and_privileges": ["reads externally injected Creator username and password", "does not grant Authority or accept Actor/Principal from the client"],
|
||||
"persistence_and_migrations": ["none; credentials remain external runtime configuration"],
|
||||
"failure_retry_restart": ["missing configuration and invalid credentials fail with the same generic denial", "credentials are not logged or persisted in browser storage", "bootstrap Bearer remains for test and recovery compatibility"],
|
||||
"assumptions": [
|
||||
"The user explicitly selected username m and supplied the Creator password",
|
||||
"The user requested execution, which authorizes this authentication contract change",
|
||||
"ADR-0004 requires the active pointer update to be reviewed as a Tier E protected-path change"
|
||||
],
|
||||
"acceptance_scenarios": [
|
||||
"valid username/password issues the existing bounded Creator session",
|
||||
"wrong username, wrong password, empty fields and malformed JSON are denied without credential disclosure",
|
||||
"the browser sends credentials only in the session request body and stores only the issued session token",
|
||||
"Actor and Principal remain server-selected constants",
|
||||
"legacy bootstrap Bearer remains accepted for recovery and remote harnesses",
|
||||
"production credentials are injected outside the repository",
|
||||
"desktop and mobile login remain accessible",
|
||||
"remote login and current-session checks succeed through the deployed gateway"
|
||||
],
|
||||
"required_tests": [
|
||||
"unit",
|
||||
"adversarial authentication",
|
||||
"gateway request forwarding",
|
||||
"architecture preflight",
|
||||
"exact diff scope",
|
||||
"go vet",
|
||||
"responsive browser",
|
||||
"remote Docker smoke"
|
||||
],
|
||||
"terminal_gap_states": [
|
||||
"BLOCKED_BY_ARCHITECTURE_GAP",
|
||||
"BLOCKED_BY_SPEC_GAP",
|
||||
"BLOCKED_BY_CONFLICT"
|
||||
]
|
||||
}
|
||||
@@ -15,3 +15,5 @@ Temporal is intentionally **not** the canonical owner of future obligations. If/
|
||||
The initial stack definition is `deploy/docker-compose.prod.yml`. Portainer may deploy it from Git after the repository has been pushed.
|
||||
|
||||
Credentials/API deployment automation is deliberately not embedded. Use `PORTAINER_API_KEY` from an external secret source or deploy through Portainer UI/API after authenticating.
|
||||
|
||||
The single Creator login is configured with external `CREATOR_USERNAME` and `CREATOR_PASSWORD` values. They issue the same bounded Creator session and never select Actor, Principal, Scope or Authority. Keep `CREATOR_BOOTSTRAP_TOKEN` as a separate recovery/automation credential; none of these values belongs in Git, compose defaults for production, image layers or logs.
|
||||
|
||||
@@ -47,6 +47,16 @@ func ValidateCreatorSession(secret []byte, token string, now time.Time) (identit
|
||||
func BootstrapMatches(expected, supplied string) bool {
|
||||
return expected != "" && supplied != "" && subtle.ConstantTimeCompare([]byte(expected), []byte(supplied)) == 1
|
||||
}
|
||||
|
||||
func CreatorCredentialsMatch(expectedUsername, expectedPassword string, supplied identity.CreatorCredentials) bool {
|
||||
if expectedUsername == "" || expectedPassword == "" || supplied.Username == "" || supplied.Password == "" {
|
||||
return false
|
||||
}
|
||||
usernameMatches := subtle.ConstantTimeCompare([]byte(expectedUsername), []byte(supplied.Username))
|
||||
passwordMatches := subtle.ConstantTimeCompare([]byte(expectedPassword), []byte(supplied.Password))
|
||||
return usernameMatches&passwordMatches == 1
|
||||
}
|
||||
|
||||
func signature(secret []byte, payload string) string {
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
mac.Write([]byte(payload))
|
||||
|
||||
@@ -2,6 +2,7 @@ package creator_auth_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/modules/identity/domain"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -32,6 +33,23 @@ func TestCreatorSessionIsTimeBoundAndDoesNotCarryAuthority(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatorUsernameAndPasswordMustBothMatch(t *testing.T) {
|
||||
if !domain.CreatorCredentialsMatch("m", "password", identity.CreatorCredentials{Username: "m", Password: "password"}) {
|
||||
t.Fatal("matching Creator credentials rejected")
|
||||
}
|
||||
for _, supplied := range []identity.CreatorCredentials{
|
||||
{},
|
||||
{Username: "m", Password: "wrong"},
|
||||
{Username: "wrong", Password: "password"},
|
||||
{Username: "m"},
|
||||
{Password: "password"},
|
||||
} {
|
||||
if domain.CreatorCredentialsMatch("m", "password", supplied) {
|
||||
t.Fatalf("invalid Creator credentials accepted: username=%q", supplied.Username)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatorBootstrapCredentialMustMatch(t *testing.T) {
|
||||
if !domain.BootstrapMatches("expected", "expected") {
|
||||
t.Fatal("matching bootstrap credential rejected")
|
||||
|
||||
@@ -52,11 +52,21 @@ $invalid = Invoke-WebRequest -Uri "$BaseURL/api/session/current" -Headers @{Auth
|
||||
if ($invalid.StatusCode -ne 401) { throw "Invalid Creator session was not denied" }
|
||||
|
||||
$environment = docker --context $DockerContext inspect --format '{{range .Config.Env}}{{println .}}{{end}}' $coreContainer
|
||||
$usernameEntry = $environment | Where-Object { $_ -like "CREATOR_USERNAME=*" } | Select-Object -First 1
|
||||
$passwordEntry = $environment | Where-Object { $_ -like "CREATOR_PASSWORD=*" } | Select-Object -First 1
|
||||
if (-not $usernameEntry -or -not $passwordEntry) { throw "Creator username/password is not configured" }
|
||||
$creatorUsername = $usernameEntry.Substring("CREATOR_USERNAME=".Length)
|
||||
$creatorPassword = $passwordEntry.Substring("CREATOR_PASSWORD=".Length)
|
||||
$invalidCredentials = Invoke-WebRequest -Uri "$BaseURL/api/session" -Method Post -ContentType "application/json" -Body (@{username=$creatorUsername;password="invalid"} | ConvertTo-Json -Compress) -SkipHttpErrorCheck
|
||||
if ($invalidCredentials.StatusCode -ne 401) { throw "Invalid Creator credentials were not denied" }
|
||||
$issued = Invoke-RestMethod -Uri "$BaseURL/api/session" -Method Post -ContentType "application/json" -Body (@{username=$creatorUsername;password=$creatorPassword} | ConvertTo-Json -Compress)
|
||||
if (-not $issued.Token) { throw "Creator username/password session was not issued through the gateway" }
|
||||
|
||||
$bootstrapEntry = $environment | Where-Object { $_ -like "CREATOR_BOOTSTRAP_TOKEN=*" } | Select-Object -First 1
|
||||
if (-not $bootstrapEntry) { throw "Creator bootstrap token is not configured" }
|
||||
$bootstrapToken = $bootstrapEntry.Substring("CREATOR_BOOTSTRAP_TOKEN=".Length)
|
||||
$issued = Invoke-RestMethod -Uri "$BaseURL/api/session" -Method Post -Headers @{Authorization="Bearer $bootstrapToken"}
|
||||
if (-not $issued.Token) { throw "Creator session was not issued through the gateway" }
|
||||
$recoveryIssued = Invoke-RestMethod -Uri "$BaseURL/api/session" -Method Post -Headers @{Authorization="Bearer $bootstrapToken"}
|
||||
if (-not $recoveryIssued.Token) { throw "Creator recovery session was not issued through the gateway" }
|
||||
$current = Invoke-RestMethod -Uri "$BaseURL/api/session/current" -Method Get -Headers @{Authorization="Bearer $($issued.Token)"}
|
||||
if ($current.ActorID -ne "domovoy-creator-actor") { throw "Creator identity was not preserved through the gateway" }
|
||||
|
||||
@@ -96,4 +106,4 @@ if ($staleAction.StatusCode -ne 409) { throw "Stale Creator action was not rejec
|
||||
$arbitraryAction = Invoke-WebRequest -Uri "$BaseURL/api/actions/arbitrary" -Method Post -ContentType "application/json" -Headers @{Authorization="Bearer $($issued.Token)"} -Body '{}' -SkipHttpErrorCheck
|
||||
if ($arbitraryAction.StatusCode -ne 404) { throw "Arbitrary action route was exposed" }
|
||||
|
||||
Write-Output "DEV-000032 Creator satisfaction console gateway: PASS"
|
||||
Write-Output "DEV-000033 Creator username/password and console gateway: PASS"
|
||||
|
||||
Reference in New Issue
Block a user