Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 54 additions & 14 deletions manager/backend/app/blueprints/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@

from flask import Blueprint, request, jsonify, current_app, g
from app.services.auth_service import AuthService
from app.services.cookie_auth import (
REFRESH_COOKIE_NAME,
clear_auth_cookies,
csrf_check_passes,
generate_csrf_token,
set_auth_cookies,
)
from app.middleware.auth import token_required, get_current_user
from app.utils.decorators import validate_json, audit_log
from app.extensions import limiter
Expand Down Expand Up @@ -85,7 +92,7 @@ def login():
)
refresh_token = AuthService.create_refresh_token(user['id'])

return jsonify({
response = jsonify({
'accessToken': access_token,
'refreshToken': refresh_token,
'user': {
Expand All @@ -95,12 +102,18 @@ def login():
'global_role': user['global_role'],
'team_roles': user['team_roles']
}
}), 200
})
# Additive: cookie-based clients (dns-webui) authenticate via these
# HttpOnly cookies instead of reading the tokens above out of the JSON
# body -- see app/services/cookie_auth.py. Bearer-token clients ignore
# the cookies and keep using the JSON tokens unchanged.
csrf_token = generate_csrf_token()
set_auth_cookies(response, access_token, refresh_token, csrf_token)
return response, 200


@auth_bp.route('/api/v1/auth/refresh', methods=['POST'])
@limiter.limit('20/minute')
@validate_json('refreshToken')
@audit_log('token_refresh', resource_type='user')
def refresh():
"""
Expand All @@ -110,9 +123,16 @@ def refresh():
access + refresh token pair is issued. Reusing a rotated or revoked
refresh token returns 401.

The refresh token is read from the JSON body (existing bearer-token
clients) if present, otherwise from the HttpOnly refresh_token cookie
(dns-webui, which never has JS-level access to the token). Cookie-based
requests must also present a matching X-CSRF-Token header (double-submit
against the csrf_token cookie) since the browser attaches the cookie
automatically.

Request:
{
"refreshToken": "..."
"refreshToken": "..." # optional if refresh_token cookie is set
}

Response:
Expand All @@ -121,8 +141,21 @@ def refresh():
"refreshToken": "..."
}
"""
data = request.get_json()
refresh_token = data['refreshToken']
data = request.get_json(silent=True) or {}
refresh_token = data.get('refreshToken')
from_cookie = False
if not refresh_token:
refresh_token = request.cookies.get(REFRESH_COOKIE_NAME)
from_cookie = bool(refresh_token)

if not refresh_token:
return jsonify({
'error': 'Missing required fields',
'missing_fields': ['refreshToken']
}), 400

if from_cookie and not csrf_check_passes():
return jsonify({'error': 'Invalid or missing CSRF token'}), 403

# Resolve the token's subject for audit attribution before rotation --
# get_current_user() has nothing to return here (no access token on this
Expand All @@ -137,40 +170,47 @@ def refresh():
if not tokens:
return jsonify({'error': 'Invalid or expired refresh token'}), 401

return jsonify({
response = jsonify({
'accessToken': tokens['access_token'],
'refreshToken': tokens['refresh_token']
}), 200
})
csrf_token = generate_csrf_token()
set_auth_cookies(response, tokens['access_token'], tokens['refresh_token'], csrf_token)
return response, 200


@auth_bp.route('/api/v1/auth/logout', methods=['POST'])
@token_required
@audit_log('user_logout')
def logout():
"""
Logout user: revoke the refresh token server-side.
Logout user: revoke the refresh token server-side and clear auth cookies.

Request (optional body):
{
"refreshToken": "..."
"refreshToken": "..." # falls back to the refresh_token cookie
}

The access token (15 min) simply ages out; the long-lived refresh token
is revoked here so it cannot mint new access tokens after logout.
is revoked here so it cannot mint new access tokens after logout. This
route requires @token_required, so a cookie-authenticated request has
already passed the CSRF check there.

Response:
{
"message": "Logged out successfully"
}
"""
data = request.get_json(silent=True) or {}
refresh_token = data.get('refreshToken')
refresh_token = data.get('refreshToken') or request.cookies.get(REFRESH_COOKIE_NAME)
if refresh_token:
AuthService.revoke_refresh_token(refresh_token, reason='logout')

return jsonify({
response = jsonify({
'message': 'Logged out successfully'
}), 200
})
clear_auth_cookies(response)
return response, 200


