fix(auth,api): resolve SSR vs CSR API URL routing and sanitize ADMIN_TOKEN parsing
- Implement getApiBaseUrl() and getAdminApiBaseUrl() to dynamically distinguish between Server/SSR (process.env.API_BASE_URL) and Client/CSR (process.env.NEXT_PUBLIC_API_BASE_URL) - Fix missing data on main landing page in containerized environment - Sanitize and trim ADMIN_TOKEN parsing in Go backend to prevent whitespace/quote mismatch - Real-time token validation in AdminLayout with auto-redirection on token mismatch - All unit and E2E test gates passed clean with unanimous PASS reviews
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"flag"
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.godopu.com/lab/landing_page/backend/internal/config"
|
"git.godopu.com/lab/landing_page/backend/internal/config"
|
||||||
@@ -37,7 +38,7 @@ func main() {
|
|||||||
cfg.GinMode = *ginModeFlag
|
cfg.GinMode = *ginModeFlag
|
||||||
}
|
}
|
||||||
if *adminTokenFlag != "" {
|
if *adminTokenFlag != "" {
|
||||||
cfg.AdminToken = *adminTokenFlag
|
cfg.AdminToken = strings.Trim(*adminTokenFlag, " \"'\r\n\t")
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.AdminToken == "" {
|
if cfg.AdminToken == "" {
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ func Load() *Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if token := os.Getenv("ADMIN_TOKEN"); token != "" {
|
if token := os.Getenv("ADMIN_TOKEN"); token != "" {
|
||||||
cfg.AdminToken = token
|
cfg.AdminToken = strings.Trim(token, " \"'\r\n\t")
|
||||||
}
|
}
|
||||||
|
|
||||||
if autoMigrateStr := os.Getenv("AUTO_MIGRATE"); autoMigrateStr != "" {
|
if autoMigrateStr := os.Getenv("AUTO_MIGRATE"); autoMigrateStr != "" {
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import (
|
|||||||
|
|
||||||
// RequireAdminToken returns a Gin middleware that validates Bearer <token> against the configured admin token.
|
// RequireAdminToken returns a Gin middleware that validates Bearer <token> against the configured admin token.
|
||||||
func RequireAdminToken(adminToken string) gin.HandlerFunc {
|
func RequireAdminToken(adminToken string) gin.HandlerFunc {
|
||||||
|
expectedToken := strings.Trim(adminToken, " \"'\r\n\t")
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
if adminToken == "" {
|
if expectedToken == "" {
|
||||||
Unauthorized(c, "Admin API is disabled: ADMIN_TOKEN is not configured")
|
Unauthorized(c, "Admin API is disabled: ADMIN_TOKEN is not configured")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
@@ -23,7 +24,7 @@ func RequireAdminToken(adminToken string) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.SplitN(auth, " ", 2)
|
parts := strings.SplitN(auth, " ", 2)
|
||||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] != adminToken {
|
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || strings.TrimSpace(parts[1]) != expectedToken {
|
||||||
Unauthorized(c, "Invalid admin bearer token")
|
Unauthorized(c, "Invalid admin bearer token")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React, { useEffect, useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { getClientToken, removeClientToken } from "./_components/AdminComponents";
|
import { getClientToken, removeClientToken } from "./_components/AdminComponents";
|
||||||
|
import { adminVerifyToken } from "../../lib/adminApi";
|
||||||
|
|
||||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -11,12 +12,37 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
async function checkAuth() {
|
||||||
const token = getClientToken();
|
const token = getClientToken();
|
||||||
if (!token && pathname !== "/console/login") {
|
if (!token) {
|
||||||
|
if (pathname !== "/console/login") {
|
||||||
router.push("/console/login");
|
router.push("/console/login");
|
||||||
|
} else {
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await adminVerifyToken(token);
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
removeClientToken();
|
||||||
|
if (pathname !== "/console/login") {
|
||||||
|
router.push("/console/login");
|
||||||
|
} else {
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkAuth();
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
}, [pathname, router]);
|
}, [pathname, router]);
|
||||||
|
|
||||||
if (pathname === "/console/login") {
|
if (pathname === "/console/login") {
|
||||||
|
|||||||
@@ -3,10 +3,22 @@
|
|||||||
* Preserves database IDs and handles authenticated mutating requests (POST, PUT, DELETE).
|
* Preserves database IDs and handles authenticated mutating requests (POST, PUT, DELETE).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const API_BASE_URL =
|
export function getAdminApiBaseUrl(): string {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
// Browser / CSR context: use public endpoint
|
||||||
|
return (
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL ||
|
process.env.NEXT_PUBLIC_API_BASE_URL ||
|
||||||
process.env.API_BASE_URL ||
|
process.env.API_BASE_URL ||
|
||||||
"http://localhost:8080/api/v1";
|
"http://localhost:8080/api/v1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Server / SSR context: prefer internal container DNS if available, fallback to NEXT_PUBLIC or localhost
|
||||||
|
return (
|
||||||
|
process.env.API_BASE_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ||
|
||||||
|
"http://localhost:8080/api/v1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminResearchProject {
|
export interface AdminResearchProject {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -143,7 +155,8 @@ async function request<T>(
|
|||||||
headers.set("Content-Type", "application/json");
|
headers.set("Content-Type", "application/json");
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `${API_BASE_URL}${endpoint}`;
|
const baseUrl = getAdminApiBaseUrl();
|
||||||
|
const url = `${baseUrl}${endpoint}`;
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
...options,
|
...options,
|
||||||
headers,
|
headers,
|
||||||
|
|||||||
@@ -10,7 +10,23 @@ import type {
|
|||||||
DocStatus,
|
DocStatus,
|
||||||
} from "../content/types";
|
} from "../content/types";
|
||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL ?? "http://localhost:8080/api/v1";
|
export function getApiBaseUrl(): string {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
// Browser / CSR context: use public endpoint
|
||||||
|
return (
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ||
|
||||||
|
process.env.API_BASE_URL ||
|
||||||
|
"http://localhost:8080/api/v1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Server / SSR context: prefer internal container DNS if available, fallback to NEXT_PUBLIC or localhost
|
||||||
|
return (
|
||||||
|
process.env.API_BASE_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ||
|
||||||
|
"http://localhost:8080/api/v1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const FETCH_TIMEOUT_MS = 3000;
|
const FETCH_TIMEOUT_MS = 3000;
|
||||||
|
|
||||||
interface APIResponse<T> {
|
interface APIResponse<T> {
|
||||||
@@ -142,7 +158,8 @@ async function apiGet<T>(path: string): Promise<T | null> {
|
|||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
const res = await fetch(`${API_BASE_URL}${path}`, {
|
const baseUrl = getApiBaseUrl();
|
||||||
|
const res = await fetch(`${baseUrl}${path}`, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user