1
0
forked from baron/baron-sso

ory용 MCP 제작, devfront/adminfront 백엔드 연결

This commit is contained in:
Lectom C Han
2026-01-28 10:57:22 +09:00
parent 1aaa772907
commit 93cab064fc
75 changed files with 7327 additions and 454 deletions

View File

@@ -1,6 +1,7 @@
package domain
import (
"context"
"time"
)
@@ -19,5 +20,6 @@ type AuditLog struct {
// AuditRepository defines interface for storing logs
type AuditRepository interface {
Create(log *AuditLog) error
// FindAll(filter Filter) ([]*AuditLog, error) // Future scope
FindAll(ctx context.Context, limit, offset int) ([]AuditLog, error)
Ping(ctx context.Context) error
}

View File

@@ -46,3 +46,22 @@ func (h *AuditHandler) CreateLog(c *fiber.Ctx) error {
"message": "Audit log saved",
})
}
// ListLogs handles GET /api/v1/audit
func (h *AuditHandler) ListLogs(c *fiber.Ctx) error {
limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0)
logs, err := h.repo.FindAll(c.Context(), limit, offset)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Failed to retrieve audit logs",
})
}
return c.JSON(fiber.Map{
"items": logs,
"limit": limit,
"offset": offset,
})
}

View File

