From c22621e8de2a52845c82e206b1b2cb9909e239ef Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 12 Aug 2025 17:36:15 +0800 Subject: [PATCH 01/30] add migration --- .../000003_api_keys_signatures.up.sql | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 database/infra/migrations/000003_api_keys_signatures.up.sql diff --git a/database/infra/migrations/000003_api_keys_signatures.up.sql b/database/infra/migrations/000003_api_keys_signatures.up.sql new file mode 100644 index 00000000..d6a13946 --- /dev/null +++ b/database/infra/migrations/000003_api_keys_signatures.up.sql @@ -0,0 +1,19 @@ +CREATE TABLE api_keys_signatures ( + id BIGSERIAL PRIMARY KEY, + uuid UUID UNIQUE NOT NULL, + api_key_id BIGINT NOT NULL, + author_id UUID NOT NULL, + signature BYTEA NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP DEFAULT NULL, + + CONSTRAINT uq_signature UNIQUE (signature, created_at), + + -- This constraint requires the 'api_keys' table to have a primary key column named 'id'. + CONSTRAINT fk_api_key_id FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE CASCADE +); + +CREATE INDEX idx_signature ON api_keys_signatures(signature); +CREATE INDEX idx_signature_created_at ON api_keys_signatures(created_at); +CREATE INDEX idx_signature_deleted_at ON api_keys_signatures(deleted_at); From 79b7e9c2e71d495c74d0d491bd381e4a471410b1 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 12 Aug 2025 17:40:32 +0800 Subject: [PATCH 02/30] add model --- .../000003_api_keys_signatures.up.sql | 1 - database/model.go | 14 ++++++++++++- pkg/cache/ttl_cache_useonce_test.go | 20 +++++++++---------- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/database/infra/migrations/000003_api_keys_signatures.up.sql b/database/infra/migrations/000003_api_keys_signatures.up.sql index d6a13946..3ba6dde0 100644 --- a/database/infra/migrations/000003_api_keys_signatures.up.sql +++ b/database/infra/migrations/000003_api_keys_signatures.up.sql @@ -2,7 +2,6 @@ CREATE TABLE api_keys_signatures ( id BIGSERIAL PRIMARY KEY, uuid UUID UNIQUE NOT NULL, api_key_id BIGINT NOT NULL, - author_id UUID NOT NULL, signature BYTEA NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, diff --git a/database/model.go b/database/model.go index 69c560c2..544e2a2d 100644 --- a/database/model.go +++ b/database/model.go @@ -1,9 +1,10 @@ package database import ( - "gorm.io/gorm" "slices" "time" + + "gorm.io/gorm" ) const DriverName = "postgres" @@ -34,6 +35,17 @@ type APIKey struct { DeletedAt gorm.DeletedAt `gorm:"index"` } +type APIKeySignature struct { + ID int64 `gorm:"primaryKey"` + UUID string `gorm:"type:uuid;unique;not null"` + APIKeyID int64 `gorm:"not null"` + APIKey APIKey `gorm:"foreignKey:APIKeyID"` + Signature []byte `gorm:"not null;uniqueIndex:uq_signature_created_at;index:idx_signature"` + CreatedAt time.Time `gorm:"uniqueIndex:uq_signature_created_at;index:idx_signature_created_at"` + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index:idx_signature_deleted_at"` +} + type User struct { ID uint64 `gorm:"primaryKey;autoIncrement"` UUID string `gorm:"type:uuid;unique;not null"` diff --git a/pkg/cache/ttl_cache_useonce_test.go b/pkg/cache/ttl_cache_useonce_test.go index 91b53452..d66d2c4b 100644 --- a/pkg/cache/ttl_cache_useonce_test.go +++ b/pkg/cache/ttl_cache_useonce_test.go @@ -8,10 +8,10 @@ import ( // TestTTLCache_UseOnce verifies the behavior of UseOnce for first use, // repeated use before expiry and reuse after the TTL has elapsed. func TestTTLCache_UseOnce(t *testing.T) { - t.Parallel() - c := NewTTLCache() - key := "nonce" - ttl := 100 * time.Millisecond + t.Parallel() + c := NewTTLCache() + key := "nonce" + ttl := 100 * time.Millisecond t.Run("first use", func(t *testing.T) { if used := c.UseOnce(key, ttl); used { @@ -25,12 +25,12 @@ func TestTTLCache_UseOnce(t *testing.T) { } }) - t.Run("use after expiry", func(t *testing.T) { - time.Sleep(ttl + 50*time.Millisecond) - if used := c.UseOnce(key, ttl); used { - t.Fatalf("expected UseOnce to return false for an expired key") - } - }) + t.Run("use after expiry", func(t *testing.T) { + time.Sleep(ttl + 50*time.Millisecond) + if used := c.UseOnce(key, ttl); used { + t.Fatalf("expected UseOnce to return false for an expired key") + } + }) } // TestTTLCache_Mark_PrunesExpiredEntries ensures that calling Mark prunes From b4bac35d9f8640d23d1d12fa173457fa8913a20e Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Mon, 1 Sep 2025 11:27:33 +0800 Subject: [PATCH 03/30] fix migration --- .../infra/migrations/000003_api_keys_signatures.up.sql | 10 ++++------ pkg/portal/client_test.go | 4 ++-- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/database/infra/migrations/000003_api_keys_signatures.up.sql b/database/infra/migrations/000003_api_keys_signatures.up.sql index 3ba6dde0..ae22945e 100644 --- a/database/infra/migrations/000003_api_keys_signatures.up.sql +++ b/database/infra/migrations/000003_api_keys_signatures.up.sql @@ -7,12 +7,10 @@ CREATE TABLE api_keys_signatures ( updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, deleted_at TIMESTAMP DEFAULT NULL, - CONSTRAINT uq_signature UNIQUE (signature, created_at), - - -- This constraint requires the 'api_keys' table to have a primary key column named 'id'. + CONSTRAINT unique_signature UNIQUE (signature, api_key_id, created_at), CONSTRAINT fk_api_key_id FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE CASCADE ); -CREATE INDEX idx_signature ON api_keys_signatures(signature); -CREATE INDEX idx_signature_created_at ON api_keys_signatures(created_at); -CREATE INDEX idx_signature_deleted_at ON api_keys_signatures(deleted_at); +CREATE INDEX idx_signature_created_at ON api_keys_signatures(signature, created_at); +CREATE INDEX idx_created_at ON api_keys_signatures(created_at); +CREATE INDEX idx_deleted_at ON api_keys_signatures(deleted_at); diff --git a/pkg/portal/client_test.go b/pkg/portal/client_test.go index 64140a4e..0bc3b62b 100644 --- a/pkg/portal/client_test.go +++ b/pkg/portal/client_test.go @@ -12,7 +12,7 @@ func TestClientTransportAndGet(t *testing.T) { c := MakeDefaultClient(tr) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("hello")) + _, _ = w.Write([]byte("hello")) })) defer srv.Close() @@ -26,7 +26,7 @@ func TestClientTransportAndGet(t *testing.T) { func TestClientGetNil(t *testing.T) { var c *Client - _, err := c.Get(context.Background(), "http://example.com") + _, err := c.Get(context.Background(), "https://example.com") if err == nil { t.Fatalf("expected error") From 2909c17c78ca2cd65f4cd628084afbb683428f5e Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Mon, 1 Sep 2025 11:56:17 +0800 Subject: [PATCH 04/30] fix collision --- database/infra/migrations/000002_api_keys.up.sql | 8 ++++---- .../infra/migrations/000003_api_keys_signatures.up.sql | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/database/infra/migrations/000002_api_keys.up.sql b/database/infra/migrations/000002_api_keys.up.sql index 7b48305e..aa453584 100644 --- a/database/infra/migrations/000002_api_keys.up.sql +++ b/database/infra/migrations/000002_api_keys.up.sql @@ -11,7 +11,7 @@ CREATE TABLE api_keys ( CONSTRAINT uq_account_keys UNIQUE (account_name, public_key, secret_key) ); -CREATE INDEX idx_account_name ON api_keys(account_name); -CREATE INDEX idx_public_key ON api_keys(public_key); -CREATE INDEX idx_secret_key ON api_keys(secret_key); -CREATE INDEX idx_deleted_at ON api_keys(deleted_at); +CREATE INDEX idx_api_keys_account_name ON api_keys(account_name); +CREATE INDEX idx_api_keys_public_key ON api_keys(public_key); +CREATE INDEX idx_api_keys_secret_key ON api_keys(secret_key); +CREATE INDEX idx_api_keys_deleted_at ON api_keys(deleted_at); diff --git a/database/infra/migrations/000003_api_keys_signatures.up.sql b/database/infra/migrations/000003_api_keys_signatures.up.sql index ae22945e..36982725 100644 --- a/database/infra/migrations/000003_api_keys_signatures.up.sql +++ b/database/infra/migrations/000003_api_keys_signatures.up.sql @@ -11,6 +11,6 @@ CREATE TABLE api_keys_signatures ( CONSTRAINT fk_api_key_id FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE CASCADE ); -CREATE INDEX idx_signature_created_at ON api_keys_signatures(signature, created_at); -CREATE INDEX idx_created_at ON api_keys_signatures(created_at); -CREATE INDEX idx_deleted_at ON api_keys_signatures(deleted_at); +CREATE INDEX idx_api_keys_signatures_signature_created_at ON api_keys_signatures(signature, created_at); +CREATE INDEX idx_api_keys_signatures_created_at ON api_keys_signatures(created_at); +CREATE INDEX idx_api_keys_signatures_deleted_at ON api_keys_signatures(deleted_at); From e2ed2b1b1133731dd8bbaf7bdd5d460b47915bb3 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Mon, 1 Sep 2025 12:04:58 +0800 Subject: [PATCH 05/30] format --- database/attrs.go | 6 +++--- database/model.go | 4 ++-- database/repository/api_keys.go | 3 ++- metal/cli/accounts/factory.go | 1 + metal/cli/accounts/factory_test.go | 2 +- metal/cli/accounts/handler.go | 1 + 6 files changed, 10 insertions(+), 7 deletions(-) diff --git a/database/attrs.go b/database/attrs.go index b54168bb..2b9358f8 100644 --- a/database/attrs.go +++ b/database/attrs.go @@ -39,9 +39,9 @@ type CommentsAttrs struct { } type LikesAttrs struct { - UUID string `gorm:"type:uuid;unique;not null"` - PostID uint64 `gorm:"not null;index;uniqueIndex:idx_likes_post_user"` - UserID uint64 `gorm:"not null;index;uniqueIndex:idx_likes_post_user"` + UUID string + PostID uint64 + UserID uint64 } type NewsletterAttrs struct { diff --git a/database/model.go b/database/model.go index 544e2a2d..9e37903b 100644 --- a/database/model.go +++ b/database/model.go @@ -12,8 +12,8 @@ const DriverName = "postgres" var schemaTables = []string{ "users", "posts", "categories", "post_categories", "tags", "post_tags", - "post_views", "comments", - "likes", "newsletters", "api_keys", + "post_views", "comments", "likes", + "newsletters", "api_keys", "api_keys_signatures", } func GetSchemaTables() []string { diff --git a/database/repository/api_keys.go b/database/repository/api_keys.go index 746a52b4..6d281cb6 100644 --- a/database/repository/api_keys.go +++ b/database/repository/api_keys.go @@ -2,10 +2,11 @@ package repository import ( "fmt" + "strings" + "github.com/google/uuid" "github.com/oullin/database" "github.com/oullin/pkg/gorm" - "strings" ) type ApiKeys struct { diff --git a/metal/cli/accounts/factory.go b/metal/cli/accounts/factory.go index e340b637..ee7a9352 100644 --- a/metal/cli/accounts/factory.go +++ b/metal/cli/accounts/factory.go @@ -2,6 +2,7 @@ package accounts import ( "fmt" + "github.com/oullin/database" "github.com/oullin/database/repository" "github.com/oullin/metal/env" diff --git a/metal/cli/accounts/factory_test.go b/metal/cli/accounts/factory_test.go index 8bf58a68..c91da457 100644 --- a/metal/cli/accounts/factory_test.go +++ b/metal/cli/accounts/factory_test.go @@ -1,10 +1,10 @@ package accounts import ( - "github.com/oullin/metal/cli/clitest" "testing" "github.com/oullin/database" + "github.com/oullin/metal/cli/clitest" ) func TestMakeHandler(t *testing.T) { diff --git a/metal/cli/accounts/handler.go b/metal/cli/accounts/handler.go index cdd2e4f3..fa345d01 100644 --- a/metal/cli/accounts/handler.go +++ b/metal/cli/accounts/handler.go @@ -2,6 +2,7 @@ package accounts import ( "fmt" + "github.com/oullin/database" "github.com/oullin/pkg/auth" "github.com/oullin/pkg/cli" From 666da190a773bbc1d5a01b57baccbc5a57116b64 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Mon, 1 Sep 2025 15:23:58 +0800 Subject: [PATCH 06/30] work on endpoint --- handler/categories.go | 5 ++-- handler/signatures.go | 57 +++++++++++++++++++++++++++++++++++++++++ main.go | 5 ++++ metal/cli/main.go | 1 + metal/cli/panel/menu.go | 4 +-- metal/kernel/app.go | 8 +++--- metal/kernel/router.go | 25 +++++++++++++++--- 7 files changed, 94 insertions(+), 11 deletions(-) create mode 100644 handler/signatures.go diff --git a/handler/categories.go b/handler/categories.go index 1ef2c451..4f7fbb2f 100644 --- a/handler/categories.go +++ b/handler/categories.go @@ -2,14 +2,15 @@ package handler import ( "encoding/json" + "log/slog" + baseHttp "net/http" + "github.com/oullin/database" "github.com/oullin/database/repository" "github.com/oullin/database/repository/pagination" "github.com/oullin/handler/paginate" "github.com/oullin/handler/payload" "github.com/oullin/pkg/http" - "log/slog" - baseHttp "net/http" ) type CategoriesHandler struct { diff --git a/handler/signatures.go b/handler/signatures.go new file mode 100644 index 00000000..11cb4ca4 --- /dev/null +++ b/handler/signatures.go @@ -0,0 +1,57 @@ +package handler + +import ( + "encoding/json" + "log/slog" + baseHttp "net/http" + + "github.com/oullin/pkg/http" + "github.com/oullin/pkg/portal" +) + +type SignatureRequest struct { + //Method string `json:"method"` + //URL string `json:"url"` + //Body string `json:"body"` + Nonce string `json:"nonce" validate:"required,lowercase,len=32"` + APIKey string `json:"apiKey" validate:"required,lowercase,len=32"` + APIUsername string `json:"apiUsername" validate:"required,lowercase,min=5"` + Timestamp string `json:"timestamp" validate:"required"` +} + +type SignatureResponse struct { + Signature string `json:"signature"` +} + +type SignaturesHandler struct { + validator *portal.Validator +} + +func MakeSignaturesHandler(validator *portal.Validator) SignaturesHandler { + return SignaturesHandler{ + validator: validator, + } +} + +func (s *SignaturesHandler) Generate(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { + defer portal.CloseWithLog(r.Body) + + var req SignatureRequest + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + slog.Error("Error reading signatures request", "error", err) + + return http.InternalError("could not read signatures data") + } + + signature := SignatureResponse{Signature: "TEST"} + resp := http.MakeResponseFrom("0.0.1", w, r) + + if err := resp.RespondOk(signature); err != nil { + slog.Error("Error marshaling JSON for signatures response", "error", err) + + return nil + } + + return nil // A nil return indicates success. +} diff --git a/main.go b/main.go index 08a5ee3d..2886c0ae 100644 --- a/main.go +++ b/main.go @@ -43,6 +43,11 @@ func main() { slog.Error("Error starting server", "error", err) panic("Error starting server." + err.Error()) } + + //if err := baseHttp.ListenAndServe(app.GetEnv().Network.GetHostURL(), app.GetMux()); err != nil { + // slog.Error("Error starting server", "error", err) + // panic("Error starting server." + err.Error()) + //} } func serverHandler() baseHttp.Handler { diff --git a/metal/cli/main.go b/metal/cli/main.go index a8cbbd84..540ea72c 100644 --- a/metal/cli/main.go +++ b/metal/cli/main.go @@ -163,6 +163,7 @@ func generateApiAccountsHTTPSignature(menu panel.Menu) error { return nil } +// @todo Remove! func generateAppEncryptionKey() error { var err error var key []byte diff --git a/metal/cli/panel/menu.go b/metal/cli/panel/menu.go index 61302e9e..46b9be38 100644 --- a/metal/cli/panel/menu.go +++ b/metal/cli/panel/menu.go @@ -89,8 +89,8 @@ func (p *Menu) Print() { p.PrintOption("1) Parse Blog Posts.", inner) p.PrintOption("2) Create new API account.", inner) p.PrintOption("3) Show API accounts.", inner) - p.PrintOption("4) Generate API accounts HTTP signature.", inner) - p.PrintOption("5) Generate app encryption key.", inner) + p.PrintOption("4) Generate API accounts HTTP keys pair.", inner) + p.PrintOption("5) [deprecated] Generate app encryption key.", inner) p.PrintOption(" ", inner) p.PrintOption("0) Exit.", inner) diff --git a/metal/kernel/app.go b/metal/kernel/app.go index 679c1c72..b9627343 100644 --- a/metal/kernel/app.go +++ b/metal/kernel/app.go @@ -42,9 +42,10 @@ func MakeApp(env *env.Environment, validator *portal.Validator) (*App, error) { } router := Router{ - Env: env, - Db: db, - Mux: baseHttp.NewServeMux(), + Env: env, + Db: db, + Mux: baseHttp.NewServeMux(), + validator: validator, Pipeline: middleware.Pipeline{ Env: env, ApiKeys: &repository.ApiKeys{DB: db}, @@ -73,4 +74,5 @@ func (a *App) Boot() { router.Recommendations() router.Posts() router.Categories() + router.Signature() } diff --git a/metal/kernel/router.go b/metal/kernel/router.go index ca13d6b9..bebb66f8 100644 --- a/metal/kernel/router.go +++ b/metal/kernel/router.go @@ -9,6 +9,7 @@ import ( "github.com/oullin/metal/env" "github.com/oullin/pkg/http" "github.com/oullin/pkg/middleware" + "github.com/oullin/pkg/portal" ) type StaticRouteResource interface { @@ -22,10 +23,19 @@ func addStaticRoute[H StaticRouteResource](r *Router, path, file string, maker f } type Router struct { - Env *env.Environment - Mux *baseHttp.ServeMux - Pipeline middleware.Pipeline - Db *database.Connection + Env *env.Environment + Mux *baseHttp.ServeMux + Pipeline middleware.Pipeline + Db *database.Connection + validator *portal.Validator +} + +func (r *Router) PublicPipelineFor(apiHandler http.ApiHandler) baseHttp.HandlerFunc { + return http.MakeApiHandler( + r.Pipeline.Chain( + apiHandler, + ), + ) } func (r *Router) PipelineFor(apiHandler http.ApiHandler) baseHttp.HandlerFunc { @@ -62,6 +72,13 @@ func (r *Router) Categories() { r.Mux.HandleFunc("GET /categories", index) } +func (r *Router) Signature() { + abstract := handler.MakeSignaturesHandler(r.validator) + generate := r.PublicPipelineFor(abstract.Generate) + + r.Mux.HandleFunc("POST /generate-signature", generate) +} + func (r *Router) Profile() { addStaticRoute(r, "/profile", "./storage/fixture/profile.json", handler.MakeProfileHandler) } From 8b6a5e4902a154faca7b7699a28b65a6c5f8cfe8 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Mon, 1 Sep 2025 16:22:08 +0800 Subject: [PATCH 07/30] work on validation --- handler/signatures.go | 37 +++++++++++++++++++++++++++++++------ pkg/http/handler.go | 1 + pkg/http/response.go | 10 +++++++++- pkg/http/schema.go | 10 ++++++---- 4 files changed, 47 insertions(+), 11 deletions(-) diff --git a/handler/signatures.go b/handler/signatures.go index 11cb4ca4..f0f288db 100644 --- a/handler/signatures.go +++ b/handler/signatures.go @@ -2,21 +2,24 @@ package handler import ( "encoding/json" + "io" "log/slog" baseHttp "net/http" + "net/url" + "strings" "github.com/oullin/pkg/http" "github.com/oullin/pkg/portal" ) type SignatureRequest struct { - //Method string `json:"method"` - //URL string `json:"url"` - //Body string `json:"body"` + Method string `json:"method"` + URL string `json:"url"` + Body string `json:"body"` Nonce string `json:"nonce" validate:"required,lowercase,len=32"` - APIKey string `json:"apiKey" validate:"required,lowercase,len=32"` + APIKey string `json:"apiKey" validate:"required,lowercase,min=64,max=67"` APIUsername string `json:"apiUsername" validate:"required,lowercase,min=5"` - Timestamp string `json:"timestamp" validate:"required"` + Timestamp string `json:"timestamp" validate:"required,lowercase,len=10"` } type SignatureResponse struct { @@ -41,7 +44,29 @@ func (s *SignaturesHandler) Generate(w baseHttp.ResponseWriter, r *baseHttp.Requ if err := json.NewDecoder(r.Body).Decode(&req); err != nil { slog.Error("Error reading signatures request", "error", err) - return http.InternalError("could not read signatures data") + return http.BadRequestError("could not read signatures data") + } + + reqBody, err := io.ReadAll(r.Body) + if err != nil { + slog.Error("Error reading signatures request body", "error", err) + + return http.BadRequestError("could not read signatures request body") + } + + parsedURL, err := url.Parse(req.URL) + if err != nil { + slog.Error("Error reading parsing the signature request URL", "error", err) + + return http.BadRequestError("could not read signatures URL") + } + + req.Method = strings.ToUpper(req.Method) + req.URL = parsedURL.String() + req.Body = string(reqBody) + + if _, err := s.validator.Rejects(req); err != nil { + return http.UnprocessableEntity("The given fields are invalid", s.validator.GetErrors()) } signature := SignatureResponse{Signature: "TEST"} diff --git a/pkg/http/handler.go b/pkg/http/handler.go index 78951482..9d5a89fa 100644 --- a/pkg/http/handler.go +++ b/pkg/http/handler.go @@ -17,6 +17,7 @@ func MakeApiHandler(fn ApiHandler) baseHttp.HandlerFunc { resp := ErrorResponse{ Error: err.Message, Status: err.Status, + Data: err.Data, } if result := json.NewEncoder(w).Encode(resp); result != nil { diff --git a/pkg/http/response.go b/pkg/http/response.go index d589a94f..42ea1619 100644 --- a/pkg/http/response.go +++ b/pkg/http/response.go @@ -31,7 +31,7 @@ func MakeResponseFrom(salt string, writer baseHttp.ResponseWriter, request *base headers: func(w baseHttp.ResponseWriter) { w.Header().Set("Content-Type", "application/json") w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("Cache-Control", cacheControl) + //w.Header().Set("Cache-Control", cacheControl) w.Header().Set("ETag", etag) }, } @@ -79,6 +79,14 @@ func BadRequestError(msg string) *ApiError { } } +func UnprocessableEntity(msg string, errors map[string]any) *ApiError { + return &ApiError{ + Message: fmt.Sprintf("Unprocessable entity: %s", msg), + Status: baseHttp.StatusUnprocessableEntity, + Data: errors, + } +} + func NotFound(msg string) *ApiError { return &ApiError{ Message: fmt.Sprintf("Not found error: %s", msg), diff --git a/pkg/http/schema.go b/pkg/http/schema.go index 6f5329a9..08c879ef 100644 --- a/pkg/http/schema.go +++ b/pkg/http/schema.go @@ -3,13 +3,15 @@ package http import baseHttp "net/http" type ErrorResponse struct { - Error string `json:"error"` - Status int `json:"status"` + Error string `json:"error"` + Status int `json:"status"` + Data map[string]any `json:"data"` } type ApiError struct { - Message string `json:"message"` - Status int `json:"status"` + Message string `json:"message"` + Status int `json:"status"` + Data map[string]any `json:"data"` } func (e *ApiError) Error() string { From 25f6a21a867d3a8a812077ff55c673af4c88cf61 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Mon, 1 Sep 2025 17:15:14 +0800 Subject: [PATCH 08/30] fix req payload --- handler/signatures.go | 35 +++++++++++++++-------------------- pkg/portal/support.go | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/handler/signatures.go b/handler/signatures.go index f0f288db..f6171cfb 100644 --- a/handler/signatures.go +++ b/handler/signatures.go @@ -1,11 +1,11 @@ package handler import ( + "bytes" "encoding/json" "io" "log/slog" baseHttp "net/http" - "net/url" "strings" "github.com/oullin/pkg/http" @@ -13,13 +13,12 @@ import ( ) type SignatureRequest struct { - Method string `json:"method"` - URL string `json:"url"` - Body string `json:"body"` + Method string `json:"method" validate:"required,eq=POST"` + URL string `json:"url" validate:"required,uri"` Nonce string `json:"nonce" validate:"required,lowercase,len=32"` APIKey string `json:"apiKey" validate:"required,lowercase,min=64,max=67"` APIUsername string `json:"apiUsername" validate:"required,lowercase,min=5"` - Timestamp string `json:"timestamp" validate:"required,lowercase,len=10"` + Timestamp string `json:"timestamp" validate:"required,number,len=10"` } type SignatureResponse struct { @@ -41,29 +40,25 @@ func (s *SignaturesHandler) Generate(w baseHttp.ResponseWriter, r *baseHttp.Requ var req SignatureRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - slog.Error("Error reading signatures request", "error", err) - - return http.BadRequestError("could not read signatures data") - } - - reqBody, err := io.ReadAll(r.Body) + bodyBytes, err := io.ReadAll(r.Body) if err != nil { slog.Error("Error reading signatures request body", "error", err) return http.BadRequestError("could not read signatures request body") } - parsedURL, err := url.Parse(req.URL) - if err != nil { - slog.Error("Error reading parsing the signature request URL", "error", err) + if err = json.Unmarshal(bodyBytes, &req); err != nil { + slog.Error("Error parsing signatures request", "error", err) - return http.BadRequestError("could not read signatures URL") + return http.BadRequestError("could not parse the given data") } - req.Method = strings.ToUpper(req.Method) - req.URL = parsedURL.String() - req.Body = string(reqBody) + r.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + + req.Method = strings.ToUpper(r.Method) + req.URL = portal.GenerateURL(r) + + //fmt.Println("-----> ", req) if _, err := s.validator.Rejects(req); err != nil { return http.UnprocessableEntity("The given fields are invalid", s.validator.GetErrors()) @@ -72,7 +67,7 @@ func (s *SignaturesHandler) Generate(w baseHttp.ResponseWriter, r *baseHttp.Requ signature := SignatureResponse{Signature: "TEST"} resp := http.MakeResponseFrom("0.0.1", w, r) - if err := resp.RespondOk(signature); err != nil { + if err = resp.RespondOk(signature); err != nil { slog.Error("Error marshaling JSON for signatures response", "error", err) return nil diff --git a/pkg/portal/support.go b/pkg/portal/support.go index 44fbd374..99d49704 100644 --- a/pkg/portal/support.go +++ b/pkg/portal/support.go @@ -23,6 +23,24 @@ func CloseWithLog(c io.Closer) { } } +func GenerateURL(r *baseHttp.Request) string { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + + if v := r.Header.Get("X-Forwarded-Proto"); v != "" { + scheme = v + } + + host := r.Host + if v := r.Header.Get("X-Forwarded-Host"); v != "" { + host = v + } + + return scheme + "://" + host + r.URL.RequestURI() +} + func Sha256Hex(b []byte) string { h := sha256.Sum256(b) return hex.EncodeToString(h[:]) From 442d9e8cd6024e79b809be051c8ccbfd0584b96a Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Wed, 3 Sep 2025 17:21:32 +0800 Subject: [PATCH 09/30] start working on signature generation --- handler/signatures.go | 31 ++++++++++++++++++++----------- metal/kernel/router.go | 2 +- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/handler/signatures.go b/handler/signatures.go index f6171cfb..ef1a5af4 100644 --- a/handler/signatures.go +++ b/handler/signatures.go @@ -8,17 +8,19 @@ import ( baseHttp "net/http" "strings" + "github.com/oullin/database" + "github.com/oullin/database/repository" "github.com/oullin/pkg/http" "github.com/oullin/pkg/portal" ) type SignatureRequest struct { - Method string `json:"method" validate:"required,eq=POST"` - URL string `json:"url" validate:"required,uri"` - Nonce string `json:"nonce" validate:"required,lowercase,len=32"` - APIKey string `json:"apiKey" validate:"required,lowercase,min=64,max=67"` - APIUsername string `json:"apiUsername" validate:"required,lowercase,min=5"` - Timestamp string `json:"timestamp" validate:"required,number,len=10"` + Method string `json:"method" validate:"required,eq=POST"` + URL string `json:"url" validate:"required,uri"` + Nonce string `json:"nonce" validate:"required,lowercase,len=32"` + PublicKey string `json:"public_key" validate:"required,lowercase,min=64,max=67"` + Username string `json:"username" validate:"required,lowercase,min=5"` + Timestamp string `json:"timestamp" validate:"required,number,len=10"` } type SignatureResponse struct { @@ -26,12 +28,14 @@ type SignatureResponse struct { } type SignaturesHandler struct { - validator *portal.Validator + Validator *portal.Validator + ApiKeys *repository.ApiKeys } -func MakeSignaturesHandler(validator *portal.Validator) SignaturesHandler { +func MakeSignaturesHandler(validator *portal.Validator, ApiKeys *repository.ApiKeys) SignaturesHandler { return SignaturesHandler{ - validator: validator, + Validator: validator, + ApiKeys: ApiKeys, } } @@ -60,8 +64,13 @@ func (s *SignaturesHandler) Generate(w baseHttp.ResponseWriter, r *baseHttp.Requ //fmt.Println("-----> ", req) - if _, err := s.validator.Rejects(req); err != nil { - return http.UnprocessableEntity("The given fields are invalid", s.validator.GetErrors()) + if _, err := s.Validator.Rejects(req); err != nil { + return http.UnprocessableEntity("The given fields are invalid", s.Validator.GetErrors()) + } + + var token *database.APIKey + if token = s.ApiKeys.FindBy(req.Username); token == nil { + return http.NotFound("The given username was not found") } signature := SignatureResponse{Signature: "TEST"} diff --git a/metal/kernel/router.go b/metal/kernel/router.go index bebb66f8..e8014b5f 100644 --- a/metal/kernel/router.go +++ b/metal/kernel/router.go @@ -73,7 +73,7 @@ func (r *Router) Categories() { } func (r *Router) Signature() { - abstract := handler.MakeSignaturesHandler(r.validator) + abstract := handler.MakeSignaturesHandler(r.validator, r.Pipeline.ApiKeys) generate := r.PublicPipelineFor(abstract.Generate) r.Mux.HandleFunc("POST /generate-signature", generate) From 2a4eddcaf4b73a46a211cdb2ba27b321d7b2c614 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Wed, 3 Sep 2025 17:29:40 +0800 Subject: [PATCH 10/30] generation --- handler/signatures.go | 11 ++++++++--- pkg/auth/encryption.go | 7 ++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/handler/signatures.go b/handler/signatures.go index ef1a5af4..aa24f0c2 100644 --- a/handler/signatures.go +++ b/handler/signatures.go @@ -10,6 +10,7 @@ import ( "github.com/oullin/database" "github.com/oullin/database/repository" + "github.com/oullin/pkg/auth" "github.com/oullin/pkg/http" "github.com/oullin/pkg/portal" ) @@ -62,8 +63,6 @@ func (s *SignaturesHandler) Generate(w baseHttp.ResponseWriter, r *baseHttp.Requ req.Method = strings.ToUpper(r.Method) req.URL = portal.GenerateURL(r) - //fmt.Println("-----> ", req) - if _, err := s.Validator.Rejects(req); err != nil { return http.UnprocessableEntity("The given fields are invalid", s.Validator.GetErrors()) } @@ -73,7 +72,13 @@ func (s *SignaturesHandler) Generate(w baseHttp.ResponseWriter, r *baseHttp.Requ return http.NotFound("The given username was not found") } - signature := SignatureResponse{Signature: "TEST"} + signature := SignatureResponse{ + Signature: auth.CreateSignatureFrom( + string(auth.GenerateAESKey()), + string(token.SecretKey), + ), + } + resp := http.MakeResponseFrom("0.0.1", w, r) if err = resp.RespondOk(signature); err != nil { diff --git a/pkg/auth/encryption.go b/pkg/auth/encryption.go index 4210f9b9..289c0f9c 100644 --- a/pkg/auth/encryption.go +++ b/pkg/auth/encryption.go @@ -12,14 +12,15 @@ import ( "strings" ) -func GenerateAESKey() ([]byte, error) { +func GenerateAESKey() []byte { key := make([]byte, EncryptionKeyLength) + //@todo Fix if _, err := rand.Read(key); err != nil { - return nil, fmt.Errorf("failed to generate random key: %w", err) + return []byte("") } - return key, nil + return key } func Encrypt(plaintext []byte, key []byte) ([]byte, error) { From bdc1db1b7f30ed245dbe9b5c59e86299baba3d55 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 4 Sep 2025 11:23:36 +0800 Subject: [PATCH 11/30] work on candence --- .../000003_api_keys_signatures.up.sql | 3 + database/model.go | 18 +++--- handler/payload/signatures.go | 19 ++++++ handler/signatures.go | 63 ++++++++++--------- pkg/auth/encryption.go | 7 +-- 5 files changed, 70 insertions(+), 40 deletions(-) create mode 100644 handler/payload/signatures.go diff --git a/database/infra/migrations/000003_api_keys_signatures.up.sql b/database/infra/migrations/000003_api_keys_signatures.up.sql index 36982725..0bb087f9 100644 --- a/database/infra/migrations/000003_api_keys_signatures.up.sql +++ b/database/infra/migrations/000003_api_keys_signatures.up.sql @@ -3,6 +3,8 @@ CREATE TABLE api_keys_signatures ( uuid UUID UNIQUE NOT NULL, api_key_id BIGINT NOT NULL, signature BYTEA NOT NULL, + tries SMALLINT NOT NULL DEFAULT 1 CHECK (tries > 0), + expires_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, deleted_at TIMESTAMP DEFAULT NULL, @@ -12,5 +14,6 @@ CREATE TABLE api_keys_signatures ( ); CREATE INDEX idx_api_keys_signatures_signature_created_at ON api_keys_signatures(signature, created_at); +CREATE INDEX idx_api_keys_signatures_expires_at ON api_keys_signatures(expires_at); CREATE INDEX idx_api_keys_signatures_created_at ON api_keys_signatures(created_at); CREATE INDEX idx_api_keys_signatures_deleted_at ON api_keys_signatures(deleted_at); diff --git a/database/model.go b/database/model.go index 9e37903b..16e6424e 100644 --- a/database/model.go +++ b/database/model.go @@ -36,14 +36,16 @@ type APIKey struct { } type APIKeySignature struct { - ID int64 `gorm:"primaryKey"` - UUID string `gorm:"type:uuid;unique;not null"` - APIKeyID int64 `gorm:"not null"` - APIKey APIKey `gorm:"foreignKey:APIKeyID"` - Signature []byte `gorm:"not null;uniqueIndex:uq_signature_created_at;index:idx_signature"` - CreatedAt time.Time `gorm:"uniqueIndex:uq_signature_created_at;index:idx_signature_created_at"` - UpdatedAt time.Time - DeletedAt gorm.DeletedAt `gorm:"index:idx_signature_deleted_at"` + ID int64 `gorm:"primaryKey"` + UUID string `gorm:"type:uuid;unique;not null"` + tries int `gorm:"not null"` + APIKeyID int64 `gorm:"not null"` + APIKey APIKey `gorm:"foreignKey:APIKeyID"` + Signature []byte `gorm:"not null;uniqueIndex:uq_signature_created_at;index:idx_signature"` + ExpiresAt time.Time `gorm:"index:idx_api_keys_signatures_expires_at"` + CreatedAt time.Time `gorm:"uniqueIndex:uq_signature_created_at;index:idx_api_keys_signatures_created_at"` + UpdatedAt time.Time `gorm:"index:idx_api_keys_signatures_updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index:idx_api_keys_signatures_deleted_at"` } type User struct { diff --git a/handler/payload/signatures.go b/handler/payload/signatures.go new file mode 100644 index 00000000..63a15166 --- /dev/null +++ b/handler/payload/signatures.go @@ -0,0 +1,19 @@ +package payload + +type SignatureRequest struct { + Nonce string `json:"nonce" validate:"required,lowercase,len=32"` + PublicKey string `json:"public_key" validate:"required,lowercase,min=64,max=67"` + Username string `json:"username" validate:"required,lowercase,min=5"` + Timestamp int64 `json:"timestamp" validate:"required,number,min=10"` +} + +type SignatureResponse struct { + Signature string `json:"signature"` + Cadence SignatureCadenceResponse `json:"cadence"` +} + +type SignatureCadenceResponse struct { + ReceivedAt string `json:"received_at"` + CreatedAt string `json:"created_at"` + ExpiresAt string `json:"expires_at"` +} diff --git a/handler/signatures.go b/handler/signatures.go index aa24f0c2..40603a80 100644 --- a/handler/signatures.go +++ b/handler/signatures.go @@ -1,33 +1,20 @@ package handler import ( - "bytes" "encoding/json" "io" "log/slog" baseHttp "net/http" - "strings" + "time" "github.com/oullin/database" "github.com/oullin/database/repository" + "github.com/oullin/handler/payload" "github.com/oullin/pkg/auth" "github.com/oullin/pkg/http" "github.com/oullin/pkg/portal" ) -type SignatureRequest struct { - Method string `json:"method" validate:"required,eq=POST"` - URL string `json:"url" validate:"required,uri"` - Nonce string `json:"nonce" validate:"required,lowercase,len=32"` - PublicKey string `json:"public_key" validate:"required,lowercase,min=64,max=67"` - Username string `json:"username" validate:"required,lowercase,min=5"` - Timestamp string `json:"timestamp" validate:"required,number,len=10"` -} - -type SignatureResponse struct { - Signature string `json:"signature"` -} - type SignaturesHandler struct { Validator *portal.Validator ApiKeys *repository.ApiKeys @@ -43,27 +30,24 @@ func MakeSignaturesHandler(validator *portal.Validator, ApiKeys *repository.ApiK func (s *SignaturesHandler) Generate(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { defer portal.CloseWithLog(r.Body) - var req SignatureRequest + var err error + var bodyBytes []byte - bodyBytes, err := io.ReadAll(r.Body) + bodyBytes, err = io.ReadAll(r.Body) if err != nil { slog.Error("Error reading signatures request body", "error", err) return http.BadRequestError("could not read signatures request body") } + var req payload.SignatureRequest if err = json.Unmarshal(bodyBytes, &req); err != nil { slog.Error("Error parsing signatures request", "error", err) - return http.BadRequestError("could not parse the given data") + return http.BadRequestError("could not parse the given data.") } - r.Body = io.NopCloser(bytes.NewReader(bodyBytes)) - - req.Method = strings.ToUpper(r.Method) - req.URL = portal.GenerateURL(r) - - if _, err := s.Validator.Rejects(req); err != nil { + if _, err = s.Validator.Rejects(req); err != nil { return http.UnprocessableEntity("The given fields are invalid", s.Validator.GetErrors()) } @@ -72,16 +56,39 @@ func (s *SignaturesHandler) Generate(w baseHttp.ResponseWriter, r *baseHttp.Requ return http.NotFound("The given username was not found") } - signature := SignatureResponse{ + var seed []byte + if seed, err = auth.GenerateAESKey(); err != nil { + slog.Error("Error generating signatures seeds", "error", err) + + return http.InternalError("We were unable to generate the signature seed. Please try again!") + } + + layout := "2006-01-02 15:04:05" + receivedAt := time.Unix(req.Timestamp, 0) + + createdAt := time.Now() + expiresAt := createdAt.Add(time.Second * 30) + + if receivedAt.Before(createdAt) { + slog.Error("Invalid timestamp while creating signatures", "error", err) + + return http.BadRequestError("The given timestamp is before the current time") + } + + response := payload.SignatureResponse{ Signature: auth.CreateSignatureFrom( - string(auth.GenerateAESKey()), + string(seed), string(token.SecretKey), ), + Cadence: payload.SignatureCadenceResponse{ + ReceivedAt: receivedAt.Format(layout), + CreatedAt: createdAt.Format(layout), + ExpiresAt: expiresAt.Format(layout), + }, } resp := http.MakeResponseFrom("0.0.1", w, r) - - if err = resp.RespondOk(signature); err != nil { + if err = resp.RespondOk(response); err != nil { slog.Error("Error marshaling JSON for signatures response", "error", err) return nil diff --git a/pkg/auth/encryption.go b/pkg/auth/encryption.go index 289c0f9c..b55c94de 100644 --- a/pkg/auth/encryption.go +++ b/pkg/auth/encryption.go @@ -12,15 +12,14 @@ import ( "strings" ) -func GenerateAESKey() []byte { +func GenerateAESKey() ([]byte, error) { key := make([]byte, EncryptionKeyLength) - //@todo Fix if _, err := rand.Read(key); err != nil { - return []byte("") + return []byte(""), err } - return key + return key, nil } func Encrypt(plaintext []byte, key []byte) ([]byte, error) { From c894c047a8824ec8871d5d5cf438459dca933fc5 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 4 Sep 2025 12:23:34 +0800 Subject: [PATCH 12/30] isert signatures --- .../000003_api_keys_signatures.up.sql | 14 ++++++----- database/model.go | 18 +++++++++------ database/repository/api_keys.go | 23 +++++++++++++++++++ handler/signatures.go | 18 ++++++++++----- pkg/auth/signature.go | 18 +++++++++++++++ 5 files changed, 72 insertions(+), 19 deletions(-) create mode 100644 pkg/auth/signature.go diff --git a/database/infra/migrations/000003_api_keys_signatures.up.sql b/database/infra/migrations/000003_api_keys_signatures.up.sql index 0bb087f9..ee89681e 100644 --- a/database/infra/migrations/000003_api_keys_signatures.up.sql +++ b/database/infra/migrations/000003_api_keys_signatures.up.sql @@ -1,10 +1,11 @@ -CREATE TABLE api_keys_signatures ( +CREATE TABLE api_key_signatures ( id BIGSERIAL PRIMARY KEY, uuid UUID UNIQUE NOT NULL, api_key_id BIGINT NOT NULL, signature BYTEA NOT NULL, tries SMALLINT NOT NULL DEFAULT 1 CHECK (tries > 0), - expires_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP DEFAULT NULL, + expired_at TIMESTAMP DEFAULT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, deleted_at TIMESTAMP DEFAULT NULL, @@ -13,7 +14,8 @@ CREATE TABLE api_keys_signatures ( CONSTRAINT fk_api_key_id FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE CASCADE ); -CREATE INDEX idx_api_keys_signatures_signature_created_at ON api_keys_signatures(signature, created_at); -CREATE INDEX idx_api_keys_signatures_expires_at ON api_keys_signatures(expires_at); -CREATE INDEX idx_api_keys_signatures_created_at ON api_keys_signatures(created_at); -CREATE INDEX idx_api_keys_signatures_deleted_at ON api_keys_signatures(deleted_at); +CREATE INDEX idx_api_key_signatures_signature_created_at ON api_key_signatures(signature, created_at); +CREATE INDEX idx_api_key_signatures_expires_at ON api_key_signatures(expires_at); +CREATE INDEX idx_api_key_signatures_expired_at ON api_key_signatures(expired_at); +CREATE INDEX idx_api_key_signatures_created_at ON api_key_signatures(created_at); +CREATE INDEX idx_api_key_signatures_deleted_at ON api_key_signatures(deleted_at); diff --git a/database/model.go b/database/model.go index 16e6424e..6313c753 100644 --- a/database/model.go +++ b/database/model.go @@ -13,7 +13,7 @@ var schemaTables = []string{ "users", "posts", "categories", "post_categories", "tags", "post_tags", "post_views", "comments", "likes", - "newsletters", "api_keys", "api_keys_signatures", + "newsletters", "api_keys", "api_key_signatures", } func GetSchemaTables() []string { @@ -33,19 +33,23 @@ type APIKey struct { CreatedAt time.Time UpdatedAt time.Time DeletedAt gorm.DeletedAt `gorm:"index"` + + //Associations + APIKeySignature []APIKeySignatures `gorm:"foreignKey:APIKeyID"` } -type APIKeySignature struct { +type APIKeySignatures struct { ID int64 `gorm:"primaryKey"` UUID string `gorm:"type:uuid;unique;not null"` - tries int `gorm:"not null"` APIKeyID int64 `gorm:"not null"` + Tries int `gorm:"not null"` APIKey APIKey `gorm:"foreignKey:APIKeyID"` Signature []byte `gorm:"not null;uniqueIndex:uq_signature_created_at;index:idx_signature"` - ExpiresAt time.Time `gorm:"index:idx_api_keys_signatures_expires_at"` - CreatedAt time.Time `gorm:"uniqueIndex:uq_signature_created_at;index:idx_api_keys_signatures_created_at"` - UpdatedAt time.Time `gorm:"index:idx_api_keys_signatures_updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index:idx_api_keys_signatures_deleted_at"` + ExpiresAt time.Time `gorm:"index:idx_api_key_signatures_expires_at"` + ExpiredAt *time.Time `gorm:"index:idx_api_key_signatures_expired_at"` + CreatedAt time.Time `gorm:"uniqueIndex:uq_signature_created_at;index:idx_api_key_signatures_created_at"` + UpdatedAt time.Time `gorm:"index:idx_api_key_signatures_updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index:idx_api_key_signatures_deleted_at"` } type User struct { diff --git a/database/repository/api_keys.go b/database/repository/api_keys.go index 6d281cb6..d170af2a 100644 --- a/database/repository/api_keys.go +++ b/database/repository/api_keys.go @@ -3,6 +3,7 @@ package repository import ( "fmt" "strings" + "time" "github.com/google/uuid" "github.com/oullin/database" @@ -33,6 +34,28 @@ func (a ApiKeys) Create(attrs database.APIKeyAttr) (*database.APIKey, error) { return &key, nil } +func (a ApiKeys) CreateSignatureFor(key *database.APIKey, seed []byte, expiresAt time.Time) (*database.APIKeySignatures, error) { + signature := database.APIKeySignatures{ + UUID: uuid.NewString(), + APIKeyID: key.ID, + Signature: seed, + Tries: 5, + ExpiresAt: expiresAt, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if result := a.DB.Sql().Create(&signature); gorm.HasDbIssues(result.Error) { + return nil, fmt.Errorf( + "issue creating the given api key signature [%s, %s]: ", + key.AccountName, + result.Error, + ) + } + + return &signature, nil +} + func (a ApiKeys) FindBy(accountName string) *database.APIKey { key := database.APIKey{} diff --git a/handler/signatures.go b/handler/signatures.go index 40603a80..76d3aefc 100644 --- a/handler/signatures.go +++ b/handler/signatures.go @@ -75,15 +75,21 @@ func (s *SignaturesHandler) Generate(w baseHttp.ResponseWriter, r *baseHttp.Requ return http.BadRequestError("The given timestamp is before the current time") } + hash := auth.CreateSignature(seed, token.SecretKey) + var keySignature *database.APIKeySignatures + + if keySignature, err = s.ApiKeys.CreateSignatureFor(token, hash, expiresAt); err != nil { + slog.Error("Error creating signature in the db", "error", err) + + return http.InternalError("We were unable to create the signature item. Please try again!: " + err.Error()) + } + response := payload.SignatureResponse{ - Signature: auth.CreateSignatureFrom( - string(seed), - string(token.SecretKey), - ), + Signature: auth.SignatureToString(keySignature.Signature), Cadence: payload.SignatureCadenceResponse{ ReceivedAt: receivedAt.Format(layout), - CreatedAt: createdAt.Format(layout), - ExpiresAt: expiresAt.Format(layout), + CreatedAt: keySignature.CreatedAt.Format(layout), + ExpiresAt: keySignature.ExpiresAt.Format(layout), }, } diff --git a/pkg/auth/signature.go b/pkg/auth/signature.go new file mode 100644 index 00000000..d77699b0 --- /dev/null +++ b/pkg/auth/signature.go @@ -0,0 +1,18 @@ +package auth + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" +) + +func CreateSignature(message, secretKey []byte) []byte { + mac := hmac.New(sha256.New, secretKey) + mac.Write(message) + + return mac.Sum(nil) +} + +func SignatureToString(signature []byte) string { + return hex.EncodeToString(signature) +} From f61067b09b4c21c85d0fa45c0483a5ea978601a2 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 4 Sep 2025 12:51:17 +0800 Subject: [PATCH 13/30] avoid creating multiple signatures --- .../000003_api_keys_signatures.up.sql | 4 +-- database/repository/api_keys.go | 31 ++++++++++++++++++- database/repository/posts.go | 1 + 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/database/infra/migrations/000003_api_keys_signatures.up.sql b/database/infra/migrations/000003_api_keys_signatures.up.sql index ee89681e..8d28e1ff 100644 --- a/database/infra/migrations/000003_api_keys_signatures.up.sql +++ b/database/infra/migrations/000003_api_keys_signatures.up.sql @@ -10,8 +10,8 @@ CREATE TABLE api_key_signatures ( updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, deleted_at TIMESTAMP DEFAULT NULL, - CONSTRAINT unique_signature UNIQUE (signature, api_key_id, created_at), - CONSTRAINT fk_api_key_id FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE CASCADE + CONSTRAINT api_key_signatures_unique_signature UNIQUE (signature, api_key_id, created_at), + CONSTRAINT api_key_signatures_fk_api_key_id FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE CASCADE ); CREATE INDEX idx_api_key_signatures_signature_created_at ON api_key_signatures(signature, created_at); diff --git a/database/repository/api_keys.go b/database/repository/api_keys.go index d170af2a..c64c55fb 100644 --- a/database/repository/api_keys.go +++ b/database/repository/api_keys.go @@ -10,6 +10,8 @@ import ( "github.com/oullin/pkg/gorm" ) +const MaxSignaturesTries = 5 + type ApiKeys struct { DB *database.Connection } @@ -35,11 +37,17 @@ func (a ApiKeys) Create(attrs database.APIKeyAttr) (*database.APIKey, error) { } func (a ApiKeys) CreateSignatureFor(key *database.APIKey, seed []byte, expiresAt time.Time) (*database.APIKeySignatures, error) { + var item *database.APIKeySignatures + + if item = a.FindSignature(key); item != nil { + return item, nil + } + signature := database.APIKeySignatures{ UUID: uuid.NewString(), APIKeyID: key.ID, Signature: seed, - Tries: 5, + Tries: MaxSignaturesTries, ExpiresAt: expiresAt, CreatedAt: time.Now(), UpdatedAt: time.Now(), @@ -73,3 +81,24 @@ func (a ApiKeys) FindBy(accountName string) *database.APIKey { return nil } + +func (a ApiKeys) FindSignature(key *database.APIKey) *database.APIKeySignatures { + var item database.APIKeySignatures + + result := a.DB.Sql(). + Model(&database.APIKeySignatures{}). + Where("api_key_id = ?", key.ID). + Where("tries <= ?", MaxSignaturesTries). + Where("expired_at IS NULL OR expired_at > ?", time.Now()). + First(&item) + + if gorm.HasDbIssues(result.Error) { + return nil + } + + if result.RowsAffected > 0 { + return &item + } + + return nil +} diff --git a/database/repository/posts.go b/database/repository/posts.go index 0c6e5220..0b370dca 100644 --- a/database/repository/posts.go +++ b/database/repository/posts.go @@ -2,6 +2,7 @@ package repository import ( "fmt" + "github.com/google/uuid" "github.com/oullin/database" "github.com/oullin/database/repository/pagination" From 3623b35e6a8feff40e378a4d1c73098a94ef5505 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 4 Sep 2025 13:01:20 +0800 Subject: [PATCH 14/30] fix query --- database/repository/api_keys.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/repository/api_keys.go b/database/repository/api_keys.go index c64c55fb..96bd7bf6 100644 --- a/database/repository/api_keys.go +++ b/database/repository/api_keys.go @@ -89,7 +89,7 @@ func (a ApiKeys) FindSignature(key *database.APIKey) *database.APIKeySignatures Model(&database.APIKeySignatures{}). Where("api_key_id = ?", key.ID). Where("tries <= ?", MaxSignaturesTries). - Where("expired_at IS NULL OR expired_at > ?", time.Now()). + Where("expires_at > ?", time.Now()). First(&item) if gorm.HasDbIssues(result.Error) { From 2486797d9f739d94848771dbf9ec5cae918f807b Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 4 Sep 2025 14:58:15 +0800 Subject: [PATCH 15/30] add tries --- handler/payload/signatures.go | 1 + handler/signatures.go | 1 + 2 files changed, 2 insertions(+) diff --git a/handler/payload/signatures.go b/handler/payload/signatures.go index 63a15166..11143787 100644 --- a/handler/payload/signatures.go +++ b/handler/payload/signatures.go @@ -9,6 +9,7 @@ type SignatureRequest struct { type SignatureResponse struct { Signature string `json:"signature"` + Tries int `json:"tries"` Cadence SignatureCadenceResponse `json:"cadence"` } diff --git a/handler/signatures.go b/handler/signatures.go index 76d3aefc..c64f2401 100644 --- a/handler/signatures.go +++ b/handler/signatures.go @@ -86,6 +86,7 @@ func (s *SignaturesHandler) Generate(w baseHttp.ResponseWriter, r *baseHttp.Requ response := payload.SignatureResponse{ Signature: auth.SignatureToString(keySignature.Signature), + Tries: keySignature.Tries, Cadence: payload.SignatureCadenceResponse{ ReceivedAt: receivedAt.Format(layout), CreatedAt: keySignature.CreatedAt.Format(layout), From 27a9f7303a12a7d2a0005eb7ad0adb6c54d6d21d Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 4 Sep 2025 16:23:27 +0800 Subject: [PATCH 16/30] format --- handler/signatures.go | 89 ++++++++++++++++++++++++----------------- main.go | 2 +- metal/cli/main.go | 30 -------------- metal/cli/panel/menu.go | 1 - pkg/http/response.go | 19 +++++++++ pkg/portal/consts.go | 3 ++ 6 files changed, 76 insertions(+), 68 deletions(-) create mode 100644 pkg/portal/consts.go diff --git a/handler/signatures.go b/handler/signatures.go index c64f2401..7d2e9b80 100644 --- a/handler/signatures.go +++ b/handler/signatures.go @@ -2,6 +2,7 @@ package handler import ( "encoding/json" + "fmt" "io" "log/slog" baseHttp "net/http" @@ -34,72 +35,88 @@ func (s *SignaturesHandler) Generate(w baseHttp.ResponseWriter, r *baseHttp.Requ var bodyBytes []byte bodyBytes, err = io.ReadAll(r.Body) - if err != nil { - slog.Error("Error reading signatures request body", "error", err) - return http.BadRequestError("could not read signatures request body") + if err != nil { + return http.LogBadRequestError("could not read signatures request body", err) } var req payload.SignatureRequest if err = json.Unmarshal(bodyBytes, &req); err != nil { - slog.Error("Error parsing signatures request", "error", err) - - return http.BadRequestError("could not parse the given data.") + return http.LogBadRequestError("could not parse the given data.", err) } if _, err = s.Validator.Rejects(req); err != nil { return http.UnprocessableEntity("The given fields are invalid", s.Validator.GetErrors()) } - var token *database.APIKey - if token = s.ApiKeys.FindBy(req.Username); token == nil { - return http.NotFound("The given username was not found") - } - - var seed []byte - if seed, err = auth.GenerateAESKey(); err != nil { - slog.Error("Error generating signatures seeds", "error", err) - - return http.InternalError("We were unable to generate the signature seed. Please try again!") - } - - layout := "2006-01-02 15:04:05" + serverTime := time.Now() receivedAt := time.Unix(req.Timestamp, 0) - createdAt := time.Now() - expiresAt := createdAt.Add(time.Second * 30) - - if receivedAt.Before(createdAt) { - slog.Error("Invalid timestamp while creating signatures", "error", err) - - return http.BadRequestError("The given timestamp is before the current time") + if err = s.isRequestWithinTimeframe(serverTime, receivedAt); err != nil { + return http.LogBadRequestError(err.Error(), err) } - hash := auth.CreateSignature(seed, token.SecretKey) var keySignature *database.APIKeySignatures - - if keySignature, err = s.ApiKeys.CreateSignatureFor(token, hash, expiresAt); err != nil { - slog.Error("Error creating signature in the db", "error", err) - - return http.InternalError("We were unable to create the signature item. Please try again!: " + err.Error()) + if keySignature, err = s.createSignature(req.Username, serverTime); err != nil { + return http.LogInternalError(err.Error(), err) } response := payload.SignatureResponse{ Signature: auth.SignatureToString(keySignature.Signature), Tries: keySignature.Tries, Cadence: payload.SignatureCadenceResponse{ - ReceivedAt: receivedAt.Format(layout), - CreatedAt: keySignature.CreatedAt.Format(layout), - ExpiresAt: keySignature.ExpiresAt.Format(layout), + ReceivedAt: receivedAt.Format(portal.DatesLayout), + CreatedAt: keySignature.CreatedAt.Format(portal.DatesLayout), + ExpiresAt: keySignature.ExpiresAt.Format(portal.DatesLayout), }, } resp := http.MakeResponseFrom("0.0.1", w, r) + if err = resp.RespondOk(response); err != nil { slog.Error("Error marshaling JSON for signatures response", "error", err) - return nil } return nil // A nil return indicates success. } + +func (s *SignaturesHandler) isRequestWithinTimeframe(serverTime, receivedAt time.Time) error { + skew := 5 * time.Second + + earliestValidTime := serverTime.Add(-skew) + if receivedAt.Before(earliestValidTime) { + return fmt.Errorf("the request timestamp [%s] is too old", receivedAt.Format(portal.DatesLayout)) + } + + latestValidTime := serverTime.Add(skew) + if receivedAt.After(latestValidTime) { + return fmt.Errorf("the request timestamp [%s] is from the future", receivedAt.Format(portal.DatesLayout)) + } + + return nil +} + +func (s *SignaturesHandler) createSignature(username string, serverTime time.Time) (*database.APIKeySignatures, error) { + var err error + var token *database.APIKey + var keySignature *database.APIKeySignatures + + if token = s.ApiKeys.FindBy(username); token == nil { + return nil, fmt.Errorf("the given username [%s] was not found", username) + } + + var seed []byte + if seed, err = auth.GenerateAESKey(); err != nil { + return nil, fmt.Errorf("unable to generate the signature seed. Please try again") + } + + expiresAt := serverTime.Add(time.Second * 30) + hash := auth.CreateSignature(seed, token.SecretKey) + + if keySignature, err = s.ApiKeys.CreateSignatureFor(token, hash, expiresAt); err != nil { + return nil, fmt.Errorf("unable to create the signature item. Please try again") + } + + return keySignature, nil +} diff --git a/main.go b/main.go index 2886c0ae..03b59c8e 100644 --- a/main.go +++ b/main.go @@ -51,7 +51,7 @@ func main() { } func serverHandler() baseHttp.Handler { - if app.IsProduction() { // CORS is handled by Caddy. + if app.IsProduction() { // Caddy handles CORS. return app.GetMux() } diff --git a/metal/cli/main.go b/metal/cli/main.go index 540ea72c..c841cef2 100644 --- a/metal/cli/main.go +++ b/metal/cli/main.go @@ -1,15 +1,12 @@ package main import ( - "fmt" - "github.com/oullin/database" "github.com/oullin/metal/cli/accounts" "github.com/oullin/metal/cli/panel" "github.com/oullin/metal/cli/posts" "github.com/oullin/metal/env" "github.com/oullin/metal/kernel" - "github.com/oullin/pkg/auth" "github.com/oullin/pkg/cli" "github.com/oullin/pkg/portal" ) @@ -65,13 +62,6 @@ func main() { continue } - return - case 5: - if err = generateAppEncryptionKey(); err != nil { - cli.Errorln(err.Error()) - continue - } - return case 0: cli.Successln("Goodbye!") @@ -162,23 +152,3 @@ func generateApiAccountsHTTPSignature(menu panel.Menu) error { return nil } - -// @todo Remove! -func generateAppEncryptionKey() error { - var err error - var key []byte - - if key, err = auth.GenerateAESKey(); err != nil { - return err - } - - decoded := fmt.Sprintf("%x", key) - - cli.Successln("\n The key was generated successfully.") - cli.Magentaln(fmt.Sprintf(" > Full key: %s", decoded)) - cli.Cyanln(fmt.Sprintf(" > First half : %s", decoded[:32])) - cli.Cyanln(fmt.Sprintf(" > Second half: %s", decoded[32:])) - fmt.Println(" ") - - return nil -} diff --git a/metal/cli/panel/menu.go b/metal/cli/panel/menu.go index 46b9be38..f6169784 100644 --- a/metal/cli/panel/menu.go +++ b/metal/cli/panel/menu.go @@ -90,7 +90,6 @@ func (p *Menu) Print() { p.PrintOption("2) Create new API account.", inner) p.PrintOption("3) Show API accounts.", inner) p.PrintOption("4) Generate API accounts HTTP keys pair.", inner) - p.PrintOption("5) [deprecated] Generate app encryption key.", inner) p.PrintOption(" ", inner) p.PrintOption("0) Exit.", inner) diff --git a/pkg/http/response.go b/pkg/http/response.go index 42ea1619..755bf6c0 100644 --- a/pkg/http/response.go +++ b/pkg/http/response.go @@ -3,6 +3,7 @@ package http import ( "encoding/json" "fmt" + "log/slog" baseHttp "net/http" "strings" ) @@ -72,6 +73,15 @@ func InternalError(msg string) *ApiError { } } +func LogInternalError(msg string, err error) *ApiError { + slog.Error(err.Error(), "error", err) + + return &ApiError{ + Message: fmt.Sprintf("Internal server error: %s", msg), + Status: baseHttp.StatusInternalServerError, + } +} + func BadRequestError(msg string) *ApiError { return &ApiError{ Message: fmt.Sprintf("Bad request error: %s", msg), @@ -79,6 +89,15 @@ func BadRequestError(msg string) *ApiError { } } +func LogBadRequestError(msg string, err error) *ApiError { + slog.Error(err.Error(), "error", err) + + return &ApiError{ + Message: fmt.Sprintf("Bad request error: %s", msg), + Status: baseHttp.StatusBadRequest, + } +} + func UnprocessableEntity(msg string, errors map[string]any) *ApiError { return &ApiError{ Message: fmt.Sprintf("Unprocessable entity: %s", msg), diff --git a/pkg/portal/consts.go b/pkg/portal/consts.go new file mode 100644 index 00000000..68561d1e --- /dev/null +++ b/pkg/portal/consts.go @@ -0,0 +1,3 @@ +package portal + +const DatesLayout = "2006-01-02 15:04:05" From 0afc249db803a9b17c55c75bea4946976b2a9fc0 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 4 Sep 2025 17:46:43 +0800 Subject: [PATCH 17/30] wip --- database/repository/api_keys.go | 15 +++ handler/signatures.go | 26 ++-- pkg/middleware/headers.go | 11 ++ pkg/middleware/token_middleware.go | 194 +++++++++++++---------------- pkg/middleware/valid_timestamp.go | 19 +-- 5 files changed, 134 insertions(+), 131 deletions(-) create mode 100644 pkg/middleware/headers.go diff --git a/database/repository/api_keys.go b/database/repository/api_keys.go index 96bd7bf6..7dff07e7 100644 --- a/database/repository/api_keys.go +++ b/database/repository/api_keys.go @@ -102,3 +102,18 @@ func (a ApiKeys) FindSignature(key *database.APIKey) *database.APIKeySignatures return nil } + +func (a ApiKeys) DisableSignaturesFor(key *database.APIKey, id int64) error { + result := a.DB.Sql(). + Model(&database.APIKeySignatures{}). + Where("api_key_id = ?", key.ID). + Where("id != ?", id). + Where("expired_at is null"). + Update("expired_at", time.Now()) + + if gorm.HasDbIssues(result.Error) { + return result.Error + } + + return nil +} diff --git a/handler/signatures.go b/handler/signatures.go index 7d2e9b80..7f113a8e 100644 --- a/handler/signatures.go +++ b/handler/signatures.go @@ -82,17 +82,17 @@ func (s *SignaturesHandler) Generate(w baseHttp.ResponseWriter, r *baseHttp.Requ } func (s *SignaturesHandler) isRequestWithinTimeframe(serverTime, receivedAt time.Time) error { - skew := 5 * time.Second - - earliestValidTime := serverTime.Add(-skew) - if receivedAt.Before(earliestValidTime) { - return fmt.Errorf("the request timestamp [%s] is too old", receivedAt.Format(portal.DatesLayout)) - } - - latestValidTime := serverTime.Add(skew) - if receivedAt.After(latestValidTime) { - return fmt.Errorf("the request timestamp [%s] is from the future", receivedAt.Format(portal.DatesLayout)) - } + //skew := 5 * time.Second + + //earliestValidTime := serverTime.Add(-skew) + //if receivedAt.Before(earliestValidTime) { + // return fmt.Errorf("the request timestamp [%s] is too old", receivedAt.Format(portal.DatesLayout)) + //} + // + //latestValidTime := serverTime.Add(skew) + //if receivedAt.After(latestValidTime) { + // return fmt.Errorf("the request timestamp [%s] is from the future", receivedAt.Format(portal.DatesLayout)) + //} return nil } @@ -118,5 +118,9 @@ func (s *SignaturesHandler) createSignature(username string, serverTime time.Tim return nil, fmt.Errorf("unable to create the signature item. Please try again") } + if result := s.ApiKeys.DisableSignaturesFor(token, keySignature.ID); result != nil { + return nil, fmt.Errorf("unable to disable the old signature items. Please try again") + } + return keySignature, nil } diff --git a/pkg/middleware/headers.go b/pkg/middleware/headers.go new file mode 100644 index 00000000..1128cba3 --- /dev/null +++ b/pkg/middleware/headers.go @@ -0,0 +1,11 @@ +package middleware + +type AuthTokenHeaders struct { + AccountName string + PublicKey string + Signature string + Timestamp string + Nonce string + ClientIP string + RequestID string +} diff --git a/pkg/middleware/token_middleware.go b/pkg/middleware/token_middleware.go index 99c23575..a8e6c3ef 100644 --- a/pkg/middleware/token_middleware.go +++ b/pkg/middleware/token_middleware.go @@ -1,11 +1,10 @@ package middleware import ( - "bytes" "context" "crypto/sha256" "crypto/subtle" - "io" + "fmt" "log/slog" baseHttp "net/http" "strings" @@ -64,7 +63,7 @@ type TokenCheckMiddleware struct { // validating the request timestamp. clockSkew time.Duration - // now is an injectable time source for deterministic tests. If nil, time.Now is used. + // Now is an injectable time source for deterministic tests. If nil, time.Now is used. now func() time.Time // disallowFuture, if true, rejects timestamps greater than the current server time, @@ -77,7 +76,7 @@ type TokenCheckMiddleware struct { // failWindow indicates the sliding time window used to evaluate authentication failures. failWindow time.Duration - // maxFailPerScope is the maximum number of failures allowed within failWindow for a given scope. + // maxFailPerScope is the maximum number of failures allowed within the failWindow for a given scope. maxFailPerScope int } @@ -99,53 +98,38 @@ func MakeTokenMiddleware(tokenHandler *auth.TokenHandler, apiKeys *repository.Ap func (t TokenCheckMiddleware) Handle(next http.ApiHandler) http.ApiHandler { return func(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { reqID := strings.TrimSpace(r.Header.Get(requestIDHeader)) - logger := slog.With("request_id", reqID, "path", r.URL.Path, "method", r.Method) - if reqID == "" || logger == nil { - return t.getInvalidRequestError() + if reqID == "" { + return t.getInvalidRequestError(fmt.Sprintf("Invalid request ID for URL [%s].", r.URL.Path)) } - if depErr := t.guardDependencies(logger); depErr != nil { - return depErr + if err := t.guardDependencies(); err != nil { + return err } - // Extract and validate required headers - accountName, publicToken, signature, ts, nonce, hdrErr := t.validateAndGetHeaders(r, logger) - if hdrErr != nil { - return hdrErr + headers, err := t.validateAndGetHeaders(r, reqID) + if err != nil { + return err } // Validate timestamp within allowed skew using ValidTimestamp helper - vt := NewValidTimestamp(ts, logger, t.now) + vt := NewValidTimestamp(headers.Timestamp, t.now) if tsErr := vt.Validate(t.clockSkew, t.disallowFuture); tsErr != nil { return tsErr } - // Read body and compute hash - bodyHash, bodyErr := t.readBodyHash(r, logger) - if bodyErr != nil { - return bodyErr - } - - // Build a canonical request string - canonical := portal.BuildCanonical(r.Method, r.URL, accountName, publicToken, ts, nonce, bodyHash) - - clientIP := portal.ParseClientIP(r) - - if err := t.shallReject(logger, accountName, publicToken, signature, canonical, nonce, clientIP); err != nil { + if err = t.shallReject(headers); err != nil { return err } // Update the request context - r = t.attachContext(r, accountName, reqID) - - logger.Info("authentication successful") + r = t.attachContext(r, headers) return next(w, r) } } -func (t TokenCheckMiddleware) guardDependencies(logger *slog.Logger) *http.ApiError { +func (t TokenCheckMiddleware) guardDependencies() *http.ApiError { missing := make([]string, 0, 4) if t.ApiKeys == nil { @@ -165,70 +149,60 @@ func (t TokenCheckMiddleware) guardDependencies(logger *slog.Logger) *http.ApiEr } if len(missing) > 0 { - logger.Error("token middleware missing dependencies", "missing", strings.Join(missing, ",")) - return t.getUnauthenticatedError() + return t.getUnauthenticatedError( + "token middleware missing dependencies: " + strings.Join(missing, ",") + ".", + ) } return nil } -func (t TokenCheckMiddleware) validateAndGetHeaders(r *baseHttp.Request, logger *slog.Logger) (accountName, publicToken, signature, ts, nonce string, apiErr *http.ApiError) { - accountName = strings.TrimSpace(r.Header.Get(usernameHeader)) - publicToken = strings.TrimSpace(r.Header.Get(tokenHeader)) - signature = strings.TrimSpace(r.Header.Get(signatureHeader)) - ts = strings.TrimSpace(r.Header.Get(timestampHeader)) - nonce = strings.TrimSpace(r.Header.Get(nonceHeader)) +func (t TokenCheckMiddleware) validateAndGetHeaders(r *baseHttp.Request, requestId string) (AuthTokenHeaders, *http.ApiError) { + accountName := strings.TrimSpace(r.Header.Get(usernameHeader)) + signature := strings.TrimSpace(r.Header.Get(signatureHeader)) + publicToken := strings.TrimSpace(r.Header.Get(tokenHeader)) + ts := strings.TrimSpace(r.Header.Get(timestampHeader)) + nonce := strings.TrimSpace(r.Header.Get(nonceHeader)) + ip := portal.ParseClientIP(r) - if accountName == "" || publicToken == "" || signature == "" || ts == "" || nonce == "" { - logger.Warn("missing authentication headers") - return "", "", "", "", "", t.getInvalidRequestError() + if accountName == "" || publicToken == "" || signature == "" || ts == "" || nonce == "" || ip == "" { + return AuthTokenHeaders{}, t.getInvalidRequestError("Invalid authentication headers / or missing headers") } if err := auth.ValidateTokenFormat(publicToken); err != nil { - logger.Warn("invalid token format") - return "", "", "", "", "", t.getInvalidTokenFormatError() + return AuthTokenHeaders{}, t.getInvalidTokenFormatError(err.Error()) } - return accountName, publicToken, signature, ts, nonce, nil -} - -func (t TokenCheckMiddleware) readBodyHash(r *baseHttp.Request, logger *slog.Logger) (string, *http.ApiError) { - if r.Body == nil { - return portal.Sha256Hex(nil), nil - } - - b, err := portal.ReadWithSizeLimit(r.Body) - if err != nil { - logger.Warn("unable to read body for signing") - return "", t.getInvalidRequestError() - } - - // restore for downstream handlers - r.Body = io.NopCloser(bytes.NewReader(b)) - - return portal.Sha256Hex(b), nil + return AuthTokenHeaders{ + AccountName: accountName, + PublicKey: publicToken, + Signature: signature, + Timestamp: ts, + Nonce: nonce, + ClientIP: ip, + RequestID: requestId, + }, nil } -func (t TokenCheckMiddleware) attachContext(r *baseHttp.Request, accountName, reqID string) *baseHttp.Request { - ctx := context.WithValue(r.Context(), authAccountNameKey, accountName) - ctx = context.WithValue(r.Context(), requestIdKey, reqID) +func (t TokenCheckMiddleware) attachContext(r *baseHttp.Request, headers AuthTokenHeaders) *baseHttp.Request { + ctx := context.WithValue(r.Context(), authAccountNameKey, headers.AccountName) + ctx = context.WithValue(r.Context(), requestIdKey, headers.RequestID) return r.WithContext(ctx) } -func (t TokenCheckMiddleware) shallReject(logger *slog.Logger, accountName, publicToken, signature, canonical, nonce, clientIP string) *http.ApiError { - limiterKey := clientIP + "|" + strings.ToLower(accountName) +func (t TokenCheckMiddleware) shallReject(headers AuthTokenHeaders) *http.ApiError { + limiterKey := headers.ClientIP + "|" + strings.ToLower(headers.AccountName) if t.rateLimiter.TooMany(limiterKey) { - logger.Warn("too many authentication failures", "ip", clientIP) - return t.getRateLimitedError() + return t.getRateLimitedError("Too many authentication attempts for key: " + limiterKey) } var item *database.APIKey - if item = t.ApiKeys.FindBy(accountName); item == nil { - t.rateLimiter.Fail(limiterKey) - logger.Warn("account not found") - return t.getUnauthenticatedError() + if item = t.ApiKeys.FindBy(headers.AccountName); item == nil { + t.rateLimiter.Fail(headers.AccountName) + + return t.getUnauthenticatedError("Account not found: " + headers.AccountName + ".") } // Fetch account to understand its keys @@ -240,83 +214,95 @@ func (t TokenCheckMiddleware) shallReject(logger *slog.Logger, accountName, publ if err != nil { t.rateLimiter.Fail(limiterKey) - logger.Error("failed to decode account keys", "account", item.AccountName, "error", err) - return t.getUnauthenticatedError() + + return t.getUnauthenticatedError(err.Error()) } - // Constant-time compare (fixed-length by hashing) of provided public token vs stored one - pBytes := []byte(strings.TrimSpace(publicToken)) + // Constant-time compare (fixed-length by hashing) of provided public token vs. stored one + pBytes := []byte(strings.TrimSpace(headers.PublicKey)) eBytes := []byte(strings.TrimSpace(token.PublicKey)) hP := sha256.Sum256(pBytes) hE := sha256.Sum256(eBytes) if subtle.ConstantTimeCompare(hP[:], hE[:]) != 1 { t.rateLimiter.Fail(limiterKey) - logger.Warn("public token mismatch", "account", item.AccountName) - return t.getUnauthenticatedError() - } - // Compute local signature over canonical request and compare in constant time (hash to fixed-length first) - localSignature := auth.CreateSignatureFrom(canonical, token.PublicKey) //@todo Change! - hSig := sha256.Sum256([]byte(strings.TrimSpace(signature))) - hLocal := sha256.Sum256([]byte(localSignature)) - - if subtle.ConstantTimeCompare(hSig[:], hLocal[:]) != 1 { - t.rateLimiter.Fail(limiterKey) - logger.Warn("signature mismatch", "account", item.AccountName) - return t.getUnauthenticatedError() + return t.getUnauthenticatedError("Invalid public token: " + headers.PublicKey + ".") } - // Nonce replay protection: atomically check-and-mark (UseOnce) - if t.nonceCache != nil { - key := item.AccountName + "|" + nonce - - if t.nonceCache.UseOnce(key, t.nonceTTL) { - t.rateLimiter.Fail(limiterKey) - logger.Warn("replay detected: nonce already used", "account", item.AccountName) - return t.getUnauthenticatedError() - } - } + //// Compute local signature over canonical request and compare in constant time (hash to fixed-length first) + //localSignature := auth.CreateSignatureFrom("", token.PublicKey) //@todo Change! + //hSig := sha256.Sum256([]byte(strings.TrimSpace(headers.Signature))) + //hLocal := sha256.Sum256([]byte(localSignature)) + // + //if subtle.ConstantTimeCompare(hSig[:], hLocal[:]) != 1 { + // t.rateLimiter.Fail(limiterKey) + // + // return t.getUnauthenticatedError("Invalid signature: " + headers.Signature + ".") + //} + // + //// Nonce replay protection: atomically check-and-mark (UseOnce) + //if t.nonceCache != nil { + // key := item.AccountName + "|" + headers.Nonce + // + // if t.nonceCache.UseOnce(key, t.nonceTTL) { + // t.rateLimiter.Fail(limiterKey) + // + // return t.getUnauthenticatedError("Invalid nonce: " + headers.Nonce + ".") + // } + //} return nil } -func (t TokenCheckMiddleware) getInvalidRequestError() *http.ApiError { +func (t TokenCheckMiddleware) getInvalidRequestError(logMessage string) *http.ApiError { + slog.Error(logMessage, "error") + return &http.ApiError{ Message: "Invalid authentication headers", Status: baseHttp.StatusUnauthorized, } } -func (t TokenCheckMiddleware) getInvalidTokenFormatError() *http.ApiError { +func (t TokenCheckMiddleware) getInvalidTokenFormatError(logMessage string) *http.ApiError { + slog.Error(logMessage, "error") + return &http.ApiError{ Message: "Invalid credentials", Status: baseHttp.StatusUnauthorized, } } -func (t TokenCheckMiddleware) getUnauthenticatedError() *http.ApiError { +func (t TokenCheckMiddleware) getUnauthenticatedError(logMessage string) *http.ApiError { + slog.Error(logMessage, "error") + return &http.ApiError{ Message: "Invalid credentials", Status: baseHttp.StatusUnauthorized, } } -func (t TokenCheckMiddleware) getRateLimitedError() *http.ApiError { +func (t TokenCheckMiddleware) getRateLimitedError(logMessage string) *http.ApiError { + slog.Error(logMessage, "error") + return &http.ApiError{ Message: "Too many authentication attempts", Status: baseHttp.StatusTooManyRequests, } } -func (t TokenCheckMiddleware) getTimestampTooOldError() *http.ApiError { +func (t TokenCheckMiddleware) getTimestampTooOldError(logMessage string) *http.ApiError { + slog.Error(logMessage, "error") + return &http.ApiError{ Message: "Request timestamp expired", Status: baseHttp.StatusUnauthorized, } } -func (t TokenCheckMiddleware) getTimestampTooNewError() *http.ApiError { +func (t TokenCheckMiddleware) getTimestampTooNewError(logMessage string) *http.ApiError { + slog.Error(logMessage, "error") + return &http.ApiError{ Message: "Request timestamp invalid", Status: baseHttp.StatusUnauthorized, diff --git a/pkg/middleware/valid_timestamp.go b/pkg/middleware/valid_timestamp.go index 88cb2dbc..2809b6b7 100644 --- a/pkg/middleware/valid_timestamp.go +++ b/pkg/middleware/valid_timestamp.go @@ -1,7 +1,6 @@ package middleware import ( - "log/slog" baseHttp "net/http" "strconv" "time" @@ -16,34 +15,24 @@ type ValidTimestamp struct { // ts is the timestamp string (expected Unix epoch in seconds). ts string - // logger is used to record validation details. - logger *slog.Logger - // now returns the current time; useful to inject a deterministic clock in tests. now func() time.Time } -func NewValidTimestamp(ts string, logger *slog.Logger, now func() time.Time) ValidTimestamp { +func NewValidTimestamp(ts string, now func() time.Time) ValidTimestamp { return ValidTimestamp{ - ts: ts, - logger: logger, - now: now, + ts: ts, + now: now, } } func (v ValidTimestamp) Validate(skew time.Duration, disallowFuture bool) *http.ApiError { - if v.logger == nil { - return &http.ApiError{Message: "Invalid timestamp headers tracker", Status: baseHttp.StatusUnauthorized} - } - if v.ts == "" { - v.logger.Warn("missing timestamp") return &http.ApiError{Message: "Invalid authentication headers", Status: baseHttp.StatusUnauthorized} } epoch, err := strconv.ParseInt(v.ts, 10, 64) if err != nil { - v.logger.Warn("invalid timestamp format") return &http.ApiError{Message: "Invalid authentication headers", Status: baseHttp.StatusUnauthorized} } @@ -66,12 +55,10 @@ func (v ValidTimestamp) Validate(skew time.Duration, disallowFuture bool) *http. } if epoch < minValue { - v.logger.Warn("timestamp outside allowed window: too old") return &http.ApiError{Message: "Request timestamp expired", Status: baseHttp.StatusUnauthorized} } if epoch > maxValue { - v.logger.Warn("timestamp outside allowed window: in the future") return &http.ApiError{Message: "Request timestamp invalid", Status: baseHttp.StatusUnauthorized} } From 40571ad35d3aeff9c1645da50d4206fb722eed38 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 5 Sep 2025 10:00:35 +0800 Subject: [PATCH 18/30] use a DB trasaction instead --- database/repository/api_keys.go | 60 +++++++++++++++++++-------------- handler/signatures.go | 4 --- pkg/gorm/support.go | 1 + 3 files changed, 36 insertions(+), 29 deletions(-) diff --git a/database/repository/api_keys.go b/database/repository/api_keys.go index 7dff07e7..8398a14f 100644 --- a/database/repository/api_keys.go +++ b/database/repository/api_keys.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" "github.com/oullin/database" "github.com/oullin/pkg/gorm" + baseGorm "gorm.io/gorm" ) const MaxSignaturesTries = 5 @@ -36,6 +37,24 @@ func (a ApiKeys) Create(attrs database.APIKeyAttr) (*database.APIKey, error) { return &key, nil } +func (a ApiKeys) FindBy(accountName string) *database.APIKey { + key := database.APIKey{} + + result := a.DB.Sql(). + Where("LOWER(account_name) = ?", strings.ToLower(accountName)). + First(&key) + + if gorm.HasDbIssues(result.Error) { + return nil + } + + if result.RowsAffected > 0 { + return &key + } + + return nil +} + func (a ApiKeys) CreateSignatureFor(key *database.APIKey, seed []byte, expiresAt time.Time) (*database.APIKeySignatures, error) { var item *database.APIKeySignatures @@ -43,43 +62,34 @@ func (a ApiKeys) CreateSignatureFor(key *database.APIKey, seed []byte, expiresAt return item, nil } + now := time.Now() signature := database.APIKeySignatures{ UUID: uuid.NewString(), APIKeyID: key.ID, Signature: seed, Tries: MaxSignaturesTries, ExpiresAt: expiresAt, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + CreatedAt: now, + UpdatedAt: now, } - if result := a.DB.Sql().Create(&signature); gorm.HasDbIssues(result.Error) { - return nil, fmt.Errorf( - "issue creating the given api key signature [%s, %s]: ", - key.AccountName, - result.Error, - ) - } - - return &signature, nil -} - -func (a ApiKeys) FindBy(accountName string) *database.APIKey { - key := database.APIKey{} + err := a.DB.Transaction(func(tx *baseGorm.DB) error { + if result := a.DB.Sql().Create(&signature); gorm.HasDbIssues(result.Error) { + return fmt.Errorf("issue creating the given api key signature [%s, %s]: ", key.AccountName, result.Error) + } - result := a.DB.Sql(). - Where("LOWER(account_name) = ?", strings.ToLower(accountName)). - First(&key) + if result := a.DisablePreviousSignatures(key, signature.UUID); result != nil { + return fmt.Errorf("issue creating the given api key signature [%s, %s]: ", key.AccountName, result.Error()) + } - if gorm.HasDbIssues(result.Error) { return nil - } + }) - if result.RowsAffected > 0 { - return &key + if err != nil { + return nil, err } - return nil + return &signature, nil } func (a ApiKeys) FindSignature(key *database.APIKey) *database.APIKeySignatures { @@ -103,11 +113,11 @@ func (a ApiKeys) FindSignature(key *database.APIKey) *database.APIKeySignatures return nil } -func (a ApiKeys) DisableSignaturesFor(key *database.APIKey, id int64) error { +func (a ApiKeys) DisablePreviousSignatures(key *database.APIKey, signatureUUID string) error { result := a.DB.Sql(). Model(&database.APIKeySignatures{}). Where("api_key_id = ?", key.ID). - Where("id != ?", id). + Where("uuid != ?", signatureUUID). Where("expired_at is null"). Update("expired_at", time.Now()) diff --git a/handler/signatures.go b/handler/signatures.go index 7f113a8e..26b9c00b 100644 --- a/handler/signatures.go +++ b/handler/signatures.go @@ -118,9 +118,5 @@ func (s *SignaturesHandler) createSignature(username string, serverTime time.Tim return nil, fmt.Errorf("unable to create the signature item. Please try again") } - if result := s.ApiKeys.DisableSignaturesFor(token, keySignature.ID); result != nil { - return nil, fmt.Errorf("unable to disable the old signature items. Please try again") - } - return keySignature, nil } diff --git a/pkg/gorm/support.go b/pkg/gorm/support.go index 4a4f98df..20ed6e5c 100644 --- a/pkg/gorm/support.go +++ b/pkg/gorm/support.go @@ -2,6 +2,7 @@ package gorm import ( "errors" + "gorm.io/gorm" ) From 216affb0a515e8da3a9a2c10d4ee621298f1b7e6 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 5 Sep 2025 11:18:48 +0800 Subject: [PATCH 19/30] extract guard --- pkg/middleware/mwguards/mw_token_guard.go | 97 +++++++++++++++++++++++ pkg/middleware/token_middleware.go | 67 ++++------------ 2 files changed, 114 insertions(+), 50 deletions(-) create mode 100644 pkg/middleware/mwguards/mw_token_guard.go diff --git a/pkg/middleware/mwguards/mw_token_guard.go b/pkg/middleware/mwguards/mw_token_guard.go new file mode 100644 index 00000000..0aac6fd4 --- /dev/null +++ b/pkg/middleware/mwguards/mw_token_guard.go @@ -0,0 +1,97 @@ +package mwguards + +import ( + "crypto/sha256" + "crypto/subtle" + "fmt" + + "github.com/oullin/database" + "github.com/oullin/database/repository" + "github.com/oullin/pkg/auth" +) + +type MWTokenGuard struct { + Error error + ApiKey *database.APIKey + TokenHandler *auth.TokenHandler + KeysRepository *repository.ApiKeys +} + +type MWTokenGuardData struct { + Username string + PublicKey string +} + +func NewMWTokenGuard(apiKeys *repository.ApiKeys, TokenHandler *auth.TokenHandler) MWTokenGuard { + return MWTokenGuard{ + KeysRepository: apiKeys, + TokenHandler: TokenHandler, + } +} + +func (g *MWTokenGuard) Rejects(data MWTokenGuardData) bool { + if g.HasInvalidDependencies() { + g.Error = fmt.Errorf("invalid mw-token guard dependencies") + + return true + } + + if err := g.AccountNotFound(data.Username); err != nil { + g.Error = err + + return true + } + + if g.HasInvalidFormat(data.PublicKey) { + return true + } + + return false +} + +func (g *MWTokenGuard) HasInvalidDependencies() bool { + return g == nil || g.KeysRepository == nil || g.TokenHandler == nil +} + +func (g *MWTokenGuard) AccountNotFound(username string) error { + var item *database.APIKey + + if item = g.KeysRepository.FindBy(username); item == nil { + return fmt.Errorf("account [%s] not found", username) + } + + g.ApiKey = item + + return nil +} + +func (g *MWTokenGuard) HasInvalidFormat(publicKey string) bool { + token, err := g.TokenHandler.DecodeTokensFor( + g.ApiKey.AccountName, + g.ApiKey.SecretKey, + g.ApiKey.PublicKey, + ) + + if err != nil { + g.Error = fmt.Errorf("unable to decode the given account [%s] keys", g.ApiKey.AccountName) + + return true + } + + pBytes := []byte(publicKey) + eBytes := []byte(token.PublicKey) + hP := sha256.Sum256(pBytes) + hE := sha256.Sum256(eBytes) + + if subtle.ConstantTimeCompare(hP[:], hE[:]) != 1 { + g.Error = fmt.Errorf("invalid provided public token: %s", publicKey) + + return true + } + + return false +} + +func (g *MWTokenGuard) GetError() error { + return g.Error +} diff --git a/pkg/middleware/token_middleware.go b/pkg/middleware/token_middleware.go index a8e6c3ef..7a8a3a46 100644 --- a/pkg/middleware/token_middleware.go +++ b/pkg/middleware/token_middleware.go @@ -2,20 +2,18 @@ package middleware import ( "context" - "crypto/sha256" - "crypto/subtle" "fmt" "log/slog" baseHttp "net/http" "strings" "time" - "github.com/oullin/database" "github.com/oullin/database/repository" "github.com/oullin/pkg/auth" "github.com/oullin/pkg/cache" "github.com/oullin/pkg/http" "github.com/oullin/pkg/limiter" + "github.com/oullin/pkg/middleware/mwguards" "github.com/oullin/pkg/portal" ) @@ -133,7 +131,7 @@ func (t TokenCheckMiddleware) guardDependencies() *http.ApiError { missing := make([]string, 0, 4) if t.ApiKeys == nil { - missing = append(missing, "ApiKeys") + missing = append(missing, "KeysRepository") } if t.TokenHandler == nil { @@ -198,60 +196,29 @@ func (t TokenCheckMiddleware) shallReject(headers AuthTokenHeaders) *http.ApiErr return t.getRateLimitedError("Too many authentication attempts for key: " + limiterKey) } - var item *database.APIKey - if item = t.ApiKeys.FindBy(headers.AccountName); item == nil { - t.rateLimiter.Fail(headers.AccountName) + guard := mwguards.NewMWTokenGuard(t.ApiKeys, t.TokenHandler) - return t.getUnauthenticatedError("Account not found: " + headers.AccountName + ".") + rejectsRequest := mwguards.MWTokenGuardData{ + Username: headers.AccountName, + PublicKey: headers.PublicKey, } - // Fetch account to understand its keys - token, err := t.TokenHandler.DecodeTokensFor( - item.AccountName, - item.SecretKey, - item.PublicKey, - ) - - if err != nil { - t.rateLimiter.Fail(limiterKey) + if guard.Rejects(rejectsRequest) { + t.rateLimiter.Fail(headers.AccountName) - return t.getUnauthenticatedError(err.Error()) + return t.getUnauthenticatedError(guard.Error.Error()) } - // Constant-time compare (fixed-length by hashing) of provided public token vs. stored one - pBytes := []byte(strings.TrimSpace(headers.PublicKey)) - eBytes := []byte(strings.TrimSpace(token.PublicKey)) - hP := sha256.Sum256(pBytes) - hE := sha256.Sum256(eBytes) + if t.nonceCache != nil { + key := strings.ToLower(headers.AccountName) + "|" + headers.Nonce - if subtle.ConstantTimeCompare(hP[:], hE[:]) != 1 { - t.rateLimiter.Fail(limiterKey) + if t.nonceCache.UseOnce(key, t.nonceTTL) { + t.rateLimiter.Fail(limiterKey) - return t.getUnauthenticatedError("Invalid public token: " + headers.PublicKey + ".") + return t.getUnauthenticatedError("Invalid nonce: " + headers.Nonce + ".") + } } - //// Compute local signature over canonical request and compare in constant time (hash to fixed-length first) - //localSignature := auth.CreateSignatureFrom("", token.PublicKey) //@todo Change! - //hSig := sha256.Sum256([]byte(strings.TrimSpace(headers.Signature))) - //hLocal := sha256.Sum256([]byte(localSignature)) - // - //if subtle.ConstantTimeCompare(hSig[:], hLocal[:]) != 1 { - // t.rateLimiter.Fail(limiterKey) - // - // return t.getUnauthenticatedError("Invalid signature: " + headers.Signature + ".") - //} - // - //// Nonce replay protection: atomically check-and-mark (UseOnce) - //if t.nonceCache != nil { - // key := item.AccountName + "|" + headers.Nonce - // - // if t.nonceCache.UseOnce(key, t.nonceTTL) { - // t.rateLimiter.Fail(limiterKey) - // - // return t.getUnauthenticatedError("Invalid nonce: " + headers.Nonce + ".") - // } - //} - return nil } @@ -268,7 +235,7 @@ func (t TokenCheckMiddleware) getInvalidTokenFormatError(logMessage string) *htt slog.Error(logMessage, "error") return &http.ApiError{ - Message: "Invalid credentials", + Message: "1- Invalid credentials: " + logMessage, Status: baseHttp.StatusUnauthorized, } } @@ -277,7 +244,7 @@ func (t TokenCheckMiddleware) getUnauthenticatedError(logMessage string) *http.A slog.Error(logMessage, "error") return &http.ApiError{ - Message: "Invalid credentials", + Message: "2- Invalid credentials: " + logMessage, Status: baseHttp.StatusUnauthorized, } } From 5a14552657ec6457769cafcc1ce4c0b1db1b56d9 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 5 Sep 2025 11:55:24 +0800 Subject: [PATCH 20/30] format --- pkg/http/response.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/http/response.go b/pkg/http/response.go index 755bf6c0..7fef9eea 100644 --- a/pkg/http/response.go +++ b/pkg/http/response.go @@ -32,7 +32,7 @@ func MakeResponseFrom(salt string, writer baseHttp.ResponseWriter, request *base headers: func(w baseHttp.ResponseWriter) { w.Header().Set("Content-Type", "application/json") w.Header().Set("X-Content-Type-Options", "nosniff") - //w.Header().Set("Cache-Control", cacheControl) + w.Header().Set("Cache-Control", cacheControl) w.Header().Set("ETag", etag) }, } From a9dc4e8c1485402e392de733074300cdf85d825b Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 5 Sep 2025 12:26:20 +0800 Subject: [PATCH 21/30] response --- .../mwguards/mw_response_messages.go | 106 ++++++++++++++++++ pkg/middleware/mwguards/mw_token_guard.go | 7 ++ pkg/middleware/token_middleware.go | 105 ++++++----------- .../token_middleware_additional_test.go | 4 +- pkg/middleware/token_middleware_test.go | 6 +- 5 files changed, 152 insertions(+), 76 deletions(-) create mode 100644 pkg/middleware/mwguards/mw_response_messages.go diff --git a/pkg/middleware/mwguards/mw_response_messages.go b/pkg/middleware/mwguards/mw_response_messages.go new file mode 100644 index 00000000..dd3c1fc1 --- /dev/null +++ b/pkg/middleware/mwguards/mw_response_messages.go @@ -0,0 +1,106 @@ +package mwguards + +import ( + "log/slog" + baseHttp "net/http" + "strings" + + "github.com/oullin/pkg/http" +) + +func normaliseData(data ...map[string]any) map[string]any { + if data == nil || len(data) == 0 { + return map[string]any{} + } + + result := make(map[string]any, len(data)) + for _, d := range data { + for k, v := range d { + result[k] = v + } + } + + return result +} + +func normaliseMessages(message, logMessage string) (string, string) { + message = strings.TrimSpace(message) + + if strings.TrimSpace(logMessage) == "" { + logMessage = message + } + + return message, logMessage +} + +func InvalidRequestError(message, logMessage string, data ...map[string]any) *http.ApiError { + message, logMessage = normaliseMessages(message, logMessage) + + slog.Error(logMessage, "error") + + return &http.ApiError{ + Message: message, + Status: baseHttp.StatusUnauthorized, + Data: normaliseData(data...), + } +} + +func InvalidTokenFormatError(message, logMessage string, data ...map[string]any) *http.ApiError { + message, logMessage = normaliseMessages(message, logMessage) + + slog.Error(logMessage, "error") + + return &http.ApiError{ + Message: message, + Status: baseHttp.StatusUnauthorized, + Data: normaliseData(data...), + } +} + +func UnauthenticatedError(message, logMessage string, data ...map[string]any) *http.ApiError { + message, logMessage = normaliseMessages(message, logMessage) + + slog.Error(logMessage, "error") + + return &http.ApiError{ + Message: "2- Invalid credentials: " + logMessage, + Status: baseHttp.StatusUnauthorized, + Data: normaliseData(data...), + } +} + +func RateLimitedError(message, logMessage string, data ...map[string]any) *http.ApiError { + message, logMessage = normaliseMessages(message, logMessage) + + slog.Error(logMessage, "error") + + return &http.ApiError{ + Message: "Too many authentication attempts", + Status: baseHttp.StatusTooManyRequests, + Data: normaliseData(data...), + } +} + +func TimestampTooOldError(message, logMessage string, data ...map[string]any) *http.ApiError { + message, logMessage = normaliseMessages(message, logMessage) + + slog.Error(logMessage, "error") + + return &http.ApiError{ + Message: "Request timestamp expired", + Status: baseHttp.StatusUnauthorized, + Data: normaliseData(data...), + } +} + +func TimestampTooNewError(message, logMessage string, data ...map[string]any) *http.ApiError { + message, logMessage = normaliseMessages(message, logMessage) + + slog.Error(logMessage, "error") + + return &http.ApiError{ + Message: "Request timestamp invalid", + Status: baseHttp.StatusUnauthorized, + Data: normaliseData(data...), + } +} diff --git a/pkg/middleware/mwguards/mw_token_guard.go b/pkg/middleware/mwguards/mw_token_guard.go index 0aac6fd4..5ef6fa5b 100644 --- a/pkg/middleware/mwguards/mw_token_guard.go +++ b/pkg/middleware/mwguards/mw_token_guard.go @@ -95,3 +95,10 @@ func (g *MWTokenGuard) HasInvalidFormat(publicKey string) bool { func (g *MWTokenGuard) GetError() error { return g.Error } + +func (receiver MWTokenGuardData) ToMap() map[string]any { + return map[string]any{ + "username": receiver.Username, + "public_key": receiver.PublicKey, + } +} diff --git a/pkg/middleware/token_middleware.go b/pkg/middleware/token_middleware.go index 7a8a3a46..c8d63d18 100644 --- a/pkg/middleware/token_middleware.go +++ b/pkg/middleware/token_middleware.go @@ -3,7 +3,6 @@ package middleware import ( "context" "fmt" - "log/slog" baseHttp "net/http" "strings" "time" @@ -98,14 +97,14 @@ func (t TokenCheckMiddleware) Handle(next http.ApiHandler) http.ApiHandler { reqID := strings.TrimSpace(r.Header.Get(requestIDHeader)) if reqID == "" { - return t.getInvalidRequestError(fmt.Sprintf("Invalid request ID for URL [%s].", r.URL.Path)) + return mwguards.InvalidRequestError(fmt.Sprintf("Invalid request ID for URL [%s].", r.URL.Path), "") } - if err := t.guardDependencies(); err != nil { + if err := t.GuardDependencies(); err != nil { return err } - headers, err := t.validateAndGetHeaders(r, reqID) + headers, err := t.ValidateAndGetHeaders(r, reqID) if err != nil { return err } @@ -116,18 +115,18 @@ func (t TokenCheckMiddleware) Handle(next http.ApiHandler) http.ApiHandler { return tsErr } - if err = t.shallReject(headers); err != nil { + if err = t.HasInvalidFormat(headers); err != nil { return err } // Update the request context - r = t.attachContext(r, headers) + r = t.AttachContext(r, headers) return next(w, r) } } -func (t TokenCheckMiddleware) guardDependencies() *http.ApiError { +func (t TokenCheckMiddleware) GuardDependencies() *http.ApiError { missing := make([]string, 0, 4) if t.ApiKeys == nil { @@ -147,15 +146,19 @@ func (t TokenCheckMiddleware) guardDependencies() *http.ApiError { } if len(missing) > 0 { - return t.getUnauthenticatedError( - "token middleware missing dependencies: " + strings.Join(missing, ",") + ".", + return mwguards.UnauthenticatedError( + "token middleware missing dependencies", + "token middleware missing dependencies: "+strings.Join(missing, ",")+".", + map[string]any{ + "missing": missing, + }, ) } return nil } -func (t TokenCheckMiddleware) validateAndGetHeaders(r *baseHttp.Request, requestId string) (AuthTokenHeaders, *http.ApiError) { +func (t TokenCheckMiddleware) ValidateAndGetHeaders(r *baseHttp.Request, requestId string) (AuthTokenHeaders, *http.ApiError) { accountName := strings.TrimSpace(r.Header.Get(usernameHeader)) signature := strings.TrimSpace(r.Header.Get(signatureHeader)) publicToken := strings.TrimSpace(r.Header.Get(tokenHeader)) @@ -164,11 +167,14 @@ func (t TokenCheckMiddleware) validateAndGetHeaders(r *baseHttp.Request, request ip := portal.ParseClientIP(r) if accountName == "" || publicToken == "" || signature == "" || ts == "" || nonce == "" || ip == "" { - return AuthTokenHeaders{}, t.getInvalidRequestError("Invalid authentication headers / or missing headers") + return AuthTokenHeaders{}, mwguards.InvalidRequestError( + "Invalid authentication headers / or missing headers", + "", + ) } if err := auth.ValidateTokenFormat(publicToken); err != nil { - return AuthTokenHeaders{}, t.getInvalidTokenFormatError(err.Error()) + return AuthTokenHeaders{}, mwguards.InvalidTokenFormatError(err.Error(), "", map[string]any{}) } return AuthTokenHeaders{ @@ -182,18 +188,21 @@ func (t TokenCheckMiddleware) validateAndGetHeaders(r *baseHttp.Request, request }, nil } -func (t TokenCheckMiddleware) attachContext(r *baseHttp.Request, headers AuthTokenHeaders) *baseHttp.Request { +func (t TokenCheckMiddleware) AttachContext(r *baseHttp.Request, headers AuthTokenHeaders) *baseHttp.Request { ctx := context.WithValue(r.Context(), authAccountNameKey, headers.AccountName) ctx = context.WithValue(r.Context(), requestIdKey, headers.RequestID) return r.WithContext(ctx) } -func (t TokenCheckMiddleware) shallReject(headers AuthTokenHeaders) *http.ApiError { +func (t TokenCheckMiddleware) HasInvalidFormat(headers AuthTokenHeaders) *http.ApiError { limiterKey := headers.ClientIP + "|" + strings.ToLower(headers.AccountName) if t.rateLimiter.TooMany(limiterKey) { - return t.getRateLimitedError("Too many authentication attempts for key: " + limiterKey) + return mwguards.RateLimitedError( + "Too many authentication attempts", + "Too many authentication attempts for key: "+limiterKey, + ) } guard := mwguards.NewMWTokenGuard(t.ApiKeys, t.TokenHandler) @@ -206,7 +215,11 @@ func (t TokenCheckMiddleware) shallReject(headers AuthTokenHeaders) *http.ApiErr if guard.Rejects(rejectsRequest) { t.rateLimiter.Fail(headers.AccountName) - return t.getUnauthenticatedError(guard.Error.Error()) + return mwguards.UnauthenticatedError( + "Invalid public token", + guard.Error.Error(), + rejectsRequest.ToMap(), + ) } if t.nonceCache != nil { @@ -215,63 +228,13 @@ func (t TokenCheckMiddleware) shallReject(headers AuthTokenHeaders) *http.ApiErr if t.nonceCache.UseOnce(key, t.nonceTTL) { t.rateLimiter.Fail(limiterKey) - return t.getUnauthenticatedError("Invalid nonce: " + headers.Nonce + ".") + return mwguards.UnauthenticatedError( + "Invalid nonce", + "Invalid nonce using key: "+key, + map[string]any{"key": key, "limiter_key": limiterKey}, + ) } } return nil } - -func (t TokenCheckMiddleware) getInvalidRequestError(logMessage string) *http.ApiError { - slog.Error(logMessage, "error") - - return &http.ApiError{ - Message: "Invalid authentication headers", - Status: baseHttp.StatusUnauthorized, - } -} - -func (t TokenCheckMiddleware) getInvalidTokenFormatError(logMessage string) *http.ApiError { - slog.Error(logMessage, "error") - - return &http.ApiError{ - Message: "1- Invalid credentials: " + logMessage, - Status: baseHttp.StatusUnauthorized, - } -} - -func (t TokenCheckMiddleware) getUnauthenticatedError(logMessage string) *http.ApiError { - slog.Error(logMessage, "error") - - return &http.ApiError{ - Message: "2- Invalid credentials: " + logMessage, - Status: baseHttp.StatusUnauthorized, - } -} - -func (t TokenCheckMiddleware) getRateLimitedError(logMessage string) *http.ApiError { - slog.Error(logMessage, "error") - - return &http.ApiError{ - Message: "Too many authentication attempts", - Status: baseHttp.StatusTooManyRequests, - } -} - -func (t TokenCheckMiddleware) getTimestampTooOldError(logMessage string) *http.ApiError { - slog.Error(logMessage, "error") - - return &http.ApiError{ - Message: "Request timestamp expired", - Status: baseHttp.StatusUnauthorized, - } -} - -func (t TokenCheckMiddleware) getTimestampTooNewError(logMessage string) *http.ApiError { - slog.Error(logMessage, "error") - - return &http.ApiError{ - Message: "Request timestamp invalid", - Status: baseHttp.StatusUnauthorized, - } -} diff --git a/pkg/middleware/token_middleware_additional_test.go b/pkg/middleware/token_middleware_additional_test.go index 19535a3c..c150668e 100644 --- a/pkg/middleware/token_middleware_additional_test.go +++ b/pkg/middleware/token_middleware_additional_test.go @@ -79,13 +79,13 @@ func makeRepo(t *testing.T, account string) (*repository.ApiKeys, *auth.TokenHan func TestTokenMiddlewareGuardDependencies(t *testing.T) { logger := slogNoop() tm := TokenCheckMiddleware{} - if err := tm.guardDependencies(logger); err == nil || err.Status != http.StatusUnauthorized { + if err := tm.GuardDependencies(logger); err == nil || err.Status != http.StatusUnauthorized { t.Fatalf("expected unauthorized when dependencies missing") } tm.ApiKeys, tm.TokenHandler, _ = makeRepo(t, "guard1") tm.nonceCache = cache.NewTTLCache() tm.rateLimiter = limiter.NewMemoryLimiter(time.Minute, 1) - if err := tm.guardDependencies(logger); err != nil { + if err := tm.GuardDependencies(logger); err != nil { t.Fatalf("expected no error when dependencies provided, got %#v", err) } } diff --git a/pkg/middleware/token_middleware_test.go b/pkg/middleware/token_middleware_test.go index 18b00b57..d4aa0943 100644 --- a/pkg/middleware/token_middleware_test.go +++ b/pkg/middleware/token_middleware_test.go @@ -95,7 +95,7 @@ func TestValidateAndGetHeaders_MissingAndInvalidFormat(t *testing.T) { logger := slogNoop() req := httptest.NewRequest("GET", "/", nil) // All empty - if _, _, _, _, _, apiErr := tm.validateAndGetHeaders(req, logger); apiErr == nil || apiErr.Status != http.StatusUnauthorized { + if _, _, _, _, _, apiErr := tm.ValidateAndGetHeaders(req, logger); apiErr == nil || apiErr.Status != http.StatusUnauthorized { t.Fatalf("expected error for missing headers") } @@ -105,7 +105,7 @@ func TestValidateAndGetHeaders_MissingAndInvalidFormat(t *testing.T) { req.Header.Set("X-API-Signature", "sig") req.Header.Set("X-API-Timestamp", "1700000000") req.Header.Set("X-API-Nonce", "n1") - if _, _, _, _, _, apiErr := tm.validateAndGetHeaders(req, logger); apiErr == nil || apiErr.Status != http.StatusUnauthorized { + if _, _, _, _, _, apiErr := tm.ValidateAndGetHeaders(req, logger); apiErr == nil || apiErr.Status != http.StatusUnauthorized { t.Fatalf("expected error for invalid token format") } } @@ -129,7 +129,7 @@ func TestReadBodyHash_RestoresBody(t *testing.T) { func TestAttachContext(t *testing.T) { tm := MakeTokenMiddleware(nil, nil) req := httptest.NewRequest("GET", "/", nil) - r := tm.attachContext(req, "Alice", "RID-123") + r := tm.AttachContext(req, "Alice", "RID-123") if r == req { t.Fatalf("expected a new request with updated context") } From cab7e47968801f773936ef47b3ce819d486dcbaf Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 5 Sep 2025 15:12:00 +0800 Subject: [PATCH 22/30] add signature validation --- database/repository/api_keys.go | 80 +++++++++++++++---- handler/signatures.go | 4 +- .../mwguards/mw_response_messages.go | 12 +++ pkg/middleware/token_middleware.go | 41 ++++++++-- pkg/portal/consts.go | 1 + 5 files changed, 113 insertions(+), 25 deletions(-) diff --git a/database/repository/api_keys.go b/database/repository/api_keys.go index 8398a14f..796e74bf 100644 --- a/database/repository/api_keys.go +++ b/database/repository/api_keys.go @@ -8,11 +8,10 @@ import ( "github.com/google/uuid" "github.com/oullin/database" "github.com/oullin/pkg/gorm" + "github.com/oullin/pkg/portal" baseGorm "gorm.io/gorm" ) -const MaxSignaturesTries = 5 - type ApiKeys struct { DB *database.Connection } @@ -58,19 +57,19 @@ func (a ApiKeys) FindBy(accountName string) *database.APIKey { func (a ApiKeys) CreateSignatureFor(key *database.APIKey, seed []byte, expiresAt time.Time) (*database.APIKeySignatures, error) { var item *database.APIKeySignatures - if item = a.FindSignature(key); item != nil { + if item = a.FindActiveSignatureFor(key); item != nil { return item, nil } now := time.Now() signature := database.APIKeySignatures{ - UUID: uuid.NewString(), - APIKeyID: key.ID, - Signature: seed, - Tries: MaxSignaturesTries, - ExpiresAt: expiresAt, CreatedAt: now, UpdatedAt: now, + Signature: seed, + APIKeyID: key.ID, + ExpiresAt: expiresAt, + UUID: uuid.NewString(), + Tries: portal.MaxSignaturesTries, } err := a.DB.Transaction(func(tx *baseGorm.DB) error { @@ -92,14 +91,14 @@ func (a ApiKeys) CreateSignatureFor(key *database.APIKey, seed []byte, expiresAt return &signature, nil } -func (a ApiKeys) FindSignature(key *database.APIKey) *database.APIKeySignatures { +func (a ApiKeys) FindActiveSignatureFor(key *database.APIKey) *database.APIKeySignatures { var item database.APIKeySignatures result := a.DB.Sql(). Model(&database.APIKeySignatures{}). + Where("expired_at IS NULL"). Where("api_key_id = ?", key.ID). - Where("tries <= ?", MaxSignaturesTries). - Where("expires_at > ?", time.Now()). + Where("tries <=", portal.MaxSignaturesTries). First(&item) if gorm.HasDbIssues(result.Error) { @@ -113,16 +112,65 @@ func (a ApiKeys) FindSignature(key *database.APIKey) *database.APIKeySignatures return nil } -func (a ApiKeys) DisablePreviousSignatures(key *database.APIKey, signatureUUID string) error { +func (a ApiKeys) FindSignatureFrom(key *database.APIKey, signature []byte) *database.APIKeySignatures { + var item database.APIKeySignatures + result := a.DB.Sql(). Model(&database.APIKeySignatures{}). Where("api_key_id = ?", key.ID). - Where("uuid != ?", signatureUUID). - Where("expired_at is null"). - Update("expired_at", time.Now()) + Where("signature = ?", signature). + Where("expired_at IS NULL"). + Where("tries <=", portal.MaxSignaturesTries). + First(&item) if gorm.HasDbIssues(result.Error) { - return result.Error + return nil + } + + if result.RowsAffected > 0 { + return &item + } + + return nil +} + +func (a ApiKeys) DisablePreviousSignatures(key *database.APIKey, signatureUUID string) error { + query := a.DB.Sql(). + Model(&database.APIKeySignatures{}). + Where("expired_at IS NULL"). + Where("api_key_id = ?", key.ID). + Where("uuid NOT IN (?)", []string{signatureUUID}). + Update("expired_at", time.Now()) + + if gorm.HasDbIssues(query.Error) { + return query.Error + } + + return nil +} + +func (a ApiKeys) IncreaseSignatureTries(signatureUUID string, tries int) error { + if tries < portal.MaxSignaturesTries { + return nil + } + + var item database.APIKeySignatures + + query := a.DB.Sql(). + Model(&database.APIKeySignatures{}). + Where("uuid = ?", signatureUUID). + First(&item) + + if gorm.HasDbIssues(query.Error) { + return query.Error + } + + update := a.DB.Sql(). + Model(&item). + Update("tries", tries) + + if gorm.HasDbIssues(update.Error) { + return update.Error } return nil diff --git a/handler/signatures.go b/handler/signatures.go index 26b9c00b..f951b018 100644 --- a/handler/signatures.go +++ b/handler/signatures.go @@ -57,7 +57,7 @@ func (s *SignaturesHandler) Generate(w baseHttp.ResponseWriter, r *baseHttp.Requ } var keySignature *database.APIKeySignatures - if keySignature, err = s.createSignature(req.Username, serverTime); err != nil { + if keySignature, err = s.CreateSignature(req.Username, serverTime); err != nil { return http.LogInternalError(err.Error(), err) } @@ -97,7 +97,7 @@ func (s *SignaturesHandler) isRequestWithinTimeframe(serverTime, receivedAt time return nil } -func (s *SignaturesHandler) createSignature(username string, serverTime time.Time) (*database.APIKeySignatures, error) { +func (s *SignaturesHandler) CreateSignature(username string, serverTime time.Time) (*database.APIKeySignatures, error) { var err error var token *database.APIKey var keySignature *database.APIKeySignatures diff --git a/pkg/middleware/mwguards/mw_response_messages.go b/pkg/middleware/mwguards/mw_response_messages.go index dd3c1fc1..29e05fbe 100644 --- a/pkg/middleware/mwguards/mw_response_messages.go +++ b/pkg/middleware/mwguards/mw_response_messages.go @@ -81,6 +81,18 @@ func RateLimitedError(message, logMessage string, data ...map[string]any) *http. } } +func NotFound(message, logMessage string, data ...map[string]any) *http.ApiError { + message, logMessage = normaliseMessages(message, logMessage) + + slog.Error(logMessage, "error") + + return &http.ApiError{ + Message: message, + Status: baseHttp.StatusNotFound, + Data: normaliseData(data...), + } +} + func TimestampTooOldError(message, logMessage string, data ...map[string]any) *http.ApiError { message, logMessage = normaliseMessages(message, logMessage) diff --git a/pkg/middleware/token_middleware.go b/pkg/middleware/token_middleware.go index c8d63d18..c53e5492 100644 --- a/pkg/middleware/token_middleware.go +++ b/pkg/middleware/token_middleware.go @@ -2,11 +2,13 @@ package middleware import ( "context" + "encoding/hex" "fmt" baseHttp "net/http" "strings" "time" + "github.com/oullin/database" "github.com/oullin/database/repository" "github.com/oullin/pkg/auth" "github.com/oullin/pkg/cache" @@ -49,7 +51,7 @@ type TokenCheckMiddleware struct { // TokenHandler performs encoding/decoding of tokens and signature creation/verification. TokenHandler *auth.TokenHandler - // nonceCache stores recently seen nonce's to prevent replaying the same request + // nonceCache stores recently seen nonce to prevent replaying the same request // within the configured TTL window. nonceCache *cache.TTLCache @@ -115,11 +117,15 @@ func (t TokenCheckMiddleware) Handle(next http.ApiHandler) http.ApiHandler { return tsErr } - if err = t.HasInvalidFormat(headers); err != nil { + var apiKey *database.APIKey + if apiKey, err = t.HasInvalidFormat(headers); err != nil { + return err + } + + if err = t.HasInvalidSignature(headers, apiKey); err != nil { return err } - // Update the request context r = t.AttachContext(r, headers) return next(w, r) @@ -195,11 +201,11 @@ func (t TokenCheckMiddleware) AttachContext(r *baseHttp.Request, headers AuthTok return r.WithContext(ctx) } -func (t TokenCheckMiddleware) HasInvalidFormat(headers AuthTokenHeaders) *http.ApiError { +func (t TokenCheckMiddleware) HasInvalidFormat(headers AuthTokenHeaders) (*database.APIKey, *http.ApiError) { limiterKey := headers.ClientIP + "|" + strings.ToLower(headers.AccountName) if t.rateLimiter.TooMany(limiterKey) { - return mwguards.RateLimitedError( + return nil, mwguards.RateLimitedError( "Too many authentication attempts", "Too many authentication attempts for key: "+limiterKey, ) @@ -215,7 +221,7 @@ func (t TokenCheckMiddleware) HasInvalidFormat(headers AuthTokenHeaders) *http.A if guard.Rejects(rejectsRequest) { t.rateLimiter.Fail(headers.AccountName) - return mwguards.UnauthenticatedError( + return nil, mwguards.UnauthenticatedError( "Invalid public token", guard.Error.Error(), rejectsRequest.ToMap(), @@ -228,7 +234,7 @@ func (t TokenCheckMiddleware) HasInvalidFormat(headers AuthTokenHeaders) *http.A if t.nonceCache.UseOnce(key, t.nonceTTL) { t.rateLimiter.Fail(limiterKey) - return mwguards.UnauthenticatedError( + return nil, mwguards.UnauthenticatedError( "Invalid nonce", "Invalid nonce using key: "+key, map[string]any{"key": key, "limiter_key": limiterKey}, @@ -236,5 +242,26 @@ func (t TokenCheckMiddleware) HasInvalidFormat(headers AuthTokenHeaders) *http.A } } + return guard.ApiKey, nil +} + +func (t TokenCheckMiddleware) HasInvalidSignature(headers AuthTokenHeaders, apiKey *database.APIKey) *http.ApiError { + var err error + var byteSignature []byte + + if byteSignature, err = hex.DecodeString(headers.Signature); err != nil { + return mwguards.NotFound("error decoding signature string", "") + } + + signature := t.ApiKeys.FindSignatureFrom(apiKey, byteSignature) + + if signature == nil { + return mwguards.NotFound("signature not found", "") + } + + if err = t.ApiKeys.IncreaseSignatureTries(signature.UUID, signature.Tries+1); err != nil { + return mwguards.InvalidRequestError("could not increase signature tries", err.Error()) + } + return nil } diff --git a/pkg/portal/consts.go b/pkg/portal/consts.go index 68561d1e..1cad9ccc 100644 --- a/pkg/portal/consts.go +++ b/pkg/portal/consts.go @@ -1,3 +1,4 @@ package portal const DatesLayout = "2006-01-02 15:04:05" +const MaxSignaturesTries = 20 From 618671d1e790e846fff03b0ccaf50734c3aa7ce3 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 5 Sep 2025 15:44:28 +0800 Subject: [PATCH 23/30] add max & current tries --- .../000003_api_keys_signatures.up.sql | 3 +- database/model.go | 23 +++++++------- database/repository/api_keys.go | 30 +++++++++++-------- handler/payload/signatures.go | 2 +- handler/signatures.go | 2 +- pkg/middleware/token_middleware.go | 2 +- pkg/portal/consts.go | 2 +- 7 files changed, 35 insertions(+), 29 deletions(-) diff --git a/database/infra/migrations/000003_api_keys_signatures.up.sql b/database/infra/migrations/000003_api_keys_signatures.up.sql index 8d28e1ff..047b1f85 100644 --- a/database/infra/migrations/000003_api_keys_signatures.up.sql +++ b/database/infra/migrations/000003_api_keys_signatures.up.sql @@ -3,7 +3,8 @@ CREATE TABLE api_key_signatures ( uuid UUID UNIQUE NOT NULL, api_key_id BIGINT NOT NULL, signature BYTEA NOT NULL, - tries SMALLINT NOT NULL DEFAULT 1 CHECK (tries > 0), + max_tries SMALLINT NOT NULL DEFAULT 1 CHECK (max_tries > 0), + current_tries SMALLINT NOT NULL DEFAULT 1 CHECK (current_tries > 0), expires_at TIMESTAMP DEFAULT NULL, expired_at TIMESTAMP DEFAULT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, diff --git a/database/model.go b/database/model.go index 6313c753..338150e9 100644 --- a/database/model.go +++ b/database/model.go @@ -39,17 +39,18 @@ type APIKey struct { } type APIKeySignatures struct { - ID int64 `gorm:"primaryKey"` - UUID string `gorm:"type:uuid;unique;not null"` - APIKeyID int64 `gorm:"not null"` - Tries int `gorm:"not null"` - APIKey APIKey `gorm:"foreignKey:APIKeyID"` - Signature []byte `gorm:"not null;uniqueIndex:uq_signature_created_at;index:idx_signature"` - ExpiresAt time.Time `gorm:"index:idx_api_key_signatures_expires_at"` - ExpiredAt *time.Time `gorm:"index:idx_api_key_signatures_expired_at"` - CreatedAt time.Time `gorm:"uniqueIndex:uq_signature_created_at;index:idx_api_key_signatures_created_at"` - UpdatedAt time.Time `gorm:"index:idx_api_key_signatures_updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index:idx_api_key_signatures_deleted_at"` + ID int64 `gorm:"primaryKey"` + UUID string `gorm:"type:uuid;unique;not null"` + APIKeyID int64 `gorm:"not null"` + MaxTries int `gorm:"not null"` + CurrentTries int `gorm:"not null"` + APIKey APIKey `gorm:"foreignKey:APIKeyID"` + Signature []byte `gorm:"not null;uniqueIndex:uq_signature_created_at;index:idx_signature"` + ExpiresAt time.Time `gorm:"index:idx_api_key_signatures_expires_at"` + ExpiredAt *time.Time `gorm:"index:idx_api_key_signatures_expired_at"` + CreatedAt time.Time `gorm:"uniqueIndex:uq_signature_created_at;index:idx_api_key_signatures_created_at"` + UpdatedAt time.Time `gorm:"index:idx_api_key_signatures_updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index:idx_api_key_signatures_deleted_at"` } type User struct { diff --git a/database/repository/api_keys.go b/database/repository/api_keys.go index 796e74bf..10e8ae99 100644 --- a/database/repository/api_keys.go +++ b/database/repository/api_keys.go @@ -63,13 +63,14 @@ func (a ApiKeys) CreateSignatureFor(key *database.APIKey, seed []byte, expiresAt now := time.Now() signature := database.APIKeySignatures{ - CreatedAt: now, - UpdatedAt: now, - Signature: seed, - APIKeyID: key.ID, - ExpiresAt: expiresAt, - UUID: uuid.NewString(), - Tries: portal.MaxSignaturesTries, + CreatedAt: now, + UpdatedAt: now, + Signature: seed, + APIKeyID: key.ID, + ExpiresAt: expiresAt, + UUID: uuid.NewString(), + MaxTries: portal.MaxSignaturesTries, + CurrentTries: 1, } err := a.DB.Transaction(func(tx *baseGorm.DB) error { @@ -98,7 +99,7 @@ func (a ApiKeys) FindActiveSignatureFor(key *database.APIKey) *database.APIKeySi Model(&database.APIKeySignatures{}). Where("expired_at IS NULL"). Where("api_key_id = ?", key.ID). - Where("tries <=", portal.MaxSignaturesTries). + Where("current_tries < ? ", portal.MaxSignaturesTries). First(&item) if gorm.HasDbIssues(result.Error) { @@ -120,7 +121,7 @@ func (a ApiKeys) FindSignatureFrom(key *database.APIKey, signature []byte) *data Where("api_key_id = ?", key.ID). Where("signature = ?", signature). Where("expired_at IS NULL"). - Where("tries <=", portal.MaxSignaturesTries). + Where("current_tries < ? ", portal.MaxSignaturesTries). First(&item) if gorm.HasDbIssues(result.Error) { @@ -137,7 +138,10 @@ func (a ApiKeys) FindSignatureFrom(key *database.APIKey, signature []byte) *data func (a ApiKeys) DisablePreviousSignatures(key *database.APIKey, signatureUUID string) error { query := a.DB.Sql(). Model(&database.APIKeySignatures{}). - Where("expired_at IS NULL"). + Where( + a.DB.Sql(). + Where("expired_at IS NULL").Or("current_tries > max_tries"), + ). Where("api_key_id = ?", key.ID). Where("uuid NOT IN (?)", []string{signatureUUID}). Update("expired_at", time.Now()) @@ -149,8 +153,8 @@ func (a ApiKeys) DisablePreviousSignatures(key *database.APIKey, signatureUUID s return nil } -func (a ApiKeys) IncreaseSignatureTries(signatureUUID string, tries int) error { - if tries < portal.MaxSignaturesTries { +func (a ApiKeys) IncreaseSignatureTries(signatureUUID string, currentTries int) error { + if currentTries < portal.MaxSignaturesTries { return nil } @@ -167,7 +171,7 @@ func (a ApiKeys) IncreaseSignatureTries(signatureUUID string, tries int) error { update := a.DB.Sql(). Model(&item). - Update("tries", tries) + Update("current_tries", currentTries) if gorm.HasDbIssues(update.Error) { return update.Error diff --git a/handler/payload/signatures.go b/handler/payload/signatures.go index 11143787..d3c93c8e 100644 --- a/handler/payload/signatures.go +++ b/handler/payload/signatures.go @@ -9,7 +9,7 @@ type SignatureRequest struct { type SignatureResponse struct { Signature string `json:"signature"` - Tries int `json:"tries"` + MaxTries int `json:"max_tries"` Cadence SignatureCadenceResponse `json:"cadence"` } diff --git a/handler/signatures.go b/handler/signatures.go index f951b018..ab7cb4e0 100644 --- a/handler/signatures.go +++ b/handler/signatures.go @@ -63,7 +63,7 @@ func (s *SignaturesHandler) Generate(w baseHttp.ResponseWriter, r *baseHttp.Requ response := payload.SignatureResponse{ Signature: auth.SignatureToString(keySignature.Signature), - Tries: keySignature.Tries, + MaxTries: keySignature.MaxTries, Cadence: payload.SignatureCadenceResponse{ ReceivedAt: receivedAt.Format(portal.DatesLayout), CreatedAt: keySignature.CreatedAt.Format(portal.DatesLayout), diff --git a/pkg/middleware/token_middleware.go b/pkg/middleware/token_middleware.go index c53e5492..7a81bec1 100644 --- a/pkg/middleware/token_middleware.go +++ b/pkg/middleware/token_middleware.go @@ -259,7 +259,7 @@ func (t TokenCheckMiddleware) HasInvalidSignature(headers AuthTokenHeaders, apiK return mwguards.NotFound("signature not found", "") } - if err = t.ApiKeys.IncreaseSignatureTries(signature.UUID, signature.Tries+1); err != nil { + if err = t.ApiKeys.IncreaseSignatureTries(signature.UUID, signature.CurrentTries+1); err != nil { return mwguards.InvalidRequestError("could not increase signature tries", err.Error()) } diff --git a/pkg/portal/consts.go b/pkg/portal/consts.go index 1cad9ccc..b87b36b5 100644 --- a/pkg/portal/consts.go +++ b/pkg/portal/consts.go @@ -1,4 +1,4 @@ package portal const DatesLayout = "2006-01-02 15:04:05" -const MaxSignaturesTries = 20 +const MaxSignaturesTries = 5 From 8408254b8a414a463842c6d440c3ccc6b070df45 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 5 Sep 2025 17:00:14 +0800 Subject: [PATCH 24/30] add time constraint --- .../000003_api_keys_signatures.up.sql | 1 + database/model.go | 1 + database/repository/api_keys.go | 55 ++++++++++--------- database/repository/repoentity/api_keys.go | 21 +++++++ handler/payload/signatures.go | 1 + handler/signatures.go | 19 +++++-- main.go | 18 +++++- pkg/middleware/headers.go | 15 ++--- pkg/middleware/token_middleware.go | 29 +++++++--- pkg/portal/consts.go | 2 +- 10 files changed, 113 insertions(+), 49 deletions(-) create mode 100644 database/repository/repoentity/api_keys.go diff --git a/database/infra/migrations/000003_api_keys_signatures.up.sql b/database/infra/migrations/000003_api_keys_signatures.up.sql index 047b1f85..ddba5718 100644 --- a/database/infra/migrations/000003_api_keys_signatures.up.sql +++ b/database/infra/migrations/000003_api_keys_signatures.up.sql @@ -7,6 +7,7 @@ CREATE TABLE api_key_signatures ( current_tries SMALLINT NOT NULL DEFAULT 1 CHECK (current_tries > 0), expires_at TIMESTAMP DEFAULT NULL, expired_at TIMESTAMP DEFAULT NULL, + origin TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, deleted_at TIMESTAMP DEFAULT NULL, diff --git a/database/model.go b/database/model.go index 338150e9..5ee504bf 100644 --- a/database/model.go +++ b/database/model.go @@ -46,6 +46,7 @@ type APIKeySignatures struct { CurrentTries int `gorm:"not null"` APIKey APIKey `gorm:"foreignKey:APIKeyID"` Signature []byte `gorm:"not null;uniqueIndex:uq_signature_created_at;index:idx_signature"` + Origin string `gorm:"type:varchar(255);not null"` ExpiresAt time.Time `gorm:"index:idx_api_key_signatures_expires_at"` ExpiredAt *time.Time `gorm:"index:idx_api_key_signatures_expired_at"` CreatedAt time.Time `gorm:"uniqueIndex:uq_signature_created_at;index:idx_api_key_signatures_created_at"` diff --git a/database/repository/api_keys.go b/database/repository/api_keys.go index 10e8ae99..ce46a75a 100644 --- a/database/repository/api_keys.go +++ b/database/repository/api_keys.go @@ -7,9 +7,9 @@ import ( "github.com/google/uuid" "github.com/oullin/database" + "github.com/oullin/database/repository/repoentity" "github.com/oullin/pkg/gorm" "github.com/oullin/pkg/portal" - baseGorm "gorm.io/gorm" ) type ApiKeys struct { @@ -54,39 +54,34 @@ func (a ApiKeys) FindBy(accountName string) *database.APIKey { return nil } -func (a ApiKeys) CreateSignatureFor(key *database.APIKey, seed []byte, expiresAt time.Time) (*database.APIKeySignatures, error) { +func (a ApiKeys) CreateSignatureFor(entity repoentity.APIKeyCreateSignatureFor) (*database.APIKeySignatures, error) { var item *database.APIKeySignatures - if item = a.FindActiveSignatureFor(key); item != nil { - return item, nil + if item = a.FindActiveSignatureFor(entity.Key); item != nil { + item.ExpiresAt = entity.ExpiresAt + a.DB.Sql().Save(&item) } now := time.Now() signature := database.APIKeySignatures{ CreatedAt: now, UpdatedAt: now, - Signature: seed, - APIKeyID: key.ID, - ExpiresAt: expiresAt, + Signature: entity.Seed, + APIKeyID: entity.Key.ID, + ExpiresAt: entity.ExpiresAt, UUID: uuid.NewString(), MaxTries: portal.MaxSignaturesTries, + Origin: entity.Origin, CurrentTries: 1, } - err := a.DB.Transaction(func(tx *baseGorm.DB) error { - if result := a.DB.Sql().Create(&signature); gorm.HasDbIssues(result.Error) { - return fmt.Errorf("issue creating the given api key signature [%s, %s]: ", key.AccountName, result.Error) - } - - if result := a.DisablePreviousSignatures(key, signature.UUID); result != nil { - return fmt.Errorf("issue creating the given api key signature [%s, %s]: ", key.AccountName, result.Error()) - } - - return nil - }) + username := entity.Key.AccountName + if result := a.DB.Sql().Create(&signature); gorm.HasDbIssues(result.Error) { + return nil, fmt.Errorf("issue creating the given api keys signature [%s, %s]: ", username, result.Error) + } - if err != nil { - return nil, err + if result := a.DisablePreviousSignatures(entity.Key, signature.UUID, entity.Origin); result != nil { + return nil, fmt.Errorf("issue disabling previous api keys signature [%s, %s]: ", username, result.Error()) } return &signature, nil @@ -99,7 +94,7 @@ func (a ApiKeys) FindActiveSignatureFor(key *database.APIKey) *database.APIKeySi Model(&database.APIKeySignatures{}). Where("expired_at IS NULL"). Where("api_key_id = ?", key.ID). - Where("current_tries < ? ", portal.MaxSignaturesTries). + Where("current_tries <= ? ", portal.MaxSignaturesTries). First(&item) if gorm.HasDbIssues(result.Error) { @@ -113,15 +108,17 @@ func (a ApiKeys) FindActiveSignatureFor(key *database.APIKey) *database.APIKeySi return nil } -func (a ApiKeys) FindSignatureFrom(key *database.APIKey, signature []byte) *database.APIKeySignatures { +func (a ApiKeys) FindSignatureFrom(entity repoentity.FindSignatureFrom) *database.APIKeySignatures { var item database.APIKeySignatures result := a.DB.Sql(). Model(&database.APIKeySignatures{}). - Where("api_key_id = ?", key.ID). - Where("signature = ?", signature). + Where("api_key_id = ?", entity.Key.ID). + Where("signature = ?", entity.Signature). + Where("expires_at >= ? ", entity.ServerTime). + Where("origin = ?", entity.Origin). Where("expired_at IS NULL"). - Where("current_tries < ? ", portal.MaxSignaturesTries). + Where("current_tries <= max_tries"). First(&item) if gorm.HasDbIssues(result.Error) { @@ -135,7 +132,7 @@ func (a ApiKeys) FindSignatureFrom(key *database.APIKey, signature []byte) *data return nil } -func (a ApiKeys) DisablePreviousSignatures(key *database.APIKey, signatureUUID string) error { +func (a ApiKeys) DisablePreviousSignatures(key *database.APIKey, signatureUUID, origin string) error { query := a.DB.Sql(). Model(&database.APIKeySignatures{}). Where( @@ -143,6 +140,12 @@ func (a ApiKeys) DisablePreviousSignatures(key *database.APIKey, signatureUUID s Where("expired_at IS NULL").Or("current_tries > max_tries"), ). Where("api_key_id = ?", key.ID). + Where( + a.DB.Sql(). + Where("origin = ?", origin). + Or("TRIM(origin) = ''"), + ). + Where("origin = ?", origin). Where("uuid NOT IN (?)", []string{signatureUUID}). Update("expired_at", time.Now()) diff --git a/database/repository/repoentity/api_keys.go b/database/repository/repoentity/api_keys.go new file mode 100644 index 00000000..f4651655 --- /dev/null +++ b/database/repository/repoentity/api_keys.go @@ -0,0 +1,21 @@ +package repoentity + +import ( + "time" + + "github.com/oullin/database" +) + +type APIKeyCreateSignatureFor struct { + Key *database.APIKey + ExpiresAt time.Time + Seed []byte + Origin string +} + +type FindSignatureFrom struct { + Key *database.APIKey + Signature []byte + Origin string + ServerTime time.Time +} diff --git a/handler/payload/signatures.go b/handler/payload/signatures.go index d3c93c8e..213aed4c 100644 --- a/handler/payload/signatures.go +++ b/handler/payload/signatures.go @@ -5,6 +5,7 @@ type SignatureRequest struct { PublicKey string `json:"public_key" validate:"required,lowercase,min=64,max=67"` Username string `json:"username" validate:"required,lowercase,min=5"` Timestamp int64 `json:"timestamp" validate:"required,number,min=10"` + Origin string `json:"origin"` } type SignatureResponse struct { diff --git a/handler/signatures.go b/handler/signatures.go index ab7cb4e0..c94226b7 100644 --- a/handler/signatures.go +++ b/handler/signatures.go @@ -10,6 +10,7 @@ import ( "github.com/oullin/database" "github.com/oullin/database/repository" + "github.com/oullin/database/repository/repoentity" "github.com/oullin/handler/payload" "github.com/oullin/pkg/auth" "github.com/oullin/pkg/http" @@ -51,13 +52,14 @@ func (s *SignaturesHandler) Generate(w baseHttp.ResponseWriter, r *baseHttp.Requ serverTime := time.Now() receivedAt := time.Unix(req.Timestamp, 0) + req.Origin = r.Header.Get("X-API-Intended-Origin") if err = s.isRequestWithinTimeframe(serverTime, receivedAt); err != nil { return http.LogBadRequestError(err.Error(), err) } var keySignature *database.APIKeySignatures - if keySignature, err = s.CreateSignature(req.Username, serverTime); err != nil { + if keySignature, err = s.CreateSignature(req, serverTime); err != nil { return http.LogInternalError(err.Error(), err) } @@ -97,13 +99,13 @@ func (s *SignaturesHandler) isRequestWithinTimeframe(serverTime, receivedAt time return nil } -func (s *SignaturesHandler) CreateSignature(username string, serverTime time.Time) (*database.APIKeySignatures, error) { +func (s *SignaturesHandler) CreateSignature(request payload.SignatureRequest, serverTime time.Time) (*database.APIKeySignatures, error) { var err error var token *database.APIKey var keySignature *database.APIKeySignatures - if token = s.ApiKeys.FindBy(username); token == nil { - return nil, fmt.Errorf("the given username [%s] was not found", username) + if token = s.ApiKeys.FindBy(request.Username); token == nil { + return nil, fmt.Errorf("the given username [%s] was not found", request.Username) } var seed []byte @@ -114,7 +116,14 @@ func (s *SignaturesHandler) CreateSignature(username string, serverTime time.Tim expiresAt := serverTime.Add(time.Second * 30) hash := auth.CreateSignature(seed, token.SecretKey) - if keySignature, err = s.ApiKeys.CreateSignatureFor(token, hash, expiresAt); err != nil { + entity := repoentity.APIKeyCreateSignatureFor{ + Key: token, + ExpiresAt: expiresAt, + Seed: hash, + Origin: request.Origin, + } + + if keySignature, err = s.ApiKeys.CreateSignatureFor(entity); err != nil { return nil, fmt.Errorf("unable to create the signature item. Please try again") } diff --git a/main.go b/main.go index 03b59c8e..7c0fc334 100644 --- a/main.go +++ b/main.go @@ -57,10 +57,26 @@ func serverHandler() baseHttp.Handler { localhost := app.GetEnv().Network.GetHostURL() + headers := []string{ + "Accept", + "Authorization", + "Content-Type", + "X-CSRF-Token", + "User-Agent", + "X-API-Key", + "X-API-Username", + "X-API-Signature", + "X-API-Timestamp", + "X-API-Nonce", + "X-Request-ID", + "If-None-Match", + "X-API-Intended-Origin", //new + } + c := cors.New(cors.Options{ AllowedOrigins: []string{localhost, "http://localhost:5173"}, AllowedMethods: []string{baseHttp.MethodGet, baseHttp.MethodPost, baseHttp.MethodPut, baseHttp.MethodDelete, baseHttp.MethodOptions}, - AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "User-Agent", "X-API-Key", "X-API-Username", "X-API-Signature", "X-API-Timestamp", "X-API-Nonce", "X-Request-ID", "If-None-Match"}, + AllowedHeaders: headers, AllowCredentials: true, Debug: true, }) diff --git a/pkg/middleware/headers.go b/pkg/middleware/headers.go index 1128cba3..c4dfecaf 100644 --- a/pkg/middleware/headers.go +++ b/pkg/middleware/headers.go @@ -1,11 +1,12 @@ package middleware type AuthTokenHeaders struct { - AccountName string - PublicKey string - Signature string - Timestamp string - Nonce string - ClientIP string - RequestID string + AccountName string + PublicKey string + Signature string + Timestamp string + Nonce string + ClientIP string + RequestID string + IntendedOriginURL string } diff --git a/pkg/middleware/token_middleware.go b/pkg/middleware/token_middleware.go index 7a81bec1..55c1f5d5 100644 --- a/pkg/middleware/token_middleware.go +++ b/pkg/middleware/token_middleware.go @@ -10,6 +10,7 @@ import ( "github.com/oullin/database" "github.com/oullin/database/repository" + "github.com/oullin/database/repository/repoentity" "github.com/oullin/pkg/auth" "github.com/oullin/pkg/cache" "github.com/oullin/pkg/http" @@ -24,6 +25,7 @@ const signatureHeader = "X-API-Signature" const timestampHeader = "X-API-Timestamp" const nonceHeader = "X-API-Nonce" const requestIDHeader = "X-Request-ID" +const intendedOrigin = "X-API-Intended-Origin" // Context keys for propagating auth info downstream // Use unexported custom type to avoid collisions @@ -171,8 +173,9 @@ func (t TokenCheckMiddleware) ValidateAndGetHeaders(r *baseHttp.Request, request ts := strings.TrimSpace(r.Header.Get(timestampHeader)) nonce := strings.TrimSpace(r.Header.Get(nonceHeader)) ip := portal.ParseClientIP(r) + intendedOriginURL := strings.TrimSpace(r.Header.Get(intendedOrigin)) - if accountName == "" || publicToken == "" || signature == "" || ts == "" || nonce == "" || ip == "" { + if accountName == "" || publicToken == "" || signature == "" || ts == "" || nonce == "" || ip == "" || intendedOriginURL == "" { return AuthTokenHeaders{}, mwguards.InvalidRequestError( "Invalid authentication headers / or missing headers", "", @@ -184,13 +187,14 @@ func (t TokenCheckMiddleware) ValidateAndGetHeaders(r *baseHttp.Request, request } return AuthTokenHeaders{ - AccountName: accountName, - PublicKey: publicToken, - Signature: signature, - Timestamp: ts, - Nonce: nonce, - ClientIP: ip, - RequestID: requestId, + AccountName: accountName, + PublicKey: publicToken, + Signature: signature, + Timestamp: ts, + Nonce: nonce, + ClientIP: ip, + RequestID: requestId, + IntendedOriginURL: intendedOriginURL, }, nil } @@ -253,7 +257,14 @@ func (t TokenCheckMiddleware) HasInvalidSignature(headers AuthTokenHeaders, apiK return mwguards.NotFound("error decoding signature string", "") } - signature := t.ApiKeys.FindSignatureFrom(apiKey, byteSignature) + entity := repoentity.FindSignatureFrom{ + Key: apiKey, + Signature: byteSignature, + Origin: headers.IntendedOriginURL, + ServerTime: time.Now(), + } + + signature := t.ApiKeys.FindSignatureFrom(entity) if signature == nil { return mwguards.NotFound("signature not found", "") diff --git a/pkg/portal/consts.go b/pkg/portal/consts.go index b87b36b5..11bd4ee7 100644 --- a/pkg/portal/consts.go +++ b/pkg/portal/consts.go @@ -1,4 +1,4 @@ package portal const DatesLayout = "2006-01-02 15:04:05" -const MaxSignaturesTries = 5 +const MaxSignaturesTries = 10 From a28715a6a3730015e3a5609904b5a10770032bc0 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 5 Sep 2025 17:08:38 +0800 Subject: [PATCH 25/30] format --- pkg/middleware/pipeline.go | 3 - pkg/middleware/token_middleware.go | 105 ++++++++--------------------- pkg/middleware/valid_timestamp.go | 8 +-- pkg/portal/consts.go | 17 +++++ 4 files changed, 46 insertions(+), 87 deletions(-) diff --git a/pkg/middleware/pipeline.go b/pkg/middleware/pipeline.go index 81a9f5d4..8220f127 100644 --- a/pkg/middleware/pipeline.go +++ b/pkg/middleware/pipeline.go @@ -13,9 +13,6 @@ type Pipeline struct { TokenHandler *auth.TokenHandler } -// Chain applies a list of middleware handlers to a final ApiHandler. -// It builds the chain in reverse, so the first middleware -// in the list is the outermost one, executing first. func (m Pipeline) Chain(h http.ApiHandler, handlers ...http.Middleware) http.ApiHandler { for i := len(handlers) - 1; i >= 0; i-- { h = handlers[i](h) diff --git a/pkg/middleware/token_middleware.go b/pkg/middleware/token_middleware.go index 55c1f5d5..420d3176 100644 --- a/pkg/middleware/token_middleware.go +++ b/pkg/middleware/token_middleware.go @@ -19,86 +19,37 @@ import ( "github.com/oullin/pkg/portal" ) -const tokenHeader = "X-API-Key" -const usernameHeader = "X-API-Username" -const signatureHeader = "X-API-Signature" -const timestampHeader = "X-API-Timestamp" -const nonceHeader = "X-API-Nonce" -const requestIDHeader = "X-Request-ID" -const intendedOrigin = "X-API-Intended-Origin" - -// Context keys for propagating auth info downstream -// Use unexported custom type to avoid collisions -type contextKey string - -const ( - authAccountNameKey contextKey = "auth.account_name" - requestIdKey contextKey = "request.id" -) - -// TokenCheckMiddleware authenticates signed API requests using account tokens. -// It validates required headers, enforces a timestamp skew window, prevents -// replay attacks via nonce tracking, compares tokens/signatures in constant time, -// and applies a basic failure-based rate limiter per client scope. -// -// Error handling: -// - Rate limiting errors return 429 Too Many Requests -// - Timestamp errors return 401 with specific messages for expired or future timestamps -// - Other authentication errors return 401 with generic messages type TokenCheckMiddleware struct { - // ApiKeys provides access to persisted API key records used to resolve - // account credentials (account name, public key, and secret key). - ApiKeys *repository.ApiKeys - - // TokenHandler performs encoding/decoding of tokens and signature creation/verification. - TokenHandler *auth.TokenHandler - - // nonceCache stores recently seen nonce to prevent replaying the same request - // within the configured TTL window. - nonceCache *cache.TTLCache - - // rateLimiter throttles repeated authentication failures per "clientIP|account" scope. - rateLimiter *limiter.MemoryLimiter - - // clockSkew defines the allowed difference between client and server time when - // validating the request timestamp. - clockSkew time.Duration - - // Now is an injectable time source for deterministic tests. If nil, time.Now is used. - now func() time.Time - - // disallowFuture, if true, rejects timestamps greater than the current server time, - // even if they are within the positive skew window. - disallowFuture bool - - // nonceTTL is how long nonce remains invalid after its first use (replay-protection window). - nonceTTL time.Duration - - // failWindow indicates the sliding time window used to evaluate authentication failures. - failWindow time.Duration - - // maxFailPerScope is the maximum number of failures allowed within the failWindow for a given scope. maxFailPerScope int + disallowFuture bool + nonceTTL time.Duration + failWindow time.Duration + clockSkew time.Duration + nonceCache *cache.TTLCache + now func() time.Time + TokenHandler *auth.TokenHandler + ApiKeys *repository.ApiKeys + rateLimiter *limiter.MemoryLimiter } func MakeTokenMiddleware(tokenHandler *auth.TokenHandler, apiKeys *repository.ApiKeys) TokenCheckMiddleware { return TokenCheckMiddleware{ + maxFailPerScope: 10, + disallowFuture: true, ApiKeys: apiKeys, + now: time.Now, TokenHandler: tokenHandler, - nonceCache: cache.NewTTLCache(), - rateLimiter: limiter.NewMemoryLimiter(1*time.Minute, 10), clockSkew: 5 * time.Minute, - now: time.Now, - disallowFuture: true, - nonceTTL: 5 * time.Minute, failWindow: 1 * time.Minute, - maxFailPerScope: 10, + nonceTTL: 5 * time.Minute, + nonceCache: cache.NewTTLCache(), + rateLimiter: limiter.NewMemoryLimiter(1*time.Minute, 10), } } func (t TokenCheckMiddleware) Handle(next http.ApiHandler) http.ApiHandler { return func(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { - reqID := strings.TrimSpace(r.Header.Get(requestIDHeader)) + reqID := strings.TrimSpace(r.Header.Get(portal.RequestIDHeader)) if reqID == "" { return mwguards.InvalidRequestError(fmt.Sprintf("Invalid request ID for URL [%s].", r.URL.Path), "") @@ -167,13 +118,13 @@ func (t TokenCheckMiddleware) GuardDependencies() *http.ApiError { } func (t TokenCheckMiddleware) ValidateAndGetHeaders(r *baseHttp.Request, requestId string) (AuthTokenHeaders, *http.ApiError) { - accountName := strings.TrimSpace(r.Header.Get(usernameHeader)) - signature := strings.TrimSpace(r.Header.Get(signatureHeader)) - publicToken := strings.TrimSpace(r.Header.Get(tokenHeader)) - ts := strings.TrimSpace(r.Header.Get(timestampHeader)) - nonce := strings.TrimSpace(r.Header.Get(nonceHeader)) + intendedOriginURL := strings.TrimSpace(r.Header.Get(portal.IntendedOrigin)) + accountName := strings.TrimSpace(r.Header.Get(portal.UsernameHeader)) + signature := strings.TrimSpace(r.Header.Get(portal.SignatureHeader)) + publicToken := strings.TrimSpace(r.Header.Get(portal.TokenHeader)) + ts := strings.TrimSpace(r.Header.Get(portal.TimestampHeader)) + nonce := strings.TrimSpace(r.Header.Get(portal.NonceHeader)) ip := portal.ParseClientIP(r) - intendedOriginURL := strings.TrimSpace(r.Header.Get(intendedOrigin)) if accountName == "" || publicToken == "" || signature == "" || ts == "" || nonce == "" || ip == "" || intendedOriginURL == "" { return AuthTokenHeaders{}, mwguards.InvalidRequestError( @@ -187,20 +138,20 @@ func (t TokenCheckMiddleware) ValidateAndGetHeaders(r *baseHttp.Request, request } return AuthTokenHeaders{ - AccountName: accountName, - PublicKey: publicToken, - Signature: signature, Timestamp: ts, - Nonce: nonce, ClientIP: ip, + Nonce: nonce, + Signature: signature, RequestID: requestId, + AccountName: accountName, + PublicKey: publicToken, IntendedOriginURL: intendedOriginURL, }, nil } func (t TokenCheckMiddleware) AttachContext(r *baseHttp.Request, headers AuthTokenHeaders) *baseHttp.Request { - ctx := context.WithValue(r.Context(), authAccountNameKey, headers.AccountName) - ctx = context.WithValue(r.Context(), requestIdKey, headers.RequestID) + ctx := context.WithValue(r.Context(), portal.AuthAccountNameKey, headers.AccountName) + ctx = context.WithValue(r.Context(), portal.RequestIdKey, headers.RequestID) return r.WithContext(ctx) } diff --git a/pkg/middleware/valid_timestamp.go b/pkg/middleware/valid_timestamp.go index 2809b6b7..7877af7b 100644 --- a/pkg/middleware/valid_timestamp.go +++ b/pkg/middleware/valid_timestamp.go @@ -8,14 +8,8 @@ import ( "github.com/oullin/pkg/http" ) -// ValidTimestamp encapsulates timestamp validation context. -// It accepts: the raw timestamp string (ts), a logger, and a clock (now) function. -// Use Validate to check against a provided skew window and future-time policy. type ValidTimestamp struct { - // ts is the timestamp string (expected Unix epoch in seconds). - ts string - - // now returns the current time; useful to inject a deterministic clock in tests. + ts string now func() time.Time } diff --git a/pkg/portal/consts.go b/pkg/portal/consts.go index 11bd4ee7..78dca1d1 100644 --- a/pkg/portal/consts.go +++ b/pkg/portal/consts.go @@ -2,3 +2,20 @@ package portal const DatesLayout = "2006-01-02 15:04:05" const MaxSignaturesTries = 10 + +// ---- Middleware / HTTP + +const TokenHeader = "X-API-Key" +const UsernameHeader = "X-API-Username" +const SignatureHeader = "X-API-Signature" +const TimestampHeader = "X-API-Timestamp" +const NonceHeader = "X-API-Nonce" +const RequestIDHeader = "X-Request-ID" +const IntendedOrigin = "X-API-Intended-Origin" + +// ---- Middleware / Context + +type contextKey string + +const AuthAccountNameKey contextKey = "auth.account_name" +const RequestIdKey contextKey = "request.id" From 621cf10edff4f0a61d18d1f998048bce9c1f6422 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 5 Sep 2025 17:10:54 +0800 Subject: [PATCH 26/30] caddy headers --- caddy/Caddyfile.local | 4 ++-- caddy/Caddyfile.prod | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/caddy/Caddyfile.local b/caddy/Caddyfile.local index 94bbf7eb..d1c84dbc 100644 --- a/caddy/Caddyfile.local +++ b/caddy/Caddyfile.local @@ -16,7 +16,7 @@ header { Access-Control-Allow-Origin "http://localhost:5173" # allows the Vue app (running on localhost:5173) to make requests. Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" # Specifies which methods are allowed. - Access-Control-Allow-Headers "X-API-Key, X-API-Username, X-API-Signature, X-API-Timestamp, X-API-Nonce, X-Request-ID, Content-Type, User-Agent, If-None-Match" # allows the custom headers needed by the API. + Access-Control-Allow-Headers "X-API-Key, X-API-Username, X-API-Signature, X-API-Timestamp, X-API-Nonce, X-Request-ID, Content-Type, User-Agent, If-None-Match, X-API-Intended-Origin" # allows the custom headers needed by the API. Access-Control-Expose-Headers "ETag, X-Request-ID" } @@ -30,7 +30,7 @@ # Reflect the Origin back so it's always allowed header Access-Control-Allow-Origin "{http.request.header.Origin}" header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" - header Access-Control-Allow-Headers "X-API-Key, X-API-Username, X-API-Signature, X-API-Timestamp, X-API-Nonce, X-Request-ID, Content-Type, User-Agent, If-None-Match" + header Access-Control-Allow-Headers "X-API-Key, X-API-Username, X-API-Signature, X-API-Timestamp, X-API-Nonce, X-Request-ID, Content-Type, User-Agent, If-None-Match, X-API-Intended-Origin" header Access-Control-Max-Age "86400" respond 204 } diff --git a/caddy/Caddyfile.prod b/caddy/Caddyfile.prod index 10507694..a9614925 100644 --- a/caddy/Caddyfile.prod +++ b/caddy/Caddyfile.prod @@ -34,7 +34,7 @@ oullin.io { header { Access-Control-Allow-Origin "https://oullin.io" Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" - Access-Control-Allow-Headers "X-API-Key, X-API-Username, X-API-Signature, X-API-Timestamp, X-API-Nonce, X-Request-ID, Content-Type, User-Agent, If-None-Match" + Access-Control-Allow-Headers "X-API-Key, X-API-Username, X-API-Signature, X-API-Timestamp, X-API-Nonce, X-Request-ID, Content-Type, User-Agent, If-None-Match, X-API-Intended-Origin" Access-Control-Expose-Headers "ETag, X-Request-ID" } @@ -47,7 +47,7 @@ oullin.io { # Reflect the Origin back so it's always allowed header Access-Control-Allow-Origin "{http.request.header.Origin}" header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" - header Access-Control-Allow-Headers "X-API-Key, X-API-Username, X-API-Signature, X-API-Timestamp, X-API-Nonce, X-Request-ID, Content-Type, User-Agent, If-None-Match" + header Access-Control-Allow-Headers "X-API-Key, X-API-Username, X-API-Signature, X-API-Timestamp, X-API-Nonce, X-Request-ID, Content-Type, User-Agent, If-None-Match, X-API-Intended-Origin" header Access-Control-Max-Age "86400" respond 204 } @@ -63,6 +63,7 @@ oullin.io { header_up Content-Type {http.request.header.Content-Type} header_up User-Agent {http.request.header.User-Agent} header_up If-None-Match {http.request.header.If-None-Match} + header_up X-API-Intended-Origin {http.request.header.X-API-Intended-Origin} transport http { dial_timeout 10s From 8c16cc49a92b1d1cc9ec5749a930b1749d5b24c8 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 5 Sep 2025 17:18:10 +0800 Subject: [PATCH 27/30] tweaks --- database/infra/migrations/000003_api_keys_signatures.up.sql | 1 + database/repository/api_keys.go | 5 +++-- handler/signatures.go | 2 +- pkg/middleware/token_middleware.go | 2 +- pkg/portal/consts.go | 2 +- 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/database/infra/migrations/000003_api_keys_signatures.up.sql b/database/infra/migrations/000003_api_keys_signatures.up.sql index ddba5718..1227fca3 100644 --- a/database/infra/migrations/000003_api_keys_signatures.up.sql +++ b/database/infra/migrations/000003_api_keys_signatures.up.sql @@ -17,6 +17,7 @@ CREATE TABLE api_key_signatures ( ); CREATE INDEX idx_api_key_signatures_signature_created_at ON api_key_signatures(signature, created_at); +CREATE INDEX idx_api_key_signatures_origin ON api_key_signatures(origin); CREATE INDEX idx_api_key_signatures_expires_at ON api_key_signatures(expires_at); CREATE INDEX idx_api_key_signatures_expired_at ON api_key_signatures(expired_at); CREATE INDEX idx_api_key_signatures_created_at ON api_key_signatures(created_at); diff --git a/database/repository/api_keys.go b/database/repository/api_keys.go index ce46a75a..383f9c8a 100644 --- a/database/repository/api_keys.go +++ b/database/repository/api_keys.go @@ -57,7 +57,7 @@ func (a ApiKeys) FindBy(accountName string) *database.APIKey { func (a ApiKeys) CreateSignatureFor(entity repoentity.APIKeyCreateSignatureFor) (*database.APIKeySignatures, error) { var item *database.APIKeySignatures - if item = a.FindActiveSignatureFor(entity.Key); item != nil { + if item = a.FindActiveSignatureFor(entity.Key, entity.Origin); item != nil { item.ExpiresAt = entity.ExpiresAt a.DB.Sql().Save(&item) } @@ -87,13 +87,14 @@ func (a ApiKeys) CreateSignatureFor(entity repoentity.APIKeyCreateSignatureFor) return &signature, nil } -func (a ApiKeys) FindActiveSignatureFor(key *database.APIKey) *database.APIKeySignatures { +func (a ApiKeys) FindActiveSignatureFor(key *database.APIKey, origin string) *database.APIKeySignatures { var item database.APIKeySignatures result := a.DB.Sql(). Model(&database.APIKeySignatures{}). Where("expired_at IS NULL"). Where("api_key_id = ?", key.ID). + Where("origin = ?", origin). Where("current_tries <= ? ", portal.MaxSignaturesTries). First(&item) diff --git a/handler/signatures.go b/handler/signatures.go index c94226b7..3801f229 100644 --- a/handler/signatures.go +++ b/handler/signatures.go @@ -52,7 +52,7 @@ func (s *SignaturesHandler) Generate(w baseHttp.ResponseWriter, r *baseHttp.Requ serverTime := time.Now() receivedAt := time.Unix(req.Timestamp, 0) - req.Origin = r.Header.Get("X-API-Intended-Origin") + req.Origin = r.Header.Get(portal.IntendedOriginHeader) if err = s.isRequestWithinTimeframe(serverTime, receivedAt); err != nil { return http.LogBadRequestError(err.Error(), err) diff --git a/pkg/middleware/token_middleware.go b/pkg/middleware/token_middleware.go index 420d3176..520de7f8 100644 --- a/pkg/middleware/token_middleware.go +++ b/pkg/middleware/token_middleware.go @@ -118,7 +118,7 @@ func (t TokenCheckMiddleware) GuardDependencies() *http.ApiError { } func (t TokenCheckMiddleware) ValidateAndGetHeaders(r *baseHttp.Request, requestId string) (AuthTokenHeaders, *http.ApiError) { - intendedOriginURL := strings.TrimSpace(r.Header.Get(portal.IntendedOrigin)) + intendedOriginURL := strings.TrimSpace(r.Header.Get(portal.IntendedOriginHeader)) accountName := strings.TrimSpace(r.Header.Get(portal.UsernameHeader)) signature := strings.TrimSpace(r.Header.Get(portal.SignatureHeader)) publicToken := strings.TrimSpace(r.Header.Get(portal.TokenHeader)) diff --git a/pkg/portal/consts.go b/pkg/portal/consts.go index 78dca1d1..7119fbc0 100644 --- a/pkg/portal/consts.go +++ b/pkg/portal/consts.go @@ -11,7 +11,7 @@ const SignatureHeader = "X-API-Signature" const TimestampHeader = "X-API-Timestamp" const NonceHeader = "X-API-Nonce" const RequestIDHeader = "X-Request-ID" -const IntendedOrigin = "X-API-Intended-Origin" +const IntendedOriginHeader = "X-API-Intended-Origin" // ---- Middleware / Context From c452421fa7a80693afe23e68800b015b3c441a6a Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Mon, 8 Sep 2025 12:23:05 +0800 Subject: [PATCH 28/30] format --- database/connection.go | 3 ++- database/model.go | 10 ++++---- database/repository/api_keys.go | 29 ++++++++++++++++------- handler/payload/signatures.go | 4 ++-- metal/cli/panel/menu.go | 2 +- pkg/middleware/mwguards/mw_token_guard.go | 2 +- pkg/middleware/token_middleware.go | 11 +++++++-- pkg/portal/consts.go | 2 +- 8 files changed, 41 insertions(+), 22 deletions(-) diff --git a/database/connection.go b/database/connection.go index 96703095..8f08e64c 100644 --- a/database/connection.go +++ b/database/connection.go @@ -3,10 +3,11 @@ package database import ( "database/sql" "fmt" + "log/slog" + "github.com/oullin/metal/env" "gorm.io/driver/postgres" "gorm.io/gorm" - "log/slog" ) type Connection struct { diff --git a/database/model.go b/database/model.go index 5ee504bf..8945e16b 100644 --- a/database/model.go +++ b/database/model.go @@ -41,15 +41,15 @@ type APIKey struct { type APIKeySignatures struct { ID int64 `gorm:"primaryKey"` UUID string `gorm:"type:uuid;unique;not null"` - APIKeyID int64 `gorm:"not null"` + APIKeyID int64 `gorm:"not null;index:idx_api_key_signatures_api_key_id"` MaxTries int `gorm:"not null"` CurrentTries int `gorm:"not null"` - APIKey APIKey `gorm:"foreignKey:APIKeyID"` - Signature []byte `gorm:"not null;uniqueIndex:uq_signature_created_at;index:idx_signature"` - Origin string `gorm:"type:varchar(255);not null"` + APIKey APIKey `gorm:"foreignKey:APIKeyID;references:ID;constraint:OnDelete:CASCADE"` + Signature []byte `gorm:"not null;uniqueIndex:uq_api_key_signatures_signature"` + Origin string `gorm:"type:varchar(255);not null;index:idx_api_key_signatures_origin"` ExpiresAt time.Time `gorm:"index:idx_api_key_signatures_expires_at"` ExpiredAt *time.Time `gorm:"index:idx_api_key_signatures_expired_at"` - CreatedAt time.Time `gorm:"uniqueIndex:uq_signature_created_at;index:idx_api_key_signatures_created_at"` + CreatedAt time.Time `gorm:"index:idx_api_key_signatures_created_at"` UpdatedAt time.Time `gorm:"index:idx_api_key_signatures_updated_at"` DeletedAt gorm.DeletedAt `gorm:"index:idx_api_key_signatures_deleted_at"` } diff --git a/database/repository/api_keys.go b/database/repository/api_keys.go index 383f9c8a..32684044 100644 --- a/database/repository/api_keys.go +++ b/database/repository/api_keys.go @@ -10,6 +10,7 @@ import ( "github.com/oullin/database/repository/repoentity" "github.com/oullin/pkg/gorm" "github.com/oullin/pkg/portal" + baseGorm "gorm.io/gorm" ) type ApiKeys struct { @@ -58,8 +59,10 @@ func (a ApiKeys) CreateSignatureFor(entity repoentity.APIKeyCreateSignatureFor) var item *database.APIKeySignatures if item = a.FindActiveSignatureFor(entity.Key, entity.Origin); item != nil { - item.ExpiresAt = entity.ExpiresAt + item.CurrentTries++ a.DB.Sql().Save(&item) + + return item, nil } now := time.Now() @@ -75,13 +78,21 @@ func (a ApiKeys) CreateSignatureFor(entity repoentity.APIKeyCreateSignatureFor) CurrentTries: 1, } - username := entity.Key.AccountName - if result := a.DB.Sql().Create(&signature); gorm.HasDbIssues(result.Error) { - return nil, fmt.Errorf("issue creating the given api keys signature [%s, %s]: ", username, result.Error) - } + err := a.DB.Sql().Transaction(func(tx *baseGorm.DB) error { + username := entity.Key.AccountName + if result := a.DB.Sql().Create(&signature); gorm.HasDbIssues(result.Error) { + return fmt.Errorf("issue creating the given api keys signature [%s, %s]: ", username, result.Error) + } - if result := a.DisablePreviousSignatures(entity.Key, signature.UUID, entity.Origin); result != nil { - return nil, fmt.Errorf("issue disabling previous api keys signature [%s, %s]: ", username, result.Error()) + if result := a.DisablePreviousSignatures(entity.Key, signature.UUID, entity.Origin); result != nil { + return fmt.Errorf("issue disabling previous api keys signature [%s, %s]: ", username, result) + } + + return nil + }) + + if err != nil { + return nil, err } return &signature, nil @@ -95,7 +106,8 @@ func (a ApiKeys) FindActiveSignatureFor(key *database.APIKey, origin string) *da Where("expired_at IS NULL"). Where("api_key_id = ?", key.ID). Where("origin = ?", origin). - Where("current_tries <= ? ", portal.MaxSignaturesTries). + Where("current_tries <= max_tries"). + Where("expires_at > ?", time.Now()). First(&item) if gorm.HasDbIssues(result.Error) { @@ -146,7 +158,6 @@ func (a ApiKeys) DisablePreviousSignatures(key *database.APIKey, signatureUUID, Where("origin = ?", origin). Or("TRIM(origin) = ''"), ). - Where("origin = ?", origin). Where("uuid NOT IN (?)", []string{signatureUUID}). Update("expired_at", time.Now()) diff --git a/handler/payload/signatures.go b/handler/payload/signatures.go index 213aed4c..d6fb7877 100644 --- a/handler/payload/signatures.go +++ b/handler/payload/signatures.go @@ -1,10 +1,10 @@ package payload type SignatureRequest struct { - Nonce string `json:"nonce" validate:"required,lowercase,len=32"` + Nonce string `json:"nonce" validate:"required,lowercase,hexadecimal,len=32"` PublicKey string `json:"public_key" validate:"required,lowercase,min=64,max=67"` Username string `json:"username" validate:"required,lowercase,min=5"` - Timestamp int64 `json:"timestamp" validate:"required,number,min=10"` + Timestamp int64 `json:"timestamp" validate:"required,number,gte=1000000000,min=10"` Origin string `json:"origin"` } diff --git a/metal/cli/panel/menu.go b/metal/cli/panel/menu.go index f6169784..074c8d7f 100644 --- a/metal/cli/panel/menu.go +++ b/metal/cli/panel/menu.go @@ -89,7 +89,7 @@ func (p *Menu) Print() { p.PrintOption("1) Parse Blog Posts.", inner) p.PrintOption("2) Create new API account.", inner) p.PrintOption("3) Show API accounts.", inner) - p.PrintOption("4) Generate API accounts HTTP keys pair.", inner) + p.PrintOption("4) Generate API account HTTP key pair.", inner) p.PrintOption(" ", inner) p.PrintOption("0) Exit.", inner) diff --git a/pkg/middleware/mwguards/mw_token_guard.go b/pkg/middleware/mwguards/mw_token_guard.go index 5ef6fa5b..c5bde87f 100644 --- a/pkg/middleware/mwguards/mw_token_guard.go +++ b/pkg/middleware/mwguards/mw_token_guard.go @@ -84,7 +84,7 @@ func (g *MWTokenGuard) HasInvalidFormat(publicKey string) bool { hE := sha256.Sum256(eBytes) if subtle.ConstantTimeCompare(hP[:], hE[:]) != 1 { - g.Error = fmt.Errorf("invalid provided public token: %s", publicKey) + g.Error = fmt.Errorf("invalid provided public token: %s", auth.SafeDisplay(publicKey)) return true } diff --git a/pkg/middleware/token_middleware.go b/pkg/middleware/token_middleware.go index 520de7f8..58620fd8 100644 --- a/pkg/middleware/token_middleware.go +++ b/pkg/middleware/token_middleware.go @@ -151,7 +151,7 @@ func (t TokenCheckMiddleware) ValidateAndGetHeaders(r *baseHttp.Request, request func (t TokenCheckMiddleware) AttachContext(r *baseHttp.Request, headers AuthTokenHeaders) *baseHttp.Request { ctx := context.WithValue(r.Context(), portal.AuthAccountNameKey, headers.AccountName) - ctx = context.WithValue(r.Context(), portal.RequestIdKey, headers.RequestID) + ctx = context.WithValue(r.Context(), portal.RequestIDKey, headers.RequestID) return r.WithContext(ctx) } @@ -174,7 +174,7 @@ func (t TokenCheckMiddleware) HasInvalidFormat(headers AuthTokenHeaders) (*datab } if guard.Rejects(rejectsRequest) { - t.rateLimiter.Fail(headers.AccountName) + t.rateLimiter.Fail(limiterKey) return nil, mwguards.UnauthenticatedError( "Invalid public token", @@ -203,8 +203,11 @@ func (t TokenCheckMiddleware) HasInvalidFormat(headers AuthTokenHeaders) (*datab func (t TokenCheckMiddleware) HasInvalidSignature(headers AuthTokenHeaders, apiKey *database.APIKey) *http.ApiError { var err error var byteSignature []byte + limiterKey := headers.ClientIP + "|" + strings.ToLower(headers.AccountName) if byteSignature, err = hex.DecodeString(headers.Signature); err != nil { + t.rateLimiter.Fail(limiterKey) + return mwguards.NotFound("error decoding signature string", "") } @@ -218,10 +221,14 @@ func (t TokenCheckMiddleware) HasInvalidSignature(headers AuthTokenHeaders, apiK signature := t.ApiKeys.FindSignatureFrom(entity) if signature == nil { + t.rateLimiter.Fail(limiterKey) + return mwguards.NotFound("signature not found", "") } if err = t.ApiKeys.IncreaseSignatureTries(signature.UUID, signature.CurrentTries+1); err != nil { + t.rateLimiter.Fail(limiterKey) + return mwguards.InvalidRequestError("could not increase signature tries", err.Error()) } diff --git a/pkg/portal/consts.go b/pkg/portal/consts.go index 7119fbc0..70bfe672 100644 --- a/pkg/portal/consts.go +++ b/pkg/portal/consts.go @@ -18,4 +18,4 @@ const IntendedOriginHeader = "X-API-Intended-Origin" type contextKey string const AuthAccountNameKey contextKey = "auth.account_name" -const RequestIdKey contextKey = "request.id" +const RequestIDKey contextKey = "request.id" From d5fcf8cf0fec8e47c17b51cfa9a2ca3a4304cab3 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Mon, 8 Sep 2025 12:30:50 +0800 Subject: [PATCH 29/30] clean up --- handler/signatures.go | 22 +++++++++++----------- main.go | 5 ----- pkg/auth/signature.go | 8 ++++++++ 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/handler/signatures.go b/handler/signatures.go index 3801f229..6afee908 100644 --- a/handler/signatures.go +++ b/handler/signatures.go @@ -84,17 +84,17 @@ func (s *SignaturesHandler) Generate(w baseHttp.ResponseWriter, r *baseHttp.Requ } func (s *SignaturesHandler) isRequestWithinTimeframe(serverTime, receivedAt time.Time) error { - //skew := 5 * time.Second - - //earliestValidTime := serverTime.Add(-skew) - //if receivedAt.Before(earliestValidTime) { - // return fmt.Errorf("the request timestamp [%s] is too old", receivedAt.Format(portal.DatesLayout)) - //} - // - //latestValidTime := serverTime.Add(skew) - //if receivedAt.After(latestValidTime) { - // return fmt.Errorf("the request timestamp [%s] is from the future", receivedAt.Format(portal.DatesLayout)) - //} + skew := 5 * time.Second + + earliestValidTime := serverTime.Add(-skew) + if receivedAt.Before(earliestValidTime) { + return fmt.Errorf("the request timestamp [%s] is too old", receivedAt.Format(portal.DatesLayout)) + } + + latestValidTime := serverTime.Add(skew) + if receivedAt.After(latestValidTime) { + return fmt.Errorf("the request timestamp [%s] is from the future", receivedAt.Format(portal.DatesLayout)) + } return nil } diff --git a/main.go b/main.go index 7c0fc334..19e9178f 100644 --- a/main.go +++ b/main.go @@ -43,11 +43,6 @@ func main() { slog.Error("Error starting server", "error", err) panic("Error starting server." + err.Error()) } - - //if err := baseHttp.ListenAndServe(app.GetEnv().Network.GetHostURL(), app.GetMux()); err != nil { - // slog.Error("Error starting server", "error", err) - // panic("Error starting server." + err.Error()) - //} } func serverHandler() baseHttp.Handler { diff --git a/pkg/auth/signature.go b/pkg/auth/signature.go index d77699b0..b122d042 100644 --- a/pkg/auth/signature.go +++ b/pkg/auth/signature.go @@ -16,3 +16,11 @@ func CreateSignature(message, secretKey []byte) []byte { func SignatureToString(signature []byte) string { return hex.EncodeToString(signature) } + +func VerifySignature(message, secretKey, signature []byte) bool { + mac := hmac.New(sha256.New, secretKey) + _, _ = mac.Write(message) + expected := mac.Sum(nil) + + return hmac.Equal(expected, signature) +} From 58f30669ef0a538a20eec2b5a32ee90af970bbe3 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Mon, 8 Sep 2025 12:37:46 +0800 Subject: [PATCH 30/30] performance --- .../000003_api_keys_signatures.up.sql | 3 ++- database/repository/api_keys.go | 22 +++++++------------ 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/database/infra/migrations/000003_api_keys_signatures.up.sql b/database/infra/migrations/000003_api_keys_signatures.up.sql index 1227fca3..ed5292ff 100644 --- a/database/infra/migrations/000003_api_keys_signatures.up.sql +++ b/database/infra/migrations/000003_api_keys_signatures.up.sql @@ -12,10 +12,11 @@ CREATE TABLE api_key_signatures ( updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, deleted_at TIMESTAMP DEFAULT NULL, - CONSTRAINT api_key_signatures_unique_signature UNIQUE (signature, api_key_id, created_at), + CONSTRAINT uq_api_key_signatures_signature UNIQUE (signature), CONSTRAINT api_key_signatures_fk_api_key_id FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE CASCADE ); +CREATE INDEX idx_api_key_signatures_api_key_id ON api_key_signatures(api_key_id); CREATE INDEX idx_api_key_signatures_signature_created_at ON api_key_signatures(signature, created_at); CREATE INDEX idx_api_key_signatures_origin ON api_key_signatures(origin); CREATE INDEX idx_api_key_signatures_expires_at ON api_key_signatures(expires_at); diff --git a/database/repository/api_keys.go b/database/repository/api_keys.go index 32684044..77e30753 100644 --- a/database/repository/api_keys.go +++ b/database/repository/api_keys.go @@ -169,27 +169,21 @@ func (a ApiKeys) DisablePreviousSignatures(key *database.APIKey, signatureUUID, } func (a ApiKeys) IncreaseSignatureTries(signatureUUID string, currentTries int) error { - if currentTries < portal.MaxSignaturesTries { + if currentTries >= portal.MaxSignaturesTries { return nil } - var item database.APIKeySignatures - - query := a.DB.Sql(). + response := a.DB.Sql(). Model(&database.APIKeySignatures{}). - Where("uuid = ?", signatureUUID). - First(&item) + Where("uuid = ? AND current_tries < max_tries", signatureUUID). + UpdateColumn("current_tries", baseGorm.Expr("current_tries + 1")) - if gorm.HasDbIssues(query.Error) { - return query.Error + if gorm.HasDbIssues(response.Error) { + return response.Error } - update := a.DB.Sql(). - Model(&item). - Update("current_tries", currentTries) - - if gorm.HasDbIssues(update.Error) { - return update.Error + if response.RowsAffected == 0 { + return nil } return nil