init push
This commit is contained in:
30
internal/transport/http/middleware/auth_middleware.go
Normal file
30
internal/transport/http/middleware/auth_middleware.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"wucher/internal/service"
|
||||
"wucher/internal/shared/pkg/appctx"
|
||||
"wucher/internal/shared/pkg/apperrorsx"
|
||||
)
|
||||
|
||||
func AuthRequired(svc *service.AuthService) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
if svc == nil {
|
||||
e := apperrorsx.New(apperrorsx.ErrUserUnauthorized)
|
||||
e.Message = "auth service not configured"
|
||||
return apperrorsx.Write(c, e)
|
||||
}
|
||||
|
||||
ctx := c.UserContext()
|
||||
for _, candidate := range service.CookieValues(c.Get(fiber.HeaderCookie), svc.AccessCookieName()) {
|
||||
userID, err := svc.ParseAccessToken(ctx, candidate)
|
||||
if err == nil {
|
||||
c.Locals("user_id", userID)
|
||||
c.SetUserContext(appctx.WithUserID(ctx, userID))
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrTokenInvalid))
|
||||
}
|
||||
}
|
||||
949
internal/transport/http/middleware/middleware_test.go
Normal file
949
internal/transport/http/middleware/middleware_test.go
Normal file
@@ -0,0 +1,949 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"wucher/internal/config"
|
||||
"wucher/internal/domain/auth"
|
||||
"wucher/internal/service"
|
||||
sharedconst "wucher/internal/shared/const"
|
||||
"wucher/internal/shared/pkg/appctx"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type middlewareAuthRepo struct {
|
||||
users map[string]*auth.User
|
||||
getByIDErr error
|
||||
}
|
||||
|
||||
func newMiddlewareAuthRepo() *middlewareAuthRepo {
|
||||
return &middlewareAuthRepo{users: map[string]*auth.User{}}
|
||||
}
|
||||
|
||||
func (r *middlewareAuthRepo) CreateUser(_ context.Context, user *auth.User) error {
|
||||
if user != nil && !user.IsActive {
|
||||
user.IsActive = true
|
||||
}
|
||||
r.users[string(user.ID)] = user
|
||||
return nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) GetUserByID(_ context.Context, id []byte) (*auth.User, error) {
|
||||
if r.getByIDErr != nil {
|
||||
return nil, r.getByIDErr
|
||||
}
|
||||
return r.users[string(id)], nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) GetUserByEmail(_ context.Context, _ string) (*auth.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) GetUserBySSOEmail(_ context.Context, _ string) (*auth.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) GetUserByUsername(_ context.Context, _ string) (*auth.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) UpdateUserTimezone(_ context.Context, _ []byte, _ string) error {
|
||||
return nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) SetUserPassword(_ context.Context, _ []byte, _ []byte, _ []byte) error {
|
||||
return nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) SetUserSecurityPIN(_ context.Context, id []byte, pinHash []byte, setAt time.Time) error {
|
||||
u := r.users[string(id)]
|
||||
if u == nil {
|
||||
return nil
|
||||
}
|
||||
u.SecurityPINHash = append([]byte(nil), pinHash...)
|
||||
ts := setAt.UTC()
|
||||
u.SecurityPINSetAt = &ts
|
||||
return nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) MarkEmailVerified(_ context.Context, _ []byte) error {
|
||||
return nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) CreatePasswordResetToken(_ context.Context, _ *auth.PasswordResetToken) error {
|
||||
return nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) InvalidateActivePasswordResetTokensByUserID(_ context.Context, _ []byte, _ time.Time) error {
|
||||
return nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) ConsumePasswordResetTokenAndSetPassword(_ context.Context, _ []byte, _ []byte, _ []byte, _ time.Time) ([]byte, bool, error) {
|
||||
return nil, false, nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) CreateSecurityPINResetToken(_ context.Context, _ *auth.SecurityPINResetToken) error {
|
||||
return nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) InvalidateActiveSecurityPINResetTokensByUserID(_ context.Context, _ []byte, _ time.Time) error {
|
||||
return nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) GetActiveSecurityPINResetTokenUser(_ context.Context, _ []byte, _ time.Time) (*auth.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) ConsumeSecurityPINResetTokenAndSetPIN(_ context.Context, _ []byte, _ []byte, _ time.Time) ([]byte, bool, error) {
|
||||
return nil, false, nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) UpsertIdentity(_ context.Context, _ *auth.UserIdentity) error {
|
||||
return nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) CreateIdentity(_ context.Context, _ *auth.UserIdentity) error {
|
||||
return nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) GetIdentityByProviderSubject(_ context.Context, _ string, _ string) (*auth.UserIdentity, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) GetIdentityByUserID(_ context.Context, _ []byte) (*auth.UserIdentity, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) DeleteIdentityByUserIDProvider(_ context.Context, _ []byte, _ string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) GetUserTOTP(_ context.Context, _ []byte) (*auth.UserTOTP, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) UpsertUserTOTP(_ context.Context, _ *auth.UserTOTP) error {
|
||||
return nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) DisableUserTOTP(_ context.Context, _ []byte) error {
|
||||
return nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) DeleteUserTOTPSetup(_ context.Context, _ []byte) error {
|
||||
return nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) UpdateUserTOTPLastUsed(_ context.Context, _ []byte) error {
|
||||
return nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) GetActiveUserEmailLoginOTP(_ context.Context, _ []byte, _ time.Time) (*auth.UserEmailLoginOTP, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) CountUserEmailLoginOTPSince(_ context.Context, _ []byte, _ time.Time) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) CreateUserEmailLoginOTP(_ context.Context, _ *auth.UserEmailLoginOTP) error {
|
||||
return nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) InvalidateActiveUserEmailLoginOTPs(_ context.Context, _ []byte, _ time.Time) error {
|
||||
return nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) ConsumeUserEmailLoginOTP(_ context.Context, _ []byte, _ []byte, _ time.Time) (bool, int, error) {
|
||||
return false, 0, nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) ListUserWebAuthnCredentials(_ context.Context, _ []byte) ([]auth.UserWebAuthnCredential, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) CreateUserWebAuthnCredential(_ context.Context, _ *auth.UserWebAuthnCredential) error {
|
||||
return nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) UpdateUserWebAuthnCredential(_ context.Context, _ []byte, _ []byte, _ []byte, _ time.Time) error {
|
||||
return nil
|
||||
}
|
||||
func (r *middlewareAuthRepo) DeleteAllUserWebAuthnCredentials(_ context.Context, _ []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type middlewareRoleRepo struct {
|
||||
allowed bool
|
||||
err error
|
||||
permissionByKey *auth.Permission
|
||||
permissionByKeyErr error
|
||||
}
|
||||
|
||||
type middlewareTokenStore struct {
|
||||
store map[string][]byte
|
||||
}
|
||||
|
||||
func newMiddlewareTokenStore() *middlewareTokenStore {
|
||||
return &middlewareTokenStore{store: map[string][]byte{}}
|
||||
}
|
||||
|
||||
func (m *middlewareTokenStore) Set(_ context.Context, key string, value []byte, _ time.Duration) error {
|
||||
m.store[key] = append([]byte(nil), value...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *middlewareTokenStore) SetIfNotExists(_ context.Context, key string, value []byte, _ time.Duration) (bool, error) {
|
||||
if _, ok := m.store[key]; ok {
|
||||
return false, nil
|
||||
}
|
||||
m.store[key] = append([]byte(nil), value...)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m *middlewareTokenStore) Get(_ context.Context, key string) ([]byte, error) {
|
||||
v, ok := m.store[key]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return append([]byte(nil), v...), nil
|
||||
}
|
||||
|
||||
func (m *middlewareTokenStore) Delete(_ context.Context, key string) error {
|
||||
delete(m.store, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *middlewareRoleRepo) CreateRole(_ context.Context, _ *auth.Role) error { return nil }
|
||||
func (r *middlewareRoleRepo) UpdateRole(_ context.Context, _ *auth.Role) error { return nil }
|
||||
func (r *middlewareRoleRepo) DeleteRole(_ context.Context, _ []byte) error { return nil }
|
||||
func (r *middlewareRoleRepo) GetRoleByID(_ context.Context, _ []byte) (*auth.Role, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *middlewareRoleRepo) GetRoleByName(_ context.Context, _ string) (*auth.Role, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *middlewareRoleRepo) HasRolePermission(_ context.Context, _ []byte, _ string) (bool, error) {
|
||||
if r.err != nil {
|
||||
return false, r.err
|
||||
}
|
||||
return r.allowed, nil
|
||||
}
|
||||
func (r *middlewareRoleRepo) GetPermissionByID(_ context.Context, _ []byte) (*auth.Permission, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *middlewareRoleRepo) GetPermissionByKey(_ context.Context, _ string) (*auth.Permission, error) {
|
||||
return r.permissionByKey, r.permissionByKeyErr
|
||||
}
|
||||
func (r *middlewareRoleRepo) UpdatePermission(_ context.Context, _ *auth.Permission) error {
|
||||
return nil
|
||||
}
|
||||
func (r *middlewareRoleRepo) ListPermissionsByRoleID(_ context.Context, _ []byte) ([]auth.Permission, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *middlewareRoleRepo) ListPermissions(_ context.Context, _ string, _ string, _ int, _ int) ([]auth.Permission, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
func (r *middlewareRoleRepo) AssignPermission(_ context.Context, roleID, permissionID []byte) (*auth.RolePermission, error) {
|
||||
return &auth.RolePermission{RoleID: roleID, PermissionID: permissionID}, nil
|
||||
}
|
||||
func (r *middlewareRoleRepo) RemovePermission(_ context.Context, _ []byte, _ []byte) error {
|
||||
return nil
|
||||
}
|
||||
func (r *middlewareRoleRepo) ListRoles(_ context.Context, _ string, _ string, _ int, _ int) ([]auth.Role, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func TestAuthRequired(t *testing.T) {
|
||||
t.Run("service not configured", func(t *testing.T) {
|
||||
app := fiber.New()
|
||||
app.Use(AuthRequired(nil))
|
||||
app.Get("/protected", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, "/protected", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid access token", func(t *testing.T) {
|
||||
svc := service.NewAuthService(nil, nil, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
|
||||
JWTAccessSecret: "access-secret",
|
||||
JWTRefreshSecret: "refresh-secret",
|
||||
JWTAccessTTL: time.Minute,
|
||||
JWTRefreshTTL: time.Hour,
|
||||
JWTAccessCookieName: "at",
|
||||
}})
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(AuthRequired(svc))
|
||||
app.Get("/protected", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, "/protected", nil)
|
||||
req.AddCookie(&http.Cookie{Name: svc.AccessCookieName(), Value: "not-a-jwt"})
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("success stores user_id and continues", func(t *testing.T) {
|
||||
repo := newMiddlewareAuthRepo()
|
||||
tokens := newMiddlewareTokenStore()
|
||||
svc := service.NewAuthService(repo, nil, tokens, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
|
||||
JWTAccessSecret: "access-secret",
|
||||
JWTRefreshSecret: "refresh-secret",
|
||||
JWTAccessTTL: time.Minute,
|
||||
JWTRefreshTTL: time.Hour,
|
||||
JWTAccessCookieName: "at",
|
||||
}})
|
||||
userID := uuidv7.MustBytes()
|
||||
user := &auth.User{ID: userID, IsActive: true}
|
||||
if err := repo.CreateUser(context.Background(), user); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
pair, err := svc.IssueTokensWithClientForProvider(context.Background(), user, "", "", "microsoft")
|
||||
if err != nil {
|
||||
t.Fatalf("issue tokens: %v", err)
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(AuthRequired(svc))
|
||||
app.Get("/protected", func(c *fiber.Ctx) error {
|
||||
raw := c.Locals("user_id")
|
||||
got, ok := raw.([]byte)
|
||||
if !ok || string(got) != string(userID) {
|
||||
return c.Status(http.StatusInternalServerError).SendString("missing user context")
|
||||
}
|
||||
if got := appctx.GetUserID(c.UserContext()); string(got) != string(userID) {
|
||||
return c.Status(http.StatusInternalServerError).SendString("missing user id in request context")
|
||||
}
|
||||
return c.SendStatus(http.StatusOK)
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, "/protected", nil)
|
||||
req.AddCookie(&http.Cookie{Name: svc.AccessCookieName(), Value: pair.AccessToken})
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate access cookies use the valid token", func(t *testing.T) {
|
||||
repo := newMiddlewareAuthRepo()
|
||||
tokens := newMiddlewareTokenStore()
|
||||
svc := service.NewAuthService(repo, nil, tokens, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
|
||||
JWTAccessSecret: "access-secret",
|
||||
JWTRefreshSecret: "refresh-secret",
|
||||
JWTAccessTTL: time.Minute,
|
||||
JWTRefreshTTL: time.Hour,
|
||||
JWTAccessCookieName: "at",
|
||||
}})
|
||||
userID := uuidv7.MustBytes()
|
||||
user := &auth.User{ID: userID, IsActive: true}
|
||||
if err := repo.CreateUser(context.Background(), user); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
pair, err := svc.IssueTokensWithClientForProvider(context.Background(), user, "", "", "microsoft")
|
||||
if err != nil {
|
||||
t.Fatalf("issue tokens: %v", err)
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(AuthRequired(svc))
|
||||
app.Get("/protected", func(c *fiber.Ctx) error {
|
||||
raw := c.Locals("user_id")
|
||||
got, ok := raw.([]byte)
|
||||
if !ok || string(got) != string(userID) {
|
||||
return c.Status(http.StatusInternalServerError).SendString("missing user context")
|
||||
}
|
||||
return c.SendStatus(http.StatusOK)
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, "/protected", nil)
|
||||
req.Header.Set("Cookie", svc.AccessCookieName()+"=not-a-valid-token; "+svc.AccessCookieName()+"="+pair.AccessToken)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("expired by idle timeout", func(t *testing.T) {
|
||||
repo := newMiddlewareAuthRepo()
|
||||
tokens := newMiddlewareTokenStore()
|
||||
svc := service.NewAuthService(repo, nil, tokens, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
|
||||
JWTAccessSecret: "access-secret",
|
||||
JWTRefreshSecret: "refresh-secret",
|
||||
JWTAccessTTL: time.Minute,
|
||||
JWTRefreshTTL: time.Hour,
|
||||
SessionIdleTTL: time.Millisecond,
|
||||
JWTAccessCookieName: "at",
|
||||
}})
|
||||
userID := uuidv7.MustBytes()
|
||||
user := &auth.User{ID: userID, IsActive: true}
|
||||
if err := repo.CreateUser(context.Background(), user); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
pair, err := svc.IssueTokensWithClientForProvider(context.Background(), user, "", "", "microsoft")
|
||||
if err != nil {
|
||||
t.Fatalf("issue tokens: %v", err)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(AuthRequired(svc))
|
||||
app.Get("/protected", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, "/protected", nil)
|
||||
req.AddCookie(&http.Cookie{Name: svc.AccessCookieName(), Value: pair.AccessToken})
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 for idle-expired session, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPermissionRequired(t *testing.T) {
|
||||
t.Run("service not configured", func(t *testing.T) {
|
||||
app := fiber.New()
|
||||
app.Use(PermissionRequired(nil, "users.read"))
|
||||
app.Get("/protected", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, "/protected", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing auth context", func(t *testing.T) {
|
||||
svc := service.NewAuthService(nil, nil, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
||||
app := fiber.New()
|
||||
app.Use(PermissionRequired(svc, "users.read"))
|
||||
app.Get("/protected", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, "/protected", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("permission check error", func(t *testing.T) {
|
||||
repo := newMiddlewareAuthRepo()
|
||||
roleRepo := &middlewareRoleRepo{err: context.DeadlineExceeded}
|
||||
svc := service.NewAuthService(repo, roleRepo, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
||||
|
||||
userID := uuidv7.MustBytes()
|
||||
roleID := uuidv7.MustBytes()
|
||||
repo.users[string(userID)] = &auth.User{ID: userID, RoleID: roleID}
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_id", userID)
|
||||
return c.Next()
|
||||
})
|
||||
app.Use(PermissionRequired(svc, "users.read"))
|
||||
app.Get("/protected", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, "/protected", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("insufficient permission", func(t *testing.T) {
|
||||
repo := newMiddlewareAuthRepo()
|
||||
roleRepo := &middlewareRoleRepo{allowed: false}
|
||||
svc := service.NewAuthService(repo, roleRepo, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
||||
|
||||
userID := uuidv7.MustBytes()
|
||||
roleID := uuidv7.MustBytes()
|
||||
repo.users[string(userID)] = &auth.User{ID: userID, RoleID: roleID}
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_id", userID)
|
||||
return c.Next()
|
||||
})
|
||||
app.Use(PermissionRequired(svc, "users.read"))
|
||||
app.Get("/protected", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, "/protected", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", resp.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(body), "insufficient permission") {
|
||||
t.Fatalf("unexpected body: %s", string(body))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("allowed permission continues", func(t *testing.T) {
|
||||
repo := newMiddlewareAuthRepo()
|
||||
roleRepo := &middlewareRoleRepo{allowed: true}
|
||||
svc := service.NewAuthService(repo, roleRepo, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
||||
|
||||
userID := uuidv7.MustBytes()
|
||||
roleID := uuidv7.MustBytes()
|
||||
repo.users[string(userID)] = &auth.User{ID: userID, RoleID: roleID}
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_id", userID)
|
||||
return c.Next()
|
||||
})
|
||||
app.Use(PermissionRequired(svc, "users.read"))
|
||||
app.Get("/protected", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, "/protected", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("requires pin without token returns 202", func(t *testing.T) {
|
||||
repo := newMiddlewareAuthRepo()
|
||||
roleRepo := &middlewareRoleRepo{
|
||||
allowed: true,
|
||||
permissionByKey: &auth.Permission{RequiresPIN: true},
|
||||
}
|
||||
tokens := newMiddlewareTokenStore()
|
||||
svc := service.NewAuthService(
|
||||
repo,
|
||||
roleRepo,
|
||||
tokens,
|
||||
nil,
|
||||
service.AuthServiceDependencies{
|
||||
SSO: nil, Protector: nil, Config: config.AuthConfig{
|
||||
JWTAccessSecret: "access-secret",
|
||||
JWTRefreshSecret: "refresh-secret",
|
||||
JWTAccessTTL: time.Minute,
|
||||
JWTRefreshTTL: time.Minute * 10,
|
||||
SecurityPINActionTokenTTL: time.Minute,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
userID := uuidv7.MustBytes()
|
||||
roleID := uuidv7.MustBytes()
|
||||
repo.users[string(userID)] = &auth.User{ID: userID, RoleID: roleID}
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_id", userID)
|
||||
return c.Next()
|
||||
})
|
||||
app.Use(PermissionRequired(svc, "users.read"))
|
||||
app.Get("/protected", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, "/protected", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
t.Fatalf("expected 202, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("requires pin with mapped action token returns 200", func(t *testing.T) {
|
||||
repo := newMiddlewareAuthRepo()
|
||||
roleRepo := &middlewareRoleRepo{
|
||||
allowed: true,
|
||||
permissionByKey: &auth.Permission{RequiresPIN: true},
|
||||
}
|
||||
tokens := newMiddlewareTokenStore()
|
||||
svc := service.NewAuthService(
|
||||
repo,
|
||||
roleRepo,
|
||||
tokens,
|
||||
nil,
|
||||
service.AuthServiceDependencies{
|
||||
SSO: nil, Protector: nil, Config: config.AuthConfig{
|
||||
JWTAccessSecret: "access-secret",
|
||||
JWTRefreshSecret: "refresh-secret",
|
||||
JWTAccessTTL: time.Minute,
|
||||
JWTRefreshTTL: time.Minute * 10,
|
||||
SecurityPINActionTokenTTL: time.Minute,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
userID := uuidv7.MustBytes()
|
||||
roleID := uuidv7.MustBytes()
|
||||
repo.users[string(userID)] = &auth.User{ID: userID, RoleID: roleID}
|
||||
if err := svc.SetSecurityPIN(context.Background(), userID, "123456"); err != nil {
|
||||
t.Fatalf("SetSecurityPIN: %v", err)
|
||||
}
|
||||
|
||||
token, err := svc.VerifySecurityPINForAction(context.Background(), userID, "123456", sharedconst.PinActionBaseCreate)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifySecurityPINForAction: %v", err)
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_id", userID)
|
||||
return c.Next()
|
||||
})
|
||||
app.Use(PermissionRequired(svc, "base.create"))
|
||||
app.Post("/protected", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, "/protected", nil)
|
||||
req.Header.Set(permissionPinVerificationHeader, token)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reusable variant accepts the same token twice", func(t *testing.T) {
|
||||
repo := newMiddlewareAuthRepo()
|
||||
roleRepo := &middlewareRoleRepo{
|
||||
allowed: true,
|
||||
permissionByKey: &auth.Permission{RequiresPIN: true},
|
||||
}
|
||||
tokens := newMiddlewareTokenStore()
|
||||
svc := service.NewAuthService(
|
||||
repo,
|
||||
roleRepo,
|
||||
tokens,
|
||||
nil,
|
||||
service.AuthServiceDependencies{
|
||||
SSO: nil, Protector: nil, Config: config.AuthConfig{
|
||||
JWTAccessSecret: "access-secret",
|
||||
JWTRefreshSecret: "refresh-secret",
|
||||
JWTAccessTTL: time.Minute,
|
||||
JWTRefreshTTL: time.Minute * 10,
|
||||
SecurityPINActionTokenTTL: time.Minute,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
userID := uuidv7.MustBytes()
|
||||
roleID := uuidv7.MustBytes()
|
||||
repo.users[string(userID)] = &auth.User{ID: userID, RoleID: roleID}
|
||||
if err := svc.SetSecurityPIN(context.Background(), userID, "123456"); err != nil {
|
||||
t.Fatalf("SetSecurityPIN: %v", err)
|
||||
}
|
||||
|
||||
token, err := svc.VerifySecurityPINForAction(context.Background(), userID, "123456", sharedconst.PinActionAuditLogRead)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifySecurityPINForAction: %v", err)
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_id", userID)
|
||||
return c.Next()
|
||||
})
|
||||
app.Use(PermissionRequiredReusable(svc, "audit.read"))
|
||||
app.Get("/protected", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
|
||||
|
||||
req1, _ := http.NewRequest(http.MethodGet, "/protected", nil)
|
||||
req1.Header.Set(permissionPinVerificationHeader, token)
|
||||
resp1, err := app.Test(req1)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test first: %v", err)
|
||||
}
|
||||
if resp1.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected first request 200, got %d", resp1.StatusCode)
|
||||
}
|
||||
|
||||
req2, _ := http.NewRequest(http.MethodGet, "/protected", nil)
|
||||
req2.Header.Set(permissionPinVerificationHeader, token)
|
||||
resp2, err := app.Test(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test second: %v", err)
|
||||
}
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected second request 200, got %d", resp2.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("consuming variant rejects the same token twice", func(t *testing.T) {
|
||||
repo := newMiddlewareAuthRepo()
|
||||
roleRepo := &middlewareRoleRepo{
|
||||
allowed: true,
|
||||
permissionByKey: &auth.Permission{RequiresPIN: true},
|
||||
}
|
||||
tokens := newMiddlewareTokenStore()
|
||||
svc := service.NewAuthService(
|
||||
repo,
|
||||
roleRepo,
|
||||
tokens,
|
||||
nil,
|
||||
service.AuthServiceDependencies{
|
||||
SSO: nil, Protector: nil, Config: config.AuthConfig{
|
||||
JWTAccessSecret: "access-secret",
|
||||
JWTRefreshSecret: "refresh-secret",
|
||||
JWTAccessTTL: time.Minute,
|
||||
JWTRefreshTTL: time.Minute * 10,
|
||||
SecurityPINActionTokenTTL: time.Minute,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
userID := uuidv7.MustBytes()
|
||||
roleID := uuidv7.MustBytes()
|
||||
repo.users[string(userID)] = &auth.User{ID: userID, RoleID: roleID}
|
||||
if err := svc.SetSecurityPIN(context.Background(), userID, "123456"); err != nil {
|
||||
t.Fatalf("SetSecurityPIN: %v", err)
|
||||
}
|
||||
|
||||
token, err := svc.VerifySecurityPINForAction(context.Background(), userID, "123456", sharedconst.PinActionBaseCreate)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifySecurityPINForAction: %v", err)
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_id", userID)
|
||||
return c.Next()
|
||||
})
|
||||
app.Use(PermissionRequired(svc, "base.create"))
|
||||
app.Post("/protected", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
|
||||
|
||||
req1, _ := http.NewRequest(http.MethodPost, "/protected", nil)
|
||||
req1.Header.Set(permissionPinVerificationHeader, token)
|
||||
resp1, err := app.Test(req1)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test first: %v", err)
|
||||
}
|
||||
if resp1.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected first request 200, got %d", resp1.StatusCode)
|
||||
}
|
||||
|
||||
req2, _ := http.NewRequest(http.MethodPost, "/protected", nil)
|
||||
req2.Header.Set(permissionPinVerificationHeader, token)
|
||||
resp2, err := app.Test(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test second: %v", err)
|
||||
}
|
||||
if resp2.StatusCode != http.StatusAccepted {
|
||||
t.Fatalf("expected second request 202, got %d", resp2.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPinRequired(t *testing.T) {
|
||||
t.Run("service not configured", func(t *testing.T) {
|
||||
app := fiber.New()
|
||||
app.Use(PinRequired(nil, "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"))
|
||||
app.Post("/protected", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, "/protected", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing auth context", func(t *testing.T) {
|
||||
svc := service.NewAuthService(nil, nil, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
||||
app := fiber.New()
|
||||
app.Use(PinRequired(svc, "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"))
|
||||
app.Post("/protected", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, "/protected", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid pin action token", func(t *testing.T) {
|
||||
tokenStore := newMiddlewareTokenStore()
|
||||
svc := service.NewAuthService(nil, nil, tokenStore, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
||||
userID := uuidv7.MustBytes()
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_id", userID)
|
||||
return c.Next()
|
||||
})
|
||||
app.Use(PinRequired(svc, "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"))
|
||||
app.Post("/protected", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, "/protected", nil)
|
||||
req.Header.Set(pinVerificationHeader, "bad-token")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
t.Fatalf("expected 202, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid pin action token", func(t *testing.T) {
|
||||
tokenStore := newMiddlewareTokenStore()
|
||||
svc := service.NewAuthService(nil, nil, tokenStore, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
||||
userID := uuidv7.MustBytes()
|
||||
userIDStr, err := uuidv7.BytesToString(userID)
|
||||
if err != nil {
|
||||
t.Fatalf("bytes to string: %v", err)
|
||||
}
|
||||
action := "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"
|
||||
token := "token-ok"
|
||||
key := "auth:pin_action:" + token
|
||||
tokenStore.store[key] = []byte(userIDStr + "|" + action)
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_id", userID)
|
||||
return c.Next()
|
||||
})
|
||||
app.Use(PinRequired(svc, action))
|
||||
app.Post("/protected", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, "/protected", nil)
|
||||
req.Header.Set(pinVerificationHeader, token)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
req2, _ := http.NewRequest(http.MethodPost, "/protected", nil)
|
||||
req2.Header.Set(pinVerificationHeader, token)
|
||||
resp2, err := app.Test(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test second: %v", err)
|
||||
}
|
||||
if resp2.StatusCode != http.StatusAccepted {
|
||||
t.Fatalf("expected second request 202, got %d", resp2.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPinRequiredForPermission(t *testing.T) {
|
||||
t.Run("skip when permission does not require pin", func(t *testing.T) {
|
||||
roleRepo := &middlewareRoleRepo{permissionByKey: &auth.Permission{RequiresPIN: false}}
|
||||
svc := service.NewAuthService(nil, roleRepo, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
||||
userID := uuidv7.MustBytes()
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_id", userID)
|
||||
return c.Next()
|
||||
})
|
||||
app.Use(PinRequiredForPermission(svc, "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74", "user.delete"))
|
||||
app.Post("/protected", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, "/protected", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("require pin when permission policy enabled", func(t *testing.T) {
|
||||
tokenStore := newMiddlewareTokenStore()
|
||||
roleRepo := &middlewareRoleRepo{permissionByKey: &auth.Permission{RequiresPIN: true}}
|
||||
svc := service.NewAuthService(nil, roleRepo, tokenStore, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
||||
userID := uuidv7.MustBytes()
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_id", userID)
|
||||
return c.Next()
|
||||
})
|
||||
app.Use(PinRequiredForPermission(svc, "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74", "user.delete"))
|
||||
app.Post("/protected", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, "/protected", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
t.Fatalf("expected 202, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
userIDStr, err := uuidv7.BytesToString(userID)
|
||||
if err != nil {
|
||||
t.Fatalf("bytes to string: %v", err)
|
||||
}
|
||||
token := "token-ok"
|
||||
tokenStore.store["auth:pin_action:"+token] = []byte(userIDStr + "|a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74")
|
||||
|
||||
req2, _ := http.NewRequest(http.MethodPost, "/protected", nil)
|
||||
req2.Header.Set(pinVerificationHeader, token)
|
||||
resp2, err := app.Test(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test second: %v", err)
|
||||
}
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp2.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reusable token when permission policy enabled", func(t *testing.T) {
|
||||
tokenStore := newMiddlewareTokenStore()
|
||||
roleRepo := &middlewareRoleRepo{permissionByKey: &auth.Permission{RequiresPIN: true}}
|
||||
svc := service.NewAuthService(nil, roleRepo, tokenStore, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
||||
userID := uuidv7.MustBytes()
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_id", userID)
|
||||
return c.Next()
|
||||
})
|
||||
app.Use(PinRequiredReusableForPermission(svc, "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74", "user.delete"))
|
||||
app.Post("/protected", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
|
||||
|
||||
userIDStr, err := uuidv7.BytesToString(userID)
|
||||
if err != nil {
|
||||
t.Fatalf("bytes to string: %v", err)
|
||||
}
|
||||
token := "token-reusable"
|
||||
tokenStore.store["auth:pin_action:"+token] = []byte(userIDStr + "|a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74")
|
||||
|
||||
req1, _ := http.NewRequest(http.MethodPost, "/protected", nil)
|
||||
req1.Header.Set(pinVerificationHeader, token)
|
||||
resp1, err := app.Test(req1)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test first: %v", err)
|
||||
}
|
||||
if resp1.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected first request 200, got %d", resp1.StatusCode)
|
||||
}
|
||||
|
||||
req2, _ := http.NewRequest(http.MethodPost, "/protected", nil)
|
||||
req2.Header.Set(pinVerificationHeader, token)
|
||||
resp2, err := app.Test(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test second: %v", err)
|
||||
}
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected second request 200, got %d", resp2.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
84
internal/transport/http/middleware/permission_middleware.go
Normal file
84
internal/transport/http/middleware/permission_middleware.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"wucher/internal/service"
|
||||
sharedconst "wucher/internal/shared/const"
|
||||
"wucher/internal/shared/pkg/apperrorsx"
|
||||
)
|
||||
|
||||
const permissionPinVerificationHeader = "X-PIN-Verification-Token"
|
||||
|
||||
func PermissionRequired(svc *service.AuthService, permissionKey string) fiber.Handler {
|
||||
return permissionRequired(svc, permissionKey, true)
|
||||
}
|
||||
|
||||
func PermissionRequiredReusable(svc *service.AuthService, permissionKey string) fiber.Handler {
|
||||
return permissionRequired(svc, permissionKey, false)
|
||||
}
|
||||
|
||||
func permissionRequired(svc *service.AuthService, permissionKey string, consume bool) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
if svc == nil {
|
||||
e := apperrorsx.New(apperrorsx.ErrUserUnauthorized)
|
||||
e.Message = "auth service not configured"
|
||||
return apperrorsx.Write(c, e)
|
||||
}
|
||||
|
||||
raw := c.Locals("user_id")
|
||||
userID, ok := raw.([]byte)
|
||||
if !ok || len(userID) == 0 {
|
||||
e := apperrorsx.New(apperrorsx.ErrUserUnauthorized)
|
||||
e.Message = "missing auth context"
|
||||
return apperrorsx.Write(c, e)
|
||||
}
|
||||
|
||||
allowed, err := svc.HasPermission(c.UserContext(), userID, permissionKey)
|
||||
if err != nil {
|
||||
e := apperrorsx.New(apperrorsx.ErrForbiddenAccess)
|
||||
e.Message = "failed to verify permission"
|
||||
return apperrorsx.Write(c, e)
|
||||
}
|
||||
if !allowed {
|
||||
e := apperrorsx.New(apperrorsx.ErrForbiddenAccess)
|
||||
e.Message = "insufficient permission"
|
||||
return apperrorsx.Write(c, e)
|
||||
}
|
||||
|
||||
required, err := svc.PermissionRequiresPIN(c.UserContext(), permissionKey)
|
||||
if err != nil {
|
||||
e := apperrorsx.New(apperrorsx.ErrForbiddenAccess)
|
||||
e.Message = "failed to verify PIN requirement"
|
||||
return apperrorsx.Write(c, e)
|
||||
}
|
||||
if required {
|
||||
action := sharedconst.PinActionForPermissionKey(permissionKey)
|
||||
if action == "" {
|
||||
action = permissionKey
|
||||
}
|
||||
token := c.Get(permissionPinVerificationHeader)
|
||||
sessionID := ""
|
||||
for _, accessToken := range service.CookieValues(c.Get(fiber.HeaderCookie), svc.AccessCookieName()) {
|
||||
sid, err := svc.ParseAccessTokenSessionID(c.UserContext(), accessToken)
|
||||
if err == nil {
|
||||
sessionID = sid
|
||||
break
|
||||
}
|
||||
}
|
||||
if sessionID == "" {
|
||||
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrTokenInvalid))
|
||||
}
|
||||
var verifyErr error
|
||||
if consume {
|
||||
verifyErr = svc.ConsumeSecurityPINActionTokenInSession(c.UserContext(), userID, action, token, sessionID)
|
||||
} else {
|
||||
verifyErr = svc.ValidateSecurityPINActionTokenInSession(c.UserContext(), userID, action, token, sessionID)
|
||||
}
|
||||
if verifyErr != nil {
|
||||
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINVerificationRequired202))
|
||||
}
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
80
internal/transport/http/middleware/pin_middleware.go
Normal file
80
internal/transport/http/middleware/pin_middleware.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"wucher/internal/service"
|
||||
"wucher/internal/shared/pkg/apperrorsx"
|
||||
)
|
||||
|
||||
const pinVerificationHeader = "X-PIN-Verification-Token"
|
||||
|
||||
func PinRequired(svc *service.AuthService, action string) fiber.Handler {
|
||||
return pinRequired(svc, action, "", true)
|
||||
}
|
||||
|
||||
func PinRequiredForPermission(svc *service.AuthService, action, permissionKey string) fiber.Handler {
|
||||
return pinRequired(svc, action, permissionKey, true)
|
||||
}
|
||||
|
||||
func PinRequiredReusable(svc *service.AuthService, action string) fiber.Handler {
|
||||
return pinRequired(svc, action, "", false)
|
||||
}
|
||||
|
||||
func PinRequiredReusableForPermission(svc *service.AuthService, action, permissionKey string) fiber.Handler {
|
||||
return pinRequired(svc, action, permissionKey, false)
|
||||
}
|
||||
|
||||
func pinRequired(svc *service.AuthService, action, permissionKey string, consume bool) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
if svc == nil {
|
||||
e := apperrorsx.New(apperrorsx.ErrUserUnauthorized)
|
||||
e.Message = "auth service not configured"
|
||||
return apperrorsx.Write(c, e)
|
||||
}
|
||||
|
||||
raw := c.Locals("user_id")
|
||||
userID, ok := raw.([]byte)
|
||||
if !ok || len(userID) == 0 {
|
||||
e := apperrorsx.New(apperrorsx.ErrUserUnauthorized)
|
||||
e.Message = "missing auth context"
|
||||
return apperrorsx.Write(c, e)
|
||||
}
|
||||
|
||||
if permissionKey != "" {
|
||||
required, err := svc.PermissionRequiresPIN(c.UserContext(), permissionKey)
|
||||
if err != nil {
|
||||
e := apperrorsx.New(apperrorsx.ErrForbiddenAccess)
|
||||
e.Message = "failed to verify PIN requirement"
|
||||
return apperrorsx.Write(c, e)
|
||||
}
|
||||
if !required {
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
token := c.Get(pinVerificationHeader)
|
||||
sessionID := ""
|
||||
for _, accessToken := range service.CookieValues(c.Get(fiber.HeaderCookie), svc.AccessCookieName()) {
|
||||
sid, err := svc.ParseAccessTokenSessionID(c.UserContext(), accessToken)
|
||||
if err == nil {
|
||||
sessionID = sid
|
||||
break
|
||||
}
|
||||
}
|
||||
if sessionID == "" {
|
||||
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrTokenInvalid))
|
||||
}
|
||||
|
||||
var err error
|
||||
if consume {
|
||||
err = svc.ConsumeSecurityPINActionTokenInSession(c.UserContext(), userID, action, token, sessionID)
|
||||
} else {
|
||||
err = svc.ValidateSecurityPINActionTokenInSession(c.UserContext(), userID, action, token, sessionID)
|
||||
}
|
||||
if err != nil {
|
||||
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINVerificationRequired202))
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
172
internal/transport/http/middleware/wopi_middleware.go
Normal file
172
internal/transport/http/middleware/wopi_middleware.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"wucher/internal/service"
|
||||
"wucher/internal/shared/pkg/apperrorsx"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
const (
|
||||
wopiAccessTokenQuery = "access_token"
|
||||
wopiFileIDParam = "fileId"
|
||||
)
|
||||
|
||||
func WOPIRequired(svc *service.WOPIService) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
if svc == nil {
|
||||
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrFileManagerWOPIServiceUnavailable))
|
||||
}
|
||||
|
||||
fileID, err := parseWOPIFileID(c)
|
||||
if err != nil {
|
||||
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrFileManagerWOPIInvalidFileID))
|
||||
}
|
||||
|
||||
token := firstNonEmptyWOPIQueryValue(c, wopiAccessTokenQuery)
|
||||
if token == "" {
|
||||
token = firstNonEmptyWOPISrcQueryValue(c, wopiAccessTokenQuery)
|
||||
}
|
||||
if token == "" {
|
||||
authz := strings.TrimSpace(c.Get("Authorization"))
|
||||
if strings.HasPrefix(strings.ToLower(authz), "bearer ") {
|
||||
token = strings.TrimSpace(authz[7:])
|
||||
}
|
||||
}
|
||||
claims, err := svc.VerifyAccessToken(token, fileID)
|
||||
if err != nil {
|
||||
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrFileManagerWOPIInvalidToken).WithCause(err))
|
||||
}
|
||||
|
||||
if err := svc.VerifyProof(c.Method(), c.Path(), token, c.Get("X-WOPI-TimeStamp"), c.Get("X-WOPI-Proof"), c.Get("X-WOPI-ProofOld")); err != nil {
|
||||
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrFileManagerWOPIInvalidProof).WithCause(err))
|
||||
}
|
||||
|
||||
userID, _ := uuidv7.ParsePathLikeString(claims.UserID)
|
||||
c.Locals("user_id", userID)
|
||||
c.Locals("wopi_claims", claims)
|
||||
c.Locals("wopi_permission", strings.ToLower(strings.TrimSpace(claims.Permission)))
|
||||
c.Locals("wopi_takeover_id", claims.TakeoverID)
|
||||
c.Locals("wopi_access_token", token)
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmptyWOPIQueryValue(c *fiber.Ctx, key string) string {
|
||||
if c == nil || strings.TrimSpace(key) == "" {
|
||||
return ""
|
||||
}
|
||||
found := ""
|
||||
c.Context().QueryArgs().VisitAll(func(k, v []byte) {
|
||||
if found != "" {
|
||||
return
|
||||
}
|
||||
if !strings.EqualFold(string(k), key) {
|
||||
return
|
||||
}
|
||||
value := strings.TrimSpace(string(v))
|
||||
if value != "" {
|
||||
found = value
|
||||
}
|
||||
})
|
||||
if found != "" {
|
||||
return found
|
||||
}
|
||||
return strings.TrimSpace(c.Query(key))
|
||||
}
|
||||
|
||||
func firstNonEmptyWOPISrcQueryValue(c *fiber.Ctx, key string) string {
|
||||
if c == nil || strings.TrimSpace(key) == "" {
|
||||
return ""
|
||||
}
|
||||
src := strings.TrimSpace(c.Query("WOPISrc"))
|
||||
if src == "" {
|
||||
return ""
|
||||
}
|
||||
decoded := src
|
||||
if unescaped, err := url.QueryUnescape(src); err == nil && strings.TrimSpace(unescaped) != "" {
|
||||
decoded = strings.TrimSpace(unescaped)
|
||||
}
|
||||
parsed, err := url.Parse(decoded)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
query := parsed.Query()
|
||||
if value := strings.TrimSpace(query.Get(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseWOPIFileID(c *fiber.Ctx) ([]byte, error) {
|
||||
if c == nil {
|
||||
return nil, fiber.ErrBadRequest
|
||||
}
|
||||
candidates := []string{
|
||||
c.Params(wopiFileIDParam),
|
||||
c.Path(),
|
||||
c.OriginalURL(),
|
||||
c.Query("WOPISrc"),
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileID, err := uuidv7.ParsePathLikeString(candidate); err == nil {
|
||||
return fileID, nil
|
||||
}
|
||||
}
|
||||
return nil, fiber.ErrBadRequest
|
||||
}
|
||||
|
||||
func WOPIRequestBodyLimit(maxBytes int) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
if maxBytes <= 0 {
|
||||
return c.Next()
|
||||
}
|
||||
if c.Method() == http.MethodPost || c.Method() == http.MethodPut {
|
||||
if c.Request().Header.ContentLength() > maxBytes {
|
||||
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrFileManagerWOPIValidationFailed))
|
||||
}
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func WOPIPermissionRequired(required service.WOPIPermission) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
if strings.TrimSpace(string(required)) == "" {
|
||||
return c.Next()
|
||||
}
|
||||
raw := c.Locals("wopi_permission")
|
||||
current, _ := raw.(string)
|
||||
if !wopiPermissionAllowed(current, string(required)) {
|
||||
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrFileManagerWOPIPermissionDenied))
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func wopiPermissionAllowed(current, required string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(required)) {
|
||||
case string(service.WOPIPermissionRead):
|
||||
return wopiPermissionRank(current) >= wopiPermissionRank(string(service.WOPIPermissionRead))
|
||||
case string(service.WOPIPermissionWrite):
|
||||
return wopiPermissionRank(current) >= wopiPermissionRank(string(service.WOPIPermissionWrite))
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func wopiPermissionRank(v string) int {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case string(service.WOPIPermissionRead):
|
||||
return 1
|
||||
case string(service.WOPIPermissionWrite):
|
||||
return 2
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
46
internal/transport/http/middleware/wopi_middleware_test.go
Normal file
46
internal/transport/http/middleware/wopi_middleware_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"wucher/internal/service"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
func TestWOPIRequiredAcceptsTokenFromWOPISrc(t *testing.T) {
|
||||
fileID := uuidv7.MustBytes()
|
||||
userID := uuidv7.MustBytes()
|
||||
wopiSvc := service.NewWOPIService(service.WOPIConfig{
|
||||
TokenSecret: "test-secret",
|
||||
PublicBaseURL: "https://api.example.com",
|
||||
TokenTTL: 30 * time.Minute,
|
||||
})
|
||||
token, _, err := wopiSvc.MintAccessToken(fileID, userID, nil, service.WOPIPermissionWrite)
|
||||
if err != nil {
|
||||
t.Fatalf("mint access token: %v", err)
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(WOPIRequired(wopiSvc))
|
||||
app.Get("/wopi/files/:fileId", func(c *fiber.Ctx) error {
|
||||
return c.SendStatus(http.StatusOK)
|
||||
})
|
||||
|
||||
fileIDString, err := uuidv7.BytesToString(fileID)
|
||||
if err != nil {
|
||||
t.Fatalf("file id string: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/wopi/files/"+fileIDString+"?WOPISrc=https%3A%2F%2Fapi.example.com%2Fwopi%2Ffiles%2F"+fileIDString+"%3Faccess_token%3D"+token, nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app test failed: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user