feat: initial commit - kratos admin service

This commit is contained in:
yvan
2026-08-13 18:22:43 +08:00
commit be5362913b
183 changed files with 16186 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
package auth
import (
"context"
"github.com/golang-jwt/jwt/v5"
)
// Auth user auth.
type Auth struct {
UserID int64 `json:"id"`
Access string `json:"access"`
jwt.RegisteredClaims
}
// HasAdminAccess checks if the user has admin access.
func (a *Auth) HasAdminAccess() bool {
return a.Access == "admin"
}
type authKey struct{}
// NewContext returns a new Context that carries value.
func NewContext(ctx context.Context, auth *Auth) context.Context {
return context.WithValue(ctx, authKey{}, auth)
}
// FromContext returns the Auth value stored in ctx, if any.
func FromContext(ctx context.Context) (auth *Auth, ok bool) {
auth, ok = ctx.Value(authKey{}).(*Auth)
return
}
+22
View File
@@ -0,0 +1,22 @@
package auth
import (
"os"
"time"
)
// authSecretFromEnv retrieves the authentication secret from the environment variable.
func authSecretFromEnv(key string) string {
if secret := os.Getenv(key); secret != "" {
return secret
}
return time.Now().Format("20060102150405")
}
// cookieNameFromEnv retrieves the cookie name from the environment variable.
func cookieNameFromEnv(key string) string {
if name := os.Getenv(key); name != "" {
return name
}
return "access_token"
}
+100
View File
@@ -0,0 +1,100 @@
package auth
import (
"context"
"fmt"
"net/http"
"time"
"github.com/go-kratos/kratos/v3/errors"
"github.com/go-kratos/kratos/v3/transport"
httpm "github.com/go-kratos/kratos/v3/transport/http"
)
// Error reasons reported by this middleware. They mirror the wire-visible names
// in api/<domain>/<version>/error_reason.proto, but are declared here so `pkg`
// does not depend on a domain's generated proto. TestReasonsMatchAPIEnum pins
// the two together so they cannot drift.
const (
reasonUnauthenticated = "UNAUTHENTICATED"
reasonPermissionDenied = "PERMISSION_DENIED"
)
var (
// noAuthPaths defines the paths that do not require authentication.
noAuthPaths = map[string]struct{}{
"/v1/admins/login": {},
}
// authSecretKey is the secret key used for signing JWT tokens.
authSecretKey = authSecretFromEnv("KRATOS_AUTH_SECRET")
// cookieName is the name of the cookie that stores the authorization token.
cookieName = cookieNameFromEnv("KRATOS_AUTH_COOKIE")
// ErrUnauthenticated indicates that the request carried no usable credential.
ErrUnauthenticated = errors.Unauthorized(reasonUnauthenticated, "Token is invalid")
// ErrPermissionDenied indicates that the caller lacks the required access.
ErrPermissionDenied = errors.Forbidden(reasonPermissionDenied, "Access denied")
)
// Middleware is an authentication middleware for HTTP servers.
func Middleware() httpm.FilterFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, ok := noAuthPaths[r.URL.Path]; ok {
next.ServeHTTP(w, r)
return
}
// This filter runs outside the kratos handler chain, so the error
// encoder has to be invoked explicitly. Writing plain text here
// instead would leave clients unable to read code / reason.
cookie, err := r.Cookie(cookieName)
if err != nil {
httpm.DefaultErrorEncoder(w, r, ErrUnauthenticated)
return
}
auth, err := ParseToken(cookie.Value, authSecretKey)
if err != nil {
httpm.DefaultErrorEncoder(w, r, err)
return
}
ctx := NewContext(r.Context(), auth)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// SetCookie sets the login cookie in the HTTP response.
func SetCookie(ctx context.Context, userID int64, access string, expiresAt time.Time) error {
tr, ok := transport.FromServerContext(ctx)
if !ok {
return fmt.Errorf("failed to get transport from context")
}
token, err := GenerateToken(userID, access, authSecretKey, expiresAt)
if err != nil {
return err
}
cookie := &http.Cookie{
Name: cookieName,
Value: token,
Path: "/",
Expires: expiresAt,
}
tr.ReplyHeader().Add("Set-Cookie", cookie.String())
return nil
}
// DeleteCookie clears the login cookie in the HTTP response.
func DeleteCookie(ctx context.Context) error {
tr, ok := transport.FromServerContext(ctx)
if !ok {
return fmt.Errorf("failed to get transport from context")
}
expires := time.Now().AddDate(0, 0, -1)
cookie := &http.Cookie{
Name: cookieName,
Value: "",
Path: "/",
Expires: expires,
}
tr.ReplyHeader().Add("Set-Cookie", cookie.String())
return nil
}
+125
View File
@@ -0,0 +1,125 @@
package auth
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
v1 "kra/api/kratos/admin/v1"
)
// errorBody mirrors the wire shape of kratos errors.Status.
type errorBody struct {
Code int32 `json:"code"`
Reason string `json:"reason"`
Message string `json:"message"`
}
// serve runs the middleware over a handler that records whether it was reached.
func serve(t *testing.T, req *http.Request) (*httptest.ResponseRecorder, bool) {
t.Helper()
reached := false
handler := Middleware()(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
reached = true
}))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec, reached
}
// The middleware runs outside the kratos handler chain, so it has to invoke the
// error encoder itself. These tests pin the body down as structured protojson:
// writing plain text here would leave clients unable to read code / reason.
func TestMiddlewareRejectsMissingCookie(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v1/admins/current", nil)
req.Header.Set("Accept", "application/protojson")
rec, reached := serve(t, req)
if reached {
t.Fatal("handler was reached without a credential")
}
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/protojson" {
t.Fatalf("Content-Type = %q, want application/protojson", ct)
}
var body errorBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("body is not JSON (%v): %s", err, rec.Body.String())
}
if body.Code != http.StatusUnauthorized {
t.Errorf("body code = %d, want %d", body.Code, http.StatusUnauthorized)
}
if want := v1.ErrorReason_UNAUTHENTICATED.String(); body.Reason != want {
t.Errorf("body reason = %q, want %q", body.Reason, want)
}
}
func TestMiddlewareRejectsInvalidToken(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v1/admins/current", nil)
req.Header.Set("Accept", "application/protojson")
req.AddCookie(&http.Cookie{Name: cookieName, Value: "not-a-jwt"})
rec, reached := serve(t, req)
if reached {
t.Fatal("handler was reached with an invalid token")
}
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/protojson" {
t.Fatalf("Content-Type = %q, want application/protojson", ct)
}
var body errorBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("body is not JSON (%v): %s", err, rec.Body.String())
}
if body.Code != http.StatusUnauthorized {
t.Errorf("body code = %d, want %d", body.Code, http.StatusUnauthorized)
}
if want := v1.ErrorReason_UNAUTHENTICATED.String(); body.Reason != want {
t.Errorf("body reason = %q, want %q", body.Reason, want)
}
}
// The reason constants in this package are declared locally so `pkg` stays free
// of any domain's generated proto. That freedom is only safe if the two stay in
// step, which is what this test enforces.
func TestReasonsMatchAPIEnum(t *testing.T) {
if want := v1.ErrorReason_UNAUTHENTICATED.String(); reasonUnauthenticated != want {
t.Errorf("reasonUnauthenticated = %q, want %q", reasonUnauthenticated, want)
}
if want := v1.ErrorReason_PERMISSION_DENIED.String(); reasonPermissionDenied != want {
t.Errorf("reasonPermissionDenied = %q, want %q", reasonPermissionDenied, want)
}
}
func TestMiddlewareAllowsValidToken(t *testing.T) {
token, err := GenerateToken(42, "admin", authSecretKey, time.Now().Add(time.Hour))
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/v1/admins/current", nil)
req.AddCookie(&http.Cookie{Name: cookieName, Value: token})
rec, reached := serve(t, req)
if !reached {
t.Fatalf("handler was not reached, status = %d body = %s", rec.Code, rec.Body.String())
}
}
func TestMiddlewareSkipsNoAuthPaths(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/admins/login", nil)
_, reached := serve(t, req)
if !reached {
t.Fatal("login path should not require authentication")
}
}
+48
View File
@@ -0,0 +1,48 @@
package auth
import (
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// GenerateToken generates a JWT token for the given username.
func GenerateToken(userID int64, access, secret string, expiresAt time.Time) (string, error) {
now := time.Now()
claims := Auth{
UserID: userID,
Access: access,
RegisteredClaims: jwt.RegisteredClaims{
ID: uuid.NewString(),
Issuer: "kratos",
Subject: "user",
Audience: []string{"admin"},
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expiresAt),
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
}
// ParseToken parses the JWT token string and returns the Auth claims.
func ParseToken(tokenStr, secret string) (*Auth, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Auth{}, func(token *jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
if err != nil {
// Map the jwt driver error to a typed one; returning it raw would make
// callers see a 500 for a merely expired or malformed token. The cause
// is kept so logs can still tell expiry from a bad signature.
return nil, ErrUnauthenticated.WithCause(err)
}
if !token.Valid {
return nil, ErrUnauthenticated
}
auth, ok := token.Claims.(*Auth)
if !ok {
return nil, ErrUnauthenticated
}
return auth, nil
}