@auth_bp.route('/api/v1/auth/me', methods=['GET'])
Expand Down
9 changes: 7 additions & 2 deletions manager/backend/app/blueprints/mfa.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from flask import Blueprint, request, jsonify, current_app, g
from app.middleware.auth import token_required, get_current_user
from app.services.auth_service import AuthService
from app.services.cookie_auth import generate_csrf_token, set_auth_cookies
from app.services.mfa_service import MFAService
from app.utils.decorators import validate_json, audit_log
from app.extensions import limiter
Expand Down Expand Up @@ -303,7 +304,7 @@ def mfa_verify():
)
refresh_token = AuthService.create_refresh_token(user_id)

return jsonify({
response = jsonify({
'accessToken': access_token,
'refreshToken': refresh_token,
'user': {
Expand All @@ -313,4 +314,8 @@ def mfa_verify():
'global_role': user_record['global_role'],
'team_roles': team_roles
}
}), 200
})
# Additive, same as /auth/login -- see app/services/cookie_auth.py.
csrf_token = generate_csrf_token()
set_auth_cookies(response, access_token, refresh_token, csrf_token)
return response, 200
17 changes: 17 additions & 0 deletions manager/backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ class Config:
JWT_ACCESS_TOKEN_EXPIRES = timedelta(minutes=15)
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=7)

# Cookie-based auth (dns-webui). Defaults to Secure=True (HTTPS-only) in
# every environment -- only disable for local HTTP development, never
# inferred from DEBUG/TESTING, so a misconfigured prod deploy fails
# closed. COOKIE_DOMAIN is unset (host-only cookie) unless the frontend
# and API are deployed on different subdomains of the same parent.
COOKIE_SECURE = os.getenv('COOKIE_SECURE', 'true').lower() == 'true'
COOKIE_DOMAIN = os.getenv('COOKIE_DOMAIN') or None

# SPIFFE/mTLS service-to-service identity (preferred over per-server JWTs).
# The service mesh / gateway terminates mTLS and forwards the verified peer
# SPIFFE ID in the XFCC header. When present, it supersedes the legacy
Expand Down Expand Up @@ -106,6 +114,10 @@ class DevelopmentConfig(Config):
# Development-only ephemeral secrets (never use in production)
SECRET_KEY = os.getenv('SECRET_KEY', 'dev-ephemeral-secret-key-only')
JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY', SECRET_KEY)
# Local dev typically runs over plain HTTP; still explicit opt-in via
# env var so it's never silently disabled by DEBUG alone in a shared
# or misconfigured environment.
COOKIE_SECURE = os.getenv('COOKIE_SECURE', 'false').lower() == 'true'

def __init__(self) -> None:
"""Generate ephemeral ES256 keypair if not configured."""
Expand Down Expand Up @@ -162,6 +174,11 @@ class TestingConfig(Config):
# Testing-only ephemeral secrets (never use in production)
SECRET_KEY = 'test-ephemeral-secret-key-only'
JWT_SECRET_KEY = 'test-ephemeral-jwt-key-only'
# The Flask/Werkzeug test client talks plain HTTP; a Secure cookie set
# here would still be recorded, but keeping this False mirrors real
# local/dev HTTP behavior and avoids masking a client-side secure-cookie
# bug behind the test client's more lenient cookie jar.
COOKIE_SECURE = False

def __init__(self) -> None:
"""Generate ephemeral ES256 keypair for testing."""
Expand Down
25 changes: 17 additions & 8 deletions manager/backend/app/middleware/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,39 @@
from functools import wraps
from flask import request, jsonify, current_app, g
from app.services.auth_service import AuthService
from app.services.cookie_auth import (
csrf_check_passes,
extract_bearer_or_cookie_token,
)

logger = logging.getLogger(__name__)


def token_required(f):
"""
Decorator to require valid JWT token.

Accepts the token from either the Authorization header (existing
bearer-token clients: manager/frontend, the Go client, machine clients)
or the HttpOnly access_token cookie (dns-webui). When the token comes
from the cookie, a matching double-submit CSRF header is required on
state-changing requests -- see app/services/cookie_auth.py.

Extracts user information from token and stores in g.current_user.
"""
@wraps(f)
def decorated(*args, **kwargs):
token = None
token, from_cookie = extract_bearer_or_cookie_token()

# Get token from Authorization header
auth_header = request.headers.get('Authorization')
if auth_header:
try:
token = auth_header.split(' ')[1] # Bearer <token>
except IndexError:
return jsonify({'error': 'Invalid authorization header format'}), 401
if request.headers.get('Authorization') and not token:
return jsonify({'error': 'Invalid authorization header format'}), 401

if not token:
return jsonify({'error': 'Authentication token required'}), 401

if from_cookie and not csrf_check_passes():
return jsonify({'error': 'Invalid or missing CSRF token'}), 403

# Decode and validate token
payload = AuthService.decode_token(token)
if not payload:
Expand Down
Loading
Loading