Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions internal/api/base64_password_guard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@
//
// For each such function the test verifies that the function body also contains
// a call to decodeBase64Password or one of its known delegate helpers
// (decodeChangePasswordRequest). Functions that are legitimately exempt (e.g.,
// setupAdmin, which is bootstrapped separately) must be listed in
// knownExemptFunctions with an explanation.
// (decodeChangePasswordRequest). A function that is legitimately exempt for a
// documented reason must be listed in knownExemptFunctions with an
// explanation; there are currently no such functions (see issue #224, which
// removed the previous setupAdmin exemption).
//
// # Synthetic-regression sub-test
//
Expand Down Expand Up @@ -54,14 +55,13 @@ import (
// decoding the password (e.g., the endpoint uses a different auth convention or
// is not exposed to the frontend encode/decode contract). Every entry here
// should be accompanied by a comment explaining why.
var knownExemptFunctions = map[string]string{
// setupAdmin: the bootstrap endpoint. The frontend does base64-encode the
// password (see frontend/src/api/auth.ts:setupAdmin), but the backend
// currently forwards the raw value to the auth service which handles
// hashing internally. Tracked as a separate concern; do not expand this
// exemption to other handlers.
"setupAdmin": "bootstrap endpoint — see comment in knownExemptFunctions for details",
}
// knownExemptFunctions intentionally starts empty. Add an entry here only
// when a handler has a documented reason for not decoding a password field
// (e.g., the endpoint uses a different auth convention or is not exposed to
// the frontend encode/decode contract), with a comment explaining why.
// setupAdmin was previously (wrongly) exempted here; see issue #224. It now
// decodes like every other password handler.
var knownExemptFunctions = map[string]string{}

// decodeHelpers is the set of function-call names that constitute a compliant
// alternative to calling decodeBase64Password directly. A handler that
Expand Down
9 changes: 9 additions & 0 deletions internal/api/handler_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,15 @@ func (h *Handler) setupAdmin(ctx context.Context, req *events.LambdaFunctionURLR
return nil, NewClientError(400, "invalid request body")
}

// Decode base64-encoded password (the frontend encodes it identically to
// login/resetPassword; skipping this hashes the base64 text and locks the
// bootstrap admin out on first login — see issue #224).
decoded, err := decodeBase64Password(setupReq.Password)
if err != nil {
return nil, err
}
setupReq.Password = decoded

response, err := h.auth.SetupAdmin(ctx, setupReq)
if err != nil {
// Share the sentinel→ClientError mapping with /api/users (issue #349)
Expand Down
5 changes: 4 additions & 1 deletion internal/api/handler_auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -431,13 +431,16 @@ func TestHandler_setupAdmin_Success(t *testing.T) {

handler := &Handler{auth: mockAuth}

// Password must be base64-encoded in the request body, exactly as the
// frontend encodes it (issue #224).
encodedPassword := base64.StdEncoding.EncodeToString([]byte("admin123"))
req := &events.LambdaFunctionURLRequest{
RequestContext: events.LambdaFunctionURLRequestContext{
HTTP: events.LambdaFunctionURLRequestContextHTTPDescription{
SourceIP: "127.0.0.1",
},
},
Body: `{"email": "admin@example.com", "password": "admin123"}`,
Body: `{"email": "admin@example.com", "password": "` + encodedPassword + `"}`,
}

result, err := handler.setupAdmin(ctx, req)
Expand Down
6 changes: 5 additions & 1 deletion internal/api/handler_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package api

import (
"context"
"encoding/base64"
"errors"
"testing"

Expand Down Expand Up @@ -239,8 +240,11 @@ func TestRouter_Handlers_Coverage(t *testing.T) {
h := &Handler{auth: mockAuth}
router := NewRouter(h)

// Password must be base64-encoded, exactly as the frontend encodes it
// (issue #224) - setupAdmin decodes it before forwarding to auth.
encodedPassword := base64.StdEncoding.EncodeToString([]byte("pass123"))
req := &events.LambdaFunctionURLRequest{
Body: `{"email": "admin@example.com", "password": "pass123"}`,
Body: `{"email": "admin@example.com", "password": "` + encodedPassword + `"}`,
}

result, err := router.setupAdminHandler(ctx, req, nil)
Expand Down
79 changes: 79 additions & 0 deletions internal/server/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ package server
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"testing"
"time"

"github.com/LeanerCloud/cloud-commitments-platform/internal/api"
"github.com/LeanerCloud/cloud-commitments-platform/internal/auth"
"github.com/aws/aws-lambda-go/events"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -480,3 +482,80 @@ func TestAuthServiceAdapter_SetupAdmin(t *testing.T) {
assert.NotNil(t, resp)
assert.Equal(t, "admin@example.com", resp.User.Email)
}

// TestSetupAdminThenLogin_RoundTrip is a regression test for issue #224: the
// frontend base64-encodes the setup-admin password exactly as it does for
// login (frontend/src/api/auth.ts), but the setupAdmin handler used to
// forward the still-encoded string straight through the adapter to
// auth.Service.SetupAdmin, which bcrypt-hashed the base64 text. Every later
// login (whose handler does decode) then compared the real plaintext
// against that hash and failed, locking the bootstrap admin out
// permanently.
//
// This drives the full stack an HTTP request would: api.Router.Route ->
// api.Handler.setupAdmin/login -> authServiceAdapter -> the real
// *auth.Service, backed by an auth.MockStore that persists the user
// setupAdmin creates and hands it back to login. It fails on the pre-fix
// setupAdmin handler (which skips decodeBase64Password) and passes once it
// decodes like every other password handler.
func TestSetupAdminThenLogin_RoundTrip(t *testing.T) {
ctx := context.Background()

mockStore := new(auth.MockStore)
realAuth := auth.NewService(auth.ServiceConfig{
Store: mockStore,
SessionDuration: time.Hour,
CSRFKey: auth.TestCSRFKey(),
DashboardURL: "http://localhost:3000",
})
adapter := newAuthServiceAdapter(realAuth)
handler := api.NewHandler(api.HandlerConfig{AuthService: adapter})
router := api.NewRouter(handler)

const email = "admin@example.com"
const plaintextPassword = "SecureBootstrapPass@1"
encodedPassword := base64.StdEncoding.EncodeToString([]byte(plaintextPassword))
body := `{"email": "` + email + `", "password": "` + encodedPassword + `"}`

mockStore.On("AdminExists", ctx).Return(false, nil).Once()

var createdUser *auth.User
mockStore.On("CreateAdminIfNone", ctx, mock.AnythingOfType("*auth.User")).
Run(func(args mock.Arguments) {
u, ok := args.Get(1).(*auth.User)
require.True(t, ok)
createdUser = u
}).
Return(true, nil).Once()
mockStore.On("CreateSession", ctx, mock.AnythingOfType("*auth.Session")).Return(nil).Twice()
mockStore.On("UpdateUser", ctx, mock.AnythingOfType("*auth.User")).Return(nil).Maybe()

setupReq := &events.LambdaFunctionURLRequest{
RequestContext: events.LambdaFunctionURLRequestContext{
HTTP: events.LambdaFunctionURLRequestContextHTTPDescription{SourceIP: "127.0.0.1"},
},
Body: body,
}
_, err := router.Route(ctx, "POST", "/api/auth/setup-admin", setupReq)
require.NoError(t, err)
require.NotNil(t, createdUser, "setupAdmin must have created the admin user")

// login looks the user up by email; hand back the user setupAdmin
// created, carrying whatever password hash setupAdmin actually produced.
mockStore.On("GetUserByEmail", ctx, email).Return(createdUser, nil).Once()

loginReq := &events.LambdaFunctionURLRequest{
RequestContext: events.LambdaFunctionURLRequestContext{
HTTP: events.LambdaFunctionURLRequestContextHTTPDescription{SourceIP: "127.0.0.1"},
},
Body: body,
}
result, err := router.Route(ctx, "POST", "/api/auth/login", loginReq)
require.NoError(t, err, "the bootstrap admin must be able to log in with the same frontend-encoded password used at setup")

resp, ok := result.(*api.LoginResponse)
require.True(t, ok)
assert.Equal(t, email, resp.User.Email)

mockStore.AssertExpectations(t)
}
Loading