Changes
11 changed files (+230/-9)
-
-
@@ -35,8 +35,16 @@ Logger: logger,}, nil } func (core *Core) Init(adminCreationPassword string) error { return core.generateAdminCreationPassword(adminCreationPassword) func (core *Core) Init(adminCreationPassword string, overwriteJwtSecret bool) error { if err := core.generateAdminCreationPassword(adminCreationPassword); err != nil { return err } if err := core.setupLoginJwtSecret(overwriteJwtSecret); err != nil { return err } return nil } func (core *Core) generateAdminCreationPassword(givenPassword string) error {
-
@@ -100,3 +108,44 @@ }return nil } func (core *Core) setupLoginJwtSecret(overwrite bool) error { tx, err := core.DB.Begin() if err != nil { return err } defer tx.Rollback() p, err := projection.GetLoginJwtSecret(tx) if err != nil { return err } if p.HasSnapshot() { if overwrite { core.Logger.Warn( "Overwriting existing JWT secret. Tokens generated before won't be available anymore.", ) } else { core.Logger.Debug("Found JWT secret, skipping newly generating.") return nil } } err = event.AppendEvents(tx, []*eventsV1.Event{ workspaceEvent.ConfigureRandomLoginJwtSecret(), }) if err != nil { return err } if err := tx.Commit(); err != nil { return err } core.Logger.Info( "Generated a new secret for signing JWT.", ) return nil }
-
-
-
@@ -0,0 +1,92 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package projection import ( "crypto/rand" "database/sql" "pocka.jp/x/yamori/backend/core/event" "pocka.jp/x/yamori/backend/core/snapshot" workspaceEventsv1 "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1" workspace "pocka.jp/x/yamori/proto/go/backend/projections/workspace/v1" ) type LoginJwtSecret struct { hasSnapshot bool eventSeq *uint64 Projection []byte } func NewLoginJwtSecret() *LoginJwtSecret { secret := make([]byte, 56) rand.Read(secret) return &LoginJwtSecret{ hasSnapshot: false, eventSeq: nil, Projection: secret, } } func GetLoginJwtSecret(tx *sql.Tx) (*LoginJwtSecret, error) { payload, seq, err := snapshot.GetLatest[workspace.LoginJwtSecret](tx) if err != nil { return nil, err } if payload == nil { return NewLoginJwtSecret(), nil } return &LoginJwtSecret{ hasSnapshot: true, eventSeq: &seq, Projection: payload.Secret, }, nil } func (p *LoginJwtSecret) HasSnapshot() bool { return p.hasSnapshot } func (p *LoginJwtSecret) EventSeq() *uint64 { return p.eventSeq } func (p *LoginJwtSecret) Update(events []event.Event) { if len(events) == 0 || p.Projection == nil { return } for i := range events { if p.eventSeq != nil && events[i].Seq <= *p.eventSeq { continue } ev := events[i].Payload.GetWorkspaceEvent() if ev == nil { continue } p.hasSnapshot = false switch v := ev.Event.(type) { case *workspaceEventsv1.Event_LoginJwtSecretConfigured: p.Projection = v.LoginJwtSecretConfigured.Secret } p.eventSeq = &events[i].Seq } } func (p *LoginJwtSecret) SaveSnapshot(tx *sql.Tx) error { if p.hasSnapshot || p.eventSeq == nil { return nil } return snapshot.Save(tx, &workspace.LoginJwtSecret{ Secret: p.Projection, }, *p.eventSeq) }
-
-
-
@@ -4,6 +4,8 @@package workspace import ( "crypto/rand" "google.golang.org/protobuf/proto" "pocka.jp/x/yamori/backend/crypto"
-
@@ -88,3 +90,20 @@ },}, } } func ConfigureRandomLoginJwtSecret() *eventV1.Event { secret := make([]byte, 48) rand.Read(secret) return &eventV1.Event{ Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceEvent.Event{ Event: &workspaceEvent.Event_LoginJwtSecretConfigured{ LoginJwtSecretConfigured: &workspaceEvent.LoginJwtSecretConfigured{ Secret: secret, }, }, }, }, } }
-
-
-
@@ -23,6 +23,7 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirectgithub.com/charmbracelet/x/term v0.2.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hack-pad/safejs v0.1.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
-
-
-
@@ -24,6 +24,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-
-
-
@@ -72,6 +72,7 @@var cli struct { Help bool `short:"?" help:"Show this message to stdout and exit."` AdminCreationPassword string `help:"Password for creating a user when there is no admin user in workspace"` OverwriteJwtSecret bool `help:"Generate a new JWT signing secret even if there is a configured one"` Verbose bool `help:"Display debug logs?"` Log string `help:"Log format." enum:"text,jsonl" default:"text"` Port uint `short:"p" help:"TCP port to bind." default:"8765"`
-
@@ -116,7 +117,7 @@ logger.Error("Failed to create core instance", "error", err)os.Exit(3) } if err := core.Init(cli.AdminCreationPassword); err != nil { if err := core.Init(cli.AdminCreationPassword, cli.OverwriteJwtSecret); err != nil { logger.Error("Failed to prepare application core", "error", err) os.Exit(4) }
-
-
-
@@ -6,16 +6,21 @@import ( "bytes" "context" "net/http" "connectrpc.com/connect" "github.com/golang-jwt/jwt/v5" "google.golang.org/protobuf/proto" errorV1 "pocka.jp/x/yamori/proto/go/error/v1" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" "pocka.jp/x/yamori/backend/core/event" "pocka.jp/x/yamori/backend/core/projection" "pocka.jp/x/yamori/backend/crypto" errorV1 "pocka.jp/x/yamori/proto/go/error/v1" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" ) const cookieName = "yamori-session-id" func (s *Service) Login( ctx context.Context,
-
@@ -54,7 +59,12 @@ if err != nil {return nil, err } if err := event.UpdateProjections(tx, users); err != nil { secret, err := projection.GetLoginJwtSecret(tx) if err != nil { return nil, err } if err := event.UpdateProjections(tx, users, secret); err != nil { return nil, err }
-
@@ -80,8 +90,6 @@ },}), nil } // TODO: JWT をSame-Origin Cookie に焼き付ける res := connect.NewResponse(&workspaceV2.LoginResponse{ Result: &workspaceV2.LoginResponse_Ok{ Ok: &workspaceV2.User{
-
@@ -97,6 +105,29 @@ IsAdmin: u.IsAdmin,}, }, }) token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "sub": u.GetId(), }) jwt, err := token.SignedString(secret.Projection) if err != nil { return nil, err } cookie := http.Cookie{ Name: cookieName, Value: jwt, SameSite: http.SameSiteStrictMode, Secure: true, HttpOnly: true, } res.Header().Add("Set-Cookie", cookie.String()) if err := tx.Commit(); err != nil { return nil, err } return res, nil }
-
-
-
@@ -34,7 +34,7 @@ logger.Error("Failed to create core instance", "error", err)os.Exit(3) } if err := core.Init(""); err != nil { if err := core.Init("", false); err != nil { logger.Error("Failed to prepare application core", "error", err) os.Exit(4) }
-
-
-
@@ -9,6 +9,7 @@ import "yamori/backend/events/workspace/v1/admin_access_granted.proto";import "yamori/backend/events/workspace/v1/admin_access_revoked.proto"; import "yamori/backend/events/workspace/v1/admin_creation_password_expired.proto"; import "yamori/backend/events/workspace/v1/admin_creation_password_generated.proto"; import "yamori/backend/events/workspace/v1/login_jwt_secret_configured.proto"; import "yamori/backend/events/workspace/v1/password_login_configured.proto"; import "yamori/backend/events/workspace/v1/user_created.proto";
-
@@ -22,5 +23,6 @@ AdminAccessRevoked admin_access_revoked = 3;PasswordLoginConfigured password_login_configured = 4; AdminCreationPasswordGenerated admin_creation_password_generated = 5; AdminCreationPasswordExpired admin_creation_password_expired = 6; LoginJwtSecretConfigured login_jwt_secret_configured = 7; } }
-
-
-
@@ -0,0 +1,12 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.events.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1"; message LoginJwtSecretConfigured { bytes secret = 1; }
-
-
-
@@ -0,0 +1,12 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.projections.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/backend/projections/workspace/v1"; message LoginJwtSecret { bytes secret = 1; }
-