@@ -0,0 +1,241 @@
package handler
import (
"baron-sso-backend/internal/service"
"errors"
"strings"
"time"
"github.com/gofiber/fiber/v2"
)
type DevHandler struct {
Hydra *service.HydraAdminService
}
func NewDevHandler() *DevHandler {
return &DevHandler{
Hydra: service.NewHydraAdminService(),
}
}
type clientSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
CreatedAt *time.Time `json:"createdAt,omitempty"`
RedirectURIs []string `json:"redirectUris"`
Scopes []string `json:"scopes"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
type clientListResponse struct {
Items []clientSummary `json:"items"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
type clientDetailResponse struct {
Client clientSummary `json:"client"`
Endpoints clientEndpoints `json:"endpoints"`
}
type clientEndpoints struct {
Discovery string `json:"discovery"`
Issuer string `json:"issuer"`
Authorization string `json:"authorization"`
Token string `json:"token"`
UserInfo string `json:"userinfo"`
}
type consentSummary struct {
Subject string `json:"subject"`
ClientID string `json:"clientId"`
ClientName string `json:"clientName,omitempty"`
GrantedScopes []string `json:"grantedScopes"`
AuthenticatedAt string `json:"authenticatedAt,omitempty"`
}
type consentListResponse struct {
Items []consentSummary `json:"items"`
}
func (h *DevHandler) ListClients(c *fiber.Ctx) error {
limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0)
if limit <= 0 {
limit = 50
}
if offset < 0 {
offset = 0
}
clients, err := h.Hydra.ListClients(c.Context(), limit, offset)
if err != nil {
if errors.Is(err, service.ErrHydraNotFound) {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "clients not found"})
}
errMsg := err.Error()
if strings.Contains(errMsg, "connection refused") || strings.Contains(errMsg, "dial tcp") {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"error": "Hydra service is unavailable. Please check if Ory Hydra is running.",
})
}
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": errMsg})
}
items := make([]clientSummary, 0, len(clients))
for _, client := range clients {
items = append(items, mapClientSummary(client))
}
return c.JSON(clientListResponse{
Items: items,
Limit: limit,
Offset: offset,
})
}
func (h *DevHandler) GetClient(c *fiber.Ctx) error {
clientID := c.Params("id")
if clientID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"})
}
client, err := h.Hydra.GetClient(c.Context(), clientID)
if err != nil {
if errors.Is(err, service.ErrHydraNotFound) {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"})
}
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
summary := mapClientSummary(*client)
return c.JSON(clientDetailResponse{
Client: summary,
Endpoints: clientEndpoints{
Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration",
Issuer: h.Hydra.PublicURL,
Authorization: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/auth",
Token: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/token",
UserInfo: strings.TrimRight(h.Hydra.PublicURL, "/") + "/userinfo",
},
})
}
func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
clientID := c.Params("id")
if clientID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"})
}
var req struct {
Status string `json:"status"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
}
status := strings.ToLower(strings.TrimSpace(req.Status))
if status != "active" && status != "inactive" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "status must be active or inactive"})
}
updated, err := h.Hydra.PatchClientStatus(c.Context(), clientID, status)
if err != nil {
if errors.Is(err, service.ErrHydraNotFound) {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"})
}
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
summary := mapClientSummary(*updated)
return c.JSON(clientDetailResponse{
Client: summary,
Endpoints: clientEndpoints{
Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration",
Issuer: h.Hydra.PublicURL,
Authorization: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/auth",
Token: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/token",
UserInfo: strings.TrimRight(h.Hydra.PublicURL, "/") + "/userinfo",
},
})
}
func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
subject := strings.TrimSpace(c.Query("subject"))
if subject == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "subject is required"})
}
clientID := strings.TrimSpace(c.Query("client_id"))
sessions, err := h.Hydra.ListConsentSessions(c.Context(), subject, clientID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
items := make([]consentSummary, 0, len(sessions))
for _, session := range sessions {
authAt := ""
if session.AuthenticatedAt != nil {
authAt = session.AuthenticatedAt.Format(time.RFC3339)
} else if session.RequestedAt != nil {
authAt = session.RequestedAt.Format(time.RFC3339)
}
items = append(items, consentSummary{
Subject: session.Subject,
ClientID: session.Client.ClientID,
ClientName: session.Client.ClientName,
GrantedScopes: session.GrantedScope,
AuthenticatedAt: authAt,
})
}
return c.JSON(consentListResponse{Items: items})
}
func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error {
subject := strings.TrimSpace(c.Query("subject"))
if subject == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "subject is required"})
}
clientID := strings.TrimSpace(c.Query("client_id"))
if err := h.Hydra.RevokeConsentSessions(c.Context(), subject, clientID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.SendStatus(fiber.StatusNoContent)
}
func mapClientSummary(client service.HydraClient) clientSummary {
status := "active"
if client.Metadata != nil {
if value, ok := client.Metadata["status"].(string); ok && strings.ToLower(value) == "inactive" {
status = "inactive"
}
}
clientType := "confidential"
if strings.EqualFold(client.TokenEndpointAuthMethod, "none") {
clientType = "public"
}
name := strings.TrimSpace(client.ClientName)
if name == "" {
name = client.ClientID
}
scopes := strings.Fields(client.Scope)
return clientSummary{
ID: client.ClientID,
Name: name,
Type: clientType,
Status: status,
RedirectURIs: client.RedirectURIs,
Scopes: scopes,
Metadata: client.Metadata,
}
}

View File

@@ -78,6 +78,46 @@ func (r *ClickHouseRepository) Create(log *domain.AuditLog) error {
)
}
func (r *ClickHouseRepository) FindAll(ctx context.Context, limit, offset int) ([]domain.AuditLog, error) {
if limit <= 0 {
limit = 50
}
if offset < 0 {
offset = 0
}
query := `
SELECT timestamp, user_id, event_type, status, ip_address, user_agent, device_id, details
FROM audit_logs
ORDER BY timestamp DESC
LIMIT ? OFFSET ?
`
rows, err := r.conn.Query(ctx, query, limit, offset)
if err != nil {
return nil, fmt.Errorf("failed to query audit logs: %w", err)
}
defer rows.Close()
var logs []domain.AuditLog
for rows.Next() {
var log domain.AuditLog
if err := rows.Scan(
&log.Timestamp,
&log.UserID,
&log.EventType,
&log.Status,
&log.IPAddress,
&log.UserAgent,
&log.DeviceID,
&log.Details,
); err != nil {
return nil, fmt.Errorf("failed to scan audit log: %w", err)
}
logs = append(logs, log)
}
return logs, nil
}
func (r *ClickHouseRepository) Ping(ctx context.Context) error {
if r.conn == nil {
return fmt.Errorf("clickhouse connection is nil")

View File

@@ -0,0 +1,265 @@
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
var ErrHydraNotFound = errors.New("hydra admin: resource not found")
// HydraAdminService는 Hydra Admin API 호출을 래핑합니다.
type HydraAdminService struct {
AdminURL string
PublicURL string
HTTPClient *http.Client
}
type HydraClient struct {
ClientID string `json:"client_id"`
ClientName string `json:"client_name,omitempty"`
RedirectURIs []string `json:"redirect_uris,omitempty"`
GrantTypes []string `json:"grant_types,omitempty"`
ResponseTypes []string `json:"response_types,omitempty"`
Scope string `json:"scope,omitempty"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
type HydraConsentSession struct {
Subject string `json:"subject"`
GrantedScope []string `json:"granted_scope"`
GrantedAudience []string `json:"granted_audience,omitempty"`
Remember bool `json:"remember"`
AuthenticatedAt *time.Time `json:"authenticated_at,omitempty"`
RequestedAt *time.Time `json:"requested_at,omitempty"`
Client HydraClient `json:"client"`
}
func NewHydraAdminService() *HydraAdminService {
return &HydraAdminService{
AdminURL: getenv("HYDRA_ADMIN_URL", "http://hydra:4445"),
PublicURL: getenv("HYDRA_PUBLIC_URL", "http://hydra:4444"),
}
}
func (s *HydraAdminService) ListClients(ctx context.Context, limit, offset int) ([]HydraClient, error) {
endpoint, err := s.buildURL("/clients", map[string]int{
"limit": limit,
"offset": offset,
})
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
resp, err := s.httpClient().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, ErrHydraNotFound
}
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return nil, fmt.Errorf("hydra admin: list clients failed status=%d body=%s", resp.StatusCode, string(body))
}
var clients []HydraClient
if err := json.NewDecoder(resp.Body).Decode(&clients); err != nil {
return nil, fmt.Errorf("hydra admin: decode clients failed: %w", err)
}
return clients, nil
}
func (s *HydraAdminService) GetClient(ctx context.Context, clientID string) (*HydraClient, error) {
endpoint := fmt.Sprintf("%s/clients/%s", strings.TrimRight(s.AdminURL, "/"), url.PathEscape(clientID))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
resp, err := s.httpClient().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, ErrHydraNotFound
}
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return nil, fmt.Errorf("hydra admin: get client failed status=%d body=%s", resp.StatusCode, string(body))
}
var client HydraClient
if err := json.NewDecoder(resp.Body).Decode(&client); err != nil {
return nil, fmt.Errorf("hydra admin: decode client failed: %w", err)
}
return &client, nil
}
func (s *HydraAdminService) PatchClientStatus(ctx context.Context, clientID, status string) (*HydraClient, error) {
payload := map[string]interface{}{
"metadata": map[string]interface{}{
"status": status,
},
}
body, _ := json.Marshal(payload)
endpoint := fmt.Sprintf("%s/clients/%s", strings.TrimRight(s.AdminURL, "/"), url.PathEscape(clientID))
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/merge-patch+json")
resp, err := s.httpClient().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, ErrHydraNotFound
}
if resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return nil, fmt.Errorf("hydra admin: patch client failed status=%d body=%s", resp.StatusCode, string(respBody))
}
var updated HydraClient
if err := json.NewDecoder(resp.Body).Decode(&updated); err != nil {
return nil, fmt.Errorf("hydra admin: decode patched client failed: %w", err)
}
return &updated, nil
}
func (s *HydraAdminService) ListConsentSessions(ctx context.Context, subject, clientID string) ([]HydraConsentSession, error) {
params := map[string]string{
"subject": subject,
}
if clientID != "" {
params["client"] = clientID
}
endpoint, err := s.buildURLWithParams("/oauth2/auth/sessions/consent", params)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
resp, err := s.httpClient().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return nil, fmt.Errorf("hydra admin: list consent sessions failed status=%d body=%s", resp.StatusCode, string(body))
}
var sessions []HydraConsentSession
if err := json.NewDecoder(resp.Body).Decode(&sessions); err != nil {
return nil, fmt.Errorf("hydra admin: decode consent sessions failed: %w", err)
}
return sessions, nil
}
func (s *HydraAdminService) RevokeConsentSessions(ctx context.Context, subject, clientID string) error {
params := map[string]string{
"subject": subject,
}
if clientID != "" {
params["client"] = clientID
}
endpoint, err := s.buildURLWithParams("/oauth2/auth/sessions/consent", params)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
if err != nil {
return err
}
resp, err := s.httpClient().Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("hydra admin: revoke consent failed status=%d body=%s", resp.StatusCode, string(body))
}
return nil
}
func (s *HydraAdminService) httpClient() *http.Client {
if s.HTTPClient != nil {
return s.HTTPClient
}
return &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
},
}
}
func (s *HydraAdminService) buildURL(path string, ints map[string]int) (string, error) {
base := strings.TrimRight(s.AdminURL, "/")
u, err := url.Parse(base + path)
if err != nil {
return "", err
}
q := u.Query()
for key, value := range ints {
if value > 0 {
q.Set(key, strconv.Itoa(value))
}
}
u.RawQuery = q.Encode()
return u.String(), nil
}
func (s *HydraAdminService) buildURLWithParams(path string, params map[string]string) (string, error) {
base := strings.TrimRight(s.AdminURL, "/")
u, err := url.Parse(base + path)
if err != nil {
return "", err
}
q := u.Query()
for key, value := range params {
if value != "" {
q.Set(key, value)
}
}
u.RawQuery = q.Encode()
return u.String(), nil
}