feat: bootstrap reusable GoOmni framework
This commit is contained in:
36
auth/README.md
Normal file
36
auth/README.md
Normal 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
145
auth/auth.go
Normal 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
122
auth/auth_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user