diff --git a/internal/api/handlers/dicom.go b/internal/api/handlers/dicom.go index afbd0d4..78bdc4a 100644 --- a/internal/api/handlers/dicom.go +++ b/internal/api/handlers/dicom.go @@ -61,10 +61,66 @@ func (h *DicomHandler) ForwardRequest(w http.ResponseWriter, r *http.Request) { if claims != nil { switch claims.Role { case "patient": - // For patients requesting study list, filter to only show their study + // For patients requesting study list, filter to only show their studies if strings.HasPrefix(urlPath, "/studies") && !strings.Contains(urlPath, "/") { - // Ensure only the patient's study is shown - queryParams.Set("StudyInstanceUID", claims.StudyIUID) + // Check if multiple studies are available in the claim + if len(claims.StudyIUIDs) > 0 { + // Remove existing StudyInstanceUID param if it exists + queryParams.Del("StudyInstanceUID") + + // For DICOMweb, we can use comma-separated UIDs + queryParams.Set("StudyInstanceUID", strings.Join(claims.StudyIUIDs, ",")) + + h.logger.Debug("Filtering by multiple studies", + zap.Strings("studies", claims.StudyIUIDs), + ) + } else if claims.StudyIUID != "" { + // Fallback for backward compatibility - ensure only the patient's study is shown + queryParams.Set("StudyInstanceUID", claims.StudyIUID) + } + } else if strings.HasPrefix(urlPath, "/studies/") { + // This is a request for a specific study - check if the patient is authorized + + // Extract the study ID from the path + // Format: /studies/{studyID}/... + pathParts := strings.Split(strings.TrimPrefix(urlPath, "/"), "/") + if len(pathParts) >= 2 { + studyID := pathParts[1] + + // Check if this study is in the patient's authorized studies + authorized := false + + // First check StudyIUIDs array + if len(claims.StudyIUIDs) > 0 { + for _, id := range claims.StudyIUIDs { + if id == studyID { + authorized = true + break + } + } + } + + // Then check single StudyIUID for backward compatibility + if !authorized && claims.StudyIUID == studyID { + authorized = true + } + + // If not authorized, return 403 Forbidden + if !authorized { + h.logger.Warn("Unauthorized study access attempt", + zap.String("studyID", studyID), + zap.String("patientID", claims.PatientID), + zap.Strings("authorizedStudies", claims.StudyIUIDs), + ) + http.Error(w, "Forbidden: You are not authorized to access this study", http.StatusForbidden) + return + } + + h.logger.Debug("Authorized access to specific study", + zap.String("studyID", studyID), + zap.String("patientID", claims.PatientID), + ) + } } case "ref_doctor": // For ref_doctor requesting study list, apply filter from token diff --git a/internal/api/middleware/auth.go b/internal/api/middleware/auth.go index fe714e3..79bbbb1 100644 --- a/internal/api/middleware/auth.go +++ b/internal/api/middleware/auth.go @@ -187,21 +187,49 @@ func PatientViewRestriction(logger *zap.Logger) func(http.Handler) http.Handler } // If a study is being requested, verify patient has access - if requestedStudyUID != "" && requestedStudyUID != claims.StudyIUID { - logger.Warn("Patient attempted to access unauthorized study", - zap.String("userID", claims.UserID), - zap.String("role", claims.Role), - zap.String("authorizedStudy", claims.StudyIUID), - zap.String("requestedStudy", requestedStudyUID)) + if requestedStudyUID != "" { + // Check if the requested study is authorized + isAuthorized := false - // Return 403 Forbidden with a clear message - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusForbidden) - json.NewEncoder(w).Encode(map[string]string{ - "error": "Access denied: You do not have permission to view this study", - "code": "forbidden_study_access", - }) - return + // First check the array of studies if available + if len(claims.StudyIUIDs) > 0 { + for _, studyUID := range claims.StudyIUIDs { + if studyUID == requestedStudyUID { + isAuthorized = true + logger.Debug("Patient authorized to access study from StudyIUIDs array", + zap.String("userID", claims.UserID), + zap.String("requestedStudy", requestedStudyUID)) + break + } + } + } + + // If not found in the array, check the single StudyIUID for backward compatibility + if !isAuthorized && claims.StudyIUID == requestedStudyUID { + isAuthorized = true + logger.Debug("Patient authorized to access study from StudyIUID", + zap.String("userID", claims.UserID), + zap.String("requestedStudy", requestedStudyUID)) + } + + // If still not authorized, return 403 Forbidden + if !isAuthorized { + logger.Warn("Patient attempted to access unauthorized study", + zap.String("userID", claims.UserID), + zap.String("role", claims.Role), + zap.String("requestedStudy", requestedStudyUID), + zap.Strings("authorizedStudies", claims.StudyIUIDs), + zap.String("authorizedStudy", claims.StudyIUID)) + + // Return 403 Forbidden with a clear message + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Access denied: You do not have permission to view this study", + "code": "forbidden_study_access", + }) + return + } } // Patient has access or is requesting a list (which will be filtered) diff --git a/internal/api/models/mock_data.go b/internal/api/models/mock_data.go index b437506..8914915 100644 --- a/internal/api/models/mock_data.go +++ b/internal/api/models/mock_data.go @@ -38,12 +38,14 @@ var MockUsers = []User{ // PatientData represents additional data for patients type PatientData struct { - PatientID string `json:"patient_id"` - UserID string `json:"user_id"` - StudyIUID string `json:"study_iuid"` - AccessionNumber string `json:"accession_number"` - PatientName string `json:"patient_name"` - ReferringPhysician string `json:"referring_physician"` + PatientID string `json:"patient_id"` + UserID string `json:"user_id"` + StudyIUID string `json:"study_iuid,omitempty"` // For backward compatibility + StudyIUIDs []string `json:"study_iuids,omitempty"` // Multiple study IDs + AccessionNumber string `json:"accession_number,omitempty"` // For backward compatibility + AccessionNumbers []string `json:"accession_numbers,omitempty"` // Multiple accession numbers + PatientName string `json:"patient_name"` + ReferringPhysician string `json:"referring_physician"` } // MockPatients represents a mock database of patient data @@ -51,8 +53,8 @@ var MockPatients = []PatientData{ { PatientID: "00211622", UserID: "2", - StudyIUID: "1.2.826.0.1.3680043.9.7307.1.20180713036", - AccessionNumber: "CR.180713.036", + StudyIUIDs: []string{"1.2.826.0.1.3680043.9.7307.1.20180530066", "1.2.826.0.1.3680043.9.7307.1.20180713036"}, + AccessionNumbers: []string{"CR.180530.066", "CR.180713.036"}, PatientName: "DIDIT SUYATNA^R.10049.18", ReferringPhysician: "DR. HERWINDO RIDWAN, SP.OT", }, diff --git a/internal/api/models/user.go b/internal/api/models/user.go index 8982554..e509032 100644 --- a/internal/api/models/user.go +++ b/internal/api/models/user.go @@ -23,10 +23,12 @@ type RefreshToken struct { // PatientDetails contains patient-specific data type PatientDetails struct { - PatientID string `json:"patient_id"` - PatientName string `json:"patient_name"` - AccessionNumber string `json:"accession_number"` - StudyInstanceUID string `json:"study_instance_uid"` + PatientID string `json:"patient_id"` + PatientName string `json:"patient_name"` + AccessionNumber string `json:"accession_number,omitempty"` // For backward compatibility + AccessionNumbers []string `json:"accession_numbers,omitempty"` // Multiple accession numbers + StudyInstanceUID string `json:"study_instance_uid,omitempty"` // For backward compatibility + StudyInstanceUIDs []string `json:"study_instance_uids,omitempty"` // Multiple study IDs } // DoctorDetails contains doctor-specific data diff --git a/internal/api/routes.go b/internal/api/routes.go index 3484bb6..66e237d 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -115,7 +115,7 @@ func SetupRouter(cfg *config.Config, logger *zap.Logger) http.Handler { }) }) - // Add this to your routes setup - in the public routes group + // * DEBUG PURPOSE ONLY * r.Get("/debug/token", func(w http.ResponseWriter, r *http.Request) { authHeader := r.Header.Get("Authorization") if authHeader == "" { diff --git a/internal/api/service/auth_service.go b/internal/api/service/auth_service.go index e8ebdba..4a683c3 100644 --- a/internal/api/service/auth_service.go +++ b/internal/api/service/auth_service.go @@ -39,7 +39,7 @@ func (s *AuthService) Login(email, password string) (*models.LoginResponse, erro } // Create token claims based on user role - additionalClaims := make(map[string]string) + additionalClaims := make(map[string]interface{}) var redirectURL string switch user.Role { @@ -53,12 +53,29 @@ func (s *AuthService) Login(email, password string) (*models.LoginResponse, erro // Set patient-specific claims additionalClaims["patient_id"] = patientData.PatientID additionalClaims["patient_name"] = patientData.PatientName - additionalClaims["accession_number"] = patientData.AccessionNumber - additionalClaims["study_iuid"] = patientData.StudyIUID - additionalClaims["home_url"] = fmt.Sprintf("viewer?StudyInstanceUIDs=%s", patientData.StudyIUID) - additionalClaims["study_list"] = "disabled" - redirectURL = fmt.Sprintf("/viewer?StudyInstanceUIDs=%s", patientData.StudyIUID) + // Handle multiple studies if available + if len(patientData.StudyIUIDs) > 0 { + // Store all studies in the token for DICOMWeb access + additionalClaims["study_iuids"] = patientData.StudyIUIDs + additionalClaims["accession_numbers"] = patientData.AccessionNumbers + + // Set the first study as default for display and backward compatibility + additionalClaims["study_iuid"] = patientData.StudyIUIDs[0] + additionalClaims["accession_number"] = patientData.AccessionNumbers[0] + + // Only use the first study in the redirect URL + additionalClaims["home_url"] = fmt.Sprintf("viewer?StudyInstanceUIDs=%s", patientData.StudyIUIDs[0]) + redirectURL = fmt.Sprintf("/viewer?StudyInstanceUIDs=%s", patientData.StudyIUIDs[0]) + } else { + // Fall back to single study for backward compatibility + additionalClaims["study_iuid"] = patientData.StudyIUID + additionalClaims["accession_number"] = patientData.AccessionNumber + additionalClaims["home_url"] = fmt.Sprintf("viewer?StudyInstanceUIDs=%s", patientData.StudyIUID) + redirectURL = fmt.Sprintf("/viewer?StudyInstanceUIDs=%s", patientData.StudyIUID) + } + + additionalClaims["study_list"] = "disabled" case "ref_doctor": // Set referring doctor claims @@ -112,7 +129,9 @@ func (s *AuthService) RefreshToken(refreshToken string) (string, error) { } // Build additionalClaims from the refresh token - additionalClaims := make(map[string]string) + additionalClaims := make(map[string]interface{}) + + // Add string claims if claims.PatientID != "" { additionalClaims["patient_id"] = claims.PatientID } @@ -135,6 +154,14 @@ func (s *AuthService) RefreshToken(refreshToken string) (string, error) { additionalClaims["filter_url"] = claims.FilterURL } + // Add array claims + if len(claims.StudyIUIDs) > 0 { + additionalClaims["study_iuids"] = claims.StudyIUIDs + } + if len(claims.AccessionNumbers) > 0 { + additionalClaims["accession_numbers"] = claims.AccessionNumbers + } + // Generate a new access token with the same claims accessToken, err := s.jwtManager.GenerateAccessToken(claims.UserID, claims.Email, claims.Role, claims.UserName, additionalClaims) if err != nil { diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index e65c90b..e310fd2 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -38,10 +38,12 @@ type CustomClaims struct { TokenType string `json:"token_type"` // access or refresh // Patient-specific fields - PatientID string `json:"patient_id,omitempty"` - PatientName string `json:"patient_name,omitempty"` - AccessionNumber string `json:"accession_number,omitempty"` - StudyIUID string `json:"study_iuid,omitempty"` + PatientID string `json:"patient_id,omitempty"` + PatientName string `json:"patient_name,omitempty"` + AccessionNumber string `json:"accession_number,omitempty"` // For backward compatibility + AccessionNumbers []string `json:"accession_numbers,omitempty"` // Multiple accession numbers + StudyIUID string `json:"study_iuid,omitempty"` // For backward compatibility + StudyIUIDs []string `json:"study_iuids,omitempty"` // Multiple study IUIDs // Navigation and permissions HomeURL string `json:"home_url,omitempty"` @@ -52,7 +54,7 @@ type CustomClaims struct { } // GenerateAccessToken creates a new access token with role-specific claims -func (m *JWTManager) GenerateAccessToken(userID, email, role, userName string, additionalClaims map[string]string) (string, error) { +func (m *JWTManager) GenerateAccessToken(userID, email, role, userName string, additionalClaims map[string]interface{}) (string, error) { claims := CustomClaims{ UserID: userID, Email: email, @@ -67,27 +69,36 @@ func (m *JWTManager) GenerateAccessToken(userID, email, role, userName string, a // Add role-specific additional claims if additionalClaims != nil { - if val, ok := additionalClaims["patient_id"]; ok { + // Handle string claims + if val, ok := additionalClaims["patient_id"].(string); ok { claims.PatientID = val } - if val, ok := additionalClaims["patient_name"]; ok { + if val, ok := additionalClaims["patient_name"].(string); ok { claims.PatientName = val } - if val, ok := additionalClaims["accession_number"]; ok { + if val, ok := additionalClaims["accession_number"].(string); ok { claims.AccessionNumber = val } - if val, ok := additionalClaims["study_iuid"]; ok { + if val, ok := additionalClaims["study_iuid"].(string); ok { claims.StudyIUID = val } - if val, ok := additionalClaims["home_url"]; ok { + if val, ok := additionalClaims["home_url"].(string); ok { claims.HomeURL = val } - if val, ok := additionalClaims["study_list"]; ok { + if val, ok := additionalClaims["study_list"].(string); ok { claims.StudyList = val } - if val, ok := additionalClaims["filter_url"]; ok { + if val, ok := additionalClaims["filter_url"].(string); ok { claims.FilterURL = val } + + // Handle array claims + if val, ok := additionalClaims["study_iuids"].([]string); ok { + claims.StudyIUIDs = val + } + if val, ok := additionalClaims["accession_numbers"].([]string); ok { + claims.AccessionNumbers = val + } } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) @@ -95,7 +106,7 @@ func (m *JWTManager) GenerateAccessToken(userID, email, role, userName string, a } // GenerateRefreshToken creates a new refresh token with the same claims as access token -func (m *JWTManager) GenerateRefreshToken(userID, email, role, userName string, additionalClaims map[string]string) (string, error) { +func (m *JWTManager) GenerateRefreshToken(userID, email, role, userName string, additionalClaims map[string]interface{}) (string, error) { claims := CustomClaims{ UserID: userID, Email: email, @@ -108,29 +119,38 @@ func (m *JWTManager) GenerateRefreshToken(userID, email, role, userName string, }, } - // Add role-specific additional claims (same as access token) + // Add role-specific additional claims if additionalClaims != nil { - if val, ok := additionalClaims["patient_id"]; ok { + // Handle string claims + if val, ok := additionalClaims["patient_id"].(string); ok { claims.PatientID = val } - if val, ok := additionalClaims["patient_name"]; ok { + if val, ok := additionalClaims["patient_name"].(string); ok { claims.PatientName = val } - if val, ok := additionalClaims["accession_number"]; ok { + if val, ok := additionalClaims["accession_number"].(string); ok { claims.AccessionNumber = val } - if val, ok := additionalClaims["study_iuid"]; ok { + if val, ok := additionalClaims["study_iuid"].(string); ok { claims.StudyIUID = val } - if val, ok := additionalClaims["home_url"]; ok { + if val, ok := additionalClaims["home_url"].(string); ok { claims.HomeURL = val } - if val, ok := additionalClaims["study_list"]; ok { + if val, ok := additionalClaims["study_list"].(string); ok { claims.StudyList = val } - if val, ok := additionalClaims["filter_url"]; ok { + if val, ok := additionalClaims["filter_url"].(string); ok { claims.FilterURL = val } + + // Handle array claims + if val, ok := additionalClaims["study_iuids"].([]string); ok { + claims.StudyIUIDs = val + } + if val, ok := additionalClaims["accession_numbers"].([]string); ok { + claims.AccessionNumbers = val + } } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)