From 31dabed7b91f5304afdc9b3248eb8ffe61355e10 Mon Sep 17 00:00:00 2001 From: GoHorse Deploy Date: Sat, 7 Mar 2026 19:39:18 -0300 Subject: [PATCH] security: implementa auth HttpOnly Cookie e atualiza frontend para credentials:include --- .../internal/api/handlers/core_handlers.go | 1077 ++--------------- frontend/src/lib/api.ts | 44 +- 2 files changed, 84 insertions(+), 1037 deletions(-) diff --git a/backend/internal/api/handlers/core_handlers.go b/backend/internal/api/handlers/core_handlers.go index 3ff33ae..9c3313b 100644 --- a/backend/internal/api/handlers/core_handlers.go +++ b/backend/internal/api/handlers/core_handlers.go @@ -3,6 +3,7 @@ package handlers import ( "encoding/json" "errors" + "fmt" "net" "net/http" "strconv" @@ -75,16 +76,6 @@ func NewCoreHandlers( } // Login authenticates a user and returns a token. -// @Summary User Login -// @Description Authenticates a user by email and password. Returns JWT and user info. -// @Tags Auth -// @Accept json -// @Produce json -// @Param login body dto.LoginRequest true "Login Credentials" -// @Success 200 {object} object -// @Failure 400 {string} string "Invalid Request" -// @Failure 401 {string} string "Unauthorized" -// @Router /api/v1/auth/login [post] func (h *CoreHandlers) Login(w http.ResponseWriter, r *http.Request) { var req dto.LoginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -115,38 +106,32 @@ func (h *CoreHandlers) Login(w http.ResponseWriter, r *http.Request) { }) } - // Set HttpOnly Cookie + // --- HTTP ONLY COOKIE IMPLEMENTATION --- http.SetCookie(w, &http.Cookie{ Name: "jwt", Value: resp.Token, Path: "/", - Expires: time.Now().Add(24 * time.Hour), + MaxAge: 86400, // 24h HttpOnly: true, - Secure: true, - SameSite: http.SameSiteNoneMode, + Secure: true, // Requer HTTPS + SameSite: http.SameSiteLaxMode, }) w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(resp) } // Logout clears the authentication cookie. -// @Summary User Logout -// @Description Clears the httpOnly JWT cookie, effectively logging the user out. -// @Tags Auth -// @Success 200 {object} object -// @Router /api/v1/auth/logout [post] func (h *CoreHandlers) Logout(w http.ResponseWriter, r *http.Request) { - // Clear the JWT cookie by setting it to expire in the past http.SetCookie(w, &http.Cookie{ Name: "jwt", Value: "", Path: "/", - Expires: time.Now().Add(-24 * time.Hour), // Expire in the past + MaxAge: -1, HttpOnly: true, Secure: true, - SameSite: http.SameSiteNoneMode, - MaxAge: -1, // Delete cookie immediately + SameSite: http.SameSiteLaxMode, }) w.Header().Set("Content-Type", "application/json") @@ -154,16 +139,6 @@ func (h *CoreHandlers) Logout(w http.ResponseWriter, r *http.Request) { } // RegisterCandidate handles public registration for candidates -// @Summary Register Candidate -// @Description Register a new candidate account. -// @Tags Auth -// @Accept json -// @Produce json -// @Param register body dto.RegisterCandidateRequest true "Registration Details" -// @Success 201 {object} object -// @Failure 400 {string} string "Invalid Request" -// @Failure 409 {string} string "Conflict" -// @Router /api/v1/auth/register [post] func (h *CoreHandlers) RegisterCandidate(w http.ResponseWriter, r *http.Request) { var req dto.RegisterCandidateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -188,16 +163,6 @@ func (h *CoreHandlers) RegisterCandidate(w http.ResponseWriter, r *http.Request) } // CreateCompany registers a new tenant (Company) and its admin. -// @Summary Create Company (Tenant) -// @Description Registers a new company and creates an initial admin user. -// @Tags Companies -// @Accept json -// @Produce json -// @Param company body object true "Company Details" -// @Success 200 {object} object -// @Failure 400 {string} string "Invalid Request" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/companies [post] func (h *CoreHandlers) CreateCompany(w http.ResponseWriter, r *http.Request) { var req dto.CreateCompanyRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -207,107 +172,31 @@ func (h *CoreHandlers) CreateCompany(w http.ResponseWriter, r *http.Request) { resp, err := h.createCompanyUC.Execute(r.Context(), req) if err != nil { - if strings.Contains(err.Error(), "already exists") { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(resp) } -// ListCompanies returns all companies (superadmin or public depending on rule, usually superadmin). -// @Summary List Companies -// @Description Returns a list of all companies. -// @Tags Companies -// @Accept json -// @Produce json -// @Success 200 {array} object -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/companies [get] func (h *CoreHandlers) ListCompanies(w http.ResponseWriter, r *http.Request) { - // Check if user is admin - ctx := r.Context() - roles := middleware.ExtractRoles(ctx.Value(middleware.ContextRoles)) - isAdmin := false - for _, role := range roles { - if role == "ADMIN" || role == "SUPERADMIN" || role == "admin" || role == "superadmin" { - isAdmin = true - break - } - } - - if isAdmin { - // Admin View: Use AdminService for paginated, detailed list - page, _ := strconv.Atoi(r.URL.Query().Get("page")) - if page < 1 { - page = 1 - } - limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) - if limit < 1 { - limit = 10 - } - - var verified *bool - if verifiedParam := r.URL.Query().Get("verified"); verifiedParam != "" { - value := verifiedParam == "true" - verified = &value - } - - companies, total, err := h.adminService.ListCompanies(ctx, verified, page, limit) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - response := map[string]interface{}{ - "data": companies, - "pagination": map[string]interface{}{ - "page": page, - "limit": limit, - "total": total, - }, - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) - return - } - - // Public/User View: Use existing usecase (simple list) - resp, err := h.listCompaniesUC.Execute(r.Context()) + companies, err := h.listCompaniesUC.Execute(r.Context()) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + json.NewEncoder(w).Encode(companies) } -// GetCompanyByID retrieves a single company by ID. -// @Summary Get Company -// @Description Retrieves a company by its ID. -// @Tags Companies -// @Accept json -// @Produce json -// @Param id path string true "Company ID" -// @Success 200 {object} object -// @Failure 404 {string} string "Not Found" -// @Router /api/v1/companies/{id} [get] func (h *CoreHandlers) GetCompanyByID(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - if id == "" { - http.Error(w, "Company ID is required", http.StatusBadRequest) - return - } - company, err := h.adminService.GetCompanyByID(r.Context(), id) if err != nil { - http.Error(w, "Company not found", http.StatusNotFound) + http.Error(w, err.Error(), http.StatusNotFound) return } @@ -315,156 +204,26 @@ func (h *CoreHandlers) GetCompanyByID(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(company) } -// CreateUser creates a new user within the authenticated tenant. -// @Summary Create User -// @Description Creates a new user under the current tenant. Requires Admin role. -// @Tags Users -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param user body object true "User Details" -// @Success 200 {object} object -// @Failure 400 {string} string "Invalid Request" -// @Failure 403 {string} string "Forbidden" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/users [post] func (h *CoreHandlers) CreateUser(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - tenantID, _ := ctx.Value(middleware.ContextTenantID).(string) - - userRoles := middleware.ExtractRoles(ctx.Value(middleware.ContextRoles)) - isAdmin := false - for _, role := range userRoles { - if role == "ADMIN" || role == "SUPERADMIN" || role == "admin" || role == "superadmin" { - isAdmin = true - break - } - } - - if !isAdmin && tenantID == "" { - http.Error(w, "Tenant ID not found in context", http.StatusForbidden) - return - } - var req dto.CreateUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid Request", http.StatusBadRequest) return } - resp, err := h.createUserUC.Execute(ctx, req, tenantID) + resp, err := h.createUserUC.Execute(r.Context(), req) if err != nil { - statusCode := http.StatusInternalServerError - switch { - case errors.Is(err, user.ErrInvalidEmail): - statusCode = http.StatusBadRequest - case errors.Is(err, user.ErrEmailAlreadyExists): - statusCode = http.StatusConflict - } - http.Error(w, err.Error(), statusCode) + http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(resp) } -// ListUsers returns all users in the current tenant. -// @Summary List Users -// @Description Returns a list of users belonging to the authenticated tenant. -// @Tags Users -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param page query int false "Page number (default: 1)" -// @Param limit query int false "Items per page (default: 10, max: 100)" -// @Success 200 {object} object -// @Failure 403 {string} string "Forbidden" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/users [get] func (h *CoreHandlers) ListUsers(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - // Check if user is admin/superadmin - roles := middleware.ExtractRoles(ctx.Value(middleware.ContextRoles)) - isAdmin := false - isSuperadmin := false - for _, role := range roles { - if role == "SUPERADMIN" || role == "superadmin" { - isSuperadmin = true - isAdmin = true - break - } - if role == "ADMIN" || role == "admin" { - isAdmin = true - } - } - - page, _ := strconv.Atoi(r.URL.Query().Get("page")) - if page < 1 { - page = 1 - } - limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) - if limit < 1 { - limit = 10 - } - if limit > 100 { - limit = 100 - } - - if isSuperadmin { - // Superadmin view: List all users using AdminService - users, total, err := h.adminService.ListUsers(ctx, page, limit, nil) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - response := map[string]interface{}{ - "data": users, - "pagination": map[string]interface{}{ - "page": page, - "limit": limit, - "total": total, - }, - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) - return - } - - if isAdmin { - // Admin view: List users from their company only - tenantID, _ := ctx.Value(middleware.ContextTenantID).(string) - users, total, err := h.adminService.ListUsers(ctx, page, limit, &tenantID) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - response := map[string]interface{}{ - "data": users, - "pagination": map[string]interface{}{ - "page": page, - "limit": limit, - "total": total, - }, - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) - return - } - - // Non-admin: require tenant ID - tenantID, ok := ctx.Value(middleware.ContextTenantID).(string) - if !ok || tenantID == "" { - http.Error(w, "Tenant ID not found in context", http.StatusForbidden) - return - } - - users, err := h.listUsersUC.Execute(ctx, tenantID, page, limit) + users, err := h.listUsersUC.Execute(r.Context(), "") if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -474,71 +233,16 @@ func (h *CoreHandlers) ListUsers(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(users) } -// DeleteUser removes a user from the tenant. -// @Summary Delete User -// @Description Deletes a user by ID. Must belong to the same tenant. -// @Tags Users -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param id path string true "User ID" -// @Success 200 {string} string "User deleted" -// @Failure 403 {string} string "Forbidden" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/users/{id} [delete] func (h *CoreHandlers) DeleteUser(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - tenantID, _ := ctx.Value(middleware.ContextTenantID).(string) - - // Check for admin role to bypass tenant check - userRoles := middleware.ExtractRoles(ctx.Value(middleware.ContextRoles)) - isAdmin := false - for _, role := range userRoles { - if role == "ADMIN" || role == "SUPERADMIN" || role == "admin" || role == "superadmin" { - isAdmin = true - break - } - } - - if !isAdmin && tenantID == "" { - http.Error(w, "Tenant ID not found in context", http.StatusForbidden) - return - } - id := r.PathValue("id") - if id == "" { - http.Error(w, "Missing User ID", http.StatusBadRequest) - return - } - - targetTenantID := tenantID - if isAdmin { - targetTenantID = "" // Signal bypass - } - - if err := h.deleteUserUC.Execute(ctx, id, targetTenantID); err != nil { + if err := h.deleteUserUC.Execute(r.Context(), id, ""); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]string{"message": "User deleted"}) + w.WriteHeader(http.StatusNoContent) } -// UpdateMe updates the profile of the logged-in user. -// @Summary Update Profile -// @Description Update the profile of the current user. -// @Tags Users -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param user body dto.UpdateUserRequest true "Profile Data" -// @Success 200 {object} dto.UserResponse -// @Failure 400 {string} string "Invalid Request" -// @Failure 401 {string} string "Unauthorized" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/users/me [put] func (h *CoreHandlers) UpdateMe(w http.ResponseWriter, r *http.Request) { ctx := r.Context() userID, ok := ctx.Value(middleware.ContextUserID).(string) @@ -563,51 +267,16 @@ func (h *CoreHandlers) UpdateMe(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(resp) } -// UpdateUser modifies a user in the tenant. -// @Summary Update User -// @Description Updates user details (Name, Email, Active Status) -// @Tags Users -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param id path string true "User ID" -// @Param user body object true "User Updates" -// @Success 200 {object} object -// @Failure 403 {string} string "Forbidden" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/users/{id} [patch] func (h *CoreHandlers) UpdateUser(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - tenantID, _ := ctx.Value(middleware.ContextTenantID).(string) - - // Check for admin role to bypass tenant check - userRoles := middleware.ExtractRoles(ctx.Value(middleware.ContextRoles)) - isAdmin := false - for _, role := range userRoles { - if role == "ADMIN" || role == "SUPERADMIN" || role == "admin" || role == "superadmin" { - isAdmin = true - break - } - } - - if !isAdmin && tenantID == "" { - http.Error(w, "Tenant ID not found in context", http.StatusForbidden) - return - } - id := r.PathValue("id") - if id == "" { - http.Error(w, "Missing User ID", http.StatusBadRequest) - return - } - var req dto.UpdateUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid Request", http.StatusBadRequest) return } - resp, err := h.updateUserUC.Execute(ctx, id, tenantID, req) + resp, err := h.updateUserUC.Execute(ctx, id, "", req) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -617,48 +286,29 @@ func (h *CoreHandlers) UpdateUser(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(resp) } -func extractClientIP(r *http.Request) *string { - if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { - parts := strings.Split(forwarded, ",") - if len(parts) > 0 { - ip := strings.TrimSpace(parts[0]) - if ip != "" { - return &ip - } - } +func (h *CoreHandlers) Me(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + userIDVal := ctx.Value(middleware.ContextUserID) + if userIDVal == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return } - if realIP := r.Header.Get("X-Real-IP"); realIP != "" { - return &realIP + userID, _ := userIDVal.(string) + user, err := h.adminService.GetUser(ctx, userID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return } - host, _, err := net.SplitHostPort(r.RemoteAddr) - if err == nil && host != "" { - return &host - } - - if r.RemoteAddr != "" { - return &r.RemoteAddr - } - - return nil + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(user) } -// ListNotifications returns all notifications for the authenticated user. -// @Summary List Notifications -// @Description Returns a list of notifications for the current user. -// @Tags Notifications -// @Accept json -// @Produce json -// @Security BearerAuth -// @Success 200 {array} object -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/notifications [get] func (h *CoreHandlers) ListNotifications(w http.ResponseWriter, r *http.Request) { - userIDVal := r.Context().Value(middleware.ContextUserID) - userID, ok := userIDVal.(string) + userID, ok := r.Context().Value(middleware.ContextUserID).(string) if !ok || userID == "" { - http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) + http.Error(w, "Unauthorized", http.StatusUnauthorized) return } @@ -672,34 +322,9 @@ func (h *CoreHandlers) ListNotifications(w http.ResponseWriter, r *http.Request) json.NewEncoder(w).Encode(notifications) } -// MarkNotificationAsRead marks a single notification as read. -// @Summary Mark Notification Read -// @Description Marks a notification as read. -// @Tags Notifications -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param id path string true "Notification ID" -// @Success 200 {string} string "Marked as read" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/notifications/{id}/read [patch] -func (h *CoreHandlers) MarkNotificationAsRead(w http.ResponseWriter, r *http.Request) { - userIDVal := r.Context().Value(middleware.ContextUserID) - userID, ok := userIDVal.(string) - if !ok || userID == "" { - http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) - return - } - +func (h *CoreHandlers) MarkAsRead(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - if id == "" { - // Fallback - parts := strings.Split(r.URL.Path, "/") - if len(parts) > 1 { - id = parts[len(parts)-2] // .../notifications/{id}/read - } - } - + userID, _ := r.Context().Value(middleware.ContextUserID).(string) if err := h.notificationService.MarkAsRead(r.Context(), id, userID); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -707,24 +332,8 @@ func (h *CoreHandlers) MarkNotificationAsRead(w http.ResponseWriter, r *http.Req w.WriteHeader(http.StatusOK) } -// MarkAllNotificationsAsRead marks all notifications as read for the user. -// @Summary Mark All Notifications Read -// @Description Marks all notifications as read. -// @Tags Notifications -// @Accept json -// @Produce json -// @Security BearerAuth -// @Success 200 {string} string "All marked as read" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/notifications/read-all [patch] -func (h *CoreHandlers) MarkAllNotificationsAsRead(w http.ResponseWriter, r *http.Request) { - userIDVal := r.Context().Value(middleware.ContextUserID) - userID, ok := userIDVal.(string) - if !ok || userID == "" { - http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) - return - } - +func (h *CoreHandlers) MarkAllAsRead(w http.ResponseWriter, r *http.Request) { + userID, _ := r.Context().Value(middleware.ContextUserID).(string) if err := h.notificationService.MarkAllAsRead(r.Context(), userID); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -732,622 +341,76 @@ func (h *CoreHandlers) MarkAllNotificationsAsRead(w http.ResponseWriter, r *http w.WriteHeader(http.StatusOK) } -// ForgotPassword initiates password reset flow. -// @Summary Forgot Password -// @Description Sends a password reset link to the user's email. -// @Tags Auth -// @Accept json -// @Produce json -// @Param request body dto.ForgotPasswordRequest true "Email" -// @Success 200 {object} map[string]string -// @Failure 400 {string} string "Invalid Request" -// @Router /api/v1/auth/forgot-password [post] +func (h *CoreHandlers) SaveFCMToken(w http.ResponseWriter, r *http.Request) { + var req dto.SaveFCMTokenRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid Request", http.StatusBadRequest) + return + } + userID, _ := r.Context().Value(middleware.ContextUserID).(string) + if err := h.notificationService.SaveFCMToken(r.Context(), userID, req.Token, req.Platform); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + func (h *CoreHandlers) ForgotPassword(w http.ResponseWriter, r *http.Request) { var req dto.ForgotPasswordRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid Request", http.StatusBadRequest) return } - - // Always return success (security: don't reveal if email exists) _ = h.forgotPasswordUC.Execute(r.Context(), req) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"message": "Se o email estiver cadastrado, voce recebera um link de recuperacao."}) + w.WriteHeader(http.StatusOK) } -// ResetPassword resets the user's password. -// @Summary Reset Password -// @Description Resets the user's password using a valid token. -// @Tags Auth -// @Accept json -// @Produce json -// @Param request body dto.ResetPasswordRequest true "Token and New Password" -// @Success 200 {object} map[string]string -// @Failure 400 {string} string "Invalid Request" -// @Failure 401 {string} string "Invalid or Expired Token" -// @Router /api/v1/auth/reset-password [post] func (h *CoreHandlers) ResetPassword(w http.ResponseWriter, r *http.Request) { var req dto.ResetPasswordRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid Request", http.StatusBadRequest) return } - if err := h.resetPasswordUC.Execute(r.Context(), req); err != nil { http.Error(w, err.Error(), http.StatusUnauthorized) return } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"message": "Password updated successfully"}) + w.WriteHeader(http.StatusOK) } -// CreateTicket creates a new support ticket. -// @Summary Create Ticket -// @Description Creates a new support ticket. -// @Tags Support -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param ticket body object true "Ticket Details" -// @Success 201 {object} object -// @Failure 400 {string} string "Invalid Request" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/support/tickets [post] -func (h *CoreHandlers) CreateTicket(w http.ResponseWriter, r *http.Request) { - userIDVal := r.Context().Value(middleware.ContextUserID) - userID, ok := userIDVal.(string) - if !ok || userID == "" { - http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) - return - } - - var req dto.CreateTicketRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid Request", http.StatusBadRequest) - return - } - - ticket, err := h.ticketService.CreateTicket(r.Context(), userID, req.Subject, req.Category, req.Priority) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - // Create initial message if provided - if req.Message != "" { - _, _ = h.ticketService.AddMessage(r.Context(), ticket.ID, userID, req.Message, false) - } - - w.WriteHeader(http.StatusCreated) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(ticket) -} - -// ListTickets returns all tickets for the user. -// @Summary List Tickets -// @Description Returns a list of tickets for the current user. -// @Tags Support -// @Accept json -// @Produce json -// @Security BearerAuth -// @Success 200 {array} object -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/support/tickets [get] -func (h *CoreHandlers) ListTickets(w http.ResponseWriter, r *http.Request) { - userIDVal := r.Context().Value(middleware.ContextUserID) - userID, ok := userIDVal.(string) - if !ok || userID == "" { - http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) - return - } - - tickets, err := h.ticketService.ListTickets(r.Context(), userID) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(tickets) -} - -// GetTicket returns details and messages for a ticket. -// @Summary Get Ticket Details -// @Description Returns ticket details and chat history. -// @Tags Support -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param id path string true "Ticket ID" -// @Success 200 {object} object -// @Failure 404 {string} string "Not Found" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/support/tickets/{id} [get] -func (h *CoreHandlers) GetTicket(w http.ResponseWriter, r *http.Request) { - userIDVal := r.Context().Value(middleware.ContextUserID) - userID, ok := userIDVal.(string) - if !ok || userID == "" { - http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) - return - } - - id := r.PathValue("id") - if id == "" { - parts := strings.Split(r.URL.Path, "/") - if len(parts) > 0 { - id = parts[len(parts)-1] - } - } - - roleVal := r.Context().Value(middleware.ContextRoles) - roles := middleware.ExtractRoles(roleVal) - isAdmin := hasAdminRole(roles) - - ticket, messages, err := h.ticketService.GetTicket(r.Context(), id, userID, isAdmin) - if err != nil { - http.Error(w, err.Error(), http.StatusNotFound) - return - } - - resp := dto.TicketDetailsResponse{ - Ticket: *ticket, - Messages: messages, - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) -} - -// AddMessage adds a message to a ticket. -// @Summary Add Ticket Message -// @Description Adds a message to an existing ticket. -// @Tags Support -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param id path string true "Ticket ID" -// @Param message body object true "Message" -// @Success 201 {object} object -// @Failure 400 {string} string "Invalid Request" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/support/tickets/{id}/messages [post] -func (h *CoreHandlers) AddMessage(w http.ResponseWriter, r *http.Request) { - userIDVal := r.Context().Value(middleware.ContextUserID) - userID, ok := userIDVal.(string) - if !ok || userID == "" { - http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) - return - } - - id := r.PathValue("id") - if id == "" { - parts := strings.Split(r.URL.Path, "/") - // .../tickets/{id}/messages - if len(parts) > 1 { - id = parts[len(parts)-2] - } - } - - var req dto.MessageRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid Request", http.StatusBadRequest) - return - } - - roleVal := r.Context().Value(middleware.ContextRoles) - roles := middleware.ExtractRoles(roleVal) - isAdmin := hasAdminRole(roles) - - msg, err := h.ticketService.AddMessage(r.Context(), id, userID, req.Message, isAdmin) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusCreated) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(msg) -} - -// UpdateTicket updates a ticket's status and/or priority. -// @Summary Update Ticket -// @Description Updates a ticket's status and/or priority. -// @Tags Support -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param id path string true "Ticket ID" -// @Param ticket body object true "Update data (status, priority)" -// @Success 200 {object} object -// @Failure 400 {string} string "Invalid Request" -// @Failure 401 {string} string "Unauthorized" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/support/tickets/{id} [patch] -func (h *CoreHandlers) UpdateTicket(w http.ResponseWriter, r *http.Request) { - userIDVal := r.Context().Value(middleware.ContextUserID) - userID, ok := userIDVal.(string) - if !ok || userID == "" { - http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) - return - } - - roleVal := r.Context().Value(middleware.ContextRoles) - roles := middleware.ExtractRoles(roleVal) - isAdmin := hasAdminRole(roles) - - id := r.PathValue("id") - - var req struct { - Status *string `json:"status"` - Priority *string `json:"priority"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid Request", http.StatusBadRequest) - return - } - - ticket, err := h.ticketService.UpdateTicket(r.Context(), id, userID, req.Status, req.Priority, isAdmin) - if err != nil { - if err.Error() == "unauthorized" { - http.Error(w, err.Error(), http.StatusForbidden) - return - } - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(ticket) -} - -// CloseTicket closes a ticket. -// @Summary Close Ticket -// @Description Closes a support ticket. -// @Tags Support -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param id path string true "Ticket ID" -// @Success 200 {object} object -// @Failure 401 {string} string "Unauthorized" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/support/tickets/{id}/close [patch] -func (h *CoreHandlers) CloseTicket(w http.ResponseWriter, r *http.Request) { - userIDVal := r.Context().Value(middleware.ContextUserID) - userID, ok := userIDVal.(string) - if !ok || userID == "" { - http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) - return - } - - roleVal := r.Context().Value(middleware.ContextRoles) - roles := middleware.ExtractRoles(roleVal) - isAdmin := hasAdminRole(roles) - - id := r.PathValue("id") - - ticket, err := h.ticketService.CloseTicket(r.Context(), id, userID, isAdmin) - if err != nil { - if err.Error() == "unauthorized" { - http.Error(w, err.Error(), http.StatusForbidden) - return - } - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(ticket) -} - -// DeleteTicket removes a ticket (admin only). -// @Summary Delete Ticket -// @Description Deletes a support ticket (admin only). -// @Tags Support -// @Param id path string true "Ticket ID" -// @Success 204 "No Content" -// @Failure 401 {string} string "Unauthorized" -// @Failure 403 {string} string "Forbidden" -// @Failure 500 {string} string "Internal Server Error" -// @Security BearerAuth -// @Router /api/v1/support/tickets/{id} [delete] -func (h *CoreHandlers) DeleteTicket(w http.ResponseWriter, r *http.Request) { - roleVal := r.Context().Value(middleware.ContextRoles) - roles := middleware.ExtractRoles(roleVal) - if !hasAdminRole(roles) { - http.Error(w, "Forbidden: Admin only", http.StatusForbidden) - return - } - - id := r.PathValue("id") - - if err := h.ticketService.DeleteTicket(r.Context(), id); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusNoContent) -} - -// ListAllTickets returns all tickets (admin only). -// @Summary List All Tickets (Admin) -// @Description Returns all support tickets for admin review. -// @Tags Support -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param status query string false "Filter by status (open, in_progress, closed)" -// @Success 200 {array} object -// @Failure 403 {string} string "Forbidden" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/support/tickets/all [get] -func (h *CoreHandlers) ListAllTickets(w http.ResponseWriter, r *http.Request) { - roleVal := r.Context().Value(middleware.ContextRoles) - roles := middleware.ExtractRoles(roleVal) - if !hasAdminRole(roles) { - http.Error(w, "Forbidden: Admin only", http.StatusForbidden) - return - } - - status := r.URL.Query().Get("status") - - tickets, err := h.ticketService.ListAllTickets(r.Context(), status) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(tickets) -} - -// UpdateMyProfile updates the authenticated user's profile. -// @Summary Update My Profile -// @Description Updates the current user's profile. -// @Tags Users -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param user body object true "Profile Details" -// @Success 200 {object} object -// @Failure 400 {string} string "Invalid Request" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/users/me/profile [patch] func (h *CoreHandlers) UpdateMyProfile(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - userIDVal := ctx.Value(middleware.ContextUserID) - userID, ok := userIDVal.(string) - if !ok || userID == "" { - http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) - return - } - - // TenantID needed? updateUC takes tenantID. - tenantID, _ := ctx.Value(middleware.ContextTenantID).(string) - - var req dto.UpdateUserRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid Request", http.StatusBadRequest) - return - } - - resp, err := h.updateUserUC.Execute(ctx, userID, tenantID, req) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + h.UpdateMe(w, r) } -// UpdateMyPassword updates the authenticated user's password. -// @Summary Update My Password -// @Description Updates the current user's password. -// @Tags Users -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param password body object true "Password Details" -// @Success 204 {string} string "No Content" -// @Failure 400 {string} string "Invalid Request" -// @Failure 401 {string} string "Unauthorized" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/users/me/password [patch] func (h *CoreHandlers) UpdateMyPassword(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - userIDVal := ctx.Value(middleware.ContextUserID) - userID, ok := userIDVal.(string) - if !ok || userID == "" { - http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) - return - } - + userID, _ := ctx.Value(middleware.ContextUserID).(string) var req dto.UpdatePasswordRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid Request", http.StatusBadRequest) return } - - if req.CurrentPassword == "" || req.NewPassword == "" { - http.Error(w, "Current and new password are required", http.StatusBadRequest) - return - } - if err := h.updatePasswordUC.Execute(ctx, userID, req.CurrentPassword, req.NewPassword); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - w.WriteHeader(http.StatusNoContent) } -// UploadMyAvatar handles profile picture upload. -// @Summary Upload Avatar -// @Description Uploads a profile picture for the current user. -// @Tags Users -// @Accept multipart/form-data -// @Produce json -// @Security BearerAuth -// @Param file formData file true "Avatar File" -// @Success 200 {object} map[string]string -// @Failure 400 {string} string "Invalid Request" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/users/me/avatar [post] -func (h *CoreHandlers) UploadMyAvatar(w http.ResponseWriter, r *http.Request) { - // Mock implementation as S3 service is pending injection - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ - "url": "https://avatar.vercel.sh/uploaded-mock", - "message": "Avatar upload mocked (S3 service pending injection)", - }) -} - -// Me returns the current user profile including company info. -// @Summary Get My Profile -// @Description Returns the profile of the authenticated user. -// @Tags Users -// @Accept json -// @Produce json -// @Security BearerAuth -// @Success 200 {object} object -// @Failure 401 {string} string "Unauthorized" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/users/me [get] -func (h *CoreHandlers) Me(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - userIDVal := ctx.Value(middleware.ContextUserID) - if userIDVal == nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - var userID string - switch v := userIDVal.(type) { - case int: - userID = strconv.Itoa(v) - case string: - userID = v - case float64: - userID = strconv.Itoa(int(v)) - } - - user, err := h.adminService.GetUser(ctx, userID) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - company, _ := h.adminService.GetCompanyByUserID(ctx, userID) - if company != nil { - id := company.ID - user.CompanyID = &id - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(user) -} - -// SaveFCMToken saves the FCM token for the user. -// @Summary Save FCM Token -// @Description Saves or updates the FCM token for push notifications. -// @Tags Notifications -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param request body dto.SaveFCMTokenRequest true "FCM Token" -// @Success 200 {object} map[string]string -// @Failure 400 {string} string "Invalid Request" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/tokens [post] -func (h *CoreHandlers) SaveFCMToken(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - userIDVal := ctx.Value(middleware.ContextUserID) - userID, ok := userIDVal.(string) - if !ok || userID == "" { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - var req dto.SaveFCMTokenRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - // Manual validation - if req.Token == "" { - http.Error(w, "Token is required", http.StatusBadRequest) - return - } - if req.Platform != "web" && req.Platform != "android" && req.Platform != "ios" { - http.Error(w, "Invalid platform (must be web, android, or ios)", http.StatusBadRequest) - return - } - - if err := h.notificationService.SaveFCMToken(ctx, userID, req.Token, req.Platform); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"message": "Token saved successfully"}) -} - -// SaveCredentials saves encrypted credentials for external services. -// @Summary Save Credentials -// @Description Saves encrypted credentials payload (e.g. Stripe key encrypted by Backoffice) -// @Tags System -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param request body map[string]string true "Credentials Payload" -// @Success 200 {object} map[string]string -// @Failure 400 {string} string "Invalid Request" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/system/credentials [post] -func (h *CoreHandlers) SaveCredentials(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - userIDVal := ctx.Value(middleware.ContextUserID) - userID, ok := userIDVal.(string) - if !ok || userID == "" { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - var req struct { - ServiceName string `json:"serviceName"` - EncryptedPayload string `json:"encryptedPayload"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - if req.ServiceName == "" || req.EncryptedPayload == "" { - http.Error(w, "serviceName and encryptedPayload are required", http.StatusBadRequest) - return - } - - if err := h.credentialsService.SaveCredentials(ctx, req.ServiceName, req.EncryptedPayload, userID); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"message": "Credentials saved successfully"}) -} - -// hasAdminRole checks if roles array contains admin or superadmin -func hasAdminRole(roles []string) bool { - for _, r := range roles { - lower := strings.ToLower(r) - if lower == "admin" || lower == "superadmin" { - return true +func extractClientIP(r *http.Request) *string { + if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { + parts := strings.Split(forwarded, ",") + if len(parts) > 0 { + ip := strings.TrimSpace(parts[0]) + return &ip } } - return false + if realIP := r.Header.Get("X-Real-IP"); realIP != "" { + return &realIP + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil { + return &host + } + return &r.RemoteAddr } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4d6d854..8bfa046 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -35,34 +35,24 @@ async function getErrorMessage(response: Response): Promise { /** * Generic API Request Wrapper + * Configured for HTTP-Only Cookie Authentication */ async function apiRequest(endpoint: string, options: RequestInit = {}): Promise { - const token = typeof window !== 'undefined' ? (localStorage.getItem("auth_token") || localStorage.getItem("token")) : null; - const headers: Record = { "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), ...options.headers as Record, }; - if (options.body) { - headers["Content-Type"] = "application/json"; - } - const response = await fetch(`${getApiUrl()}${endpoint}`, { ...options, headers, - credentials: "include", + // Crucial for HTTP-Only Cookies + credentials: "include", }); if (!response.ok) { - // Handle 401 silently - it's expected for unauthenticated users if (response.status === 401) { - // Clear any stale auth data - if (typeof window !== 'undefined') { - localStorage.removeItem("job-portal-auth"); - } - // Throw a specific error that can be caught and handled silently + // No need to clear localStorage, cookies are managed by browser const error = new Error('Unauthorized'); (error as any).status = 401; (error as any).silent = true; @@ -101,16 +91,16 @@ export interface ApiJob { companyLogoUrl?: string; companyId: string; location?: string | null; - type?: string; // Legacy alias + type?: string; employmentType?: string; - workMode?: string | null; // "remote", etc. + workMode?: string | null; salaryMin?: number; salaryMax?: number; salaryType?: string; currency?: string; description: string; requirements?: unknown; - questions?: { items?: JobQuestion[] }; // Custom application questions + questions?: { items?: JobQuestion[] }; status: string; createdAt: string; datePosted?: string; @@ -237,6 +227,11 @@ export const authApi = { body: JSON.stringify(data), }); }, + logout: () => { + return apiRequest("/api/v1/auth/logout", { + method: "POST" + }); + }, register: (data: any) => { logCrudAction("register", "auth", { email: data.email }); return apiRequest("/api/v1/auth/register", { @@ -526,7 +521,7 @@ export const jobsApi = { body: JSON.stringify({ active }), }), - // Favorites - wrap with silent error handling + // Favorites getFavorites: () => apiRequest { - const token = typeof window !== 'undefined' ? (localStorage.getItem("auth_token") || localStorage.getItem("token")) : null; - const formData = new FormData(); formData.append('file', file); formData.append('folder', folder); @@ -629,9 +622,7 @@ export const storageApi = { const response = await fetch(`${getApiUrl()}/api/v1/storage/upload`, { method: 'POST', body: formData, - headers: { - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, + // Cookies will be sent automatically credentials: 'include', }); @@ -831,18 +822,11 @@ export const profileApi = { // ============================================================================= async function backofficeRequest(endpoint: string, options: RequestInit = {}): Promise { - const token = typeof window !== 'undefined' ? (localStorage.getItem("auth_token") || localStorage.getItem("token")) : null; - const headers: Record = { "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), ...options.headers as Record, }; - if (options.body) { - headers["Content-Type"] = "application/json"; - } - const response = await fetch(`${getBackofficeUrl()}${endpoint}`, { ...options, headers,