168 lines
5.6 KiB
Lua
168 lines
5.6 KiB
Lua
|
|
-- JWT validation module for OpenResty edge authentication
|
||
|
|
-- Validates JWT tokens before requests hit the backend
|
||
|
|
local jwt = require "resty.jwt"
|
||
|
|
|
||
|
|
local _M = {}
|
||
|
|
|
||
|
|
-- Get JWT secret (cached after first call)
|
||
|
|
local jwt_secret_cache = nil
|
||
|
|
|
||
|
|
local function get_jwt_secret()
|
||
|
|
if jwt_secret_cache then
|
||
|
|
return jwt_secret_cache
|
||
|
|
end
|
||
|
|
|
||
|
|
-- os.getenv works in OpenResty when 'env JWT_SECRET;' is in nginx.conf
|
||
|
|
local secret = os.getenv("JWT_SECRET")
|
||
|
|
if secret and secret ~= "" then
|
||
|
|
jwt_secret_cache = secret
|
||
|
|
ngx.log(ngx.INFO, "JWT_SECRET loaded successfully (", string.len(secret), " chars)")
|
||
|
|
return secret
|
||
|
|
end
|
||
|
|
|
||
|
|
ngx.log(ngx.ERR, "JWT_SECRET environment variable not set or empty")
|
||
|
|
return nil
|
||
|
|
end
|
||
|
|
|
||
|
|
-- Validate JWT and inject user headers for backend
|
||
|
|
-- Call this in access_by_lua_block for authenticated endpoints
|
||
|
|
function _M.validate_and_inject()
|
||
|
|
-- Get token from httpOnly cookie (name: auth_token)
|
||
|
|
local token = ngx.var.cookie_auth_token
|
||
|
|
if not token or token == "" then
|
||
|
|
ngx.log(ngx.DEBUG, "No auth_token cookie found")
|
||
|
|
ngx.status = 401
|
||
|
|
ngx.header["Content-Type"] = "application/json"
|
||
|
|
ngx.say('{"error":"No authentication token"}')
|
||
|
|
return ngx.exit(401)
|
||
|
|
end
|
||
|
|
|
||
|
|
-- Get JWT secret
|
||
|
|
local secret = get_jwt_secret()
|
||
|
|
if not secret then
|
||
|
|
-- SECURITY FIX: Fail closed - do NOT allow requests through if secret is missing
|
||
|
|
-- This prevents authentication bypass if env var is misconfigured
|
||
|
|
ngx.log(ngx.ERR, "JWT_SECRET not configured - blocking request")
|
||
|
|
ngx.status = 500
|
||
|
|
ngx.header["Content-Type"] = "application/json"
|
||
|
|
ngx.say('{"error":"Server configuration error"}')
|
||
|
|
return ngx.exit(500)
|
||
|
|
end
|
||
|
|
|
||
|
|
-- Verify JWT signature and decode
|
||
|
|
local jwt_obj = jwt:verify(secret, token)
|
||
|
|
|
||
|
|
if not jwt_obj then
|
||
|
|
ngx.log(ngx.ERR, "JWT verification returned nil")
|
||
|
|
ngx.status = 401
|
||
|
|
ngx.header["Content-Type"] = "application/json"
|
||
|
|
ngx.say('{"error":"Invalid token"}')
|
||
|
|
return ngx.exit(401)
|
||
|
|
end
|
||
|
|
|
||
|
|
if not jwt_obj.verified then
|
||
|
|
local reason = jwt_obj.reason or "unknown"
|
||
|
|
ngx.log(ngx.WARN, "JWT verification failed: ", reason)
|
||
|
|
ngx.status = 401
|
||
|
|
ngx.header["Content-Type"] = "application/json"
|
||
|
|
ngx.say('{"error":"Invalid token: ' .. reason .. '"}')
|
||
|
|
return ngx.exit(401)
|
||
|
|
end
|
||
|
|
|
||
|
|
-- Payload should exist if verified
|
||
|
|
local payload = jwt_obj.payload
|
||
|
|
if not payload then
|
||
|
|
ngx.log(ngx.ERR, "JWT verified but no payload")
|
||
|
|
ngx.status = 401
|
||
|
|
ngx.header["Content-Type"] = "application/json"
|
||
|
|
ngx.say('{"error":"Invalid token structure"}')
|
||
|
|
return ngx.exit(401)
|
||
|
|
end
|
||
|
|
|
||
|
|
-- Check expiry (jwt library should handle this, but be safe)
|
||
|
|
local exp = payload.exp
|
||
|
|
if exp then
|
||
|
|
local now = ngx.time()
|
||
|
|
if exp < now then
|
||
|
|
ngx.log(ngx.DEBUG, "JWT expired: exp=", exp, " now=", now)
|
||
|
|
ngx.status = 401
|
||
|
|
ngx.header["Content-Type"] = "application/json"
|
||
|
|
ngx.say('{"error":"Token expired"}')
|
||
|
|
return ngx.exit(401)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
-- Check issuer
|
||
|
|
local iss = payload.iss
|
||
|
|
if iss ~= "streaming-app" then
|
||
|
|
ngx.log(ngx.WARN, "Invalid JWT issuer: ", tostring(iss))
|
||
|
|
ngx.status = 401
|
||
|
|
ngx.header["Content-Type"] = "application/json"
|
||
|
|
ngx.say('{"error":"Invalid token issuer"}')
|
||
|
|
return ngx.exit(401)
|
||
|
|
end
|
||
|
|
|
||
|
|
-- SECURITY: Check if user account is disabled
|
||
|
|
local is_disabled = payload.is_disabled
|
||
|
|
if is_disabled == "1" or is_disabled == true then
|
||
|
|
ngx.log(ngx.WARN, "Disabled user attempted access: ", tostring(payload.username))
|
||
|
|
ngx.status = 403
|
||
|
|
ngx.header["Content-Type"] = "application/json"
|
||
|
|
ngx.say('{"error":"Account disabled"}')
|
||
|
|
return ngx.exit(403)
|
||
|
|
end
|
||
|
|
|
||
|
|
-- Inject user info headers for backend (allows backend to skip re-validation)
|
||
|
|
ngx.req.set_header("X-User-ID", tostring(payload.user_id or ""))
|
||
|
|
ngx.req.set_header("X-Username", tostring(payload.username or ""))
|
||
|
|
ngx.req.set_header("X-Is-Admin", tostring(payload.is_admin or "0"))
|
||
|
|
ngx.req.set_header("X-Is-Moderator", tostring(payload.is_moderator or "0"))
|
||
|
|
ngx.req.set_header("X-Is-Streamer", tostring(payload.is_streamer or "0"))
|
||
|
|
ngx.req.set_header("X-Is-Restreamer", tostring(payload.is_restreamer or "0"))
|
||
|
|
ngx.req.set_header("X-Token-Version", tostring(payload.token_version or "1"))
|
||
|
|
ngx.req.set_header("X-User-Color", tostring(payload.color_code or "#561D5E"))
|
||
|
|
ngx.req.set_header("X-Avatar-URL", tostring(payload.avatar_url or ""))
|
||
|
|
ngx.req.set_header("X-JWT-Validated", "true")
|
||
|
|
|
||
|
|
ngx.log(ngx.DEBUG, "JWT validated for user: ", tostring(payload.username))
|
||
|
|
end
|
||
|
|
|
||
|
|
-- Optional: Get user info without blocking (for endpoints that work with or without auth)
|
||
|
|
-- Returns user payload if authenticated, nil otherwise
|
||
|
|
function _M.get_user_if_authenticated()
|
||
|
|
local token = ngx.var.cookie_auth_token
|
||
|
|
if not token or token == "" then
|
||
|
|
return nil
|
||
|
|
end
|
||
|
|
|
||
|
|
local secret = get_jwt_secret()
|
||
|
|
if not secret then
|
||
|
|
return nil
|
||
|
|
end
|
||
|
|
|
||
|
|
local jwt_obj = jwt:verify(secret, token)
|
||
|
|
if not jwt_obj or not jwt_obj.verified then
|
||
|
|
return nil
|
||
|
|
end
|
||
|
|
|
||
|
|
local payload = jwt_obj.payload
|
||
|
|
if not payload then
|
||
|
|
return nil
|
||
|
|
end
|
||
|
|
|
||
|
|
-- Check expiry
|
||
|
|
if payload.exp and payload.exp < ngx.time() then
|
||
|
|
return nil
|
||
|
|
end
|
||
|
|
|
||
|
|
-- Check disabled
|
||
|
|
local is_disabled = payload.is_disabled
|
||
|
|
if is_disabled == "1" or is_disabled == true then
|
||
|
|
return nil
|
||
|
|
end
|
||
|
|
|
||
|
|
return payload
|
||
|
|
end
|
||
|
|
|
||
|
|
return _M
|