Implement scoped P06 authentication policy and guarded optional onboarding
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a09cbb-87c6-7900-a145-4ce53ba9f1a6
This commit is contained in:
parent
aa709fb854
commit
e0b3c25f06
12 changed files with 1085 additions and 2 deletions
|
|
@ -2,7 +2,7 @@ name: Authentication acceptance
|
|||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["src/**", "scripts/provider-onboarding-contract.py", "scripts/factor_recovery.py", "scripts/test_factor_recovery.py", "scripts/recovery_service.py", "scripts/test_recovery_service.py", ".forgejo/workflows/acceptance.yaml"]
|
||||
paths: ["src/**", "scripts/provider-onboarding-contract.py", "scripts/keycape_onboarding_guard.py", "scripts/factor_recovery.py", "scripts/test_factor_recovery.py", "scripts/recovery_service.py", "scripts/test_recovery_service.py", ".forgejo/workflows/acceptance.yaml"]
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
acceptance:
|
||||
|
|
|
|||
27
scripts/keycape_onboarding_guard.py
Normal file
27
scripts/keycape_onboarding_guard.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""Provider enrollment hook: password-only self-service cannot replace an active factor.
|
||||
|
||||
Loaded explicitly by provider configuration, so a missing module fails startup.
|
||||
Pending setup can be regenerated/confirmed; active-factor replacement goes through
|
||||
fresh-MFA audited recovery. Admin actions retain their existing provider policies.
|
||||
"""
|
||||
def check(request, action):
|
||||
from flask import g
|
||||
from privacyidea.lib.error import PolicyError
|
||||
from privacyidea.lib.user import User
|
||||
from privacyidea.lib.token import get_tokens
|
||||
|
||||
principal = g.logged_in_user
|
||||
if principal.get('role') != 'user':
|
||||
return True
|
||||
user = User(principal.get('username', ''), principal.get('realm', ''))
|
||||
if user.is_empty() or action != 'init':
|
||||
raise PolicyError('Use the account recovery process to replace an active authenticator.')
|
||||
tokens = get_tokens(user=user, active=True)
|
||||
if any(token.token.rollout_state not in {'verify', 'clientwait', 'pending'} for token in tokens):
|
||||
raise PolicyError('An active authenticator already exists. Use the account recovery process to replace it.')
|
||||
serial = request.all_data.get('serial')
|
||||
if serial:
|
||||
matches = [token for token in tokens if token.token.serial == serial]
|
||||
if len(matches) != 1 or matches[0].token.rollout_state != 'verify':
|
||||
raise PolicyError('Only your unfinished authenticator setup can be confirmed or regenerated.')
|
||||
return True
|
||||
53
scripts/provider-browser-fixture.py
Normal file
53
scripts/provider-browser-fixture.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"""Isolated provider contract test. Never loads production config or credentials."""
|
||||
import contextlib,io,json,logging,os,tempfile,time,base64,hmac,hashlib,struct,urllib.parse
|
||||
from pathlib import Path
|
||||
result={"success":False};phase="isolation"
|
||||
def check(name,condition):
|
||||
result[name]=bool(condition)
|
||||
if not condition:raise ValueError(name)
|
||||
def run():
|
||||
global phase
|
||||
for k in list(os.environ):
|
||||
if k.startswith("PRIVACYIDEA_"):del os.environ[k]
|
||||
logging.disable(logging.CRITICAL)
|
||||
with tempfile.TemporaryDirectory(prefix="provider-contract-") as d:
|
||||
root=Path(d);(root/'enckey').write_bytes(os.urandom(96))
|
||||
cfg=root/'fixture.cfg';cfg.write_text("SQLALCHEMY_DATABASE_URI='sqlite:///PLACEHOLDER_DB'\nSECRET_KEY='isolated-fixture-only'\nPI_PEPPER='isolated-fixture-only'\nPI_NO_RESPONSE_SIGN=True\nPI_AUDIT_NO_SIGN=True\nPI_LOGFILE="+repr(str(root/'log'))+"\nPI_ENCFILE="+repr(str(root/'enckey'))+"\nPI_TRUSTED_JWT=[]\n")
|
||||
cfg.write_text(cfg.read_text().replace('PLACEHOLDER_DB', str(root/'fixture.sqlite')))
|
||||
with cfg.open('a') as f:f.write('PI_AUDIT_SQL_URI='+repr('sqlite:///'+str(root/'audit.sqlite'))+'\n')
|
||||
from privacyidea.app import create_app
|
||||
from privacyidea.models import db
|
||||
from privacyidea.lib.policy import set_policy,enable_policy
|
||||
from privacyidea.lib.resolver import save_resolver
|
||||
from privacyidea.lib.realm import set_realm
|
||||
from privacyidea.lib.auth import create_db_admin
|
||||
from privacyidea.lib.resolvers.PasswdIdResolver import crypt_ctx
|
||||
app=create_app(config_name="testing",config_file=str(cfg),silent=True)
|
||||
app.config["PI_INIT_CHECK_HOOK"]="keycape_onboarding_guard.check"
|
||||
phase="fixture_database"
|
||||
with app.app_context():
|
||||
check('database_isolated',str(db.engine.url)=='sqlite:///'+str(root/'fixture.sqlite'));db.create_all()
|
||||
from privacyidea.lib.audit import getAudit
|
||||
from privacyidea.lib.auditmodules.sqlaudit import LogEntry
|
||||
LogEntry.__table__.create(getAudit(app.config).engine,checkfirst=True)
|
||||
passwd=root/'users';passwd.write_text('alice:'+crypt_ctx.hash('fixture-password',scheme='sha512_crypt')+':1001:1001:Fixture:/tmp:/bin/false\n')
|
||||
with passwd.open('a') as f:f.write('native-alice:'+crypt_ctx.hash('fixture-password',scheme='sha512_crypt')+':1002:1002:Native fixture:/tmp:/bin/false\n')
|
||||
save_resolver({'resolver':'fixture-users','type':'passwdresolver','fileName':str(passwd)})
|
||||
set_realm('fixture',[{'name':'fixture-users'}])
|
||||
create_db_admin('fixture-reader',password='fixture-service-password')
|
||||
set_policy(name='fixture-admin-baseline',scope='admin',action='*',adminuser=['*','!fixture-reader'])
|
||||
set_policy(name='fixture-reader',scope='admin',action='tokenlist',adminuser='fixture-reader',realm='fixture')
|
||||
set_policy(name='fixture-user',scope='user',action='enrollTOTP',realm='fixture')
|
||||
set_policy(name='fixture-pending-cancel',scope='user',action='delete',realm='fixture',conditions=[('token','rollout_state','equals','verify',True)])
|
||||
set_policy(name='fixture-confirmation',scope='enrollment',action='verify_enrollment=totp',realm='fixture')
|
||||
set_policy(name='fixture-passthru',scope='authentication',action='passthru',realm='fixture')
|
||||
from privacyidea.lib.token import init_token
|
||||
probe=init_token({'serial':'P06SCOPEPROBE','type':'hotp','genkey':1,'realm':'fixture'})
|
||||
probe.token.active=False;probe.token.save()
|
||||
print(json.dumps({"fixture_ready":True,"production_database":False}),flush=True)
|
||||
app.run(host="0.0.0.0",port=8088,debug=False,use_reloader=False)
|
||||
if __name__=="__main__":
|
||||
try:run()
|
||||
except Exception as error:
|
||||
print(json.dumps({"fixture_failure":type(error).__name__,"phase":phase}),flush=True)
|
||||
raise SystemExit(1)
|
||||
|
|
@ -22,6 +22,7 @@ def run():
|
|||
from privacyidea.lib.auth import create_db_admin
|
||||
from privacyidea.lib.resolvers.PasswdIdResolver import crypt_ctx
|
||||
app=create_app(config_name="testing",config_file=str(cfg),silent=True)
|
||||
app.config["PI_INIT_CHECK_HOOK"]="keycape_onboarding_guard.check"
|
||||
phase="fixture_database"
|
||||
with app.app_context():
|
||||
check('database_isolated',str(db.engine.url)=='sqlite:///:memory:');db.create_all()
|
||||
|
|
@ -34,7 +35,8 @@ def run():
|
|||
create_db_admin('fixture-reader',password='fixture-service-password')
|
||||
set_policy(name='fixture-admin-baseline',scope='admin',action='*',adminuser=['*','!fixture-reader'])
|
||||
set_policy(name='fixture-reader',scope='admin',action='tokenlist',adminuser='fixture-reader',realm='fixture')
|
||||
set_policy(name='fixture-user',scope='user',action='enrollTOTP,delete,disable',realm='fixture')
|
||||
set_policy(name='fixture-user',scope='user',action='enrollTOTP',realm='fixture')
|
||||
set_policy(name='fixture-pending-cancel',scope='user',action='delete',realm='fixture',conditions=[('token','rollout_state','equals','verify',True)])
|
||||
set_policy(name='fixture-confirmation',scope='enrollment',action='verify_enrollment=totp',realm='fixture')
|
||||
set_policy(name='fixture-passthru',scope='authentication',action='passthru',realm='fixture')
|
||||
client=app.test_client()
|
||||
|
|
@ -74,6 +76,14 @@ def run():
|
|||
check('possession_confirmed',code==200 and body['result']['status'] and body['result']['value'] is True)
|
||||
code,body=req('GET','/token/?user=alice&realm=fixture&active=True',token=reader)
|
||||
check('confirmed_token_enrolled',code==200 and body['result']['value']['tokens'][0]['rollout_state']=='enrolled')
|
||||
code,body=req('DELETE','/token/'+serial,token=user)
|
||||
check('pre_enrollment_session_cannot_delete_confirmed_factor',code in (400,403))
|
||||
code,body=req('POST','/token/disable',{'serial':serial},user)
|
||||
check('self_service_cannot_disable_confirmed_factor',code in (400,403))
|
||||
code,body=req('POST','/token/init',{'type':'totp','genkey':'1'},user)
|
||||
check('pre_enrollment_session_cannot_add_another_factor',code in (400,403))
|
||||
code,body=req('POST','/token/init',{'serial':serial,'type':'totp','genkey':'1'},user)
|
||||
check('pre_enrollment_session_cannot_regenerate_active_factor',code in (400,403))
|
||||
phase='provider_scope_withdrawal'
|
||||
with app.app_context():enable_policy('fixture-reader',False)
|
||||
code,body=req('GET','/token/?tokenrealm=fixture',token=reader)
|
||||
|
|
|
|||
68
scripts/provider_browser.mjs
Normal file
68
scripts/provider_browser.mjs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import {createHmac} from 'node:crypto';
|
||||
// No production accounts, network providers or header overrides are used.
|
||||
const [debug, base] = process.argv.slice(2);
|
||||
const version=await (await fetch(debug+'/json/version')).json();
|
||||
const ws=new WebSocket(version.webSocketDebuggerUrl);
|
||||
await new Promise(resolve=>ws.addEventListener('open',resolve,{once:true}));
|
||||
let next=0;const pending=new Map();
|
||||
ws.addEventListener('message',event=>{const m=JSON.parse(event.data);if(pending.has(m.id)){const p=pending.get(m.id);pending.delete(m.id);m.error?p.reject(Error(m.error.message)):p.resolve(m.result);}});
|
||||
const call=(method,params={},sessionId)=>new Promise((resolve,reject)=>{const id=++next;pending.set(id,{resolve,reject});ws.send(JSON.stringify({id,method,params,...(sessionId?{sessionId}:{})}));});
|
||||
const {targetId}=await call('Target.createTarget',{url:'about:blank'});
|
||||
const {sessionId}=await call('Target.attachToTarget',{targetId,flatten:true});
|
||||
const cmd=(method,params)=>call(method,params,sessionId);
|
||||
await cmd('Page.enable');await cmd('Network.enable');
|
||||
const evaluate=async expression=>{const r=await cmd('Runtime.evaluate',{expression,returnByValue:true});if(r.exceptionDetails)throw Error(r.exceptionDetails.exception?.description?.split('\n')[0] || 'Browser evaluation failed');return r.result.value;};
|
||||
async function waitFor(expression){for(let n=0;n<300;n++){if(await evaluate(expression))return;await new Promise(r=>setTimeout(r,100));}throw Error('Browser condition not met: '+expression);}
|
||||
async function navigate(path){await cmd('Page.navigate',{url:base+path});await waitFor('location.origin === '+JSON.stringify(base)+' && document.readyState !== "loading"');}
|
||||
async function identity(who){await cmd('Network.clearBrowserCookies');if(who)await cmd('Network.setCookie',{name:'ue_session',value:who,url:base,path:'/',httpOnly:true,sameSite:'Lax'});}
|
||||
let checks=0;async function check(expression,name){if(!await evaluate(expression))throw Error(name);console.log('PASS '+name);checks++;}
|
||||
const captures=[];const responses=new Set();
|
||||
ws.addEventListener('message', async event=>{const message=JSON.parse(event.data);if(message.method==='Network.responseReceived' && message.params.response.url.split('?')[0].endsWith('/token/init'))responses.add(message.params.requestId);if(message.method==='Network.loadingFinished' && responses.has(message.params.requestId)){try{const body=await cmd('Network.getResponseBody',{requestId:message.params.requestId});captures.push(JSON.parse(body.body));}catch{}}});
|
||||
try {
|
||||
await navigate('/');
|
||||
await waitFor('!!document.querySelector("#username") && document.querySelector("#username").offsetParent!==null');
|
||||
await evaluate(`document.querySelector('#username').value='alice@fixture';document.querySelector('#username').dispatchEvent(new Event('input',{bubbles:true}));const password=Array.from(document.querySelectorAll('input[type=password]')).find(e=>e.offsetParent!==null);password.value='fixture-password';password.dispatchEvent(new Event('input',{bubbles:true}));Array.from(document.querySelectorAll('button')).find(b=>b.offsetParent!==null && b.textContent.trim()==='Log In').click()`);
|
||||
await waitFor('!!Array.from(document.links).find(a=>a.textContent.trim()==="Enroll Token")');
|
||||
await check(`document.body.innerText.includes('alice @fixture (user)')`,'provider password login identifies the correct account');
|
||||
async function enroll(){
|
||||
const count=captures.length;
|
||||
await evaluate(`location.hash='!/token/enroll///'`);
|
||||
await waitFor('!!document.querySelector("#tokentype") && document.querySelector("#tokentype").offsetParent!==null');
|
||||
await waitFor('document.querySelector("#tokentype").value === "string:totp"');
|
||||
await evaluate(`document.querySelector('#tokentype').dispatchEvent(new Event('change',{bubbles:true}));`);
|
||||
await evaluate(`Array.from(document.querySelectorAll('button')).find(b=>b.offsetParent!==null && b.textContent.trim()==='Enroll Token').click()`);
|
||||
for(let i=0;i<300 && captures.length===count;i++)await new Promise(r=>setTimeout(r,100));
|
||||
const response=captures[count];if(response?.detail?.rollout_state!=='verify')throw Error('Enrollment did not require possession confirmation');
|
||||
await waitFor('!!document.querySelector("#verifyResponse") && document.querySelector("#verifyResponse").offsetParent!==null');
|
||||
return response.detail;
|
||||
}
|
||||
const first=await enroll();
|
||||
await check(`!!document.querySelector('#verifyResponse')`,'provider pending setup requests possession proof');
|
||||
await evaluate(`location.hash='!/token/list'`);
|
||||
await waitFor('!!Array.from(document.links).find(a=>a.textContent.trim()==='+JSON.stringify(first.serial)+')');
|
||||
await evaluate(`Array.from(document.links).find(a=>a.textContent.trim()===${JSON.stringify(first.serial)}).click()`);
|
||||
await waitFor(`!!document.querySelector('[ng-click="deleteTokenAsk()"]')`);
|
||||
await waitFor(`!document.querySelector('#deleteButton').disabled && Array.from(document.querySelectorAll('td')).some(e=>e.textContent.trim()==='verify')`);
|
||||
await evaluate(`document.querySelector('[ng-click="deleteTokenAsk()"]').click()`);
|
||||
await waitFor('location.hash.includes("/token/list") && !Array.from(document.links).find(a=>a.textContent.trim()==='+JSON.stringify(first.serial)+')');
|
||||
await check(`!document.body.innerText.includes(${JSON.stringify(first.serial)})`,'provider unfinished setup can be cancelled');
|
||||
const second=await enroll();
|
||||
const uri=new URL(second.googleurl.value);const alphabet='ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';let bits=0,value=0;const bytes=[];
|
||||
for(const character of uri.searchParams.get('secret').replace(/=/g,'')){value=(value<<5)|alphabet.indexOf(character);bits+=5;if(bits>=8){bits-=8;bytes.push((value>>>bits)&255);}}
|
||||
const counter=Buffer.alloc(8);counter.writeBigUInt64BE(BigInt(Math.floor(Date.now()/30000)));
|
||||
const digest=createHmac('sha1',Buffer.from(bytes)).update(counter).digest();const offset=digest[digest.length-1]&15;const otp=String((digest.readUInt32BE(offset)&0x7fffffff)%1000000).padStart(6,'0');
|
||||
await evaluate(`const field=document.querySelector('#verifyResponse');field.value=${JSON.stringify(otp)};field.dispatchEvent(new Event('input',{bubbles:true}));Array.from(document.querySelectorAll('button')).find(b=>b.offsetParent!==null && b.textContent.trim()==='Verify Token').click()`);
|
||||
await waitFor('document.body.innerText.includes("Token successfully verified")');
|
||||
await check(`document.body.innerText.includes('Token successfully verified')`,'provider browser possession confirmation activates TOTP');
|
||||
// A password-only provider session must not enroll an attacker-controlled replacement.
|
||||
await evaluate(`location.hash='!/token/list'`);
|
||||
await waitFor('!!Array.from(document.links).find(a=>a.textContent.trim()==='+JSON.stringify(second.serial)+')');
|
||||
await evaluate(`location.hash='!/token/enroll///'`);
|
||||
await waitFor('!!document.querySelector("#tokentype") && document.querySelector("#tokentype").offsetParent!==null');
|
||||
await waitFor('document.querySelector("#tokentype").value === "string:totp"');
|
||||
await evaluate(`document.querySelector('#tokentype').dispatchEvent(new Event('change',{bubbles:true}));`);
|
||||
await evaluate(`Array.from(document.querySelectorAll('button')).find(b=>b.offsetParent!==null && b.textContent.trim()==='Enroll Token').click()`);
|
||||
await waitFor('document.body.innerText.includes("active authenticator already exists")');
|
||||
await check(`document.body.innerText.includes('account recovery process')`,'provider active-factor replacement has a recovery route');
|
||||
console.log(JSON.stringify({checks,result:'passed',scope:'installed provider browser, disposable database and fixture identity only'}));
|
||||
} finally {await call('Target.closeTarget',{targetId});ws.close();}
|
||||
95
scripts/provider_browser_acceptance.py
Normal file
95
scripts/provider_browser_acceptance.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Run installed-provider browser and KeyCape adapter acceptance in a disposable Job.
|
||||
|
||||
No production environment, service account, databases or real users are mounted.
|
||||
Requires kubectl, Chromium, Node and Go; prints checks, never OTP material.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
IMAGE='ghcr.io/gpappsoft/privacyidea-docker@sha256:af7841adad262f129e0c1d4f553af13f21cb2f4dc713533f316cfe43ed0b4473'
|
||||
|
||||
def main():
|
||||
chrome=os.environ.get('JOURNEY_CHROME') or shutil.which('chromium') or shutil.which('google-chrome')
|
||||
if not chrome:
|
||||
candidates=sorted((Path.home()/'.cache/ms-playwright').glob('chromium-*/chrome-linux64/chrome'))
|
||||
chrome=str(candidates[-1]) if candidates else None
|
||||
if not chrome or not shutil.which('node') or not shutil.which('go'):
|
||||
raise RuntimeError('Chromium, Node and Go are required; acceptance was not run')
|
||||
guard=(ROOT/'scripts/keycape_onboarding_guard.py').read_text()
|
||||
source='import sys,types\ng=types.ModuleType("keycape_onboarding_guard");sys.modules["keycape_onboarding_guard"]=g\nexec('+repr(guard)+',g.__dict__)\n'+(ROOT/'scripts/provider-browser-fixture.py').read_text()
|
||||
name='provider-p06-browser-'+uuid.uuid4().hex[:8]
|
||||
job={'apiVersion':'batch/v1','kind':'Job','metadata':{'name':name,'namespace':'mfa'},'spec':{'activeDeadlineSeconds':600,'ttlSecondsAfterFinished':3600,'template':{'metadata':{'labels':{'app.kubernetes.io/name':'provider-p06-browser'}},'spec':{'automountServiceAccountToken':False,'restartPolicy':'Never','securityContext':{'runAsUser':65534,'runAsGroup':65534,'runAsNonRoot':True,'fsGroup':65534},'containers':[{'name':'fixture','image':IMAGE,'command':['python3','-c',source],'env':[{'name':'PYTHONDONTWRITEBYTECODE','value':'1'}],'securityContext':{'allowPrivilegeEscalation':False,'readOnlyRootFilesystem':True,'capabilities':{'drop':['ALL']}},'resources':{'requests':{'cpu':'1m','memory':'128Mi'},'limits':{'cpu':'500m','memory':'384Mi'}},'volumeMounts':[{'name':'scratch','mountPath':'/tmp'}]}],'volumes':[{'name':'scratch','emptyDir':{}}]}}}}
|
||||
created=subprocess.run(['kubectl','create','-f','-'],input=json.dumps(job).encode(),capture_output=True)
|
||||
if created.returncode:raise RuntimeError('Fixture creation failed')
|
||||
forward=None
|
||||
forward_log=tempfile.TemporaryFile(mode="w+t")
|
||||
try:
|
||||
for _ in range(120):
|
||||
result=subprocess.run(['kubectl','-n','mfa','get','pods','-l','job-name='+name,'-o','json'],capture_output=True)
|
||||
pods=json.loads(result.stdout).get('items',[]) if result.returncode==0 else []
|
||||
if pods and pods[0]['status']['phase']=='Running':break
|
||||
time.sleep(.5)
|
||||
else:raise RuntimeError('Fixture did not start')
|
||||
# A Running pod is not an HTTP-ready provider. Connecting too early can
|
||||
# terminate kubectl port-forward on the first refused upstream socket.
|
||||
for _ in range(120):
|
||||
logs=subprocess.run(['kubectl','-n','mfa','logs','job/'+name],capture_output=True,text=True)
|
||||
events=[]
|
||||
for line in logs.stdout.splitlines():
|
||||
try: events.append(json.loads(line))
|
||||
except ValueError: pass
|
||||
if any(event.get('fixture_ready') is True for event in events if isinstance(event,dict)):break
|
||||
if any('fixture_failure' in event for event in events if isinstance(event,dict)):raise RuntimeError('Fixture initialization failed')
|
||||
time.sleep(.5)
|
||||
else:raise RuntimeError('Fixture initialization timed out')
|
||||
with socket.socket() as listener:listener.bind(('127.0.0.1',0));port=listener.getsockname()[1]
|
||||
forward=subprocess.Popen(['kubectl','-n','mfa','port-forward','job/'+name,str(port)+':8088','--address=127.0.0.1'],stdout=subprocess.DEVNULL,stderr=forward_log)
|
||||
base='http://127.0.0.1:'+str(port)
|
||||
for _ in range(120):
|
||||
if forward.poll() is not None:raise RuntimeError('Fixture forwarding stopped')
|
||||
try:
|
||||
with urllib.request.urlopen(base+'/',timeout=2) as response:
|
||||
if response.status==200:break
|
||||
except Exception:time.sleep(.25)
|
||||
else:raise RuntimeError('Fixture HTTP unavailable')
|
||||
environment=dict(os.environ,P06_NATIVE_PROVIDER_URL=base)
|
||||
subprocess.run(['go','test','./internal/server/oidc','-run','^TestNativeOptionalEnrollmentAndOldSession$','-count=1','-v'],cwd=ROOT/'src',env=environment,check=True,timeout=120)
|
||||
with tempfile.TemporaryDirectory(prefix='p06-provider-browser-') as profile:
|
||||
browser=subprocess.Popen([chrome,'--headless','--no-sandbox','--disable-gpu','--remote-debugging-address=127.0.0.1','--remote-debugging-port=0','--user-data-dir='+profile,'about:blank'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
marker=Path(profile)/'DevToolsActivePort'
|
||||
for _ in range(100):
|
||||
if marker.exists():break
|
||||
time.sleep(.1)
|
||||
debug='http://127.0.0.1:'+marker.read_text().splitlines()[0]
|
||||
subprocess.run(['node',str(ROOT/'scripts/provider_browser.mjs'),debug,base],check=True,timeout=240)
|
||||
finally:browser.terminate();browser.wait(timeout=5)
|
||||
print(json.dumps({'success':True,'job':name,'provider_image':IMAGE,'production_accounts_changed':False}))
|
||||
except Exception:
|
||||
if forward and forward.poll() is not None:
|
||||
forward_log.seek(0)
|
||||
message=forward_log.read(1000)
|
||||
print(json.dumps({"forward_exit":forward.returncode,"reason": "pod_not_running" if "not running" in message else "permission" if "permission" in message.lower() else "port_in_use" if "address already in use" in message else "forward_failed"}),flush=True)
|
||||
logs=subprocess.run(['kubectl','-n','mfa','logs','job/'+name],capture_output=True,text=True)
|
||||
for line in logs.stdout.splitlines():
|
||||
try:
|
||||
event=json.loads(line)
|
||||
if 'fixture_failure' in event:print(json.dumps({k:event[k] for k in ['fixture_failure','phase']}),flush=True)
|
||||
except Exception:pass
|
||||
raise
|
||||
finally:
|
||||
if forward:forward.terminate();forward.wait(timeout=5)
|
||||
forward_log.close()
|
||||
subprocess.run(['kubectl','-n','mfa','delete','job',name,'--wait=false'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
|
||||
if __name__=='__main__':main()
|
||||
|
|
@ -30,6 +30,7 @@ import (
|
|||
"keycape/internal/domain"
|
||||
servererrors "keycape/internal/server/errors"
|
||||
"keycape/internal/server/oidc"
|
||||
"keycape/internal/server/policy"
|
||||
"keycape/internal/server/telemetry"
|
||||
)
|
||||
|
||||
|
|
@ -172,6 +173,15 @@ func main() {
|
|||
Issuer: issuer,
|
||||
Emitter: emitter,
|
||||
}
|
||||
if path := os.Getenv("KEYCAPE_POLICY_PATH"); path != "" {
|
||||
policies, err := policy.Open(path, clients)
|
||||
if err != nil {
|
||||
log.Error().Msg("authentication policy store unavailable")
|
||||
os.Exit(1)
|
||||
}
|
||||
authorizeHandler.EffectivePolicy = policies.Effective
|
||||
mux.Handle("/platform/authentication-policy", policy.Handler(policies, issuer, &privateKey.PublicKey))
|
||||
}
|
||||
mux.Handle("/authorize", enforcement.Middleware(authorizeHandler))
|
||||
mux.Handle("/authorize/callback", authorizeHandler)
|
||||
mux.Handle("/authorize/return", authorizeHandler)
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ func (p *pendingStateStore) Delete(state string) {
|
|||
|
||||
// AuthorizeHandler implements GET /authorize and GET /authorize/callback.
|
||||
type AuthorizeHandler struct {
|
||||
EffectivePolicy func(*domain.Client) (*domain.Client, error)
|
||||
AccountPortalURL string
|
||||
ClientConfig map[string]*domain.Client
|
||||
Auth domain.AuthProvider
|
||||
|
|
@ -372,6 +373,13 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
|
|||
|
||||
func (h *AuthorizeHandler) decideAssurance(ctx context.Context, ps *PendingState, username string, login *LoginSession) (domain.AssuranceDecision, error) {
|
||||
client := h.ClientConfig[ps.ClientID]
|
||||
if h.EffectivePolicy != nil {
|
||||
var err error
|
||||
client, err = h.EffectivePolicy(client)
|
||||
if err != nil {
|
||||
return domain.AssuranceDecision{}, err
|
||||
}
|
||||
}
|
||||
providerRequired := false
|
||||
if !domain.ACRRequiresAAL2(ps.ACRValues) && (client == nil || client.MFARequired == nil) {
|
||||
var err error
|
||||
|
|
|
|||
124
src/internal/server/oidc/native_optional_test.go
Normal file
124
src/internal/server/oidc/native_optional_test.go
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
package oidc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/base32"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"keycape/internal/adapters/privacyidea"
|
||||
"keycape/internal/domain"
|
||||
"keycape/internal/server/oidc"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Uses only the companion disposable provider fixture, never a production URL.
|
||||
func TestNativeOptionalEnrollmentAndOldSession(t *testing.T) {
|
||||
base := os.Getenv("P06_NATIVE_PROVIDER_URL")
|
||||
if base == "" {
|
||||
t.Skip("requires disposable installed-provider fixture")
|
||||
}
|
||||
parsed, e := url.Parse(base)
|
||||
if e != nil || parsed.Hostname() != "127.0.0.1" || parsed.Scheme != "http" {
|
||||
t.Fatal("loopback fixture required")
|
||||
}
|
||||
call := func(method, path, token string, data url.Values) map[string]interface{} {
|
||||
r, e := http.NewRequest(method, base+path, strings.NewReader(data.Encode()))
|
||||
if e != nil {
|
||||
t.Fatal("fixture request")
|
||||
}
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if token != "" {
|
||||
r.Header.Set("Authorization", token)
|
||||
}
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
response, e := client.Do(r)
|
||||
if e != nil {
|
||||
t.Fatal("fixture unavailable")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var body map[string]interface{}
|
||||
if json.NewDecoder(response.Body).Decode(&body) != nil || response.StatusCode != 200 {
|
||||
t.Fatal("fixture request failed", response.StatusCode)
|
||||
}
|
||||
return body
|
||||
}
|
||||
value := func(b map[string]interface{}) map[string]interface{} {
|
||||
return b["result"].(map[string]interface{})["value"].(map[string]interface{})
|
||||
}
|
||||
reader := value(call("POST", "/auth", "", url.Values{"username": {"fixture-reader"}, "password": {"fixture-service-password"}}))["token"].(string)
|
||||
user := value(call("POST", "/auth", "", url.Values{"username": {"native-alice"}, "password": {"fixture-password"}, "realm": {"fixture"}}))["token"].(string)
|
||||
adapter := privacyidea.New(privacyidea.Config{BaseURL: base, Realm: "fixture", AdminToken: reader, ReadProbeSerial: "P06SCOPEPROBE", RequireForAll: true}, nil)
|
||||
h := &oidc.AuthorizeHandler{ClientConfig: map[string]*domain.Client{"fixture": {ClientID: "fixture", MFAOptional: true}}, Auth: &mockAuthProvider{callbackResult: &domain.AuthResult{Username: "native-alice"}}, MFA: adapter, Sessions: oidc.NewSessionStore(), Logins: oidc.NewLoginSessionStore(), Emitter: &captureEmitter{}}
|
||||
callback := func(acr []string, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||
h.PendingStates().Store("native", &oidc.PendingState{ClientID: "fixture", RedirectURI: "https://fixture.test/callback", State: "native", ACRValues: acr, ExpiresAt: time.Now().Add(time.Minute)})
|
||||
r := httptest.NewRequest("GET", "/authorize/callback?code=fixture&state=native", nil)
|
||||
if cookie != nil {
|
||||
r.AddCookie(cookie)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTPCallback(w, r)
|
||||
return w
|
||||
}
|
||||
first := callback(nil, nil)
|
||||
if first.Code != 302 {
|
||||
t.Fatal("native no-factor decision", first.Code)
|
||||
}
|
||||
cookie := first.Result().Cookies()[0]
|
||||
detail := call("POST", "/token/init", user, url.Values{"type": {"totp"}, "genkey": {"1"}})["detail"].(map[string]interface{})
|
||||
if detail["rollout_state"] != "verify" {
|
||||
t.Fatal("possession confirmation not required")
|
||||
}
|
||||
if w := callback(nil, cookie); w.Code != 302 {
|
||||
t.Fatal("pending setup activated MFA")
|
||||
}
|
||||
uri, e := url.Parse(detail["googleurl"].(map[string]interface{})["value"].(string))
|
||||
if e != nil {
|
||||
t.Fatal("invalid fixture enrollment URI")
|
||||
}
|
||||
key, e := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.TrimRight(uri.Query().Get("secret"), "="))
|
||||
if e != nil {
|
||||
t.Fatal("fixture seed format")
|
||||
}
|
||||
otp := func() string {
|
||||
counter := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(counter, uint64(time.Now().Unix()/30))
|
||||
mac := hmac.New(sha1.New, key)
|
||||
mac.Write(counter)
|
||||
digest := mac.Sum(nil)
|
||||
offset := digest[len(digest)-1] & 15
|
||||
return fmt.Sprintf("%06d", (binary.BigEndian.Uint32(digest[offset:offset+4])&0x7fffffff)%1000000)
|
||||
}
|
||||
call("POST", "/token/init", user, url.Values{"serial": {detail["serial"].(string)}, "type": {"totp"}, "verify": {otp()}})
|
||||
if enrolled, e := adapter.HasEnrolledFactor(context.Background(), "native-alice"); e != nil || !enrolled {
|
||||
t.Fatal("native activation not observed")
|
||||
}
|
||||
if w := callback(nil, cookie); w.Code != 200 || !strings.Contains(w.Body.String(), "KeyCape MFA") {
|
||||
t.Fatal("old AAL1 session bypassed native enrolled factor")
|
||||
}
|
||||
if w := callback([]string{"aal2"}, cookie); w.Code != 200 || !strings.Contains(w.Body.String(), "KeyCape MFA") {
|
||||
t.Fatal("native explicit step-up bypassed")
|
||||
}
|
||||
// Confirmation consumed the current TOTP; wait for the next independent code.
|
||||
time.Sleep(time.Duration(31-time.Now().Unix()%30) * time.Second)
|
||||
request := httptest.NewRequest("POST", "/authorize/callback", strings.NewReader(url.Values{"state": {"native"}, "mfa_token": {otp()}}.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
response := httptest.NewRecorder()
|
||||
h.ServeHTTPCallback(response, request)
|
||||
if response.Code != 302 {
|
||||
t.Fatal("native OTP sign-in failed", response.Code)
|
||||
}
|
||||
location, _ := url.Parse(response.Header().Get("Location"))
|
||||
session, ok := h.Sessions.Get(location.Query().Get("code"))
|
||||
if !ok || !session.MFAVerified {
|
||||
t.Fatal("native OTP did not establish MFA")
|
||||
}
|
||||
}
|
||||
69
src/internal/server/oidc/policy_runtime_test.go
Normal file
69
src/internal/server/oidc/policy_runtime_test.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
package oidc_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"keycape/internal/domain"
|
||||
"keycape/internal/server/oidc"
|
||||
"keycape/internal/server/policy"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRuntimePolicyEnrollmentOldSessionAndStepUp(t *testing.T) {
|
||||
clients := map[string]*domain.Client{}
|
||||
for _, id := range []string{"vergabe-demo-company", "user-engine-portal"} {
|
||||
clients[id] = &domain.Client{ClientID: id, GrantTypes: []string{"authorization_code"}}
|
||||
}
|
||||
policies, e := policy.Open(filepath.Join(t.TempDir(), "policy.json"), clients)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
defer policies.Close()
|
||||
mfa := &mockMFAProvider{required: true}
|
||||
h := &oidc.AuthorizeHandler{ClientConfig: clients, EffectivePolicy: policies.Effective, Auth: &mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice"}}, MFA: mfa, Sessions: oidc.NewSessionStore(), Logins: oidc.NewLoginSessionStore(), Emitter: &captureEmitter{}}
|
||||
callback := func(acr []string, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||
h.PendingStates().Store("runtime", &oidc.PendingState{ClientID: "vergabe-demo-company", RedirectURI: "https://app.test/callback", State: "runtime", ACRValues: acr, ExpiresAt: time.Now().Add(time.Minute)})
|
||||
r := httptest.NewRequest("GET", "/authorize/callback?code=fixture&state=runtime", nil)
|
||||
if cookie != nil {
|
||||
r.AddCookie(cookie)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTPCallback(w, r)
|
||||
return w
|
||||
}
|
||||
if w := callback(nil, nil); w.Code != 200 || !strings.Contains(w.Body.String(), "KeyCape MFA") {
|
||||
t.Fatal("mandatory policy not effective")
|
||||
}
|
||||
preview, e := policies.Operation("operator", policy.Request{Action: "preview", Client: "vergabe-demo-company", Mode: policy.Optional, Reference: "change"})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, e = policies.Operation("operator", policy.Request{Action: "apply", Confirmation: preview["confirmation"].(string), Acknowledged: true}); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
first := callback(nil, nil)
|
||||
if first.Code != 302 {
|
||||
t.Fatal("no-factor login denied", first.Code)
|
||||
}
|
||||
cookie := first.Result().Cookies()[0]
|
||||
mfa.enrolled = true
|
||||
if w := callback(nil, cookie); w.Code != 200 || !strings.Contains(w.Body.String(), "KeyCape MFA") {
|
||||
t.Fatal("old AAL1 session bypassed enrolled factor")
|
||||
}
|
||||
mfa.enrolled = false
|
||||
if w := callback([]string{"aal2"}, cookie); w.Code != 200 || !strings.Contains(w.Body.String(), "KeyCape MFA") {
|
||||
t.Fatal("optional policy bypassed explicit MFA")
|
||||
}
|
||||
mfa.enrolledErr = errors.New("fixture outage")
|
||||
if w := callback(nil, cookie); w.Code != 500 || strings.Contains(w.Header().Get("Location"), "code=") {
|
||||
t.Fatal("lookup outage granted authorization")
|
||||
}
|
||||
mfa.enrolledErr = nil
|
||||
if w := callback(nil, cookie); w.Code != 302 {
|
||||
t.Fatal("lookup recovery failed")
|
||||
}
|
||||
}
|
||||
423
src/internal/server/policy/policy.go
Normal file
423
src/internal/server/policy/policy.go
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
// Package policy owns the narrowly scoped browser MFA policy and durable audit.
|
||||
package policy
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"keycape/internal/domain"
|
||||
"keycape/internal/jose"
|
||||
)
|
||||
|
||||
const Optional = "optional_after_enrollment"
|
||||
const Mandatory = "mandatory"
|
||||
|
||||
var ErrUnavailable = errors.New("policy_unavailable")
|
||||
|
||||
type Receipt struct {
|
||||
Reference string `json:"reference"`
|
||||
Actor string `json:"actor"`
|
||||
Client string `json:"client"`
|
||||
Before string `json:"before"`
|
||||
After string `json:"after"`
|
||||
Revision uint64 `json:"revision"`
|
||||
At int64 `json:"at"`
|
||||
}
|
||||
type state struct {
|
||||
Revision uint64 `json:"revision"`
|
||||
Modes map[string]string `json:"modes"`
|
||||
History []Receipt `json:"history"`
|
||||
}
|
||||
type ticket struct {
|
||||
Actor, Client, Mode, Reference string
|
||||
Revision uint64
|
||||
Expires int64
|
||||
}
|
||||
type Store struct {
|
||||
lock *os.File
|
||||
mu sync.Mutex
|
||||
path string
|
||||
current state
|
||||
names map[string]string
|
||||
pending map[string]ticket
|
||||
failed bool
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func Open(path string, clients map[string]*domain.Client) (*Store, error) {
|
||||
s := &Store{path: path, names: map[string]string{}, pending: map[string]ticket{}, now: time.Now}
|
||||
s.current.Modes = map[string]string{}
|
||||
for _, id := range []string{"vergabe-demo-company", "user-engine-portal"} {
|
||||
c := clients[id]
|
||||
if c == nil || !contains(c.GrantTypes, "authorization_code") || contains(c.GrantTypes, "client_credentials") {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
mode := Mandatory
|
||||
if c.MFAOptional && c.MFARequired == nil {
|
||||
mode = Optional
|
||||
} else if c.MFARequired != nil && !*c.MFARequired {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
s.current.Modes[id] = mode
|
||||
s.names[id] = c.DisplayName
|
||||
}
|
||||
lock, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR, 0600)
|
||||
if err != nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) != nil {
|
||||
lock.Close()
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
s.lock = lock
|
||||
initialized := false
|
||||
defer func() {
|
||||
if !initialized {
|
||||
s.Close()
|
||||
}
|
||||
}()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if err == nil {
|
||||
if len(raw) > 16*1024*1024 || json.Unmarshal(raw, &s.current) != nil || len(s.current.Modes) != 2 {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
for id, mode := range s.current.Modes {
|
||||
if _, ok := s.names[id]; !ok || !validMode(mode) {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
}
|
||||
if len(s.current.History) > 0 && s.current.History[len(s.current.History)-1].Revision != s.current.Revision {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
} else if s.persist(s.current) != nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
initialized = true
|
||||
return s, nil
|
||||
}
|
||||
func (s *Store) Close() {
|
||||
if s.lock != nil {
|
||||
syscall.Flock(int(s.lock.Fd()), syscall.LOCK_UN)
|
||||
s.lock.Close()
|
||||
s.lock = nil
|
||||
}
|
||||
}
|
||||
func contains(values []string, value string) bool {
|
||||
for _, v := range values {
|
||||
if v == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func validMode(mode string) bool { return mode == Optional || mode == Mandatory }
|
||||
func (s *Store) persist(next state) error {
|
||||
raw, err := json.Marshal(next)
|
||||
if err != nil || len(raw) > 16*1024*1024 {
|
||||
return ErrUnavailable
|
||||
}
|
||||
file, err := os.CreateTemp(filepath.Dir(s.path), ".policy-")
|
||||
if err != nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
name := file.Name()
|
||||
defer os.Remove(name)
|
||||
if _, err = file.Write(raw); err == nil {
|
||||
err = file.Sync()
|
||||
}
|
||||
closeErr := file.Close()
|
||||
if err != nil || closeErr != nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
if os.Rename(name, s.path) != nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
dir, err := os.Open(filepath.Dir(s.path))
|
||||
if err != nil {
|
||||
s.failed = true
|
||||
return ErrUnavailable
|
||||
}
|
||||
defer dir.Close()
|
||||
if dir.Sync() != nil {
|
||||
s.failed = true
|
||||
return ErrUnavailable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Effective returns a copy; unrelated registrations and explicit AAL2 stay intact.
|
||||
func (s *Store) Effective(c *domain.Client) (*domain.Client, error) {
|
||||
if c == nil {
|
||||
return c, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
mode, ok := s.current.Modes[c.ClientID]
|
||||
if !ok {
|
||||
return c, nil
|
||||
}
|
||||
if s.failed {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
copy := *c
|
||||
copy.MFAOptional = mode == Optional
|
||||
copy.MFARequired = nil
|
||||
if mode == Mandatory {
|
||||
required := true
|
||||
copy.MFARequired = &required
|
||||
}
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
Action string `json:"action"`
|
||||
Client string `json:"client"`
|
||||
Mode string `json:"mode"`
|
||||
Reference string `json:"reference"`
|
||||
Confirmation string `json:"confirmation"`
|
||||
Acknowledged bool `json:"acknowledged"`
|
||||
}
|
||||
|
||||
func (s *Store) snapshot() map[string]interface{} {
|
||||
clients := []map[string]interface{}{}
|
||||
for _, id := range []string{"vergabe-demo-company", "user-engine-portal"} {
|
||||
clients = append(clients, map[string]interface{}{"id": id, "name": s.names[id], "mode": s.current.Modes[id]})
|
||||
}
|
||||
history := s.current.History
|
||||
if len(history) > 20 {
|
||||
history = history[len(history)-20:]
|
||||
}
|
||||
return map[string]interface{}{"success": true, "status": "current", "revision": s.current.Revision, "clients": clients, "history": history}
|
||||
}
|
||||
func (s *Store) Operation(actor string, request Request) (map[string]interface{}, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.failed {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if request.Action == "status" {
|
||||
return s.snapshot(), nil
|
||||
}
|
||||
if request.Action == "preview" || request.Action == "rollback" {
|
||||
if _, ok := s.names[request.Client]; !ok || !validReference(request.Reference) {
|
||||
return nil, errors.New("unsupported_policy")
|
||||
}
|
||||
mode := request.Mode
|
||||
if request.Action == "rollback" {
|
||||
mode = ""
|
||||
for i := len(s.current.History) - 1; i >= 0; i-- {
|
||||
r := s.current.History[i]
|
||||
if r.Client == request.Client {
|
||||
mode = r.Before
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !validMode(mode) {
|
||||
return nil, errors.New("unsupported_policy")
|
||||
}
|
||||
if mode == s.current.Modes[request.Client] {
|
||||
return nil, errors.New("policy_unchanged")
|
||||
}
|
||||
for _, r := range s.current.History {
|
||||
if r.Reference == request.Reference {
|
||||
return nil, errors.New("reference_already_used")
|
||||
}
|
||||
}
|
||||
for key, t := range s.pending {
|
||||
if t.Expires < s.now().Unix() {
|
||||
delete(s.pending, key)
|
||||
}
|
||||
}
|
||||
if len(s.pending) >= 1024 {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
key := hex.EncodeToString(bytes)
|
||||
s.pending[key] = ticket{actor, request.Client, mode, request.Reference, s.current.Revision, s.now().Unix() + 900}
|
||||
result := s.snapshot()
|
||||
result["status"] = "preview"
|
||||
result["confirmation"] = key
|
||||
result["client"] = request.Client
|
||||
result["before"] = s.current.Modes[request.Client]
|
||||
result["after"] = mode
|
||||
result["reference"] = request.Reference
|
||||
return result, nil
|
||||
}
|
||||
if request.Action == "apply" {
|
||||
t, ok := s.pending[request.Confirmation]
|
||||
if !ok || t.Actor != actor || t.Expires < s.now().Unix() || !request.Acknowledged {
|
||||
return nil, errors.New("preview_expired_or_changed")
|
||||
}
|
||||
if (request.Client != "" && request.Client != t.Client) || (request.Mode != "" && request.Mode != t.Mode) || (request.Reference != "" && request.Reference != t.Reference) {
|
||||
return nil, errors.New("preview_expired_or_changed")
|
||||
}
|
||||
for _, r := range s.current.History {
|
||||
if r.Reference == t.Reference {
|
||||
if r.Actor != actor || r.Client != t.Client || r.After != t.Mode {
|
||||
return nil, errors.New("preview_expired_or_changed")
|
||||
}
|
||||
result := s.snapshot()
|
||||
result["status"] = "recorded"
|
||||
result["receipt"] = r
|
||||
result["replayed"] = true
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
if s.current.Revision != t.Revision {
|
||||
return nil, errors.New("policy_changed_review_again")
|
||||
}
|
||||
modes := map[string]string{}
|
||||
for k, v := range s.current.Modes {
|
||||
modes[k] = v
|
||||
}
|
||||
modes[t.Client] = t.Mode
|
||||
receipt := Receipt{t.Reference, actor, t.Client, s.current.Modes[t.Client], t.Mode, s.current.Revision + 1, s.now().Unix()}
|
||||
history := append(append([]Receipt{}, s.current.History...), receipt)
|
||||
next := state{s.current.Revision + 1, modes, history}
|
||||
if s.persist(next) != nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
s.current = next
|
||||
result := s.snapshot()
|
||||
result["status"] = "recorded"
|
||||
result["receipt"] = receipt
|
||||
return result, nil
|
||||
}
|
||||
return nil, errors.New("unsupported_policy")
|
||||
}
|
||||
func validReference(value string) bool {
|
||||
if len(value) < 1 || len(value) > 150 {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || strings.ContainsRune("_.:/-", r)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func Actor(claims map[string]interface{}, issuer string, now time.Time) (string, error) {
|
||||
deny := errors.New("fresh_platform_mfa_required")
|
||||
if claims["iss"] != issuer || claims["principal_type"] != "human" {
|
||||
return "", deny
|
||||
}
|
||||
audience := false
|
||||
switch value := claims["aud"].(type) {
|
||||
case string:
|
||||
audience = value == "user-engine-portal"
|
||||
case []interface{}:
|
||||
for _, v := range value {
|
||||
if v == "user-engine-portal" {
|
||||
audience = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !audience {
|
||||
return "", deny
|
||||
}
|
||||
if azp, ok := claims["azp"]; ok && azp != "user-engine-portal" {
|
||||
return "", deny
|
||||
}
|
||||
exp, ok := claims["exp"].(float64)
|
||||
if !ok || exp <= float64(now.Unix()) {
|
||||
return "", deny
|
||||
}
|
||||
if nbf, ok := claims["nbf"].(float64); ok && nbf > float64(now.Unix()) {
|
||||
return "", deny
|
||||
}
|
||||
roles, ok := claims["roles"].([]interface{})
|
||||
if !ok {
|
||||
return "", deny
|
||||
}
|
||||
operator := false
|
||||
for _, v := range roles {
|
||||
if v == "platform-operator" {
|
||||
operator = true
|
||||
}
|
||||
}
|
||||
if !operator {
|
||||
return "", deny
|
||||
}
|
||||
a, ok := claims["assurance"].(map[string]interface{})
|
||||
if !ok || a["level"] != "aal2" || a["mfa"] != true {
|
||||
return "", deny
|
||||
}
|
||||
at, ok := a["at"].(float64)
|
||||
if !ok || float64(now.Unix())-at < 0 || float64(now.Unix())-at > 300 {
|
||||
return "", deny
|
||||
}
|
||||
subject, ok := claims["sub"].(string)
|
||||
if !ok || subject == "" || len(subject) > 150 {
|
||||
return "", deny
|
||||
}
|
||||
return subject, nil
|
||||
}
|
||||
func Handler(store *Store, issuer string, key *rsa.PublicKey) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
fail := func(code int, reason string) {
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "failure": reason})
|
||||
}
|
||||
if r.Method != "POST" {
|
||||
fail(405, "method_not_allowed")
|
||||
return
|
||||
}
|
||||
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if len(token) > 16384 {
|
||||
fail(403, "fresh_platform_mfa_required")
|
||||
return
|
||||
}
|
||||
claims, err := jose.Verify(token, jose.KeySet{"key-1": key})
|
||||
if err != nil {
|
||||
fail(403, "fresh_platform_mfa_required")
|
||||
return
|
||||
}
|
||||
actor, err := Actor(claims, issuer, time.Now())
|
||||
if err != nil {
|
||||
fail(403, "fresh_platform_mfa_required")
|
||||
return
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192))
|
||||
decoder.DisallowUnknownFields()
|
||||
var request Request
|
||||
if decoder.Decode(&request) != nil {
|
||||
fail(400, "unsupported_policy")
|
||||
return
|
||||
}
|
||||
var extra interface{}
|
||||
if decoder.Decode(&extra) != io.EOF {
|
||||
fail(400, "unsupported_policy")
|
||||
return
|
||||
}
|
||||
result, err := store.Operation(actor, request)
|
||||
if err != nil {
|
||||
code := 409
|
||||
if errors.Is(err, ErrUnavailable) {
|
||||
code = 503
|
||||
}
|
||||
fail(code, err.Error())
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(result)
|
||||
})
|
||||
}
|
||||
196
src/internal/server/policy/policy_test.go
Normal file
196
src/internal/server/policy/policy_test.go
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
package policy
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"keycape/internal/domain"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func clients() map[string]*domain.Client {
|
||||
result := map[string]*domain.Client{}
|
||||
for _, id := range []string{"vergabe-demo-company", "user-engine-portal", "untouched"} {
|
||||
result[id] = &domain.Client{ClientID: id, DisplayName: id, GrantTypes: []string{"authorization_code"}}
|
||||
}
|
||||
return result
|
||||
}
|
||||
func open(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, e := Open(filepath.Join(t.TempDir(), "policy.json"), clients())
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
return s
|
||||
}
|
||||
func preview(t *testing.T, s *Store, ref string) string {
|
||||
t.Helper()
|
||||
r, e := s.Operation("operator", Request{Action: "preview", Client: "vergabe-demo-company", Mode: Optional, Reference: ref})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
return r["confirmation"].(string)
|
||||
}
|
||||
func apply(s *Store, ticket string) (map[string]interface{}, error) {
|
||||
return s.Operation("operator", Request{Action: "apply", Confirmation: ticket, Acknowledged: true})
|
||||
}
|
||||
func TestReviewedApplyReplayRestartRollback(t *testing.T) {
|
||||
s := open(t)
|
||||
key := preview(t, s, "case-1")
|
||||
before, _ := s.Effective(clients()["vergabe-demo-company"])
|
||||
if before.MFAOptional {
|
||||
t.Fatal("preview mutated policy")
|
||||
}
|
||||
if _, e := s.Operation("other", Request{Action: "apply", Confirmation: key, Acknowledged: true}); e == nil {
|
||||
t.Fatal("wrong actor")
|
||||
}
|
||||
if _, e := s.Operation("operator", Request{Action: "apply", Confirmation: key}); e == nil {
|
||||
t.Fatal("missing acknowledgement")
|
||||
}
|
||||
if _, e := apply(s, key); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if r, e := apply(s, key); e != nil || r["replayed"] != true {
|
||||
t.Fatal("lost response replay", e)
|
||||
}
|
||||
after, _ := s.Effective(clients()["vergabe-demo-company"])
|
||||
if !after.MFAOptional || after.MFARequired != nil {
|
||||
t.Fatal("effective policy")
|
||||
}
|
||||
other := clients()["untouched"]
|
||||
unchanged, _ := s.Effective(other)
|
||||
if unchanged != other {
|
||||
t.Fatal("unrelated registration changed")
|
||||
}
|
||||
s.Close()
|
||||
restored, e := Open(s.path, clients())
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
defer restored.Close()
|
||||
if len(restored.current.History) != 1 || restored.current.Modes["vergabe-demo-company"] != Optional {
|
||||
t.Fatal("durability")
|
||||
}
|
||||
r, e := restored.Operation("operator", Request{Action: "rollback", Client: "vergabe-demo-company", Reference: "case-2"})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, e = apply(restored, r["confirmation"].(string)); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if restored.current.Modes["vergabe-demo-company"] != Mandatory || len(restored.current.History) != 2 {
|
||||
t.Fatal("rollback not audited")
|
||||
}
|
||||
}
|
||||
func TestStaleExpiredUnsupportedAndAlteredRequests(t *testing.T) {
|
||||
s := open(t)
|
||||
first := preview(t, s, "first")
|
||||
second := preview(t, s, "second")
|
||||
if _, e := s.Operation("operator", Request{Action: "apply", Confirmation: first, Acknowledged: true, Mode: "disabled"}); e == nil {
|
||||
t.Fatal("altered intent")
|
||||
}
|
||||
if _, e := apply(s, first); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, e := apply(s, second); e == nil {
|
||||
t.Fatal("stale review")
|
||||
}
|
||||
for _, r := range []Request{{Action: "preview", Client: "untouched", Mode: Optional, Reference: "x"}, {Action: "preview", Client: "user-engine-portal", Mode: "disabled", Reference: "x"}, {Action: "preview", Client: "user-engine-portal", Mode: Optional, Reference: "first"}} {
|
||||
if _, e := s.Operation("operator", r); e == nil {
|
||||
t.Fatal("unsupported request")
|
||||
}
|
||||
}
|
||||
s2 := open(t)
|
||||
ticket := preview(t, s2, "expire")
|
||||
s2.now = func() time.Time { return time.Now().Add(time.Hour) }
|
||||
if _, e := apply(s2, ticket); e == nil {
|
||||
t.Fatal("expired review")
|
||||
}
|
||||
}
|
||||
func TestWriteFailureDoesNotChangeEffectivePolicyAndSingleWriter(t *testing.T) {
|
||||
s := open(t)
|
||||
if second, e := Open(s.path, clients()); e == nil {
|
||||
second.Close()
|
||||
t.Fatal("second writer allowed")
|
||||
}
|
||||
ticket := preview(t, s, "fail")
|
||||
s.path = filepath.Join(t.TempDir(), "missing", "policy.json")
|
||||
if _, e := apply(s, ticket); e == nil {
|
||||
t.Fatal("write failure accepted")
|
||||
}
|
||||
if s.current.Revision != 0 || s.current.Modes["vergabe-demo-company"] != Mandatory {
|
||||
t.Fatal("failed write changed policy")
|
||||
}
|
||||
}
|
||||
func TestCorruptPersistenceFailsClosed(t *testing.T) {
|
||||
s := open(t)
|
||||
s.Close()
|
||||
os.WriteFile(s.path, []byte(`{"modes":{"other":"optional_after_enrollment"}}`), 0600)
|
||||
if _, e := Open(s.path, clients()); e == nil {
|
||||
t.Fatal("corrupt store accepted")
|
||||
}
|
||||
}
|
||||
func signed(t *testing.T, key *rsa.PrivateKey, claims map[string]interface{}) string {
|
||||
raw, _ := json.Marshal(claims)
|
||||
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","kid":"key-1"}`))
|
||||
input := header + "." + base64.RawURLEncoding.EncodeToString(raw)
|
||||
digest := sha256.Sum256([]byte(input))
|
||||
signature, e := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
return input + "." + base64.RawURLEncoding.EncodeToString(signature)
|
||||
}
|
||||
func TestHTTPRequiresSignedFreshPlatformMFA(t *testing.T) {
|
||||
s := open(t)
|
||||
key, e := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
handler := Handler(s, "https://issuer.test", &key.PublicKey)
|
||||
base := func() map[string]interface{} {
|
||||
return map[string]interface{}{"iss": "https://issuer.test", "aud": "user-engine-portal", "sub": "operator", "principal_type": "human", "roles": []string{"platform-operator"}, "exp": time.Now().Add(time.Minute).Unix(), "assurance": map[string]interface{}{"level": "aal2", "mfa": true, "at": time.Now().Unix()}}
|
||||
}
|
||||
for index, change := range []func(map[string]interface{}){func(c map[string]interface{}) {}, func(c map[string]interface{}) { c["aud"] = "other" }, func(c map[string]interface{}) { c["roles"] = []string{"tenant-admin"} }, func(c map[string]interface{}) {
|
||||
c["assurance"] = map[string]interface{}{"level": "aal1", "mfa": false, "at": time.Now().Unix()}
|
||||
}, func(c map[string]interface{}) {
|
||||
c["assurance"] = map[string]interface{}{"level": "aal2", "mfa": true, "at": time.Now().Add(-time.Hour).Unix()}
|
||||
}, func(c map[string]interface{}) { c["exp"] = 0 }} {
|
||||
claims := base()
|
||||
change(claims)
|
||||
token := signed(t, key, claims)
|
||||
r := httptest.NewRequest("POST", "/platform/authentication-policy", strings.NewReader(`{"action":"status"}`))
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, r)
|
||||
expected := 403
|
||||
if index == 0 {
|
||||
expected = 200
|
||||
}
|
||||
if w.Code != expected {
|
||||
t.Fatalf("unexpected status %d", w.Code)
|
||||
}
|
||||
}
|
||||
r := httptest.NewRequest("POST", "/platform/authentication-policy", strings.NewReader(`{"action":"status"}`))
|
||||
r.Header.Set("Authorization", "Bearer unsigned")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, r)
|
||||
if w.Code != 403 {
|
||||
t.Fatal("unsigned accepted")
|
||||
}
|
||||
}
|
||||
func mustClaims(token string) map[string]interface{} {
|
||||
raw, _ := base64.RawURLEncoding.DecodeString(strings.Split(token, ".")[1])
|
||||
var c map[string]interface{}
|
||||
json.Unmarshal(raw, &c)
|
||||
return c
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue