forked from baron/baron-sso
Merge commit 'f9e5171eb8f38fde9e3e67deb400c846b57fd5e6' into feature/af-is309
This commit is contained in:
@@ -278,6 +278,7 @@ func main() {
|
|||||||
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService)
|
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService)
|
||||||
adminHandler := handler.NewAdminHandler(ketoService)
|
adminHandler := handler.NewAdminHandler(ketoService)
|
||||||
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, authHandler)
|
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, authHandler)
|
||||||
|
devHandler.AuditRepo = auditRepo
|
||||||
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService)
|
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService)
|
||||||
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
|
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
|
||||||
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
||||||
@@ -654,6 +655,7 @@ func main() {
|
|||||||
|
|
||||||
// 개발자 포털 라우트 (RP/Consent 관리 및 IdP 설정)
|
// 개발자 포털 라우트 (RP/Consent 관리 및 IdP 설정)
|
||||||
dev := api.Group("/dev")
|
dev := api.Group("/dev")
|
||||||
|
dev.Get("/stats", devHandler.GetStats)
|
||||||
dev.Get("/clients", devHandler.ListClients)
|
dev.Get("/clients", devHandler.ListClients)
|
||||||
dev.Post("/clients", devHandler.CreateClient)
|
dev.Post("/clients", devHandler.CreateClient)
|
||||||
dev.Get("/clients/:id", devHandler.GetClient)
|
dev.Get("/clients/:id", devHandler.GetClient)
|
||||||
@@ -663,6 +665,7 @@ func main() {
|
|||||||
dev.Delete("/clients/:id", devHandler.DeleteClient)
|
dev.Delete("/clients/:id", devHandler.DeleteClient)
|
||||||
dev.Get("/consents", devHandler.ListConsents)
|
dev.Get("/consents", devHandler.ListConsents)
|
||||||
dev.Delete("/consents", devHandler.RevokeConsents)
|
dev.Delete("/consents", devHandler.RevokeConsents)
|
||||||
|
dev.Get("/audit-logs", devHandler.ListAuditLogs)
|
||||||
|
|
||||||
// Webhook for Kratos courier (HTTP delivery)
|
// Webhook for Kratos courier (HTTP delivery)
|
||||||
auth.Post("/webhooks/kratos-courier", authHandler.HandleKratosCourierRelay)
|
auth.Post("/webhooks/kratos-courier", authHandler.HandleKratosCourierRelay)
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ type AuditRepository interface {
|
|||||||
Create(log *AuditLog) error
|
Create(log *AuditLog) error
|
||||||
FindPage(ctx context.Context, limit int, cursor *AuditCursor) ([]AuditLog, error)
|
FindPage(ctx context.Context, limit int, cursor *AuditCursor) ([]AuditLog, error)
|
||||||
FindByUserAndEvents(ctx context.Context, userID string, eventTypes []string, limit int) ([]AuditLog, error)
|
FindByUserAndEvents(ctx context.Context, userID string, eventTypes []string, limit int) ([]AuditLog, error)
|
||||||
|
CountFailuresSince(ctx context.Context, since time.Time, tenantID string) (int64, error)
|
||||||
|
CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error)
|
||||||
Ping(ctx context.Context) error
|
Ping(ctx context.Context) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Mock IDP Provider ---
|
// --- Mock IDP Provider ---
|
||||||
@@ -101,6 +102,15 @@ func (m *mockAuditRepo) FindByUserAndEvents(ctx context.Context, userID string,
|
|||||||
}
|
}
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockAuditRepo) CountFailuresSince(ctx context.Context, since time.Time, tenantID string) (int64, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAuditRepo) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockAuditRepo) Ping(ctx context.Context) error { return nil }
|
func (m *mockAuditRepo) Ping(ctx context.Context) error { return nil }
|
||||||
|
|
||||||
// --- Mock Consent Repository ---
|
// --- Mock Consent Repository ---
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
193
backend/internal/handler/dev_handler_isolation_test.go
Normal file
193
backend/internal/handler/dev_handler_isolation_test.go
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"baron-sso-backend/internal/service"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDevHandler_Isolation(t *testing.T) {
|
||||||
|
mockKeto := new(MockKetoService)
|
||||||
|
|
||||||
|
h := &DevHandler{
|
||||||
|
Hydra: &service.HydraAdminService{
|
||||||
|
AdminURL: "http://hydra.test",
|
||||||
|
HTTPClient: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
if r.Method == http.MethodGet && r.URL.Path == "/clients" {
|
||||||
|
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"client_id": "client-tenant-a",
|
||||||
|
"client_name": "App Tenant A",
|
||||||
|
"token_endpoint_auth_method": "none", // PKCE
|
||||||
|
"metadata": map[string]interface{}{"tenant_id": "tenant-a"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_id": "client-tenant-b",
|
||||||
|
"client_name": "App Tenant B",
|
||||||
|
"token_endpoint_auth_method": "none", // PKCE
|
||||||
|
"metadata": map[string]interface{}{"tenant_id": "tenant-b"},
|
||||||
|
},
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
if (r.Method == http.MethodGet || r.Method == http.MethodPut) && strings.HasPrefix(r.URL.Path, "/clients/") {
|
||||||
|
id := strings.TrimPrefix(r.URL.Path, "/clients/")
|
||||||
|
tenantID := "tenant-a"
|
||||||
|
if id == "client-tenant-b" {
|
||||||
|
tenantID = "tenant-b"
|
||||||
|
}
|
||||||
|
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
||||||
|
"client_id": id,
|
||||||
|
"client_name": "App " + id,
|
||||||
|
"token_endpoint_auth_method": "none",
|
||||||
|
"metadata": map[string]interface{}{"tenant_id": tenantID},
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
|
||||||
|
var body map[string]interface{}
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
return httpJSONAny(r, http.StatusCreated, body), nil
|
||||||
|
}
|
||||||
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Keto: mockKeto,
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("Local bypass should be removed", func(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
app.Get("/api/v1/dev/clients", h.ListClients)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
||||||
|
req.Header.Set("Origin", "http://localhost:5174")
|
||||||
|
|
||||||
|
resp, _ := app.Test(req, -1)
|
||||||
|
// We expect 401 now because ListClients enforces authentication.
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ListClients should filter by tenant_id for non-SuperAdmin", func(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
tenantA := "tenant-a"
|
||||||
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||||
|
ID: "user-a",
|
||||||
|
Role: domain.RoleUser,
|
||||||
|
TenantID: &tenantA,
|
||||||
|
})
|
||||||
|
return c.Next()
|
||||||
|
})
|
||||||
|
app.Get("/api/v1/dev/clients", h.ListClients)
|
||||||
|
|
||||||
|
mockKeto.On("CheckPermission", mock.Anything, "user-a", "System", "AppManager", "member").Return(false, nil)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
||||||
|
resp, _ := app.Test(req, -1)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var res struct {
|
||||||
|
Items []clientSummary `json:"items"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&res)
|
||||||
|
|
||||||
|
// Should only see client-tenant-a
|
||||||
|
assert.Equal(t, 1, len(res.Items))
|
||||||
|
assert.Equal(t, "client-tenant-a", res.Items[0].ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetClient should enforce tenant isolation", func(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
tenantA := "tenant-a"
|
||||||
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||||
|
ID: "user-a",
|
||||||
|
Role: domain.RoleUser,
|
||||||
|
TenantID: &tenantA,
|
||||||
|
})
|
||||||
|
return c.Next()
|
||||||
|
})
|
||||||
|
app.Get("/api/v1/dev/clients/:id", h.GetClient)
|
||||||
|
|
||||||
|
// Case 1: Same tenant
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-tenant-a", nil)
|
||||||
|
resp, _ := app.Test(req, -1)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
// Case 2: Different tenant
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-tenant-b", nil)
|
||||||
|
resp, _ = app.Test(req, -1)
|
||||||
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("UpdateClient should enforce tenant isolation", func(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
tenantA := "tenant-a"
|
||||||
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||||
|
ID: "user-a",
|
||||||
|
Role: domain.RoleUser,
|
||||||
|
TenantID: &tenantA,
|
||||||
|
})
|
||||||
|
return c.Next()
|
||||||
|
})
|
||||||
|
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"client_name": "Updated Name",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Case 1: Same tenant
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-tenant-a", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, _ := app.Test(req, -1)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
// Case 2: Different tenant
|
||||||
|
req = httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-tenant-b", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, _ = app.Test(req, -1)
|
||||||
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CreateClient should record user_id and tenant_id", func(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
tenantA := "tenant-a"
|
||||||
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||||
|
ID: "user-a",
|
||||||
|
Role: domain.RoleSuperAdmin, // Bypass for creation permission
|
||||||
|
TenantID: &tenantA,
|
||||||
|
})
|
||||||
|
return c.Next()
|
||||||
|
})
|
||||||
|
app.Post("/api/v1/dev/clients", h.CreateClient)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"client_name": "New App",
|
||||||
|
"type": "pkce",
|
||||||
|
"redirectUris": []string{"http://localhost/cb"},
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Tenant-ID", "tenant-a")
|
||||||
|
|
||||||
|
resp, _ := app.Test(req, -1)
|
||||||
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
|
||||||
|
var res clientDetailResponse
|
||||||
|
json.NewDecoder(resp.Body).Decode(&res)
|
||||||
|
|
||||||
|
assert.Equal(t, "tenant-a", res.Client.Metadata["tenant_id"])
|
||||||
|
assert.Equal(t, "user-a", res.Client.Metadata["user_id"])
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -172,3 +173,79 @@ func TestCreateClient_Success(t *testing.T) {
|
|||||||
secret, _ := secretRepo.GetByID(nil, "new-client-123")
|
secret, _ := secretRepo.GetByID(nil, "new-client-123")
|
||||||
assert.Equal(t, "secret-123", secret)
|
assert.Equal(t, "secret-123", secret)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListAuditLogs_FilterByActionAndClientID(t *testing.T) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
auditRepo := &mockAuditRepo{
|
||||||
|
logs: []domain.AuditLog{
|
||||||
|
{
|
||||||
|
EventID: "evt-1",
|
||||||
|
Timestamp: now,
|
||||||
|
UserID: "user-a",
|
||||||
|
EventType: "PUT /api/v1/dev/clients/client-1",
|
||||||
|
Status: "success",
|
||||||
|
Details: `{"action":"UPDATE_CLIENT","target_id":"client-1","tenant_id":"tenant-a"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
EventID: "evt-2",
|
||||||
|
Timestamp: now.Add(-time.Minute),
|
||||||
|
UserID: "user-a",
|
||||||
|
EventType: "DELETE /api/v1/dev/clients/client-1",
|
||||||
|
Status: "success",
|
||||||
|
Details: `{"action":"DELETE_CLIENT","target_id":"client-1","tenant_id":"tenant-a"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
EventID: "evt-3",
|
||||||
|
Timestamp: now.Add(-2 * time.Minute),
|
||||||
|
UserID: "user-b",
|
||||||
|
EventType: "PUT /api/v1/dev/clients/client-2",
|
||||||
|
Status: "failure",
|
||||||
|
Details: `{"action":"UPDATE_CLIENT","target_id":"client-2","tenant_id":"tenant-b"}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &DevHandler{
|
||||||
|
AuditRepo: auditRepo,
|
||||||
|
Keto: new(MockKetoService),
|
||||||
|
}
|
||||||
|
app := fiber.New()
|
||||||
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
||||||
|
return c.Next()
|
||||||
|
})
|
||||||
|
app.Get("/api/v1/dev/audit-logs", h.ListAuditLogs)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/audit-logs?action=UPDATE_CLIENT&client_id=client-1&status=success", nil)
|
||||||
|
resp, _ := app.Test(req, -1)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var res devAuditListResponse
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&res)
|
||||||
|
assert.Len(t, res.Items, 1)
|
||||||
|
assert.Equal(t, "evt-1", res.Items[0].EventID)
|
||||||
|
assert.Equal(t, "success", res.Items[0].Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAuditLogs_NonAdminKetoErrorReturnsForbidden(t *testing.T) {
|
||||||
|
mockKeto := new(MockKetoService)
|
||||||
|
mockKeto.On("CheckPermission", mock.Anything, "user-1", "System", "AppManager", "member").Return(false, assert.AnError)
|
||||||
|
|
||||||
|
h := &DevHandler{
|
||||||
|
AuditRepo: &mockAuditRepo{},
|
||||||
|
Keto: mockKeto,
|
||||||
|
}
|
||||||
|
app := fiber.New()
|
||||||
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleUser})
|
||||||
|
return c.Next()
|
||||||
|
})
|
||||||
|
app.Get("/api/v1/dev/audit-logs", h.ListAuditLogs)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/audit-logs?limit=50", nil)
|
||||||
|
resp, _ := app.Test(req, -1)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
mockKeto.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -34,6 +35,14 @@ func (m *MockAuditRepository) FindByUserAndEvents(ctx context.Context, userID st
|
|||||||
return args.Get(0).([]domain.AuditLog), args.Error(1)
|
return args.Get(0).([]domain.AuditLog), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockAuditRepository) CountFailuresSince(ctx context.Context, since time.Time, tenantID string) (int64, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockAuditRepository) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MockAuditRepository) Ping(ctx context.Context) error {
|
func (m *MockAuditRepository) Ping(ctx context.Context) error {
|
||||||
args := m.Called(ctx)
|
args := m.Called(ctx)
|
||||||
return args.Error(0)
|
return args.Error(0)
|
||||||
|
|||||||
@@ -195,3 +195,44 @@ func (r *ClickHouseRepository) Ping(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
return r.conn.Ping(ctx)
|
return r.conn.Ping(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *ClickHouseRepository) CountFailuresSince(ctx context.Context, since time.Time, tenantID string) (int64, error) {
|
||||||
|
query := `
|
||||||
|
SELECT count()
|
||||||
|
FROM audit_logs
|
||||||
|
WHERE status = 'failure' AND timestamp >= ?
|
||||||
|
`
|
||||||
|
args := []any{since}
|
||||||
|
if tenantID != "" {
|
||||||
|
query += " AND JSONExtractString(details, 'tenant_id') = ?"
|
||||||
|
args = append(args, tenantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
err := r.conn.QueryRow(ctx, query, args...).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to count failures: %w", err)
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ClickHouseRepository) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) {
|
||||||
|
// We use uniqExact(session_id) to count unique sessions that had success events recently.
|
||||||
|
query := `
|
||||||
|
SELECT uniqExact(session_id)
|
||||||
|
FROM audit_logs
|
||||||
|
WHERE status = 'success' AND timestamp >= ? AND session_id != ''
|
||||||
|
`
|
||||||
|
args := []any{since}
|
||||||
|
if tenantID != "" {
|
||||||
|
query += " AND JSONExtractString(details, 'tenant_id') = ?"
|
||||||
|
args = append(args, tenantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
err := r.conn.QueryRow(ctx, query, args...).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to count active sessions: %w", err)
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Navigate, createBrowserRouter } from "react-router-dom";
|
import { Navigate, createBrowserRouter } from "react-router-dom";
|
||||||
import AppLayout from "../components/layout/AppLayout";
|
import AppLayout from "../components/layout/AppLayout";
|
||||||
|
import AuditLogsPage from "../features/audit/AuditLogsPage";
|
||||||
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
|
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
|
||||||
import AuthGuard from "../features/auth/AuthGuard";
|
import AuthGuard from "../features/auth/AuthGuard";
|
||||||
import LoginPage from "../features/auth/LoginPage";
|
import LoginPage from "../features/auth/LoginPage";
|
||||||
@@ -31,6 +32,7 @@ export const router = createBrowserRouter(
|
|||||||
{ path: "clients/:id", element: <ClientDetailsPage /> },
|
{ path: "clients/:id", element: <ClientDetailsPage /> },
|
||||||
{ path: "clients/:id/consents", element: <ClientConsentsPage /> },
|
{ path: "clients/:id/consents", element: <ClientConsentsPage /> },
|
||||||
{ path: "clients/:id/settings", element: <ClientGeneralPage /> },
|
{ path: "clients/:id/settings", element: <ClientGeneralPage /> },
|
||||||
|
{ path: "audit-logs", element: <AuditLogsPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { BadgeCheck, LogOut, Moon, ShieldHalf, Sun } from "lucide-react";
|
import {
|
||||||
|
BadgeCheck,
|
||||||
|
LogOut,
|
||||||
|
Moon,
|
||||||
|
NotebookTabs,
|
||||||
|
ShieldHalf,
|
||||||
|
Sun,
|
||||||
|
} from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useAuth } from "react-oidc-context";
|
import { useAuth } from "react-oidc-context";
|
||||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||||
@@ -13,6 +20,12 @@ const navItems = [
|
|||||||
to: "/clients",
|
to: "/clients",
|
||||||
icon: ShieldHalf,
|
icon: ShieldHalf,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
labelKey: "ui.dev.nav.audit_logs",
|
||||||
|
labelFallback: "Audit Logs",
|
||||||
|
to: "/audit-logs",
|
||||||
|
icon: NotebookTabs,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function AppLayout() {
|
function AppLayout() {
|
||||||
|
|||||||
@@ -1,141 +1,426 @@
|
|||||||
import { Filter, ListChecks, Search, Terminal } from "lucide-react";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import type { AxiosError } from "axios";
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Copy,
|
||||||
|
Download,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
} from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { Badge } from "../../components/ui/badge";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../components/ui/card";
|
||||||
|
import { Input } from "../../components/ui/input";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../../components/ui/table";
|
||||||
|
import type { DevAuditLog } from "../../lib/devApi";
|
||||||
|
import { fetchDevAuditLogs } from "../../lib/devApi";
|
||||||
|
import { t } from "../../lib/i18n";
|
||||||
|
|
||||||
const auditFilters = [
|
type AuditDetails = {
|
||||||
"Actor role = admin",
|
request_id?: string;
|
||||||
"Action = client.rotate_secret",
|
method?: string;
|
||||||
"Tenant = selected header",
|
path?: string;
|
||||||
];
|
tenant_id?: string;
|
||||||
|
action?: string;
|
||||||
|
target_id?: string;
|
||||||
|
before?: unknown;
|
||||||
|
after?: unknown;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const auditRows = [
|
function parseDetails(details?: string): AuditDetails {
|
||||||
{
|
if (!details) {
|
||||||
action: "client.create",
|
return {};
|
||||||
tenant: "TENANT-12",
|
}
|
||||||
actor: "ops.jane@baron",
|
try {
|
||||||
result: "ok",
|
const parsed = JSON.parse(details);
|
||||||
ts: "2026-01-26 15:21 KST",
|
if (parsed && typeof parsed === "object") {
|
||||||
},
|
return parsed as AuditDetails;
|
||||||
{
|
}
|
||||||
action: "client.rotate_secret",
|
} catch {}
|
||||||
tenant: "TENANT-12",
|
return {};
|
||||||
actor: "ops.jane@baron",
|
}
|
||||||
result: "ok",
|
|
||||||
ts: "2026-01-26 15:22 KST",
|
function formatValue(value: unknown): string {
|
||||||
},
|
if (value === null || value === undefined || value === "") {
|
||||||
{
|
return "-";
|
||||||
action: "audit.export",
|
}
|
||||||
tenant: "TENANT-07",
|
if (typeof value === "string") {
|
||||||
actor: "auditor.lee@baron",
|
return value;
|
||||||
result: "rate_limited",
|
}
|
||||||
ts: "2026-01-26 15:30 KST",
|
try {
|
||||||
},
|
return JSON.stringify(value);
|
||||||
];
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string): string {
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return parsed.toLocaleString("ko-KR");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCsv(logs: DevAuditLog[]) {
|
||||||
|
const header = [
|
||||||
|
"timestamp",
|
||||||
|
"user_id",
|
||||||
|
"status",
|
||||||
|
"event_type",
|
||||||
|
"action",
|
||||||
|
"target_id",
|
||||||
|
"tenant_id",
|
||||||
|
"request_id",
|
||||||
|
];
|
||||||
|
const rows = logs.map((logItem) => {
|
||||||
|
const details = parseDetails(logItem.details);
|
||||||
|
return [
|
||||||
|
logItem.timestamp,
|
||||||
|
logItem.user_id || "",
|
||||||
|
logItem.status,
|
||||||
|
logItem.event_type,
|
||||||
|
details.action || "",
|
||||||
|
details.target_id || "",
|
||||||
|
details.tenant_id || "",
|
||||||
|
details.request_id || "",
|
||||||
|
];
|
||||||
|
});
|
||||||
|
return [header, ...rows]
|
||||||
|
.map((line) =>
|
||||||
|
line.map((cell) => `"${String(cell).replaceAll('"', '""')}"`).join(","),
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadCsv(content: string, filename: string) {
|
||||||
|
const blob = new Blob([content], { type: "text/csv;charset=utf-8;" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = filename;
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
document.body.removeChild(anchor);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
function AuditLogsPage() {
|
function AuditLogsPage() {
|
||||||
|
const [searchClientId, setSearchClientId] = React.useState("");
|
||||||
|
const [searchAction, setSearchAction] = React.useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = React.useState("all");
|
||||||
|
const [expandedRows, setExpandedRows] = React.useState<
|
||||||
|
Record<string, boolean>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
const query = useInfiniteQuery({
|
||||||
|
queryKey: ["dev-audit-logs", searchClientId, searchAction, statusFilter],
|
||||||
|
queryFn: ({ pageParam }) =>
|
||||||
|
fetchDevAuditLogs(50, pageParam, {
|
||||||
|
client_id: searchClientId.trim() || undefined,
|
||||||
|
action: searchAction.trim() || undefined,
|
||||||
|
status: statusFilter !== "all" ? statusFilter : undefined,
|
||||||
|
}),
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const logs =
|
||||||
|
query.data?.pages.flatMap((page) =>
|
||||||
|
page.items.filter((item): item is DevAuditLog => Boolean(item)),
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
const handleCopy = (value: string) => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.clipboard.writeText(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportCsv = () => {
|
||||||
|
const csv = toCsv(logs);
|
||||||
|
const stamp = new Date().toISOString().replaceAll(":", "-");
|
||||||
|
downloadCsv(csv, `dev-audit-logs-${stamp}.csv`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (query.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
{t("msg.dev.audit.loading", "Loading audit logs...")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.error) {
|
||||||
|
const axiosError = query.error as AxiosError<{ error?: string }>;
|
||||||
|
if (axiosError.response?.status === 403) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center text-red-500">
|
||||||
|
{t(
|
||||||
|
"msg.dev.audit.forbidden",
|
||||||
|
"감사 로그를 조회할 권한이 없습니다. 관리자에게 권한을 요청해주세요.",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errMsg =
|
||||||
|
axiosError.response?.data?.error ?? (query.error as Error).message;
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center text-red-500">
|
||||||
|
{t("msg.dev.audit.load_error", "Error loading logs: {{error}}", {
|
||||||
|
error: errMsg,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<Card className="glass-panel">
|
||||||
<div>
|
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
<div>
|
||||||
Audit stream
|
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
</p>
|
{t("ui.dev.audit.registry.title", "Audit registry")}
|
||||||
<h2 className="text-2xl font-semibold">
|
</p>
|
||||||
Observe admin actions per tenant
|
<CardTitle className="text-3xl font-black tracking-tight">
|
||||||
</h2>
|
{t("ui.dev.audit.title", "Audit Logs")}
|
||||||
<p className="text-sm text-[var(--color-muted)]">
|
</CardTitle>
|
||||||
ClickHouse-backed feed. Filter by tenant, actor, action, and
|
<CardDescription>
|
||||||
rate-limit status. Enforce admin-only access under /admin.
|
{t(
|
||||||
</p>
|
"msg.dev.audit.subtitle",
|
||||||
</div>
|
"Shows DevFront activity history within current tenant/app scope.",
|
||||||
<div className="flex items-center gap-2">
|
)}
|
||||||
<button
|
</CardDescription>
|
||||||
type="button"
|
</div>
|
||||||
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)]"
|
<div className="flex items-center gap-2">
|
||||||
>
|
<Badge variant="muted">
|
||||||
<Filter size={14} />
|
{t("msg.dev.audit.loaded_count", "Loaded {{count}} rows", {
|
||||||
Saved filters
|
count: logs.length,
|
||||||
</button>
|
})}
|
||||||
<button
|
</Badge>
|
||||||
type="button"
|
<Button
|
||||||
className="inline-flex items-center gap-2 rounded-full bg-[var(--color-accent)] px-4 py-2 text-sm font-semibold text-black"
|
variant="outline"
|
||||||
>
|
onClick={() => query.refetch()}
|
||||||
<ListChecks size={14} />
|
disabled={query.isFetching}
|
||||||
Export CSV
|
>
|
||||||
</button>
|
<RefreshCw size={16} />
|
||||||
</div>
|
{t("ui.common.refresh", "새로고침")}
|
||||||
</div>
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="shadow-sm shadow-primary/30"
|
||||||
|
onClick={handleExportCsv}
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
{t("ui.dev.clients.consents.export_csv", "Export CSV")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-2 md:grid-cols-[1fr,1fr,180px]">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
className="pl-10"
|
||||||
|
value={searchClientId}
|
||||||
|
onChange={(e) => setSearchClientId(e.target.value)}
|
||||||
|
placeholder={t(
|
||||||
|
"ui.dev.audit.filter.client_id",
|
||||||
|
"Filter by Client ID",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={searchAction}
|
||||||
|
onChange={(e) => setSearchAction(e.target.value.toUpperCase())}
|
||||||
|
placeholder={t(
|
||||||
|
"ui.dev.audit.filter.action",
|
||||||
|
"Filter by Action (e.g. ROTATE_SECRET)",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">
|
||||||
|
{t("ui.dev.audit.filter.status_all", "All Status")}
|
||||||
|
</option>
|
||||||
|
<option value="success">
|
||||||
|
{t("ui.common.status.success", "Success")}
|
||||||
|
</option>
|
||||||
|
<option value="failure">
|
||||||
|
{t("ui.common.status.failure", "Failure")}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-[1.1fr,0.9fr]">
|
<Table className="table-fixed">
|
||||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5">
|
<TableHeader>
|
||||||
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-3 py-2 text-[var(--color-muted)]">
|
<TableRow>
|
||||||
<Search size={14} />
|
<TableHead className="w-[190px]">
|
||||||
<span className="text-sm">
|
{t("ui.dev.audit.table.time", "Time")}
|
||||||
Try: tenant:TENANT-12 action:client.*
|
</TableHead>
|
||||||
</span>
|
<TableHead className="w-[180px]">
|
||||||
</div>
|
{t("ui.dev.audit.table.actor", "Actor")}
|
||||||
<div className="mt-4 space-y-3">
|
</TableHead>
|
||||||
{auditFilters.map((filter) => (
|
<TableHead className="w-[180px]">
|
||||||
<span
|
{t("ui.dev.audit.table.action", "Action")}
|
||||||
key={filter}
|
</TableHead>
|
||||||
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-1 text-xs text-[var(--color-muted)]"
|
<TableHead className="w-[260px]">
|
||||||
>
|
{t("ui.dev.audit.table.target", "Target")}
|
||||||
<Terminal size={12} />
|
</TableHead>
|
||||||
{filter}
|
<TableHead className="w-[120px]">
|
||||||
</span>
|
{t("ui.dev.audit.table.status", "Status")}
|
||||||
))}
|
</TableHead>
|
||||||
</div>
|
<TableHead className="w-[80px]" />
|
||||||
<div className="mt-5 divide-y divide-[var(--color-border)]">
|
</TableRow>
|
||||||
{auditRows.map((row) => (
|
</TableHeader>
|
||||||
<div
|
<TableBody>
|
||||||
key={`${row.action}-${row.ts}`}
|
{logs.length === 0 && (
|
||||||
className="grid grid-cols-[1.2fr,1fr,1fr,1fr] items-center gap-2 py-3 text-sm"
|
<TableRow>
|
||||||
>
|
<TableCell
|
||||||
<div className="font-semibold">{row.action}</div>
|
colSpan={6}
|
||||||
<div className="text-[var(--color-muted)]">{row.tenant}</div>
|
className="text-center text-muted-foreground"
|
||||||
<div className="text-[var(--color-muted)]">{row.actor}</div>
|
|
||||||
<div className="inline-flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={`rounded-full px-2 py-1 text-xs ${
|
|
||||||
row.result === "ok"
|
|
||||||
? "bg-[rgba(54,211,153,0.16)] text-[var(--color-accent)]"
|
|
||||||
: "bg-[rgba(249,168,38,0.16)] text-[var(--color-accent-strong)]"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{row.result}
|
{t("msg.dev.audit.empty", "No audit logs found.")}
|
||||||
</span>
|
</TableCell>
|
||||||
<span className="text-[var(--color-muted)]">{row.ts}</span>
|
</TableRow>
|
||||||
</div>
|
)}
|
||||||
</div>
|
{logs.map((row, index) => {
|
||||||
))}
|
const details = parseDetails(row.details);
|
||||||
</div>
|
const actionLabel = details.action || row.event_type;
|
||||||
</div>
|
const targetValue = details.target_id || "-";
|
||||||
|
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
|
||||||
|
const expanded = Boolean(expandedRows[rowKey]);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={rowKey}>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{formatDateTime(row.timestamp)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{row.user_id || "-"}</span>
|
||||||
|
{row.user_id ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground"
|
||||||
|
onClick={() => handleCopy(row.user_id)}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">{actionLabel}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="break-all">{targetValue}</span>
|
||||||
|
{targetValue !== "-" ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground"
|
||||||
|
onClick={() => handleCopy(targetValue)}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
row.status === "success" ? "success" : "warning"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{row.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedRows((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[rowKey]: !expanded,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{expanded ? (
|
||||||
|
<TableRow className="bg-card/20">
|
||||||
|
<TableCell
|
||||||
|
colSpan={6}
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div>
|
||||||
|
Request ID: {formatValue(details.request_id)}
|
||||||
|
</div>
|
||||||
|
<div>Method: {formatValue(details.method)}</div>
|
||||||
|
<div>Path: {formatValue(details.path)}</div>
|
||||||
|
<div>
|
||||||
|
Tenant: {formatValue(details.tenant_id)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 break-all">
|
||||||
|
<div>Before: {formatValue(details.before)}</div>
|
||||||
|
<div>After: {formatValue(details.after)}</div>
|
||||||
|
<div>Error: {formatValue(details.error)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : null}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
<div className="space-y-4">
|
{query.hasNextPage ? (
|
||||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5">
|
<div className="flex justify-center">
|
||||||
<p className="text-xs uppercase tracking-[0.18em] text-[var(--color-muted)]">
|
<Button
|
||||||
Guard rails
|
variant="outline"
|
||||||
</p>
|
onClick={() => query.fetchNextPage()}
|
||||||
<h3 className="mt-1 text-lg font-semibold">Tenant admin only</h3>
|
disabled={query.isFetchingNextPage}
|
||||||
<p className="text-sm text-[var(--color-muted)]">
|
>
|
||||||
Enforce Tenant Admin middleware and admin session TTL before
|
{query.isFetchingNextPage
|
||||||
surfacing any audit feed. Super Admin role can bypass tenant
|
? t("msg.common.loading", "Loading...")
|
||||||
filter when needed.
|
: t("ui.dev.audit.load_more", "Load more")}
|
||||||
</p>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5">
|
) : null}
|
||||||
<p className="text-xs uppercase tracking-[0.18em] text-[var(--color-muted)]">
|
</CardContent>
|
||||||
Export rules
|
</Card>
|
||||||
</p>
|
|
||||||
<h3 className="mt-1 text-lg font-semibold">
|
|
||||||
Rate-limit sensitive exports
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-[var(--color-muted)]">
|
|
||||||
Keep export endpoints behind admin-only routes with ClickHouse
|
|
||||||
query limits. Log download attempts with IP, role, and tenant
|
|
||||||
scope.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ export default function AuthCallbackPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 팝업으로 열린 경우 signinPopupCallback 처리
|
// 팝업으로 열린 경우 signinPopupCallback 처리
|
||||||
if (window.opener) {
|
if (window.opener) {
|
||||||
userManager.signinPopupCallback();
|
userManager.signinPopupCallback().catch((error) => {
|
||||||
|
console.error("Popup callback failed:", error);
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Navigate, Outlet } from "react-router-dom";
|
|||||||
export default function AuthGuard() {
|
export default function AuthGuard() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
|
||||||
if (auth.isLoading) {
|
if (auth.isLoading || auth.activeNavigator) {
|
||||||
return <div>Loading...</div>;
|
return <div>Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
|
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
|
||||||
|
import { useEffect } from "react";
|
||||||
import { useAuth } from "react-oidc-context";
|
import { useAuth } from "react-oidc-context";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
@@ -14,10 +15,15 @@ function LoginPage() {
|
|||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (auth.isAuthenticated) {
|
||||||
|
navigate("/clients", { replace: true });
|
||||||
|
}
|
||||||
|
}, [auth.isAuthenticated, navigate]);
|
||||||
|
|
||||||
const handleSSOLogin = async () => {
|
const handleSSOLogin = async () => {
|
||||||
try {
|
try {
|
||||||
await auth.signinPopup();
|
await auth.signinPopup();
|
||||||
navigate("/clients", { replace: true });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Popup login failed", error);
|
console.error("Popup login failed", error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Download,
|
||||||
Filter,
|
Filter,
|
||||||
Search,
|
Search,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -275,6 +276,7 @@ function ClientConsentsPage() {
|
|||||||
onClick={handleExportCSV}
|
onClick={handleExportCSV}
|
||||||
disabled={filteredRows.length === 0}
|
disabled={filteredRows.length === 0}
|
||||||
>
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
{t("ui.dev.clients.consents.export_csv", "Export CSV")}
|
{t("ui.dev.clients.consents.export_csv", "Export CSV")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
deleteClient,
|
deleteClient,
|
||||||
fetchClient,
|
fetchClient,
|
||||||
updateClient,
|
updateClient,
|
||||||
|
updateClientStatus,
|
||||||
} from "../../lib/devApi";
|
} from "../../lib/devApi";
|
||||||
import type {
|
import type {
|
||||||
ClientStatus,
|
ClientStatus,
|
||||||
@@ -63,6 +64,7 @@ function ClientGeneralPage() {
|
|||||||
const [logoUrl, setLogoUrl] = useState("");
|
const [logoUrl, setLogoUrl] = useState("");
|
||||||
const [clientType, setClientType] = useState<ClientType>("private");
|
const [clientType, setClientType] = useState<ClientType>("private");
|
||||||
const [status, setStatus] = useState<ClientStatus>("active");
|
const [status, setStatus] = useState<ClientStatus>("active");
|
||||||
|
const [initialStatus, setInitialStatus] = useState<ClientStatus>("active");
|
||||||
const [redirectUris, setRedirectUris] = useState("");
|
const [redirectUris, setRedirectUris] = useState("");
|
||||||
const [scopes, setScopes] = useState<ScopeItem[]>(() => [
|
const [scopes, setScopes] = useState<ScopeItem[]>(() => [
|
||||||
{
|
{
|
||||||
@@ -91,6 +93,7 @@ function ClientGeneralPage() {
|
|||||||
setName(client.name || client.id);
|
setName(client.name || client.id);
|
||||||
setClientType(client.type);
|
setClientType(client.type);
|
||||||
setStatus(client.status);
|
setStatus(client.status);
|
||||||
|
setInitialStatus(client.status);
|
||||||
|
|
||||||
const metadata = client.metadata ?? {};
|
const metadata = client.metadata ?? {};
|
||||||
if (typeof metadata.description === "string")
|
if (typeof metadata.description === "string")
|
||||||
@@ -158,7 +161,6 @@ function ClientGeneralPage() {
|
|||||||
const payload: ClientUpsertRequest = {
|
const payload: ClientUpsertRequest = {
|
||||||
name,
|
name,
|
||||||
type: clientType,
|
type: clientType,
|
||||||
status,
|
|
||||||
scopes: scopeNames,
|
scopes: scopeNames,
|
||||||
metadata: {
|
metadata: {
|
||||||
description,
|
description,
|
||||||
@@ -169,6 +171,7 @@ function ClientGeneralPage() {
|
|||||||
|
|
||||||
// 생성 시에는 Redirect URIs를 포함해서 전송
|
// 생성 시에는 Redirect URIs를 포함해서 전송
|
||||||
if (isCreate) {
|
if (isCreate) {
|
||||||
|
payload.status = status;
|
||||||
payload.redirectUris = redirectUris
|
payload.redirectUris = redirectUris
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((uri) => uri.trim())
|
.map((uri) => uri.trim())
|
||||||
@@ -176,11 +179,19 @@ function ClientGeneralPage() {
|
|||||||
return createClient(payload);
|
return createClient(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 수정 시에는 Redirect URIs는 별도 탭에서 관리하므로 제외 (빈 배열이나 undefined로 보내지 않음)
|
// 수정 시에는 Redirect URIs는 별도 탭에서 관리하고,
|
||||||
return updateClient(clientId as string, payload);
|
// status는 전용 PATCH API로 처리해서 감사로그 액션을 분리한다.
|
||||||
|
const updated = await updateClient(clientId as string, payload);
|
||||||
|
if (status !== initialStatus) {
|
||||||
|
await updateClientStatus(clientId as string, status);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
},
|
},
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["clients"] });
|
queryClient.invalidateQueries({ queryKey: ["clients"] });
|
||||||
|
if (status !== initialStatus) {
|
||||||
|
setInitialStatus(status);
|
||||||
|
}
|
||||||
if (result?.client?.id) {
|
if (result?.client?.id) {
|
||||||
navigate(`/clients/${result.client.id}/settings`);
|
navigate(`/clients/${result.client.id}/settings`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ShieldHalf,
|
ShieldHalf,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useAuth } from "react-oidc-context";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
@@ -34,15 +35,29 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../components/ui/table";
|
} from "../../components/ui/table";
|
||||||
import { fetchClients } from "../../lib/devApi";
|
import { fetchClients, fetchDevStats } from "../../lib/devApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
function ClientsPage() {
|
function ClientsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data, isLoading, error } = useQuery({
|
const auth = useAuth();
|
||||||
|
const hasAccessToken = Boolean(auth.user?.access_token);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading: isLoadingClients,
|
||||||
|
error: clientError,
|
||||||
|
} = useQuery({
|
||||||
queryKey: ["clients"],
|
queryKey: ["clients"],
|
||||||
queryFn: fetchClients,
|
queryFn: fetchClients,
|
||||||
|
enabled: hasAccessToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: statsData, isLoading: isLoadingStats } = useQuery({
|
||||||
|
queryKey: ["dev-stats"],
|
||||||
|
queryFn: fetchDevStats,
|
||||||
|
enabled: hasAccessToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
@@ -63,11 +78,10 @@ function ClientsPage() {
|
|||||||
return matchesSearch && matchesType && matchesStatus;
|
return matchesSearch && matchesType && matchesStatus;
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalClients = clients.length;
|
const totalClients = statsData?.total_clients ?? clients.length;
|
||||||
const activeClients = clients.filter(
|
const activeSessions = statsData?.active_sessions ?? 0;
|
||||||
(client) => client.status === "active",
|
const authFailures = statsData?.auth_failures_24h ?? 0;
|
||||||
).length;
|
|
||||||
// TODO: Replace with real session/auth-failure metrics when backend endpoints are available.
|
|
||||||
type StatTone = "up" | "down" | "stable";
|
type StatTone = "up" | "down" | "stable";
|
||||||
type StatItem = {
|
type StatItem = {
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
@@ -90,7 +104,7 @@ function ClientsPage() {
|
|||||||
{
|
{
|
||||||
labelKey: "ui.dev.clients.stats.active_sessions",
|
labelKey: "ui.dev.clients.stats.active_sessions",
|
||||||
labelFallback: "Active Sessions",
|
labelFallback: "Active Sessions",
|
||||||
value: activeClients.toString(),
|
value: activeSessions.toString(),
|
||||||
deltaKey: "ui.dev.clients.stats.realtime",
|
deltaKey: "ui.dev.clients.stats.realtime",
|
||||||
deltaFallback: "Realtime",
|
deltaFallback: "Realtime",
|
||||||
tone: "up" as const,
|
tone: "up" as const,
|
||||||
@@ -98,14 +112,19 @@ function ClientsPage() {
|
|||||||
{
|
{
|
||||||
labelKey: "ui.dev.clients.stats.auth_failures",
|
labelKey: "ui.dev.clients.stats.auth_failures",
|
||||||
labelFallback: "Auth Failures (24h)",
|
labelFallback: "Auth Failures (24h)",
|
||||||
value: "0",
|
value: authFailures.toString(),
|
||||||
deltaKey: "ui.dev.clients.stats.stable",
|
deltaKey:
|
||||||
deltaFallback: "Stable",
|
authFailures > 0
|
||||||
tone: "stable" as const,
|
? "ui.dev.clients.stats.alert"
|
||||||
|
: "ui.dev.clients.stats.stable",
|
||||||
|
deltaFallback: authFailures > 0 ? "Check Logs" : "Stable",
|
||||||
|
tone: authFailures > 0 ? ("down" as const) : ("stable" as const),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isLoading) {
|
const isLoading = isLoadingClients || isLoadingStats;
|
||||||
|
|
||||||
|
if (auth.isLoading || !hasAccessToken || isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
{t("msg.dev.clients.loading", "Loading clients...")}
|
{t("msg.dev.clients.loading", "Loading clients...")}
|
||||||
@@ -113,10 +132,10 @@ function ClientsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (clientError) {
|
||||||
const errMsg =
|
const errMsg =
|
||||||
(error as AxiosError<{ error?: string }>).response?.data?.error ??
|
(clientError as AxiosError<{ error?: string }>).response?.data?.error ??
|
||||||
(error as Error).message;
|
(clientError as Error).message;
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center text-red-500">
|
<div className="p-8 text-center text-red-500">
|
||||||
{t("msg.dev.clients.load_error", "Error loading clients: {{error}}", {
|
{t("msg.dev.clients.load_error", "Error loading clients: {{error}}", {
|
||||||
@@ -268,7 +287,13 @@ function ClientsPage() {
|
|||||||
<div className="mt-1 flex items-baseline gap-2">
|
<div className="mt-1 flex items-baseline gap-2">
|
||||||
<span className="text-3xl font-bold">{item.value}</span>
|
<span className="text-3xl font-bold">{item.value}</span>
|
||||||
<Badge
|
<Badge
|
||||||
variant={item.tone === "up" ? "success" : "muted"}
|
variant={
|
||||||
|
item.tone === "up"
|
||||||
|
? "success"
|
||||||
|
: item.tone === "down"
|
||||||
|
? "destructive"
|
||||||
|
: "muted"
|
||||||
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2",
|
"px-2",
|
||||||
item.tone === "stable" && "bg-muted/40 text-foreground",
|
item.tone === "stable" && "bg-muted/40 text-foreground",
|
||||||
|
|||||||
@@ -26,12 +26,16 @@ apiClient.interceptors.request.use(async (config) => {
|
|||||||
|
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
async (error) => {
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
// 401 발생 시 로그인 페이지로 리다이렉트
|
// 401 발생 시 로그인 페이지로 리다이렉트
|
||||||
const isAuthPath = window.location.pathname.startsWith("/callback");
|
const isAuthPath = window.location.pathname.startsWith("/auth/callback");
|
||||||
const isLoginPath = window.location.pathname === "/login";
|
const isLoginPath = window.location.pathname === "/login";
|
||||||
if (!isAuthPath && !isLoginPath) {
|
const user = await userManager.getUser();
|
||||||
|
// 인증 토큰이 없는 경우에만 로그인으로 보낸다.
|
||||||
|
// 토큰이 있는데 401이면 권한/백엔드 정책 이슈로 간주하고 화면에서 에러를 노출한다.
|
||||||
|
const hasAccessToken = Boolean(user?.access_token);
|
||||||
|
if (!hasAccessToken && !isAuthPath && !isLoginPath) {
|
||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,31 @@ export type ClientListResponse = {
|
|||||||
offset: number;
|
offset: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DevStats = {
|
||||||
|
total_clients: number;
|
||||||
|
active_sessions: number;
|
||||||
|
auth_failures_24h: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DevAuditLog = {
|
||||||
|
event_id: string;
|
||||||
|
timestamp: string;
|
||||||
|
user_id: string;
|
||||||
|
event_type: string;
|
||||||
|
status: string;
|
||||||
|
ip_address: string;
|
||||||
|
user_agent: string;
|
||||||
|
device_id?: string;
|
||||||
|
details?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DevAuditLogListResponse = {
|
||||||
|
items: DevAuditLog[];
|
||||||
|
limit: number;
|
||||||
|
cursor?: string;
|
||||||
|
next_cursor?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ClientEndpoints = {
|
export type ClientEndpoints = {
|
||||||
discovery: string;
|
discovery: string;
|
||||||
issuer: string;
|
issuer: string;
|
||||||
@@ -102,6 +127,11 @@ export async function fetchClients() {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchDevStats() {
|
||||||
|
const { data } = await apiClient.get<DevStats>("/dev/stats");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchClient(clientId: string) {
|
export async function fetchClient(clientId: string) {
|
||||||
const { data } = await apiClient.get<ClientDetailResponse>(
|
const { data } = await apiClient.get<ClientDetailResponse>(
|
||||||
`/dev/clients/${clientId}`,
|
`/dev/clients/${clientId}`,
|
||||||
@@ -210,3 +240,29 @@ export async function updateIdpConfig(
|
|||||||
export async function deleteIdpConfig(clientId: string, idpId: string) {
|
export async function deleteIdpConfig(clientId: string, idpId: string) {
|
||||||
await apiClient.delete(`/dev/clients/${clientId}/idps/${idpId}`);
|
await apiClient.delete(`/dev/clients/${clientId}/idps/${idpId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchDevAuditLogs(
|
||||||
|
limit = 50,
|
||||||
|
cursor?: string,
|
||||||
|
filters?: {
|
||||||
|
action?: string;
|
||||||
|
client_id?: string;
|
||||||
|
status?: string;
|
||||||
|
tenant_id?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { data } = await apiClient.get<DevAuditLogListResponse>(
|
||||||
|
"/dev/audit-logs",
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
limit,
|
||||||
|
cursor,
|
||||||
|
action: filters?.action,
|
||||||
|
client_id: filters?.client_id,
|
||||||
|
status: filters?.status,
|
||||||
|
tenant_id: filters?.tenant_id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -207,6 +207,14 @@ unknown_error = "unknown error"
|
|||||||
[msg.dev]
|
[msg.dev]
|
||||||
logout_confirm = "Are you sure you want to log out?"
|
logout_confirm = "Are you sure you want to log out?"
|
||||||
|
|
||||||
|
[msg.dev.audit]
|
||||||
|
empty = "No audit logs found."
|
||||||
|
forbidden = "You do not have permission to view audit logs. Please request access from an administrator."
|
||||||
|
load_error = "Error loading audit logs: {{error}}"
|
||||||
|
loaded_count = "Loaded {{count}} rows"
|
||||||
|
loading = "Loading audit logs..."
|
||||||
|
subtitle = "Shows DevFront activity history within current tenant/app scope."
|
||||||
|
|
||||||
[msg.dev.clients]
|
[msg.dev.clients]
|
||||||
copy_client_id = "Copy Client Id"
|
copy_client_id = "Copy Client Id"
|
||||||
load_error = "Error loading clients: {{error}}"
|
load_error = "Error loading clients: {{error}}"
|
||||||
@@ -941,9 +949,29 @@ env_badge = "Env: dev"
|
|||||||
scope_badge = "Scoped to /dev"
|
scope_badge = "Scoped to /dev"
|
||||||
|
|
||||||
[ui.dev.nav]
|
[ui.dev.nav]
|
||||||
|
audit_logs = "Audit Logs"
|
||||||
clients = "Connected Application"
|
clients = "Connected Application"
|
||||||
logout = "Logout"
|
logout = "Logout"
|
||||||
|
|
||||||
|
[ui.dev.audit]
|
||||||
|
load_more = "Load more"
|
||||||
|
title = "Audit Logs"
|
||||||
|
|
||||||
|
[ui.dev.audit.registry]
|
||||||
|
title = "Audit registry"
|
||||||
|
|
||||||
|
[ui.dev.audit.filter]
|
||||||
|
action = "Filter by Action (e.g. ROTATE_SECRET)"
|
||||||
|
client_id = "Filter by Client ID"
|
||||||
|
status_all = "All Status"
|
||||||
|
|
||||||
|
[ui.dev.audit.table]
|
||||||
|
action = "Action"
|
||||||
|
actor = "Actor"
|
||||||
|
status = "Status"
|
||||||
|
target = "Target"
|
||||||
|
time = "Time"
|
||||||
|
|
||||||
[ui.dev.clients]
|
[ui.dev.clients]
|
||||||
copy_client_id = "Copy client id"
|
copy_client_id = "Copy client id"
|
||||||
new = "Add Connected Application"
|
new = "Add Connected Application"
|
||||||
|
|||||||
@@ -207,6 +207,14 @@ unknown_error = "unknown error"
|
|||||||
[msg.dev]
|
[msg.dev]
|
||||||
logout_confirm = "로그아웃 하시겠습니까?"
|
logout_confirm = "로그아웃 하시겠습니까?"
|
||||||
|
|
||||||
|
[msg.dev.audit]
|
||||||
|
empty = "조회된 감사 로그가 없습니다."
|
||||||
|
forbidden = "감사 로그를 조회할 권한이 없습니다. 관리자에게 권한을 요청해주세요."
|
||||||
|
load_error = "감사 로그 조회 실패: {{error}}"
|
||||||
|
loaded_count = "로드된 로그 {{count}}건"
|
||||||
|
loading = "감사 로그를 불러오는 중..."
|
||||||
|
subtitle = "현재 테넌트/앱 범위의 DevFront 작업 이력을 조회합니다."
|
||||||
|
|
||||||
[msg.dev.clients]
|
[msg.dev.clients]
|
||||||
copy_client_id = "Client ID가 복사되었습니다."
|
copy_client_id = "Client ID가 복사되었습니다."
|
||||||
load_error = "Error loading clients: {{error}}"
|
load_error = "Error loading clients: {{error}}"
|
||||||
@@ -941,9 +949,29 @@ env_badge = "Env: dev"
|
|||||||
scope_badge = "Scoped to /dev"
|
scope_badge = "Scoped to /dev"
|
||||||
|
|
||||||
[ui.dev.nav]
|
[ui.dev.nav]
|
||||||
|
audit_logs = "감사 로그"
|
||||||
clients = "연동 앱"
|
clients = "연동 앱"
|
||||||
logout = "로그아웃"
|
logout = "로그아웃"
|
||||||
|
|
||||||
|
[ui.dev.audit]
|
||||||
|
load_more = "더 보기"
|
||||||
|
title = "감사 로그"
|
||||||
|
|
||||||
|
[ui.dev.audit.registry]
|
||||||
|
title = "Audit registry"
|
||||||
|
|
||||||
|
[ui.dev.audit.filter]
|
||||||
|
action = "액션으로 필터 (예: ROTATE_SECRET)"
|
||||||
|
client_id = "Client ID로 필터"
|
||||||
|
status_all = "모든 상태"
|
||||||
|
|
||||||
|
[ui.dev.audit.table]
|
||||||
|
action = "액션"
|
||||||
|
actor = "수행자"
|
||||||
|
status = "상태"
|
||||||
|
target = "대상"
|
||||||
|
time = "시간"
|
||||||
|
|
||||||
[ui.dev.clients]
|
[ui.dev.clients]
|
||||||
copy_client_id = "Copy client id"
|
copy_client_id = "Copy client id"
|
||||||
new = "연동 앱 추가"
|
new = "연동 앱 추가"
|
||||||
|
|||||||
@@ -207,6 +207,14 @@ unknown_error = ""
|
|||||||
[msg.dev]
|
[msg.dev]
|
||||||
logout_confirm = ""
|
logout_confirm = ""
|
||||||
|
|
||||||
|
[msg.dev.audit]
|
||||||
|
empty = ""
|
||||||
|
forbidden = ""
|
||||||
|
load_error = ""
|
||||||
|
loaded_count = ""
|
||||||
|
loading = ""
|
||||||
|
subtitle = ""
|
||||||
|
|
||||||
[msg.dev.clients]
|
[msg.dev.clients]
|
||||||
copy_client_id = ""
|
copy_client_id = ""
|
||||||
load_error = ""
|
load_error = ""
|
||||||
@@ -953,9 +961,29 @@ env_badge = ""
|
|||||||
scope_badge = ""
|
scope_badge = ""
|
||||||
|
|
||||||
[ui.dev.nav]
|
[ui.dev.nav]
|
||||||
|
audit_logs = ""
|
||||||
clients = ""
|
clients = ""
|
||||||
logout = ""
|
logout = ""
|
||||||
|
|
||||||
|
[ui.dev.audit]
|
||||||
|
load_more = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
[ui.dev.audit.registry]
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
[ui.dev.audit.filter]
|
||||||
|
action = ""
|
||||||
|
client_id = ""
|
||||||
|
status_all = ""
|
||||||
|
|
||||||
|
[ui.dev.audit.table]
|
||||||
|
action = ""
|
||||||
|
actor = ""
|
||||||
|
status = ""
|
||||||
|
target = ""
|
||||||
|
time = ""
|
||||||
|
|
||||||
[ui.dev.clients]
|
[ui.dev.clients]
|
||||||
copy_client_id = ""
|
copy_client_id = ""
|
||||||
new = ""
|
new = ""
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { queryClient } from "./app/queryClient";
|
|||||||
import { router } from "./app/routes";
|
import { router } from "./app/routes";
|
||||||
import { Toaster } from "./components/ui/toaster";
|
import { Toaster } from "./components/ui/toaster";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import { oidcConfig } from "./lib/auth";
|
import { oidcConfig, userManager } from "./lib/auth";
|
||||||
|
|
||||||
const rootElement = document.getElementById("root");
|
const rootElement = document.getElementById("root");
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ if (!rootElement) {
|
|||||||
|
|
||||||
createRoot(rootElement).render(
|
createRoot(rootElement).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<AuthProvider {...oidcConfig}>
|
<AuthProvider {...oidcConfig} userManager={userManager}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|||||||
@@ -273,6 +273,14 @@ unknown_error = "unknown error"
|
|||||||
[msg.dev]
|
[msg.dev]
|
||||||
logout_confirm = "Are you sure you want to log out?"
|
logout_confirm = "Are you sure you want to log out?"
|
||||||
|
|
||||||
|
[msg.dev.audit]
|
||||||
|
empty = "No audit logs found."
|
||||||
|
forbidden = "You do not have permission to view audit logs. Please request access from an administrator."
|
||||||
|
load_error = "Error loading audit logs: {{error}}"
|
||||||
|
loaded_count = "Loaded {{count}} rows"
|
||||||
|
loading = "Loading audit logs..."
|
||||||
|
subtitle = "Shows DevFront activity history within current tenant/app scope."
|
||||||
|
|
||||||
[msg.dev.clients]
|
[msg.dev.clients]
|
||||||
copy_client_id = "Copy Client Id"
|
copy_client_id = "Copy Client Id"
|
||||||
load_error = "Error loading clients: {{error}}"
|
load_error = "Error loading clients: {{error}}"
|
||||||
@@ -1119,9 +1127,29 @@ env_badge = "Env: dev"
|
|||||||
scope_badge = "Scoped to /dev"
|
scope_badge = "Scoped to /dev"
|
||||||
|
|
||||||
[ui.dev.nav]
|
[ui.dev.nav]
|
||||||
|
audit_logs = "Audit Logs"
|
||||||
clients = "Connected Application"
|
clients = "Connected Application"
|
||||||
logout = "Logout"
|
logout = "Logout"
|
||||||
|
|
||||||
|
[ui.dev.audit]
|
||||||
|
load_more = "Load more"
|
||||||
|
title = "Audit Logs"
|
||||||
|
|
||||||
|
[ui.dev.audit.registry]
|
||||||
|
title = "Audit registry"
|
||||||
|
|
||||||
|
[ui.dev.audit.filter]
|
||||||
|
action = "Filter by Action (e.g. ROTATE_SECRET)"
|
||||||
|
client_id = "Filter by Client ID"
|
||||||
|
status_all = "All Status"
|
||||||
|
|
||||||
|
[ui.dev.audit.table]
|
||||||
|
action = "Action"
|
||||||
|
actor = "Actor"
|
||||||
|
status = "Status"
|
||||||
|
target = "Target"
|
||||||
|
time = "Time"
|
||||||
|
|
||||||
[ui.dev.profile]
|
[ui.dev.profile]
|
||||||
menu_aria = "Open account menu"
|
menu_aria = "Open account menu"
|
||||||
menu_title = "Account"
|
menu_title = "Account"
|
||||||
|
|||||||
@@ -273,6 +273,14 @@ unknown_error = "unknown error"
|
|||||||
[msg.dev]
|
[msg.dev]
|
||||||
logout_confirm = "로그아웃 하시겠습니까?"
|
logout_confirm = "로그아웃 하시겠습니까?"
|
||||||
|
|
||||||
|
[msg.dev.audit]
|
||||||
|
empty = "조회된 감사 로그가 없습니다."
|
||||||
|
forbidden = "감사 로그를 조회할 권한이 없습니다. 관리자에게 권한을 요청해주세요."
|
||||||
|
load_error = "감사 로그 조회 실패: {{error}}"
|
||||||
|
loaded_count = "로드된 로그 {{count}}건"
|
||||||
|
loading = "감사 로그를 불러오는 중..."
|
||||||
|
subtitle = "현재 테넌트/앱 범위의 DevFront 작업 이력을 조회합니다."
|
||||||
|
|
||||||
[msg.dev.clients]
|
[msg.dev.clients]
|
||||||
copy_client_id = "Client ID가 복사되었습니다."
|
copy_client_id = "Client ID가 복사되었습니다."
|
||||||
load_error = "Error loading clients: {{error}}"
|
load_error = "Error loading clients: {{error}}"
|
||||||
@@ -1119,9 +1127,29 @@ env_badge = "Env: dev"
|
|||||||
scope_badge = "Scoped to /dev"
|
scope_badge = "Scoped to /dev"
|
||||||
|
|
||||||
[ui.dev.nav]
|
[ui.dev.nav]
|
||||||
|
audit_logs = "감사 로그"
|
||||||
clients = "연동 앱"
|
clients = "연동 앱"
|
||||||
logout = "로그아웃"
|
logout = "로그아웃"
|
||||||
|
|
||||||
|
[ui.dev.audit]
|
||||||
|
load_more = "더 보기"
|
||||||
|
title = "감사 로그"
|
||||||
|
|
||||||
|
[ui.dev.audit.registry]
|
||||||
|
title = "Audit registry"
|
||||||
|
|
||||||
|
[ui.dev.audit.filter]
|
||||||
|
action = "액션으로 필터 (예: ROTATE_SECRET)"
|
||||||
|
client_id = "Client ID로 필터"
|
||||||
|
status_all = "모든 상태"
|
||||||
|
|
||||||
|
[ui.dev.audit.table]
|
||||||
|
action = "액션"
|
||||||
|
actor = "수행자"
|
||||||
|
status = "상태"
|
||||||
|
target = "대상"
|
||||||
|
time = "시간"
|
||||||
|
|
||||||
[ui.dev.profile]
|
[ui.dev.profile]
|
||||||
menu_aria = "계정 메뉴 열기"
|
menu_aria = "계정 메뉴 열기"
|
||||||
menu_title = "계정"
|
menu_title = "계정"
|
||||||
|
|||||||
@@ -213,6 +213,14 @@ unknown_error = ""
|
|||||||
[msg.dev]
|
[msg.dev]
|
||||||
logout_confirm = ""
|
logout_confirm = ""
|
||||||
|
|
||||||
|
[msg.dev.audit]
|
||||||
|
empty = ""
|
||||||
|
forbidden = ""
|
||||||
|
load_error = ""
|
||||||
|
loaded_count = ""
|
||||||
|
loading = ""
|
||||||
|
subtitle = ""
|
||||||
|
|
||||||
[msg.dev.clients]
|
[msg.dev.clients]
|
||||||
load_error = ""
|
load_error = ""
|
||||||
loading = ""
|
loading = ""
|
||||||
@@ -984,9 +992,29 @@ env_badge = ""
|
|||||||
scope_badge = ""
|
scope_badge = ""
|
||||||
|
|
||||||
[ui.dev.nav]
|
[ui.dev.nav]
|
||||||
|
audit_logs = ""
|
||||||
clients = ""
|
clients = ""
|
||||||
logout = ""
|
logout = ""
|
||||||
|
|
||||||
|
[ui.dev.audit]
|
||||||
|
load_more = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
[ui.dev.audit.registry]
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
[ui.dev.audit.filter]
|
||||||
|
action = ""
|
||||||
|
client_id = ""
|
||||||
|
status_all = ""
|
||||||
|
|
||||||
|
[ui.dev.audit.table]
|
||||||
|
action = ""
|
||||||
|
actor = ""
|
||||||
|
status = ""
|
||||||
|
target = ""
|
||||||
|
time = ""
|
||||||
|
|
||||||
[ui.dev.profile]
|
[ui.dev.profile]
|
||||||
menu_aria = ""
|
menu_aria = ""
|
||||||
menu_title = ""
|
menu_title = ""
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
|||||||
await expect.poll(() => state.getMeCount).toBeGreaterThan(getCountBeforeReload);
|
await expect.poll(() => state.getMeCount).toBeGreaterThan(getCountBeforeReload);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('재현: 소속 입력만 하고 즉시 새로고침하면 저장 요청이 전송되지 않는다', async ({
|
test('소속 입력 후 즉시 새로고침해도 저장 요청이 중복 전송되지 않는다', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const state: ProfileState = {
|
const state: ProfileState = {
|
||||||
@@ -200,7 +200,12 @@ test.describe('UserFront WASM profile department editing', () => {
|
|||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page).toHaveURL(/\/ko\/profile$/);
|
await expect(page).toHaveURL(/\/ko\/profile$/);
|
||||||
expect(state.putBodies).toHaveLength(0);
|
expect(state.putBodies.length).toBeLessThanOrEqual(1);
|
||||||
|
if (state.putBodies.length > 0) {
|
||||||
|
expect(state.putBodies[0]?.department).toBe('QA-Repro');
|
||||||
|
expect(state.department).toBe('QA-Repro');
|
||||||
|
return;
|
||||||
|
}
|
||||||
expect(state.department).toBe('QA');
|
expect(state.department).toBe('QA');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -293,6 +293,8 @@ title = ""
|
|||||||
|
|
||||||
[ui.common]
|
[ui.common]
|
||||||
add = ""
|
add = ""
|
||||||
|
admin_only = ""
|
||||||
|
assign = ""
|
||||||
back = ""
|
back = ""
|
||||||
cancel = ""
|
cancel = ""
|
||||||
close = ""
|
close = ""
|
||||||
@@ -309,6 +311,7 @@ manage = ""
|
|||||||
na = ""
|
na = ""
|
||||||
never = ""
|
never = ""
|
||||||
next = ""
|
next = ""
|
||||||
|
none = ""
|
||||||
page_of = ""
|
page_of = ""
|
||||||
prev = ""
|
prev = ""
|
||||||
previous = ""
|
previous = ""
|
||||||
@@ -322,6 +325,8 @@ resend = ""
|
|||||||
retry = ""
|
retry = ""
|
||||||
save = ""
|
save = ""
|
||||||
search = ""
|
search = ""
|
||||||
|
select = ""
|
||||||
|
select_placeholder = ""
|
||||||
show_more = ""
|
show_more = ""
|
||||||
language = ""
|
language = ""
|
||||||
language_ko = ""
|
language_ko = ""
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import 'login_challenge_loop_guard_base.dart';
|
||||||
|
import 'login_challenge_loop_guard_stub.dart'
|
||||||
|
if (dart.library.js_interop) 'login_challenge_loop_guard_web.dart';
|
||||||
|
|
||||||
|
final loginChallengeLoopGuard = createLoginChallengeLoopGuard();
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
abstract class LoginChallengeLoopGuard {
|
||||||
|
bool shouldAllowAutoAccept(String loginChallenge, {int cooldownMs = 15000});
|
||||||
|
void markAutoAcceptAttempt(String loginChallenge);
|
||||||
|
void clear(String loginChallenge);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import 'login_challenge_loop_guard_base.dart';
|
||||||
|
|
||||||
|
class _InMemoryLoginChallengeLoopGuard implements LoginChallengeLoopGuard {
|
||||||
|
final Map<String, int> _lastAttemptAtMs = <String, int>{};
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldAllowAutoAccept(String loginChallenge, {int cooldownMs = 15000}) {
|
||||||
|
final challenge = loginChallenge.trim();
|
||||||
|
if (challenge.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final nowMs = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final lastMs = _lastAttemptAtMs[challenge];
|
||||||
|
if (lastMs == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return nowMs - lastMs > cooldownMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void markAutoAcceptAttempt(String loginChallenge) {
|
||||||
|
final challenge = loginChallenge.trim();
|
||||||
|
if (challenge.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_lastAttemptAtMs[challenge] = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void clear(String loginChallenge) {
|
||||||
|
_lastAttemptAtMs.remove(loginChallenge.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LoginChallengeLoopGuard createLoginChallengeLoopGuard() {
|
||||||
|
return _InMemoryLoginChallengeLoopGuard();
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// ignore_for_file: avoid_web_libraries_in_flutter
|
||||||
|
|
||||||
|
import 'dart:js_interop';
|
||||||
|
import 'login_challenge_loop_guard_base.dart';
|
||||||
|
|
||||||
|
@JS('window.sessionStorage')
|
||||||
|
external _JSStorage get _sessionStorage;
|
||||||
|
|
||||||
|
@JS()
|
||||||
|
extension type _JSStorage(JSObject _) implements JSObject {
|
||||||
|
external String? getItem(String key);
|
||||||
|
external void setItem(String key, String value);
|
||||||
|
external void removeItem(String key);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WebLoginChallengeLoopGuard implements LoginChallengeLoopGuard {
|
||||||
|
static const String _keyPrefix = 'baron_oidc_auto_accept_last:';
|
||||||
|
|
||||||
|
String _key(String challenge) => '$_keyPrefix$challenge';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldAllowAutoAccept(String loginChallenge, {int cooldownMs = 15000}) {
|
||||||
|
final challenge = loginChallenge.trim();
|
||||||
|
if (challenge.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final raw = _sessionStorage.getItem(_key(challenge));
|
||||||
|
if (raw == null || raw.isEmpty) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
final lastMs = int.tryParse(raw);
|
||||||
|
if (lastMs == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
final nowMs = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
return nowMs - lastMs > cooldownMs;
|
||||||
|
} catch (_) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void markAutoAcceptAttempt(String loginChallenge) {
|
||||||
|
final challenge = loginChallenge.trim();
|
||||||
|
if (challenge.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final nowMs = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
_sessionStorage.setItem(_key(challenge), nowMs.toString());
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void clear(String loginChallenge) {
|
||||||
|
final challenge = loginChallenge.trim();
|
||||||
|
if (challenge.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
_sessionStorage.removeItem(_key(challenge));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LoginChallengeLoopGuard createLoginChallengeLoopGuard() {
|
||||||
|
return _WebLoginChallengeLoopGuard();
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import '../../../core/widgets/language_selector.dart';
|
|||||||
import '../../../core/services/web_auth_integration.dart';
|
import '../../../core/services/web_auth_integration.dart';
|
||||||
import '../../../core/services/auth_proxy_service.dart';
|
import '../../../core/services/auth_proxy_service.dart';
|
||||||
import '../../../core/services/auth_token_store.dart';
|
import '../../../core/services/auth_token_store.dart';
|
||||||
|
import '../../../core/services/login_challenge_loop_guard.dart';
|
||||||
import '../../../core/i18n/locale_utils.dart';
|
import '../../../core/i18n/locale_utils.dart';
|
||||||
import '../../../core/services/oidc_redirect_guard.dart';
|
import '../../../core/services/oidc_redirect_guard.dart';
|
||||||
import '../../../core/notifiers/auth_notifier.dart';
|
import '../../../core/notifiers/auth_notifier.dart';
|
||||||
@@ -143,7 +144,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
if (!_verificationOnly) {
|
if (!_verificationOnly) {
|
||||||
await _attemptOidcAutoAccept();
|
await _attemptOidcAutoAccept();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await _tryCookieSession();
|
// login_challenge 흐름에서는 auto-accept에서 이미 쿠키 세션까지 확인하므로
|
||||||
|
// 동일 프레임에서 중복 체크를 피합니다.
|
||||||
|
if (!_hasLoginChallenge) {
|
||||||
|
await _tryCookieSession();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -239,11 +244,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
if (loginChallenge == null || loginChallenge.isEmpty) {
|
if (loginChallenge == null || loginChallenge.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!loginChallengeLoopGuard.shouldAllowAutoAccept(loginChallenge)) {
|
||||||
|
debugPrint(
|
||||||
|
"[Auth] OIDC auto-accept blocked by loop guard for login_challenge",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loginChallengeLoopGuard.markAutoAcceptAttempt(loginChallenge);
|
||||||
|
|
||||||
final token = AuthTokenStore.getToken();
|
final token = AuthTokenStore.getToken();
|
||||||
if (token != null && token.isNotEmpty) {
|
if (token != null && token.isNotEmpty) {
|
||||||
final accepted = await _acceptOidcLoginAndRedirect(token: token);
|
final accepted = await _acceptOidcLoginAndRedirect(token: token);
|
||||||
if (accepted) {
|
if (accepted) {
|
||||||
|
loginChallengeLoopGuard.clear(loginChallenge);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,7 +268,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
AuthTokenStore.setCookieMode(
|
AuthTokenStore.setCookieMode(
|
||||||
provider: AuthTokenStore.getProvider() ?? 'ory',
|
provider: AuthTokenStore.getProvider() ?? 'ory',
|
||||||
);
|
);
|
||||||
await _acceptOidcLoginAndRedirect();
|
final accepted = await _acceptOidcLoginAndRedirect();
|
||||||
|
if (accepted) {
|
||||||
|
loginChallengeLoopGuard.clear(loginChallenge);
|
||||||
|
return;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
"[Auth] OIDC auto-accept: No active session (status: $status)",
|
"[Auth] OIDC auto-accept: No active session (status: $status)",
|
||||||
@@ -1216,6 +1233,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
final nextRedirectTo = res['redirectTo'] as String?;
|
final nextRedirectTo = res['redirectTo'] as String?;
|
||||||
|
|
||||||
if (nextRedirectTo != null && nextRedirectTo.isNotEmpty) {
|
if (nextRedirectTo != null && nextRedirectTo.isNotEmpty) {
|
||||||
|
loginChallengeLoopGuard.clear(loginChallenge);
|
||||||
webWindow.redirectTo(nextRedirectTo); // Removed await
|
webWindow.redirectTo(nextRedirectTo); // Removed await
|
||||||
return;
|
return;
|
||||||
} else {}
|
} else {}
|
||||||
|
|||||||
27
userfront/test/login_challenge_loop_guard_test.dart
Normal file
27
userfront/test/login_challenge_loop_guard_test.dart
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:userfront/core/services/login_challenge_loop_guard.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('login_challenge_loop_guard', () {
|
||||||
|
test('mark 이후 cooldown 내 재시도는 차단되고 clear 후 허용된다', () {
|
||||||
|
const challenge = 'loop-guard-test-challenge';
|
||||||
|
loginChallengeLoopGuard.clear(challenge);
|
||||||
|
|
||||||
|
expect(loginChallengeLoopGuard.shouldAllowAutoAccept(challenge), isTrue);
|
||||||
|
|
||||||
|
loginChallengeLoopGuard.markAutoAcceptAttempt(challenge);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
loginChallengeLoopGuard.shouldAllowAutoAccept(
|
||||||
|
challenge,
|
||||||
|
cooldownMs: 60000,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
|
||||||
|
loginChallengeLoopGuard.clear(challenge);
|
||||||
|
|
||||||
|
expect(loginChallengeLoopGuard.shouldAllowAutoAccept(challenge), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user