feat: bootstrap reusable GoOmni framework

This commit is contained in:
2026-09-20 22:06:24 +08:00
commit 8b19aa79ee
60 changed files with 4923 additions and 0 deletions

36
auth/README.md Normal file
View File

@@ -0,0 +1,36 @@
# auth
`auth` 只定义身份和权限边界,不把 JWT、Session、OAuth 或某个用户表绑定到核心框架。
## 核心用法
认证器把外部凭据转换成通用身份:
```go
type Authenticator interface {
Authenticate(context.Context, string) (Principal, error)
}
```
把认证放在路由组上:
```go
private := app.Group("/api", auth.RequireAuth(authenticator))
private.GET("/me", getMe)
admin := private.Group("/admin", auth.RequireRole("admin"))
admin.POST("/users", createUser)
```
Handler 可以读取身份,但不需要知道 Token 的格式:
```go
principal, ok := auth.FromContext(ctx)
if !ok {
return omni.Unauthorized("UNAUTHENTICATED", "authentication is required")
}
```
认证失败统一返回 `401 UNAUTHENTICATED`,身份存在但角色不满足统一返回 `403 FORBIDDEN`。无效 Token 的详细原因保留在错误链中,不出现在 HTTP 响应里。
`StaticAuthenticator` 只用于本地开发和测试,不能作为生产认证方案。生产项目实现 `Authenticator`,在实现内部校验 JWT、Session 或其他凭据,并返回 `Principal`。

145
auth/auth.go Normal file
View File

@@ -0,0 +1,145 @@
// Package auth defines authentication and authorization boundaries for GoOmni.
// It does not choose JWT, sessions, OAuth, or any other credential format.
package auth
import (
"context"
"errors"
"strings"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
)
const principalKey = "go-omni.auth.principal"
var (
ErrMissingToken = errors.New("authorization token is missing")
ErrInvalidToken = errors.New("authorization token is invalid")
)
// Principal is the authenticated identity made available to business handlers.
// Claims are optional provider-specific values and should not be required by core business code.
type Principal struct {
UserID string
Roles []string
Claims map[string]any
}
func (p Principal) HasRole(role string) bool {
role = strings.TrimSpace(role)
if role == "" {
return false
}
for _, candidate := range p.Roles {
if candidate == role {
return true
}
}
return false
}
// Authenticator converts a credential into a provider-neutral Principal.
type Authenticator interface {
Authenticate(context.Context, string) (Principal, error)
}
// StaticAuthenticator is intended only for local development and tests.
type StaticAuthenticator map[string]Principal
func (a StaticAuthenticator) Authenticate(_ context.Context, token string) (Principal, error) {
principal, ok := a[token]
if !ok {
return Principal{}, ErrInvalidToken
}
return clonePrincipal(principal), nil
}
// FromContext returns the authenticated identity attached by RequireAuth.
func FromContext(ctx *httpx.Context) (Principal, bool) {
value, ok := ctx.Get(principalKey)
if !ok {
return Principal{}, false
}
principal, ok := value.(Principal)
return principal, ok
}
// RequireAuth extracts a Bearer token and attaches its Principal to the request.
func RequireAuth(authenticator Authenticator) httpx.Middleware {
return func(next httpx.Handler) httpx.Handler {
return func(ctx *httpx.Context) error {
if authenticator == nil {
return apierrors.Internal(errors.New("authenticator is not configured"))
}
token, err := bearerToken(ctx.Header("Authorization"))
if err != nil {
return apierrors.Unauthorized("UNAUTHENTICATED", "authentication is required").WithCause(err)
}
principal, err := authenticator.Authenticate(ctx.Request().Context(), token)
if err != nil || strings.TrimSpace(principal.UserID) == "" {
if err == nil {
err = ErrInvalidToken
}
return apierrors.Unauthorized("UNAUTHENTICATED", "authentication is required").WithCause(err)
}
ctx.Set(principalKey, clonePrincipal(principal))
return next(ctx)
}
}
}
// OptionalAuth attaches a Principal when a valid Bearer token is present.
// Missing credentials are allowed; malformed or invalid credentials are rejected.
func OptionalAuth(authenticator Authenticator) httpx.Middleware {
return func(next httpx.Handler) httpx.Handler {
return func(ctx *httpx.Context) error {
if strings.TrimSpace(ctx.Header("Authorization")) == "" {
return next(ctx)
}
return RequireAuth(authenticator)(next)(ctx)
}
}
}
// RequireRole checks a Principal attached by RequireAuth.
func RequireRole(roles ...string) httpx.Middleware {
return func(next httpx.Handler) httpx.Handler {
return func(ctx *httpx.Context) error {
principal, ok := FromContext(ctx)
if !ok {
return apierrors.Unauthorized("UNAUTHENTICATED", "authentication is required")
}
for _, role := range roles {
if principal.HasRole(role) {
return next(ctx)
}
}
return apierrors.Forbidden("FORBIDDEN", "permission denied")
}
}
}
func bearerToken(value string) (string, error) {
parts := strings.Fields(value)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" {
return "", ErrMissingToken
}
return parts[1], nil
}
func clonePrincipal(principal Principal) Principal {
principal.Roles = append([]string(nil), principal.Roles...)
if principal.Claims != nil {
principal.Claims = mapsClone(principal.Claims)
}
return principal
}
func mapsClone(values map[string]any) map[string]any {
clone := make(map[string]any, len(values))
for key, value := range values {
clone[key] = value
}
return clone
}

