From 8924f5a48a2b8283daaa2cd875ba2d3dbae7d394 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 29 Aug 2026 17:02:42 +0300 Subject: [PATCH] Move creator passwords into identity accounts --- adapters/postgres/identity_repository.go | 36 +++++ cmd/core-runtime/main.go | 126 +++++++++++++++++- cmd/creator-web/server.go | 2 + cmd/creator-web/static/assets/chat.css | 14 ++ cmd/creator-web/static/assets/chat.js | 24 +++- cmd/creator-web/static/chat.html | 11 ++ contracts/identity/session.go | 8 ++ .../052_identity_creator_accounts.sql | 20 +++ modules/identity/domain/password.go | 70 ++++++++++ modules/identity/domain/password_test.go | 36 +++++ modules/identity/domain/session.go | 10 +- 11 files changed, 347 insertions(+), 10 deletions(-) create mode 100644 database/migrations/052_identity_creator_accounts.sql create mode 100644 modules/identity/domain/password.go create mode 100644 modules/identity/domain/password_test.go diff --git a/adapters/postgres/identity_repository.go b/adapters/postgres/identity_repository.go index d03ef79..65ae6d7 100644 --- a/adapters/postgres/identity_repository.go +++ b/adapters/postgres/identity_repository.go @@ -26,3 +26,39 @@ func (r *IdentityRepository) LoadBinding(ctx context.Context, actor identity.Act } return binding, nil } + +func (r *IdentityRepository) LoadCreatorAccount(ctx context.Context, username string) (identity.CreatorAccount, error) { + var account identity.CreatorAccount + err := r.pool.QueryRow(ctx, `SELECT username, actor_id, principal_id, password_hash, revision FROM identity.creator_accounts WHERE username=$1`, username). + Scan(&account.Username, &account.ActorID, &account.PrincipalID, &account.PasswordHash, &account.Revision) + if err != nil { + return identity.CreatorAccount{}, fmt.Errorf("load creator account: %w", err) + } + return account, nil +} + +func (r *IdentityRepository) LoadCreatorAccountByBinding(ctx context.Context, actor identity.ActorID, principal identity.PrincipalID) (identity.CreatorAccount, error) { + var account identity.CreatorAccount + err := r.pool.QueryRow(ctx, `SELECT username, actor_id, principal_id, password_hash, revision FROM identity.creator_accounts WHERE actor_id=$1 AND principal_id=$2`, actor, principal). + Scan(&account.Username, &account.ActorID, &account.PrincipalID, &account.PasswordHash, &account.Revision) + if err != nil { + return identity.CreatorAccount{}, fmt.Errorf("load creator account by binding: %w", err) + } + return account, nil +} + +func (r *IdentityRepository) CreateCreatorAccount(ctx context.Context, account identity.CreatorAccount) error { + _, err := r.pool.Exec(ctx, `INSERT INTO identity.creator_accounts (username, actor_id, principal_id, password_hash) VALUES ($1,$2,$3,$4) ON CONFLICT (username) DO NOTHING`, account.Username, account.ActorID, account.PrincipalID, account.PasswordHash) + return err +} + +func (r *IdentityRepository) UpdateCreatorPassword(ctx context.Context, actor identity.ActorID, principal identity.PrincipalID, revision int64, passwordHash string) error { + result, err := r.pool.Exec(ctx, `UPDATE identity.creator_accounts SET password_hash=$1, revision=revision+1, password_changed_at=CURRENT_TIMESTAMP WHERE actor_id=$2 AND principal_id=$3 AND revision=$4`, passwordHash, actor, principal, revision) + if err != nil { + return fmt.Errorf("update creator password: %w", err) + } + if result.RowsAffected() != 1 { + return fmt.Errorf("update creator password: revision conflict") + } + return nil +} diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index be1a367..dae9bab 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -60,12 +60,11 @@ func main() { if err != nil { log.Fatal(err) } - creatorPassword, err := readRequiredSecretFile("CREATOR_PASSWORD", creatorPasswordPath) - if err != nil { - log.Fatal(err) - } sessionKey := []byte(sessionKeyValue) creatorUsername := os.Getenv("CREATOR_USERNAME") + if creatorUsername == "" { + log.Fatal("CREATOR_USERNAME is required") + } 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) @@ -99,6 +98,9 @@ func main() { log.Fatal(err) } defer identityRepository.Close() + if err := bootstrapCreatorAccount(context.Background(), identityRepository, creatorUsername, creatorPasswordPath); err != nil { + log.Fatal(err) + } interactionPassword, err := readCredentialRefSecret("INTERACTION_POSTGRES_PASSWORD", "INTERACTION_POSTGRES_CREDENTIAL_REF", interactionDBPasswordPath) if err != nil { log.Fatal(err) @@ -185,7 +187,39 @@ func main() { } w.WriteHeader(http.StatusNoContent) }) - mux.HandleFunc("POST /v1/creator/sessions", creatorSessionHandler(bootstrapToken, creatorUsername, creatorPassword, sessionKey, time.Now)) + mux.HandleFunc("POST /v1/creator/sessions", creatorDatabaseSessionHandler(bootstrapToken, creatorUsername, identityRepository, sessionKey, time.Now)) + mux.HandleFunc("POST /v1/creator/account/password", func(w http.ResponseWriter, r *http.Request) { + session, ok := authenticateCreator(w, r) + if !ok { + return + } + var request struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` + } + decoder := json.NewDecoder(io.LimitReader(r.Body, maxCreatorCredentialsBytes+1)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil || request.CurrentPassword == "" || request.NewPassword == "" { + http.Error(w, "invalid password change request", http.StatusBadRequest) + return + } + account, err := identityRepository.LoadCreatorAccountByBinding(r.Context(), session.ActorID, session.PrincipalID) + if err != nil || !identitydomain.VerifyCreatorPassword(account.PasswordHash, request.CurrentPassword) { + http.Error(w, "current password denied", http.StatusUnauthorized) + return + } + newHash, err := identitydomain.HashCreatorPassword(request.NewPassword) + if err != nil { + http.Error(w, "invalid new password", http.StatusBadRequest) + return + } + if err := identityRepository.UpdateCreatorPassword(r.Context(), session.ActorID, session.PrincipalID, account.Revision, newHash); err != nil { + http.Error(w, "password change conflict", http.StatusConflict) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"status": "changed"}) + }) mux.HandleFunc("GET /v1/creator/sessions/current", func(w http.ResponseWriter, r *http.Request) { session, ok := authenticateCreator(w, r) if !ok { @@ -1712,6 +1746,88 @@ func creatorSessionHandler(bootstrapToken, creatorUsername, creatorPassword stri } } +func creatorDatabaseSessionHandler(bootstrapToken, creatorUsername string, repository *postgres.IdentityRepository, sessionKey []byte, now func() time.Time) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if identitydomain.BootstrapMatches(bootstrapToken, bearerToken(r)) { + token, session, err := identitydomain.IssueCreatorSession(sessionKey, now(), 8*time.Hour) + if err != nil { + http.Error(w, "creator session unavailable", http.StatusServiceUnavailable) + return + } + writeCreatorSession(w, token, session) + return + } + 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 + } + account, err := repository.LoadCreatorAccount(r.Context(), credentials.Username) + if err != nil || !identitydomain.VerifyCreatorPassword(account.PasswordHash, credentials.Password) { + http.Error(w, "creator authentication denied", http.StatusUnauthorized) + return + } + binding, err := repository.LoadBinding(r.Context(), account.ActorID, account.PrincipalID) + if err != nil || !binding.Active { + http.Error(w, "creator authentication denied", http.StatusUnauthorized) + return + } + token, session, err := identitydomain.IssueCreatorSessionForBinding(sessionKey, account.ActorID, account.PrincipalID, now(), 8*time.Hour) + if err != nil { + http.Error(w, "creator session unavailable", http.StatusServiceUnavailable) + return + } + writeCreatorSession(w, token, session) + } +} + +func writeCreatorSession(w http.ResponseWriter, token string, session identity.CreatorSession) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(struct { + Token string + Session identity.CreatorSession + }{token, session}) +} + +func bootstrapCreatorAccount(ctx context.Context, repository *postgres.IdentityRepository, username, passwordPath string) error { + if account, err := repository.LoadCreatorAccount(ctx, username); err == nil { + if account.ActorID == "" || account.PrincipalID == "" { + return errors.New("creator account binding is incomplete") + } + return nil + } else if !errors.Is(unwrapIdentityRepositoryError(err), pgx.ErrNoRows) { + return err + } + password, err := readRequiredSecretFile("CREATOR_PASSWORD", passwordPath) + if err != nil { + return fmt.Errorf("creator account is missing and bootstrap password is unavailable: %w", err) + } + hash, err := identitydomain.HashCreatorPassword(password) + if err != nil { + return fmt.Errorf("hash creator bootstrap password: %w", err) + } + if err := repository.CreateCreatorAccount(ctx, identity.CreatorAccount{Username: username, ActorID: "domovoy-creator-actor", PrincipalID: "domovoy-creator-principal", PasswordHash: hash}); err != nil { + return fmt.Errorf("create creator account: %w", err) + } + return nil +} + +func unwrapIdentityRepositoryError(err error) error { + for err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return pgx.ErrNoRows + } + unwrapped := errors.Unwrap(err) + if unwrapped == nil { + break + } + err = unwrapped + } + return err +} + func bearerToken(r *http.Request) string { value := r.Header.Get("Authorization") if !strings.HasPrefix(value, "Bearer ") { diff --git a/cmd/creator-web/server.go b/cmd/creator-web/server.go index fb633b6..cdd975c 100644 --- a/cmd/creator-web/server.go +++ b/cmd/creator-web/server.go @@ -841,6 +841,8 @@ func creatorRoute(method, path string) (routeTarget, string, bool) { return targetCore, "/v1/creator/sessions", true case method == http.MethodGet && path == "/api/session/current": return targetCore, "/v1/creator/sessions/current", true + case method == http.MethodPost && path == "/api/account/password": + return targetCore, "/v1/creator/account/password", true case method == http.MethodGet && path == "/api/focus": return targetCore, "/v1/creator/focus", true case method == http.MethodGet && strings.HasPrefix(path, "/api/tasks/"): diff --git a/cmd/creator-web/static/assets/chat.css b/cmd/creator-web/static/assets/chat.css index 063b21d..809f2e5 100644 --- a/cmd/creator-web/static/assets/chat.css +++ b/cmd/creator-web/static/assets/chat.css @@ -205,6 +205,20 @@ textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 13px; .technical-meta { display: none !important; } .context-backdrop { position: fixed; z-index: 8; inset: 60px 0 0; border: 0; background: rgba(4,9,15,.58); } .context-drawer { position: fixed; z-index: 9; top: 60px; right: 0; bottom: 0; width: min(360px, 92vw); padding: 24px 20px; border-left: 1px solid var(--line-strong); box-shadow: -20px 0 50px rgba(0,0,0,.28); transform: translateX(105%); transition: transform .2s ease; } +.password-dialog { width: min(420px, calc(100vw - 32px)); padding: 0; color: var(--ink); background: var(--panel); border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: 0 24px 80px rgba(0,0,0,.45); } +.password-dialog::backdrop { background: rgba(3,8,14,.68); } +.password-dialog form { padding: 22px; } +.dialog-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; } +.dialog-heading h2 { margin: 0; font-size: 20px; } +.dialog-heading button { width: 30px; height: 30px; border: 1px solid var(--line); background: transparent; } +.password-dialog label { display: block; margin: 14px 0 7px; color: var(--muted); font: 11px/1 var(--mono); } +.password-dialog input { width: 100%; height: 42px; padding: 0 12px; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: var(--panel-raised); } +.password-dialog input:focus { outline: 2px solid var(--blue); outline-offset: 1px; } +.dialog-actions { display: flex; justify-content: space-between; gap: 10px; margin-top: 20px; } +.dialog-actions button { min-height: 38px; padding: 0 14px; border: 1px solid var(--line); background: transparent; } +.dialog-actions button:first-child { border-color: var(--lime); color: var(--lime-ink); background: var(--lime); } +.dialog-actions button:disabled { opacity: .55; cursor: wait; } +#password-status { min-height: 18px; margin: 12px 0 0; color: var(--muted); font-size: 12px; } .context-drawer.drawer-open { transform: translateX(0); } .drawer-heading { display: flex; align-items: start; justify-content: space-between; gap: 12px; } .drawer-heading h2 { margin-top: 9px; font-size: 26px; } diff --git a/cmd/creator-web/static/assets/chat.js b/cmd/creator-web/static/assets/chat.js index 0189475..e026f36 100644 --- a/cmd/creator-web/static/assets/chat.js +++ b/cmd/creator-web/static/assets/chat.js @@ -9,6 +9,11 @@ const sendButton = document.querySelector("#send"); const sendLabel = document.querySelector("#send-label"); const messageCount = document.querySelector("#message-count"); const logoutButton = document.querySelector("#logout"); +const passwordDialog = document.querySelector("#password-dialog"); +const passwordForm = document.querySelector("#password-form"); +const passwordClose = document.querySelector("#password-close"); +const passwordLogout = document.querySelector("#password-logout"); +const passwordStatus = document.querySelector("#password-status"); const activeFocus = document.querySelector("#active-focus"); const rightFocus = document.querySelector("#right-focus"); const headerFocus = document.querySelector("#header-focus"); @@ -519,8 +524,23 @@ if (aiDataForm) { } if (aiDataInput) aiDataInput.addEventListener("input", () => showAIDataError()); -logoutButton.addEventListener("click", () => { - expireSession(); +logoutButton.addEventListener("click", () => passwordDialog?.showModal()); +passwordClose?.addEventListener("click", () => passwordDialog?.close()); +passwordLogout?.addEventListener("click", () => { passwordDialog?.close(); expireSession(); }); +passwordForm?.addEventListener("submit", async (event) => { + event.preventDefault(); + passwordStatus.textContent = "Сохраняю…"; + const submit = document.querySelector("#password-submit"); + submit.disabled = true; + try { + await api("/api/account/password", {method: "POST", body: JSON.stringify({current_password: document.querySelector("#current-password").value, new_password: document.querySelector("#new-password").value})}); + passwordForm.reset(); + passwordStatus.textContent = "Пароль изменён без перезапуска."; + } catch (error) { + passwordStatus.textContent = error?.status === 401 ? "Текущий пароль не принят." : "Не удалось изменить пароль."; + } finally { + submit.disabled = false; + } }); if (token) { diff --git a/cmd/creator-web/static/chat.html b/cmd/creator-web/static/chat.html index 0fca577..db407f2 100644 --- a/cmd/creator-web/static/chat.html +++ b/cmd/creator-web/static/chat.html @@ -112,6 +112,17 @@ + +
+

Сменить пароль

+ + + + +

+
+
+
diff --git a/contracts/identity/session.go b/contracts/identity/session.go index 4256088..68a1416 100644 --- a/contracts/identity/session.go +++ b/contracts/identity/session.go @@ -5,6 +5,14 @@ type CreatorCredentials struct { Password string `json:"password"` } +type CreatorAccount struct { + Username string + ActorID ActorID + PrincipalID PrincipalID + PasswordHash string + Revision int64 +} + type CreatorSession struct { ActorID ActorID PrincipalID PrincipalID diff --git a/database/migrations/052_identity_creator_accounts.sql b/database/migrations/052_identity_creator_accounts.sql new file mode 100644 index 0000000..5994064 --- /dev/null +++ b/database/migrations/052_identity_creator_accounts.sql @@ -0,0 +1,20 @@ +BEGIN; +SET ROLE identity_role; +CREATE TABLE IF NOT EXISTS identity.creator_accounts ( + username TEXT PRIMARY KEY, + actor_id TEXT NOT NULL, + principal_id TEXT NOT NULL, + password_hash TEXT NOT NULL, + revision BIGINT NOT NULL DEFAULT 1, + password_changed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (username <> ''), + CHECK (actor_id <> ''), + CHECK (principal_id <> ''), + CHECK (password_hash <> ''), + CHECK (revision > 0), + UNIQUE (actor_id, principal_id) +); +CREATE INDEX IF NOT EXISTS creator_accounts_binding_idx ON identity.creator_accounts (actor_id, principal_id); +REVOKE ALL ON identity.creator_accounts FROM PUBLIC; +RESET ROLE; +COMMIT; diff --git a/modules/identity/domain/password.go b/modules/identity/domain/password.go new file mode 100644 index 0000000..5529b16 --- /dev/null +++ b/modules/identity/domain/password.go @@ -0,0 +1,70 @@ +package domain + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "errors" + "strconv" + "strings" +) + +const creatorPasswordIterations = 120000 + +var ErrInvalidCreatorPassword = errors.New("invalid creator password") + +func HashCreatorPassword(password string) (string, error) { + if len(password) < 6 || len(password) > 4096 { + return "", ErrInvalidCreatorPassword + } + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return "", err + } + derived := pbkdf2SHA256([]byte(password), salt, creatorPasswordIterations, 32) + return "pbkdf2-sha256$" + strconv.Itoa(creatorPasswordIterations) + "$" + base64.RawStdEncoding.EncodeToString(salt) + "$" + base64.RawStdEncoding.EncodeToString(derived), nil +} + +func VerifyCreatorPassword(encoded, password string) bool { + parts := strings.Split(encoded, "$") + if len(parts) != 4 || parts[0] != "pbkdf2-sha256" || password == "" { + return false + } + iterations, err := strconv.Atoi(parts[1]) + if err != nil || iterations < 100000 || iterations > 1000000 { + return false + } + salt, err := base64.RawStdEncoding.DecodeString(parts[2]) + if err != nil || len(salt) < 16 { + return false + } + expected, err := base64.RawStdEncoding.DecodeString(parts[3]) + if err != nil || len(expected) != 32 { + return false + } + actual := pbkdf2SHA256([]byte(password), salt, iterations, len(expected)) + return subtle.ConstantTimeCompare(actual, expected) == 1 +} + +func pbkdf2SHA256(password, salt []byte, iterations, keyLength int) []byte { + key := make([]byte, 0, keyLength) + for block := 1; len(key) < keyLength; block++ { + mac := hmac.New(sha256.New, password) + mac.Write(salt) + mac.Write([]byte{byte(block >> 24), byte(block >> 16), byte(block >> 8), byte(block)}) + u := mac.Sum(nil) + t := append([]byte(nil), u...) + for i := 1; i < iterations; i++ { + mac = hmac.New(sha256.New, password) + mac.Write(u) + u = mac.Sum(nil) + for j := range t { + t[j] ^= u[j] + } + } + key = append(key, t...) + } + return key[:keyLength] +} diff --git a/modules/identity/domain/password_test.go b/modules/identity/domain/password_test.go new file mode 100644 index 0000000..15be8be --- /dev/null +++ b/modules/identity/domain/password_test.go @@ -0,0 +1,36 @@ +package domain + +import "testing" + +func TestCreatorPasswordHashRoundTrip(t *testing.T) { + hash, err := HashCreatorPassword("a-long-enough-test-password") + if err != nil { + t.Fatal(err) + } + if !VerifyCreatorPassword(hash, "a-long-enough-test-password") { + t.Fatal("hashed password was not accepted") + } + if VerifyCreatorPassword(hash, "wrong-password") { + t.Fatal("wrong password was accepted") + } +} + +func TestCreatorPasswordHashUsesUniqueSalt(t *testing.T) { + first, err := HashCreatorPassword("a-long-enough-test-password") + if err != nil { + t.Fatal(err) + } + second, err := HashCreatorPassword("a-long-enough-test-password") + if err != nil { + t.Fatal(err) + } + if first == second { + t.Fatal("password hashes reused a salt") + } +} + +func TestCreatorPasswordHashRejectsShortPassword(t *testing.T) { + if _, err := HashCreatorPassword("short"); err == nil { + t.Fatal("short password was accepted") + } +} diff --git a/modules/identity/domain/session.go b/modules/identity/domain/session.go index 77ee8f3..3fa7429 100644 --- a/modules/identity/domain/session.go +++ b/modules/identity/domain/session.go @@ -16,10 +16,14 @@ import ( var ErrInvalidCreatorSession = errors.New("invalid creator session") func IssueCreatorSession(secret []byte, now time.Time, ttl time.Duration) (string, identity.CreatorSession, error) { - if len(secret) < 32 || ttl <= 0 { + return IssueCreatorSessionForBinding(secret, identity.ActorID("domovoy-creator-actor"), identity.PrincipalID("domovoy-creator-principal"), now, ttl) +} + +func IssueCreatorSessionForBinding(secret []byte, actorID identity.ActorID, principalID identity.PrincipalID, now time.Time, ttl time.Duration) (string, identity.CreatorSession, error) { + if len(secret) < 32 || actorID == "" || principalID == "" || ttl <= 0 { return "", identity.CreatorSession{}, ErrInvalidCreatorSession } - session := identity.CreatorSession{ActorID: "domovoy-creator-actor", PrincipalID: "domovoy-creator-principal", ExpiresAtUnix: now.Add(ttl).Unix()} + session := identity.CreatorSession{ActorID: actorID, PrincipalID: principalID, ExpiresAtUnix: now.Add(ttl).Unix()} payload, err := json.Marshal(session) if err != nil { return "", identity.CreatorSession{}, err @@ -38,7 +42,7 @@ func ValidateCreatorSession(secret []byte, token string, now time.Time) (identit return identity.CreatorSession{}, ErrInvalidCreatorSession } var session identity.CreatorSession - if json.Unmarshal(payload, &session) != nil || session.ActorID != "domovoy-creator-actor" || session.PrincipalID != "domovoy-creator-principal" || now.Unix() >= session.ExpiresAtUnix { + if json.Unmarshal(payload, &session) != nil || session.ActorID == "" || session.PrincipalID == "" || now.Unix() >= session.ExpiresAtUnix { return identity.CreatorSession{}, ErrInvalidCreatorSession } return session, nil