feat: declare API errors and authentication with developer tooling
This commit is contained in:
42
examples/userService/internal/apierrors/errorsGen.go
Normal file
42
examples/userService/internal/apierrors/errorsGen.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Code generated by go-omni api. DO NOT EDIT.
|
||||
package apierrors
|
||||
|
||||
import omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
|
||||
const CodeUnauthenticated = "UNAUTHENTICATED"
|
||||
|
||||
// Unauthenticated returns a fresh public error declared in the API contract.
|
||||
func Unauthenticated() *omnierrors.APIError {
|
||||
return omnierrors.New(401, CodeUnauthenticated, "authentication required")
|
||||
}
|
||||
|
||||
const CodeForbidden = "FORBIDDEN"
|
||||
|
||||
// Forbidden returns a fresh public error declared in the API contract.
|
||||
func Forbidden() *omnierrors.APIError { return omnierrors.New(403, CodeForbidden, "permission denied") }
|
||||
|
||||
const CodeUserNotFound = "USER_NOT_FOUND"
|
||||
|
||||
// UserNotFound returns a fresh public error declared in the API contract.
|
||||
func UserNotFound() *omnierrors.APIError {
|
||||
return omnierrors.New(404, CodeUserNotFound, "user not found")
|
||||
}
|
||||
|
||||
const CodeEmailTaken = "EMAIL_TAKEN"
|
||||
|
||||
// EmailTaken returns a fresh public error declared in the API contract.
|
||||
func EmailTaken() *omnierrors.APIError {
|
||||
return omnierrors.New(409, CodeEmailTaken, "email already in use")
|
||||
}
|
||||
|
||||
const CodeInvalidUser = "INVALID_USER"
|
||||
|
||||
// InvalidUser returns a fresh public error declared in the API contract.
|
||||
func InvalidUser() *omnierrors.APIError { return omnierrors.New(400, CodeInvalidUser, "invalid user") }
|
||||
|
||||
const CodeInvalidPage = "INVALID_PAGE"
|
||||
|
||||
// InvalidPage returns a fresh public error declared in the API contract.
|
||||
func InvalidPage() *omnierrors.APIError {
|
||||
return omnierrors.New(400, CodeInvalidPage, "invalid pagination")
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ package user
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"example.com/userService/internal/apierrors"
|
||||
"example.com/userService/internal/model"
|
||||
repo "example.com/userService/internal/repository/user"
|
||||
"example.com/userService/internal/servicecontext"
|
||||
"example.com/userService/internal/types"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"net/mail"
|
||||
"strings"
|
||||
@@ -34,19 +35,19 @@ func (s *Service) failure(ctx context.Context, err error) error {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, model.ErrUserNotFound):
|
||||
return apierrors.NotFound("USER_NOT_FOUND", "user not found")
|
||||
return apierrors.UserNotFound()
|
||||
case errors.Is(err, model.ErrEmailTaken):
|
||||
return apierrors.New(409, "EMAIL_TAKEN", "email already in use")
|
||||
return apierrors.EmailTaken()
|
||||
case errors.Is(err, model.ErrInvalidUser):
|
||||
return apierrors.BadRequest("INVALID_USER", "invalid user")
|
||||
return apierrors.InvalidUser()
|
||||
case errors.Is(err, model.ErrInvalidPage):
|
||||
return apierrors.BadRequest("INVALID_PAGE", "invalid pagination")
|
||||
return apierrors.InvalidPage()
|
||||
default:
|
||||
// Do not log SQL/DSN or user input; requestId correlates the failed operation.
|
||||
s.deps.Logger.ErrorContext(ctx, "user.operationFailed", "category", "persistence")
|
||||
// HTTP access logging also sees the returned error. Keep raw driver
|
||||
// errors out of that path because their messages may contain input.
|
||||
return apierrors.New(500, "INTERNAL_ERROR", "internal server error")
|
||||
return omnierrors.New(500, "INTERNAL_ERROR", "internal server error")
|
||||
}
|
||||
}
|
||||
func (s *Service) mutate(ctx context.Context, operation string, fn func(repo.UserRepository) (model.User, error)) (*types.UserResp, error) {
|
||||
|
||||
Reference in New Issue
Block a user