Changed logic for auth token refresh
This commit is contained in:
@@ -18,7 +18,7 @@ class Settings(BaseSettings):
|
|||||||
cookie_secure: bool = Field(default=False, env="COOKIE_SECURE")
|
cookie_secure: bool = Field(default=False, env="COOKIE_SECURE")
|
||||||
cookie_samesite: str = Field(default="lax", env="COOKIE_SAMESITE")
|
cookie_samesite: str = Field(default="lax", env="COOKIE_SAMESITE")
|
||||||
cookie_domain: str | None = Field(default=None, env="COOKIE_DOMAIN")
|
cookie_domain: str | None = Field(default=None, env="COOKIE_DOMAIN")
|
||||||
cookie_max_age: int = Field(default=3600, env="COOKIE_MAX_AGE")
|
cookie_max_age: int = Field(default=28800, env="COOKIE_MAX_AGE") # 8 hours
|
||||||
cookie_name: str = Field(default="access_token", env="COOKIE_NAME")
|
cookie_name: str = Field(default="access_token", env="COOKIE_NAME")
|
||||||
|
|
||||||
# Database settings
|
# Database settings
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ from src.models import TokenResponse, UserInfo
|
|||||||
from src.service import AuthService, UserService
|
from src.service import AuthService, UserService
|
||||||
from src.config import get_settings
|
from src.config import get_settings
|
||||||
from src.database import get_db
|
from src.database import get_db
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Initialize HTTPBearer security dependency
|
# Initialize HTTPBearer security dependency
|
||||||
bearer_scheme = HTTPBearer()
|
bearer_scheme = HTTPBearer()
|
||||||
@@ -13,6 +16,9 @@ bearer_scheme = HTTPBearer()
|
|||||||
# Get settings
|
# Get settings
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Cookie name for refresh token
|
||||||
|
REFRESH_TOKEN_COOKIE = "refresh_token"
|
||||||
|
|
||||||
|
|
||||||
class AuthController:
|
class AuthController:
|
||||||
"""
|
"""
|
||||||
@@ -85,6 +91,20 @@ class AuthController:
|
|||||||
path="/",
|
path="/",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Also store the refresh token if available
|
||||||
|
refresh_token = token_response.get("refresh_token")
|
||||||
|
if refresh_token:
|
||||||
|
response.set_cookie(
|
||||||
|
key=REFRESH_TOKEN_COOKIE,
|
||||||
|
value=refresh_token,
|
||||||
|
httponly=True,
|
||||||
|
secure=settings.cookie_secure,
|
||||||
|
samesite=settings.cookie_samesite,
|
||||||
|
max_age=86400 * 7, # 7 days for refresh token
|
||||||
|
domain=settings.cookie_domain,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -148,15 +168,100 @@ class AuthController:
|
|||||||
status_code=status.HTTP_200_OK
|
status_code=status.HTTP_200_OK
|
||||||
)
|
)
|
||||||
|
|
||||||
# Clear the authentication cookie
|
# Clear the authentication cookies
|
||||||
response.delete_cookie(
|
response.delete_cookie(
|
||||||
key=settings.cookie_name,
|
key=settings.cookie_name,
|
||||||
path="/",
|
path="/",
|
||||||
domain=settings.cookie_domain,
|
domain=settings.cookie_domain,
|
||||||
)
|
)
|
||||||
|
response.delete_cookie(
|
||||||
|
key=REFRESH_TOKEN_COOKIE,
|
||||||
|
path="/",
|
||||||
|
domain=settings.cookie_domain,
|
||||||
|
)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def refresh_token(request: Request) -> JSONResponse:
|
||||||
|
"""
|
||||||
|
Silently refresh the access token using the refresh token.
|
||||||
|
This endpoint is called by the frontend before the access token expires.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request (Request): The FastAPI request object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSONResponse: Contains success status and new expiration time.
|
||||||
|
"""
|
||||||
|
# Get the refresh token from cookies
|
||||||
|
refresh_token = request.cookies.get(REFRESH_TOKEN_COOKIE)
|
||||||
|
|
||||||
|
if not refresh_token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="No refresh token available",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get new tokens from Keycloak
|
||||||
|
token_response = AuthService.refresh_access_token(refresh_token)
|
||||||
|
new_access_token = token_response.get("access_token")
|
||||||
|
new_refresh_token = token_response.get("refresh_token")
|
||||||
|
expires_in = token_response.get("expires_in", settings.cookie_max_age)
|
||||||
|
|
||||||
|
if not new_access_token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Failed to refresh token",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create response with new tokens
|
||||||
|
response = JSONResponse(
|
||||||
|
content={
|
||||||
|
"success": True,
|
||||||
|
"expires_in": expires_in,
|
||||||
|
},
|
||||||
|
status_code=status.HTTP_200_OK
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set new access token cookie
|
||||||
|
response.set_cookie(
|
||||||
|
key=settings.cookie_name,
|
||||||
|
value=new_access_token,
|
||||||
|
httponly=True,
|
||||||
|
secure=settings.cookie_secure,
|
||||||
|
samesite=settings.cookie_samesite,
|
||||||
|
max_age=expires_in,
|
||||||
|
domain=settings.cookie_domain,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set new refresh token cookie if provided
|
||||||
|
if new_refresh_token:
|
||||||
|
response.set_cookie(
|
||||||
|
key=REFRESH_TOKEN_COOKIE,
|
||||||
|
value=new_refresh_token,
|
||||||
|
httponly=True,
|
||||||
|
secure=settings.cookie_secure,
|
||||||
|
samesite=settings.cookie_samesite,
|
||||||
|
max_age=86400 * 7, # 7 days
|
||||||
|
domain=settings.cookie_domain,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Token refreshed successfully")
|
||||||
|
return response
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Token refresh failed: {exc}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Token refresh failed",
|
||||||
|
) from exc
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def protected_endpoint(
|
def protected_endpoint(
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
|
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
|
||||||
|
|||||||
@@ -196,6 +196,19 @@ async def logout(request: Request):
|
|||||||
return AuthController.logout()
|
return AuthController.logout()
|
||||||
|
|
||||||
|
|
||||||
|
# Define the token refresh endpoint
|
||||||
|
@app.post("/api/auth/refresh")
|
||||||
|
async def refresh_token(request: Request):
|
||||||
|
"""
|
||||||
|
Silently refresh the access token using the refresh token cookie.
|
||||||
|
This should be called by the frontend before the access token expires.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Success status and new expiration time.
|
||||||
|
"""
|
||||||
|
return AuthController.refresh_token(request)
|
||||||
|
|
||||||
|
|
||||||
# Define the protected endpoint (kept for API token-based access)
|
# Define the protected endpoint (kept for API token-based access)
|
||||||
@app.get("/api/protected", response_model=UserInfo)
|
@app.get("/api/protected", response_model=UserInfo)
|
||||||
async def protected_endpoint(
|
async def protected_endpoint(
|
||||||
|
|||||||
@@ -115,6 +115,35 @@ class AuthService:
|
|||||||
detail="Could not decode token",
|
detail="Could not decode token",
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def refresh_access_token(refresh_token: str) -> dict:
|
||||||
|
"""
|
||||||
|
Use a refresh token to get a new access token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
refresh_token: The refresh token from Keycloak
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict containing new access_token, refresh_token, and expires_in
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
keycloak_openid = get_keycloak_openid()
|
||||||
|
token = keycloak_openid.refresh_token(refresh_token)
|
||||||
|
logger.info("Token refresh successful")
|
||||||
|
return token
|
||||||
|
except KeycloakAuthenticationError as exc:
|
||||||
|
logger.error(f"Token refresh failed - KeycloakAuthenticationError: {exc}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Refresh token expired or invalid",
|
||||||
|
) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Token refresh failed: {exc}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Could not refresh token",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
class UserService:
|
class UserService:
|
||||||
"""Service for user-related operations."""
|
"""Service for user-related operations."""
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useCallback,
|
useCallback,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import type { User, AuthContextType } from '@/types'
|
import type { User, AuthContextType } from '@/types'
|
||||||
import { authService } from '@/services'
|
import { authService, handleUnauthorized } from '@/services'
|
||||||
|
|
||||||
export const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
export const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||||
|
|
||||||
@@ -15,9 +16,14 @@ interface AuthProviderProps {
|
|||||||
children: ReactNode
|
children: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh token 1 minute before it expires (Keycloak default is 5 min access token)
|
||||||
|
// We'll refresh every 4 minutes to be safe
|
||||||
|
const TOKEN_REFRESH_INTERVAL = 4 * 60 * 1000
|
||||||
|
|
||||||
export function AuthProvider({ children }: AuthProviderProps) {
|
export function AuthProvider({ children }: AuthProviderProps) {
|
||||||
const [user, setUser] = useState<User | null>(null)
|
const [user, setUser] = useState<User | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const refreshIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
const refreshUser = useCallback(async () => {
|
const refreshUser = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -38,6 +44,11 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
|||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
// Clear the refresh interval
|
||||||
|
if (refreshIntervalRef.current) {
|
||||||
|
clearInterval(refreshIntervalRef.current)
|
||||||
|
refreshIntervalRef.current = null
|
||||||
|
}
|
||||||
await authService.logout()
|
await authService.logout()
|
||||||
setUser(null)
|
setUser(null)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -52,6 +63,47 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
|||||||
refreshUser()
|
refreshUser()
|
||||||
}, [refreshUser])
|
}, [refreshUser])
|
||||||
|
|
||||||
|
// Set up silent token refresh when user is authenticated
|
||||||
|
useEffect(() => {
|
||||||
|
// Only set up interval if user is authenticated
|
||||||
|
if (!user) {
|
||||||
|
if (refreshIntervalRef.current) {
|
||||||
|
clearInterval(refreshIntervalRef.current)
|
||||||
|
refreshIntervalRef.current = null
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Silently refresh token periodically
|
||||||
|
const silentRefresh = async () => {
|
||||||
|
console.log('Attempting silent token refresh...')
|
||||||
|
const success = await authService.refreshToken()
|
||||||
|
if (!success) {
|
||||||
|
console.log('Silent refresh failed, checking if session is still valid...')
|
||||||
|
// If refresh failed, check if we're still logged in
|
||||||
|
const isValid = await authService.checkSession()
|
||||||
|
if (!isValid) {
|
||||||
|
console.log('Session invalid, redirecting to login...')
|
||||||
|
handleUnauthorized()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do an initial refresh after a short delay
|
||||||
|
const initialRefreshTimeout = setTimeout(silentRefresh, 10000) // 10 seconds after login
|
||||||
|
|
||||||
|
// Then refresh periodically
|
||||||
|
refreshIntervalRef.current = setInterval(silentRefresh, TOKEN_REFRESH_INTERVAL)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(initialRefreshTimeout)
|
||||||
|
if (refreshIntervalRef.current) {
|
||||||
|
clearInterval(refreshIntervalRef.current)
|
||||||
|
refreshIntervalRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [user])
|
||||||
|
|
||||||
// Determine if user is an auditor (role_id = 2)
|
// Determine if user is an auditor (role_id = 2)
|
||||||
const isAuditor = useMemo(() => user?.role_id === 2, [user?.role_id])
|
const isAuditor = useMemo(() => user?.role_id === 2, [user?.role_id])
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,65 @@ import App from './App'
|
|||||||
import { AuthProvider, ProjectProvider } from '@/context'
|
import { AuthProvider, ProjectProvider } from '@/context'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
|
// Global fetch interceptor to handle 401 Unauthorized responses
|
||||||
|
// This intercepts all fetch calls and attempts silent refresh before redirecting
|
||||||
|
const originalFetch = window.fetch
|
||||||
|
let isRefreshing = false
|
||||||
|
let refreshPromise: Promise<boolean> | null = null
|
||||||
|
|
||||||
|
window.fetch = async (...args) => {
|
||||||
|
const response = await originalFetch(...args)
|
||||||
|
|
||||||
|
// Check if it's a 401 response from our API (not auth endpoints)
|
||||||
|
const url = typeof args[0] === 'string' ? args[0] : args[0] instanceof Request ? args[0].url : ''
|
||||||
|
const isApiCall = url.includes('/api/') &&
|
||||||
|
!url.includes('/api/login') &&
|
||||||
|
!url.includes('/api/callback') &&
|
||||||
|
!url.includes('/api/auth/refresh') &&
|
||||||
|
!url.includes('/api/auth/me')
|
||||||
|
|
||||||
|
if (response.status === 401 && isApiCall) {
|
||||||
|
// Try to silently refresh the token
|
||||||
|
if (!isRefreshing) {
|
||||||
|
isRefreshing = true
|
||||||
|
refreshPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const refreshResponse = await originalFetch('/api/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
return refreshResponse.ok
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false
|
||||||
|
refreshPromise = null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the refresh attempt
|
||||||
|
const refreshSuccess = await refreshPromise
|
||||||
|
|
||||||
|
if (refreshSuccess) {
|
||||||
|
// Retry the original request with fresh token
|
||||||
|
console.log('Token refreshed, retrying request...')
|
||||||
|
return originalFetch(...args)
|
||||||
|
} else {
|
||||||
|
// Refresh failed, redirect to login
|
||||||
|
console.log('Token refresh failed, redirecting to login...')
|
||||||
|
const currentPath = window.location.pathname + window.location.search
|
||||||
|
if (currentPath !== '/' && currentPath !== '/login') {
|
||||||
|
sessionStorage.setItem('redirectAfterLogin', currentPath)
|
||||||
|
}
|
||||||
|
window.location.href = '/api/login'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
|||||||
@@ -2,6 +2,48 @@ import type { User } from '@/types'
|
|||||||
|
|
||||||
const API_BASE_URL = '/api'
|
const API_BASE_URL = '/api'
|
||||||
|
|
||||||
|
// Global flag to prevent multiple redirects
|
||||||
|
let isRedirecting = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle 401 Unauthorized responses by redirecting to login.
|
||||||
|
* This is called when the session cookie expires and refresh fails.
|
||||||
|
*/
|
||||||
|
export function handleUnauthorized(): void {
|
||||||
|
if (isRedirecting) return
|
||||||
|
isRedirecting = true
|
||||||
|
|
||||||
|
// Store the current URL to redirect back after login
|
||||||
|
const currentPath = window.location.pathname + window.location.search
|
||||||
|
if (currentPath !== '/' && currentPath !== '/login') {
|
||||||
|
sessionStorage.setItem('redirectAfterLogin', currentPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to login
|
||||||
|
window.location.href = `${API_BASE_URL}/login`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper around fetch that automatically handles 401 responses.
|
||||||
|
* Use this instead of fetch for authenticated API calls.
|
||||||
|
*/
|
||||||
|
export async function authenticatedFetch(
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
init?: RequestInit
|
||||||
|
): Promise<Response> {
|
||||||
|
const response = await fetch(input, {
|
||||||
|
...init,
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
handleUnauthorized()
|
||||||
|
throw new Error('Session expired')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
class AuthService {
|
class AuthService {
|
||||||
/**
|
/**
|
||||||
* Get the current authenticated user from the session cookie.
|
* Get the current authenticated user from the session cookie.
|
||||||
@@ -32,6 +74,53 @@ class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the session is still valid.
|
||||||
|
* Returns true if authenticated, false otherwise.
|
||||||
|
*/
|
||||||
|
async checkSession(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/auth/me`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return response.ok
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Silently refresh the access token using the refresh token.
|
||||||
|
* Returns true if successful, false otherwise.
|
||||||
|
*/
|
||||||
|
async refreshToken(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.log('Token refresh failed:', response.status)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
console.log('Token refreshed successfully, expires in:', data.expires_in, 'seconds')
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to refresh token:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logout the current user by clearing the session cookie
|
* Logout the current user by clearing the session cookie
|
||||||
* and redirecting to Keycloak logout to end the SSO session.
|
* and redirecting to Keycloak logout to end the SSO session.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export { authService } from './authService'
|
export { authService, handleUnauthorized, authenticatedFetch } from './authService'
|
||||||
export { groupService } from './groupService'
|
export { groupService } from './groupService'
|
||||||
export type { Group } from './groupService'
|
export type { Group } from './groupService'
|
||||||
export { tagService } from './tagService'
|
export { tagService } from './tagService'
|
||||||
|
|||||||
Reference in New Issue
Block a user