forked from baron/baron-sso
Merge pull request 'feature/uf-enhance' (#447) from feature/uf-enhance into dev
Reviewed-on: baron/baron-sso#447
This commit is contained in:
@@ -1101,6 +1101,9 @@ components:
|
||||
type: string
|
||||
phone:
|
||||
type: string
|
||||
sessionAuthenticatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
department:
|
||||
type: string
|
||||
affiliationType:
|
||||
|
||||
@@ -68,19 +68,20 @@ type SignupRequest struct {
|
||||
// User Profile Models
|
||||
|
||||
type UserProfileResponse struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
Role string `json:"role"` // 추가
|
||||
Department string `json:"department"`
|
||||
AffiliationType string `json:"affiliationType"`
|
||||
CompanyCode string `json:"companyCode,omitempty"`
|
||||
TenantID *string `json:"tenantId,omitempty"` // 추가
|
||||
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
Tenant *Tenant `json:"tenant,omitempty"`
|
||||
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
Role string `json:"role"` // 추가
|
||||
SessionAuthenticatedAt string `json:"sessionAuthenticatedAt,omitempty"`
|
||||
Department string `json:"department"`
|
||||
AffiliationType string `json:"affiliationType"`
|
||||
CompanyCode string `json:"companyCode,omitempty"`
|
||||
TenantID *string `json:"tenantId,omitempty"` // 추가
|
||||
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
Tenant *Tenant `json:"tenant,omitempty"`
|
||||
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
|
||||
}
|
||||
|
||||
type UpdateUserRequest struct {
|
||||
|
||||
@@ -4886,37 +4886,43 @@ func extractLoginIDFromClaims(claims map[string]any) string {
|
||||
}
|
||||
|
||||
func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]interface{}, error) {
|
||||
identityID, traits, _, err := h.getKratosIdentityWithSession(sessionToken)
|
||||
return identityID, traits, err
|
||||
}
|
||||
|
||||
func (h *AuthHandler) getKratosIdentityWithSession(sessionToken string) (string, map[string]interface{}, string, error) {
|
||||
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
|
||||
if kratosURL == "" {
|
||||
kratosURL = "http://kratos:4433"
|
||||
}
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
return "", nil, "", err
|
||||
}
|
||||
req.Header.Set("X-Session-Token", sessionToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
return "", nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return "", nil, fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
|
||||
return "", nil, "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Identity struct {
|
||||
AuthenticatedAt string `json:"authenticated_at"`
|
||||
Identity struct {
|
||||
ID string `json:"id"`
|
||||
Traits map[string]interface{} `json:"traits"`
|
||||
} `json:"identity"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", nil, err
|
||||
return "", nil, "", err
|
||||
}
|
||||
|
||||
return result.Identity.ID, result.Identity.Traits, nil
|
||||
return result.Identity.ID, result.Identity.Traits, result.AuthenticatedAt, nil
|
||||
}
|
||||
|
||||
func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) {
|
||||
@@ -4993,37 +4999,43 @@ func (h *AuthHandler) issueKratosSession(ctx context.Context, identityID string)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) getKratosIdentityWithCookie(cookie string) (string, map[string]interface{}, error) {
|
||||
identityID, traits, _, err := h.getKratosIdentityWithCookieAndSession(cookie)
|
||||
return identityID, traits, err
|
||||
}
|
||||
|
||||
func (h *AuthHandler) getKratosIdentityWithCookieAndSession(cookie string) (string, map[string]interface{}, string, error) {
|
||||
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
|
||||
if kratosURL == "" {
|
||||
kratosURL = "http://kratos:4433"
|
||||
}
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
return "", nil, "", err
|
||||
}
|
||||
req.Header.Set("Cookie", cookie)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
return "", nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return "", nil, fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
|
||||
return "", nil, "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Identity struct {
|
||||
AuthenticatedAt string `json:"authenticated_at"`
|
||||
Identity struct {
|
||||
ID string `json:"id"`
|
||||
Traits map[string]interface{} `json:"traits"`
|
||||
} `json:"identity"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", nil, err
|
||||
return "", nil, "", err
|
||||
}
|
||||
|
||||
return result.Identity.ID, result.Identity.Traits, nil
|
||||
return result.Identity.ID, result.Identity.Traits, result.AuthenticatedAt, nil
|
||||
}
|
||||
|
||||
func (h *AuthHandler) getKratosSessionIDWithCookie(cookie string) (string, error) {
|
||||
@@ -5158,20 +5170,34 @@ func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[s
|
||||
return profile
|
||||
}
|
||||
|
||||
func (h *AuthHandler) applySessionAuthenticatedAtFromWhoami(profile *domain.UserProfileResponse, authenticatedAt string) *domain.UserProfileResponse {
|
||||
if profile == nil {
|
||||
return nil
|
||||
}
|
||||
profile.SessionAuthenticatedAt = strings.TrimSpace(authenticatedAt)
|
||||
return profile
|
||||
}
|
||||
|
||||
func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfileResponse, error) {
|
||||
identityID, traits, err := h.getKratosIdentity(sessionToken)
|
||||
identityID, traits, authenticatedAt, err := h.getKratosIdentityWithSession(sessionToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return h.mapKratosIdentityToProfile(identityID, traits), nil
|
||||
return h.applySessionAuthenticatedAtFromWhoami(
|
||||
h.mapKratosIdentityToProfile(identityID, traits),
|
||||
authenticatedAt,
|
||||
), nil
|
||||
}
|
||||
|
||||
func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserProfileResponse, error) {
|
||||
identityID, traits, err := h.getKratosIdentityWithCookie(cookie)
|
||||
identityID, traits, authenticatedAt, err := h.getKratosIdentityWithCookieAndSession(cookie)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return h.mapKratosIdentityToProfile(identityID, traits), nil
|
||||
return h.applySessionAuthenticatedAtFromWhoami(
|
||||
h.mapKratosIdentityToProfile(identityID, traits),
|
||||
authenticatedAt,
|
||||
), nil
|
||||
}
|
||||
|
||||
// UpdateMe - Updates current user's profile with phone verification check
|
||||
|
||||
105
backend/internal/handler/auth_handler_session_profile_test.go
Normal file
105
backend/internal/handler/auth_handler_session_profile_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetMe_IncludesSessionAuthenticatedAtFromKratosSession(t *testing.T) {
|
||||
const (
|
||||
token = "token-session"
|
||||
identityID = "user-session"
|
||||
sessionAuthenticated = "2026-03-23T15:30:00Z"
|
||||
)
|
||||
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Host == "kratos.test" &&
|
||||
r.URL.Path == "/sessions/whoami" &&
|
||||
r.Method == http.MethodGet {
|
||||
require.Equal(t, token, r.Header.Get("X-Session-Token"))
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"id": "kratos-session-1",
|
||||
"authenticated_at": sessionAuthenticated,
|
||||
"identity": map[string]any{
|
||||
"id": identityID,
|
||||
"traits": map[string]any{
|
||||
"email": "qa@example.com",
|
||||
"name": "QA User",
|
||||
"department": "Platform",
|
||||
"affiliationType": "GENERAL",
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})
|
||||
setDefaultHTTPClientForTest(t, transport)
|
||||
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
|
||||
|
||||
h := &AuthHandler{}
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/user/me", h.GetMe)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := app.Test(req, -1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var profile map[string]any
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&profile))
|
||||
require.Equal(t, sessionAuthenticated, profile["sessionAuthenticatedAt"])
|
||||
}
|
||||
|
||||
func TestGetMe_IncludesSessionAuthenticatedAtForCookieSession(t *testing.T) {
|
||||
const (
|
||||
cookieHeader = "ory_kratos_session=session-cookie"
|
||||
identityID = "user-cookie"
|
||||
sessionAuthenticated = "2026-03-24T01:20:00Z"
|
||||
)
|
||||
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Host == "kratos.test" &&
|
||||
r.URL.Path == "/sessions/whoami" &&
|
||||
r.Method == http.MethodGet {
|
||||
require.Equal(t, cookieHeader, r.Header.Get("Cookie"))
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"id": "kratos-session-cookie",
|
||||
"authenticated_at": sessionAuthenticated,
|
||||
"identity": map[string]any{
|
||||
"id": identityID,
|
||||
"traits": map[string]any{
|
||||
"email": "cookie@example.com",
|
||||
"name": "Cookie User",
|
||||
"department": "Platform",
|
||||
"affiliationType": "GENERAL",
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})
|
||||
setDefaultHTTPClientForTest(t, transport)
|
||||
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
|
||||
|
||||
h := &AuthHandler{}
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/user/me", h.GetMe)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/me", nil)
|
||||
req.Header.Set("Cookie", cookieHeader)
|
||||
resp, err := app.Test(req, -1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var profile map[string]any
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&profile))
|
||||
require.Equal(t, sessionAuthenticated, profile["sessionAuthenticatedAt"])
|
||||
}
|
||||
@@ -142,6 +142,10 @@ type clientUpsertRequest struct {
|
||||
Metadata *map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
var protectedSystemClientIDs = map[string]struct{}{
|
||||
"oathkeeper-introspect": {},
|
||||
}
|
||||
|
||||
func normalizeUserRole(role string) string {
|
||||
return domain.NormalizeRole(role)
|
||||
}
|
||||
@@ -263,6 +267,15 @@ func profileRole(profile *domain.UserProfileResponse) string {
|
||||
return strings.TrimSpace(profile.Role)
|
||||
}
|
||||
|
||||
func isProtectedSystemClientID(clientID string) bool {
|
||||
_, ok := protectedSystemClientIDs[strings.TrimSpace(clientID)]
|
||||
return ok
|
||||
}
|
||||
|
||||
func isProtectedSystemClient(client domain.HydraClient) bool {
|
||||
return isProtectedSystemClientID(client.ClientID)
|
||||
}
|
||||
|
||||
func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
|
||||
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
if (!ok || profile == nil) && h.Auth != nil {
|
||||
@@ -557,6 +570,10 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
||||
|
||||
items := make([]clientSummary, 0, len(clients))
|
||||
for _, client := range clients {
|
||||
if isProtectedSystemClient(client) {
|
||||
continue
|
||||
}
|
||||
|
||||
summary := h.mapClientSummary(client)
|
||||
|
||||
// 1. [Security] Filter out 'private' clients if user is not an AppManager
|
||||
@@ -604,6 +621,10 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
if isProtectedSystemClient(*client) {
|
||||
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
||||
}
|
||||
|
||||
summary := h.mapClientSummary(*client)
|
||||
profile := h.getCurrentProfile(c)
|
||||
if profile == nil {
|
||||
@@ -678,6 +699,10 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
if isProtectedSystemClient(*current) {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
|
||||
}
|
||||
|
||||
summary := h.mapClientSummary(*current)
|
||||
profile := h.getCurrentProfile(c)
|
||||
if profile == nil {
|
||||
@@ -759,6 +784,9 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
if clientID == "" {
|
||||
clientID = uuid.NewString()
|
||||
}
|
||||
if isProtectedSystemClientID(clientID) {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: reserved system client id")
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(valueOr(req.Name, ""))
|
||||
if name == "" {
|
||||
@@ -899,6 +927,10 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
if isProtectedSystemClient(*current) {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
|
||||
}
|
||||
|
||||
currentSummary := h.mapClientSummary(*current)
|
||||
profile := h.getCurrentProfile(c)
|
||||
if profile == nil {
|
||||
@@ -1030,6 +1062,10 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
if isProtectedSystemClient(*current) {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
|
||||
}
|
||||
|
||||
summary := h.mapClientSummary(*current)
|
||||
profile := h.getCurrentProfile(c)
|
||||
if profile == nil {
|
||||
@@ -1265,6 +1301,10 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
if isProtectedSystemClient(*current) {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
|
||||
}
|
||||
|
||||
summary := h.mapClientSummary(*current)
|
||||
profile := h.getCurrentProfile(c)
|
||||
if profile == nil {
|
||||
@@ -1462,6 +1502,9 @@ func (h *DevHandler) GetStats(c *fiber.Ctx) error {
|
||||
var totalClients int64
|
||||
if err == nil {
|
||||
for _, client := range clients {
|
||||
if isProtectedSystemClient(client) {
|
||||
continue
|
||||
}
|
||||
if isSuperAdmin {
|
||||
totalClients++
|
||||
continue
|
||||
|
||||
@@ -124,6 +124,44 @@ func TestListClients_Success(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestListClients_ProtectedSystemClientHidden(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/clients" {
|
||||
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
||||
{"client_id": "oathkeeper-introspect", "client_name": "Internal Client"},
|
||||
{"client_id": "client-1", "client_name": "App One", "metadata": map[string]interface{}{"status": "active"}},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
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)
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var res clientListResponse
|
||||
_ = json.NewDecoder(resp.Body).Decode(&res)
|
||||
assert.Len(t, res.Items, 1)
|
||||
assert.Equal(t, "client-1", res.Items[0].ID)
|
||||
}
|
||||
|
||||
func TestUpdateClientStatus_Success(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
@@ -164,6 +202,38 @@ func TestUpdateClientStatus_Success(t *testing.T) {
|
||||
assert.Equal(t, "inactive", res.Client.Status)
|
||||
}
|
||||
|
||||
func TestUpdateClientStatus_ProtectedSystemClientForbidden(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
||||
"client_id": "oathkeeper-introspect",
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: new(devMockKetoService),
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"status": "inactive"})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/oathkeeper-introspect/status", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestDeleteClient_Success(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
@@ -204,6 +274,67 @@ func TestDeleteClient_Success(t *testing.T) {
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDeleteClient_ProtectedSystemClientForbidden(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{"client_id": "oathkeeper-introspect"}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
SecretRepo: &mockSecretRepo{secrets: map[string]string{"oathkeeper-introspect": "secret"}},
|
||||
Redis: &devMockRedisRepo{data: map[string]string{"client_secret:oathkeeper-introspect": "secret"}},
|
||||
Keto: new(devMockKetoService),
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Delete("/api/v1/dev/clients/:id", h.DeleteClient)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/dev/clients/oathkeeper-introspect", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestGetClient_ProtectedSystemClientHidden(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
||||
"client_id": "oathkeeper-introspect",
|
||||
"client_name": "Internal Client",
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: new(devMockKetoService),
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/dev/clients/:id", h.GetClient)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/oathkeeper-introspect", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestRotateClientSecret_Success(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
@@ -254,6 +385,7 @@ func TestGetStats_Success(t *testing.T) {
|
||||
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
||||
{"client_id": "c1", "metadata": map[string]interface{}{"tenant_id": "t1"}},
|
||||
{"client_id": "c2", "metadata": map[string]interface{}{"tenant_id": "t1"}},
|
||||
{"client_id": "oathkeeper-introspect", "metadata": map[string]interface{}{"tenant_id": "t1"}},
|
||||
{"client_id": "c3", "metadata": map[string]interface{}{"tenant_id": "t2"}},
|
||||
}), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Issue #434 트러블슈팅 기록: 대시보드 세션 시작 시간이 `Unknown`으로 표시됨
|
||||
|
||||
## 기준 시점
|
||||
- 2026-03-24 KST
|
||||
- 대상 화면: UserFront 대시보드 상단 세션 정보 칩
|
||||
|
||||
## 증상
|
||||
- 로그인 후 대시보드의 세션 시작 시간이 `Unknown` 또는 `알 수 없음`으로 표시됨
|
||||
- 특히 동일 브라우저의 cookie session 승격 경로에서 재현됨
|
||||
|
||||
## 원인
|
||||
1. 기존 대시보드는 저장된 로컬 토큰만 파싱해 `iat` 또는 `auth_time`을 읽었습니다.
|
||||
2. cookie mode에서는 `AuthTokenStore.setCookieMode()`가 로컬 토큰을 제거하고 cookie 플래그만 유지합니다.
|
||||
3. 그 결과 대시보드는 파싱할 JWT가 없어 항상 fallback 문구로 떨어졌습니다.
|
||||
|
||||
## 수정 방향
|
||||
1. Backend `/api/v1/user/me` 응답에 Kratos `sessions/whoami`의 `authenticated_at` 값을 `sessionAuthenticatedAt`으로 포함합니다.
|
||||
2. UserFront 대시보드는 세션 시각 계산 시 다음 우선순위를 사용합니다.
|
||||
- JWT의 `iat` 또는 `auth_time`
|
||||
- profile의 `sessionAuthenticatedAt`
|
||||
3. 두 값이 모두 없을 때만 `ui.userfront.session.unknown` fallback을 사용합니다.
|
||||
|
||||
## 반영 파일
|
||||
- `backend/internal/domain/auth_models.go`
|
||||
- `backend/internal/handler/auth_handler.go`
|
||||
- `backend/docs/openapi.yaml`
|
||||
- `userfront/lib/features/profile/data/models/user_profile_model.dart`
|
||||
- `userfront/lib/features/dashboard/domain/session_time_resolver.dart`
|
||||
- `userfront/lib/features/dashboard/presentation/dashboard_screen.dart`
|
||||
|
||||
## 회귀 테스트
|
||||
- Backend
|
||||
- `backend/internal/handler/auth_handler_session_profile_test.go`
|
||||
- UserFront
|
||||
- `userfront/test/dashboard_session_time_resolver_test.dart`
|
||||
- `userfront/test/dashboard_screen_smoke_test.dart`
|
||||
|
||||
## 검증 명령
|
||||
- `GOCACHE=/tmp/go-build go test ./internal/handler -run 'TestGetMe_IncludesSessionAuthenticatedAt' -count=1`
|
||||
- `flutter test test/dashboard_session_time_resolver_test.dart`
|
||||
- `flutter test test/dashboard_screen_smoke_test.dart`
|
||||
|
||||
## 남은 참고사항
|
||||
- Hydra introspection fallback만 사용되는 토큰 경로에서는 `sessionAuthenticatedAt`이 비어 있을 수 있습니다.
|
||||
- 이 경우에도 JWT claim이 없으면 기존 fallback 문구를 유지합니다.
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
@@ -43,8 +44,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
final TextEditingController _passwordLoginIdController =
|
||||
TextEditingController();
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
final FocusNode _passwordFocusNode = FocusNode();
|
||||
String? _redirectUrl;
|
||||
String? _loginChallenge;
|
||||
bool _isPasswordCapsLockOn = false;
|
||||
|
||||
// QR Login Variables
|
||||
String? _qrImageBase64;
|
||||
@@ -93,6 +96,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
_parseBoolParam(Uri.base.queryParameters['drySend']) &&
|
||||
!AuthProxyService.isProdEnv;
|
||||
_redirectUrl = widget.redirectUrl;
|
||||
_passwordFocusNode.addListener(_handlePasswordFocusChange);
|
||||
HardwareKeyboard.instance.addHandler(_handleHardwareKeyEvent);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
final uri = Uri.base;
|
||||
@@ -154,6 +159,40 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
});
|
||||
}
|
||||
|
||||
void _handlePasswordFocusChange() {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (_passwordFocusNode.hasFocus) {
|
||||
_syncPasswordCapsLockState();
|
||||
return;
|
||||
}
|
||||
if (_isPasswordCapsLockOn) {
|
||||
setState(() {
|
||||
_isPasswordCapsLockOn = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
bool _handleHardwareKeyEvent(KeyEvent event) {
|
||||
if (_passwordFocusNode.hasFocus) {
|
||||
_syncPasswordCapsLockState();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void _syncPasswordCapsLockState() {
|
||||
final isEnabled = HardwareKeyboard.instance.lockModesEnabled.contains(
|
||||
KeyboardLockMode.capsLock,
|
||||
);
|
||||
if (!mounted || isEnabled == _isPasswordCapsLockOn) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isPasswordCapsLockOn = isEnabled;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _tryCookieSession({bool silent = true}) async {
|
||||
final loginChallenge = _loginChallenge;
|
||||
final token = AuthTokenStore.getToken();
|
||||
@@ -936,6 +975,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
_linkIdController.dispose();
|
||||
_passwordLoginIdController.dispose();
|
||||
_passwordController.dispose();
|
||||
_passwordFocusNode
|
||||
..removeListener(_handlePasswordFocusChange)
|
||||
..dispose();
|
||||
HardwareKeyboard.instance.removeHandler(_handleHardwareKeyEvent);
|
||||
_shortCodePrefixController.dispose();
|
||||
_shortCodeDigitsController.dispose();
|
||||
_linkResendTimer?.cancel();
|
||||
@@ -1299,6 +1342,24 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
);
|
||||
}
|
||||
|
||||
String _capsLockWarningText(BuildContext context) {
|
||||
const key = 'msg.userfront.login.password.caps_lock_on';
|
||||
final languageCode = Localizations.localeOf(context).languageCode;
|
||||
if (languageCode == 'ko') {
|
||||
final translated = tr(key);
|
||||
if (translated != key) {
|
||||
return translated;
|
||||
}
|
||||
return 'Caps Lock이 켜져 있습니다.';
|
||||
}
|
||||
|
||||
final translated = tr(key, fallback: 'Caps Lock is on.');
|
||||
if (translated != key) {
|
||||
return translated;
|
||||
}
|
||||
return 'Caps Lock is on.';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_verificationOnly && _verificationApproved) {
|
||||
@@ -1410,6 +1471,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
key: const ValueKey(
|
||||
'password_login_password_input',
|
||||
),
|
||||
focusNode: _passwordFocusNode,
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
@@ -1423,6 +1485,29 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
),
|
||||
onSubmitted: (_) => _handlePasswordLogin(),
|
||||
),
|
||||
if (_isPasswordCapsLockOn) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.keyboard_capslock_rounded,
|
||||
size: 18,
|
||||
color: Colors.orange,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_capsLockWarningText(context),
|
||||
style: const TextStyle(
|
||||
color: Colors.orange,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
key: const ValueKey(
|
||||
|
||||
@@ -316,9 +316,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
phone: _phoneController.text.trim(),
|
||||
affiliationType: _affiliationType,
|
||||
companyCode: _affiliationType == 'AFFILIATE' ? _companyCode : null,
|
||||
department: _deptController.text.trim().isEmpty
|
||||
? (_affiliationType == 'GENERAL' ? 'External' : '')
|
||||
: _deptController.text.trim(),
|
||||
department: _deptController.text.trim(),
|
||||
termsAccepted: true,
|
||||
);
|
||||
if (mounted) _showSuccessDialog();
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../profile/data/models/user_profile_model.dart';
|
||||
|
||||
DateTime? resolveDashboardSessionIssuedAt({
|
||||
String? token,
|
||||
UserProfile? profile,
|
||||
}) {
|
||||
final tokenIssuedAt = _getJwtIssuedAt(token);
|
||||
if (tokenIssuedAt != null) {
|
||||
return tokenIssuedAt;
|
||||
}
|
||||
return _parseSessionAuthenticatedAt(profile?.sessionAuthenticatedAt);
|
||||
}
|
||||
|
||||
DateTime? _getJwtIssuedAt(String? token) {
|
||||
if (token == null || token.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final parts = token.split('.');
|
||||
if (parts.length != 3) {
|
||||
return null;
|
||||
}
|
||||
final payload = utf8.decode(
|
||||
base64Url.decode(base64Url.normalize(parts[1])),
|
||||
);
|
||||
final data = json.decode(payload) as Map<String, dynamic>;
|
||||
final iatValue = data['iat'] ?? data['auth_time'];
|
||||
if (iatValue is num) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(
|
||||
iatValue.toInt() * 1000,
|
||||
).toLocal();
|
||||
}
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
DateTime? _parseSessionAuthenticatedAt(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return DateTime.parse(value).toLocal();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import '../domain/session_time_resolver.dart';
|
||||
import '../domain/providers/linked_rps_provider.dart';
|
||||
import '../../../../core/notifiers/auth_notifier.dart';
|
||||
import '../../../../core/services/auth_proxy_service.dart';
|
||||
@@ -404,32 +405,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
DateTime? _getJwtIssuedAt() {
|
||||
final token = AuthTokenStore.getToken();
|
||||
if (token == null || token.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final parts = token.split('.');
|
||||
if (parts.length != 3) {
|
||||
return null;
|
||||
}
|
||||
final payload = utf8.decode(
|
||||
base64Url.decode(base64Url.normalize(parts[1])),
|
||||
);
|
||||
final data = json.decode(payload) as Map<String, dynamic>;
|
||||
final iatValue = data['iat'] ?? data['auth_time'];
|
||||
if (iatValue is num) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(
|
||||
iatValue.toInt() * 1000,
|
||||
).toLocal();
|
||||
}
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _formatDateTime(DateTime dateTime) {
|
||||
final yyyy = dateTime.year.toString().padLeft(4, '0');
|
||||
final mm = dateTime.month.toString().padLeft(2, '0');
|
||||
@@ -712,11 +687,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
profile?.email ??
|
||||
profile?.phone ??
|
||||
tr('ui.userfront.profile.user_fallback', fallback: 'User');
|
||||
final departmentValue = profile?.department ?? '';
|
||||
final departmentValue =
|
||||
profile?.tenant?.name ?? profile?.department ?? '';
|
||||
final department = departmentValue.isNotEmpty
|
||||
? departmentValue
|
||||
: tr('ui.userfront.profile.department_empty');
|
||||
final sessionIssuedAt = _getJwtIssuedAt();
|
||||
final sessionIssuedAt = resolveDashboardSessionIssuedAt(
|
||||
token: AuthTokenStore.getToken(),
|
||||
profile: profile,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: _subtle,
|
||||
@@ -1462,9 +1441,12 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
}
|
||||
|
||||
double _historySessionColumnWidth(double maxWidth) {
|
||||
return math.max(
|
||||
_historySessionMinWidth,
|
||||
maxWidth - _historyOtherColumnsBaselineWidth,
|
||||
return math.min(
|
||||
200.0,
|
||||
math.max(
|
||||
_historySessionMinWidth,
|
||||
maxWidth - _historyOtherColumnsBaselineWidth,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ class UserProfile {
|
||||
final String department;
|
||||
final String affiliationType;
|
||||
final String companyCode;
|
||||
final String? sessionAuthenticatedAt;
|
||||
final Map<String, dynamic>? metadata;
|
||||
final Tenant? tenant;
|
||||
|
||||
@@ -44,6 +45,7 @@ class UserProfile {
|
||||
required this.department,
|
||||
required this.affiliationType,
|
||||
required this.companyCode,
|
||||
this.sessionAuthenticatedAt,
|
||||
this.metadata,
|
||||
this.tenant,
|
||||
});
|
||||
@@ -57,6 +59,7 @@ class UserProfile {
|
||||
department: json['department'] ?? '',
|
||||
affiliationType: json['affiliationType'] ?? '',
|
||||
companyCode: json['companyCode'] ?? '',
|
||||
sessionAuthenticatedAt: json['sessionAuthenticatedAt'] as String?,
|
||||
metadata: json['metadata'] != null
|
||||
? Map<String, dynamic>.from(json['metadata'])
|
||||
: null,
|
||||
@@ -73,6 +76,7 @@ class UserProfile {
|
||||
'department': department,
|
||||
'affiliationType': affiliationType,
|
||||
'companyCode': companyCode,
|
||||
'sessionAuthenticatedAt': sessionAuthenticatedAt,
|
||||
'metadata': metadata,
|
||||
'tenant': tenant?.toJson(),
|
||||
};
|
||||
@@ -87,6 +91,7 @@ class UserProfile {
|
||||
department: department ?? this.department,
|
||||
affiliationType: affiliationType,
|
||||
companyCode: companyCode,
|
||||
sessionAuthenticatedAt: sessionAuthenticatedAt,
|
||||
tenant: tenant,
|
||||
);
|
||||
}
|
||||
|
||||
35
userfront/test/dashboard_session_time_resolver_test.dart
Normal file
35
userfront/test/dashboard_session_time_resolver_test.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/features/dashboard/domain/session_time_resolver.dart';
|
||||
import 'package:userfront/features/profile/data/models/user_profile_model.dart';
|
||||
|
||||
void main() {
|
||||
test('JWT에 iat가 있으면 세션 시각으로 사용한다', () {
|
||||
const token = 'eyJhbGciOiJub25lIn0.eyJpYXQiOjE3MTEyMDc4MDB9.signature';
|
||||
|
||||
final issuedAt = resolveDashboardSessionIssuedAt(token: token);
|
||||
|
||||
expect(issuedAt, isNotNull);
|
||||
expect(issuedAt!.toUtc().toIso8601String(), '2024-03-23T15:30:00.000Z');
|
||||
});
|
||||
|
||||
test('cookie mode에서는 profile의 sessionAuthenticatedAt으로 복원한다', () {
|
||||
final profile = UserProfile(
|
||||
id: 'user-1',
|
||||
email: 'qa@example.com',
|
||||
name: 'QA User',
|
||||
phone: '01012345678',
|
||||
department: 'Platform',
|
||||
affiliationType: 'GENERAL',
|
||||
companyCode: '',
|
||||
sessionAuthenticatedAt: '2026-03-23T15:30:00Z',
|
||||
);
|
||||
|
||||
final issuedAt = resolveDashboardSessionIssuedAt(
|
||||
token: null,
|
||||
profile: profile,
|
||||
);
|
||||
|
||||
expect(issuedAt, isNotNull);
|
||||
expect(issuedAt!.toUtc().toIso8601String(), '2026-03-23T15:30:00.000Z');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user