122
auth/auth_test.go Normal file
View File

@@ -0,0 +1,122 @@
package auth_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/auth"
"github.com/gin-gonic/gin"
)
func TestRequireAuthAndRole(t *testing.T) {
app := omni.New(omni.WithMode(gin.TestMode))
authenticator := auth.StaticAuthenticator{"good-token": {UserID: "user-1", Roles: []string{"user"}}}
private := app.Group("/private", auth.RequireAuth(authenticator))
private.GET("/me", func(ctx *omni.Context) error {
principal, ok := auth.FromContext(ctx)
if !ok {
t.Fatal("principal was not attached")
}
return ctx.OK(principal)
})
private.GET("/admin", func(ctx *omni.Context) error {
return ctx.OK(map[string]string{"ok": "true"})
}, auth.RequireRole("admin"))
tests := []struct {
name, authorization, path, code string
status int
}{
{"missing", "", "/private/me", "UNAUTHENTICATED", http.StatusUnauthorized},
{"malformed", "Token good-token", "/private/me", "UNAUTHENTICATED", http.StatusUnauthorized},
{"invalid", "Bearer bad-token", "/private/me", "UNAUTHENTICATED", http.StatusUnauthorized},
{"valid", "Bearer good-token", "/private/me", "OK", http.StatusOK},
{"forbidden", "Bearer good-token", "/private/admin", "FORBIDDEN", http.StatusForbidden},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, test.path, nil)
if test.authorization != "" {
request.Header.Set("Authorization", test.authorization)
}
app.ServeHTTP(recorder, request)
if recorder.Code != test.status {
t.Fatalf("expected status %d, got %d: %s", test.status, recorder.Code, recorder.Body.String())
}
var body struct {
Code string `json:"code"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Code != test.code {
t.Fatalf("expected code %q, got %q", test.code, body.Code)
}
})
}
}
func TestOptionalAuth(t *testing.T) {
app := omni.New(omni.WithMode(gin.TestMode))
app.GET("/public", func(ctx *omni.Context) error {
principal, authenticated := auth.FromContext(ctx)
return ctx.OK(map[string]any{"authenticated": authenticated, "userId": principal.UserID})
}, auth.OptionalAuth(auth.StaticAuthenticator{"token": {UserID: "user-1"}}))
for _, test := range []struct {
name, token string
wantAuth bool
status int
}{
{"anonymous", "", false, http.StatusOK},
{"authenticated", "Bearer token", true, http.StatusOK},
{"invalid", "Bearer bad", false, http.StatusUnauthorized},
} {
t.Run(test.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/public", nil)
if test.token != "" {
request.Header.Set("Authorization", test.token)
}
app.ServeHTTP(recorder, request)
if recorder.Code != test.status {
t.Fatalf("expected status %d, got %d", test.status, recorder.Code)
}
if test.wantAuth {
var body struct {
Data struct {
Authenticated bool `json:"authenticated"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if !body.Data.Authenticated {
t.Fatal("expected authenticated response")
}
}
})
}
}
func TestStaticAuthenticatorDoesNotExposeMutableSlices(t *testing.T) {
authenticator := auth.StaticAuthenticator{"token": {UserID: "user-1", Roles: []string{"admin"}, Claims: map[string]any{"tenant": "a"}}}
principal, err := authenticator.Authenticate(context.Background(), "token")
if err != nil {
t.Fatal(err)
}
principal.Roles[0] = "changed"
principal.Claims["tenant"] = "changed"
again, err := authenticator.Authenticate(context.Background(), "token")
if err != nil {
t.Fatal(err)
}
if !again.HasRole("admin") || again.Claims["tenant"] != "a" {
t.Fatalf("static authenticator returned mutable state: %+v", again)
}
}