feat: open API v1 and postman collection

This commit is contained in:
Adnan Zahir
2026-04-14 15:14:31 +07:00
parent fbe0634d46
commit 1ab16cfe06
18 changed files with 14668 additions and 4 deletions
+86 -2
View File
@@ -1,9 +1,13 @@
package middleware
import (
"context"
"errors"
"strings"
"sync"
"github.com/gofiber/fiber/v2"
"gitlab.com/mbugroup/lti-api.git/internal/apikeys"
"gitlab.com/mbugroup/lti-api.git/internal/config"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session"
@@ -17,11 +21,21 @@ const (
authUserLocalsKey = "auth.user"
)
var (
verifyAccessTokenFunc = sso.VerifyAccessToken
fetchProfileFunc = sso.FetchProfile
apiKeyAuthMu sync.RWMutex
apiKeyAuthenticator apikeys.Authenticator
)
// AuthContext keeps authentication details captured by the middleware.
type AuthContext struct {
Token string
Verification *sso.VerificationResult
User *entity.User
PrincipalType string
PrincipalName string
Roles []sso.Role
Permissions map[string]struct{}
UserAreaIDs []uint
@@ -30,6 +44,13 @@ type AuthContext struct {
UserAllLocation bool
}
func SetAPIKeyAuthenticator(authenticator apikeys.Authenticator) {
apiKeyAuthMu.Lock()
defer apiKeyAuthMu.Unlock()
apiKeyAuthenticator = authenticator
}
// Auth validates the incoming request against the central SSO access token and
// loads the corresponding local user. Optional scopes can be provided to enforce
// fine-grained authorization using the SSO access token scopes.
@@ -62,10 +83,20 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
}
}
if token == "" {
if c.Method() == fiber.MethodGet {
if err := authenticateAPIKey(c); err == nil {
if len(requiredScopes) > 0 {
return fiber.NewError(fiber.StatusForbidden, "Insufficient scope")
}
return c.Next()
} else if err != nil && !errors.Is(err, apikeys.ErrInvalidAPIKey) && !errors.Is(err, apikeys.ErrInactiveKey) {
return err
}
}
return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
}
verification, err := sso.VerifyAccessToken(token)
verification, err := verifyAccessTokenFunc(token)
if err != nil {
if sso.IsSignatureError(err) {
logSignatureError("auth", tokenSource, token, err)
@@ -99,7 +130,7 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
permissions := make(map[string]struct{})
var profile *sso.UserProfile
if verification.UserID != 0 {
if p, err := sso.FetchProfile(c.Context(), token, verification); err != nil {
if p, err := fetchProfileFunc(c.Context(), token, verification); err != nil {
utils.Log.WithError(err).Warn("auth: failed to fetch sso profile")
} else {
profile = p
@@ -118,6 +149,8 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
Token: token,
Verification: verification,
User: user,
PrincipalType: "user",
PrincipalName: user.Name,
Roles: roles,
Permissions: permissions,
UserAreaIDs: nil,
@@ -219,6 +252,57 @@ func bearerToken(c *fiber.Ctx) string {
return ""
}
func authenticateAPIKey(c *fiber.Ctx) error {
rawKey := strings.TrimSpace(c.Get("X-API-Key"))
if rawKey == "" {
return apikeys.ErrInvalidAPIKey
}
authenticator := currentAPIKeyAuthenticator()
if authenticator == nil {
return apikeys.ErrInvalidAPIKey
}
principal, err := authenticator.Authenticate(context.Background(), rawKey, c.IP())
if err != nil {
if errors.Is(err, apikeys.ErrInvalidAPIKey) || errors.Is(err, apikeys.ErrInactiveKey) {
return apikeys.ErrInvalidAPIKey
}
utils.Log.WithError(err).Warn("auth: api key authentication failed")
return fiber.NewError(fiber.StatusInternalServerError, "Failed to authenticate request")
}
permissions := make(map[string]struct{}, len(principal.Permissions))
for _, perm := range principal.Permissions {
if canonical := canonicalPermission(perm); canonical != "" {
permissions[canonical] = struct{}{}
}
}
c.Locals(authContextLocalsKey, &AuthContext{
Token: "",
Verification: nil,
User: nil,
PrincipalType: "api_key",
PrincipalName: principal.Name,
Roles: nil,
Permissions: permissions,
UserAreaIDs: principal.AreaIDs,
UserLocationIDs: principal.LocationIDs,
UserAllArea: principal.AllArea,
UserAllLocation: principal.AllLocation,
})
c.Locals(authUserLocalsKey, nil)
return nil
}
func currentAPIKeyAuthenticator() apikeys.Authenticator {
apiKeyAuthMu.RLock()
defer apiKeyAuthMu.RUnlock()
return apiKeyAuthenticator
}
func hasAllScopes(have, required []string) bool {
if len(required) == 0 {
return true
+239
View File
@@ -0,0 +1,239 @@
package middleware
import (
"context"
"errors"
"net/http/httptest"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"github.com/golang-jwt/jwt/v5"
"gitlab.com/mbugroup/lti-api.git/internal/apikeys"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier"
userValidation "gitlab.com/mbugroup/lti-api.git/internal/modules/users/validations"
)
type stubUserService struct {
user *entity.User
err error
}
func (s *stubUserService) GetAll(_ *fiber.Ctx, _ *userValidation.Query) ([]entity.User, int64, error) {
return nil, 0, nil
}
func (s *stubUserService) GetOne(_ *fiber.Ctx, _ uint) (*entity.User, error) {
return s.user, s.err
}
func (s *stubUserService) CreateOne(_ *fiber.Ctx, _ *userValidation.Create) (*entity.User, error) {
return nil, nil
}
func (s *stubUserService) UpdateOne(_ *fiber.Ctx, _ *userValidation.Update, _ uint) (*entity.User, error) {
return nil, nil
}
func (s *stubUserService) DeleteOne(_ *fiber.Ctx, _ uint) error {
return nil
}
func (s *stubUserService) GetBySSOUserID(_ *fiber.Ctx, _ uint) (*entity.User, error) {
return s.user, s.err
}
type stubAPIKeyAuthenticator struct {
principal *apikeys.Principal
err error
}
func (s *stubAPIKeyAuthenticator) Authenticate(_ context.Context, _ string, _ string) (*apikeys.Principal, error) {
return s.principal, s.err
}
func TestAuthAllowsAPIKeyOnGet(t *testing.T) {
SetAPIKeyAuthenticator(&stubAPIKeyAuthenticator{
principal: &apikeys.Principal{
Name: "dashboard",
Permissions: []string{"perm.read"},
LocationIDs: []uint{3, 5},
},
})
defer SetAPIKeyAuthenticator(nil)
app := fiber.New()
app.Get("/reports", Auth(&stubUserService{}), RequirePermissions("perm.read"), func(c *fiber.Ctx) error {
scope, err := ResolveLocationScope(c, nil)
if err != nil {
return err
}
return c.JSON(fiber.Map{
"principal": c.Locals(authContextLocalsKey).(*AuthContext).PrincipalType,
"restrict": scope.Restrict,
"ids": scope.IDs,
})
})
req := httptest.NewRequest(fiber.MethodGet, "/reports", nil)
req.Header.Set("X-API-Key", "lti_dev_prefix_secret")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != fiber.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
func TestAuthRejectsAPIKeyOnPost(t *testing.T) {
SetAPIKeyAuthenticator(&stubAPIKeyAuthenticator{
principal: &apikeys.Principal{
Name: "dashboard",
Permissions: []string{"perm.write"},
},
})
defer SetAPIKeyAuthenticator(nil)
app := fiber.New()
app.Post("/reports", Auth(&stubUserService{}), RequirePermissions("perm.write"), func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK)
})
req := httptest.NewRequest(fiber.MethodPost, "/reports", nil)
req.Header.Set("X-API-Key", "lti_dev_prefix_secret")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != fiber.StatusUnauthorized {
t.Fatalf("expected 401, got %d", resp.StatusCode)
}
}
func TestAuthRejectsInvalidAPIKey(t *testing.T) {
SetAPIKeyAuthenticator(&stubAPIKeyAuthenticator{err: apikeys.ErrInvalidAPIKey})
defer SetAPIKeyAuthenticator(nil)
app := fiber.New()
app.Get("/reports", Auth(&stubUserService{}), func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK)
})
req := httptest.NewRequest(fiber.MethodGet, "/reports", nil)
req.Header.Set("X-API-Key", "lti_dev_prefix_secret")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != fiber.StatusUnauthorized {
t.Fatalf("expected 401, got %d", resp.StatusCode)
}
}
func TestAuthRejectsInactiveAPIKey(t *testing.T) {
SetAPIKeyAuthenticator(&stubAPIKeyAuthenticator{err: apikeys.ErrInactiveKey})
defer SetAPIKeyAuthenticator(nil)
app := fiber.New()
app.Get("/reports", Auth(&stubUserService{}), func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK)
})
req := httptest.NewRequest(fiber.MethodGet, "/reports", nil)
req.Header.Set("X-API-Key", "lti_dev_prefix_secret")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != fiber.StatusUnauthorized {
t.Fatalf("expected 401, got %d", resp.StatusCode)
}
}
func TestAuthRejectsMissingPermission(t *testing.T) {
SetAPIKeyAuthenticator(&stubAPIKeyAuthenticator{
principal: &apikeys.Principal{
Name: "dashboard",
Permissions: []string{"perm.other"},
},
})
defer SetAPIKeyAuthenticator(nil)
app := fiber.New()
app.Get("/reports", Auth(&stubUserService{}), RequirePermissions("perm.read"), func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK)
})
req := httptest.NewRequest(fiber.MethodGet, "/reports", nil)
req.Header.Set("X-API-Key", "lti_dev_prefix_secret")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != fiber.StatusForbidden {
t.Fatalf("expected 403, got %d", resp.StatusCode)
}
}
func TestAuthAllowsBearerOnGet(t *testing.T) {
previousVerify := verifyAccessTokenFunc
previousProfile := fetchProfileFunc
defer func() {
verifyAccessTokenFunc = previousVerify
fetchProfileFunc = previousProfile
}()
verifyAccessTokenFunc = func(_ string) (*sso.VerificationResult, error) {
return &sso.VerificationResult{
UserID: 1,
Claims: &sso.AccessTokenClaims{
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now().UTC()),
},
},
}, nil
}
fetchProfileFunc = func(_ context.Context, _ string, _ *sso.VerificationResult) (*sso.UserProfile, error) {
return &sso.UserProfile{
Permissions: []sso.Permission{{Name: "perm.read"}},
LocationIDs: []uint{7},
}, nil
}
app := fiber.New()
app.Get("/reports", Auth(&stubUserService{user: &entity.User{Id: 9, Name: "API User"}}), RequirePermissions("perm.read"), func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"principal": c.Locals(authContextLocalsKey).(*AuthContext).PrincipalType})
})
req := httptest.NewRequest(fiber.MethodGet, "/reports", nil)
req.Header.Set("Authorization", "Bearer test-token")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != fiber.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
func TestAuthReturnsServerErrorWhenAPIKeyVerifierFailsUnexpectedly(t *testing.T) {
SetAPIKeyAuthenticator(&stubAPIKeyAuthenticator{err: errors.New("boom")})
defer SetAPIKeyAuthenticator(nil)
app := fiber.New()
app.Get("/reports", Auth(&stubUserService{}), func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK)
})
req := httptest.NewRequest(fiber.MethodGet, "/reports", nil)
req.Header.Set("X-API-Key", "lti_dev_prefix_secret")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != fiber.StatusInternalServerError {
t.Fatalf("expected 500, got %d", resp.StatusCode)
}
}