Skip to content
Merged
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ See mc-admin-cli's [README](https://github.com/m-cmp/mc-admin-cli#readme) (Quick

mc-web-console runs as **two separate Echo servers**: `api/` (backend API / proxy to mc-iam-manager, mc-infra-manager, and the other microservices, default port 3000) and `front/` (HTML templates + static assets, default port 3001).

**API access** — `api/` (3000) is a public API, not only a backend for `front/`: other frameworks and clients sign in with `POST /api/auth/login` and call the services with the returned `access_token`. Every `api/` route except `/readyz` and `/api/auth/{login,refresh,signup}` requires that token (`Authorization: Bearer <token>`); requests without a valid token get `401`. With `MC_WEB_CONSOLE_USE_IAM=true` the token is verified against mc-iam-manager's `/api/auth/certs`, otherwise against the local `MC_WEB_CONSOLE_JWT_SECRET`. Browsers use `front/` (3001), which forwards `/api/*` to `api/` with the signed-in user's token.

**Prerequisites**
- Go 1.25+ ([install guide](https://go.dev/doc/install))
- Node.js (for `front/`'s webpack build)
Expand Down
28 changes: 15 additions & 13 deletions api/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,16 @@ func main() {
authProtected.POST("/logout", handler.Logout)
authProtected.GET("/userinfo", handler.UserInfo)

// 이하 라우트는 front(ApiCaller)를 거치지 않고 API 포트로 직접 호출될 수 있으므로
// 요청마다 토큰을 검증한다 (front의 IsTokenExistMiddleware는 쿠키 존재만 확인).
protect := middleware.ProxyAuthMiddleware

// 단일 세그먼트 내부 핸들러
api.POST("/disklookup", handler.DiskLookup)
api.POST("/getapihosts", handler.GetApiHosts)
api.POST("/disklookup", handler.DiskLookup, protect)
api.POST("/getapihosts", handler.GetApiHosts, protect)

// Async request history (WEB-TECH-017) — must register before subsystem wildcard
asyncReq := api.Group("/async-requests")
asyncReq := api.Group("/async-requests", protect)
asyncReq.GET("", handler.ListAsyncRequests)
asyncReq.DELETE("", handler.ClearFinishedAsyncRequests)
asyncReq.PATCH("/:requestId", handler.PatchAsyncRequest)
Expand All @@ -135,22 +139,21 @@ func main() {

// 관리자 전용 BFF 라우트 (와일드카드보다 먼저 등록되어야 정적 매칭됨)
// FR-CLOUD-ADMIN-006-08: 외부 raw YAML 도달성 확인 (CORS 우회 + 토큰 노출 방지)
// 인증 모델: SubsystemAnyController와 동일 — front Buffalo의 IsTokenExistMiddleware(cookie 검증)
// 가 1차 게이트 역할을 하므로 BFF는 별도 JWT 검증을 수행하지 않는다.
adminBFF := api.Group("/admin")
adminBFF := api.Group("/admin", protect)
adminBFF.GET("/setup-yaml-check", handler.GetSetupYamlCheck)

// 서브시스템 프록시 라우트 (Buffalo SubsystemAnyController 호환)
// POST /api/:subsystemName/:operationId → conf/api.yaml 기반으로 백엔드 서비스에 프록시
api.Any("/:subsystemName/:operationId", handler.SubsystemAnyController)
api.Any("/:subsystemName/:operationId", handler.SubsystemAnyController, protect)

// 테스트 엔드포인트들
// 테스트 엔드포인트들 (generate-token이 로컬 서명 토큰을 발급하므로 함께 보호)
test := api.Group("", protect)
// @Summary Hello
// @Tags test
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /api/hello [get]
api.GET("/hello", func(c echo.Context) error {
test.GET("/hello", func(c echo.Context) error {
requestID := middleware.GetRequestID(c)
return c.JSON(200, map[string]interface{}{
"message": "Hello from Echo API!",
Expand All @@ -159,7 +162,7 @@ func main() {
})
})

api.GET("/test/response", func(c echo.Context) error {
test.GET("/test/response", func(c echo.Context) error {
resp := model.CommonResponseStatusOK(map[string]interface{}{
"test": "data",
"items": []string{"item1", "item2"},
Expand All @@ -168,7 +171,7 @@ func main() {
})

// JWT 토큰 생성 테스트
api.POST("/test/generate-token", func(c echo.Context) error {
test.POST("/test/generate-token", func(c echo.Context) error {
token, err := jwt.GenerateToken("testuser", "Test User", "test@example.com", "user", 3600*1000000000)
if err != nil {
return errors.NewInternalServerError("Failed to generate token", err)
Expand All @@ -179,8 +182,7 @@ func main() {
return c.JSON(resp.Status.Code, resp)
})

// 공개 테스트 엔드포인트 (인증 불필요)
api.GET("/test/error", func(c echo.Context) error {
test.GET("/test/error", func(c echo.Context) error {
return errors.NewBadRequest("This is a test error")
})

Expand Down
87 changes: 87 additions & 0 deletions api/internal/middleware/auth.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package middleware

import (
"log"
"mc_web_console_api/internal/config"
"mc_web_console_api/internal/repository"
"mc_web_console_api/pkg/errors"
"mc_web_console_api/pkg/jwt"
"strings"
"sync"

"github.com/labstack/echo/v4"
)
Expand Down Expand Up @@ -44,6 +47,90 @@ func AuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
}
}

var (
iamKeySetMu sync.Mutex
iamKeySet *jwt.KeySet
)

// getIAMKeySet mc-iam-manager 공개키 캐시 (api.yaml의 mc-iam-manager Getcerts 경로 기준)
func getIAMKeySet(cfg *config.Config) (*jwt.KeySet, error) {
service, action, err := cfg.ApiSpec.GetAction("mc-iam-manager", "Getcerts")
if err != nil {
return nil, err
}
certsURL := service.BaseURL + action.ResourcePath

iamKeySetMu.Lock()
defer iamKeySetMu.Unlock()
if iamKeySet == nil || iamKeySet.URL() != certsURL {
iamKeySet = jwt.NewKeySet(certsURL)
}
return iamKeySet, nil
}

// ProxyAuthMiddleware 서브시스템 프록시·BFF 라우트 인증 미들웨어
// - MC_WEB_CONSOLE_USE_IAM=true: mc-iam-manager 발급 토큰(RS256)을 /api/auth/certs 공개키로 검증
// - false: 로컬 발급 토큰(HS256) 검증 + DB 세션 확인 (AuthMiddleware와 동일)
// front(ApiCaller)는 쿠키 토큰을 "Bearer " 없이 Authorization 헤더에 싣으므로 두 형식 모두 허용한다.
// 프록시가 role로 x-credential-holder를 정하므로 role은 컨텍스트에 설정하지 않는다 (기존 동작 유지).
func ProxyAuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
token := proxyToken(c)
if token == "" {
return errors.NewUnauthorized("Missing authorization token")
}

cfg, _ := c.Get("config").(*config.Config)
if cfg != nil && cfg.MCIAM.Use {
keySet, err := getIAMKeySet(cfg)
if err != nil {
return errors.NewInternalServerError("MCIAM certs config not found", err)
}
claims, err := keySet.ParseIAMToken(token)
if err != nil {
log.Printf("ProxyAuthMiddleware: %s %s rejected: %v", c.Request().Method, c.Request().URL.Path, err)
return errors.NewUnauthorized("Invalid token")
}
c.Set("userId", claims.UserID())
c.Set("userName", claims.Name)
c.Set("email", claims.Email)
} else {
claims, err := jwt.ParseToken(token)
if err != nil {
return errors.NewUnauthorized("Invalid token")
}
if db := repository.GetDB(); db != nil {
sessionRepo := repository.NewSessionRepository(db)
exists, err := sessionRepo.Exists(claims.UserID)
if err != nil || !exists {
return errors.NewUnauthorized("Session not found")
}
}
c.Set("userId", claims.UserID)
c.Set("userName", claims.UserName)
c.Set("email", claims.Email)
}

return next(c)
}
}

// proxyToken "Bearer <token>" 또는 토큰 원문 형식의 Authorization 헤더에서 토큰 추출
func proxyToken(c echo.Context) string {
authHeader := strings.TrimSpace(c.Request().Header.Get("Authorization"))
if authHeader == "" {
return ""
}
parts := strings.Fields(authHeader)
switch {
case len(parts) == 2 && parts[0] == "Bearer":
return parts[1]
case len(parts) == 1:
return parts[0]
}
return ""
}

// extractToken Authorization 헤더에서 토큰 추출
func extractToken(c echo.Context) string {
authHeader := c.Request().Header.Get("Authorization")
Expand Down
161 changes: 161 additions & 0 deletions api/internal/middleware/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package middleware

import (
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"math/big"
"net/http"
"net/http/httptest"
"testing"
"time"

"mc_web_console_api/internal/config"
"mc_web_console_api/pkg/errors"
pkgjwt "mc_web_console_api/pkg/jwt"

gojwt "github.com/golang-jwt/jwt/v5"
"github.com/labstack/echo/v4"
)

const testKid = "test-kid"

func newCertsServer(t *testing.T, key *rsa.PublicKey) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/auth/certs" {
http.NotFound(w, r)
return
}
json.NewEncoder(w).Encode(map[string]interface{}{
"keys": []map[string]string{{
"kid": testKid,
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes()),
}},
})
}))
t.Cleanup(srv.Close)
return srv
}

func iamConfig(baseURL string) *config.Config {
return &config.Config{
MCIAM: config.MCIAMConfig{Use: true},
ApiSpec: &config.ApiSpec{
Services: map[string]config.Service{
"mc-iam-manager": {BaseURL: baseURL, Auth: config.AuthConfig{Type: "bearer"}},
},
ServiceActions: map[string]map[string]config.ActionSpec{
"mc-iam-manager": {"Getcerts": {Method: "get", ResourcePath: "/api/auth/certs"}},
},
},
}
}

func signRS256(t *testing.T, key *rsa.PrivateKey, kid string, exp time.Time) string {
t.Helper()
tok := gojwt.NewWithClaims(gojwt.SigningMethodRS256, gojwt.MapClaims{
"preferred_username": "mzc-noh",
"email": "noh@example.com",
"exp": exp.Unix(),
})
tok.Header["kid"] = kid
s, err := tok.SignedString(key)
if err != nil {
t.Fatalf("sign: %v", err)
}
return s
}

// runProxyAuth 미들웨어를 통과하면 200과 userId, 거부되면 AppError 코드를 반환한다.
func runProxyAuth(t *testing.T, cfg *config.Config, authHeader string) (int, string) {
t.Helper()
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/api/mc-infra-manager/GetCredentialHolderList", nil)
if authHeader != "" {
req.Header.Set("Authorization", authHeader)
}
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.Set("config", cfg)

var userID string
err := ProxyAuthMiddleware(func(c echo.Context) error {
userID, _ = c.Get("userId").(string)
return c.NoContent(http.StatusOK)
})(c)
if err != nil {
appErr, ok := err.(*errors.AppError)
if !ok {
t.Fatalf("unexpected error type %T: %v", err, err)
}
return appErr.Code, ""
}
return rec.Code, userID
}

func TestProxyAuthMiddleware_IAMMode(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
otherKey, _ := rsa.GenerateKey(rand.Reader, 2048)
srv := newCertsServer(t, &key.PublicKey)
cfg := iamConfig(srv.URL)

valid := signRS256(t, key, testKid, time.Now().Add(time.Hour))

cases := []struct {
name string
header string
wantCode int
}{
{"no token", "", http.StatusUnauthorized},
{"malformed token", "Bearer abc.def.ghi", http.StatusUnauthorized},
{"signed by other key", "Bearer " + signRS256(t, otherKey, testKid, time.Now().Add(time.Hour)), http.StatusUnauthorized},
{"expired", "Bearer " + signRS256(t, key, testKid, time.Now().Add(-time.Minute)), http.StatusUnauthorized},
{"local HS256 token", "Bearer " + mustLocalToken(t), http.StatusUnauthorized},
{"valid bearer", "Bearer " + valid, http.StatusOK},
{"valid raw (front ApiCaller format)", valid, http.StatusOK},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
code, userID := runProxyAuth(t, cfg, tc.header)
if code != tc.wantCode {
t.Fatalf("code = %d, want %d", code, tc.wantCode)
}
if tc.wantCode == http.StatusOK && userID != "mzc-noh" {
t.Fatalf("userId = %q, want mzc-noh", userID)
}
})
}
}

func mustLocalToken(t *testing.T) string {
t.Helper()
tok, err := pkgjwt.GenerateToken("local", "Local", "local@example.com", "user", time.Hour)
if err != nil {
t.Fatal(err)
}
return tok
}

func TestProxyAuthMiddleware_LocalMode(t *testing.T) {
cfg := &config.Config{MCIAM: config.MCIAMConfig{Use: false}}

if code, _ := runProxyAuth(t, cfg, ""); code != http.StatusUnauthorized {
t.Fatalf("no token: code = %d, want 401", code)
}
if code, _ := runProxyAuth(t, cfg, "Bearer abc.def.ghi"); code != http.StatusUnauthorized {
t.Fatalf("forged token: code = %d, want 401", code)
}
code, userID := runProxyAuth(t, cfg, "Bearer "+mustLocalToken(t))
if code != http.StatusOK || userID != "local" {
t.Fatalf("valid local token: code = %d userId = %q", code, userID)
}
}
Loading
Loading