Changes
11 changed files (+404/-11)
-
events/list.go (new)
-
@@ -0,0 +1,88 @@// Copyright 2025 Shota FUJI // // This source code is licensed under Zero-Clause BSD License. // You can find a copy of the Zero-Clause BSD License at LICENSES/0BSD.txt // You may also obtain a copy of the Zero-Clause BSD License at // <https://opensource.org/license/0bsd> // // SPDX-License-Identifier: 0BSD package events import ( "context" "database/sql" "fmt" "google.golang.org/protobuf/proto" "pocka.jp/x/event_sourcing_user_management_poc/gen/event" ) func List(db *sql.DB) ([]proto.Message, error) { ctx := context.Background() tx, err := db.BeginTx(ctx, nil) if err != nil { return nil, fmt.Errorf("Failed to begin transaction for listing user_events: %s", err) } defer tx.Rollback() var rowCount int if err := tx.QueryRow("SELECT count(*) FROM user_events").Scan(&rowCount); err != nil { return nil, fmt.Errorf("Failed to count user_events: %s", err) } if rowCount == 0 { return []proto.Message{}, nil } rows, err := tx.Query("SELECT event_name, payload FROM user_events ORDER BY seq ASC") if err != nil { return nil, fmt.Errorf("Failed to SELECT user_events: %s", err) } events := make([]proto.Message, rowCount) for i := range events { if !rows.Next() { return nil, fmt.Errorf("Number of events is less than rowCount") } var eventName string var payload []byte if err := rows.Scan(&eventName, &payload); err != nil { return nil, fmt.Errorf("Failed to scan user event: %s", err) } switch eventName { case "InitialAdminCreationPasswordCreated": var event event.InitialAdminCreationPasswordCreated if err := proto.Unmarshal(payload, &event); err != nil { return nil, fmt.Errorf("Illegal InitialAdminCreationPasswordCreated event: %s", err) } events[i] = &event case "UserCreated": var event event.UserCreated if err := proto.Unmarshal(payload, &event); err != nil { return nil, fmt.Errorf("Illegal UserCreated event: %s", err) } events[i] = &event case "PasswordLoginConfigured": var event event.PasswordLoginConfigured if err := proto.Unmarshal(payload, &event); err != nil { return nil, fmt.Errorf("Illegal PasswordLoginConfigured event: %s", err) } events[i] = &event case "RoleAssigned": var event event.RoleAssigned if err := proto.Unmarshal(payload, &event); err != nil { return nil, fmt.Errorf("Illegal RoleAssigned event: %s", err) } events[i] = &event default: return nil, fmt.Errorf("Unknown event in user_events: name=%s", eventName) } } return events, nil }
-
-
-
@@ -0,0 +1,42 @@// Copyright 2025 Shota FUJI // // This source code is licensed under Zero-Clause BSD License. // You can find a copy of the Zero-Clause BSD License at LICENSES/0BSD.txt // You may also obtain a copy of the Zero-Clause BSD License at // <https://opensource.org/license/0bsd> // // SPDX-License-Identifier: 0BSD package initial_admin_creation_password import ( "google.golang.org/protobuf/proto" "pocka.jp/x/event_sourcing_user_management_poc/gen/event" "pocka.jp/x/event_sourcing_user_management_poc/gen/model" ) type initialAdminCreationPassword struct { Hash []byte Salt []byte } func GetFromUserEvents(events []proto.Message) *initialAdminCreationPassword { var password *initialAdminCreationPassword for _, e := range events { switch v := e.(type) { case *event.InitialAdminCreationPasswordCreated: password = &initialAdminCreationPassword{ Hash: v.PasswordHash, Salt: v.Salt, } case *event.RoleAssigned: if *v.Role == model.Role_ROLE_ADMIN { password = nil } } } return password }
-
-
-
@@ -0,0 +1,75 @@// Copyright 2025 Shota FUJI // // This source code is licensed under Zero-Clause BSD License. // You can find a copy of the Zero-Clause BSD License at LICENSES/0BSD.txt // You may also obtain a copy of the Zero-Clause BSD License at // <https://opensource.org/license/0bsd> // // SPDX-License-Identifier: 0BSD package initial_admin_creation_password import ( "bytes" "testing" "google.golang.org/protobuf/proto" "pocka.jp/x/event_sourcing_user_management_poc/gen/event" "pocka.jp/x/event_sourcing_user_management_poc/gen/model" ) func TestReturnsNonNil(t *testing.T) { password := GetFromUserEvents([]proto.Message{ &event.InitialAdminCreationPasswordCreated{ PasswordHash: []byte{0, 1, 2}, Salt: []byte{3, 4, 5}, }, }) if password == nil { t.Error("Expected found password, got nil") } if !bytes.Equal([]byte{0, 1, 2}, password.Hash) { t.Errorf("Hash does not match to [0,1,2]: %v", password.Hash) } if !bytes.Equal([]byte{3, 4, 5}, password.Salt) { t.Errorf("Salt does not match to [3,4,5]: %v", password.Salt) } } func TestAdminCreationExpiresOne(t *testing.T) { password := GetFromUserEvents([]proto.Message{ &event.InitialAdminCreationPasswordCreated{ PasswordHash: []byte{0, 1, 2}, Salt: []byte{3, 4, 5}, }, &event.RoleAssigned{ UserId: proto.String(""), Role: model.Role_ROLE_ADMIN.Enum(), }, }) if password != nil { t.Errorf("Expected nil, got %v", password) } } func TestNonAdminCreationShouldNotExpiresOne(t *testing.T) { password := GetFromUserEvents([]proto.Message{ &event.InitialAdminCreationPasswordCreated{ PasswordHash: []byte{0, 1, 2}, Salt: []byte{3, 4, 5}, }, &event.RoleAssigned{ UserId: proto.String(""), Role: model.Role_ROLE_EDITOR.Enum(), }, }) if password == nil { t.Errorf("Expected non-nil, got nil") } }
-
-
-
@@ -0,0 +1,10 @@// Copyright 2025 Shota FUJI // // This source code is licensed under Zero-Clause BSD License. // You can find a copy of the Zero-Clause BSD License at LICENSES/0BSD.txt // You may also obtain a copy of the Zero-Clause BSD License at // <https://opensource.org/license/0bsd> // // SPDX-License-Identifier: 0BSD package projections
-
-
-
@@ -0,0 +1,38 @@<!DOCTYPE html> <!-- Copyright 2025 Shota FUJI This source code is licensed under Zero-Clause BSD License. You can find a copy of the Zero-Clause BSD License at LICENSES/0BSD.txt You may also obtain a copy of the Zero-Clause BSD License at <https://opensource.org/license/0bsd> SPDX-License-Identifier: 0BSD --> <html lang="en-US"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Create initial admin</title> </head> <body> <main> <h1>Create an administrator user</h1> <form action="/initial-admin" method="POST"> <label for="username">User name</label> <input id="username" name="username" required minlength="1" /> <label for="email">Email</label> <input id="email" name="email" type="email" required /> <label for="password">Password</label> <input id="password" name="password" type="password" required minlength="8" /> <label for="init_password">Initial user password</label> <input id="init_password" name="init_password" type="password" required /> <button>Create</button> </form> </main> </body> </html>
-
-
routes/routes.go (new)
-
@@ -0,0 +1,128 @@// Copyright 2025 Shota FUJI // // This source code is licensed under Zero-Clause BSD License. // You can find a copy of the Zero-Clause BSD License at LICENSES/0BSD.txt // You may also obtain a copy of the Zero-Clause BSD License at // <https://opensource.org/license/0bsd> // // SPDX-License-Identifier: 0BSD package routes import ( "bytes" "database/sql" _ "embed" "fmt" "net/http" "github.com/charmbracelet/log" "github.com/google/uuid" "google.golang.org/protobuf/proto" "pocka.jp/x/event_sourcing_user_management_poc/auth" "pocka.jp/x/event_sourcing_user_management_poc/events" "pocka.jp/x/event_sourcing_user_management_poc/gen/event" "pocka.jp/x/event_sourcing_user_management_poc/gen/model" "pocka.jp/x/event_sourcing_user_management_poc/projections/initial_admin_creation_password" ) //go:embed initial_admin_creation.html var initialAdminCreationHtml string func Handler(db *sql.DB, logger *log.Logger) http.Handler { mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { events, err := events.List(db) if err != nil { logger.Error(err) http.Error(w, "Server error: event loading failure", http.StatusInternalServerError) return } initialAdminPass := initial_admin_creation_password.GetFromUserEvents(events) if initialAdminPass != nil { fmt.Fprint(w, initialAdminCreationHtml) return } fmt.Fprintf(w, "TODO") }) mux.HandleFunc("/initial-admin", func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "Not found", http.StatusMethodNotAllowed) return } evs, err := events.List(db) if err != nil { logger.Error(err) http.Error(w, "Server error: event loading failure", http.StatusInternalServerError) return } initialAdminPass := initial_admin_creation_password.GetFromUserEvents(evs) if initialAdminPass == nil { logger.Debug("Found no active initial admin creation password at POST /initial-admin, redirecting") http.Redirect(w, r, "/", http.StatusSeeOther) return } r.ParseForm() username := r.PostForm.Get("username") email := r.PostForm.Get("email") password := r.PostForm.Get("password") initPassword := r.PostForm.Get("init_password") if username == "" || email == "" || password == "" || initPassword == "" { w.WriteHeader(http.StatusBadRequest) fmt.Fprint(w, initialAdminCreationHtml) return } initPwHash := auth.HashPassword(initPassword, initialAdminPass.Salt) if !bytes.Equal(initialAdminPass.Hash, initPwHash) { w.WriteHeader(http.StatusUnauthorized) fmt.Fprint(w, initialAdminCreationHtml) return } pwHash, salt := auth.HashPasswordWithRandomSalt(password) id := uuid.New().String() if err := events.Insert(db, []proto.Message{ &event.UserCreated{ Id: proto.String(id), DisplayName: proto.String(username), Email: proto.String(email), }, &event.PasswordLoginConfigured{ PasswordHash: pwHash, Salt: salt, }, &event.RoleAssigned{ UserId: proto.String(id), Role: model.Role.Enum(model.Role_ROLE_ADMIN), }, }); err != nil { logger.Error(err) w.WriteHeader(http.StatusInternalServerError) fmt.Fprint(w, initialAdminCreationHtml) return } // This project is PoC for event sourcing. UI and security is completely out-of-scope. http.SetCookie(w, &http.Cookie{ Name: "id", Value: id, }) http.Redirect(w, r, "/", http.StatusFound) }) return mux }
-
-
-
@@ -22,6 +22,7 @@ "github.com/charmbracelet/lipgloss""github.com/charmbracelet/log" _ "modernc.org/sqlite" "pocka.jp/x/event_sourcing_user_management_poc/routes" "pocka.jp/x/event_sourcing_user_management_poc/setups" )
-
@@ -124,6 +125,8 @@addr := fmt.Sprintf("%s:%d", *host, *port) logger.Infof("Starting HTTP server at http://%s", addr) http.Handle("/", routes.Handler(db, logger)) logger.Fatal(http.ListenAndServe(addr, nil)) }
-
-
-
@@ -16,6 +16,8 @@"github.com/google/uuid" "google.golang.org/protobuf/proto" "pocka.jp/x/event_sourcing_user_management_poc/auth" "pocka.jp/x/event_sourcing_user_management_poc/events" "pocka.jp/x/event_sourcing_user_management_poc/gen/event" "pocka.jp/x/event_sourcing_user_management_poc/gen/model" )
-
@@ -26,9 +28,9 @@ // CreateAlice returns an ID of the created user on success.func CreateAlice(db *sql.DB) (string, error) { id := uuid.New().String() passwordHash, salt := hashPassword("Alice's password") passwordHash, salt := auth.HashPasswordWithRandomSalt("Alice's password") if err := insertEvents(db, []proto.Message{ if err := events.Insert(db, []proto.Message{ &event.UserCreated{ Id: proto.String(id), DisplayName: proto.String("Alice"),
-
-
-
@@ -7,7 +7,7 @@ // <https://opensource.org/license/0bsd>// // SPDX-License-Identifier: 0BSD package setups package events import ( "context"
-
@@ -18,7 +18,7 @@"google.golang.org/protobuf/proto" ) func insertEvents(db *sql.DB, events []proto.Message) error { func Insert(db *sql.DB, events []proto.Message) error { ctx := context.Background() tx, err := db.BeginTx(ctx, nil)
-
-
-
@@ -7,7 +7,7 @@ // <https://opensource.org/license/0bsd>// // SPDX-License-Identifier: 0BSD package setups package auth import ( "crypto/rand"
-
@@ -15,14 +15,19 @@"golang.org/x/crypto/argon2" ) // hashPassword returns hash of the password and salt used for the hash. func hashPassword(password string) ([]byte, []byte) { // HashP func HashPassword(password string, salt []byte) []byte { // Parameters recommended in RFC (according to Go docs) return argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32) } // HashPasswordWithRandomSalt returns hash of the password and salt used for the hash. func HashPasswordWithRandomSalt(password string) ([]byte, []byte) { salt := make([]byte, 32) // rand.Read never returns an error. // https://pkg.go.dev/crypto/rand@go1.24.1#Read rand.Read(salt) // Parameters recommended in RFC (according to Go docs) return argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32), salt return HashPassword(password, salt), salt }
-
-
-
@@ -16,6 +16,8 @@ "fmt""google.golang.org/protobuf/proto" "pocka.jp/x/event_sourcing_user_management_poc/auth" "pocka.jp/x/event_sourcing_user_management_poc/events" "pocka.jp/x/event_sourcing_user_management_poc/gen/event" )
-
@@ -26,9 +28,9 @@ // inefficient in real-world use cases.func InitAdminCreationPassword(db *sql.DB) (string, error) { password := rand.Text() passwordHash, salt := hashPassword(password) passwordHash, salt := auth.HashPasswordWithRandomSalt(password) if err := insertEvents(db, []proto.Message{ if err := events.Insert(db, []proto.Message{ &event.InitialAdminCreationPasswordCreated{ PasswordHash: passwordHash, Salt: salt,
-