Skip to content

Commit

Permalink
Finish TODOs for remaining Attempt.Status values on ChainBundle for auth
Browse files Browse the repository at this point in the history
  • Loading branch information
cpurdy committed Nov 25, 2024
1 parent 71ca27a commit cb474f2
Show file tree
Hide file tree
Showing 2 changed files with 149 additions and 115 deletions.
8 changes: 4 additions & 4 deletions lib_web/src/main/x/web/security/Authenticator.x
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,14 @@ interface Authenticator
* An enumeration of potential authorization statuses, in the order of least applicable to most
* applicable:
*
* * [NoSession] indicates that the `Authenticator` requires a `Session` to be established
* before the authentication process can proceed
* * [NoData] indicates that the request contains no information relevant to the `Authenticator`
* (the Claim should be `Null`)
* * [KnownNoSession] same as [KnownNoData] but additionally indicates [NoSession]
* * [NoSession] indicates that the `Authenticator` requires a `Session` to be established
* before the authentication process can proceed
* * [KnownNoData] indicates that the request contains no information relevant to the
* `Authenticator` (the Claim should be `Null`), but that the `Authenticator` does have
* reason to expect that the specific client will use this authentication scheme
* * [KnownNoSession] same as [KnownNoData] but additionally indicates [NoSession]
* * [InProgress] indicates that the `Authenticator` has started the process of authentication,
* but additional information from the client is still required to authenticate
* * [Alert] indicates that authentication was attempted and failed in a manner that indicates
Expand All @@ -83,7 +83,7 @@ interface Authenticator
* from the client's point of view
* * [Success] indicates that the client request contained valid authentication information
*/
enum Status {NoSession, NoData, KnownNoSession, KnownNoData, InProgress, Alert, Failed, NotActive, Success}
enum Status {NoData, NoSession, KnownNoData, KnownNoSession, InProgress, Alert, Failed, NotActive, Success}

/**
* Represents a response from an `Authenticator` to the client, iff the `Authenticator` needs to
Expand Down
256 changes: 145 additions & 111 deletions lib_xenia/src/main/x/xenia/ChainBundle.x
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import Catalog.WebServiceInfo;
import convert.Codec;
import convert.Format;

import ecstasy.collections.CollectArray;

import net.UriTemplate;

import sec.Entitlement;
Expand Down Expand Up @@ -35,6 +37,8 @@ import web.security.Authenticator.AuthResponse;
import web.security.Authenticator.Claim;
import web.security.Authenticator.Status as AuthStatus;

import web.sessions.Broker as SessionBroker;

/**
* The chain bundle represents a set of lazily created call chain collections.
*/
Expand Down Expand Up @@ -79,6 +83,12 @@ service ChainBundle {
*/
private Authenticator authenticator;

/**
* A lazily created duplicate of the app's session broker, created only if the authentication
* processing discovers that it requires a session.
*/
protected @Lazy SessionBroker sessionBroker.calc() = catalog.webApp.sessionBroker.duplicate();

/**
* The index of this bundle in the BundlePool.
*/
Expand Down Expand Up @@ -283,6 +293,8 @@ service ChainBundle {
}
}

static CollectArray<Attempt> ToAttemptArray = new CollectArray<Attempt>();

