forked from baron/baron-sso
Merge branch 'dev' into feat/org-chart-rebac
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/logger"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"baron-sso-backend/internal/response"
|
||||
"baron-sso-backend/internal/service"
|
||||
"baron-sso-backend/internal/utils"
|
||||
"bytes"
|
||||
@@ -1562,13 +1563,12 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.ProviderError = err.Error()
|
||||
ale.Log(slog.LevelError, "Body parse error")
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
|
||||
return response.Error(c, fiber.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
}
|
||||
|
||||
loginID := strings.TrimSpace(req.LoginID)
|
||||
ale.LoginIDs["loginId"] = req.LoginID // 원문
|
||||
ale.LoginIDs["loginId_normalized"] = loginID
|
||||
ale.NewPassword = req.Password // For test only, logging password (sensitive)
|
||||
|
||||
ale.Log(slog.LevelInfo, "Attempting to login")
|
||||
|
||||
@@ -1577,22 +1577,22 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.ProviderError = "IDP Provider is nil"
|
||||
ale.Log(slog.LevelError, "IDP Provider is nil")
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"})
|
||||
return response.Error(c, fiber.StatusInternalServerError, "service_unavailable", "Authentication service not configured")
|
||||
}
|
||||
|
||||
authInfo, err := h.IdpProvider.SignIn(loginID, req.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotSupported) {
|
||||
return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"})
|
||||
return response.Error(c, fiber.StatusNotImplemented, "not_supported", "Login method not supported")
|
||||
}
|
||||
ale.Status = fiber.StatusUnauthorized
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.ProviderError = err.Error()
|
||||
ale.Log(slog.LevelWarn, "IDP sign-in failed", slog.String("provider", h.IdpProvider.Name()))
|
||||
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "identity") {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not registered"})
|
||||
return response.Error(c, fiber.StatusNotFound, "not_found", "User not registered")
|
||||
}
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"})
|
||||
return response.Error(c, fiber.StatusUnauthorized, "password_or_email_mismatch", "Invalid credentials")
|
||||
}
|
||||
|
||||
subject, resolveErr := h.resolveKratosIdentityIDFromLoginID(c.Context(), loginID)
|
||||
@@ -1604,7 +1604,6 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
|
||||
|
||||
ale.Status = fiber.StatusOK
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.SessionJwt = authInfo.SessionToken.JWT
|
||||
setSessionIDLocal(c, authInfo.SessionToken)
|
||||
ale.Log(slog.LevelInfo, "Login successful", slog.String("provider", h.IdpProvider.Name()), slog.String("subject", authInfo.Subject))
|
||||
|
||||
@@ -1856,11 +1855,23 @@ func (h *AuthHandler) ProcessPasswordResetToken(c *fiber.Ctx) error {
|
||||
ale.LoginIDs["loginId"] = loginID
|
||||
ale.LoginIDs["loginId_normalized"] = loginID
|
||||
|
||||
redirectURL := fmt.Sprintf("%s/reset-password?loginId=%s&token=%s",
|
||||
os.Getenv("USERFRONT_URL"),
|
||||
loginID,
|
||||
token,
|
||||
)
|
||||
userfrontURL := strings.TrimRight(os.Getenv("USERFRONT_URL"), "/")
|
||||
if userfrontURL == "" {
|
||||
userfrontURL = "https://sso.hmac.kr"
|
||||
}
|
||||
redirectBase, parseErr := url.Parse(userfrontURL + "/reset-password")
|
||||
if parseErr != nil {
|
||||
ale.Status = fiber.StatusInternalServerError
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.ProviderError = parseErr.Error()
|
||||
ale.Log(slog.LevelError, "Failed to compose reset redirect URL")
|
||||
return c.Status(fiber.StatusInternalServerError).SendString("Failed to compose redirect URL")
|
||||
}
|
||||
query := redirectBase.Query()
|
||||
query.Set("loginId", loginID)
|
||||
query.Set("token", token)
|
||||
redirectBase.RawQuery = query.Encode()
|
||||
redirectURL := redirectBase.String()
|
||||
|
||||
ale.RedirectTo = redirectURL
|
||||
ale.Status = fiber.StatusFound
|
||||
@@ -1894,22 +1905,29 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// loginID는 URL 쿼리 파라미터 또는 토큰 조회로 받습니다.
|
||||
loginID := c.Query("loginId")
|
||||
resetToken := c.Query("token")
|
||||
if loginID == "" && resetToken != "" {
|
||||
if val, err := h.RedisService.Get(prefixPwdResetToken + resetToken); err == nil && val != "" {
|
||||
loginID = val
|
||||
loginID := strings.TrimSpace(c.Query("loginId"))
|
||||
resetToken := strings.TrimSpace(c.Query("token"))
|
||||
if resetToken != "" {
|
||||
val, err := h.RedisService.Get(prefixPwdResetToken + resetToken)
|
||||
if err != nil || strings.TrimSpace(val) == "" {
|
||||
ale.Status = fiber.StatusUnauthorized
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.ProviderError = "Invalid or expired reset token"
|
||||
ale.Token = resetToken
|
||||
ale.Log(slog.LevelWarn, "Reset token invalid or expired")
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired reset token"})
|
||||
}
|
||||
loginID = strings.TrimSpace(val)
|
||||
ale.Token = resetToken
|
||||
}
|
||||
if loginID != "" && !strings.Contains(loginID, "@") {
|
||||
loginID = normalizePhoneForLoginID(loginID)
|
||||
}
|
||||
|
||||
ale.LoginIDs["loginId"] = loginID
|
||||
ale.RequestBody = fmt.Sprintf("{\"newPassword\": \"%s\"}", req.NewPassword) // Log request body (for test only)
|
||||
ale.NewPassword = req.NewPassword // Log new password (for test only)
|
||||
|
||||
// Request cookie logging (minimal)
|
||||
// 요청 쿠키는 원문을 기록하지 않고 존재 여부만 기록합니다.
|
||||
if cookieHeader := c.Get(fiber.HeaderCookie); cookieHeader != "" {
|
||||
ale.Headers["Request-Cookie-Header"] = cookieHeader
|
||||
if dsrfCookie := c.Cookies("DSRF"); dsrfCookie != "" {
|
||||
ale.ParsedCookieDSRF = dsrfCookie
|
||||
ale.HasCookieDSRF = true
|
||||
@@ -1926,7 +1944,7 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Login ID and new password are required"})
|
||||
}
|
||||
|
||||
// 디버깅을 위해 요청된 새 비밀번호를 로그로 출력
|
||||
// 새 비밀번호 값은 기록하지 않고, 요청 수신 이벤트만 남깁니다.
|
||||
ale.Log(slog.LevelInfo, "Received new password for reset")
|
||||
|
||||
policy := h.resolvePasswordPolicy()
|
||||
|
||||
@@ -300,3 +300,44 @@ func TestPasswordLogin_NoOIDC_Success(t *testing.T) {
|
||||
t.Errorf("expected no redirectTo, got %s", got["redirectTo"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordLogin_InvalidCredentials_ReturnsCode(t *testing.T) {
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "user@example.com", "wrong-password").Return(nil, errors.New("비밀번호가 일치하지 않습니다"))
|
||||
|
||||
h := &AuthHandler{
|
||||
IdpProvider: mockIdp,
|
||||
KratosAdmin: service.NewKratosAdminService(),
|
||||
Hydra: service.NewHydraAdminService(),
|
||||
}
|
||||
|
||||
app := newAuthLoginTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"loginId": "user@example.com",
|
||||
"password": "wrong-password",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var got map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if got["code"] != "password_or_email_mismatch" {
|
||||
t.Fatalf("expected code=password_or_email_mismatch, got=%v", got["code"])
|
||||
}
|
||||
if got["error"] != "Invalid credentials" {
|
||||
t.Fatalf("expected error=Invalid credentials, got=%v", got["error"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ package handler
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
@@ -17,6 +19,51 @@ func newTestApp(h *AuthHandler) *fiber.App {
|
||||
return app
|
||||
}
|
||||
|
||||
func newResetFlowTestApp(h *AuthHandler) *fiber.App {
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/password/reset/verify", h.ProcessPasswordResetToken)
|
||||
app.Post("/api/v1/auth/password/reset/complete", h.CompletePasswordReset)
|
||||
return app
|
||||
}
|
||||
|
||||
type testRedisRepo struct {
|
||||
values map[string]string
|
||||
}
|
||||
|
||||
func (m *testRedisRepo) Set(key string, value string, expiration time.Duration) error {
|
||||
if m.values == nil {
|
||||
m.values = map[string]string{}
|
||||
}
|
||||
m.values[key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *testRedisRepo) Get(key string) (string, error) {
|
||||
if m.values == nil {
|
||||
return "", nil
|
||||
}
|
||||
return m.values[key], nil
|
||||
}
|
||||
|
||||
func (m *testRedisRepo) Delete(key string) error {
|
||||
if m.values != nil {
|
||||
delete(m.values, key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *testRedisRepo) StoreVerificationCode(phone, code string) error {
|
||||
return m.Set("sms:"+phone, code, time.Minute)
|
||||
}
|
||||
|
||||
func (m *testRedisRepo) GetVerificationCode(phone string) (string, error) {
|
||||
return m.Get("sms:" + phone)
|
||||
}
|
||||
|
||||
func (m *testRedisRepo) DeleteVerificationCode(phone string) error {
|
||||
return m.Delete("sms:" + phone)
|
||||
}
|
||||
|
||||
func TestCompletePasswordReset_MissingLoginID(t *testing.T) {
|
||||
h := &AuthHandler{}
|
||||
app := newTestApp(h)
|
||||
@@ -106,3 +153,136 @@ func TestCompletePasswordReset_NilIDPProvider(t *testing.T) {
|
||||
t.Fatalf("unexpected error message: %v", got["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletePasswordReset_TokenValueOverridesLoginIDQuery(t *testing.T) {
|
||||
const resetToken = "tok-reset-1"
|
||||
const tokenLoginID = "user@example.com"
|
||||
const wrongLoginID = "wrong@example.com"
|
||||
const newPassword = "StrongPass1!"
|
||||
|
||||
redis := &testRedisRepo{
|
||||
values: map[string]string{
|
||||
prefixPwdResetToken + resetToken: tokenLoginID,
|
||||
},
|
||||
}
|
||||
idp := &mockIdpProvider{
|
||||
userExists: true,
|
||||
err: nil,
|
||||
}
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
IdpProvider: idp,
|
||||
}
|
||||
app := newResetFlowTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"newPassword": newPassword,
|
||||
})
|
||||
url := fmt.Sprintf(
|
||||
"/api/v1/auth/password/reset/complete?loginId=%s&token=%s",
|
||||
wrongLoginID,
|
||||
resetToken,
|
||||
)
|
||||
req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
if !idp.updateCalled {
|
||||
t.Fatal("expected UpdateUserPassword to be called")
|
||||
}
|
||||
if idp.updatedLoginID != tokenLoginID {
|
||||
t.Fatalf("expected loginId from token(%s), got %s", tokenLoginID, idp.updatedLoginID)
|
||||
}
|
||||
if idp.updatedPassword != newPassword {
|
||||
t.Fatalf("expected newPassword propagated, got %s", idp.updatedPassword)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletePasswordReset_InvalidTokenRejectedEvenWhenLoginIDExists(t *testing.T) {
|
||||
const resetToken = "invalid-token"
|
||||
|
||||
redis := &testRedisRepo{
|
||||
values: map[string]string{},
|
||||
}
|
||||
idp := &mockIdpProvider{
|
||||
userExists: true,
|
||||
err: nil,
|
||||
}
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
IdpProvider: idp,
|
||||
}
|
||||
app := newResetFlowTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"newPassword": "StrongPass1!",
|
||||
})
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/auth/password/reset/complete?loginId=user@example.com&token="+resetToken,
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 for invalid token, got %d", resp.StatusCode)
|
||||
}
|
||||
if idp.updateCalled {
|
||||
t.Fatal("UpdateUserPassword must not be called when token is invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessPasswordResetToken_EncodesLoginIDInRedirect(t *testing.T) {
|
||||
const token = "tok-enc"
|
||||
const loginID = "user+alias@example.com"
|
||||
|
||||
t.Setenv("USERFRONT_URL", "https://sss.hmac.kr")
|
||||
|
||||
redis := &testRedisRepo{
|
||||
values: map[string]string{
|
||||
prefixPwdResetToken + token: loginID,
|
||||
},
|
||||
}
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
}
|
||||
app := newResetFlowTestApp(h)
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/auth/password/reset/verify?token="+token,
|
||||
nil,
|
||||
)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("expected 302, got %d", resp.StatusCode)
|
||||
}
|
||||
location := resp.Header.Get("Location")
|
||||
if location == "" {
|
||||
t.Fatal("missing redirect location")
|
||||
}
|
||||
redirectReq := httptest.NewRequest(http.MethodGet, location, nil)
|
||||
gotLoginID := redirectReq.URL.Query().Get("loginId")
|
||||
if gotLoginID != loginID {
|
||||
t.Fatalf("expected encoded loginId round-trip=%s, got %s (location=%s)", loginID, gotLoginID, location)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ type mockIdpProvider struct {
|
||||
verifyCodeInfo *domain.AuthInfo
|
||||
err error
|
||||
initiateLinkErr error
|
||||
updateCalled bool
|
||||
updatedLoginID string
|
||||
updatedPassword string
|
||||
}
|
||||
|
||||
func (m *mockIdpProvider) Name() string {
|
||||
@@ -63,6 +66,9 @@ func (m *mockIdpProvider) VerifyPasswordResetToken(token string) (*domain.AuthIn
|
||||
}
|
||||
|
||||
func (m *mockIdpProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
|
||||
m.updateCalled = true
|
||||
m.updatedLoginID = loginID
|
||||
m.updatedPassword = newPassword
|
||||
return m.err
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,11 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -22,15 +25,39 @@ type DevHandler struct {
|
||||
SecretRepo domain.ClientSecretRepository
|
||||
KratosAdmin service.KratosAdminService
|
||||
ConsentRepo repository.ClientConsentRepository
|
||||
Keto service.KetoService
|
||||
RPSvc service.RelyingPartyService
|
||||
Auth interface {
|
||||
GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error)
|
||||
}
|
||||
}
|
||||
|
||||
func NewDevHandler(redis domain.RedisRepository, secretRepo domain.ClientSecretRepository, consentRepo repository.ClientConsentRepository, rpSvc service.RelyingPartyService) *DevHandler {
|
||||
func NewDevHandler(
|
||||
redis domain.RedisRepository,
|
||||
secretRepo domain.ClientSecretRepository,
|
||||
consentRepo repository.ClientConsentRepository,
|
||||
rpSvc service.RelyingPartyService,
|
||||
keto service.KetoService,
|
||||
auth ...interface {
|
||||
GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error)
|
||||
},
|
||||
) *DevHandler {
|
||||
var authProvider interface {
|
||||
GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error)
|
||||
}
|
||||
if len(auth) > 0 {
|
||||
authProvider = auth[0]
|
||||
}
|
||||
|
||||
return &DevHandler{
|
||||
Hydra: service.NewHydraAdminService(),
|
||||
Redis: redis,
|
||||
SecretRepo: secretRepo,
|
||||
KratosAdmin: service.NewKratosAdminService(),
|
||||
ConsentRepo: consentRepo,
|
||||
Keto: keto,
|
||||
RPSvc: rpSvc,
|
||||
Auth: authProvider,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +121,142 @@ type clientUpsertRequest struct {
|
||||
Metadata *map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
|
||||
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
if (!ok || profile == nil) && h.Auth != nil {
|
||||
enriched, err := h.Auth.GetEnrichedProfile(c)
|
||||
if err == nil && enriched != nil {
|
||||
profile = enriched
|
||||
ok = true
|
||||
c.Locals("user_profile", enriched)
|
||||
}
|
||||
}
|
||||
if ok && profile != nil {
|
||||
// Super Admin bypass
|
||||
if profile.Role == domain.RoleSuperAdmin {
|
||||
slog.Info("Dev private permission granted by super_admin role", "user_id", profile.ID)
|
||||
return true, nil
|
||||
}
|
||||
if isAdminEmail(profile.Email) {
|
||||
slog.Info("Dev private permission granted by ADMIN_EMAIL match", "email", profile.Email)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Check with Keto: System:AppManager#member
|
||||
allowed, err := h.Keto.CheckPermission(c.Context(), profile.ID, "System", "AppManager", "member")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
slog.Info("Dev private permission evaluated by Keto", "user_id", profile.ID, "allowed", allowed)
|
||||
|
||||
return allowed, nil
|
||||
}
|
||||
|
||||
tokenSubject, tokenEmail := extractAuthClaimsFromBearer(c.Get("Authorization"))
|
||||
if isAdminEmail(tokenEmail) {
|
||||
slog.Info("Dev private permission granted by token email", "email", tokenEmail)
|
||||
return true, nil
|
||||
}
|
||||
if tokenSubject == "" {
|
||||
if isTrustedLocalDevfrontRequest(c) {
|
||||
// Local devfront fallback: allow localhost developer flow even if auth context is missing.
|
||||
slog.Warn("Dev private permission fallback granted for trusted local devfront request", "path", c.Path(), "origin", c.Get("Origin"))
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Fallback: resolve role from Kratos identity traits when user_profile is not injected.
|
||||
if h.KratosAdmin != nil {
|
||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), tokenSubject)
|
||||
if err == nil && identity != nil {
|
||||
if rawRole, ok := identity.Traits["role"].(string); ok && rawRole == domain.RoleSuperAdmin {
|
||||
slog.Info("Dev private permission granted by Kratos role", "subject", tokenSubject)
|
||||
return true, nil
|
||||
}
|
||||
if email, ok := identity.Traits["email"].(string); ok && isAdminEmail(email) {
|
||||
slog.Info("Dev private permission granted by Kratos email", "subject", tokenSubject, "email", email)
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check with Keto: System:AppManager#member
|
||||
allowed, err := h.Keto.CheckPermission(c.Context(), tokenSubject, "System", "AppManager", "member")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
slog.Info("Dev private permission evaluated by Keto(subject)", "subject", tokenSubject, "allowed", allowed)
|
||||
|
||||
return allowed, nil
|
||||
}
|
||||
|
||||
func extractAuthClaimsFromBearer(authHeader string) (string, string) {
|
||||
authHeader = strings.TrimSpace(authHeader)
|
||||
if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(authHeader[len("Bearer "):])
|
||||
if token == "" || strings.Count(token, ".") != 2 {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
payload, err = base64.URLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
}
|
||||
|
||||
var claims map[string]interface{}
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
sub := ""
|
||||
if sub, ok := claims["sub"].(string); ok {
|
||||
sub = strings.TrimSpace(sub)
|
||||
}
|
||||
email := ""
|
||||
if claimEmail, ok := claims["email"].(string); ok {
|
||||
email = strings.TrimSpace(claimEmail)
|
||||
}
|
||||
|
||||
return sub, email
|
||||
}
|
||||
|
||||
func isAdminEmail(email string) bool {
|
||||
adminEmail := strings.TrimSpace(os.Getenv("ADMIN_EMAIL"))
|
||||
return adminEmail != "" && strings.EqualFold(strings.TrimSpace(email), adminEmail)
|
||||
}
|
||||
|
||||
func isTrustedLocalDevfrontRequest(c *fiber.Ctx) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
origin := strings.ToLower(strings.TrimSpace(c.Get("Origin")))
|
||||
referer := strings.ToLower(strings.TrimSpace(c.Get("Referer")))
|
||||
allowedPrefixes := []string{
|
||||
"http://localhost:5174",
|
||||
"https://localhost:5174",
|
||||
}
|
||||
|
||||
for _, prefix := range allowedPrefixes {
|
||||
if strings.HasPrefix(origin, prefix) || strings.HasPrefix(referer, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
||||
limit := c.QueryInt("limit", 50)
|
||||
offset := c.QueryInt("offset", 0)
|
||||
@@ -104,6 +267,11 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
isAppManager, err := h.checkAppManagerPermission(c)
|
||||
if err != nil {
|
||||
slog.Error("Failed to check app manager permission", "error", err)
|
||||
}
|
||||
|
||||
clients, err := h.Hydra.ListClients(c.Context(), limit, offset)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrHydraNotFound) {
|
||||
@@ -120,7 +288,12 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
||||
|
||||
items := make([]clientSummary, 0, len(clients))
|
||||
for _, client := range clients {
|
||||
items = append(items, h.mapClientSummary(client))
|
||||
summary := h.mapClientSummary(client)
|
||||
// Filter out 'private' clients if user is not an AppManager
|
||||
if summary.Type == "private" && !isAppManager {
|
||||
continue
|
||||
}
|
||||
items = append(items, summary)
|
||||
}
|
||||
|
||||
return c.JSON(clientListResponse{
|
||||
@@ -145,6 +318,18 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
summary := h.mapClientSummary(*client)
|
||||
|
||||
// Check permission for private clients
|
||||
if summary.Type == "private" {
|
||||
isAppManager, err := h.checkAppManagerPermission(c)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "permission check error"})
|
||||
}
|
||||
if !isAppManager {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"})
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(clientDetailResponse{
|
||||
Client: summary,
|
||||
Endpoints: clientEndpoints{
|
||||
@@ -175,6 +360,18 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "status must be active or inactive"})
|
||||
}
|
||||
|
||||
// [Security] Check permission before patching
|
||||
current, err := h.Hydra.GetClient(c.Context(), clientID)
|
||||
if err == nil {
|
||||
summary := h.mapClientSummary(*current)
|
||||
if summary.Type == "private" {
|
||||
isAppManager, _ := h.checkAppManagerPermission(c)
|
||||
if !isAppManager {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updated, err := h.Hydra.PatchClientStatus(c.Context(), clientID, status)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrHydraNotFound) {
|
||||
@@ -221,9 +418,20 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
grantTypes := derefSlice(req.GrantTypes, defaultGrantTypes())
|
||||
responseTypes := derefSlice(req.ResponseTypes, defaultResponseTypes())
|
||||
|
||||
clientType := strings.ToLower(strings.TrimSpace(valueOr(req.Type, "confidential")))
|
||||
if clientType != "public" && clientType != "confidential" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type must be public or confidential"})
|
||||
clientType := strings.ToLower(strings.TrimSpace(valueOr(req.Type, "private")))
|
||||
if clientType != "pkce" && clientType != "private" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type must be pkce or private"})
|
||||
}
|
||||
|
||||
// [Security] Check permission for private clients
|
||||
if clientType == "private" {
|
||||
isAppManager, err := h.checkAppManagerPermission(c)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "permission check error"})
|
||||
}
|
||||
if !isAppManager {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions to create private client"})
|
||||
}
|
||||
}
|
||||
|
||||
status := strings.ToLower(strings.TrimSpace(valueOr(req.Status, "active")))
|
||||
@@ -236,10 +444,11 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
metadata = map[string]interface{}{}
|
||||
}
|
||||
metadata["status"] = status
|
||||
metadata["created_at"] = time.Now().Format(time.RFC3339)
|
||||
|
||||
tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, ""))
|
||||
if tokenAuthMethod == "" {
|
||||
if clientType == "public" {
|
||||
if clientType == "pkce" {
|
||||
tokenAuthMethod = "none"
|
||||
} else {
|
||||
tokenAuthMethod = "client_secret_basic"
|
||||
@@ -310,8 +519,20 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
||||
clientType := ""
|
||||
if req.Type != nil {
|
||||
clientType = strings.ToLower(strings.TrimSpace(*req.Type))
|
||||
if clientType != "public" && clientType != "confidential" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type must be public or confidential"})
|
||||
if clientType != "pkce" && clientType != "private" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type must be pkce or private"})
|
||||
}
|
||||
}
|
||||
|
||||
// [Security] Check permission for private clients (both current and new type)
|
||||
currentSummary := h.mapClientSummary(*current)
|
||||
if currentSummary.Type == "private" || clientType == "private" {
|
||||
isAppManager, err := h.checkAppManagerPermission(c)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "permission check error"})
|
||||
}
|
||||
if !isAppManager {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,7 +546,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
||||
|
||||
tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, ""))
|
||||
if tokenAuthMethod == "" && clientType != "" {
|
||||
if clientType == "public" {
|
||||
if clientType == "pkce" {
|
||||
tokenAuthMethod = "none"
|
||||
} else {
|
||||
tokenAuthMethod = "client_secret_basic"
|
||||
@@ -382,6 +603,18 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"})
|
||||
}
|
||||
|
||||
// [Security] Check permission for private clients
|
||||
current, err := h.Hydra.GetClient(c.Context(), clientID)
|
||||
if err == nil {
|
||||
summary := h.mapClientSummary(*current)
|
||||
if summary.Type == "private" {
|
||||
isAppManager, _ := h.checkAppManagerPermission(c)
|
||||
if !isAppManager {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.Hydra.DeleteClient(c.Context(), clientID); err != nil {
|
||||
if errors.Is(err, service.ErrHydraNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"})
|
||||
@@ -517,14 +750,25 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"})
|
||||
}
|
||||
|
||||
// [Security] Check permission for private clients
|
||||
current, err := h.Hydra.GetClient(c.Context(), clientID)
|
||||
if err == nil {
|
||||
summary := h.mapClientSummary(*current)
|
||||
if summary.Type == "private" {
|
||||
isAppManager, _ := h.checkAppManagerPermission(c)
|
||||
if !isAppManager {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Generate new secret
|
||||
newSecret, err := generateRandomSecret(20)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate secret"})
|
||||
}
|
||||
|
||||
// 2. Get current client to preserve other fields
|
||||
current, err := h.Hydra.GetClient(c.Context(), clientID)
|
||||
// 2. Get current client to preserve other fields (already fetched above)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrHydraNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"})
|
||||
@@ -578,15 +822,22 @@ func generateRandomSecret(length int) (string, error) {
|
||||
|
||||
func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
|
||||
status := "active"
|
||||
var createdAt *time.Time
|
||||
|
||||
if client.Metadata != nil {
|
||||
if value, ok := client.Metadata["status"].(string); ok && strings.ToLower(value) == "inactive" {
|
||||
status = "inactive"
|
||||
}
|
||||
if value, ok := client.Metadata["created_at"].(string); ok {
|
||||
if t, err := time.Parse(time.RFC3339, value); err == nil {
|
||||
createdAt = &t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clientType := "confidential"
|
||||
clientType := "private"
|
||||
if strings.EqualFold(client.TokenEndpointAuthMethod, "none") {
|
||||
clientType = "public"
|
||||
clientType = "pkce"
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(client.ClientName)
|
||||
@@ -627,6 +878,7 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
|
||||
Name: name,
|
||||
Type: clientType,
|
||||
Status: status,
|
||||
CreatedAt: createdAt,
|
||||
RedirectURIs: client.RedirectURIs,
|
||||
Scopes: scopes,
|
||||
ClientSecret: clientSecret,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -10,8 +12,36 @@ import (
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
type MockKetoService struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockKetoService) CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error) {
|
||||
args := m.Called(ctx, subject, namespace, object, relation)
|
||||
return args.Bool(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockKetoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
||||
return m.Called(ctx, namespace, object, relation, subject).Error(0)
|
||||
}
|
||||
|
||||
func (m *MockKetoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
||||
return m.Called(ctx, namespace, object, relation, subject).Error(0)
|
||||
}
|
||||
|
||||
func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]service.RelationTuple, error) {
|
||||
args := m.Called(ctx, namespace, object, relation, subject)
|
||||
return args.Get(0).([]service.RelationTuple), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
|
||||
args := m.Called(ctx, namespace, relation, subject)
|
||||
return args.Get(0).([]string), args.Error(1)
|
||||
}
|
||||
|
||||
func TestListClients_Success(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/clients" {
|
||||
@@ -23,13 +53,22 @@ func TestListClients_Success(t *testing.T) {
|
||||
return httpJSONAny(r, http.StatusNotFound, map[string]string{"error": "not found"}), nil
|
||||
})
|
||||
|
||||
mockKeto := new(MockKetoService)
|
||||
// For simplicity, always allow in basic success test
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "AppManager", "member").Return(true, nil)
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: mockKeto,
|
||||
}
|
||||
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/clients", h.ListClients)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
||||
@@ -58,14 +97,21 @@ func TestGetClient_Success(t *testing.T) {
|
||||
return httpJSONAny(r, http.StatusNotFound, map[string]string{"error": "not found"}), nil
|
||||
})
|
||||
|
||||
mockKeto := new(MockKetoService)
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
PublicURL: "http://hydra-public.test", // PublicURL 추가
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: mockKeto,
|
||||
}
|
||||
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/clients/:id", h.GetClient)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-123", nil)
|
||||
@@ -80,26 +126,6 @@ func TestGetClient_Success(t *testing.T) {
|
||||
assert.Equal(t, "http://hydra-public.test/oauth2/auth", res.Endpoints.Authorization)
|
||||
}
|
||||
|
||||
func TestGetClient_NotFound(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
return httpJSONAny(r, http.StatusNotFound, map[string]string{"error": "not found"}), nil
|
||||
})
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/dev/clients/:id", h.GetClient)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/non-existent", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestCreateClient_Success(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
|
||||
@@ -112,6 +138,7 @@ func TestCreateClient_Success(t *testing.T) {
|
||||
return httpJSONAny(r, http.StatusInternalServerError, map[string]string{"error": "hydra error"}), nil
|
||||
})
|
||||
|
||||
mockKeto := new(MockKetoService)
|
||||
secretRepo := &mockSecretRepo{secrets: make(map[string]string)}
|
||||
redisRepo := &mockRedisRepo{data: make(map[string]string)}
|
||||
|
||||
@@ -122,13 +149,18 @@ func TestCreateClient_Success(t *testing.T) {
|
||||
},
|
||||
SecretRepo: secretRepo,
|
||||
Redis: redisRepo,
|
||||
Keto: mockKeto,
|
||||
}
|
||||
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.Post("/api/v1/dev/clients", h.CreateClient)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"client_name": "New App",
|
||||
"type": "confidential",
|
||||
"type": "private",
|
||||
"redirectUris": []string{"http://localhost/cb"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
|
||||
|
||||
Reference in New Issue
Block a user