-
This commit is contained in:
@ -49,39 +49,46 @@ func (hv *HeaderValidator) ValidateAuthenticationHeaders(r *http.Request) (*Vali
|
||||
return nil, errors.NewAuthenticationError("User authentication required")
|
||||
}
|
||||
|
||||
if timestamp == "" || signature == "" {
|
||||
hv.logger.Warn("Missing authentication signature headers",
|
||||
// In development mode, skip signature validation for trusted headers
|
||||
if hv.config.IsDevelopment() {
|
||||
hv.logger.Debug("Development mode: skipping signature validation",
|
||||
zap.String("user_email", userEmail))
|
||||
return nil, errors.NewAuthenticationError("Authentication signature required")
|
||||
}
|
||||
} else {
|
||||
// Production mode: require full signature validation
|
||||
if timestamp == "" || signature == "" {
|
||||
hv.logger.Warn("Missing authentication signature headers",
|
||||
zap.String("user_email", userEmail))
|
||||
return nil, errors.NewAuthenticationError("Authentication signature required")
|
||||
}
|
||||
|
||||
// Validate timestamp (prevent replay attacks)
|
||||
timestampInt, err := strconv.ParseInt(timestamp, 10, 64)
|
||||
if err != nil {
|
||||
hv.logger.Warn("Invalid timestamp format",
|
||||
zap.String("timestamp", timestamp),
|
||||
zap.String("user_email", userEmail))
|
||||
return nil, errors.NewAuthenticationError("Invalid timestamp format")
|
||||
}
|
||||
// Validate timestamp (prevent replay attacks)
|
||||
timestampInt, err := strconv.ParseInt(timestamp, 10, 64)
|
||||
if err != nil {
|
||||
hv.logger.Warn("Invalid timestamp format",
|
||||
zap.String("timestamp", timestamp),
|
||||
zap.String("user_email", userEmail))
|
||||
return nil, errors.NewAuthenticationError("Invalid timestamp format")
|
||||
}
|
||||
|
||||
timestampTime := time.Unix(timestampInt, 0)
|
||||
now := time.Now()
|
||||
|
||||
// Allow 5 minutes clock skew
|
||||
maxAge := 5 * time.Minute
|
||||
if now.Sub(timestampTime) > maxAge || timestampTime.After(now.Add(1*time.Minute)) {
|
||||
hv.logger.Warn("Timestamp outside acceptable window",
|
||||
zap.Time("timestamp", timestampTime),
|
||||
zap.Time("now", now),
|
||||
zap.String("user_email", userEmail))
|
||||
return nil, errors.NewAuthenticationError("Request timestamp outside acceptable window")
|
||||
}
|
||||
timestampTime := time.Unix(timestampInt, 0)
|
||||
now := time.Now()
|
||||
|
||||
// Allow 5 minutes clock skew
|
||||
maxAge := 5 * time.Minute
|
||||
if now.Sub(timestampTime) > maxAge || timestampTime.After(now.Add(1*time.Minute)) {
|
||||
hv.logger.Warn("Timestamp outside acceptable window",
|
||||
zap.Time("timestamp", timestampTime),
|
||||
zap.Time("now", now),
|
||||
zap.String("user_email", userEmail))
|
||||
return nil, errors.NewAuthenticationError("Request timestamp outside acceptable window")
|
||||
}
|
||||
|
||||
// Validate HMAC signature
|
||||
if !hv.validateSignature(userEmail, timestamp, signature) {
|
||||
hv.logger.Warn("Invalid authentication signature",
|
||||
zap.String("user_email", userEmail))
|
||||
return nil, errors.NewAuthenticationError("Invalid authentication signature")
|
||||
// Validate HMAC signature
|
||||
if !hv.validateSignature(userEmail, timestamp, signature) {
|
||||
hv.logger.Warn("Invalid authentication signature",
|
||||
zap.String("user_email", userEmail))
|
||||
return nil, errors.NewAuthenticationError("Invalid authentication signature")
|
||||
}
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
@ -94,11 +101,24 @@ func (hv *HeaderValidator) ValidateAuthenticationHeaders(r *http.Request) (*Vali
|
||||
hv.logger.Debug("Authentication headers validated successfully",
|
||||
zap.String("user_email", userEmail))
|
||||
|
||||
// Set defaults for development mode
|
||||
var timestampTime time.Time
|
||||
var signatureValue string
|
||||
|
||||
if hv.config.IsDevelopment() {
|
||||
timestampTime = time.Now()
|
||||
signatureValue = "dev-mode-bypass"
|
||||
} else {
|
||||
timestampInt, _ := strconv.ParseInt(timestamp, 10, 64)
|
||||
timestampTime = time.Unix(timestampInt, 0)
|
||||
signatureValue = signature
|
||||
}
|
||||
|
||||
return &ValidatedUserContext{
|
||||
UserID: userEmail,
|
||||
Email: userEmail,
|
||||
Timestamp: timestampTime,
|
||||
Signature: signature,
|
||||
Signature: signatureValue,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@ -124,11 +124,20 @@ type VerifyResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// TokenDeliveryMode specifies how tokens should be delivered in redirect flows
|
||||
type TokenDeliveryMode string
|
||||
|
||||
const (
|
||||
TokenDeliveryCookie TokenDeliveryMode = "cookie" // Token in secure cookie (default)
|
||||
TokenDeliveryQuery TokenDeliveryMode = "query" // Token in query parameter (for integrations)
|
||||
)
|
||||
|
||||
// LoginRequest represents a user login request
|
||||
type LoginRequest struct {
|
||||
AppID string `json:"app_id" validate:"required"`
|
||||
Permissions []string `json:"permissions,omitempty"`
|
||||
RedirectURI string `json:"redirect_uri,omitempty"`
|
||||
AppID string `json:"app_id" validate:"required"`
|
||||
Permissions []string `json:"permissions,omitempty"`
|
||||
RedirectURI string `json:"redirect_uri,omitempty"`
|
||||
TokenDelivery TokenDeliveryMode `json:"token_delivery,omitempty"` // How to deliver token in redirect flows
|
||||
}
|
||||
|
||||
// LoginResponse represents a user login response
|
||||
|
||||
@ -81,25 +81,53 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// For redirect flows, use secure cookie-based token delivery
|
||||
// Set secure cookie with the token
|
||||
c.SetSameSite(http.SameSiteStrictMode)
|
||||
c.SetCookie(
|
||||
"auth_token", // name
|
||||
token, // value
|
||||
604800, // maxAge (7 days)
|
||||
"/", // path
|
||||
"", // domain (empty for current domain)
|
||||
true, // secure (HTTPS only)
|
||||
true, // httpOnly (no JavaScript access)
|
||||
)
|
||||
// For redirect flows, choose token delivery method
|
||||
// Default to cookie delivery for security
|
||||
tokenDelivery := req.TokenDelivery
|
||||
if tokenDelivery == "" {
|
||||
tokenDelivery = domain.TokenDeliveryCookie
|
||||
}
|
||||
|
||||
h.logger.Debug("Token delivery mode", zap.String("mode", string(tokenDelivery)))
|
||||
|
||||
// Generate a secure state parameter for CSRF protection
|
||||
state := h.generateSecureState(userContext.UserID, req.AppID)
|
||||
|
||||
// Redirect without token in URL
|
||||
var redirectURL string
|
||||
|
||||
switch tokenDelivery {
|
||||
case domain.TokenDeliveryQuery:
|
||||
// Deliver token via query parameter (for integrations like VS Code)
|
||||
redirectURL = req.RedirectURI + "?token=" + token + "&state=" + state
|
||||
|
||||
case domain.TokenDeliveryCookie:
|
||||
// Deliver token via secure cookie (default, more secure)
|
||||
c.SetSameSite(http.SameSiteStrictMode)
|
||||
|
||||
// In development mode, make cookie accessible to JavaScript for testing
|
||||
// In production, keep HTTP-only for security
|
||||
httpOnly := !h.config.IsDevelopment()
|
||||
secure := !h.config.IsDevelopment() // Only require HTTPS in production
|
||||
|
||||
c.SetCookie(
|
||||
"auth_token", // name
|
||||
token, // value
|
||||
604800, // maxAge (7 days)
|
||||
"/", // path
|
||||
"", // domain (empty for current domain)
|
||||
secure, // secure (HTTPS only in production)
|
||||
httpOnly, // httpOnly (no JavaScript access in production)
|
||||
)
|
||||
|
||||
// Redirect without token in URL for security
|
||||
redirectURL = req.RedirectURI + "?state=" + state
|
||||
|
||||
default:
|
||||
// Invalid delivery mode, default to cookie
|
||||
redirectURL = req.RedirectURI + "?state=" + state
|
||||
}
|
||||
|
||||
response := domain.LoginResponse{
|
||||
RedirectURL: req.RedirectURI + "?state=" + state,
|
||||
RedirectURL: redirectURL,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
|
||||
@ -47,7 +47,13 @@ interface CallbackTestResult {
|
||||
success: boolean;
|
||||
token?: string;
|
||||
verified?: boolean;
|
||||
permitted?: boolean;
|
||||
user_id?: string;
|
||||
permissions?: string[];
|
||||
permission_results?: Record<string, boolean>;
|
||||
expires_at?: string;
|
||||
max_valid_at?: string;
|
||||
token_type?: string;
|
||||
error?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
@ -79,6 +85,8 @@ const TokenTester: React.FC = () => {
|
||||
const [callbackModalVisible, setCallbackModalVisible] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [callbackForm] = Form.useForm();
|
||||
const [useCallback, setUseCallback] = useState(false);
|
||||
const [extractedToken, setExtractedToken] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadApplications();
|
||||
@ -123,7 +131,8 @@ const TokenTester: React.FC = () => {
|
||||
const response = await apiService.login(
|
||||
values.app_id,
|
||||
values.permissions || [],
|
||||
values.use_callback ? callbackUrl : undefined
|
||||
values.use_callback ? callbackUrl : undefined,
|
||||
values.token_delivery || 'query'
|
||||
);
|
||||
|
||||
console.log('Login response:', response);
|
||||
@ -146,16 +155,25 @@ const TokenTester: React.FC = () => {
|
||||
// If we have a redirect URL, show the callback modal
|
||||
if (response.redirect_url && values.use_callback) {
|
||||
setCallbackModalVisible(true);
|
||||
// Pre-fill the callback form with the token from the redirect URL
|
||||
const urlParams = new URLSearchParams(response.redirect_url.split('?')[1]);
|
||||
const token = urlParams.get('token');
|
||||
if (token) {
|
||||
callbackForm.setFieldsValue({
|
||||
app_id: values.app_id,
|
||||
token: token,
|
||||
permissions: values.permissions || [],
|
||||
});
|
||||
|
||||
// Extract token from redirect URL if using query parameter delivery
|
||||
let tokenFromUrl = '';
|
||||
if (values.token_delivery === 'query') {
|
||||
try {
|
||||
const url = new URL(response.redirect_url);
|
||||
tokenFromUrl = url.searchParams.get('token') || '';
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse redirect URL for token extraction:', e);
|
||||
}
|
||||
}
|
||||
|
||||
setExtractedToken(tokenFromUrl);
|
||||
|
||||
callbackForm.setFieldsValue({
|
||||
app_id: values.app_id,
|
||||
token: tokenFromUrl, // Pre-fill with extracted token if available
|
||||
permissions: values.permissions || [],
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
@ -195,7 +213,13 @@ const TokenTester: React.FC = () => {
|
||||
success: verifyResponse.valid,
|
||||
token: values.token,
|
||||
verified: verifyResponse.valid,
|
||||
permitted: verifyResponse.permitted,
|
||||
user_id: verifyResponse.user_id,
|
||||
permissions: verifyResponse.permissions,
|
||||
permission_results: verifyResponse.permission_results,
|
||||
expires_at: verifyResponse.expires_at,
|
||||
max_valid_at: verifyResponse.max_valid_at,
|
||||
token_type: verifyResponse.token_type,
|
||||
error: verifyResponse.error,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
@ -205,8 +229,16 @@ const TokenTester: React.FC = () => {
|
||||
|
||||
if (verifyResponse.valid) {
|
||||
message.success('Callback test completed successfully!');
|
||||
// Auto-close modal after successful verification to show results
|
||||
setTimeout(() => {
|
||||
setCallbackModalVisible(false);
|
||||
}, 1500);
|
||||
} else {
|
||||
message.warning('Callback test completed - token verification failed');
|
||||
// Auto-close modal after failed verification to show results
|
||||
setTimeout(() => {
|
||||
setCallbackModalVisible(false);
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
@ -221,6 +253,11 @@ const TokenTester: React.FC = () => {
|
||||
setCallbackResult(result);
|
||||
setCurrentStep(4);
|
||||
message.error('Callback test failed');
|
||||
|
||||
// Auto-close modal to show error results
|
||||
setTimeout(() => {
|
||||
setCallbackModalVisible(false);
|
||||
}, 1500);
|
||||
} finally {
|
||||
setCallbackLoading(false);
|
||||
}
|
||||
@ -231,6 +268,8 @@ const TokenTester: React.FC = () => {
|
||||
setLoginResult(null);
|
||||
setCallbackResult(null);
|
||||
setCallbackModalVisible(false);
|
||||
setUseCallback(false);
|
||||
setExtractedToken('');
|
||||
form.resetFields();
|
||||
callbackForm.resetFields();
|
||||
// Clear stored test data
|
||||
@ -257,6 +296,20 @@ const TokenTester: React.FC = () => {
|
||||
<Text type="secondary">
|
||||
Test the /login flow and callback handling for user tokens
|
||||
</Text>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<Alert
|
||||
message="Two Testing Modes Available"
|
||||
description={
|
||||
<div>
|
||||
<Text strong>Direct Mode:</Text> Login returns token directly in response body (no callback)<br/>
|
||||
<Text strong>Callback Mode:</Text> Login returns redirect URL, token in query parameter (default) or secure cookie
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ fontSize: '12px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
@ -316,11 +369,44 @@ const TokenTester: React.FC = () => {
|
||||
valuePropName="checked"
|
||||
label=" "
|
||||
>
|
||||
<Checkbox>Use callback URL (test full flow)</Checkbox>
|
||||
<Checkbox onChange={(e) => setUseCallback(e.target.checked)}>
|
||||
Use callback URL (test full flow)
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item
|
||||
name="token_delivery"
|
||||
label="Token Delivery Method (for callback flows)"
|
||||
tooltip="Choose how tokens are delivered when using callback URLs"
|
||||
initialValue="query"
|
||||
>
|
||||
<Select placeholder="Select delivery method" disabled={!useCallback} defaultValue="query">
|
||||
<Option value="query">
|
||||
<div>
|
||||
<Text strong>Query Parameter</Text> (Recommended for testing)
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
Token included in callback URL query string
|
||||
</Text>
|
||||
</div>
|
||||
</Option>
|
||||
<Option value="cookie">
|
||||
<div>
|
||||
<Text strong>Cookie</Text> (More secure for production)
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
Token stored in HTTP-only cookie
|
||||
</Text>
|
||||
</div>
|
||||
</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Row gutter={16}>
|
||||
</Row>
|
||||
|
||||
<Form.Item
|
||||
name="permissions"
|
||||
label="Permissions to Request"
|
||||
@ -480,20 +566,83 @@ const TokenTester: React.FC = () => {
|
||||
showIcon
|
||||
/>
|
||||
|
||||
{callbackResult.success && callbackResult.permissions && (
|
||||
{callbackResult.success && (
|
||||
<div>
|
||||
<Text strong>Verified Permissions:</Text>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
{callbackResult.permissions.map(permission => (
|
||||
<Tag key={permission} color="green" style={{ margin: '2px' }}>
|
||||
{permission}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Card size="small" title="Token Information">
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Text strong>Token Type:</Text>
|
||||
<div>
|
||||
<Tag color="blue">{(callbackResult.token_type || 'user').toUpperCase()}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{callbackResult.user_id && (
|
||||
<div>
|
||||
<Text strong>User ID:</Text>
|
||||
<div>{callbackResult.user_id}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{callbackResult.expires_at && (
|
||||
<div>
|
||||
<Text strong>Expires At:</Text>
|
||||
<div>{new Date(callbackResult.expires_at).toLocaleString()}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{callbackResult.max_valid_at && (
|
||||
<div>
|
||||
<Text strong>Max Valid Until:</Text>
|
||||
<div>{new Date(callbackResult.max_valid_at).toLocaleString()}</div>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Card size="small" title="Permissions">
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{callbackResult.permissions && callbackResult.permissions.length > 0 ? (
|
||||
<div>
|
||||
<Text strong>Available Permissions:</Text>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
{callbackResult.permissions.map(permission => (
|
||||
<Tag key={permission} color="green" style={{ margin: '2px' }}>
|
||||
{permission}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Text type="secondary">No permissions available</Text>
|
||||
)}
|
||||
|
||||
{callbackResult.permission_results && Object.keys(callbackResult.permission_results).length > 0 && (
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<Text strong>Permission Check Results:</Text>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
{Object.entries(callbackResult.permission_results).map(([permission, granted]) => (
|
||||
<div key={permission} style={{ marginBottom: '4px' }}>
|
||||
<Tag color={granted ? 'green' : 'red'}>
|
||||
{permission}: {granted ? 'GRANTED' : 'DENIED'}
|
||||
</Tag>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<Text strong>Timestamp:</Text>
|
||||
<div>{new Date(callbackResult.timestamp).toLocaleString()}</div>
|
||||
</div>
|
||||
@ -514,7 +663,10 @@ const TokenTester: React.FC = () => {
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<Alert
|
||||
message="Callback URL Received"
|
||||
description="The login flow returned a redirect URL with a token. Test the callback handling by verifying the token."
|
||||
description={extractedToken
|
||||
? "Token successfully extracted from callback URL. Verify the token to complete the flow test."
|
||||
: "Redirect URL received. If using cookie delivery, the token is stored in a secure cookie."
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
/>
|
||||
|
||||
@ -62,8 +62,15 @@ const TokenTesterCallback: React.FC = () => {
|
||||
|
||||
// Parse URL parameters
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
let token = urlParams.get('token') || undefined;
|
||||
|
||||
// If no token in URL, try to extract from auth_token cookie
|
||||
if (!token) {
|
||||
token = getCookie('auth_token') || undefined;
|
||||
}
|
||||
|
||||
const data: CallbackData = {
|
||||
token: urlParams.get('token') || undefined,
|
||||
token: token,
|
||||
state: urlParams.get('state') || undefined,
|
||||
error: urlParams.get('error') || undefined,
|
||||
error_description: urlParams.get('error_description') || undefined,
|
||||
@ -84,6 +91,17 @@ const TokenTesterCallback: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Utility function to get cookie value by name
|
||||
const getCookie = (name: string): string | null => {
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) {
|
||||
const cookieValue = parts.pop()?.split(';').shift();
|
||||
return cookieValue || null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const verifyToken = async (token: string) => {
|
||||
try {
|
||||
// We need to extract app_id from the state or make a best guess
|
||||
|
||||
@ -186,11 +186,12 @@ class ApiService {
|
||||
}
|
||||
|
||||
// Authentication
|
||||
async login(appId: string, permissions: string[], redirectUri?: string): Promise<any> {
|
||||
async login(appId: string, permissions: string[], redirectUri?: string, tokenDelivery?: string): Promise<any> {
|
||||
const response = await this.api.post('/api/login', {
|
||||
app_id: appId,
|
||||
permissions,
|
||||
redirect_uri: redirectUri,
|
||||
token_delivery: tokenDelivery,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@ -14,7 +14,7 @@ server {
|
||||
# Apply rate limiting
|
||||
limit_req zone=api burst=20 nodelay;
|
||||
|
||||
# Add test user header for HeaderAuthenticationProvider
|
||||
# Development mode: only user email header required
|
||||
proxy_set_header X-User-Email "test@example.com";
|
||||
|
||||
# Standard proxy headers
|
||||
@ -38,7 +38,7 @@ server {
|
||||
# Apply stricter rate limiting for auth endpoints
|
||||
limit_req zone=login burst=5 nodelay;
|
||||
|
||||
# Add test user header for HeaderAuthenticationProvider
|
||||
# Development mode: only user email header required
|
||||
proxy_set_header X-User-Email "test@example.com";
|
||||
|
||||
# Standard proxy headers
|
||||
@ -47,7 +47,7 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Proxy to API service (cannot have URI part in regex location)
|
||||
# Proxy to API service
|
||||
proxy_pass http://api-service:8080;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_connect_timeout 10s;
|
||||
@ -139,7 +139,7 @@ server {
|
||||
location /api/ {
|
||||
limit_req zone=api burst=50 nodelay;
|
||||
|
||||
# Admin test user
|
||||
# Development mode: admin test user
|
||||
proxy_set_header X-User-Email "admin@example.com";
|
||||
|
||||
proxy_set_header Host $host;
|
||||
@ -159,7 +159,7 @@ server {
|
||||
location /api/ {
|
||||
limit_req zone=api burst=10 nodelay;
|
||||
|
||||
# Limited test user
|
||||
# Development mode: limited test user
|
||||
proxy_set_header X-User-Email "limited@example.com";
|
||||
|
||||
proxy_set_header Host $host;
|
||||
|
||||
Reference in New Issue
Block a user