feat: declare API errors and authentication with developer tooling

This commit is contained in:
2026-09-23 18:17:43 +08:00
parent ab8eac5fc4
commit 2913e94a0a
35 changed files with 2246 additions and 107 deletions

View File

@@ -4,18 +4,18 @@ import (
"crypto/sha256"
"crypto/subtle"
"errors"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"example.com/userService/internal/apierrors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"os"
"strings"
)
// Auth is the fail-closed registry default. Startup replaces it with configured
// authentication. This administrative example uses one environment-only key;
// authentication. This example uses an administrator key and an optional read key;
// production applications can inject JWT/OIDC through the same middleware slot.
func Auth(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
return apierrors.Unauthorized("UNAUTHENTICATED", "authentication required")
return apierrors.Unauthenticated()
}
}
func NewAuthFromEnv() (httpx.Middleware, error) {
@@ -23,16 +23,26 @@ func NewAuthFromEnv() (httpx.Middleware, error) {
if len(token) < 32 || strings.ContainsAny(token, " \t\r\n") {
return nil, errors.New("USER_SERVICE_API_TOKEN requires at least 32 bytes without whitespace")
}
readToken := os.Getenv("USER_SERVICE_READ_TOKEN")
if readToken != "" && (len(readToken) < 32 || strings.ContainsAny(readToken, " \t\r\n") || readToken == token) {
return nil, errors.New("USER_SERVICE_READ_TOKEN must be a distinct token of at least 32 bytes without whitespace")
}
expectedRead := sha256.Sum256([]byte(readToken))
expected := sha256.Sum256([]byte(token))
return func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
parts := strings.Fields(c.Header("Authorization"))
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
return apierrors.Unauthorized("UNAUTHENTICATED", "authentication required")
return apierrors.Unauthenticated()
}
actual := sha256.Sum256([]byte(parts[1]))
if subtle.ConstantTimeCompare(expected[:], actual[:]) != 1 {
return apierrors.Unauthorized("UNAUTHENTICATED", "authentication required")
admin := subtle.ConstantTimeCompare(expected[:], actual[:]) == 1
read := subtle.ConstantTimeCompare(expectedRead[:], actual[:]) == 1 && readToken != ""
if !admin && !read {
return apierrors.Unauthenticated()
}
if !admin && c.Request().Method != "GET" && c.Request().Method != "HEAD" {
return apierrors.Forbidden()
}
return next(c)
}

View File

@@ -33,3 +33,31 @@ func TestAuthFailsClosedAndDoesNotLeakKey(t *testing.T) {
}
}
}
func TestReadOnlyTokenCanReadButCannotWrite(t *testing.T) {
admin := "admin-012345678901234567890123456789"
read := "read-012345678901234567890123456789"
t.Setenv("USER_SERVICE_API_TOKEN", admin)
t.Setenv("USER_SERVICE_READ_TOKEN", read)
middleware, err := NewAuthFromEnv()
if err != nil {
t.Fatal(err)
}
for _, method := range []string{"GET", "POST", "PUT", "DELETE"} {
called := false
r := httptest.NewRequest(method, "/api/v1/users", nil)
r.Header.Set("Authorization", "Bearer "+read)
err = middleware(func(*httpx.Context) error { called = true; return nil })(httpx.NewContext(r, httptest.NewRecorder(), nil))
if method == "GET" {
if err != nil || !called {
t.Fatal("read token rejected on GET")
}
} else if err == nil || called || apierrors.From(err).Status != 403 {
t.Fatal("read token reached a write")
}
}
t.Setenv("USER_SERVICE_READ_TOKEN", admin)
if _, err = NewAuthFromEnv(); err == nil {
t.Fatal("identical privilege tokens accepted")
}
}