Changes
7 changed files (+343/-4)
-
-
@@ -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 users 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 passwordLogin struct { Hash []byte Salt []byte } type user struct { ID string DisplayName string Email string PasswordLogin *passwordLogin Role *model.Role } func ListFromUserEvents(events []proto.Message) []user { users := make(map[string]*user) for _, e := range events { switch v := e.(type) { case *event.UserCreated: users[*v.Id] = &user{ ID: *v.Id, DisplayName: *v.DisplayName, Email: *v.Email, } case *event.PasswordLoginConfigured: if v.UserId == nil { break } found := users[*v.UserId] if found != nil { found.PasswordLogin = &passwordLogin{ Hash: v.PasswordHash, Salt: v.Salt, } } case *event.RoleAssigned: if v.UserId == nil { break } found := users[*v.UserId] if found != nil { found.Role = v.Role } } } ret := make([]user, 0, len(users)) for _, u := range users { ret = append(ret, *u) } return ret }
-
-
-
@@ -0,0 +1,99 @@// 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 users 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 TestIdentityOnly(t *testing.T) { users := ListFromUserEvents([]proto.Message{ &event.UserCreated{ Id: proto.String("foo"), DisplayName: proto.String("Foo"), Email: proto.String("foo@example.com"), }, &event.RoleAssigned{ UserId: proto.String("bar"), Role: model.Role_ROLE_EDITOR.Enum(), }, }) if len(users) != 1 { t.Errorf("Expected 1 user, got %d", len(users)) } if users[0].ID != "foo" { t.Errorf("Expected ID \"foo\", got \"%s\"", users[0].ID) } if users[0].DisplayName != "Foo" { t.Errorf("Expected DisplayName \"Foo\", got \"%s\"", users[0].DisplayName) } if users[0].Email != "foo@example.com" { t.Errorf("Expected Email \"foo@example.com\", got \"%s\"", users[0].Email) } if users[0].Role != nil { t.Errorf("Expected Role to be nil, got %v", users[0].Role) } if users[0].PasswordLogin != nil { t.Errorf("Expected Role to be nil, got %v", users[0].Role) } } func TestWithRole(t *testing.T) { users := ListFromUserEvents([]proto.Message{ &event.UserCreated{ Id: proto.String("foo"), DisplayName: proto.String("Foo"), Email: proto.String("foo@example.com"), }, &event.RoleAssigned{ UserId: proto.String("foo"), Role: model.Role_ROLE_ADMIN.Enum(), }, }) if *users[0].Role != model.Role_ROLE_ADMIN { t.Errorf("Expected Role_ROLE_ADMIN, got %v", users[0].Role.String()) } } func TestWithPWLogin(t *testing.T) { users := ListFromUserEvents([]proto.Message{ &event.UserCreated{ Id: proto.String("foo"), DisplayName: proto.String("Foo"), Email: proto.String("foo@example.com"), }, &event.PasswordLoginConfigured{ UserId: proto.String("foo"), PasswordHash: []byte{0, 1, 2}, Salt: []byte{3, 4, 5}, }, }) if !bytes.Equal(users[0].PasswordLogin.Hash, []byte{0, 1, 2}) { t.Errorf("Expected [0,1,2], got %v", users[0].PasswordLogin.Hash) } if !bytes.Equal(users[0].PasswordLogin.Salt, []byte{3, 4, 5}) { t.Errorf("Expected [3,4,5], got %v", users[0].PasswordLogin.Salt) } }
-
-
-
@@ -0,0 +1,32 @@<!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>Logged In</title> </head> <body> <main> <h1>Logged in as {{ .DisplayName }}</h1> <p>Role: {{ .Role }}</p> <nav> <ul> <li> <a href="/logout">Logout</a> </li> </ul> </nav> </main> </body> </html>
-
-
routes/login.html (new)
-
@@ -0,0 +1,32 @@<!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>Login</title> </head> <body> <main> <h1>Login</h1> <form action="/login" method="POST"> <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" /> <button>Login</button> </form> </main> </body> </html>
-
-
-
@@ -14,7 +14,9 @@ "bytes""database/sql" _ "embed" "fmt" "html/template" "net/http" "time" "github.com/charmbracelet/log" "github.com/google/uuid"
-
@@ -25,13 +27,30 @@ "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" "pocka.jp/x/event_sourcing_user_management_poc/projections/users" ) //go:embed initial_admin_creation.html var initialAdminCreationHtml string func Handler(db *sql.DB, logger *log.Logger) http.Handler { //go:embed logged_in.html.tmpl var loggedInHTMLTmpl string //go:embed login.html var loginHTML string type loggedInAdminPipeline struct { DisplayName string Role string } func Handler(db *sql.DB, logger *log.Logger) (http.Handler, error) { mux := http.NewServeMux() loggedInAdminHtml, err := template.New("loggedInAdminHtml").Parse(loggedInHTMLTmpl) if err != nil { return nil, err } mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { events, err := events.List(db)
-
@@ -47,7 +66,29 @@ fmt.Fprint(w, initialAdminCreationHtml)return } fmt.Fprintf(w, "TODO") id, err := r.Cookie("id") if err != nil { w.WriteHeader(http.StatusUnauthorized) fmt.Fprint(w, loginHTML) return } users := users.ListFromUserEvents(events) for _, user := range users { // No real auth. No security. if user.ID == id.Value { loggedInAdminHtml.Execute(w, loggedInAdminPipeline{ DisplayName: user.DisplayName, Role: user.Role.String(), }) return } } w.WriteHeader(http.StatusUnauthorized) fmt.Fprint(w, loginHTML) return }) mux.HandleFunc("/initial-admin", func(w http.ResponseWriter, r *http.Request) {
-
@@ -101,6 +142,7 @@ DisplayName: proto.String(username),Email: proto.String(email), }, &event.PasswordLoginConfigured{ UserId: proto.String(id), PasswordHash: pwHash, Salt: salt, },
-
@@ -124,5 +166,58 @@http.Redirect(w, r, "/", http.StatusFound) }) return mux mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { r.ParseForm() events, err := events.List(db) if err != nil { logger.Error(err) http.Error(w, "Server error: event loading failure", http.StatusInternalServerError) return } users := users.ListFromUserEvents(events) email := r.PostForm.Get("email") password := r.PostForm.Get("password") if email == "" || password == "" { w.WriteHeader(http.StatusBadRequest) fmt.Fprint(w, loginHTML) return } for _, user := range users { // No real auth. No security. if user.Email == email && user.PasswordLogin != nil { hash := auth.HashPassword(password, user.PasswordLogin.Salt) if bytes.Equal(user.PasswordLogin.Hash, hash) { // This project is PoC for event sourcing. UI and security is completely out-of-scope. http.SetCookie(w, &http.Cookie{ Name: "id", Value: user.ID, }) http.Redirect(w, r, "/", http.StatusFound) return } } } w.WriteHeader(http.StatusUnauthorized) fmt.Fprint(w, loginHTML) return }) mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Name: "id", Value: "", Expires: time.Now(), }) http.Redirect(w, r, "/", http.StatusFound) }) return mux, nil }
-
-
-
@@ -126,7 +126,12 @@ addr := fmt.Sprintf("%s:%d", *host, *port)logger.Infof("Starting HTTP server at http://%s", addr) http.Handle("/", routes.Handler(db, logger)) handler, err := routes.Handler(db, logger) if err != nil { logger.Fatal(err) } http.Handle("/", handler) logger.Fatal(http.ListenAndServe(addr, nil)) }
-
-
-
@@ -37,6 +37,7 @@ DisplayName: proto.String("Alice"),Email: proto.String("alice@example.com"), }, &event.PasswordLoginConfigured{ UserId: proto.String(id), PasswordHash: passwordHash, Salt: salt, },
-