// return a function that returns False if the request is trusted to proceed, or returns
// True and a ResponseOut if the request cannot proceed and the ResponseOut needs to be
// sent back to the user agent instead
Expand All @@ -293,138 +305,160 @@ service ChainBundle {
// just means we have to fall through to the slow path
Session? session = request.session;
Permission? permission = resolvePermission?(request) : Null;
if (session != Null && requiredTrust <= session.trustLevel
&& checkSessionApproval(session, permission, accessGranted)) {
return False;
}
FromTheTop: while (True) {
if (session != Null && requiredTrust <= session.trustLevel
&& checkSessionApproval(session, permission, accessGranted)) {
return False;
}

// at this point, the endpoint's trust level requirement is higher than what the session
// has, or the authentication information cached on the session was not sufficient to
// approve the request, or there simply was no session, so we revert to the "slow path"
// and explicitly authenticate the request
Attempt[] attempts = authenticator.authenticate(request);
if (attempts.empty) {
// this is unexpected, since we should at least see a "NoData" Attempt, but we'll
// assume that the absence of any Attempt is the same as a "NoData" Attempt
return True, new SimpleResponse(Unauthorized);
}
// at this point, the endpoint's trust level requirement is higher than what the
// session has, or the authentication information cached on the session was not
// sufficient to approve the request, or there simply was no session, so we revert
// to the "slow path" and explicitly authenticate the request
Attempt[] attempts = authenticator.authenticate(request);
if (attempts.empty) {
// this is unexpected; there should have been at least see a "NoData" Attempt;
// assume that the absence of any Attempt is the same as a "NoData" Attempt
return True, new SimpleResponse(Unauthorized);
}

// scan the attempts:
// 1) each Failed/Alert needs to be logged, and the presence of any of these needs to
// result in a failure (even if other Attempts succeed)
// 2) if there's no better answer than NoSession/KnownNoSession, then ask the Session
// Broker to create a session
// 3) NotActive Attempts are not considered attacks as long as there is a Success with
// the same Principal (or for non-conferring Entitlements, there is any Success);
// these are tolerated as non-errors because some clients will continue to send
// expired credentials forever
// 4) At least one Success attempt is what we're aiming for; if there is more than one,
// each Success Claims must: (i) be a Principal Claim that does not disagree with any
// other claim, (ii) be an identity-conferring Entitlement Claim that does not
// disagree with any other claim, or (iii) be an non-conferring Entitlement Claim
Principal[] principals = [];
Entitlement[] entitlements = [];
Attempt[] alerts = [];
Attempt[] failures = [];
Attempt[] inactives = [];
Boolean needsSession = False;
for (Attempt attempt : attempts) {
switch (attempt.status) {
case NoSession:
case KnownNoSession:
needsSession = True;
break;
// scan the attempts:
// 1) each Failed/Alert needs to be logged, and the presence of any of these needs
// to result in a failure (even if other Attempts succeed)
// 2) if there's no better answer than NoSession/KnownNoSession, then ask the
// Session Broker to create a session
// 3) NotActive Attempts are not considered attacks as long as there is a Success
// with the same Principal (or for non-conferring Entitlements, there is any
// Success); these are tolerated as non-errors because some clients will continue
// to send expired credentials forever
// 4) At least one Success attempt is what we're aiming for; if there is more than
// one, each Success Claims must: (i) be a Principal Claim that does not disagree
// with any other claim, (ii) be an identity-conferring Entitlement Claim that
// does not disagree with any other claim, or (iii) be an non-conferring
// Entitlement Claim
Principal[] principals = [];
Entitlement[] entitlements = [];
Attempt[] alerts = [];
Attempt[] failures = [];
Attempt[] inactives = [];
AuthStatus bestStatus = NoData;
for (Attempt attempt : attempts) {
switch (attempt.status) {
case Success:
// is it a principal or an entitlement?
val claim = attempt.claim;
if (claim.is(Principal)) {
principals := principals.addIfAbsent(claim);
} else {
assert claim.is(Entitlement);
entitlements := entitlements.addIfAbsent(claim);
}
break;

case Alert:
failedAuth(request, session, attempt);
alerts += attempt;
break;
case NotActive:
// defer logging for these (only happens if there are other failures)
inactives += attempt;
break;

case Failed:
failedAuth(request, session, attempt);
failures += attempt;
break;
case Failed:
failedAuth(request, session, attempt);
failures += attempt;
break;

case NotActive:
// defer logging for these (only happens if there are other failures)
inactives += attempt;
break;
case Alert:
failedAuth(request, session, attempt);
alerts += attempt;
break;

// TODO keep best of: KnownNoData, InProgress
case InProgress:
case KnownNoSession:
case KnownNoData:
case NoSession:
case NoData:
bestStatus = bestStatus.notLessThan(attempt.status);
break;

case Success:
// is it a principal or an entitlement?
val claim = attempt.claim;
if (claim.is(Principal)) {
principals := principals.addIfAbsent(claim);
} else {
assert claim.is(Entitlement);
entitlements := entitlements.addIfAbsent(claim);
}
break;
default:
assert;
}
}
}

// if there were any alerts or failures, then also log any inactive claim auth attempts
if (!alerts.empty || !failures.empty) {
for (Attempt attempt : inactives) {
failedAuth(request, session, attempt);
}
// if there were any alerts or failures, then also log any inactive claims
if (!alerts.empty || !failures.empty) {
for (Attempt attempt : inactives) {
failedAuth(request, session, attempt);
}

// bail out immediately and stop trying to auth if there were any alerts
if (!alerts.empty) {
HttpStatus status = Forbidden;
for (Attempt attempt : alerts) {
if (ResponseOut response := attempt.response.is(ResponseOut)) {
return True, response;
// bail out immediately and stop trying to auth if there were any alerts
if (!alerts.empty) {
HttpStatus status = Forbidden;
for (Attempt attempt : alerts) {
if (ResponseOut response := attempt.response.is(ResponseOut)) {
return True, response;
}
status := attempt.response.is(HttpStatus);
}
status := attempt.response.is(HttpStatus);
return True, new SimpleResponse(status);
}
return True, new SimpleResponse(status);
}

// otherwise emit an appropriate challenge
return True, buildChallenge(failures);
}
// otherwise emit an appropriate challenge
return True, buildChallenge(failures);
}

// check for Success
if (!principals.empty || !entitlements.empty) {
for (Entitlement entitlement : entitlements) {
Int pid = entitlement.principalId;
if (entitlement.conferIdentity && !principals.any(p -> p.principalId == pid)) {
if (Principal principal := authenticator.realm.readPrincipal(pid)) {
principals := principals.addIfAbsent(principal);
} else {
// TODO log failure?
// check for Success
if (!principals.empty || !entitlements.empty) {
for (Entitlement entitlement : entitlements) {
Int pid = entitlement.principalId;
if (entitlement.conferIdentity && !principals.any(p -> p.principalId == pid)) {
if (Principal principal := authenticator.realm.readPrincipal(pid)) {
principals := principals.addIfAbsent(principal);
} else {
// TODO log an internal error for the Realm failing to read the Principal?
}
}
}
}
if (principals.size > 1) {
// contradicting Principals
return True, new SimpleResponse(BadRequest);
}
if (principals.size > 1) {
// contradicting Principals
return True, new SimpleResponse(BadRequest);
}

Principal? principal = principals.empty ? Null : principals[0];
if (session != Null) {
// TODO Session.exclusiveAgent
session.authenticate(principal, entitlements);
Principal? principal = principals.empty ? Null : principals[0];
if (session != Null) {
// TODO Session.exclusiveAgent
session.authenticate(principal, entitlements);
}
// use the raw auth data we just collected
if (checkApproval(principal, entitlements, permission, accessGranted)) {
return False;
} else {
return True, new SimpleResponse(Forbidden);
}
}
// use the raw auth data we just collected
if (checkApproval(principal, entitlements, permission, accessGranted)) {
return False;

if (inactives.empty) {
if (bestStatus > NoData) {
// handle the "needs session" statuses
if (bestStatus == NoSession || bestStatus == KnownNoSession) {
if ((session, ResponseOut? response) := sessionBroker.requireSession(request)) {
if (response != Null) {
return True, response;
}
assert session != Null;
continue FromTheTop;
} else {
// the Broker could not create a Session; return an error response
return True, new SimpleResponse(NotImplemented);
}
}
attempts = attempts.filter(a -> a.status == bestStatus, ToAttemptArray);
} else {
attempts = attempts;
}
} else {
return True, new SimpleResponse(Forbidden);
attempts = inactives;
}
return True, buildChallenge(attempts);
}

// TODO use best of: KnownNoData, InProgress

if (needsSession) {
// TODO
}

// the request doesn't have the necessary security permissions
return True, buildChallenge(attempts);
};

private ResponseOut buildChallenge(Attempt[] attempts) {
Expand Down

0 comments on commit cb474f2

Please sign in to comment.