From c684327bd18988f7b7a1d7bca4b2d90ae07cc745 Mon Sep 17 00:00:00 2001 From: Cameron Purdy <699204+cpurdy@users.noreply.github.com> Date: Tue, 11 Jun 2024 16:07:18 -0400 Subject: [PATCH] Security updates: Create new security module, use that module in the http & xenia modules, and remove requirement for web sessions; refactor Realm API and DB-based realm implementation --- doc/bnf.x | 4 +- gradle/libs.versions.toml | 1 + .../src/main/java/org/xvm/asm/Annotation.java | 5 +- .../org/xvm/asm/constants/TypeConstant.java | 2 +- .../main/java/org/xvm/compiler/Parser.java | 23 +- .../compiler/ast/AnnotationExpression.java | 3 +- .../java/org/xvm/runtime/ClassTemplate.java | 7 +- .../src/main/java/org/xvm/runtime/Frame.java | 4 +- .../java/org/xvm/runtime/NativeContainer.java | 15 +- .../template/_native/web/xRTServer.java | 64 +- .../template/annotations/xInjectedRef.java | 5 +- .../runtime/template/reflect/xInjector.java | 5 +- .../org/xvm/runtime/template/xException.java | 6 + javatools_bridge/build.gradle.kts | 1 + .../src/main/x/_native/web/RTServer.x | 17 +- lib_ecstasy/src/main/x/ecstasy/text/String.x | 199 +++-- lib_jsondb/src/main/x/jsondb/Client.x | 3 + lib_oodb/src/main/x/oodb/Connection.x | 8 + lib_sec/build.gradle.kts | 9 + lib_sec/src/main/x/sec.x | 6 + lib_sec/src/main/x/sec/Credential.x | 151 ++++ lib_sec/src/main/x/sec/Entitlement.x | 141 +++ lib_sec/src/main/x/sec/Entity.x | 131 +++ lib_sec/src/main/x/sec/Group.x | 150 ++++ lib_sec/src/main/x/sec/KeyCredential.x | 56 ++ lib_sec/src/main/x/sec/NonceManager.x | 146 +++ lib_sec/src/main/x/sec/Permission.x | 101 +++ lib_sec/src/main/x/sec/PlainTextCredential.x | 61 ++ lib_sec/src/main/x/sec/Principal.x | 93 ++ lib_sec/src/main/x/sec/Realm.x | 437 +++++++++ lib_sec/src/main/x/sec/Subject.x | 293 ++++++ lib_web/build.gradle.kts | 1 + lib_web/src/main/x/web.x | 153 +++- lib_web/src/main/x/web/Header.x | 50 +- lib_web/src/main/x/web/HttpClient.x | 39 +- lib_web/src/main/x/web/HttpMessage.x | 2 +- lib_web/src/main/x/web/Request.x | 18 +- lib_web/src/main/x/web/RequestIn.x | 21 + lib_web/src/main/x/web/Response.x | 4 +- lib_web/src/main/x/web/Session.x | 116 +-- lib_web/src/main/x/web/WebApp.x | 60 +- lib_web/src/main/x/web/WebService.x | 95 +- .../src/main/x/web/security/Authenticator.x | 185 +++- .../main/x/web/security/BasicAuthenticator.x | 125 ++- .../main/x/web/security/ChainAuthenticator.x | 49 +- .../main/x/web/security/DigestAuthenticator.x | 509 +++++------ .../main/x/web/security/DigestCredential.x | 261 ++++++ lib_web/src/main/x/web/security/FixedRealm.x | 268 ++---- .../main/x/web/security/NeverAuthenticator.x | 22 +- lib_web/src/main/x/web/security/Realm.x | 254 ------ .../main/x/web/security/TokenAuthenticator.x | 40 +- lib_web/src/main/x/web/sessions/Broker.x | 52 ++ .../src/main/x/web/sessions/ChainedBroker.x | 62 ++ lib_web/src/main/x/web/sessions/NeverBroker.x | 25 + lib_webauth/build.gradle.kts | 1 + lib_webauth/src/main/x/webauth.x | 35 +- lib_webauth/src/main/x/webauth/AuthSchema.x | 36 +- .../src/main/x/webauth/Configuration.x | 51 +- lib_webauth/src/main/x/webauth/DBRealm.x | 510 +++++++---- lib_webauth/src/main/x/webauth/Role.x | 12 - lib_webauth/src/main/x/webauth/Roles.x | 88 -- lib_webauth/src/main/x/webauth/User.x | 75 -- lib_webauth/src/main/x/webauth/UserChange.x | 35 - lib_webauth/src/main/x/webauth/UserHistory.x | 22 - lib_webauth/src/main/x/webauth/Users.x | 84 -- lib_xenia/build.gradle.kts | 1 + lib_xenia/src/main/x/xenia.x | 11 +- lib_xenia/src/main/x/xenia/Catalog.x | 272 ++++-- lib_xenia/src/main/x/xenia/ChainBundle.x | 374 +++++++- lib_xenia/src/main/x/xenia/CookieBroker.x | 841 ++++++++++++++++++ lib_xenia/src/main/x/xenia/Dispatcher.x | 800 ++--------------- lib_xenia/src/main/x/xenia/Http1Request.x | 99 ++- lib_xenia/src/main/x/xenia/HttpHandler.x | 12 +- lib_xenia/src/main/x/xenia/SessionImpl.x | 117 +-- lib_xenia/src/main/x/xenia/SessionManager.x | 44 +- lib_xenia/src/main/x/xenia/SystemService.x | 255 ------ manualTests/src/main/x/webTests/Hello.x | 62 +- xdk/build.gradle.kts | 1 + xdk/settings.gradle.kts | 1 + 79 files changed, 5489 insertions(+), 2908 deletions(-) create mode 100644 lib_sec/build.gradle.kts create mode 100644 lib_sec/src/main/x/sec.x create mode 100644 lib_sec/src/main/x/sec/Credential.x create mode 100644 lib_sec/src/main/x/sec/Entitlement.x create mode 100644 lib_sec/src/main/x/sec/Entity.x create mode 100644 lib_sec/src/main/x/sec/Group.x create mode 100644 lib_sec/src/main/x/sec/KeyCredential.x create mode 100644 lib_sec/src/main/x/sec/NonceManager.x create mode 100644 lib_sec/src/main/x/sec/Permission.x create mode 100644 lib_sec/src/main/x/sec/PlainTextCredential.x create mode 100644 lib_sec/src/main/x/sec/Principal.x create mode 100644 lib_sec/src/main/x/sec/Realm.x create mode 100644 lib_sec/src/main/x/sec/Subject.x create mode 100644 lib_web/src/main/x/web/security/DigestCredential.x delete mode 100644 lib_web/src/main/x/web/security/Realm.x create mode 100644 lib_web/src/main/x/web/sessions/Broker.x create mode 100644 lib_web/src/main/x/web/sessions/ChainedBroker.x create mode 100644 lib_web/src/main/x/web/sessions/NeverBroker.x delete mode 100644 lib_webauth/src/main/x/webauth/Role.x delete mode 100644 lib_webauth/src/main/x/webauth/Roles.x delete mode 100644 lib_webauth/src/main/x/webauth/User.x delete mode 100644 lib_webauth/src/main/x/webauth/UserChange.x delete mode 100644 lib_webauth/src/main/x/webauth/UserHistory.x delete mode 100644 lib_webauth/src/main/x/webauth/Users.x create mode 100644 lib_xenia/src/main/x/xenia/CookieBroker.x delete mode 100644 lib_xenia/src/main/x/xenia/SystemService.x diff --git a/doc/bnf.x b/doc/bnf.x index 1dcae2eed7..98bea85cdd 100644 --- a/doc/bnf.x +++ b/doc/bnf.x @@ -45,8 +45,8 @@ ArgumentList "(" Arguments-opt ")" Arguments - Argument - Arguments "," Argument + Argument ","-opt + Arguments "," Argument ","-opt Argument NamedArgument-opt ArgumentExpression diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b0125d474b..2d2f294df8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -54,6 +54,7 @@ xdk-json = { group = "org.xtclang", name = "lib-json", version.ref = "xdk" } xdk-jsondb = { group = "org.xtclang", name = "lib-jsondb", version.ref = "xdk" } xdk-net = { group = "org.xtclang", name = "lib-net", version.ref = "xdk" } xdk-oodb = { group = "org.xtclang", name = "lib-oodb", version.ref = "xdk" } +xdk-sec = { group = "org.xtclang", name = "lib-sec", version.ref = "xdk" } xdk-web = { group = "org.xtclang", name = "lib-web", version.ref = "xdk" } xdk-webauth = { group = "org.xtclang", name = "lib-webauth", version.ref = "xdk" } xdk-xenia = { group = "org.xtclang", name = "lib-xenia", version.ref = "xdk" } diff --git a/javatools/src/main/java/org/xvm/asm/Annotation.java b/javatools/src/main/java/org/xvm/asm/Annotation.java index 2b67002f21..04d42fc597 100644 --- a/javatools/src/main/java/org/xvm/asm/Annotation.java +++ b/javatools/src/main/java/org/xvm/asm/Annotation.java @@ -194,18 +194,19 @@ public Constant[] getParams() * * @param aParams the new parameters (may include default parameter values) */ - public void resolveParams(Constant[] aParams) + public Annotation resolveParams(Constant[] aParams) { if (!Arrays.equals(aParams, m_aParams)) { if (getPosition() >= 0) { // we must never change the hashCode/equality for already registered constants - throw new IllegalStateException("Annotation has already been registered: " + this); + return getConstantPool().ensureAnnotation(getAnnotationClass(), aParams); } m_aParams = aParams; } + return this; } /** diff --git a/javatools/src/main/java/org/xvm/asm/constants/TypeConstant.java b/javatools/src/main/java/org/xvm/asm/constants/TypeConstant.java index 73f6a91a87..6cc3711827 100644 --- a/javatools/src/main/java/org/xvm/asm/constants/TypeConstant.java +++ b/javatools/src/main/java/org/xvm/asm/constants/TypeConstant.java @@ -4035,7 +4035,7 @@ protected void layerOnProp( if (propBase == null && propContrib.isOverride()) { log(errs, Severity.ERROR, VE_PROPERTY_OVERRIDE_NO_SPEC, - typeContrib.getValueString(), propContrib.getName()); + typeContrib.removeAccess().getValueString(), propContrib.getName()); } // the property is stored both by its absolute (fully qualified) ID and its nested diff --git a/javatools/src/main/java/org/xvm/compiler/Parser.java b/javatools/src/main/java/org/xvm/compiler/Parser.java index 5637577f33..74f15dcd13 100644 --- a/javatools/src/main/java/org/xvm/compiler/Parser.java +++ b/javatools/src/main/java/org/xvm/compiler/Parser.java @@ -1855,7 +1855,7 @@ List parseConditionList() { List list = new ArrayList<>(4); list.add(parseCondition(false)); - while (match(Id.COMMA) != null) + while (match(Id.COMMA) != null && !peek(Id.R_PAREN)) { list.add(parseCondition(false)); } @@ -1864,14 +1864,14 @@ List parseConditionList() AstNode parseCondition(boolean fNegated) { - if (!fNegated && peek(Id.NOT) != null) + if (!fNegated && peek(Id.NOT)) { // test for a negated conditional assignment - Token tokNot = peek(); + Token tokNot = null; AssignmentStatement stmtAsn = null; try (SafeLookAhead attempt = new SafeLookAhead()) { - expect(Id.NOT); + tokNot = expect(Id.NOT); if (match(Id.L_PAREN) != null) { AstNode stmtPeek = parseCondition(true); @@ -2440,12 +2440,17 @@ MultipleLValueStatement peekMultiVariableInitializer() listLVals.add(LVal); } + Token comma = match(Id.COMMA); if (!fFirst && match(Id.R_PAREN) != null) { return new MultipleLValueStatement(listLVals); } - expect(Id.COMMA); + if (comma == null) + { + // comma is required + expect(Id.COMMA); + } } } @@ -5463,8 +5468,8 @@ List parseParameterTypeList(boolean required) * "(" Arguments-opt ")" * * Arguments - * Argument - * Arguments "," Argument + * Argument ","-opt + * Arguments "," Argument ","-opt * * Argument * NamedArgument-opt ArgumentExpression @@ -5880,7 +5885,7 @@ protected Token peek() * * @return true iff the next token matches */ - protected Boolean peek(Token.Id id) + protected boolean peek(Token.Id id) { return peek().getId() == id; } @@ -5891,7 +5896,7 @@ protected Boolean peek(Token.Id id) * * @return true iff the next token is either an identifier or the "_" token */ - protected Boolean peekNameOrAny() + protected boolean peekNameOrAny() { Token.Id id = peek().getId(); return id == Id.IDENTIFIER || id == Id.ANY; diff --git a/javatools/src/main/java/org/xvm/compiler/ast/AnnotationExpression.java b/javatools/src/main/java/org/xvm/compiler/ast/AnnotationExpression.java index 96604b9e6c..0e79d36bc5 100644 --- a/javatools/src/main/java/org/xvm/compiler/ast/AnnotationExpression.java +++ b/javatools/src/main/java/org/xvm/compiler/ast/AnnotationExpression.java @@ -357,6 +357,7 @@ else if (exprNew instanceof LambdaExpression exprLambda) } } exprOld.log(errs, Severity.ERROR, Compiler.CONSTANT_REQUIRED); + return; } } @@ -372,7 +373,7 @@ else if (exprNew instanceof LambdaExpression exprLambda) } } - anno.resolveParams(aconstArgs); + m_anno = anno.resolveParams(aconstArgs); } } diff --git a/javatools/src/main/java/org/xvm/runtime/ClassTemplate.java b/javatools/src/main/java/org/xvm/runtime/ClassTemplate.java index 5956125f13..7341db9852 100644 --- a/javatools/src/main/java/org/xvm/runtime/ClassTemplate.java +++ b/javatools/src/main/java/org/xvm/runtime/ClassTemplate.java @@ -970,7 +970,7 @@ else if (field.isTransient()) private int getInjectedProperty(Frame frame, GenericHandle hThis, PropertyConstant idProp, int iReturn) { - TypeInfo info = hThis.getType().ensureTypeInfo(); + TypeInfo info = hThis.getType().ensureAccess(Access.PRIVATE).ensureTypeInfo(); PropertyInfo prop = info.findProperty(idProp, true); Annotation anno = prop.getRefAnnotations()[0]; Constant[] aParams = anno.getParams(); @@ -983,15 +983,14 @@ private int getInjectedProperty(Frame frame, GenericHandle hThis, PropertyConsta if (Op.isDeferred(hOpts)) { return hOpts.proceed(frame, frameCaller -> - getInjectedProperty(frameCaller, hThis, idProp, iReturn)); + getInjectedProperty(frameCaller, hThis, idProp, iReturn)); } ObjectHandle hValue = frame.getInjected(sResource, prop.getType(), hOpts); if (hValue == null) { return frame.raiseException( - xException.illegalState(frame, "Unknown injectable resource \"" + - prop.getType().getValueString() + ' ' + sResource + '"')); + xException.unknownInjectable(frame, prop.getType(), sResource)); } // store off the value (even if deferred), so a concurrent operation wouldn't "double dip" diff --git a/javatools/src/main/java/org/xvm/runtime/Frame.java b/javatools/src/main/java/org/xvm/runtime/Frame.java index 83c23ddfb3..c64f6a00f8 100644 --- a/javatools/src/main/java/org/xvm/runtime/Frame.java +++ b/javatools/src/main/java/org/xvm/runtime/Frame.java @@ -312,9 +312,9 @@ public Frame createWaitFrame(ObjectHandle[] ahFuture, int[] aiReturn) int cReturns = ahFuture.length; List listFutures = new ArrayList<>(cReturns); - for (int i = 0; i < cReturns; i++) + for (ObjectHandle handle : ahFuture) { - if (ahFuture[i] instanceof FutureHandle hFuture) + if (handle instanceof FutureHandle hFuture) { listFutures.add(hFuture.getFuture()); } diff --git a/javatools/src/main/java/org/xvm/runtime/NativeContainer.java b/javatools/src/main/java/org/xvm/runtime/NativeContainer.java index a55ce45661..645ccc6494 100644 --- a/javatools/src/main/java/org/xvm/runtime/NativeContainer.java +++ b/javatools/src/main/java/org/xvm/runtime/NativeContainer.java @@ -384,16 +384,6 @@ private void initResources(ConstantPool pool) TypeConstant typeServer = templateServer.getCanonicalType(); addResourceSupplier(new InjectionKey("server", typeServer), templateServer::ensureServer); - // +++ web:Authenticator (Nullable|Authenticator) - ModuleConstant moduleWeb = pool.ensureModuleConstant("web.xtclang.org"); - TypeConstant typeAuthenticator = pool.ensureTerminalTypeConstant( - pool.ensureClassConstant(pool.ensurePackageConstant(moduleWeb, "security"), "Authenticator")). - ensureNullable(); - // the NativeContainer can only supply a trivial result; anything better than that must be - // done naturally by a container that hosts the calling container - addResourceSupplier(new InjectionKey("providedAuthenticator", typeAuthenticator), - (frame_, opts_) -> xNullable.NULL); - // +++ mgmt.Linker xContainerLinker templateLinker = xContainerLinker.INSTANCE; TypeConstant typeLinker = templateLinker.getCanonicalType(); @@ -767,7 +757,10 @@ public ObjectHandle getInjectable(Frame frame, String sName, TypeConstant type, InjectionKey key = f_mapResourceNames.get(sName); if (key == null) { - return null; + // for "Nullable" types the NativeContainer can only supply a trivial result; + // anything better than that must be done naturally by a container that hosts the + // calling container + return type.isNullable() ? xNullable.NULL : null; } // check for equality first, but allow "congruency" or "duck type" equality as well diff --git a/javatools/src/main/java/org/xvm/runtime/template/_native/web/xRTServer.java b/javatools/src/main/java/org/xvm/runtime/template/_native/web/xRTServer.java index d4f2d10921..505eb2a899 100644 --- a/javatools/src/main/java/org/xvm/runtime/template/_native/web/xRTServer.java +++ b/javatools/src/main/java/org/xvm/runtime/template/_native/web/xRTServer.java @@ -357,16 +357,18 @@ private void configureBinding(HttpServerHandle hServer, ObjectHandle hBinding) { } /** - * Implementation of "void addRouteImpl(String hostName, HandlerWrapper wrapper, - * KeyStore keystore, String? tlsKey=Null)" method. + * Implementation of "void addRouteImpl(String hostName, UInt16 httpPort, UInt16 httpsPort, + * HandlerWrapper wrapper, KeyStore keystore, String? tlsKey=Null)" method. */ private int invokeAddRoute(Frame frame, HttpServerHandle hServer, ObjectHandle[] ahArg) { - String sHostName = ((StringHandle) ahArg[0]).getStringValue(); - ServiceHandle hWrapper = (ServiceHandle) ahArg[1]; - KeyStoreHandle hKeystore = ahArg[2] instanceof KeyStoreHandle hK ? hK : null; - String sTlsKey = ahArg[3] instanceof StringHandle hS ? hS.getStringValue() : null; - Router router = hServer.getRouter(); + String sHostName = ((StringHandle) ahArg[0]).getStringValue(); + int nHttpPort = (int) ((JavaLong) ahArg[1]).getValue(); + int nHttpsPort = (int) ((JavaLong) ahArg[2]).getValue(); + ServiceHandle hWrapper = (ServiceHandle) ahArg[3]; + KeyStoreHandle hKeystore = ahArg[4] instanceof KeyStoreHandle hK ? hK : null; + String sTlsKey = ahArg[5] instanceof StringHandle hS ? hS.getStringValue() : null; + Router router = hServer.getRouter(); if (hKeystore != null) { @@ -401,7 +403,7 @@ private int invokeAddRoute(Frame frame, HttpServerHandle hServer, ObjectHandle[] } RequestHandler handler = createRequestHandler(frame, hWrapper, hServer); - RouteInfo route = new RouteInfo(handler, hKeystore, sTlsKey); + RouteInfo route = new RouteInfo(handler, nHttpPort, nHttpsPort, hKeystore, sTlsKey); if (hServer.getHttpServer().getAddress().getHostName().equals(sHostName)) { @@ -430,11 +432,10 @@ private boolean isValidPair(KeyStoreHandle hKeystore, String sName) */ private int invokeReplaceRoute(Frame frame, HttpServerHandle hServer, ObjectHandle[] ahArg, int iResult) { - StringHandle hHostName = (StringHandle) ahArg[0]; - ServiceHandle hWrapper = (ServiceHandle) ahArg[1]; - Router router = hServer.getRouter(); - String sHostName = hHostName.getStringValue(); - RouteInfo info = router.mapRoutes.get(sHostName); + String sHostName = ((StringHandle) ahArg[0]).getStringValue(); + ServiceHandle hWrapper = (ServiceHandle) ahArg[1]; + Router router = hServer.getRouter(); + RouteInfo info = router.mapRoutes.get(sHostName); if (info == null) { @@ -442,7 +443,8 @@ private int invokeReplaceRoute(Frame frame, HttpServerHandle hServer, ObjectHand } RequestHandler handler = createRequestHandler(frame, hWrapper, hServer); - router.mapRoutes.put(sHostName, new RouteInfo(handler, info.hKeyStore, info.sTlsKey)); + router.mapRoutes.put(sHostName, + new RouteInfo(handler, info.nHttpPort, info.nHttpPort, info.hKeyStore, info.sTlsKey)); return frame.assignValue(iResult, xBoolean.TRUE); } @@ -499,13 +501,15 @@ private int invokeGetReceivedFromAddress(Frame frame, HttpContextHandle hCtx, in */ private int invokeGetHostInfo(Frame frame, HttpContextHandle hCtx, int[] aiResult) { - String sHost = getHostName(hCtx.f_exchange); - int nPort = getHostPort(hCtx.f_exchange); + HttpExchange exchange = hCtx.f_exchange; + String sHost = exchange.getRequestHeaders().getFirst("Host"); + String sName = extractHostName(sHost); + int nPort = extractHostPort(sHost, exchange); - return sHost == null + return sName == null ? frame.assignValue(aiResult[0], xBoolean.FALSE) : frame.assignValues(aiResult, xBoolean.TRUE, - xString.makeHandle(sHost), xUInt16.INSTANCE.makeJavaLong(nPort)); + xString.makeHandle(sName), xUInt16.INSTANCE.makeJavaLong(nPort)); } /** @@ -660,9 +664,8 @@ private int invokeRespond(Frame frame, ObjectHandle[] ahArg) // ----- helper methods ------------------------------------------------------------------------ - protected static String getHostName(HttpExchange exchange) + protected static String extractHostName(String sHost) { - String sHost = exchange.getRequestHeaders().getFirst("Host"); if (sHost != null) { int ofPort = sHost.lastIndexOf(':'); @@ -674,9 +677,8 @@ protected static String getHostName(HttpExchange exchange) return sHost; } - protected static int getHostPort(HttpExchange exchange) + protected static int extractHostPort(String sHost, HttpExchange exchange) { - String sHost = exchange.getRequestHeaders().getFirst("Host"); if (sHost == null) { return exchange.getRemoteAddress().getPort(); @@ -899,13 +901,16 @@ protected static class Router public void handle(HttpExchange exchange) throws IOException { - String sHost = getHostName(exchange); - RouteInfo route = mapRoutes.get(sHost); - if (route == null) + String sHost = exchange.getRequestHeaders().getFirst("Host"); + String sName = extractHostName(sHost); + int nPort = extractHostPort(sHost, exchange); + boolean fTls = exchange instanceof HttpsExchange; + RouteInfo route = mapRoutes.get(sName); + if (route == null || nPort != (fTls ? route.nHttpsPort : route.nHttpPort)) { - System.err.println("*** Request for unknown host: " + sHost - + exchange.getRequestURI()); - exchange.sendResponseHeaders(444, -1); // HttpStatus.NoResponse + System.err.println("*** Request for unregistered route: " + + (fTls ? "https://" : "http://") + sHost + exchange.getRequestURI()); + exchange.sendResponseHeaders(421, -1); // HttpStatus.MisdirectedRequest } else { @@ -934,7 +939,8 @@ protected void setDirectRoute(RouteInfo route) } } - protected record RouteInfo(RequestHandler handler, KeyStoreHandle hKeyStore, String sTlsKey) {} + protected record RouteInfo(RequestHandler handler, int nHttpPort, int nHttpsPort, + KeyStoreHandle hKeyStore, String sTlsKey) {} // ----- ObjectHandles ------------------------------------------------------------------------- diff --git a/javatools/src/main/java/org/xvm/runtime/template/annotations/xInjectedRef.java b/javatools/src/main/java/org/xvm/runtime/template/annotations/xInjectedRef.java index 0a1a3fd36d..acc1f86e30 100644 --- a/javatools/src/main/java/org/xvm/runtime/template/annotations/xInjectedRef.java +++ b/javatools/src/main/java/org/xvm/runtime/template/annotations/xInjectedRef.java @@ -17,6 +17,7 @@ import org.xvm.runtime.TypeComposition; import org.xvm.runtime.template.xBoolean; +import org.xvm.runtime.template.xException; import org.xvm.runtime.template.xNullable; import org.xvm.runtime.template.text.xString; @@ -107,8 +108,8 @@ public int getReferent(Frame frame, RefHandle hTarget, int iReturn) hValue = frame.getInjected(sResource, typeResource, hOpts); if (hValue == null) { - return frame.raiseException("Unknown injectable resource \"" + - typeResource.getValueString() + ' ' + sResource + '"'); + return frame.raiseException( + xException.unknownInjectable(frame, typeResource, sResource)); } if (Op.isDeferred(hValue)) diff --git a/javatools/src/main/java/org/xvm/runtime/template/reflect/xInjector.java b/javatools/src/main/java/org/xvm/runtime/template/reflect/xInjector.java index 48bd25b9c1..adc17dc109 100644 --- a/javatools/src/main/java/org/xvm/runtime/template/reflect/xInjector.java +++ b/javatools/src/main/java/org/xvm/runtime/template/reflect/xInjector.java @@ -9,6 +9,7 @@ import org.xvm.runtime.Frame; import org.xvm.runtime.ObjectHandle; +import org.xvm.runtime.template.xException; import org.xvm.runtime.template.xNullable; import org.xvm.runtime.template.xService; @@ -77,8 +78,8 @@ public int invokeNativeN(Frame frame, MethodStructure method, ObjectHandle hTarg ObjectHandle hValue = frame.getInjected(hName.getStringValue(), hType.getDataType(), hOpts); if (hValue == null) { - return frame.raiseException("Unknown injectable resource \"" + - hType.getDataType().getValueString() + ' ' + hName.getStringValue() + '"'); + return frame.raiseException(xException.unknownInjectable(frame, + hType.getDataType(), hName.getStringValue())); } return Op.isDeferred(hValue) diff --git a/javatools/src/main/java/org/xvm/runtime/template/xException.java b/javatools/src/main/java/org/xvm/runtime/template/xException.java index fc4165f7d6..e8c66d3f52 100644 --- a/javatools/src/main/java/org/xvm/runtime/template/xException.java +++ b/javatools/src/main/java/org/xvm/runtime/template/xException.java @@ -302,6 +302,12 @@ public static ExceptionHandle abstractMethod(Frame frame, String sMethod) return makeHandle(frame, "No implementation for \"" + sMethod + '"'); } + public static ExceptionHandle unknownInjectable(Frame frame, TypeConstant type, String sName) + { + return makeHandle(frame, "Unknown injectable resource \"" + type.getValueString() + + ' ' + sName + '"'); + } + // ---- ObjectHandle helpers ------------------------------------------------------------------- diff --git a/javatools_bridge/build.gradle.kts b/javatools_bridge/build.gradle.kts index 7091f8b790..0c6087a3a3 100644 --- a/javatools_bridge/build.gradle.kts +++ b/javatools_bridge/build.gradle.kts @@ -19,6 +19,7 @@ dependencies { xtcModule(libs.xdk.crypto) xtcModule(libs.xdk.json) xtcModule(libs.xdk.net) + xtcModule(libs.xdk.sec) xtcModule(libs.xdk.web) } diff --git a/javatools_bridge/src/main/x/_native/web/RTServer.x b/javatools_bridge/src/main/x/_native/web/RTServer.x index 6df632fbe5..b351656a6a 100644 --- a/javatools_bridge/src/main/x/_native/web/RTServer.x +++ b/javatools_bridge/src/main/x/_native/web/RTServer.x @@ -85,13 +85,11 @@ service RTServer } // we should be able to replace an exiting route, but must not add any ambiguous ones - if (!routes.contains(route)) { - UInt16 httpPort = route.httpPort; - UInt16 httpsPort = route.httpsPort; - if (routes.keys.any(info -> info.host.toString() == hostName && - (info.httpPort == httpPort || info.httpsPort == httpsPort))) { - throw new IllegalArgument($"Route is not unique: {route}"); - } + UInt16 httpPort = route.httpPort; + UInt16 httpsPort = route.httpsPort; + if (routes.keys.any(info -> info.host.toString() == hostName && + (info.httpPort == httpPort || info.httpsPort == httpsPort))) { + throw new IllegalArgument($"Route is not unique: {route}"); } cookieDecryptor: @@ -125,7 +123,7 @@ service RTServer handler.configure(decryptor); } - addRouteImpl(hostName, new HandlerWrapper(handler), keystore, tlsKey); + addRouteImpl(hostName, httpPort, httpsPort, new HandlerWrapper(handler), keystore, tlsKey); routes = routes.put(route, handler); assert routes.is(immutable); @@ -216,7 +214,8 @@ service RTServer // ----- native implementations all run on the service context --------------------------------- private void bindImpl(HostInfo binding, String bindAddr, UInt16 httpPort, UInt16 httpsPort) {TODO("Native");} - private void addRouteImpl(String hostName, HandlerWrapper wrapper, KeyStore? keystore, String? tlsKey) {TODO("Native");} + private void addRouteImpl(String hostName, UInt16 httpPort, UInt16 httpsPort, + HandlerWrapper wrapper, KeyStore? keystore, String? tlsKey) {TODO("Native");} private Boolean replaceRouteImpl(String hostName, HandlerWrapper wrapper) {TODO("Native");} private void removeRouteImpl(String hostName) {TODO("Native");} (Byte[], UInt16) getReceivedAtAddress(RequestContext context) {TODO("Native");} diff --git a/lib_ecstasy/src/main/x/ecstasy/text/String.x b/lib_ecstasy/src/main/x/ecstasy/text/String.x index 6dcebf1e67..eb79dcbdfa 100644 --- a/lib_ecstasy/src/main/x/ecstasy/text/String.x +++ b/lib_ecstasy/src/main/x/ecstasy/text/String.x @@ -310,17 +310,21 @@ const String * @param entrySeparator the character that separates each entry from the next entry in the * the sequence of "map entries" represented by the String * @param whitespace a function that identifies white space to strip off of keys and values + * @param valueQuote a function that identifies an opening balanced quote of a quoted value * - * @return an array of Strings + * @return a map from `String` keys to `String` values */ - Map splitMap(Char kvSeparator ='=', - Char entrySeparator =',', - function Boolean(Char) whitespace = ch -> ch.isWhitespace()) { + Map splitMap( + Char kvSeparator = '=', + Char entrySeparator = ',', + function Boolean(Char) whitespace = ch -> ch.isWhitespace(), + function Boolean(Char) valueQuote = _ -> False, + ) { if (size == 0) { return []; } - return new StringMap(this, kvSeparator, entrySeparator, whitespace); + return new StringMap(this, kvSeparator, entrySeparator, whitespace, valueQuote); } /** @@ -329,10 +333,13 @@ const String * sequential, e.g. a call to `get(k)` in the Map will return the value from the first entry * with that key. */ - protected static const StringMap(String data, - Char kvSep, - Char entrySep, - function Boolean(Char) whitespace) + protected static const StringMap( + String data, + Char kvSep, + Char entrySep, + function Boolean(Char) whitespace, + function Boolean(Char) valueQuote, + ) implements Map extends maps.KeyBasedMap { @@ -343,15 +350,52 @@ const String @Lazy Int size.calc() = keyIterator().count(); @Override - Boolean empty.get() = False; + @Lazy Boolean empty.get() = keyIterator().next(); @Override Boolean contains(String key) = find(key); @Override conditional String get(String key) { - if ((Int keyStart, Int sepOffset, Int valueEnd) := find(key)) { - return True, valueEnd > sepOffset+1 ? data[sepOffset >..< valueEnd].trim(whitespace) : ""; + if (Int delimOffset := find(key)) { + Int length = data.size; + if (delimOffset >= length || data[delimOffset] == entrySep) { + return True, ""; + } + + // skip leading white space in the value + Int valStart = delimOffset + 1; + while (valStart < length && whitespace(data[valStart])) { + ++valStart; + } + + // it's possible that the entire value was whitespace + if (valStart >= length || data[valStart] == entrySep) { + return True, ""; + } + + // check for a quoted value + if (valueQuote(data[valStart])) { + Char quote = data[valStart++]; + Int valStop = valStart; + while (valStop < length && data[valStop] != quote) { + ++valStop; + } + return True, data[valStart..= length) { return False; } - - // find the end of the entry - Int endEntry = length; - endEntry := data.indexOf(entrySep, offset); - - // the delimiter between key and value is optional (i.e. value assumed - // to be "") - Int endKey = endEntry; - if (endKey := data.indexOf(kvSep, offset), endKey > endEntry) { - endKey = endEntry; + Int keyStart = offset; + Int keyEnd = offset; + while (offset < length) { + Char ch = data[offset++]; + if (ch == kvSep) { + offset = skipValue(offset); + break; + } else if (ch == entrySep) { + break; + } else { + ++keyEnd; + } } - - String key = data[offset ..< endKey].trim(whitespace); - offset = endEntry + 1; - - return True, key; + return True, data[keyStart..= length) { return False; } } + // first, verify that the key would even fit + if (offset + keyLength > length) { + return False; + } + + // match the key, character by character Boolean match = True; for (Char keyChar : key) { - if (offset >= length) { - return False; - } - Char mapChar = data[offset++]; if (mapChar != keyChar) { - if (mapChar == entrySep) { - continue EachEntry; + match = False; + --offset; + break; + } + } + + // finish whatever remains of the key and the white space after it, up until the kv + // separator or the entry separator is encountered (or there are no more chars) + while (offset < length) { + Char ch = data[offset]; + if (ch == entrySep) { + if (match) { + // key is followed immediately by the entry separator, so value is blank + return True, offset; + } else { + // wasn't a match: no value to skip, so try again to find the key + ++offset; + continue NextEntry; + } + } + + if (ch == kvSep) { + if (match) { + // key is followed immediately by the key separator, so value is next + return True, offset; } else { - match = False; + // wasn't a match: skip the value, then try again to find the key + ++offset; break; } } - } - while (offset < length && whitespace(data[offset])) { + if (match && !whitespace(ch)) { + match = False; + } ++offset; } if (offset >= length) { - // key is at the very end, with no delimiter - return match, keyOffset, length, length; - } - - Int sepOffset = offset; - Char sepChar = data[offset++]; - if (match && sepChar == entrySep) { - // key is followed immediately by the entry separator, so value is blank - return True, keyOffset, sepOffset, sepOffset; + // we've passed the end of the data, so there is no following value, but we + // might have found the key; either way, the search is done + return match, offset; } - // find the separator offset - while (offset < length && data[offset] != entrySep) { - ++offset; - } + offset = skipValue(offset); + } - if (match && sepChar == kvSep) { - // we did find the key, and now we have found the end of the value - return True, keyOffset, sepOffset, offset; - } + return False; + } + /** + * @param the offset of the first character of a value in the current k/v pair, i.e. one + * character past the `kvSep` + * + * @return the offset of the first character of a key in the next k/v pair (or the first + * index past the end of the string) + */ + protected Int skipValue(Int offset) { + String data = data; + Int length = data.size; + while (offset < length && whitespace(data[offset])) { ++offset; } - return False; + // skip past the quoted value (if the value is quoted) + if (offset < length && valueQuote(data[offset])) { + Char quote = data[offset++]; + while (offset < length && data[offset++] != quote) {} + } + + // skip everything else until one character past the entry separator + while (offset < length && data[offset++] != entrySep) {} + return offset; } } diff --git a/lib_jsondb/src/main/x/jsondb/Client.x b/lib_jsondb/src/main/x/jsondb/Client.x index 49de6e5c29..a0137eb61e 100644 --- a/lib_jsondb/src/main/x/jsondb/Client.x +++ b/lib_jsondb/src/main/x/jsondb/Client.x @@ -1288,6 +1288,9 @@ service Client { return newTx; } + @Override + Connection clone() = this.Client.catalog.createConnection(dbUser).as(Connection); + @Override void close(Exception? e = Null) { super(e); diff --git a/lib_oodb/src/main/x/oodb/Connection.x b/lib_oodb/src/main/x/oodb/Connection.x index 68f963d6e3..8ec7b1870a 100644 --- a/lib_oodb/src/main/x/oodb/Connection.x +++ b/lib_oodb/src/main/x/oodb/Connection.x @@ -57,6 +57,14 @@ interface Connection Int retryCount = 0, ); + /** + * Create a new `Connection` instance, which is the same as this `Connection` instance with the + * same `DBUser`, but **without** copying any in-flight `Transaction`. + * + * @return a new `Connection` to the same database as this `Connection`, and with the same user + */ + Connection clone(); + @Override void close(Exception? e = Null) { if (Transaction tx ?= transaction) { diff --git a/lib_sec/build.gradle.kts b/lib_sec/build.gradle.kts new file mode 100644 index 0000000000..1873e6feef --- /dev/null +++ b/lib_sec/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + id("org.xtclang.build.xdk.versioning") + alias(libs.plugins.xtc) +} + +dependencies { + xdkJavaTools(libs.javatools) + xtcModule(libs.xdk.ecstasy) +} diff --git a/lib_sec/src/main/x/sec.x b/lib_sec/src/main/x/sec.x new file mode 100644 index 0000000000..c0c34d573e --- /dev/null +++ b/lib_sec/src/main/x/sec.x @@ -0,0 +1,6 @@ +/** + * The security module defines a small set of security primitives that can be used as the basis for + * authentication and permission-based authorization, including support for principals, groups, and + * entitlements. + */ +module sec.xtclang.org {} diff --git a/lib_sec/src/main/x/sec/Credential.x b/lib_sec/src/main/x/sec/Credential.x new file mode 100644 index 0000000000..e45ac22fce --- /dev/null +++ b/lib_sec/src/main/x/sec/Credential.x @@ -0,0 +1,151 @@ +/** + * A `Credential` represents a means of authentication. + */ +@Abstract const Credential { + + typedef Subject.Status as Status; + + // ----- construction -------------------------------------------------------------------------- + + /** + * Construct a `Credential`. + * + * @param scheme uniquely identifies the form of authentication (and potentially additional + * specifics) that this credential is intended to be used with; for example, + * "password" might indicate a plain text password, while "digest:SHA-256" + * might indicate a digest of a password using a specific hash function + * @param validFrom the point in time before which the `Credential` does not exist; defaults + * to the current time + * @param validUntil the point in time after which the `Credential` is automatically expired + * @param status the explicit `Suspended` or `Revoked` status, or `Null` + */ + construct( + String scheme, + Time? validFrom = Null, + Time? validUntil = Null, + Status? status = Null, + ) { + this.scheme = scheme; + this.validFrom = validFrom ?: {@Inject Clock clock; return clock.now;}; + this.validUntil = validUntil; + this.status = status == Active ? Null : status; + } + + assert() { + assert status == Null || status == Suspended || status == Revoked; + assert validUntil? >= validFrom; + } + + /** + * Create a copy of this `Credential`, but with specific attributes modified. + * + * @param scheme the new `scheme` name, or pass `Null` to leave unchanged + * @param validFrom the new `validFrom` value to use, or `Null` to leave unchanged + * @param validUntil the new `validUntil` value to use, or `Null` to leave unchanged + * @param status the new `status` value to use, or `Null` to leave unchanged; the only + * legal [Status] values to pass are `Active`, `Suspended`, and `Revoked`; + * passing `Active` will result in the [status] of `Null` + */ + @Abstract Credential with( + String? scheme = Null, + Time? validFrom = Null, + Time? validUntil = Null, + Status? status = Null, + ); + + /** + * The form of authentication that this credential is intended to be used with, which may + * contain specific parameters of that authentication mechanism. For example, "pt" may indicate + * a plain text password, while "ak" may indicate an API key. + */ + String scheme; + + /** + * A `Credential` produces one or more locator strings that are specific to the scheme of the + * `Credential`, and are enforced as a unique domain composed of the combination of [scheme] + * name and locator string. Each locator will be usable within a [Realm] to perform a fast find + * on an `Entity` that has an active (not [revoked]) `Credential` which produced that locator + * string. + */ + @RO String[] locators; + + /** + * The point in time before which the `Credential` is considered to not yet exist and thus is + * not valid. Credentials must always have an explicit point before which they are not valid. + */ + Time validFrom; + + /** + * The optional point in time after which the `Credential` is considered to have expired and is + * no longer valid. + */ + Time? validUntil; + + /** + * Null iff the `Credential` has not been explicitly suspended or revoked; otherwise this + * indicates the explicit `Suspended` or `Revoked` status. + */ + Status? status; + + /** + * Virtual property: Is the `Credential` currently `Active`? + */ + @RO Boolean active.get() = calcStatus() == Active; + + /** + * Determine if this `Credential` is valid at some specific time. + * + * @param at (optional) the time at which to determine if the `Credential` is valid; if nothing + * is specified, then the current time is used + * + * @return the [Status] of `NotYet`, `Expired`, or `Active` for this `Credential` + */ + Status calcStatus(Time? at = Null) { + Status? status = this.status; + if (status == Suspended || status == Revoked) { + return status; + } + + if (at == Null) { + @Inject Clock clock; + at = clock.now; + } + + if (at < validFrom) { + return NotYet; + } + + if (at > validUntil?) { + return Expired; + } + + return Active; + } + + /** + * Suspend this `Credential`. + * + * @return a copy of this Subject, but with a [status] of [Suspended] + */ + Credential suspend() { + return status == Suspended ? this : this.with(status=Suspended); + } + + /** + * If this `Credential` is suspended, then undo the suspension. + * + * @return a copy of this Subject, but without the [status] of [Suspended] + */ + Credential unsuspend() { + return status == Suspended ? this.with(status=Active) : this; + } + + /** + * Revoke this `Credential`. + * + * @return a copy of this Subject, but with a [status] of [Revoked] + */ + Credential revoke() { + return status == Revoked ? this : this.with(status=Revoked); + } +} \ No newline at end of file diff --git a/lib_sec/src/main/x/sec/Entitlement.x b/lib_sec/src/main/x/sec/Entitlement.x new file mode 100644 index 0000000000..25f4394c38 --- /dev/null +++ b/lib_sec/src/main/x/sec/Entitlement.x @@ -0,0 +1,141 @@ +/** + * An `Entitlement` represents a set of permissions granted to a particular `Principal`. + */ +const Entitlement + extends Subject { + + // ----- construction -------------------------------------------------------------------------- + + /** + * Construct an `Entitlement`, which represents a grant of a group of permissions on behalf of + * a `Principal`. + * + * @param entitlementId the identity of the `Entitlement` + * @param name a human-readable name or description for the `Entitlement` + * @param principalId the identity of the [Principal] authorizing the `Entitlement` + * @param permissions explicit permissions (including revocations) for this `Entitlement`, + * in order of precedence + * @param validFrom the point in time before which the `Entitlement` does not exist; + * defaults to the current time + * @param validUntil the point in time after which the `Entitlement` is expired + * @param status the explicit `Suspended` or `Revoked` status, or `Null` + * @param credentials the credentials for authenticating the `Entitlement` + * @param conferIdentity True indicates that the `Entitlement` is used as a means of + * authentication, by explicitly conferring the `Principal`'s identity + */ + construct( + Int entitlementId, + String name, + Int principalId, + Permission[] permissions = [], + Time? validFrom = Null, + Time? validUntil = Null, + Status? status = Null, + Credential[] credentials = [], + Boolean conferIdentity = False, + ) { + construct Subject(entitlementId, name, permissions, validFrom, validUntil, status); + this.credentials = credentials; + this.principalId = principalId; + this.conferIdentity = conferIdentity; + } + + /** + * Create a new `Entitlement` that is a clone of this `Entitlement`, but with the specific + * changes specified. + * + * @param subjectId the new `subjectId` value, or `Null` to leave unchanged + * @param name the new `name` value, or `Null` to leave unchanged + * @param permissions the new permissions to use, or `Null` to leave unchanged + * @param validFrom the new `validFrom` value to use, or `Null` to leave unchanged + * @param validUntil the new `validUntil` value to use, or `Null` to leave unchanged + * @param status the new `status` value to use, or `Null` to leave unchanged; the only + * legal [Status] values to pass are `Active`, `Suspended`, and + * `Revoked`; passing `Active` will result in the [status] of `Null` + * @param credentials the new credentials to use, or `Null` to leave unchanged + * @param entitlementId the new `entitlementId` value, or `Null` to leave unchanged; this must + * the same value as the subjectId value if both are non-`Null` + * @param principalId the new `principalId` value, or `Null` to leave unchanged + * @param conferIdentity the new `conferIdentity` value, or `Null` to leave unchanged + * + * @return the new `Entitlement` copied from this `Entitlement` but with the specified changes + */ + @Override + Entitlement with( + Int? subjectId = Null, + String? name = Null, + Permission[]? permissions = Null, + Time? validFrom = Null, + Time? validUntil = Null, + Status? status = Null, + Credential[]? credentials = Null, + Int? entitlementId = Null, + Int? principalId = Null, + Boolean? conferIdentity = Null, + ) { + assert subjectId? == entitlementId?; + return new Entitlement( + entitlementId = entitlementId ?: subjectId ?: this.entitlementId, + name = name ?: this.name, + principalId = principalId ?: this.principalId, + permissions = permissions ?: this.permissions, + validFrom = validFrom ?: this.validFrom, + validUntil = validUntil ?: this.validUntil, + status = status ?: this.status, + credentials = credentials ?: this.credentials, + conferIdentity = conferIdentity ?: this.conferIdentity, + ); + } + + // ----- properties ---------------------------------------------------------------------------- + + /** + * The unique identity of the `Entitlement`. + */ + Int entitlementId.get() = subjectId; + + /** + * The credentials that can be used to authenticate this `Entitlement`. + */ + @Override + Credential[] credentials; + + /** + * The `Principal` that created the `Entitlement`. + */ + Int principalId; + + /** + * True iff the `Entitlement` can be used as a [Principal] identity for means of authentication. + */ + Boolean conferIdentity; + + // ----- API ----------------------------------------------------------------------------------- + + @Override + Status calcStatus(Realm? realm, Time? at = Null) { + Status entitlementStatus = super(Null, at); + if (realm == Null) { + return entitlementStatus; + } + + Status principalStatus = realm.readPrincipal(principalId)?.calcStatus(realm, at) : NotYet; + return minOf(entitlementStatus, principalStatus); + } + + @Override + Boolean permitted(Realm realm, Permission permission, Time? at = Null) { + // verify Entitlement status and that the permission is allowed as part of the Entitlement + if (!super(realm, permission, at)) { + return False; + } + + // in theory, the Principal could have lost the permission since the Entitlement was + // created, so verify that the Principal still has the required permission + if (Principal principal := realm.readPrincipal(principalId)) { + return principal.permitted(realm, permission, at); + } else { + return False; + } + } +} diff --git a/lib_sec/src/main/x/sec/Entity.x b/lib_sec/src/main/x/sec/Entity.x new file mode 100644 index 0000000000..cff62c5466 --- /dev/null +++ b/lib_sec/src/main/x/sec/Entity.x @@ -0,0 +1,131 @@ +/** + * An `Entity` represents a user or group for purposes of authorization. + */ +@Abstract const Entity + extends Subject{ + + // ----- construction -------------------------------------------------------------------------- + + /** + * Construct an `Entity` from an identity, a set of permissions, and a lifetime. + * + * @param entityId the identity of the `Entity` + * @param name a human-readable name or description for the `Entity` + * @param permissions explicit permissions (including revocations) for this `Entity`, in order + * of precedence + * @param validFrom the point in time before which the `Entity` does not exist; defaults to + * the current time + * @param validUntil the point in time after which the `Entity` is expired + * @param status the explicit `Suspended` or `Revoked` status, or `Null` + * @param groupIds the list of [Group] ids that this `Entity` belongs to + */ + construct( + Int entityId, + String name, + Permission[] permissions = [], + Time? validFrom = Null, + Time? validUntil = Null, + Status? status = Null, + Int[] groupIds = [], + ) { + construct Subject(entityId, name, permissions, validFrom, validUntil, status); + this.groupIds = groupIds.distinct().toArray(Constant); + } + + /** + * Create a new `Entity` that is a clone of this `Entity`, but with the specific changes + * specified. + * + * @param subjectId the new `subjectId` value, or `Null` to leave unchanged + * @param name the new `name` value, or `Null` to leave unchanged + * @param permissions the new permissions to use, or `Null` to leave unchanged + * @param validFrom the new `validFrom` value to use, or `Null` to leave unchanged + * @param validUntil the new `validUntil` value to use, or `Null` to leave unchanged + * @param status the new `status` value to use, or `Null` to leave unchanged; the only + * legal [Status] values to pass are `Active`, `Suspended`, and `Revoked`; + * passing `Active` will result in the [status] of `Null` + * @param credentials the new credentials to use, or `Null` to leave unchanged + * @param groupIds the new `groupIds` value, or `Null` to leave unchanged + * + * @return the new `Entity` copied from this `Entity` but with the specified changes + */ + @Override + @Abstract Entity with( + Int? subjectId = Null, + String? name = Null, + Permission[]? permissions = Null, + Time? validFrom = Null, + Time? validUntil = Null, + Status? status = Null, + Credential[]? credentials = Null, + Int[]? groupIds = Null, + ); + + // ----- properties ---------------------------------------------------------------------------- + + /** + * The identity of the `Entity`. + */ + @RO Int entityId.get() = subjectId; + + /** + * [Group]s that this `Entity` belongs to. + */ + Int[] groupIds; + + // ----- API ----------------------------------------------------------------------------------- + + @Override + Status calcStatus(Realm? realm, Time? at = Null) { + Status status = super(Null, at); + if (realm == Null || status != Active) { + return status; + } + + Int[] groupIds = this.groupIds; + if (!groupIds.empty) { + // at least one of the groups that this Principal derives from must be active + Status? groupStatus = Null; + for (Int groupId : groupIds) { + if (Group group := realm.readGroup(groupId)) { + Status curStatus = group.calcStatus(realm, at); + if (curStatus == Active) { + return Active; + } + groupStatus = maxOf(groupStatus?, curStatus) : curStatus; + } + } + return groupStatus ?: NotYet; // NotYet is reported if we failed every read + } + + return Active; + } + + @Override + Boolean permitted(Realm realm, Permission permission, Time? at = Null) { + switch (covers(permission)) { + case True: + // this Entity grants the permission; just verify that this Entity is Active + return calcStatus(realm, at) == Active; + case False: + // this Entity revokes the permission + return False; + } + + // this Entity neither explicitly grants nor revokes the permission; regardless of all else, + // if this Entity is itself not Active, then nothing is permitted + Status status = calcStatus(Null, at); + if (status != Active) { + return False; + } + + // at least one Group must permit the requested permission + for (Int groupId : groupIds) { + if (Group group := realm.readGroup(groupId), group.permitted(realm, permission, at)) { + return True; + } + } + + return False; + } +} diff --git a/lib_sec/src/main/x/sec/Group.x b/lib_sec/src/main/x/sec/Group.x new file mode 100644 index 0000000000..115fb4ce41 --- /dev/null +++ b/lib_sec/src/main/x/sec/Group.x @@ -0,0 +1,150 @@ +/** + * A `Group` represents a named group in a hierarchy of groups of [Principal]s, for organizational + * and authorization purposes. + */ +const Group + extends Entity { + + // ----- construction -------------------------------------------------------------------------- + + /** + * Construct a `Group`, which represents a named user. + * + * @param groupId the identity of the [Group] + * @param name a human-readable name or description of the `Group` + * @param permissions explicit permissions (including revocations) for this `Group`, in + * order of precedence + * @param validFrom the point in time before which the `Group` does not exist; defaults to + * the current time + * @param validUntil the point in time after which the `Group` is expired + * @param status the explicit `Suspended` or `Revoked` status, or `Null` + * @param groupIds the list of [Group] ids that this `Group` belongs to + */ + construct( + Int groupId, + String name, + Permission[] permissions = [], + Time? validFrom = Null, + Time? validUntil = Null, + Status? status = Null, + Int[] groupIds = [], + ) { + construct Entity(groupId, name, permissions, validFrom, validUntil, status, groupIds); + } + + assert() { + assert:arg !name.empty; + } + + /** + * Create a new `Group` that is a clone of this `Group`, but with the specific changes + * specified. + * + * @param subjectId the new `subjectId` value, or `Null` to leave unchanged + * @param name the new `name` value, or `Null` to leave unchanged + * @param permissions the new permissions to use, or `Null` to leave unchanged + * @param validFrom the new `validFrom` value to use, or `Null` to leave unchanged + * @param validUntil the new `validUntil` value to use, or `Null` to leave unchanged + * @param status the new `status` value to use, or `Null` to leave unchanged; the only + * legal [Status] values to pass are `Active`, `Suspended`, and + * `Revoked`; passing `Active` will result in the [status] of `Null` + * @param credentials the new credentials to use, or `Null` to leave unchanged + * @param groupIds the new `groupIds` value, or `Null` to leave unchanged + * @param groupId the new `groupId` value, or `Null` to leave unchanged; this must be the + * same value as the subjectId value if both are non-`Null` + * + * @return the new `Group` copied from this `Group` but with the specified changes + */ + @Override + Group with( + Int? subjectId = Null, + String? name = Null, + Permission[]? permissions = Null, + Time? validFrom = Null, + Time? validUntil = Null, + Status? status = Null, + Credential[]? credentials = Null, + Int[]? groupIds = Null, + Int? groupId = Null, + ) { + assert:arg subjectId? == groupId?; + assert:arg credentials?.empty; + return new Group( + groupId = groupId ?: subjectId ?: this.groupId, + name = name ?: this.name, + permissions = permissions ?: this.permissions, + validFrom = validFrom ?: this.validFrom, + validUntil = validUntil ?: this.validUntil, + status = status ?: this.status, + groupIds = groupIds ?: this.groupIds, + ); + } + + // ----- properties ---------------------------------------------------------------------------- + + /** + * The identity of the `Group`. + */ + @RO Int groupId.get() = entityId; + + @Override + @RO Credential[] credentials.get() = []; + + // ----- API ----------------------------------------------------------------------------------- + + /** + * Check for any infinite loops in the group hierarchy, starting from this group. + * + * @param realm the [Realm] to obtain `Group`s from + * + * @return True iff an infinite loop is detected + * @return (conditional) the `Group` id to blame for the infinite loop (which may not be this + * `Group`'s id) + */ + conditional Int circularDependency(Realm realm) { + return groupIds.empty ? False : detectLoop(realm, new HashMap()); + } + + // ----- internal ------------------------------------------------------------------------------ + + /** + * Detect an infinite loop in the group hierarchy, starting from this group. + * + * @param realm the [Realm] to obtain `Group`s from + * @param inProgress contains the `Group`s currently being visited (value==`True`) and the + * `Group`s already verified to be non-looping (value==`False`) + * + * @return True iff an infinite loop is detected + * @return (conditional) the `Group` id to blame for the infinite loop + */ + private conditional Int detectLoop(Realm realm, HashMap inProgress) { + // can't have a loop if this group is not part of any other group + if (groupIds.empty) { + return False; + } + + // add this group to the inProgress list + inProgress.put(groupId, True); + + for (Int parentId : groupIds) { + // check if we've already tested this group + if (Boolean looping := inProgress.get(parentId)) { + if (looping) { + return True, parentId; + } + // else it was already checked and verified to be non-looping + } else { + // the parent group has not yet been checked + if ( Group parent := realm.readGroup(parentId), + Int blameId := parent.detectLoop(realm, inProgress)) { + return True, blameId; + } + } + } + + // cache the result of checking this group + inProgress.put(groupId, False); + + return False; + } +} diff --git a/lib_sec/src/main/x/sec/KeyCredential.x b/lib_sec/src/main/x/sec/KeyCredential.x new file mode 100644 index 0000000000..b16e539fd0 --- /dev/null +++ b/lib_sec/src/main/x/sec/KeyCredential.x @@ -0,0 +1,56 @@ +/** + * A `KeyCredential` represents a secret key, such as an "API key". + */ +const KeyCredential(String key) + extends Credential(Scheme) { + + /** + * "ak" == API Key + */ + static String Scheme = "ak"; + + /** + * Internal constructor for [with] method and subclasses. + */ + protected construct( + String scheme, + Time? validFrom, + Time? validUntil, + Status? status, + String key, + ) { + construct Credential(scheme, validFrom, validUntil, status); + this.key = key; + } + + /** + * Create a copy of this `KeyCredential`, but with specific attributes modified. + * + * @param scheme the new `scheme` name, or pass `Null` to leave unchanged + * @param validFrom the new `validFrom` value to use, or `Null` to leave unchanged + * @param validUntil the new `validUntil` value to use, or `Null` to leave unchanged + * @param status the new `status` value to use, or `Null` to leave unchanged; the only + * legal [Status] values to pass are `Active`, `Suspended`, and `Revoked`; + * passing `Active` will result in the [status] of `Null` + * @param key the new [key] value, or pass `Null` to leave unchanged + */ + @Override + KeyCredential with( + String? scheme = Null, + Time? validFrom = Null, + Time? validUntil = Null, + Status? status = Null, + String? key = Null, + ) { + return new KeyCredential( + scheme = scheme ?: this.scheme, + validFrom = validFrom ?: this.validFrom, + validUntil = validUntil ?: this.validUntil, + status = status ?: this.status, + key = key ?: this.key, + ); + } + + @Override + String[] locators.get() = [key]; +} diff --git a/lib_sec/src/main/x/sec/NonceManager.x b/lib_sec/src/main/x/sec/NonceManager.x new file mode 100644 index 0000000000..a9792c06c6 --- /dev/null +++ b/lib_sec/src/main/x/sec/NonceManager.x @@ -0,0 +1,146 @@ +/** + * A `NonceManager` generates and validates nonces, and supports an automatic expiry. + */ +service NonceManager(Duration? expiry = Null) { + + // ----- properties ---------------------------------------------------------------------------- + + private static @Inject Clock clock; + + private @Inject Random rnd; + + /** + * The duration that a nonce is valid from the point in time that it was generated. + */ + public/private @Final Duration? expiry; + + /** + * Nodes stored by nonce value. + */ + private HashMap nodes = new HashMap(); + + /** + * Head of linked list of nodes, iff nonces expire. + */ + private Node? oldest; + + /** + * Tail of linked list of nodes, iff nonces expire. + */ + private Node? newest; + + /** + * Generator count. + */ + private Int count; + + // ----- API ----------------------------------------------------------------------------------- + + /** + * Generate a nonce value. + * + * @return the nonce value + */ + Int generate() { + // generate a new nonce value + Int nonce; + do { + // the value is a random 64-bit signed int, but we disallow negative values + nonce = rnd.int(MaxValue); + } while (nodes.contains(nonce)); + + // periodically clean up any expired nonces + if (++count & 0x3FF == 0) { + clean(); + } + + Node node = new Node(nonce); + nodes[nonce] = node; + if (expiry != Null) { + node.prev = newest; + newest?.next = node; + newest = node; + oldest ?:= node; + } + + return nonce; + } + + /** + * Validate a previously generated nonce value. + * + * @param nonce the nonce value + * + * @return `True` iff the nonce value was generated by this `NonceManager`, has not already been + * validated, and has not already expired + */ + Boolean validate(Int nonce) { + // get rid of any expired nonces (which makes validation simpler) + clean(); + + if (Node node := nodes.get(nonce), !node.dead) { + kill(node); + return True; + } + + return False; + } + + /** + * Remove (invalidate) all previously generated nonce values. + */ + void reset() { + nodes.clear(); + oldest = Null; + newest = Null; + } + + // ----- internal ------------------------------------------------------------------------------ + + private static class Node(Int nonce) { + Node? next = Null; + Node? prev = Null; + Boolean dead = False; + Time created = clock.now; + } + + private void clean() { + Node? node = oldest; + if (node != Null) { + Time cutoff = clock.now - expiry ?: assert; + while (node != Null && node.created < cutoff) { + if (!node.dead) { + // we could use kill() here, but it does an awful lot of unnecessary + // house-cleaning considering we're throwing a bunch of nodes away + nodes.remove(node.nonce); + node.dead = True; + } + node = node.next; + } + + // this is the deferred house-cleaning from above + oldest = node; + if (node == Null) { + newest = Null; + assert nodes.empty; + } else { + node.prev = Null; + } + } + } + + private void kill(Node node) { + if (!node.dead) { + node.dead = True; + nodes.remove(node.nonce); + if (&oldest == &node) { + oldest = node.next; + } + if (&newest == &node) { + newest = node.prev; + } + node.prev?.next = node.next; + node.next?.prev = node.prev; + } + } +} \ No newline at end of file diff --git a/lib_sec/src/main/x/sec/Permission.x b/lib_sec/src/main/x/sec/Permission.x new file mode 100644 index 0000000000..232dae0215 --- /dev/null +++ b/lib_sec/src/main/x/sec/Permission.x @@ -0,0 +1,101 @@ +/** + * A `Permission` represents a targeted action that can be allowed (permitted) or disallowed + * (revoked). + * + * One literal value "*" is reserved for both the action and target strings; it is a wild-card, and + * as such matches any action or target, respectively. Additionally, a permission action and/or + * target may end with the character '*', which when used in a [covers] test will match any number + * of characters. + * + * The lexical rules for actions and targets: + * * Must not be an empty string; + * * Must not begin or end with white space; + * * An action may not begin with an exclamation mark; + * * An action may not contain a colon. + * Otherwise, no lexical or semantic definition is implied. + * + * The String format of a Permission is the action, a colon (':'), and the target; for example, with + * the action "GET" and the target "/items/42", the permission string format is: `GET:/items/42`. + * A "revoked" permission is prefixed with `!` (i.e. "not"). + * + * @param target the target specified by the permission + * @param action the action specified by the permission + * @param revoke (optional) indicates that a permission is explicitly revoked on a given [Entity]; + * in other words, if `True` then the `Permission` object is an "anti-permission" + */ +const Permission(String target, String action, Boolean revoke = False) + implements Destringable { + + // ----- construction -------------------------------------------------------------------------- + + @Override + construct(String text) { + Int start = 0; + if (text.startsWith('!')) { + revoke = True; + start = 1; + } + + assert:arg Int colon := text.indexOf(':'); + construct Permission(text[start.. findPrincipals(function Boolean(Principal) match); + + /** + * Attempt to locate a [Principal] using a scheme-specific locator `String`. + * + * It is assumed that when a `Principal` is stored or modified, its active Credentials + * automatically have their locator strings registered under their scheme name, to support + * subsequent fast lookup using that information. + * + * @param scheme the scheme name; see [Credential.scheme] + * @param locator a locator `String`; see [Credential.locators] + * + * @return `True` if the scheme+locator identifies a [Principal] in the `Realm` + * @return (conditional) the [Principal] for the specified scheme+locator + */ + conditional Principal findPrincipal(String scheme, String locator) { + Principal[] principals = findPrincipals(p -> p.credentials.any( + c -> c.scheme == scheme && c.locators.contains(locator))).toArray(); + switch (principals.size) { + case 0: + return False; + case 1: + return True, principals[0]; + default: + Principal bestPrincipal = principals[0]; + Int bestScore = 0; + for (Principal principal : principals) { + Int score = 0; + if (principal.calcStatus(this) == Active) { + score += 2; + } + + for (Credential credential : principal.credentials) { + if (credential.scheme == scheme && credential.active + && credential.locators.contains(locator)) { + score += 1; + break; + } + } + + if (score == 3) { + return True, principal; + } + + if (score > bestScore) { + bestPrincipal = principal; + bestScore = score; + } + } + return True, bestPrincipal; + } + } + + /** + * Create a new [Principal] in the `Realm`. + * + * @param principal the [Principal] data to use to create a new `Principal` in the `Realm`; the + * [Principal.principalId] of this value is ignored + * + * @return the newly created [Principal], with an assigned `principalId` + * + * @throws ReadOnly if the `Realm` is [readOnly] + * @throws DuplicateCredential if the [Principal] has a non-unique [Credential] locator + * @throws InvalidName if the [Principal] name violates the `Realm`'s naming rules + * @throws DuplicateName if the [Principal] name is required to be unique, and is not + * @throws GroupLoop if a loop is detected in the [Group] hierarchy + * @throws MissingGroup if a [Group] identity is referenced, but does not exist + */ + Principal createPrincipal(Principal principal); + + /** + * Attempt to locate a [Principal] using its identity. + * + * @param id the [Principal] identity + * + * @return `True` if a [Principal] with the specified identity exists in the `Realm` + * @return (conditional) the [Principal] for the specified identity + */ + conditional Principal readPrincipal(Int id); + + /** + * Store the provided [Principal]. + * + * @param principal the [Principal] to store + * + * @return the [Principal] as it now exists in the `Realm`; in theory the returned `Principal` + * could differ from the passed in `Principal` + * + * @throws ReadOnly if the `Realm` is [readOnly] + * @throws DuplicateCredential if the [Principal] has a non-unique [Credential] locator + * @throws InvalidName if the [Principal] name violates the `Realm`'s naming rules + * @throws DuplicateName if the [Principal] name is required to be unique, and is not + * @throws GroupLoop if a loop is detected in the [Group] hierarchy + * @throws MissingGroup if a [Group] identity is referenced, but does not exist + * @throws MissingPrincipal if the [Principal] to update does not exist in the `Realm` + */ + Principal updatePrincipal(Principal principal); + + /** + * Delete the specified [Principal] from the `Realm`. The Realm is responsible for deleting any + * [Entitlement]s related to the `Principal`. This operation should be used with care, as it can + * (in theory) create dangling references if any other `Subject`s or other data structures + * reference the specified `Principal`; it is generally preferred to _revoke_ `Principal`s, and + * leave them in the `Realm` as historical records. + * + * @param principal the [Principal] (or identity thereof) to delete + * + * @return `True` if the [Principal] existed and has been deleted; `False` if the specified + * `Principal` did not exist in the `Realm` + * + * @throws ReadOnly if the `Realm` is [readOnly] + */ + Boolean deletePrincipal(Int|Principal principal); + + // ----- operations: Groups ---------------------------------------------------------------- + + /** + * Provide an `Iterator` over all [Group] objects in the `Realm` that match the specified + * filter. + * + * A caller that does not always exhaust the `Iterator` _should_ call [close()](Iterator.close) + * (or use a `using` statement) to ensure that the `Iterator`'s resources are released. + * + * @param match a function that evaluates each [Group] for inclusion, and returns `True` + * for each `Group` to include + * + * @return an [Iterator] of matching [Group] objects + */ + Iterator findGroups(function Boolean(Group) match); + + /** + * Create a new [Group] in the `Realm`. + * + * @param group the [Group] data to use to create a new `Group` in the `Realm`; the + * [Group.groupId] of this value is ignored + * + * @return the newly created [Group], with an assigned `groupId` + * + * @throws ReadOnly if the `Realm` is [readOnly] + * @throws DuplicateCredential if the [Group] has a non-unique [Credential] locator + * @throws InvalidName if the [Group] name violates the `Realm`'s naming rules + * @throws DuplicateName if the [Group] name is not unique + * @throws GroupLoop if a loop is detected in the [Group] hierarchy + * @throws MissingSubject if a [Subject] identity is referenced, but does not exist + */ + Group createGroup(Group group); + + /** + * Attempt to locate a [Group] using its identity. + * + * @param id the [Group] identity + * + * @return `True` if a [Group] with the specified identity exists in the `Realm` + * @return (conditional) the [Group] for the specified identity + */ + conditional Group readGroup(Int id); + + /** + * Store the provided [Group]. + * + * @param group the [Group] to store + * + * @return the [Group] as it now exists in the `Realm`; in theory the returned `Group` + * could differ from the passed in `Group` + * + * @throws ReadOnly if the `Realm` is [readOnly] + * @throws DuplicateCredential if the [Group] has a non-unique [Credential] locator + * @throws InvalidName if the [Group] name violates the `Realm`'s naming rules + * @throws DuplicateName if the [Group] name is required to be unique, and is not + * @throws GroupLoop if a loop is detected in the [Group] hierarchy + * @throws MissingSubject if a [Subject] identity is referenced, but does not exist, or if + * the passed [Group] to update does not exist + */ + Group updateGroup(Group group); + + /** + * Delete the specified [Group] from the `Realm`. This operation should be used with care, + * as it can (in theory) create dangling references if any other `Subject`s or other data + * structures reference the specified `Group`; it is generally preferred to _revoke_ + * `Group`s, and leave them in the `Realm` as historical records. + * + * @param group the [Group] (or identity thereof) to delete + * + * @return `True` if the [Group] existed and has been deleted; `False` if the specified + * `Group` did not exist in the `Realm` + * + * @throws ReadOnly if the `Realm` is [readOnly] + * @throws MissingSubject if the [Group] cannot be deleted because it is still referenced + * by some other [Subject] in the `Realm`; a `Realm` implementation may + * choose to delete all references to the `Group` instead of throwing + */ + Boolean deleteGroup(Int|Group group); + + // ----- operations: Entitlements -------------------------------------------------------------- + + /** + * Provide an `Iterator` over all [Entitlement] objects in the `Realm` that match the specified + * filter. + * + * A caller that does not always exhaust the `Iterator` _should_ call [close()](Iterator.close) + * (or use a `using` statement) to ensure that the `Iterator`'s resources are released. + * + * @param match a function that evaluates each [Entitlement] for inclusion, and returns `True` + * for each `Entitlement` to include + * + * @return an [Iterator] of matching [Entitlement] objects + */ + Iterator findEntitlements(function Boolean(Entitlement) match); + + /** + * Attempt to locate a [Entitlement] using a scheme-specific locator `String`. + * + * It is assumed that when a `Entitlement` is stored or modified, its active Credentials + * automatically have their locator strings registered under their scheme name, to support + * subsequent fast lookup using that information. + * + * @param scheme the scheme name; see [Credential.scheme] + * @param locator a locator `String`; see [Credential.locators] + * + * @return `True` if the scheme+locator identifies a [Entitlement] in the `Realm` + * @return (conditional) the [Entitlement] for the specified scheme+locator + */ + conditional Entitlement findEntitlement(String scheme, String locator) { + Entitlement[] entitlements = findEntitlements(p -> p.credentials.any( + c -> c.scheme == scheme && c.locators.contains(locator))).toArray(); + switch (entitlements.size) { + case 0: + return False; + case 1: + return True, entitlements[0]; + default: + Entitlement bestEntitlement = entitlements[0]; + Int bestScore = 0; + for (Entitlement entitlement : entitlements) { + Int score = 0; + if (entitlement.calcStatus(this) == Active) { + score += 2; + } + + for (Credential credential : entitlement.credentials) { + if (credential.scheme == scheme && credential.active + && credential.locators.contains(locator)) { + score += 1; + break; + } + } + + if (score == 3) { + return True, entitlement; + } + + if (score > bestScore) { + bestEntitlement = entitlement; + bestScore = score; + } + } + return True, bestEntitlement; + } + } + + /** + * Create a new [Entitlement] in the `Realm`. + * + * @param entitlement the [Entitlement] data to use to create a new `Entitlement` in the `Realm`; the + * [Entitlement.entitlementId] of this value is ignored + * + * @return the newly created [Entitlement], with an assigned `entitlementId` + * + * @throws ReadOnly if the `Realm` is [readOnly] + * @throws DuplicateCredential if the [Entitlement] has a non-unique [Credential] locator + * @throws InvalidName if the [Entitlement] name violates the `Realm`'s naming rules + * @throws DuplicateName if the [Entitlement] name is required to be unique, and is not + * @throws GroupLoop if a loop is detected in the [Group] hierarchy + * @throws MissingSubject if a [Subject] identity is referenced, but does not exist + */ + Entitlement createEntitlement(Entitlement entitlement); + + /** + * Attempt to locate a [Entitlement] using its identity. + * + * @param id the [Entitlement] identity + * + * @return `True` if a [Entitlement] with the specified identity exists in the `Realm` + * @return (conditional) the [Entitlement] for the specified identity + */ + conditional Entitlement readEntitlement(Int id); + + /** + * Store the provided [Entitlement]. + * + * @param entitlement the [Entitlement] to store + * + * @return the [Entitlement] as it now exists in the `Realm`; in theory the returned `Entitlement` + * could differ from the passed in `Entitlement` + * + * @throws ReadOnly if the `Realm` is [readOnly] + * @throws DuplicateCredential if the [Entitlement] has a non-unique [Credential] locator + * @throws InvalidName if the [Entitlement] name violates the `Realm`'s naming rules + * @throws DuplicateName if the [Entitlement] name is required to be unique, and is not + * @throws GroupLoop if a loop is detected in the [Group] hierarchy + * @throws MissingSubject if a [Subject] identity is referenced, but does not exist, or if + * the passed [Entitlement] to update does not exist + */ + Entitlement updateEntitlement(Entitlement entitlement); + + /** + * Delete the specified [Entitlement] from the `Realm`. This operation should be used with care, + * as it can (in theory) create dangling references if any other `Subject`s or other data + * structures reference the specified `Entitlement`; it is generally preferred to _revoke_ + * `Entitlement`s, and leave them in the `Realm` as historical records. + * + * @param entitlement the [Entitlement] (or identity thereof) to delete + * + * @return `True` if the [Entitlement] existed and has been deleted; `False` if the specified + * `Entitlement` did not exist in the `Realm` + * + * @throws ReadOnly if the `Realm` is [readOnly] + * @throws MissingSubject if the [Entitlement] cannot be deleted because it is still referenced + * by some other [Subject] in the `Realm` + */ + Boolean deleteEntitlement(Int|Entitlement entitlement); +} \ No newline at end of file diff --git a/lib_sec/src/main/x/sec/Subject.x b/lib_sec/src/main/x/sec/Subject.x new file mode 100644 index 0000000000..18e3bd8807 --- /dev/null +++ b/lib_sec/src/main/x/sec/Subject.x @@ -0,0 +1,293 @@ +import ecstasy.collections.VirtualHasher; + +import ecstasy.maps.HasherMap; + +/** + * A `Subject` represents a source of security authorization capability. The four primary use cases + * are: (1) Security Principals, (2) Security Groups, (3) Entitlements, and (4) API keys. + */ +@Abstract const Subject { + + // ----- construction -------------------------------------------------------------------------- + + /** + * Construct an `Subject` from an identity, a set of permissions, and a lifetime. + * + * @param subjectId the identity of the `Subject` + * @param name a human-readable name or description for the `Subject` + * @param permissions explicit permissions (including revocations) for this `Subject`, in order + * of precedence + * @param validFrom the point in time before which the `Subject` does not exist; defaults to + * the current time + * @param validUntil the point in time after which the `Subject` is expired + * @param status the explicit `Suspended` or `Revoked` status, or `Null` + */ + construct( + Int subjectId, + String name, + Permission[] permissions = [], + Time? validFrom = Null, + Time? validUntil = Null, + Status? status = Null, + ) { + this.subjectId = subjectId; + this.name = name; + this.permissions = permissions; + this.validFrom = validFrom ?: {@Inject Clock clock; return clock.now;}; + this.validUntil = validUntil; + this.status = status == Active ? Null : status; + } + + assert() { + assert status == Null || status == Suspended || status == Revoked; + assert validUntil? >= validFrom; + } + + /** + * Create a new `Subject` that is a clone of this `Subject`, but with the specific changes + * specified. + * + * @param subjectId the new `subjectId` value, or `Null` to leave unchanged + * @param name the new `name` value, or `Null` to leave unchanged + * @param permissions the new permissions to use, or `Null` to leave unchanged + * @param validFrom the new `validFrom` value to use, or `Null` to leave unchanged + * @param validUntil the new `validUntil` value to use, or `Null` to leave unchanged + * @param status the new `status` value to use, or `Null` to leave unchanged; the only + * legal [Status] values to pass are `Active`, `Suspended`, and `Revoked`; + * passing `Active` will result in the [status] of `Null` + * @param credentials the new credentials to use, or `Null` to leave unchanged + * + * @return the new `Subject` copied from this `Subject` but with the specified changes + */ + @Abstract Subject with( + Int? subjectId = Null, + String? name = Null, + Permission[]? permissions = Null, + Time? validFrom = Null, + Time? validUntil = Null, + Status? status = Null, + Credential[]? credentials = Null, + ); + + // ----- properties ---------------------------------------------------------------------------- + + /** + * The identity of the `Subject`. For persistence purposes, this is considered the primary key. + */ + Int subjectId; + + /** + * The human-readable name or description provided for the `Subject`, which is useful for + * auditing, debugging, etc. This value is not used as a "login name" or any other form of + * credential. Every [Group] name must be non-blank and unique within its `Realm`. + */ + String name; + + /** + * Explicit permissions (including revocations) for this `Subject`, in order of precedence. + */ + Permission[] permissions; + + /** + * The point in time before which the `Subject` is considered to not yet exist and thus is + * not valid. Entities must always have an explicit point before which they are not valid. + */ + Time validFrom; + + /** + * The optional point in time after which the `Subject` is considered to have expired and is no + * longer valid. + */ + Time? validUntil; + + /** + * Null iff the `Subject` has not been explicitly suspended or revoked; otherwise this indicates + * the explicit `Suspended` or `Revoked` status. + */ + Status? status; + + /** + * The [Credential]s that can be used to authenticate the `Subject`. [Group]s never have + * `Credential`s. + */ + @RO Credential[] credentials; + + // ----- API ----------------------------------------------------------------------------------- + + /** + * Subject statuses, roughly in order from worst to best: + * + * * `Revoked` - the `Subject` has been explicitly revoked + * * `Suspended` - the `Subject` is explicitly suspended + * * `NotYet` - the [validFrom] time for the `Subject` is in the future + * * `Expired` - the [validUntil] time for the `Subject` is in the past + * * `Active` - the `Subject` is valid and active + */ + enum Status {Revoked, Suspended, NotYet, Expired, Active} + + /** + * Determine if `this` Subject is valid at some specific time. + * + * Note that this does not incorporate any revocations that occurred after this Subject + * object was instantiated. + * + * @param realm the [Realm] that provided this `Subject`; passing `Null` will determine the + * result solely from this `Subject`, ignoring the status of any entities that this + * `Subject` delegates to + * @param at (optional) the time at which to determine if the `Subject` is valid; if + * nothing is specified, then the current time is used + * + * @return True iff `this` Subject is valid at the specified time + */ + Status calcStatus(Realm? realm, Time? at = Null) { + Status? status = this.status; + if (status == Suspended || status == Revoked) { + return status; + } + + if (at == Null) { + @Inject Clock clock; + at = clock.now; + } + + if (at < validFrom) { + return NotYet; + } + + if (at > validUntil?) { + return Expired; + } + + return Active; + } + + /** + * Determine if the specified [Permission] is allowed for this `Subject`. + * + * @param realm the [Realm] that provided this `Subject` + * @param permission the required Permission + * @param at (optional) the time at which the permission evaluation should apply to; + * defaults to the current time + * + * @return True iff the specified `Permission` is allowed at the specified time + */ + Boolean permitted(Realm realm, Permission permission, Time? at = Null) { + if (calcStatus(realm, at) != Active) { + return False; + } + + // this base `Subject` implementation is not aware of any other entities to delegate to; + // sub-classes with entities to delegate to must override this method + return covers(permission) == True; + } + + /** + * Determine if `this` Subject covers `that` Permission. + * + * @param that a required Permission + * + * @return `True` if this `Subject`'s permissions explicitly allow the specified permission; + * `False` if this `Subject`'s permissions explicitly **dis**allow the specified + * permission; or `Null` if this `Subject`'s permissions do not account for the specified + * permission + */ + Boolean? covers(Permission that) { + for (Permission permission : this.permissions) { + if (permission.covers(that)) { + return !permission.revoke; + } + } + return Null; + } + + /** + * Suspend this Subject. + * + * @return a copy of this Subject, but with a [status] of [Suspended] + */ + Subject suspend() { + return status == Suspended ? this : this.with(status=Suspended); + } + + /** + * If this Subject is suspended, then undo the suspension. + * + * @return a copy of this Subject, but without the [status] of [Suspended] + */ + Subject unsuspend() { + return status == Suspended ? this.with(status=Active) : this; + } + + /** + * Revoke this Subject. + * + * @return a copy of this Subject, but with a [status] of [Revoked] + */ + Subject revoke() { + return status == Revoked ? this : this.with(status=Revoked); + } + + /** + * Revoke a [Credential] from this `Subject`. + * + * @param credential the [Credential] to revoke + * + * @return `True` iff the specified [Credential] was present on this `Subject`, and was revoked + * @return (conditional) the resulting `Subject` + */ + conditional Subject revokeCredential(Credential credential) { + // replace any instances of that credential with a revoked credential + Subject subject = this; + Boolean modified = False; + String[] locators = credential.locators; + Each: for (Credential each : credentials) { + if (each.status != Revoked + && each.scheme == credential.scheme + && each.locators == locators) { + subject = subject.with(credentials=subject.credentials.replace(Each.count, credential.revoke())); + modified = True; + } + } + return modified, subject; + } + + /** + * Determine what [Credential]s have been added and removed comparing an older version of this + * `Subject` to this `Subject`. + * + * @param that an older version of this `Subject` + * + * @return added an array of added [Credential]s + * @return removed an array of removed [Credential]s + */ + (Credential[] added, Credential[] removed) credentialDiff(Subject that) { + Credential[] newCreds = this.credentials; + Credential[] oldCreds = that.credentials; + if (newCreds.empty || oldCreds.empty) { + return newCreds, oldCreds; + } + + Credential[] added = new Credential[]; + Credential[] removed = new Credential[]; + + static VirtualHasher hasher = new VirtualHasher(); + HasherMap overlap = new HasherMap(hasher, oldCreds.size); + for (Credential credential : oldCreds) { + overlap.put(credential, False); + } + for (Credential credential : newCreds) { + if (overlap.contains(credential)) { + overlap.put(credential, True); + } else { + added += credential; + } + } + for (Credential credential : oldCreds) { + if (overlap[credential] == True) { + removed += credential; + } + } + + return added, removed; + } +} diff --git a/lib_web/build.gradle.kts b/lib_web/build.gradle.kts index 50e839aa68..39401594dd 100644 --- a/lib_web/build.gradle.kts +++ b/lib_web/build.gradle.kts @@ -12,5 +12,6 @@ dependencies { xtcModule(libs.xdk.crypto) xtcModule(libs.xdk.json) xtcModule(libs.xdk.net) + xtcModule(libs.xdk.sec) } diff --git a/lib_web/src/main/x/web.x b/lib_web/src/main/x/web.x index 8135c93dc2..f740ef3b66 100644 --- a/lib_web/src/main/x/web.x +++ b/lib_web/src/main/x/web.x @@ -10,6 +10,7 @@ module web.xtclang.org { package crypto import crypto.xtclang.org; package json import json.xtclang.org; package net import net.xtclang.org; + package sec import sec.xtclang.org; import ecstasy.reflect.Parameter; @@ -19,6 +20,9 @@ module web.xtclang.org { typedef net.Uri as Uri; + import sec.Entitlement; + import sec.Principal; + import sec.Realm; // ----- request/response support -------------------------------------------------------------- @@ -26,13 +30,13 @@ module web.xtclang.org { * A function that can process an incoming request corresponding to a `Route` is called an * `Endpoint Handler`. */ - typedef function ResponseOut(Session, RequestIn) as Handler; + typedef function ResponseOut(RequestIn) as Handler; /** * A function that is called when an exception occurs (or an internal error represented by a * `String` description or an HttpsStatus code) is called an `ErrorHandler`. */ - typedef function ResponseOut(Session, RequestIn, Exception|String|HttpStatus) as ErrorHandler; + typedef function ResponseOut(RequestIn, Exception|String|HttpStatus) as ErrorHandler; /** * `TrustLevel` is an enumeration that approximates a point-in-time level of trust associated @@ -41,7 +45,6 @@ module web.xtclang.org { */ enum TrustLevel {None, Normal, High, Highest} - // ----- WebApp mixins ------------------------------------------------------------------------- /** @@ -84,6 +87,43 @@ module web.xtclang.org { mixin Consumes(MediaType|MediaType[] consumes) into Class | Class | Endpoint; + /** + * This annotation, `@SessionRequired`, is used to mark a web service call -- or any containing + * class thereof, up to the level of the web module itself -- as requiring a [Session]. + * + * A method (any `Endpoint`, such as `@Get` or `@Post`) that handles a web service call will + * require a [Session] iff: + * + * * the web service method is annotated with `SessionRequired`, or + * * the web service method is not annotated with `SessionOptional`, and the parent class + * requires a [Session]. + * + * A parent class will require a [Session] iff: + * + * * the class is annotated with `SessionRequired`, or + * * the class is not annotated with `SessionOptional`, and there is a containing class, and the + * containing class requires a [Session], or + * * the class is a module (and thus has no containing class), and the host for the module has + * been configured to require a [Session] by default. + * + * The purpose of this design is to allow the use of annotations for specifying the requirement + * for a session, but only requiring those annotations within the class hierarchy at the + * few points where a change occurs from "requiring a session" to "not requiring a session", or + * vice versa. + */ + mixin SessionRequired(Boolean autoRedirect = False) + into Class | Class | Endpoint; + + /** + * This annotation, `@SessionOptional`, is used to mark a web service call -- or any containing + * class thereof, up to the level of the web module itself -- as **not** requiring a [Session]. + * + * For more information, see the detailed description of the [@SessionRequired](SessionRequired) + * annotation. + */ + mixin SessionOptional + into Class | Class | Endpoint; + /** * This annotation, `@LoginRequired`, is used to mark a web service call -- or any containing * class thereof, up to the level of the web module itself -- as requiring authentication. @@ -111,8 +151,8 @@ module web.xtclang.org { * @param security [TrustLevel] of security that is required by the annotated operation or * web service */ - mixin LoginRequired(TrustLevel security=Normal) - extends HttpsRequired; + mixin LoginRequired(TrustLevel security=Normal, Boolean autoRedirect=False) + extends HttpsRequired(autoRedirect); /** * This annotation, `@LoginOptional`, is used to mark a web service endpoint, or the web service @@ -127,19 +167,30 @@ module web.xtclang.org { /** * This annotation, `@Restrict`, is used to mark a web service endpoint, or the web service - * that contains it, up to the level of the web module itself -- as requiring a user to be - * logged in, and for that user to meet the role requirements specified by the annotation. + * that contains it, up to the level of the web module itself -- as requiring authorization by + * meeting the permission requirements specified by the annotation. * - * Example: + * The annotation can imply or specify a required permission, or it can specify a method to be + * used for authorization; for example: * - * @Restrict("admin") - * void shutdown() {...} + * @Restrict // implied @Restrict("GET:/accounts/{id}") + * @Get("/accounts/{id}") + * AccountInfo getAccount(Int id) {...} * - * @Restrict(["admin", "manager"]) - * conditional User createUser(@UriParam String id) {...} + * @Restrict("create:/accounts") + * @LoginRequired + * @Put + * HttpStatus openBankAccount(@BodyParam AccountInfo info) {...} + * + * @Restrict(accountAccess) // specifies the method (below) + * @Post("/accounts/{from}/transfer{?to,amount}") + * ResponseOut transferFunds(Int from, Int to, Dec amount) {...} + * + * Boolean accountAccess(RequestIn request) {...} */ - mixin Restrict(String|String[] subject, TrustLevel security=Normal) - extends LoginRequired(security); + mixin Restrict(String?|Method, > permission = Null, + Boolean autoRedirect = False) + extends LoginRequired(autoRedirect=autoRedirect); /** * This annotation, `@HttpsRequired`, is used to mark a web service call -- or any containing @@ -162,10 +213,13 @@ module web.xtclang.org { * been configured to require TLS by default. * * The purpose of this design is to allow the use of annotations for specifying the requirement - * for TLS, but only requiring those annotations within the class hierarchy at the - * few points where a change occurs from "requiring TLS" to "not requiring TLS", or vice versa. + * for TLS, but only requiring those annotations within the class hierarchy at the few points + * where a change occurs from "requiring TLS" to "not requiring TLS", or vice versa. + * + * Regarding the default of the `autoRedirect` of `False`, see the article: + * [Your API Shouldn't Redirect HTTP to HTTPS](https://jviide.iki.fi/http-redirects) */ - mixin HttpsRequired + mixin HttpsRequired(Boolean autoRedirect = False) into Class | Class | Endpoint; /** @@ -199,7 +253,6 @@ module web.xtclang.org { mixin StreamingResponse into Class | Class | Endpoint; - // ----- handler method annotations ------------------------------------------------------------ /** @@ -207,53 +260,77 @@ module web.xtclang.org { * * Example: * - * @Endpoint(GET, "/{id}") + * @Endpoint(GET, "/{id}", v:3) * * @param httpMethod the name of the HTTP method * @param template an optional URI Template describing a path to reach this endpoint + * @param api the [Version] at which this `Endpoint` was introduced, or (for `Endpoints` + * that have been removed) the range of versions that the `Endpoint` _was_ + * part of the API; `Null` indicates that the API is not versioned or that + * this `Endpoint` has been present since the original version of the API */ - mixin Endpoint(HttpMethod httpMethod, String template = "") + mixin Endpoint(HttpMethod httpMethod, String template = "", Version?|Range api = Null) into Method; /** * An HTTP `GET` method. * * @param template an optional URI Template describing a path to reach this endpoint + * @param api the [Version] at which this `Endpoint` was introduced, or (for `Endpoints` + * that have been removed) the range of versions that the `Endpoint` _was_ + * part of the API; `Null` indicates that the API is not versioned or that + * this `Endpoint` has been present since the original version of the API */ - mixin Get(String template = "") - extends Endpoint(GET, template); + mixin Get(String template = "", Version?|Range api = Null) + extends Endpoint(GET, template, api); /** * An HTTP `POST` method. * * @param template an optional URI Template describing a path to reach this endpoint + * @param api the [Version] at which this `Endpoint` was introduced, or (for `Endpoints` + * that have been removed) the range of versions that the `Endpoint` _was_ + * part of the API; `Null` indicates that the API is not versioned or that + * this `Endpoint` has been present since the original version of the API */ - mixin Post(String template = "") - extends Endpoint(POST, template); + mixin Post(String template = "", Version?|Range api = Null) + extends Endpoint(POST, template, api); /** * An HTTP `PATCH` method. * * @param template an optional URI Template describing a path to reach this endpoint + * @param api the [Version] at which this `Endpoint` was introduced, or (for `Endpoints` + * that have been removed) the range of versions that the `Endpoint` _was_ + * part of the API; `Null` indicates that the API is not versioned or that + * this `Endpoint` has been present since the original version of the API */ - mixin Patch(String template = "") - extends Endpoint(PATCH, template); + mixin Patch(String template = "", Version?|Range api = Null) + extends Endpoint(PATCH, template, api); /** * An HTTP `PUT` method. * * @param template an optional URI Template describing a path to reach this endpoint + * @param api the [Version] at which this `Endpoint` was introduced, or (for `Endpoints` + * that have been removed) the range of versions that the `Endpoint` _was_ + * part of the API; `Null` indicates that the API is not versioned or that + * this `Endpoint` has been present since the original version of the API */ - mixin Put(String template = "") - extends Endpoint(PUT, template); + mixin Put(String template = "", Version?|Range api = Null) + extends Endpoint(PUT, template, api); /** * An HTTP `DELETE` method. * * @param template an optional URI Template describing a path to reach this endpoint + * @param api the [Version] at which this `Endpoint` was introduced, or (for `Endpoints` + * that have been removed) the range of versions that the `Endpoint` _was_ + * part of the API; `Null` indicates that the API is not versioned or that + * this `Endpoint` has been present since the original version of the API */ - mixin Delete(String template = "") - extends Endpoint(DELETE, template); + mixin Delete(String template = "", Version?|Range api = Null) + extends Endpoint(DELETE, template, api); /** * Default route on a WebService, if no other route could be found. At the moment, it only @@ -273,10 +350,10 @@ module web.xtclang.org { * Example: * * @Intercept(GET) - * ResponseOut interceptGet(Session session, RequestIn request, Handler handle) {...} + * ResponseOut interceptGet(RequestIn request, Handler handle) {...} */ mixin Intercept(HttpMethod? httpMethod=Null) - into Method, >; + into Method, >; /** * Request observer (possibly asynchronous to the request processing itself) for all requests on @@ -285,15 +362,15 @@ module web.xtclang.org { * Example: * * @Observe - * void logAllRequests(Session session, RequestIn request) {...} + * void logAllRequests(RequestIn request) {...} * * And/or: * * @Observe(DELETE) - * void logDeleteRequests(Session session, RequestIn request) {...} + * void logDeleteRequests(RequestIn request) {...} */ mixin Observe(HttpMethod? httpMethod=Null) - into Method, <>>; + into Method, <>>; /** * Specify a method as handling all errors on (or under) the WebService. @@ -301,11 +378,10 @@ module web.xtclang.org { * Example: * * @OnError - * void handleErrors(Session session, RequestIn request, Exception|String) {...} + * void handleErrors(RequestIn request, Exception|String) {...} */ mixin OnError - into Method, >; - + into Method, >; // ----- parameter annotations ----------------------------------------------------------------- @@ -421,7 +497,6 @@ module web.xtclang.org { mixin BodyParam(String? format = Null) extends ParameterBinding(format=format); - // ----- exceptions ---------------------------------------------------------------------------- /** diff --git a/lib_web/src/main/x/web/Header.x b/lib_web/src/main/x/web/Header.x index 1aaecd5be5..9cde42ad60 100644 --- a/lib_web/src/main/x/web/Header.x +++ b/lib_web/src/main/x/web/Header.x @@ -1,3 +1,4 @@ +import ecstasy.collections.CaseInsensitive; import ecstasy.collections.CollectImmutableArray; /** @@ -41,6 +42,8 @@ interface Header static String Cookie = "Cookie"; static String SetCookie = "Set-Cookie"; + static CollectImmutableArray ToStringArray = CollectImmutableArray.of(String); + /** * `True` if this `Header` is for a [Request]; `False` if it is for a [Response]. */ @@ -66,9 +69,7 @@ interface Header * * @return a list of names of the HTTP headers */ - @RO List names.get() { - return entries.map(e -> e[0], CollectImmutableArray.of(String)); - } + @RO List names.get() = entries.map(e -> e[0], ToStringArray); /** * Obtain the HTTP headers as list of [Entry] objects. @@ -85,25 +86,21 @@ interface Header * header entry may be expanded to multiple values, if that delimiter occurs * within the associated value * - * @return True iff the specified name is found at least once - * @return (conditional) all of the corresponding values from the HTTP header + * @return a List of all of the corresponding values from the HTTP headers */ - Iterator valuesOf(String name, Char? expandDelim=Null) { - import ecstasy.collections.CaseInsensitive; - Iterator iter = entries.iterator().filter(e -> CaseInsensitive.areEqual(e[0], name)) - .map(e -> e[1]); + List valuesOf(String name, Char? expandDelim=Null) { + var result = entries.filter(e -> CaseInsensitive.areEqual(e[0], name)) + .map(e -> e[1]); if (expandDelim != Null) { - iter = iter.flatMap(s -> s.split(expandDelim)); + result = result.flatMap(s -> s.split(expandDelim)); } - return iter.map(s -> s.trim()); + return result.map(s -> s.trim(), ToStringArray); } /** - * Obtain the header value, as-is (not comma-expanded) for the specified case-insensitive header - * name. If the header name occurs multiple times within the HTTP header, then only the first - * instance is returned. + * Obtain the value of the first instance of the header of the specified name. * * @param name the case-insensitive header name * @param expandDelim pass a character delimiter, such as `','`, to indicate that a single @@ -114,13 +111,11 @@ interface Header * @return (conditional) the corresponding value from the HTTP header */ conditional String firstOf(String name, Char? expandDelim=Null) { - return valuesOf(name, expandDelim).next(); + return valuesOf(name, expandDelim).first(); } /** - * Obtain the header value, as-is (not comma-expanded) for the specified case-insensitive header - * name. If the header name occurs multiple times within the HTTP header, then only the last - * instance is returned. + * Obtain the last value of the last instance of the header of the specified name. * * @param name the case-insensitive header name * @param expandDelim pass a character delimiter, such as `','`, to indicate that a single @@ -131,7 +126,7 @@ interface Header * @return (conditional) the corresponding value from the HTTP header */ conditional String lastOf(String name, Char? expandDelim=Null) { - return valuesOf(name, expandDelim).reversed().next(); + return valuesOf(name, expandDelim).last(); } /** @@ -144,7 +139,7 @@ interface Header * @return the corresponding value from the HTTP header or `Null` if not found */ @Op("[]") String? getOrNull(String name) { - if (String value := firstOf(name)) { + if (String value := valuesOf(name).first()) { return value; } return Null; @@ -159,18 +154,7 @@ interface Header */ void removeAll(String name) { checkMutable(); - - List entries = this.entries; - if (!entries.empty) { - val cursor = entries.cursor(); - while (cursor.exists) { - if (cursor.value[0] == name) { - cursor.delete(); - } else { - cursor.advance(); - } - } - } + entries.removeAll(e -> CaseInsensitive.areEqual(e[0], name)); } /** @@ -193,7 +177,6 @@ interface Header */ void add(Entry entry) { checkMutable(); - entries.add(entry); } @@ -206,7 +189,6 @@ interface Header */ void add(String name, String|String[] value) { checkMutable(); - if (value.is(String[])) { value = value.appendTo(new StringBuffer(value.estimateStringLength(",", "", "")), ",", "", "").toString(); } diff --git a/lib_web/src/main/x/web/HttpClient.x b/lib_web/src/main/x/web/HttpClient.x index 4dc5edf9f3..14d7518c59 100644 --- a/lib_web/src/main/x/web/HttpClient.x +++ b/lib_web/src/main/x/web/HttpClient.x @@ -7,6 +7,8 @@ import convert.formats.Base64Format; import ecstasy.collections.CaseInsensitive; +import net.Uri; + import Header.Entry; @@ -138,11 +140,11 @@ const HttpClient break Authorize; } - Map props = challenge.substring(realmIndex).splitMap(); - String realm; - if (!(realm := props.get("realm"))) { - break Authorize; - } + Map props = challenge.substring(realmIndex).splitMap(valueQuote=ch->ch=='\"'); + String realm; + if (!(realm := props.get("realm"))) { + break Authorize; + } realm := realm.unquote(); @@ -205,9 +207,7 @@ const HttpClient { // create cnonce and extract necessary properties (see DigestAuthenticator.parseDigest) import security.DigestAuthenticator; - import security.Realm.Hash; - - import DigestAuthenticator.require; + import security.DigestCredential.Hash; import DigestAuthenticator.toHash; static String toString(Hash hash) { @@ -215,9 +215,9 @@ const HttpClient return DigestAuthenticator.toString(hash); } - if (String algorithm := require(props, "algorithm", Null), - String opaque := require(props, "opaque" , True), - String nonce := require(props, "nonce" , True)) { + if (String algorithm := props.get("algorithm"), + String opaque := props.get("opaque"), + String nonce := props.get("nonce")) { @Inject Algorithms algorithms; @@ -232,10 +232,13 @@ const HttpClient @Inject Random rnd; String cnonce = Base64Format.Instance.encode(rnd.bytes(9)); - String qop = "auth"; - qop := require(props, "qop", True); - if (qop != "auth") { - return False; // TODO implement auth-int + if (String qopList := props.get("qop")) { + String[] qops = qopList.split(',', trim=True); + if (!qops.contains("auth")) { + return False; // TODO implement auth-int + } + } else { + return False; } String ncText = retryCount.toString().rightJustify(8, fill='0'); @@ -266,11 +269,11 @@ const HttpClient Hash hashA1 = toHash($"{toString(pwdHash)}:{nonce}:{cnonce}", hasher); Hash hashA2 = toHash($"{request.method.name}:{request.uri}" , hasher); Hash response = toHash($|{toString(hashA1)}:{nonce}:{ncText}\ - |:{cnonce}:{qop}:{toString(hashA2)} + |:{cnonce}:auth:{toString(hashA2)} , hasher); return True, $|Digest username="{name}", realm="{realm}", uri="{request.uri}", \ |algorithm={algorithm}, nonce="{nonce}", nc={ncText}, cnonce="{cnonce}", \ - |qop={qop}, response="{toString(response)}", opaque="{opaque}" + |qop="auth", response="{toString(response)}", opaque="{opaque}" ; } return False; @@ -352,7 +355,7 @@ const HttpClient @Override MediaType mediaType.get() { - if (String mediaTypeName := header.firstOf("Content-Type"), + if (String mediaTypeName := header.valuesOf("Content-Type").first(), MediaType mediaType := MediaType.of(mediaTypeName)) { return mediaType; } diff --git a/lib_web/src/main/x/web/HttpMessage.x b/lib_web/src/main/x/web/HttpMessage.x index 239deb9600..7181b038ac 100644 --- a/lib_web/src/main/x/web/HttpMessage.x +++ b/lib_web/src/main/x/web/HttpMessage.x @@ -48,5 +48,5 @@ interface HttpMessage /** * @return an `Iterator` of all cookie names in this message */ - Iterator cookieNames(); + List cookieNames(); } \ No newline at end of file diff --git a/lib_web/src/main/x/web/Request.x b/lib_web/src/main/x/web/Request.x index a122b4c1c0..080eae1b2c 100644 --- a/lib_web/src/main/x/web/Request.x +++ b/lib_web/src/main/x/web/Request.x @@ -1,4 +1,5 @@ import ecstasy.collections.CaseInsensitive; +import ecstasy.collections.CollectImmutableArray; import net.UriTemplate; @@ -30,9 +31,7 @@ interface Request /** * Corresponds to the ":scheme" pseudo-header field in HTTP/2. */ - @RO Scheme scheme.get() { - return Scheme.byName.getOrNull(uri.scheme?)? : assert; - } + @RO Scheme scheme.get() = Scheme.byName.getOrNull(uri.scheme?)? : assert; /** * Corresponds to the ":authority" pseudo-header field in HTTP/2. This includes the authority @@ -64,17 +63,22 @@ interface Request return Null; } + @RO String? userAgent.get() = header.firstOf(Header.UserAgent) ?: Null; + /** * The accepted media types. */ @RO AcceptList accepts; + private static CollectImmutableArray> ToTupleArray = + CollectImmutableArray.of(Tuple); + /** * @return an iterator of all cookie names and values in this request */ - Iterator> cookies() { + List> cookies() { return header.valuesOf(Header.Cookie, ';') - .map(kv -> (kv.extract('=', 0, "???").trim(), kv.extract('=', 1).trim())); + .map(kv -> (kv.extract('=', 0, "???").trim(), kv.extract('=', 1).trim()), ToTupleArray); } /** @@ -93,8 +97,8 @@ interface Request } @Override - Iterator cookieNames() { + List cookieNames() { return header.valuesOf(Header.Cookie, ';') - .map(kv -> kv.extract('=', 0, "???").trim()); + .map(kv -> kv.extract('=', 0, "???").trim(), Header.ToStringArray); } } \ No newline at end of file diff --git a/lib_web/src/main/x/web/RequestIn.x b/lib_web/src/main/x/web/RequestIn.x index 9dd9c80201..7b6c3dd844 100644 --- a/lib_web/src/main/x/web/RequestIn.x +++ b/lib_web/src/main/x/web/RequestIn.x @@ -46,13 +46,34 @@ interface RequestIn */ @RO UInt16 serverPort; + /** + * True if the request was received over a trusted Transport Layer Security (TLS) connection, + * such as HTTPS. + */ + @RO Boolean tls; + /** * The HTTP parameters contained with the URI query string. */ @RO Map> queryParams; + /** + * The session associated with this request, if one exists. + */ + @RO Session? session; + + /** + * The UriTemplate that matched this request. + */ + @RO UriTemplate template; + /** * The result of matching a UriTemplate against this request. */ @RO UriTemplate.UriParameters matchResult; + + /** + * The [Endpoint] targeted by this request, if one could be determined; otherwise, `Null`. + */ + @RO Endpoint? endpoint; } \ No newline at end of file diff --git a/lib_web/src/main/x/web/Response.x b/lib_web/src/main/x/web/Response.x index 0aecd9114a..f270f22378 100644 --- a/lib_web/src/main/x/web/Response.x +++ b/lib_web/src/main/x/web/Response.x @@ -14,9 +14,9 @@ interface Response @RO HttpStatus status; @Override - Iterator cookieNames() { + List cookieNames() { return header.valuesOf(Header.SetCookie, ';') - .map(kv -> kv.extract('=', 0, "???").trim()); + .map(kv -> kv.extract('=', 0, "???").trim(), Header.ToStringArray); } /** diff --git a/lib_web/src/main/x/web/Session.x b/lib_web/src/main/x/web/Session.x index 43c355d13c..494296c8e4 100644 --- a/lib_web/src/main/x/web/Session.x +++ b/lib_web/src/main/x/web/Session.x @@ -1,5 +1,6 @@ -import net.IPAddress; +import security.Authenticator.Attempt; +import net.IPAddress; /** * `Session` represents the information that is (i) managed on a server, (ii) on behalf of a person @@ -64,13 +65,13 @@ import net.IPAddress; * When it works as designed, the `Session` corresponds exactly to the person using the application, * i.e. the actual person in front of the screen. There are two related but separate concepts here: * (i) the person (or entity) _using_ the application, via a specific "user agent" (e.g. a browser), - * on a specific device (e.g. a computer); and (ii) the security _subject_ aka [userId] which the + * on a specific device (e.g. a computer); and (ii) the security _subject_ aka [Principal] which the * application authenticates and performs work on behalf of. While these two concepts are heavily * correlated, they actually represent an `m x n` matrix: * * * The person (or entity) is the one that can _consent_ to accepting cookies; - * * The person may log in and out as different userIds, but the previous consent applies across all - * of those logins; + * * The person may log in and out as different Principals, but the previous consent applies across + * all of those logins; * * The person indicates whether the device and user agent is shared or exclusive; * * Settings such as language, locale, time zone, and so on are set by and bound to the person, not * to the authenticated user; @@ -131,10 +132,11 @@ import net.IPAddress; * There are details related to the session that are automatically managed by the server, and are * visible to the application logic running on the server, and there are operations that the session * makes available to the application logic. For example, the application can determine if the user - * represented by the session has been authenticated (the [userId] property), and when that - * authentication occurred (the [lastAuthenticated] property), and the application can explicitly - * [authenticate] or [deauthenticate] a user. There are also notifications that occur on the session - * of significant changes and events; these require the application to provide a session mix-in. + * represented by the session has been authenticated (the [principal] and [entitlements] + * properties), and when that authentication occurred (the [lastAuthenticated] property), and the + * application can explicitly [authenticate] or [deauthenticate] a user. There are also + * notifications that occur on the session of significant changes and events; these require the + * application to provide a session mix-in. * * The use of custom mix-ins allows an application (or framework, etc.) to enhance a session by * adding state and functionality to the session that is created and managed automatically by the @@ -187,6 +189,8 @@ import net.IPAddress; * To obtain the session for use in an [Endpoint], the endpoint method should include a parameter of * type session: * + * TODO CP and/or annotate using ... + * * @Post("/{id}/items") * Item addItem(Session session, @UriParam String id, @BodyParam Item item) {...} * @@ -299,37 +303,42 @@ interface Session CookieConsent cookieConsent; /** - * This is a `String` representation of the authenticated user identity (aka "subject"); - * otherwise `Null`. + * The authenticated user identity, known as a [Principal]. If the user has not been + * authenticated, then the value is `Null`. + * + * It is possible that the `Principal` is obtained from one or more of the [entitlements], iff + * there exists at least one Entitlement with `conferIdentity==True`, and there is no conflict + * about the identity of the `Principal`. + */ + @RO Principal? principal; + + /** + * The authenticated [Entitlement]s for the session. */ - @RO String? userId; + @RO Entitlement[] entitlements; /** - * This is the date/time that the session was last authenticated; otherwise `Null`. + * The date/time that the session was last authenticated; otherwise `Null`. */ @RO Time? lastAuthenticated; /** - * This is the current trust level associated with this session. Authentication will tend to - * set the trust level to its highest setting, and deauthentication to its lowest setting. + * The current trust level associated with this session. Authentication will tend to set the + * trust level to its highest setting, and deauthentication to its lowest setting. * Changes to the [ipAddress] and [userAgent] will tend to degrade the trust level. * - * It is possible for the trust level to be `None`, yet the [userId] have a value. This occurs - * when the residual information about the previously authenticated user is retained (the user - * is not explicitly deauthenticated), but when re-authentication is required to perform any - * [@LoginRequired](LoginRequired) activity. In other words, the application still knows who is - * likely to be using the application, but will force re-authentication for any operation that - * needs to know who is using the application. + * It is possible for the trust level to be `None`, yet the [principal] have a value (or + * [entitlements] to exist). This occurs when the residual information about the previously + * authenticated user is retained (the user is not explicitly deauthenticated), but when + * re-authentication is required to perform any [@LoginRequired](LoginRequired) activity, + * including all [@Restrict](Restrict)-annotated web service methods. In other words, the + * application knows who is likely to be using the application, but will still force + * re-authentication. * * This property may be modified by the application. */ TrustLevel trustLevel; - /** - * The set of roles associated with the user associated with this session. - */ - immutable Set roles; - /** * This is a `String` representation of the _internal_ identity of the `Session` itself. * @@ -348,14 +357,17 @@ interface Session */ @RO String sessionId; - // ----- session control ----------------------------------------------------------------------- /** - * This method allows an application to explicitly configure the authentication information for - * the session. This method allows an application to perform its own explicit authentication. + * Explicitly configure the authentication information for the session. An application may + * call this method to explicitly authenticate a Session. + * + * Either the [Principal] must be non-`Null` or at least one [Entitlement] must be provided. * - * @param userId the user identity to associate with the session + * @param principal (optional) the authenticated [Principal] to associate with the session + * @param entitlements (optional) the authenticated [Entitlement]s to associate with the + * session * @param exclusiveAgent (optional) pass `True` iff the device and `User-Agent` that the login * is occurring from is used exclusively by the user being authenticated; * pass `False` for a public or shared device @@ -365,30 +377,30 @@ interface Session * authentication scheme could specify a lower trust level by default * until additional steps (like a second factor authentication) are * performed - * @param roles (optional) a set of roles that area associated with the user */ - void authenticate(String userId, - Boolean exclusiveAgent = False, - TrustLevel trustLevel = Highest, - Set roles = [], + void authenticate(Principal? principal = Null, + Entitlement[] entitlements = [], + Boolean exclusiveAgent = False, + TrustLevel trustLevel = Highest, ); /** * This method is invoked when an authentication attempt is made, but fails; for example, when * an incorrect password is provided. * - * This is not a session event; like the [authenticate] method, this method allows an - * Authenticator and/or custom application logic to register an attempt to authenticate that - * did not succeed. While this is often an expected occurrence, the presence of repeated failed - * attempts by the same session, or failed attempts by different sessions against the same user - * id, can indicate the presence of a security threat. + * This is not a session event; like the [authenticate] method, this method allows built-in + * authentication logic and/or custom application logic to register an attempt to authenticate + * that did not succeed. While authentication failure is an expected occurrence, the presence of + * repeated failed attempts by the same session, or failed attempts by different sessions + * against the same [Principal] or other `Subject`, can indicate the presence of a security + * threat. * - * @param userId the user identity that the authentication attempt specified, or `Null` if no - * user identity was specified + * @param request the request that included the authentication attempt + * @param attempt the failed authentication [Attempt] * * @return True if the caller should abort additional attempts to authenticate at this point */ - Boolean authenticationFailed(String? userId); + Boolean authenticationFailed(RequestIn request, Attempt attempt); /** * This method allows an application to explicitly de-authenticate the session. One obvious @@ -403,7 +415,6 @@ interface Session */ void destroy(); - // ----- session events ------------------------------------------------------------------------ /** @@ -462,24 +473,27 @@ interface Session * This event is invoked when the session is authenticated. It allows the application to set up * information related to the user in the session. * - * Note: If a different `userId` was previously authenticated, then the [sessionDeauthenticated] - * event will be invoked before this event. + * Note: If different subject(s) were previously authenticated, then the + * [sessionDeauthenticated] event will be invoked before this event. * * When implementing this method, remember to invoke the `super` function. * - * @param user the user that was is now authenticated + * @param principal the Principal + * @param entitlements any Entitlements */ - void sessionAuthenticated(String user); + void sessionAuthenticated(Principal? principal, Entitlement[] entitlements); /** - * This event is invoked when a previously authenticated user is "logged out". It allows the - * application to clean up any information related to the user in the session. + * This event is invoked when a previously authenticated session is deauthenticated, i.e. a user + * is "logged out". It allows the application to clean up any information related to the user in + * the session, for example. * * When implementing this method, remember to invoke the `super` function. * - * @param user the user that was previously authenticated but is being logged out + * @param principal the Principal + * @param entitlements any Entitlements */ - void sessionDeauthenticated(String user); + void sessionDeauthenticated(Principal? principal, Entitlement[] entitlements); /** * This event is invoked when a TLS connection is re-negotiated. It is possible for non-TLS diff --git a/lib_web/src/main/x/web/WebApp.x b/lib_web/src/main/x/web/WebApp.x index a2211fc33d..502b0fc472 100644 --- a/lib_web/src/main/x/web/WebApp.x +++ b/lib_web/src/main/x/web/WebApp.x @@ -27,15 +27,12 @@ mixin WebApp * processing within this `WebApp`, and produce a [Response] that is appropriate to the * exception or other error that was raised. * - * @param session the session (usually non-`Null`) within which the request is being - * processed; the session can be `Null` if the error occurred before or during - * the instantiation of the session * @param request the request being processed * @param error the exception thrown, the error description, or an HttpStatus code * * @return the [Response] to send back to the caller */ - ResponseOut handleUnhandledError(Session? session, RequestIn request, Exception|String|HttpStatus error) { + ResponseOut handleUnhandledError(RequestIn request, Exception|String|HttpStatus error) { // TODO CP: does the exception need to be logged? HttpStatus status = error.is(RequestAborted) ? error.status : error.is(HttpStatus) ? error @@ -45,15 +42,15 @@ mixin WebApp } /** - * A Webapp that knows how to provide an `Authenticator` should implement this interface in + * A `Webapp` that knows how to provide an [Authenticator] should implement this interface in * order to do so. */ static interface AuthenticatorFactory { /** - * Create (or otherwise provide) the `Authenticator` that this WebApp will use. It is + * Create (or otherwise provide) the [Authenticator] that this `WebApp` will use. It is * expected that this method will only be called once. * - * @return the `Authenticator` for this WebApp + * @return the [Authenticator] for this `WebApp` */ Authenticator createAuthenticator(); } @@ -62,24 +59,49 @@ mixin WebApp * The [Authenticator] for the web application. */ @Lazy Authenticator authenticator.calc() { - // use the Authenticator provided by injection, which allows a deployer to select a specific - // form of authentication; if one is injected, use it, otherwise, use the one specified by - // this application + // use the Authenticator provided by injection, if any, which can be specified as part of + // the application deployment process or otherwise provided by the containing HTTP server @Inject Authenticator? providedAuthenticator; return providedAuthenticator?; - try { - // allow a module to implement the factory method createAuthenticator() - if (this.is(AuthenticatorFactory)) { - return createAuthenticator(); - } - } catch (Exception e) { - // TODO this is temporary, we do need to expose a unified log API - @Inject Console console; - console.print($"An exception occurred while creating an Authenticator: {e}"); + // allow a WebApp module to implement the factory method createAuthenticator() + if (this.is(AuthenticatorFactory)) { + return createAuthenticator(); } // disable authentication, since no authenticator was found return new NeverAuthenticator(); } + + /** + * A `Webapp` that knows how to provide a [Session Broker](sessions.Broker) should implement + * this interface in order to do so. + */ + static interface SessionBrokerFactory { + /** + * Create (or otherwise provide) the [Session Broker](sessions.Broker) that this `WebApp` + * will use. It is expected that this method will only be called once. + * + * @return the [Session Broker](sessions.Broker) for this `WebApp` + */ + sessions.Broker createSessionBroker(); + } + + /** + * The [Session Broker](sessions.Broker) for the web application. + */ + @Lazy sessions.Broker sessionBroker.calc() { + // use the Session Broker provided by injection, if any, which can be specified as part of + // the application deployment process or otherwise provided by the containing HTTP server + @Inject sessions.Broker? sessionBroker; + return sessionBroker?; + + // allow a WebApp module to implement the factory method createSessionBroker() + if (this.is(SessionBrokerFactory)) { + return createSessionBroker(); + } + + // disable sessions, since no broker was provided + return new sessions.NeverBroker(); + } } \ No newline at end of file diff --git a/lib_web/src/main/x/web/WebService.x b/lib_web/src/main/x/web/WebService.x index a80fee6395..8252422724 100644 --- a/lib_web/src/main/x/web/WebService.x +++ b/lib_web/src/main/x/web/WebService.x @@ -16,10 +16,56 @@ * } * } * } + * + * To version an HTTP API, tag only the API changes on the endpoints. For example, assume an + * existing versionless API: + * + * @WebService("/api") + * service api { + * @Get("/items{/id}") Item|HttpStatus getItems(Int? id = Null) {...} + * @Put("/items/{id}") HttpStatus putItem(Int? id, @BodyParam Item item) {...} + * @Delete("/items/{id}") HttpStatus deleteItem(Int? id) {...} + * } + * + * At some point, the popularity of the API leads to a new version that completely changes the + * implementation of the HTTP `PUT` method for items, and removes the HTTP `DELETE` method, and + * the decision is made that existing clients should automatically use the latest API version: + * + * @WebService("/api", currentVer=v:2, defaultVer=v:2) + * service api { + * @Get("/items{/id}") Item|HttpStatus getItems(Int? id = Null) {...} + * @Put("/items/{id}") HttpStatus putItem(Int? id, @BodyParam Item item) {...} + * @Put("/items/{id}", api=v:2) HttpStatus putItem2(Int? id, @BodyParam Item item) {...} + * @Delete("/items/{id}", api=v:1..v:1) HttpStatus deleteItem(Int? id) {...} + * } + * + * Given the version 2 of the above example, a `PUT` to `/api/v1/items/14` would be routed to the + * `putItem()` method, while a `PUT` to either `/api/items/14` or `/api/v2/items/14` would be routed + * to the `putItem2()` method. + * + * @param path the path string for the web service, for example "/" or "/api" + * @param currentVer (optional) the current (i.e. latest) API version, if the API is versioned + * @param defaultVer (optional) iff the API is versioned, then this is the API version that will be + * automatically routed to if no version number is present in the URL path; to + * use the latest API version by default, use the same value as `currentVer` */ -mixin WebService(String path) +mixin WebService(String path, Version? currentVer = Null, Version? defaultVer = Null) into service { - // ----- properties ---------------------------------------------------------------------------- + + /** + * The function that represents a WebService constructor. + */ + typedef function WebService() as Constructor; + + /** + * The request for the currently executing handler within this service. + */ + RequestIn? request; + + /** + * The session related to the current request. + */ + Session? session.get() = request?.session : Null; /** * The [WebApp] containing this `WebService`. If no `WebApp` is explicitly configured, then the @@ -28,17 +74,14 @@ mixin WebService(String path) WebApp webApp { @Override WebApp get() { - WebApp app; - if (assigned) { - app = super(); - } else { + if (!assigned) { assert val moduleObject := this:class.baseTemplate.containingModule.ensureClass().isSingleton() as $"Unable to obtain containing module for {this}"; - assert app := moduleObject.is(WebApp) as $"Unable to obtain the WebApp for {this}"; + assert WebApp app := moduleObject.is(WebApp) as $"Unable to obtain the WebApp for {this}"; set(app); } - return app; + return super(); } @Override @@ -48,57 +91,31 @@ mixin WebService(String path) } } - /** - * The session related to the currently executing handler within this service. - */ - Session? session; - - /** - * The request for the currently executing handler within this service. - */ - RequestIn? request; - - /** - * The function that represents a WebService constructor. - */ - typedef function WebService() as Constructor; - - - // ----- processing ---------------------------------------------------------------------------- - /** * Process a received [RequestIn]. This is invoked by the server in order to transfer control to * an "EndPoint Handler". * * This method is called from the previous step in the routing chain, in order to transfer * control into this web service. For the duration of the processing inside this web service, - * the session and the request will be available as properties on the WebService. + * the request will be available as a property on this WebService. * - * @param session the [Session] to hold onto (so that it's available for the duration of the - * request processing) - * @param request the [RequestIn] to hold onto (so that it's available for the duration of the - * request processing) + * @param request the current [RequestIn] that is going to be processed by the `WebService` * @param handler the handler to delegate the processing to * @param onError (optional) the error handler to delegate the error processing to * * @return the [ResponseOut] to send back to the caller */ - ResponseOut route(Session session, RequestIn request, Handler handle, ErrorHandler? onError) { + ResponseOut route(RequestIn request, Handler handle, ErrorHandler? onError) { assert this.request == Null; - - // store the request and session for the duration of the request processing this.request = request; - this.session = session; - try { - return handle(session, request).freeze(True); + return handle(request).freeze(True); } catch (RequestAborted e) { return e.makeResponse(); } catch (Exception e) { - return onError?(session, request, e).freeze(True) : throw e; + return onError?(request, e).freeze(True) : throw e; } finally { this.request = Null; - this.session = Null; } } } \ No newline at end of file diff --git a/lib_web/src/main/x/web/security/Authenticator.x b/lib_web/src/main/x/web/security/Authenticator.x index f63fd95f11..62096ca961 100644 --- a/lib_web/src/main/x/web/security/Authenticator.x +++ b/lib_web/src/main/x/web/security/Authenticator.x @@ -1,54 +1,161 @@ /** - * An Authenticator is a service that is used when a user (client) authentication is required. - * Authentication takes many forms, and as such, it is difficult to represent the potential forms - * that authentication can take in an elegant, simple, pluggable API. This interface attempts to - * achieve the necessary pluggability, but exposes some necessary complexity, due to the complexity - * of the various approaches to authentication that already exist and are in use. + * An Authenticator is a service that is used when a user (client) authentication is required, and + * also when client authorization information may be present. Authentication takes many forms that + * must be supportable via this interface; however, the result is somewhat complex due to the + * variety of authentication mechanisms already in use. * * The model itself is fairly simple: * * * A request is received from a client; + * * A web service endpoint is selected based on the request; + * * The `Endpoint` is examined to determine if it requires authentication (or re-authentication, or + * authorization); + * * If so, then the `Authenticator` is invoked, and provided with the request. + * * The Authenticator responds with a list of `Attempt` objects describing each authentication + * attempt and each authorization grant (aka "entitlement") that was found in the request. * - * * The session associated with the request is found; + * It is common that there is exactly zero or one authentication attempts in a request, but there + * can be any number of attempts using any number of authentication forms (bearer token, digest, + * etc.) Some may indicate valid authentication attempts; some may indicate failed attempts (for + * various reasons, such as an incorrect password, or expired token); some may be only partially + * complete attempts. If authentication is required, then the host (whoever is invoking the + * `Authenticator`) would decide based on the resulting list of [Attempt] information what further + * steps to take. The host then takes the appropriate action: * - * * An web service endpoint is selected based on the request; + * * If there is no authentication information, then in most cases, a challenge would be returned to + * the client; + * * If the authentication is incomplete but proceeding as defined by the specific authentication + * process, then the appropriate response is sent to the client to advance the process; + * * If an authentication error or failure occurs, then the appropriate error status will be sent to + * the client; + * * If the user is sufficiently authenticated, then the host will proceed. * - * * The endpoint is determined to require authentication (or re-authentication); + * If authorization is required, then the host would evaluate the required permission against the + * [Entitlement] and [Principal] objects that appear in the authentication results. * - * * The Authenticator is invoked, and provided with the known context: The request, the session, - * and the endpoint; - * - * * The Authenticator can respond in one of three ways: - * - * * * The user is sufficiently authenticated, and the server should invoke the endpoint; - * - * * * The user cannot be authenticated, and the server should respond with an appropriate error; - * - * * * The user is not authenticated, and the Authenticator can either begin (or advance) the - * authentication process, or report an error back to the client; in both cases, the mechanism - * is a response that is returned from the Authenticator. + * The `Authenticator` is expected to be a bottleneck, since implementations are likely to combine + * high-latency I/O (e.g. database accesses) with computationally expensive logic; to maximize + * concurrency, the HTTP server can [duplicate](Duplicable.duplicate) an `Authenticator` as + * necessary in order to avoid contention on a single instance. */ interface Authenticator - extends service { + extends Duplicable, service { + /** + * The [Realm] that this `Authenticator` relies on for security information. + */ + @RO Realm realm; + + /** + * When a request is received, it may include (or imply) information that can be used in the + * authentication process, including claims of user identity and/or entitlement(s), and proof + * of those claims. + * + * * A [Principal] represents a user identity; + * * An [Entitlement] represents an authorization grant, and **may also** represent a user + * identity iff [Entitlement.conferIdentity] is true; + * * A `String` is used to represent an invalid user or entitlement when it is not possible to + * obtain an actual [Principal] or [Entitlement] object. + */ + typedef Principal|Entitlement|String as Claim; + + /** + * An enumeration of potential authorization statuses, in the order of least applicable to most + * applicable: + * + * * [NoData] indicates that the request contains no information relevant to the `Authenticator` + * (the Claim should be `Null`) + * * [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 + * an attack on the authentication system + * * [Failed] indicates that authentication was attempted, but that the attempt failed because + * the supplied credentials were wrong; the client may be permitted to authenticate again, but + * should be prevented from doing so without limit (it could represent an attack) + * * [NotActive] indicates that authentication was attempted, but that the attempt failed + * because the [Principal], [Entitlement], or credentials have expired, or that the + * `Principal` has been suspended; this represents a failure, but the information is correct + * from the client's point of view + * * [Success] indicates that the client request contained valid authentication information + */ + 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 + * indicate a specific response to the client that differs from the "normal" request/response + * flow (i.e. differs from the default response). Normally, when authentication is needed, a + * response containing the [HttpStatus] of [Unauthorized](HttpStatus.Unauthorized) is returned + * to the client. If an `AuthResponse` is provided by the Authenticator, it should be one of (in + * order of preference): + * + * * A `String` value that should be provided to the client in a "WWW-Authenticate:" header; + * this is the most common form of an `Authenticator` response, because multiple + * `Authenticators` can provide "WWW-Authenticate:" header values, and those can be combined + * into a single response. + * + * * Each `String` in a `String[]` value is treated in the same manner as above. + * + * * An [HttpStatus] if the status differs from the expected. Specifically, the server will + * respond to the client by default with one of the following statuses: + * * * [Unauthorized](HttpStatus.Unauthorized) - when authentication is absent but required + * * * [BadRequest](HttpStatus.BadRequest) - when an authentication response is poorly formed + * * * [Forbidden](HttpStatus.Forbidden) - when processing an authentication response has failed + * + * * If the protocol is more complex, only then should the `Authenticator` provide an entire + * HTTP response by providing a [ResponseOut]. + */ + typedef String|String[]|HttpStatus|ResponseOut as AuthResponse; + /** - * Authenticate the client (or user) using the provided request, session, and endpoint. - * Authentication requires both an established session and a TLS connection, and is triggered by - * an endpoint that is annotated by [LoginRequired] that specifies a [TrustLevel] higher than - * the session's current `TrustLevel`. - * - * When the `Authenticator` successfully authenticates the client, it is responsible for - * updating the session as appropriate, such as by calling [Session.authenticate]. - * - * @param request a request that requires authentication - * @param session the session associated with the request that requires authentication - * - * @return [Allowed] to indicate the client has been authenticated; [Unknown] to indicate that - * this Authenticator does not know how to process the specified request; - * [Forbidden] to indicate that the response has bee recognized, but client is not being - * permitted to authenticate for any reason; or an HTTP [ResponseOut] to deliver to the - * client to indicate the next step in the process of authentication + * A record of an attempt to authenticate. + * + * @param claim the [Principal], [Entitlement], or claim thereof; otherwise `Null` + * @param status the status of the authentication claim in the request + * @param response a specific response to send to the client to advance the authentication + * process or indicate an authentication failure, or delete information on the + * client (in the case of the [findAndRevokeSecrets] method); `Null` is the + * preferred value, and indicates that the appropriate default response should + * be utilized */ - AuthStatus|ResponseOut authenticate(RequestIn request, Session session); + static const Attempt(Claim? claim, Status status, AuthResponse? response = Null); - enum AuthStatus {Allowed, Unknown, Forbidden} + /** + * Check a request that was received in plain text (i.e. **not** TLS) to make sure that it does + * not have any secret tokens or password material in it. An implementation of `Authenticator` + * that normally uses secret material in an HTTPS request should always check for that material + * being present in any plain text HTTP request, and if any is found, then the `Authenticator` + * **must** invalidate all future use of that secret material, because all material transmitted + * in plain text form must **always** be assumed to have been leaked to malicious actors. + * + * In addition to invalidating the secret material, the authenticator _may_ also need to respond + * to the caller with an HTTP response; for example, a response could be used to modify or + * delete specific cookies. + * + * See also: [Your API Shouldn't Redirect HTTP to HTTPS](https://jviide.iki.fi/http-redirects) + * + * @param request a request that was received without TLS enabled + * + * @return an array of zero or more `Attempt` records indicating the secret material that was + * revoked + */ + Attempt[] findAndRevokeSecrets(RequestIn request); + + /** + * Authenticate the client (or user) using the provided request. Authentication requires a TLS + * connection, and is triggered by routing to any `Endpoint` that is annotated by + * [LoginRequired] or [Restrict]. When a valid [Session] exists for the request, the + * authentication information in the session is used, if it exists, before invoking the + * `Authenticator`. + * + * @param request a request that requires authentication + * + * @return an array of `Attempt` records enumerating each authentication attempt found in -- or + * missing from -- the request + */ + Attempt[] authenticate(RequestIn request); } diff --git a/lib_web/src/main/x/web/security/BasicAuthenticator.x b/lib_web/src/main/x/web/security/BasicAuthenticator.x index 7f42edfa24..1cf7b8d46f 100644 --- a/lib_web/src/main/x/web/security/BasicAuthenticator.x +++ b/lib_web/src/main/x/web/security/BasicAuthenticator.x @@ -5,6 +5,9 @@ import responses.SimpleResponse; import ecstasy.collections.CaseInsensitive; +import sec.Credential; +import sec.PlainTextCredential; + /** * An implementation of the Authenticator interface for @@ -12,44 +15,122 @@ import ecstasy.collections.CaseInsensitive; */ @Concurrent service BasicAuthenticator(Realm realm) - implements Authenticator { - public/private Realm realm; + implements Duplicable, Authenticator { + + // ----- constructors -------------------------------------------------------------------------- + + @Override + construct(BasicAuthenticator that) { + Realm realm = that.realm; + if (realm.is(Duplicable)) { + realm = realm.duplicate(); + } + this.realm = realm; + } + + // ----- properties ---------------------------------------------------------------------------- + + /** + * The Realm that contains the user/password information. + */ + @Override + public/protected Realm realm; + + // ----- internal ------------------------------------------------------------------------------ + + static const BasicAttempt(Claim? subject, Status status, AuthResponse? response = Null, + PlainTextCredential? credential = Null) + extends Attempt(subject, status, response) { + @RO Principal? principal.get() = subject.is(Principal) ?: Null; + } + + // ----- Authenticator API --------------------------------------------------------------------- + + @Override + BasicAttempt[] findAndRevokeSecrets(RequestIn request) { + BasicAttempt[] attempts = scan(request); + if (attempts.empty) { + return attempts; + } + + BasicAttempt[] secrets = []; + for (BasicAttempt attempt : attempts) { + if (attempt.status >= NotActive + && attempt.subject.is(Principal) + && attempt.credential?.active : False) { + realm.updatePrincipal(attempt.principal?.revokeCredential(attempt.credential?)?); + secrets += attempt; + } + } + return secrets; + } @Override - AuthStatus|ResponseOut authenticate(RequestIn request, Session session) { - // TLS is a pre-requisite for authentication - assert request.scheme.tls; + BasicAttempt[] authenticate(RequestIn request) { + // to cause the client to request the user for a name and password, we need to return an + // "Unauthorized" error code with a header that directs the client to use basic auth + private BasicAttempt[] RequestAuth = [new BasicAttempt(Null, NoData, + $|Basic realm="{realm.name}", charset="UTF-8" + )]; + + BasicAttempt[] attempts = scan(request); + return attempts.empty ? RequestAuth : attempts; + } - // first, check to see if the incoming request includes the necessary authentication - // information, which will be in one or more "Authorization" header entries - for (String auth : request.header.valuesOf("Authorization")) { + /** + * Scan the incoming request for any Basic HTTP Authentication Scheme information, which will be + * in one or more `Authorization` header entries. + * + * @param request the HTTP request + * + * @return an array of zero or more [Attempt] records, corresponding to the information found in + * the `Authorization` headers + */ + BasicAttempt[] scan(RequestIn request) { + BasicAttempt[] attempts = []; + static BasicAttempt Corrupt = new BasicAttempt(Null, Failed); + NextHeader: for (String auth : request.header.valuesOf("Authorization")) { auth = auth.trim(); if (CaseInsensitive.stringStartsWith(auth, "Basic ")) { try { auth = Utf8Codec.decode(Base64Format.Instance.decode(auth.substring(6))); } catch (Exception e) { - return new SimpleResponse(BadRequest); + attempts += Corrupt; + continue; } if (Int colon := auth.indexOf(':')) { - String user = auth[0 ..< colon]; + String name = auth[0 ..< colon]; String pwd = auth[colon >..< auth.size]; - if (realm.authenticate(user, pwd)) { - session.authenticate(user); - return Allowed; - } - if (session.authenticationFailed(user)) { - return Forbidden; + + if (Principal principal := realm.findPrincipal(PlainTextCredential.Scheme, name)) { + PlainTextCredential? failure = Null; + for (Credential credential : principal.credentials) { + // for plain text credentials that match the name, there are three + // outcomes: (1) revoked, (2) wrong password, (3) all good! + if (credential.scheme == PlainTextCredential.Scheme + && credential.is(PlainTextCredential) + && credential.active + && credential.name == name) { + if (credential.password == pwd) { + attempts += new BasicAttempt(principal, principal.calcStatus(realm) + == Active ? Success : NotActive, Null, credential); + continue NextHeader; + } else { + failure ?:= credential; + } + } + } + attempts += new BasicAttempt(principal, Failed, Null, failure); + } else { + attempts += new BasicAttempt(name, Failed); } + } else { + attempts += Corrupt; } } } - // to cause the client to request the user for a name and password, we need to return an - // "Unauthorized" error code with a header that directs the client to use basic auth - ResponseOut response = new SimpleResponse(Unauthorized); - response.header.add("WWW-Authenticate", $|Basic realm="{realm.name}", charset="UTF-8" - ); - return response; + return attempts; } } \ No newline at end of file diff --git a/lib_web/src/main/x/web/security/ChainAuthenticator.x b/lib_web/src/main/x/web/security/ChainAuthenticator.x index aacc40fdd2..9a42fd6234 100644 --- a/lib_web/src/main/x/web/security/ChainAuthenticator.x +++ b/lib_web/src/main/x/web/security/ChainAuthenticator.x @@ -2,29 +2,46 @@ * An implementation of the [Authenticator] interface that attempts to authorized the incoming * request using any of the specified `Authenticators`. */ -const ChainAuthenticator(List chain) +const ChainAuthenticator(Realm realm, Authenticator[] chain) implements Authenticator { @Override - AuthStatus|ResponseOut authenticate(RequestIn request, Session session) { - ResponseOut? response = Null; - for (Authenticator authenticator : chain) { - AuthStatus|ResponseOut success = authenticator.authenticate(request, session); + construct(ChainAuthenticator that) { + construct ChainAuthenticator(realm, new Authenticator[that.chain.size](i -> that.chain[i].duplicate())); + } - switch (success) { - case Allowed, Forbidden: - return success; + // ----- Authenticator API --------------------------------------------------------------------- - case Unknown: - // try the next one - break; + @Override + Attempt[] findAndRevokeSecrets(RequestIn request) { + Attempt[] result = []; + for (Authenticator auth : chain) { + Attempt[] partial = auth.findAndRevokeSecrets(request); + if (!partial.empty) { + result += partial; + } + } + return result; + } - default: - // remember the response (ignore multiple ones), but try the next one still - response ?:= success.as(ResponseOut); - break; + @Override + Attempt[] authenticate(RequestIn request) { + Attempt[] single = []; + Attempt[]? merged = Null; + for (Authenticator auth : chain) { + Attempt[] current = auth.authenticate(request); + if (!current.empty) { + if (single.empty) { + single = current; + } else { + if (merged == Null) { + merged = new Attempt[](single.size + current.size); + merged.addAll(single); + } + merged.addAll(current); + } } } - return response ?: Unknown; + return merged?.freeze(True) : single; } } diff --git a/lib_web/src/main/x/web/security/DigestAuthenticator.x b/lib_web/src/main/x/web/security/DigestAuthenticator.x index 440bff694d..f8ba71e683 100644 --- a/lib_web/src/main/x/web/security/DigestAuthenticator.x +++ b/lib_web/src/main/x/web/security/DigestAuthenticator.x @@ -1,14 +1,19 @@ -import convert.codecs.Utf8Codec; import convert.formats.Base64Format; import crypto.Signer; import ecstasy.collections.CaseInsensitive; -import Realm.Hash; -import Realm.UserId; +import sec.Credential; +import sec.Entity; +import sec.NonceManager; +import sec.Principal; -import responses.SimpleResponse; +import DigestCredential.Hash; +import DigestCredential.md5; +import DigestCredential.sha256; +import DigestCredential.sha512_256; +import DigestCredential.UserId; /** @@ -16,226 +21,237 @@ import responses.SimpleResponse; * [The 'Digest' HTTP Authentication Scheme](https://datatracker.ietf.org/doc/html/rfc7616). */ @Concurrent -service DigestAuthenticator(Realm realm) +service DigestAuthenticator implements Authenticator { - assert() { - Signer[] hashers = realm.hashers; - assert !hashers.empty as $|The "{realm.name}" realm must specify at least one hashing\ - | algorithm - ; - for (Signer hasher : hashers) { - switch (String name = hasher.algorithm.name) { - case "MD5": - case "SHA-256": - case "SHA-512-256": - break; - - default: - assert as $|The "{realm.name}" realm specifies an unsupported hash algorithm:\ - | {name} - ; + // ----- constructors -------------------------------------------------------------------------- + + /** + * Construct a `DigestAuthenticator`. + * + * @param realm the [Realm] that this `DigestAuthenticator` uses to verify + * [Principal]s and [Credential]s + * @param disallowAlgorithms (optional) an array of algorithm names to explicitly disallow the + * use of for digest authentication; the contents, if specified, can + * be some combination of "MD5", "SHA-256", and "SHA-512-256" + */ + construct(Realm realm, String[] disallowAlgorithms = []) { + Signer[] hashers = [md5, sha256, sha512_256]; + if (!disallowAlgorithms.empty) { + for (String name : disallowAlgorithms) { + hashers = hashers - switch (name.toUppercase()) { + case "MD5": md5; + case "SHA-256": sha256; + case "SHA-512-256": sha512_256; + default: assert as $"Unknown/unsupported hash algorithm: {name.quoted()}"; + }; } + assert !hashers.empty; } + + this.realm = realm; + this.hashers = hashers; + this.nonces = new NonceManager(Duration:5m); } + @Override + construct(DigestAuthenticator that) { + Realm thatRealm = that.realm; + this.realm = thatRealm.is(Duplicable) ? thatRealm.duplicate() : thatRealm; + this.hashers = that.hashers; + this.nonces = that.nonces; + } + + // ----- properties ---------------------------------------------------------------------------- + /** * The Realm that contains the user/password information. */ - public/private Realm realm; + @Override + public/protected Realm realm; + + /** + * The [Signer]s (hash algorithms) used by this `DigestAuthenticator`. + */ + Signer[] hashers; + /** + * A `NonceManager` whose job it is to generate and ensure at most a single use of each nonce. + */ + protected/private NonceManager nonces; // ----- Authenticator interface --------------------------------------------------------------- @Override - AuthStatus|ResponseOut authenticate(RequestIn request, Session session) { + DigestAttempt[] findAndRevokeSecrets(RequestIn request) { + // scan for and cancel all nonces + for (String auth : request.header.valuesOf("Authorization")) { + auth = auth.trim(); + if (CaseInsensitive.stringStartsWith(auth, "Digest "), + (String? realmName, _, _, _, _, _, String nonceText) := parseDigest(auth.substring(7)), + realmName == Null || realmName == realm.name, + Int nonce := Int.parse(nonceText, 16)) { + // validate the nonce to kill it (it's only valid once!) + nonces.validate(nonce); + } + } + + // no plain text secrets + return []; + } + + @Override + DigestAttempt[] authenticate(RequestIn request) { + static DigestAttempt Corrupt = new DigestAttempt(Null, Failed); + // TLS is a pre-requisite for authentication assert request.scheme.tls; - Boolean stale = False; + private String[] challenges(RequestIn request, Boolean stale) { + String nonce = toString(nonces.generate()); + String[] result = new String[]; + + // the Safari browser does not implement the spec correctly; if you give it an option to + // use anything other than MD5, it cannot proceed with any of the challenge options + Signer[] hashers = (request.userAgent?.indexOf("Safari") : False) + ? [md5] + : this.hashers; + + for (Signer hasher : hashers) { + result += $|Digest realm="{realm.name}",\ + |qop="auth",\ + |algorithm={hasher.algorithm.name}-sess,\ + |nonce="{nonce}",\ + |opaque="BeKindToOthers",\ + |charset=UTF-8\ + |{stale ? ",stale=true" : ""} + ; + } + return result.freeze(inPlace=True); + } // first, check to see if the incoming request includes the necessary authentication // information, which will be in one or more "Authorization" header entries + DigestAttempt[] attempts = []; + Boolean stale = False; + Boolean passed = False; NextAuthAttempt: for (String auth : request.header.valuesOf("Authorization")) { auth = auth.trim(); if (CaseInsensitive.stringStartsWith(auth, "Digest ")) { - if ((UserId userId, - Hash responseHash, - Signer hasher, - String opaque, - String nonce, - String uri, - String cnonce, - String ncText, - Int nc ) := parseDigest(auth.substring(7))) { - // verify that the server nonce is still acceptable; the client nonce and its - // count isn't checked here, since the communication is over TLS, and any - // noteworthy changes to the session or the connection will automatically make - // the server nonce stale - switch (lookupNonce(session, nonce)) { - case Unknown: + if (( String? realmName, + String[] qop, + UserId userId, + Hash responseHash, + Signer hasher, + String opaque, + String nonce, + String uri, + String cnonce, + String ncText, + Int nc, + ) := parseDigest(auth.substring(7))) { + + // realm is optional, but if it is present, it must match + if (!qop.contains("auth") || realmName? != realm.name) { continue NextAuthAttempt; + } - case Stale: + // verify that the server nonce is still acceptable; the client nonce and its + // count isn't checked here, since the communication is over TLS + Int nonceValue; + if (!(nonceValue := Int.parse(nonce, 16))) { + attempts += Corrupt; + continue NextAuthAttempt; + } + if (!nonces.validate(nonceValue)) { stale = True; continue NextAuthAttempt; - - case Valid: - break; } - Hash[] hashes = realm.hashesFor(userId, hasher); - String? badUser = Null; - for (Hash pwdHash : hashes) { - String user; - Set? roles; - if (userId.is(String)) { - user = userId; - badUser ?:= user; - assert roles := realm.validUser(user); - // obtain the plain text user name (which we need to reproduce the hash) by - // "validating" the password hash that we just got from the realm when we - // looked up the hashed user id; in other words, this is not "validating" - // anything; it's just looking up a plain text user name - } else if (!((user, roles) := realm.authenticateHash(userId, pwdHash, hasher))) { - // somehow, when we went to look up the user name for the password hash - // that the realm just gave us, the user hash/password hash combination - // disappeared; in theory, someone could have just changed the password - // for that user or something similar, so just pretend that we didn't - // even know about that user hash/password hash combo - continue; + // the userId is either a name or a hash; use it as a Principal "locator" + String locator = userId.is(String) ? userId.quoted() : userId.toString(pre=""); + if (Principal principal := realm.findPrincipal(DigestCredential.Scheme, locator)) { + Authenticator.Status status = principal.calcStatus(realm) == Active ? Success : NotActive; + + // validate the credential + DigestCredential? failure = Null; + for (Credential credential : principal.credentials) { + // for credentials that match the name/user-hash, there are three + // outcomes: (1) revoked, (2) bad password hash, (3) all good! + if (credential.scheme == DigestCredential.Scheme + && credential.is(DigestCredential) + && credential.isUser(userId) + && credential.active) { + + // create what a response digest would look like, using the hashed + // password and other information that we parsed from the digest + // auth: + // + // response = KD ( H(A1), unq(nonce) + // ":" nc + // ":" unq(cnonce) + // ":" unq(qop) + // ":" H(A2) + // ) + // A1 = H( unq(username) ":" unq(realm) ":" passwd ) + // ":" unq(nonce-prime) ":" unq(cnonce-prime) + // A2 = Method ":" request-uri + // + // where: + // + // H(data) = (data) + // KD(secret, data) = H(concat(secret, ":", data)) + // + // and the "hash" value provided by the realm is the first part of A1: + // + // H( unq(username) ":" unq(realm) ":" passwd ) + + Hash pwdHash; + if (!(pwdHash := credential.findPasswordHash(hasher))) { + failure ?:= credential; + continue; + } + Hash hashA1 = toHash($"{toString(pwdHash)}:{nonce}:{cnonce}", hasher); + Hash hashA2 = toHash($"{request.method.name}:{uri}" , hasher); + Hash expected = toHash($|{toString(hashA1)}:{nonce}:{ncText}\ + |:{cnonce}:auth:{toString(hashA2)} + , hasher); + if (responseHash == expected) { + attempts += new DigestAttempt(principal, status, Null, credential); + passed = True; + continue NextAuthAttempt; + } else { + failure ?:= credential; + } + } } - // create what a response digest would look like, using the hashed password - // and other information that we parsed from the digest auth: - // - // response = KD ( H(A1), unq(nonce) - // ":" nc - // ":" unq(cnonce) - // ":" unq(qop) - // ":" H(A2) - // ) - // A1 = H( unq(username) ":" unq(realm) ":" passwd ) - // ":" unq(nonce-prime) ":" unq(cnonce-prime) - // A2 = Method ":" request-uri - // - // where: - // - // H(data) = (data) - // KD(secret, data) = H(concat(secret, ":", data)) - // - // and the "hash" value provided by the realm is the first part of A1: - // - // H( unq(username) ":" unq(realm) ":" passwd ) - Hash hashA1 = toHash($"{toString(pwdHash)}:{nonce}:{cnonce}", hasher); - Hash hashA2 = toHash($"{request.method.name}:{uri}" , hasher); - Hash expected = toHash($|{toString(hashA1)}:{nonce}:{ncText}\ - |:{cnonce}:auth:{toString(hashA2)} - , hasher); - if (responseHash == expected) { - session.authenticate(user, roles=roles); - return Allowed; - } - } - - if (session.authenticationFailed(badUser)) { - return Forbidden; + // none of the credentials matched + attempts += new DigestAttempt(principal, Failed, challenges(request, stale), failure); + } else { + // no such user + attempts += new DigestAttempt(locator, Failed, challenges(request, stale)); } } else { - return new SimpleResponse(BadRequest); + attempts += Corrupt; } } } - // to cause the client to request the user for a name and password, we need to return an - // "Unauthorized" error code with a header that directs the client to use Digest auth - ResponseOut response = new SimpleResponse(Unauthorized); - String nonce = createNonce(session); - for (Signer hasher : realm.hashers) { - response.header.add("WWW-Authenticate", $|Digest realm="{realm.name}",\ - |qop="auth",\ - |algorithm={hasher.algorithm.name}-sess,\ - |nonce="{nonce}",\ - |opaque="BeKindToOthers",\ - |charset=UTF-8\ - |{stale ? ",stale=true" : ""} - ); + if (attempts.empty) { + // to cause the client to request the user for a name and password, we need to return an + // "Unauthorized" error code with a header that directs the client to use Digest auth + attempts = [new DigestAttempt(Null, NoData, challenges(request, stale))]; } - return response.freeze(inPlace=True); - } - - - // ----- internal ------------------------------------------------------------------------------ - - /** - * A key to store nonce data on the session. The data are stored in a Map whose key is the - * String nonce, and whose value is the time at which the nonce was created. - */ - protected static String ActiveNonces = "DigestAuthenticator.nonces"; - - /** - * Nonces are stored in a table keyed by the nonce string, with a corresponding value being the - * point in time that the nonce was created. - */ - protected typedef ListMap as NonceTable; - /** - * Create and register an authentication nonce on the current session. - * - * @param session the current session - * - * @return the new nonce - */ - protected String createNonce(Session session) { - return session.attributes.process(ActiveNonces, entry -> { - @Inject Random rnd; - String nonce = Base64Format.Instance.encode(rnd.bytes(9)); - - NonceTable nonces; - if (entry.exists, nonces := entry.value.is(NonceTable)) { - while (nonces.size >= 20) { - nonces = nonces.remove(nonces.keys.iterator().take()); - } - } else { - nonces = new NonceTable().freeze(inPlace=True); - } - - @Inject Clock clock; - entry.value = nonces.put(nonce, clock.now); - - return nonce; - }); + return attempts; } - /** - * The current status of a previously created nonce: - * - * * `Unknown` -- the nonce is not recognized (i.e. it is not remembered) - * * `Stale` -- the nonce is recognized, but cannot be used because session events have occurred - * in the period of time since the nonce was handed out for use - * * `Valid` -- the nonce is recognized, and is valid for use - */ - protected enum NonceStatus {Unknown, Stale, Valid} + // ----- internal ------------------------------------------------------------------------------ - /** - * Look up a previously created nonce and check its status. - * - * @param session the current session - * @param nonce the previously created nonce - * - * @return the nonce status - */ - protected NonceStatus lookupNonce(Session session, String nonce) { - return session.attributes.process(ActiveNonces, entry -> { - if (entry.exists, NonceTable nonces := entry.value.is(NonceTable), - Time nonceCreated := nonces.get(nonce)) { - return session.anyEventsSince(nonceCreated) - ? Stale - : Valid; - } - return Unknown; - }); - } + static const DigestAttempt(Claim? claim, Status status, AuthResponse? response = Null, + DigestCredential? credential = Null) + extends Attempt(claim, status, response); /** * Parse the text that follows "Digest " in the "Authorization" header. @@ -298,6 +314,8 @@ service DigestAuthenticator(Realm realm) * @param text the text that follows "Digest " in the "Authorization" header * * @return `True` iff the parse was successful; `False` indicates a `BadRequest` error + * @return (conditional) realmName - the realm name, iff specified + * @return (conditional) qop - a list of quality-of-protection options e.g. "auth", "auth-int" * @return (conditional) userId - the user name in plain text, or the user hash * @return (conditional) responseHash - the response digest that includes password proof * @return (conditional) hasher - the [hasher](Signer) to use @@ -309,43 +327,39 @@ service DigestAuthenticator(Realm realm) * @return (conditional) nc - the number of times, including this time, that the client nonce * has been sent in a response */ - conditional (UserId userId, - Hash responseHash, - Signer hasher, - String opaque, - String nonce, - String uri, - String cnonce, - String ncText, - Int nc ) parseDigest(String text) { - val props = text.trim().splitMap(); - - // realm name is optional, but it should match the name previously provided - if (String realmName := props.get("realm")) { - if (realmName := realmName.unquote(), realmName == realm.name) { + conditional (String? realmName, + String[] qop, + UserId userId, + Hash responseHash, + Signer hasher, + String opaque, + String nonce, + String uri, + String cnonce, + String ncText, + Int nc, + ) parseDigest(String text) { + + val props = text.trim().splitMap(valueQuote=ch->ch=='\"'); + + // realm name is optional, but if present, it must match the name previously provided + String? realmName = Null; + if (realmName := props.get("realm")) { + if (realmName == realm.name) { // realm name matches } else { return False; } } - // qop is required, and only one qop is supported ("auth") - if (String qop := props.get("qop"), qop == "auth" || qop == "\"auth\"") { - // qop matches + // qop is required + String[] qop; + if (String qopList := props.get("qop")) { + qop = qopList.split(',', trim=True); } else { return False; } - // the things that need to be parsed and returned - UserId userId; - Hash responseHash; - Signer hasher; - String opaque; - String nonce; - String uri; - String cnonce; - Int nc = 0; - // "username", "username*", "userhash": the user name -- possibly hashed, or possibly // encoded differently in a similarly named key with a star on the end, because why TF // not? -- is required @@ -358,15 +372,14 @@ service DigestAuthenticator(Realm realm) } } + UserId userId; if (String username := props.get("username")) { // the presence of both "username" and "username*" is illegal if (props.contains("username*")) { return False; } - if (!(userId := username.unquote())) { - return False; - } + userId = username; } else if (String username := props.get("username*")) { // user hashing is not compatible with the use of the "username*" MIME parameter if (userHashed) { @@ -380,18 +393,20 @@ service DigestAuthenticator(Realm realm) return False; } - // a few more required pieces of information from the header properties String algorithm; - String response; + String opaque; + String nonce; + String uri; + String cnonce; String ncHex; - - if (algorithm := require(props, "algorithm", Null ), - opaque := require(props, "opaque" , True ), - nonce := require(props, "nonce" , True ), - uri := require(props, "uri" , True ), - cnonce := require(props, "cnonce" , True ), - ncHex := require(props, "nc" , False), - response := require(props, "response" , True )) { + String response; + if (algorithm := props.get("algorithm"), + opaque := props.get("opaque"), + nonce := props.get("nonce"), + uri := props.get("uri"), + cnonce := props.get("cnonce"), + ncHex := props.get("nc"), + response := props.get("response")) { // all required props found } else { return False; @@ -399,14 +414,12 @@ service DigestAuthenticator(Realm realm) // figure out which hashing algorithm (Signer) to use static String Suffix = "-sess"; + Signer hasher; FindHasher: if (algorithm.endsWith(Suffix)) { algorithm = algorithm[0 ..< algorithm.size-Suffix.size]; - for (hasher : realm.hashers) { - if (hasher.algorithm.name == algorithm) { - break FindHasher; - } + if (!(hasher := hashers.any(h -> h.algorithm.name == algorithm))) { + return False; } - return False; } else { return False; } @@ -415,6 +428,7 @@ service DigestAuthenticator(Realm realm) if (ncHex.size != 8) { return False; } + Int nc = 0; for (Char ch : ncHex) { if (Nibble n := ch.isNibble()) { nc = nc << 4 | n; @@ -423,7 +437,6 @@ service DigestAuthenticator(Realm realm) } } - // response (which contains the password information) is required // the response is a hex string if (response.size & 1 == 1) { @@ -439,32 +452,9 @@ service DigestAuthenticator(Realm realm) return False; } } - responseHash = hash.freeze(inPlace=True); + Hash responseHash = hash.freeze(inPlace=True); - return True, userId, responseHash, hasher, opaque, nonce, uri, cnonce, ncHex, nc; - } - - /** - * Helper function to grab required header properties. - * - * @param props the map of properties - * @param name the property name - * @param quoted True if the value must be quoted; False if the value must be unquoted; Null - * if the value may or may not be quoted - * - * @return `True` iff the corresponding value exists in the map and is quoted if necessary - * @return (conditional) the property value - */ - static conditional String require(Map props, String name, Boolean? quoted) { - if (String value := props.get(name)) { - return quoted? - ? value.unquote() - : (True, value); - - value := value.unquote(); - return True, value; - } - return False; + return True, realmName, qop, userId, responseHash, hasher, opaque, nonce, uri, cnonce, ncHex, nc; } /** @@ -477,7 +467,7 @@ service DigestAuthenticator(Realm realm) * @param s a String in the format defined by * [RFC 5987](https://datatracker.ietf.org/doc/html/rfc5987) * - * @return `True` iff the String contents were successfull decoded + * @return `True` iff the String contents were successfully decoded * @return (conditional) the decoded String contents */ static conditional String decodeUtf8MimeHeader(String text) { @@ -539,6 +529,17 @@ service DigestAuthenticator(Realm realm) return False; } + /** + * Hash to hash-string conversion. Hashes are often converted to strings of lowercase hexits, + * without the leading "0x", so that they can be concatenated with other strings, so that those + * strings can be hashed, etc. + * + * @param nonce the nonce value + * + * @return the nonce string + */ + static String toString(Int nonce) = toString(nonce.toByteArray(Constant).as(Hash)); + /** * Hash to hash-string conversion. Hashes are often converted to strings of lowercase hexits, * without the leading "0x", so that they can be concatenated with other strings, so that those diff --git a/lib_web/src/main/x/web/security/DigestCredential.x b/lib_web/src/main/x/web/security/DigestCredential.x new file mode 100644 index 0000000000..4caa609066 --- /dev/null +++ b/lib_web/src/main/x/web/security/DigestCredential.x @@ -0,0 +1,261 @@ +import crypto.Signer; + +import sec.Credential; + +/** + * A `DigestCredential` represents a user name and password, with the password (and potentially the + * user name) stored as hashes. + */ +const DigestCredential + extends Credential { + + /** + * "md" == Message Digest + */ + static String Scheme = "md"; + + /** + * Construct a `DigestCredential` for the specified user with the specified password. + * + * @param realmName the realm name (note: not stored on the DigestCredential) + * @param userName the user name in plain text + * @param password the user password in plain text (note: not stored on the DigestCredential) + */ + construct(String realmName, String userName, String password) { + construct Credential(Scheme); + name = userName; + name_md5 = userHash(userName, realmName, md5); + name_sha256 = userHash(userName, realmName, sha256); + name_sha512_256 = userHash(userName, realmName, sha512_256); + password_md5 = passwordHash(userName, realmName, password, md5); + password_sha256 = passwordHash(userName, realmName, password, sha256); + password_sha512_256 = passwordHash(userName, realmName, password, sha512_256); + } + + /** + * Internal constructor for [with] method and subclasses. + */ + protected construct( + String scheme, + Time? validFrom, + Time? validUntil, + Status? status, + String name, + Byte[] name_md5, + Byte[] name_sha256, + Byte[] name_sha512_256, + Byte[] password_md5, + Byte[] password_sha256, + Byte[] password_sha512_256, + ) { + construct Credential(scheme, validFrom, validUntil, status); + this.name = name; + this.name_md5 = name_md5; + this.name_sha256 = name_sha256; + this.name_sha512_256 = name_sha512_256; + this.password_md5 = password_md5; + this.password_sha256 = password_sha256; + this.password_sha512_256 = password_sha512_256; + } + + // ----- construction -------------------------------------------------------------------------- + + + /** + * Create a copy of this `DigestCredential`, but with specific attributes modified. + * + * @param scheme the new `scheme` name, or pass `Null` to leave unchanged + * @param validFrom the new `validFrom` value to use, or `Null` to leave unchanged + * @param validUntil the new `validUntil` value to use, or `Null` to leave unchanged + * @param status the new `status` value to use, or `Null` to leave unchanged; the + * only legal [Status] values to pass are `Active`, `Suspended`, and + * `Revoked`; passing `Active` will result in the [status] of `Null` + * @param realmName the realm name, which is required if changing the user name + * and/or password + * @param userName the new [name] value, or pass `Null` to leave unchanged; if the + * user name is changed, then the realm name and plain text password + * must also be passed + * @param password the plain text password value, or pass `Null` to leave unchanged; + * if the password is changed, then the realm name must also be + * passed + * @param name_md5 the new value for [name_md5], or pass `Null` to leave unchanged + * @param name_sha256 the new value for [name_sha256], or pass `Null` to leave + * unchanged + * @param name_sha512_256 the new value for [name_sha512_256], or pass `Null` to leave + * unchanged + * @param password_md5 the new value for [password_md5], or pass `Null` to leave + * unchanged + * @param password_sha256 the new value for [password_sha256], or pass `Null` to leave + * unchanged + * @param password_sha512_256 the new value for [password_sha512_256], or pass `Null` to leave + * unchanged + */ + @Override + DigestCredential with( + String? scheme = Null, + Time? validFrom = Null, + Time? validUntil = Null, + Status? status = Null, + String? realmName = Null, + String? userName = Null, + String? password = Null, + Byte[]? name_md5 = Null, + Byte[]? name_sha256 = Null, + Byte[]? name_sha512_256 = Null, + Byte[]? password_md5 = Null, + Byte[]? password_sha256 = Null, + Byte[]? password_sha512_256 = Null, + ) { + if (realmName != Null || password != Null) { + assert password == Null || realmName != Null; + if (realmName != Null) { + userName ?:= name; + name_md5 = userHash(userName, realmName, md5); + name_sha256 = userHash(userName, realmName, sha256); + name_sha512_256 = userHash(userName, realmName, sha512_256); + if (password == Null) { + assert name_md5 == this.name_md5 + && name_sha256 == this.name_sha256 + && name_sha512_256 == this.name_sha512_256; + } else { + password_md5 = passwordHash(userName, realmName, password, md5); + password_sha256 = passwordHash(userName, realmName, password, sha256); + password_sha512_256 = passwordHash(userName, realmName, password, sha512_256); + } + } + } + return new DigestCredential( + scheme = scheme ?: this.scheme, + validFrom = validFrom ?: this.validFrom, + validUntil = validUntil ?: this.validUntil, + status = status ?: this.status, + name = userName ?: this.name, + name_md5 = name_md5 ?: this.name_md5, + name_sha256 = name_sha256 ?: this.name_sha256, + name_sha512_256 = name_sha512_256 ?: this.name_sha512_256, + password_md5 = password_md5 ?: this.password_md5, + password_sha256 = password_sha256 ?: this.password_sha256, + password_sha512_256 = password_sha512_256 ?: this.password_sha512_256, + ); + } + + // ----- properties ---------------------------------------------------------------------------- + + String name; + Byte[] name_md5; + Byte[] name_sha256; + Byte[] name_sha512_256; + Byte[] password_md5; + Byte[] password_sha256; + Byte[] password_sha512_256; + + @Override + String[] locators.get() = [ + name.quoted(), + name_md5.toString(pre=""), + name_sha256.toString(pre=""), + name_sha512_256.toString(pre=""), + ]; + + // ----- API ----------------------------------------------------------------------------------- + + /** + * Test if this `DigestCredential` refers to the specified [UserId]. + * + * @param userId a user name or user name hash + * + * @return `True` iff this credential refers to the specified user + */ + Boolean isUser(UserId userId) { + if (userId.is(String)) { + return name == userId; + } else { + return name_md5 == userId || name_sha256 == userId || name_sha512_256 == userId; + } + } + + /** + * Given a "hasher" aka [Signer], obtain the corresponding password hash from this `Credential`. + * + * @param hasher TODO + * + * @return True if the `Signer` is recognized by this `DigestCredential` + * @return (conditional) the corresponding password hash + */ + conditional Hash findPasswordHash(Signer hasher) { + return switch (hasher.algorithm.name) { + case "MD5": (True, password_md5); + case "SHA-256": (True, password_sha256); + case "SHA-512-256": (True, password_sha512_256); + default: False; + }; + } + + // ----- internal ------------------------------------------------------------------------------ + + static Signer md5 = { + @Inject crypto.Algorithms algorithms; + return algorithms.hasherFor("MD5") ?: assert as "MD5 Signer required"; + }; + + static Signer sha256 = { + @Inject crypto.Algorithms algorithms; + return algorithms.hasherFor("SHA-256") ?: assert as "SHA-256 Signer required"; + }; + + static Signer sha512_256 = { + @Inject crypto.Algorithms algorithms; + return algorithms.hasherFor("SHA-512-256") ?: assert as "SHA-512-256 Signer required"; + }; + + /** + * A `Hash` is an immutable array of bytes. + */ + typedef immutable Byte[] as Hash; + + /** + * A `UserId` is either a plain text user name, or the hash specified in section 3.4.4 of the + * [HTTP Digest Access Authentication](https://datatracker.ietf.org/doc/html/rfc7616) standard: + * + * username = H( unq(username) ":" unq(realm) ) + */ + typedef String | Hash as UserId; + + /** + * Produce a user hash. + * + * The approach specified in section 3.4.4 of the + * [HTTP Digest Access Authentication](https://datatracker.ietf.org/doc/html/rfc7616) standard + * serves as the basis for this feature: + * + * username = H( unq(username) ":" unq(realm) ) + * + * @param user the user name in plain text, or the hash of the user name as specified by + * [RFC7616](https://datatracker.ietf.org/doc/html/rfc7616#section-3.4.4) + * @param realm the realm name in plain text + * @param hasher the hasher (a [Signer]) to use + * + * @return the digest that represents the user name + */ + static Hash userHash(String user, String realm, Signer hasher) { + return hasher.sign($"{user}:{realm}".utf8()).bytes.freeze(inPlace=True); + } + + /** + * Produce a password digest. + * + * The [HTTP Digest Access Authentication](https://datatracker.ietf.org/doc/html/rfc7616) + * standard specifies the password digest that the client and server are each aware of as + * `H(user:realm:pwd)`, where `H` is the hash function. + * + * @param user the user name in plain text + * @param realm the realm name in plain text + * @param password the password in plain text + * @param hasher the hasher (a [Signer]) to use + * + * @return the digest that represents the password information + */ + static Hash passwordHash(String user, String realm, String password, Signer hasher) { + return hasher.sign($"{user}:{realm}:{password}".utf8()).bytes.freeze(inPlace=True); + } +} \ No newline at end of file diff --git a/lib_web/src/main/x/web/security/FixedRealm.x b/lib_web/src/main/x/web/security/FixedRealm.x index 7f7d7a9371..8e7e131786 100644 --- a/lib_web/src/main/x/web/security/FixedRealm.x +++ b/lib_web/src/main/x/web/security/FixedRealm.x @@ -1,226 +1,148 @@ -import crypto.Signer; +import sec.Credential; +import sec.Group; +import sec.Permission; +import sec.PlainTextCredential; +import web.security.DigestCredential; /** * A FixedRealm is a realm implementation with a fixed number of named users, each with a fixed * password. */ -const FixedRealm +const FixedRealm(String name, Principal[] principals, Group[] groups = [], + Entitlement[] entitlements = []) implements Realm { + /** - * Construct a `FixedRealm` from plain text user names and passwords, using an optional list - * of [hashing algorithms](Signer). - * - * @param realmName the human readable name of the realm - * @param userPwds the user names and passwords, in plain text form - * @param userRoles (optional) the user names and roles, in plain text form - * @param hashers (optional) the [hashing algorithms](Signer) to support, which allow a - * client to never send either a user name or password in plain text, and - * which simultaneously allow the server to store the user names and passwords - * in a cryptographically secure digest form + * Construct a FixedRealm for a single user/password. The specified user is granted all + * permissions. */ - construct(String realmName, - Map userPwds, - Map userRoles = [], - Signer[] hashers = [], - ) { - // select a default signer; use the weakest of the signers by default (which should be at - // the very end of the list) - Signer defaultHasher; - if (!(defaultHasher := hashers.last())) { - @Inject crypto.Algorithms algorithms; - if (!(defaultHasher := algorithms.hasherFor("MD5"))) { - defaultHasher = TODO create crypto.hashers.MD5 class - } - } - - // incorporate the default hasher into the array of hashers if it wasn't already present - if (hashers.empty) { - hashers = [defaultHasher]; - } + construct(String realmName, String userName, String password) { + name = realmName; + principals = [new Principal(0, userName, + permissions=[AllowAll], + credentials=[new PlainTextCredential(userName, password), + new DigestCredential(name, userName, password)], + )]; + } - // hash the passwords (and possibly the user names) - Int userCount = userPwds.size; - Int hasherCount = hashers.size; - HashMap pwdsByUser = new HashMap(userCount); - HashMap> rolesByUser = new HashMap(userRoles.size); - HashMap usersByHash = new HashMap(userCount * hasherCount); - - static Boolean addUser(HashMap.Entry entry, String user) { - if (entry.exists) { - String|String[] users = entry.value; - entry.value = users.is(String[]) ? users + user : [users, user]; - } else { - entry.value = user; - } - return True; - } + // ----- properties ---------------------------------------------------------------------------- - if (hasherCount > 1) { - for ((String user, String pwd) : userPwds) { - pwdsByUser.put(user, new Hash[hasherCount] - (i -> passwordHash(user, realmName, pwd, hashers[i])) - .freeze(inPlace=True)); - } + @Override + @RO Boolean readOnly.get() = True; - for (Signer hasher : hashers) { - for (String user : userPwds) { - usersByHash.process(userHash(user, realmName, hasher), addUser(_, user)); + /** + * An index from scheme/locator to Principal. + */ + @Lazy Map principalIndex.calc() { + HashMap index = new HashMap(); + for (Principal principal : principals) { + for (Credential credential : principal.credentials) { + String scheme = credential.scheme; + for (String locator : credential.locators) { + index[$"{scheme}:{locator}"] = principal; } } - } else { - for ((String user, String pwd) : userPwds) { - usersByHash.process(userHash(user, realmName, defaultHasher), addUser(_, user)); - pwdsByUser.put(user, passwordHash(user, realmName, pwd, defaultHasher)); - } } + return index.freeze(inPlace=True); + } - // build the lookup from user to roles - Map, HashSet> intern = new HashMap(); - for ((String user, String[] roles) : userRoles) { - if (pwdsByUser.contains(user) && !roles.empty) { - HashSet roleSet = new HashSet(roles).freeze(True); - if (!(roleSet := intern.get(roleSet))) { - intern.put(roleSet, roleSet); + /** + * An index from scheme/locator to Entitlement. + */ + @Lazy Map entitlementIndex.calc() { + HashMap index = new HashMap(); + for (Entitlement entitlement : entitlements) { + for (Credential credential : entitlement.credentials) { + String scheme = credential.scheme; + for (String locator : credential.locators) { + index[$"{scheme}:{locator}"] = entitlement; } - rolesByUser.put(user, new HashSet(roles)); } } - - this.name = realmName; - this.hashers = hashers; - this.defaultHasher = defaultHasher; - this.pwdsByUser = pwdsByUser.freeze(True); - this.rolesByUser = rolesByUser.freeze(True); - this.usersByHash = usersByHash.freeze(True); + return index.freeze(inPlace=True); } - /** - * The default [hashing algorithm](Signer) is the weakest one provided to this FixedRealm, or - * the default MD5 algorithm if none was provided. - */ - protected/private Signer defaultHasher; - - /** - * For each user, a password hash from each [hashing algorithm](Signer) is held. - */ - protected/private immutable Map pwdsByUser; + // ----- operations: Principals ---------------------------------------------------------------- - /** - * For each user, a list of roles for that user. - */ - protected/private immutable Map> rolesByUser; - - /** - * For each user hash, there is typically only one user name; in the rare case that the hashes - * collide for each [hashing algorithm](Signer) provided to this realm (or the default MD5 - * if none was provided), a map of user hash to user(s) is held. - */ - protected/private immutable Map usersByHash; - - - // ----- Realm interface ----------------------------------------------------------------------- + @Override + Iterator findPrincipals(function Boolean(Principal) match) { + return principals.filter(match).iterator(); + } @Override - conditional Set validUser(String user) { - return pwdsByUser.contains(user) - ? (True, rolesByUser.get(user) ?: []) - : False; + conditional Principal findPrincipal(String scheme, String locator) { + return principalIndex.get($"{scheme}:{locator}"); } @Override - conditional Set authenticate(String user, String password) { - Hash hash = passwordHash(user, name, password, defaultHasher); - if ((_, Set roles) := authenticateHash(user, hash, defaultHasher)) { - return True, roles; + Principal createPrincipal(Principal principal) = throw new ReadOnly(); + + @Override + conditional Principal readPrincipal(Int id) { + if (0 <= id < principals.size) { + return True, principals[id]; } return False; } @Override - Signer[] hashers; + Principal updatePrincipal(Principal principal) = throw new ReadOnly(); @Override - Hash[] hashesFor(UserId userId, Signer hasher) { - if (userId.is(String)) { - if (Hash|Hash[] pwdHashes := pwdsByUser.get(userId)) { - return [pwdHashes.is(Hash) ? pwdHashes : pwdHashes[hashIndex(hasher)]]; - } + Boolean deletePrincipal(Int|Principal principal) = throw new ReadOnly(); - return []; - } - - String|String[] users = usersByHash.getOrDefault(userId, []); - if (users.is(String)) { - return hashesFor(users, hasher); - } + // ----- operations: Groups ---------------------------------------------------------------- - switch (users.size) { - case 0: - return []; + @Override + Iterator findGroups(function Boolean(Group) match) { + return groups.filter(match).iterator(); + } - case 1: - return hashesFor(users[0], hasher); + @Override + Group createGroup(Group group) = throw new ReadOnly(); - default: - Hash[] pwdHashes = new Hash[]; - for (String user : users) { - pwdHashes += hashesFor(user, hasher); - } - return pwdHashes.freeze(inPlace=True); + @Override + conditional Group readGroup(Int id) { + if (0 <= id < groups.size) { + return True, groups[id]; } + return False; } @Override - conditional (String, Set) authenticateHash(UserId userId, Hash pwdHash, Signer hasher) { - if (userId.is(String)) { - if (Hash|Hash[] pwdHashes := pwdsByUser.get(userId)) { - Hash checkHash = pwdHashes.is(Hash) ? pwdHashes : pwdHashes[hashIndex(hasher)]; - return pwdHash == checkHash, userId, rolesByUser.get(userId) ?: []; - } - - return False; - } + Group updateGroup(Group group) = throw new ReadOnly(); - // look up the user by the user hash - if (String|String[] plainTextUsers := usersByHash.get(userId)) { - if (plainTextUsers.is(String)) { - return authenticateHash(plainTextUsers, pwdHash, hasher); - } + @Override + Boolean deleteGroup(Int|Group group) = throw new ReadOnly(); - for (String plainTextUser : plainTextUsers) { - if ((_, Set roles) := authenticateHash(plainTextUser, pwdHash, hasher)) { - return True, plainTextUser, roles; - } - } - } + // ----- operations: Entitlements -------------------------------------------------------------- - return False; + @Override + Iterator findEntitlements(function Boolean(Entitlement) match) { + return entitlements.filter(match).iterator(); } - // ----- internal ------------------------------------------------------------------------------ + @Override + conditional Entitlement findEntitlement(String scheme, String locator) { + return entitlementIndex.get($"{scheme}:{locator}"); + } - /** - * Obtain the index within the values (arrays of hashed passwords) stored in `pwdsByUser` that - * corresponds to a particular [hasher](Signer). - * - * @param hasher the [hasher](Signer) that indicates which digested passwords to hash or verify - * - * @return the index within the arrays of hashed passwords stored as the values of the - * `pwdsByUser` map - */ - protected Int hashIndex(Signer hasher) { - if (hasher == defaultHasher) { - return hashers.size - 1; - } + @Override + Entitlement createEntitlement(Entitlement entitlement) = throw new ReadOnly(); - Loop: for (val candidate : hashers) { - if (hasher.algorithm == candidate.algorithm) { - return Loop.count; - } + @Override + conditional Entitlement readEntitlement(Int id) { + if (0 <= id < entitlements.size) { + return True, entitlements[id]; } - - assert as $"Unknown hasher: {hasher}"; + return False; } + + @Override + Entitlement updateEntitlement(Entitlement entitlement) = throw new ReadOnly(); + + @Override + Boolean deleteEntitlement(Int|Entitlement entitlement) = throw new ReadOnly(); } \ No newline at end of file diff --git a/lib_web/src/main/x/web/security/NeverAuthenticator.x b/lib_web/src/main/x/web/security/NeverAuthenticator.x index 42fe968d86..a15197f8bf 100644 --- a/lib_web/src/main/x/web/security/NeverAuthenticator.x +++ b/lib_web/src/main/x/web/security/NeverAuthenticator.x @@ -2,10 +2,25 @@ * An implementation of the Authenticator interface that rejects all authentication attempts. */ service NeverAuthenticator - implements Authenticator { + implements Duplicable, Authenticator { + + // ----- constructors -------------------------------------------------------------------------- + + construct() {} + + @Override + construct(NeverAuthenticator that) {} + + // ----- Authenticator API --------------------------------------------------------------------- + + @Override + Realm realm = new FixedRealm("Neverland", []); + + @Override + Attempt[] findAndRevokeSecrets(RequestIn request) = []; @Override - AuthStatus|ResponseOut authenticate(RequestIn request, Session session) { + Attempt[] authenticate(RequestIn request) { private Boolean logged = False; if (!logged) { // log a message the first time this Authenticator has to reject a user, so the @@ -18,6 +33,7 @@ service NeverAuthenticator logged = True; } - return Forbidden; + static Attempt[] Never = [new Attempt(Null, NoData, Forbidden)]; + return Never; } } diff --git a/lib_web/src/main/x/web/security/Realm.x b/lib_web/src/main/x/web/security/Realm.x deleted file mode 100644 index 56a44adf16..0000000000 --- a/lib_web/src/main/x/web/security/Realm.x +++ /dev/null @@ -1,254 +0,0 @@ -import crypto.Signer; - - -/** - * A Realm is a named security domain, and an implementation of this interface is responsible - * for the validation of user credentials, specifically the user name and password. - */ -interface Realm { - typedef immutable Byte[] as Hash; - - /** - * A group of hashes, one for each of the supported hash algorithms. If a hash algorithm is not - * configured to be used, then its hash will be a zero-length byte array. - */ - static const HashInfo - ( - Time created, - Hash md5, - Hash sha256, - Hash sha512_256, - ) { - conditional Hash hashFor(Signer hasher) { - Hash hash; - switch (hasher.algorithm.name) { - case "MD5": - hash = md5; - break; - - case "SHA-256": - hash = sha256; - break; - - case "SHA-512-256": - hash = sha512_256; - break; - - default: - return False; - } - - return hash.empty - ? False - : (True, hash); - } - } - - /** - * A `UserId` is either a plain text user name, or the hash specified in section 3.4.4 of the - * [HTTP Digest Access Authentication](https://datatracker.ietf.org/doc/html/rfc7616) standard: - * - * username = H( unq(username) ":" unq(realm) ) - */ - typedef String | Hash as UserId; - - /** - * The name of the Realm is intended to be human-readable and short-yet-descriptive. - */ - @RO String name; - - /** - * Verify that the passed user name is valid, for example that the account is not disabled, and - * obtain the roles associated with the user. - * - * @param user the user's identity, as provided by the client - * @param password the user's password, as provided by the client - * - * @return True iff the user identity is verified to exist, and is active (e.g. can log in) - * @return (conditional) the role names associated with the user, if any, otherwise `[]` - */ - conditional Set validUser(String user); - - /** - * Authenticate the passed user name and password. - * - * @param user the user's identity, as provided by the client - * @param password the user's password, as provided by the client - * - * @return True iff the user identity is verified to exist, and the provided password is - * correct for that user - * @return (conditional) the role names associated with the user, if any, otherwise `[]` - */ - conditional Set authenticate(String user, String password); - - /** - * A realm may support user id and password hashes directly, which allows the realm to not store - * the credential information in plain text. (Supporting the hash of the user id also avoids the - * user id being _transmitted_ as plain text.) - * - * An empty array indicates that no hashes are supported by the realm. - * - * Hashing algorithms are expected to included some subset (or none) of the following: - * - * * SHA-512-256 - * * SHA-256 - * * MD5 - * - * It is possible that a custom Realm implementation may choose to support other hashing - * algorithms, or not support any of those listed above. The implementations listed above are - * specified by the - * [HTTP Digest Access Authentication](https://datatracker.ietf.org/doc/html/rfc7616) standard. - * - * Using the HTTP Digest Access Authentication method, the server may specify several of these - * to the client as being acceptable; the client should use the first one in the list (in the - * `401 Unauthorized` response message) that it supports; this is why MD5 is listed last, since - * it is the weakest hash, and fairly easy to defeat using a brute force attack. Unfortunately, - * many clients still only support MD5 because it has only been a few decades since the other - * options were added. - */ - @RO Signer[] hashers.get() { - return []; - } - - /** - * Given a user (either plain text or hashed) and a [hasher](Signer), obtain the corresponding - * hashed password information, if any. - * - * The forms of the hashes are specified by the - * [HTTP Digest Access Authentication](https://datatracker.ietf.org/doc/html/rfc7616) - * standard. - * - * If the `userId` is hashed, the hash is in the form: - * - * username = H( unq(username) ":" unq(realm) ) - * - * The returned hashes are in the form: - * - * A1 = unq(username) ":" unq(realm) ":" passwd - * - * @param userId the plain text or hashed user identity - * @param hasher the specific [hasher](Signer) which corresponds to the resulting password - * hashes - * - * @return an array of zero or more password hashes that correspond to the specified `userId` - */ - Hash[] hashesFor(UserId userId, Signer hasher); - - /** - * Authenticate the passed user name, which may be hashed, and password, which is hashed. - * - * The forms of the hashes are specified by the - * [HTTP Digest Access Authentication](https://datatracker.ietf.org/doc/html/rfc7616) - * standard. - * - * If the `userId` is hashed, the hash is in the form: - * - * username = H( unq(username) ":" unq(realm) ) - * - * The `pwdHash` is in the form: - * - * A1 = unq(username) ":" unq(realm) ":" passwd - * - * @param userId the user's identity, which may be either plain text or a hash - * @param pwdHash the user's password, which is in a hashed form - * - * @return True iff the user identity is verified to exist, and the provided password is - * correct for that user - * @return (conditional) the user identity in plain text - * @return (conditional) the role names associated with the user, if any, otherwise `[]` - */ - conditional (String, Set) authenticateHash(UserId userId, Hash pwdHash, Signer hasher) { - TODO User identity and password hashing are not supported by this Realm - } - - /** - * Hash the user and password data; this method is performing the same work as [userHash()] - * and [passwordHash()] for each of the (up to) three supported algorithms. - * - * @param userName the user name - * @param password (optional) password - */ - (HashInfo userHashes, HashInfo pwdHashes) createHashes(String userName, String? password) { - Hash userBytes = $"{userName}:{name}".utf8(); - Hash pwdBytes = $"{userName}:{name}:{password ?: ""}".utf8(); - - Signer[] hashers = hashers; - Signer? md5 = Null; - Signer? sha256 = Null; - Signer? sha512_256 = Null; - for (Signer signer : hashers) { - switch (signer.algorithm.name) { - case "MD5": - md5 = signer; - break; - case "SHA-256": - sha256 = signer; - break; - case "SHA-512-256": - sha512_256 = signer; - break; - } - } - - @Inject Clock clock; - Time created = clock.now; - - HashInfo userHashes = new HashInfo(created, - md5? .sign(userBytes).bytes : [], - sha256? .sign(userBytes).bytes : [], - sha512_256?.sign(userBytes).bytes : [], - ); - - HashInfo pwdHashes = new HashInfo(created, - md5? .sign(pwdBytes).bytes : [], - sha256? .sign(pwdBytes).bytes : [], - sha512_256?.sign(pwdBytes).bytes : [], - ); - - return (userHashes, pwdHashes); - } - - - // ----- helpers ------------------------------------------------------------------------------- - - /** - * Produce a user hash. - * - * The approach specified in section 3.4.4 of the - * [HTTP Digest Access Authentication](https://datatracker.ietf.org/doc/html/rfc7616) standard - * serves as the basis for this feature: - * - * username = H( unq(username) ":" unq(realm) ) - * - * @param user the user name in plain text, or the hash of the user name as specified by - * [RFC7616](https://datatracker.ietf.org/doc/html/rfc7616#section-3.4.4) - * @param realm the realm name in plain text - * @param hasher the hasher (a [Signer]) to use - * - * @return the digest that represents the user information that is used by the FixedRealm as a - * key to the password digest - */ - static Hash userHash(UserId user, String realm, Signer hasher) { - return user.is(String) - ? hasher.sign($"{user}:{realm}".utf8()).bytes - : user; - } - - /** - * Produce a password digest. - * - * The [HTTP Digest Access Authentication](https://datatracker.ietf.org/doc/html/rfc7616) - * standard specifies the password digest that the client and server are each aware of as - * `H(user:realm:pwd)`, where `H` is the hash function. - * - * @param user the user name in plain text - * @param realm the realm name in plain text - * @param password the password in plain text - * @param hasher the hasher (a [Signer]) to use - * - * @return the digest that represents the password information as it is held by the FixedRealm - */ - static Hash passwordHash(String user, String realm, String password, Signer hasher) { - return hasher.sign($"{user}:{realm}:{password}".utf8()).bytes; - } -} diff --git a/lib_web/src/main/x/web/security/TokenAuthenticator.x b/lib_web/src/main/x/web/security/TokenAuthenticator.x index 9d2527526c..3ae2acae45 100644 --- a/lib_web/src/main/x/web/security/TokenAuthenticator.x +++ b/lib_web/src/main/x/web/security/TokenAuthenticator.x @@ -15,23 +15,40 @@ import responses.SimpleResponse; service TokenAuthenticator implements Authenticator { + // ----- constructors -------------------------------------------------------------------------- + /** * Construct the `TokenAuthenticator` for the specified [Realm]. */ construct(Realm realm) { - this.realm = realm; + this.realm = realm; + } + + @Override + construct(TokenAuthenticator that) { + this.realm = that.realm; } + + // ----- properties ---------------------------------------------------------------------------- + /** * The Realm that contains the user/token information. */ - public/private Realm realm; + @Override + public/protected Realm realm; // ----- Authenticator interface --------------------------------------------------------------- @Override - AuthStatus|ResponseOut authenticate(RequestIn request, Session session) { + Attempt[] findAndRevokeSecrets(RequestIn request) { + // TODO + return []; + } + + @Override + Attempt[] authenticate(RequestIn request) { // TLS is a pre-requisite for authentication assert request.scheme.tls; @@ -44,7 +61,7 @@ service TokenAuthenticator try { auth = Utf8Codec.decode(Base64Format.Instance.decode(auth.substring(7))); } catch (Exception e) { - return new SimpleResponse(BadRequest); + return [new Attempt(Null, Failed, BadRequest)]; } String user; @@ -59,14 +76,15 @@ service TokenAuthenticator token = auth; } - if (Set roles := realm.authenticate(user, token)) { - session.authenticate(user, roles=roles); - return Allowed; - } else { - return Forbidden; - } +// TODO +// if (Set roles := realm.authenticate(user, token)) { +// session?.authenticate(user, roles=roles); +// return Allowed; +// } else { +// return Forbidden; +// } } } - return Unknown; + return [new Attempt(Null, NoData)]; } } \ No newline at end of file diff --git a/lib_web/src/main/x/web/sessions/Broker.x b/lib_web/src/main/x/web/sessions/Broker.x new file mode 100644 index 0000000000..892df9298c --- /dev/null +++ b/lib_web/src/main/x/web/sessions/Broker.x @@ -0,0 +1,52 @@ +/** + * A [Session] `Broker` is a service that knows how to provide a `Session` for an + * [incoming request](RequestIn), if the request is associated with a `Session` or if the associated + * `Endpoint` requires a `Session` and one must be created. + * + * The `Session` `Broker` is expected to be a bottleneck, since implementations are likely to employ + * computationally expensive logic including secure hashes and/or encryption, and may also rely on + * a persistent store such as a database; to maximize concurrency, the web server can + * [duplicate](Duplicable.duplicate) a `Session` `Broker` as necessary in order to avoid contention + * on a single instance. + */ +interface Broker + extends Duplicable, service { + /** + * Find the [Session] indicated by the [request](RequestIn). The `Broker` is permitted to create + * a `Session` if one is specified by the request in a trustworthy manner, but does not yet + * exist; for example, a "device id" may be assigned to a device, and on the device's first + * communication with the server, a `Session` may be realized (created) for it. + * + * @param request the incoming [request](RequestIn) + * + * @return `True` iff the client is supported by this `Broker`, and a [Session] is indicated by + * the [request](RequestIn) and it exists + * @return (conditional) the [Session] indicated by the [request](RequestIn) + * @return (conditional) if non-`Null`, this is a [response](ResponseOut) that must immediately + * be sent to the client as part of the Session establishment or maintenance dialogue + */ + conditional (Session, ResponseOut?) findSession(RequestIn request); + + /** + * Find the [Session] indicated by the [request](RequestIn), if it exists; otherwise, create one + * if possible, returning either the new `Session` or the response to the client necessary to + * establish the new `Session`. + * + * If the request indicates a session and the indicated session exists, then return it. If no + * session exists, and one is required, then create and return it. If further communication with + * the client is required to establish the session, or if an error occurs, then return a + * [response](ResponseOut) for the client. + * + * @param request the incoming [request](RequestIn) + * + * @return `True` iff this Broker is capable of handling the [Session]-related duties for the + * request + * @return (conditional) the [Session] indicated by the [request](RequestIn), or created for it; + * if the returned `Session` is `Null`, that indicates that the `Session` cannot be + * created yet because the dialogue (session-creation negotiation) with the client has + * not progressed sufficiently, and the returned [ResponseOut] must be non-`Null` + * @return (conditional) if non-`Null`, this is a [response](ResponseOut) that must immediately + * be sent to the client as part of the Session establishment or maintenance dialogue + */ + conditional (Session?, ResponseOut?) requireSession(RequestIn request); +} diff --git a/lib_web/src/main/x/web/sessions/ChainedBroker.x b/lib_web/src/main/x/web/sessions/ChainedBroker.x new file mode 100644 index 0000000000..2517d16f97 --- /dev/null +++ b/lib_web/src/main/x/web/sessions/ChainedBroker.x @@ -0,0 +1,62 @@ +/** + * A `ChainedBroker` enables the use of more than one [Session Broker](Broker) when an application + * needs to support different client types, such as when an application has both a web browser + * client (relying on cookie support) and native device application clients (with unique client ID + * support). + */ + service ChainedBroker + implements Broker { + + /** + * Construct a `ChainedBroker` from a list of [Brokers](Broker). + */ + construct(Broker[] brokers) { + assert !brokers.empty; + this.brokers = brokers.freeze(); + } + + /** + * [Duplicable] constructor. + */ + @Override + construct(ChainedBroker that) { + this.brokers = new Broker[that.brokers.size](i -> that.brokers[i].duplicate()).freeze(inPlace=True); + } + + /** + * A list of [Brokers](Broker). + */ + public/private Broker[] brokers; + + @Override + conditional (Session, ResponseOut?) findSession(RequestIn request) { + for (Broker broker : brokers) { + if ((Session session, ResponseOut? response) := broker.findSession(request)) { + // regardless of whether any other broker could answer the question positively, take + // the first positive answer because the order of the brokers is significant + return True, session, response; + } + } + return False; + } + + @Override + conditional (Session?, ResponseOut?) requireSession(RequestIn request) { + if ((Session session, ResponseOut? response) := findSession(request)) { + return True, session, response; + } + + ResponseOut? firstResponse = Null; + for (Broker broker : brokers) { + if ((Session? session, ResponseOut? response) := broker.requireSession(request)) { + if (session != Null) { + return True, session, response; + } + firstResponse ?:= response; + } + } + return firstResponse == Null + ? False + : True, Null, firstResponse; + } +} diff --git a/lib_web/src/main/x/web/sessions/NeverBroker.x b/lib_web/src/main/x/web/sessions/NeverBroker.x new file mode 100644 index 0000000000..aca5af71c6 --- /dev/null +++ b/lib_web/src/main/x/web/sessions/NeverBroker.x @@ -0,0 +1,25 @@ +import responses.SimpleResponse; + +/** + * An `SessionBroker` is a service that knows how to provide a session for an incoming request, if + * the request is associated with a session or if the associated `Endpoint` requires a session and + * one must be created. + */ +service NeverBroker + implements Duplicable, Broker { + + // ----- constructors -------------------------------------------------------------------------- + + construct() {} + + @Override + construct(NeverBroker that) {} + + // ----- Broker API ---------------------------------------------------------------------------- + + @Override + conditional (Session, ResponseOut?) findSession(RequestIn request) = False; + + @Override + conditional (Session?, ResponseOut?) requireSession(RequestIn request) = False; +} diff --git a/lib_webauth/build.gradle.kts b/lib_webauth/build.gradle.kts index 42ed704657..92ec74bdc7 100644 --- a/lib_webauth/build.gradle.kts +++ b/lib_webauth/build.gradle.kts @@ -13,5 +13,6 @@ dependencies { xtcModule(libs.xdk.json) xtcModule(libs.xdk.net) xtcModule(libs.xdk.oodb) + xtcModule(libs.xdk.sec) xtcModule(libs.xdk.web) } diff --git a/lib_webauth/src/main/x/webauth.x b/lib_webauth/src/main/x/webauth.x index 38ddf0a379..527eb752ff 100644 --- a/lib_webauth/src/main/x/webauth.x +++ b/lib_webauth/src/main/x/webauth.x @@ -16,28 +16,33 @@ */ module webauth.xtclang.org { package crypto import crypto.xtclang.org; - package net import net.xtclang.org; // TODO "for this module, I want to override injection of ..." + package net import net.xtclang.org; package oodb import oodb.xtclang.org; + package sec import sec.xtclang.org; package web import web.xtclang.org; import crypto.Signer; import crypto.Signature; + import net.IPAddress; - import web.security.Realm; - import Realm.Hash; - import Realm.HashInfo; - /** - * Information about the use of a particular IP address. - */ - const IPInfo - ( - IPAddress ip, - Int passCount, - Time? lastPass, - Int failCount, - Time? lastFail, - ); + import sec.Credential; + import sec.Entitlement; + import sec.Group; + import sec.Principal; + import sec.Realm; + +// /** +// * Information about the use of a particular IP address. +// */ +// const IPInfo +// ( +// IPAddress ip, +// Int passCount, +// Time? lastPass, +// Int failCount, +// Time? lastFail, +// ); // /** // * For a telephonic device, what type is the device? diff --git a/lib_webauth/src/main/x/webauth/AuthSchema.x b/lib_webauth/src/main/x/webauth/AuthSchema.x index b56a50b7e7..159a95a5c7 100644 --- a/lib_webauth/src/main/x/webauth/AuthSchema.x +++ b/lib_webauth/src/main/x/webauth/AuthSchema.x @@ -18,32 +18,44 @@ interface AuthSchema @RO DBValue config; /** - * Internal user id generator. + * The [Principal] objects that exist within the [DBRealm]. */ - @RO @NoTx DBCounter userId; + @RO DBMap principals; /** - * The users that can be authenticated. + * Internal [Principal] id generator. */ - @RO Users users; + @RO @NoTx DBCounter principalGen; /** - * User contact information. + * A lookup table from [Credential] "locator" strings to the id of the [Principal] that contains + * that `Credential`. */ -// TODO @RO Contacts contacts; + @RO DBMap principalLocators; /** - * The users that can be authenticated. + * The [Group] objects that exist within the [DBRealm]. */ - @RO UserHistory userHistory; + @RO DBMap groups; /** - * Internal role id generator. + * Internal [Group] id generator. */ - @RO @NoTx DBCounter roleId; + @RO @NoTx DBCounter groupGen; /** - * The roles that can be associated with a user. + * The [Entitlement] objects that exist within the [DBRealm]. */ - @RO Roles roles; + @RO DBMap entitlements; + + /** + * Internal [Entitlement] id generator. + */ + @RO @NoTx DBCounter entitlementGen; + + /** + * A lookup table from [Credential] "locator" strings to the id of the [Entitlement] that + * contains that `Credential`. + */ + @RO DBMap entitlementLocators; } \ No newline at end of file diff --git a/lib_webauth/src/main/x/webauth/Configuration.x b/lib_webauth/src/main/x/webauth/Configuration.x index b244c94e2f..4593800b9c 100644 --- a/lib_webauth/src/main/x/webauth/Configuration.x +++ b/lib_webauth/src/main/x/webauth/Configuration.x @@ -1,58 +1,37 @@ +import sec.Credential; + +import web.security.DigestCredential; + /** * An injectable Configuration for the db-based web authentication functionality. * * @param initUserPass an *initial* mapping of user names to passwords; it is expected that this * will include only an administrator login, and the password will be changed * immediately after the database is configured - * @param initRoleUsers an *initial* mapping of role names to an array of user names - * @param useMD5 allow the use of MD5 hashing - * @param useSHA256 allow the use of SHA-256 hashing - * @param useSHA512_256 allow the use of SHA-512-256 hashing + * @param scheme the name of the [Credential] scheme to use for storing credentials * @param configured (optional) pass True to indicate that the Configuration has already been * successfully applied to the database */ -const Configuration - ( - Map initUserPass, - Map initRoleUsers = [], - Boolean useMD5 = True, - Boolean useSHA256 = True, - Boolean useSHA512_256 = True, - // TODO other config for 2FA, email verification, password requirements, etc. - Boolean configured = True, - ) - default(new Configuration([], configured=False)) { - - assert() { - assert useMD5 | useSHA256 | useSHA512_256 as "At least one hashing algorithm is required"; - } - +// TODO other config for 2FA, email verification, password requirements, etc. +const Configuration(Map initUserPass, + String credScheme = DigestCredential.Scheme, + Boolean configured = False, + ) { /** * Create a copy of this Configuration, with the specified properties modified. * * @param initUserPass (optional) an initial mapping of user names to passwords - * @param useMD5 (optional) allow the use of MD5 hashing - * @param useSHA256 (optional) allow the use of SHA-256 hashing - * @param useSHA512_256 (optional) allow the use of SHA-512-256 hashing * @param configured (optional) pass True to indicate that the Configuration has been * successfully applied to the database * * @return a new Configuration with the specified changes */ - Configuration with - ( - Map? initUserPass = Null, - Map? initRoleUsers = Null, - Boolean? useMD5 = Null, - Boolean? useSHA256 = Null, - Boolean? useSHA512_256 = Null, - Boolean? configured = Null, - ) { + Configuration with(Map? initUserPass = Null, + String? credScheme = Null, + Boolean? configured = Null, + ) { return new Configuration(initUserPass = initUserPass ?: this.initUserPass, - initRoleUsers = initRoleUsers ?: this.initRoleUsers, - useMD5 = useMD5 ?: this.useMD5, - useSHA256 = useSHA256 ?: this.useSHA256, - useSHA512_256 = useSHA512_256 ?: this.useSHA512_256, + credScheme = credScheme ?: this.credScheme, configured = configured ?: this.configured, ); } diff --git a/lib_webauth/src/main/x/webauth/DBRealm.x b/lib_webauth/src/main/x/webauth/DBRealm.x index 406524693c..e741ec5f93 100644 --- a/lib_webauth/src/main/x/webauth/DBRealm.x +++ b/lib_webauth/src/main/x/webauth/DBRealm.x @@ -1,35 +1,45 @@ import crypto.Signer; import oodb.Connection; -import oodb.DBSchema; +import oodb.DBMap; import oodb.DBObject; import oodb.DBObjectInfo; +import oodb.DBSchema; import oodb.RootSchema; +import sec.Credential; +import sec.PlainTextCredential; +import sec.Subject; + +import web.security.DigestCredential; + /** * A DBRealm is a realm implementation on top of an [AuthSchema]. */ const DBRealm implements Realm { /** - * Construct a `DBRealm` from plain text user names and passwords, using an optional list - * of [hashing algorithms](Signer). + * Construct a `DBRealm`. * - * @param realmName the human readable name of the realm + * @param name the human readable name of the realm * @param rootSchema (optional) the database to look for the AuthSchema at * @param initConfig (optional) the initial configuration * @param connectionName (optional) the name of the injected database (in case there are more * than one) to look for the AuthSchema */ - construct(String realmName, RootSchema? rootSchema = Null, Configuration? initConfig = Null, - String? connectionName = Null) { + construct(String name, + RootSchema? rootSchema = Null, + Configuration? initConfig = Null, + String? connectionName = Null, + ) { + // if no schema is passed in, then request it by injection if (rootSchema == Null) { @Inject(resourceName=connectionName) Connection dbc; rootSchema = dbc; } - // obtain a connection to the database, and find the AuthSchema inside the database - String? path = Null; - AuthSchema? authSchema = Null; + // find the AuthSchema inside the database + String? path = Null; + AuthSchema? db = Null; for ((String pathStr, DBObjectInfo info) : rootSchema.sys.schemas) { // find the AuthSchema; it must occur exactly-once assert DBObject schema ?= info.lookupUsing(rootSchema); @@ -38,13 +48,13 @@ const DBRealm | locations within the database:\ | {pathStr.quoted()} and {path.quoted()} ; - path = pathStr; - authSchema = schema; + path = pathStr; + db = schema; } } - assert path != Null && authSchema != Null as "The database does not contain an \"AuthSchema\""; + assert path != Null && db != Null as "The database does not contain an \"AuthSchema\""; - Configuration cfg = authSchema.config.get(); + Configuration cfg = db.config.get(); if (!cfg.configured) { // the database has not yet been configured, so we need an initial configuration to be // provided or injected, and we'll configure the database in the finally block @@ -58,43 +68,14 @@ const DBRealm this.createCfg = cfg; } - @Inject crypto.Algorithms algorithms; - Signer[] hashers = new Signer[]; - Signer?[] supportedHashers = new Signer?[3]; - Signer? weakestHasher = Null; - - if (cfg.useSHA512_256) { - Signer hasher = hasherByName("SHA-512-256"); - hashers += hasher; - supportedHashers[2] = hasher; - weakestHasher = hasher; - } - - if (cfg.useSHA256) { - Signer hasher = hasherByName("SHA-256"); - hashers += hasher; - supportedHashers[1] = hasher; - weakestHasher = hasher; - } - if (cfg.useMD5) { - Signer hasher = hasherByName("MD5"); - hashers += hasher; - supportedHashers[0] = hasher; - weakestHasher = hasher; - } - - assert weakestHasher != Null as "No hasher configured; at least one is required"; - - this.name = realmName; - this.authSchema = authSchema; - this.hashers = hashers; - this.supportedHashers = supportedHashers; - this.weakestHasher = weakestHasher; + this.name = name; + this.db = db; } finally { if (Configuration cfg ?= this.createCfg) { - if (authSchema.dbConnection.transaction == Null) { - using (authSchema.dbConnection.createTransaction()) { + // REVIEW need a: `using (schema.ensureTx()) {...}` + if (db.dbConnection.transaction == Null) { + using (db.dbConnection.createTransaction()) { applyConfig(cfg); } } else { @@ -105,33 +86,33 @@ const DBRealm // we need this helper method since atm there is no Connection.ensureTransaction() API private void applyConfig(Configuration cfg) { - // create the user roles - Roles roles = authSchema.roles; - Map initUserRoles = new HashMap(); - for ((String roleName, String[] userNames) : cfg.initRoleUsers) { - assert roles.createRole(roleName); - for (String userName : userNames) { - if (!initUserRoles.putIfAbsent(userName, [roleName])) { - initUserRoles[userName] = initUserRoles.getOrDefault(userName, []) + roleName; - } - } + // create the user records + function Credential(String, String) createCredential; + + if (cfg.credScheme == DigestCredential.Scheme) { + createCredential = (userName, pwd) -> new DigestCredential(name, userName, pwd); + } else if (cfg.credScheme == PlainTextCredential.Scheme) { + createCredential = (userName, pwd) -> new PlainTextCredential(userName, pwd); + } else { + throw new IllegalState("Unsupported credential scheme: {cfg.credScheme.quoted}"); } - // create the user records - Users users = authSchema.users; - ListMap initUserNoPass = new ListMap(cfg.initUserPass.size); + ListMap initUserNoPass = new ListMap(); for ((String userName, String password) : cfg.initUserPass) { - assert users.createUser(this, userName, password, - initUserRoles.getOrDefault(userName, [])); + Credential credential = createCredential(userName, password); + Principal principal = createPrincipal( + new Principal(0, userName, permissions=[AllowAll], credentials=[credential])); + initUserNoPass.put(userName, "???"); } // store the configuration (but remove the passwords), and specify that the database // has now been configured (so we don't repeat the db configuration the next time) - initUserNoPass.entries.forEach(e -> {e.value = "???";}); - authSchema.config.set(cfg.with(initUserPass = initUserNoPass, - configured = True)); + db.config.set(cfg.with(initUserPass = initUserNoPass, + configured = True)); } + @Override + public/private String name; /** * The configuration to write to the database the first time the database and realm are created. @@ -141,176 +122,357 @@ const DBRealm /** * The part of the database where the authentication information is stored. */ - protected AuthSchema authSchema; - - /** - * An array of three elements, containing up to the three supported signers, specifically: - * - * * Element `[0]` contains either an MD5 hasher or `Null` - * * Element `[1]` contains either an SHA-256 hasher or `Null` - * * Element `[2]` contains either an SHA-512-256 hasher or `Null` - */ - protected Signer?[] supportedHashers; - - /** - * The default [hashing algorithm](Signer) is the weakest one provided to this DBRealm, or - * the default MD5 algorithm if none was provided. - */ - protected Signer weakestHasher; + protected AuthSchema db; - - // ----- Realm interface ----------------------------------------------------------------------- + // ----- operations: Principals ---------------------------------------------------------------- @Override - conditional Set validUser(String userName) = loadUserRoles(userName); + Iterator findPrincipals(function Boolean(Principal) match) { + return db.principals.filter(e -> match(e.value)).values.iterator(); + } @Override - conditional Set authenticate(String user, String password) { - if ((_, Set roles) := authenticateHash( - user, passwordHash(user, name, password, weakestHasher), weakestHasher)) { - return True, roles; + conditional Principal findPrincipal(String scheme, String locator) { + if (Int id := db.principalLocators.get(munge(scheme, locator))) { + return readPrincipal(id); } return False; } @Override - Signer[] hashers; + Principal createPrincipal(Principal principal) { + Int principalId = db.principalGen.next(); + principal = principal.with(principalId = principalId); + using (db.dbConnection.createTransaction()) { + // verify groups + DBMap groups = db.groups; + for (Int groupId : principal.groupIds) { + if (!groups.contains(groupId)) { + throw new MissingGroup(groupId); + } + } - @Override - Hash[] hashesFor(UserId userId, Signer hasher) { - if (userId.is(String)) { - Hash? hash = Null; - if (User user := loadUserByName(userId)) { - hash := user.passwordHashes.hashFor(hasher); + // add new locators + DBMap index = db.principalLocators; + HashSet locators = locatorsFor(principal); + for (String locator : locators) { + if (!index.putIfAbsent(locator, principalId)) { + throw new DuplicateCredential(schemeFrom(locator), locatorFrom(locator)); + } + } + + // store the principal + if (!db.principals.putIfAbsent(principalId, principal)) { + throw new RealmException($"Principal id={principalId} already existed"); } - return [hash?] : []; } + return principal; + } - User[] users = loadUsersByHash(userId); - switch (users.size) { - case 0: - return []; + @Override + conditional Principal readPrincipal(Int id) { + return db.principals.get(id); + } - case 1: - return [users[0].passwordHashes.hashFor(hasher)?] : []; + @Override + Principal updatePrincipal(Principal principal) { + Int principalId = principal.principalId; + using (db.dbConnection.createTransaction()) { + Principal old; + if (!(old := readPrincipal(principalId))) { + throw new MissingPrincipal(principalId); + } - default: - Hash[] pwdHashes = new Hash[]; - for (User user : users) { - if (Hash pwdHash := user.passwordHashes.hashFor(hasher)) { - pwdHashes += pwdHash; + // verify groups + DBMap groups = db.groups; + for (Int groupId : principal.groupIds) { + if (!groups.contains(groupId)) { + throw new MissingGroup(groupId); } } - return pwdHashes.freeze(inPlace=True); + + // add new locators + DBMap index = db.principalLocators; + HashSet oldLocators = locatorsFor(old); + HashSet newLocators = locatorsFor(principal); + for (String locator : newLocators) { + if (!oldLocators.contains(locator)) { + if (!index.putIfAbsent(locator, principalId) && index[locator] != principalId) { + throw new DuplicateCredential(schemeFrom(locator), locatorFrom(locator)); + } + } + } + + // remove unused locators + for (String locator : oldLocators) { + if (!newLocators.contains(locator)) { + index.remove(locator, principalId); + } + } + + // store the principal + db.principals.put(principalId, principal); } + return principal; } @Override - conditional (String, Set) authenticateHash(UserId userId, Hash pwdHash, Signer hasher) { - conditional (String, Set) authenticateUser(User user, Hash pwdHash, Signer hasher) { - if (user.enabled, - Hash actualHash := user.passwordHashes.hashFor(hasher), - pwdHash == actualHash) { - return True, user.userName, loadUserRoles(user) ?: assert; + Boolean deletePrincipal(Int|Principal principal) { + Int principalId = principal.is(Int) ?: principal.principalId; + using (db.dbConnection.createTransaction()) { + if (!(principal := readPrincipal(principalId))) { + return False; } - return False; + // delete all locators for the principal + db.principalLocators.filter(e -> e.value == principalId).clear(); // TODO GG REVIEW + +// REVIEW GG - alternative? +// Set locators = locatorsFor(principal); +// DBMap index = db.principalLocators; +// for (String locator : locators) { +// if (Int locatorId := index.get(locator), locatorId == principalId) { +// index.remove(locator); +// } +// } + + // delete all entitlements for the principal + db.entitlements.filter(e -> e.value.principalId == principalId).clear(); + + // delete the principal + db.principals.remove(principalId); } + return True; + } - if (userId.is(String)) { - if (User user := loadUserByName(userId)) { - return authenticateUser(user, pwdHash, hasher); + // ----- operations: Groups ---------------------------------------------------------------- + + @Override + Iterator findGroups(function Boolean(Group) match) { + return db.groups.filter(e -> match(e.value)).values.iterator(); + } + + @Override + Group createGroup(Group group) { + Int groupId = db.groupGen.next(); + group = group.with(groupId = groupId); + using (db.dbConnection.createTransaction()) { + // verify groups + DBMap groups = db.groups; + for (Int parentId : group.groupIds) { + if (parentId == groupId) { + throw new GroupLoop(groupId); + } else if (!groups.contains(parentId)) { + throw new MissingGroup(parentId); + } } - return False; + // store the group + if (!groups.putIfAbsent(groupId, group)) { + throw new RealmException($"Group id={groupId} already existed"); + } } + return group; + } - // look up the user by the user hash - User[] users = loadUsersByHash(userId); - for (User user : users) { - if ((String userName, Set roleNames) := authenticateUser(user, pwdHash, hasher)) { - return True, userName, roleNames; + @Override + conditional Group readGroup(Int id) { + return db.groups.get(id); + } + + @Override + Group updateGroup(Group group) { + Int groupId = group.groupId; + using (db.dbConnection.createTransaction()) { + Group old; + if (!(old := readGroup(groupId))) { + throw new MissingGroup(groupId); + } + + // verify groups + DBMap groups = db.groups; + for (Int parentId : group.groupIds) { + if (!groups.contains(parentId)) { + throw new MissingGroup(parentId); + } + } + + // store the group + db.groups.put(groupId, group); + + // check for infinite loop of group dependencies + if (Int loopId := group.circularDependency(this)) { + throw new GroupLoop(loopId); } } + return group; + } + + @Override + Boolean deleteGroup(Int|Group group) { + Int groupId = group.is(Int) ?: group.groupId; + return db.groups.keys.removeIfPresent(groupId); + } + + // ----- operations: Entitlements -------------------------------------------------------------- + + @Override + Iterator findEntitlements(function Boolean(Entitlement) match) { + return db.entitlements.filter(e -> match(e.value)).values.iterator(); + } + + @Override + conditional Entitlement findEntitlement(String scheme, String locator) { + if (Int id := db.entitlementLocators.get(munge(scheme, locator))) { + return readEntitlement(id); + } return False; } + @Override + Entitlement createEntitlement(Entitlement entitlement) { + Int entitlementId = db.entitlementGen.next(); + entitlement = entitlement.with(entitlementId = entitlementId); + using (db.dbConnection.createTransaction()) { + // verify principal + if (!db.principals.contains(entitlement.principalId)) { + throw new MissingPrincipal(entitlement.principalId); + } + + // add new locators + DBMap index = db.entitlementLocators; + HashSet locators = locatorsFor(entitlement); + for (String locator : locators) { + if (!index.putIfAbsent(locator, entitlementId)) { + throw new DuplicateCredential(schemeFrom(locator), locatorFrom(locator)); + } + } + + // store the entitlement + if (!db.entitlements.putIfAbsent(entitlementId, entitlement)) { + throw new RealmException($"Entitlement id={entitlementId} already existed"); + } + } + return entitlement; + } + + @Override + conditional Entitlement readEntitlement(Int id) { + return db.entitlements.get(id); + } + + @Override + Entitlement updateEntitlement(Entitlement entitlement) { + Int entitlementId = entitlement.entitlementId; + using (db.dbConnection.createTransaction()) { + Entitlement old; + if (!(old := readEntitlement(entitlementId))) { + throw new MissingEntitlement(entitlementId); + } + + // add new locators + DBMap index = db.entitlementLocators; + HashSet oldLocators = locatorsFor(old); + HashSet newLocators = locatorsFor(entitlement); + for (String locator : newLocators) { + if (!oldLocators.contains(locator)) { + if (!index.putIfAbsent(locator, entitlementId) && index[locator] != entitlementId) { + throw new DuplicateCredential(schemeFrom(locator), locatorFrom(locator)); + } + } + } + + // remove unused locators + for (String locator : oldLocators) { + if (!newLocators.contains(locator)) { + index.remove(locator, entitlementId); + } + } + + // store the entitlement + db.entitlements.put(entitlementId, entitlement); + } + return entitlement; + } + + @Override + Boolean deleteEntitlement(Int|Entitlement entitlement) { + Int entitlementId = entitlement.is(Int) ?: entitlement.entitlementId; + using (db.dbConnection.createTransaction()) { + if (!(entitlement := readEntitlement(entitlementId))) { + return False; + } + + // delete all locators for the entitlement + db.entitlementLocators.filter(e -> e.value == entitlementId).clear(); // TODO GG REVIEW + +// REVIEW GG - alternative? +// Set locators = locatorsFor(entitlement); +// DBMap index = db.entitlementLocators; +// for (String locator : locators) { +// if (Int locatorId := index.get(locator), locatorId == entitlementId) { +// index.remove(locator); +// } +// } + + // delete the entitlement + db.entitlements.remove(entitlementId); + } + return True; + } // ----- internal ------------------------------------------------------------------------------ /** - * Obtain a hasher (a [Signer]) by the name of the hashing algorithm. + * Munge a credential "scheme" and "locator" together into a string. * - * @param algorithm one of: "SHA-512-256", "SHA-256", "MD5" + * @param scheme a credential scheme + * @param locator a credential locator * - * @return the requested hasher + * @return a string that combines the credential "scheme" and "locator" */ - static protected Signer hasherByName(String algorithm) { - @Inject crypto.Algorithms algorithms; - return algorithms.hasherFor(algorithm)?; - TODO CP alternative if algorithm unavailable - } + static protected String munge(String scheme, String locator) = $"{scheme}:{locator}"; /** - * Load the specified user from the database. + * Given a previously munged string, extract the "scheme" from it. * - * @param userName the name of the user to load + * @param munged a previous result from [munge] * - * @return True iff the user was found and loaded - * @return (conditional) the user + * @return the credential "scheme" */ - protected conditional User loadUserByName(String userName) { - using (authSchema.dbConnection.createTransaction(readOnly=True)) { - return authSchema.users.findByName(userName); - } + static String schemeFrom(String munged) { + assert Int colon := munged.indexOf(':'); + return munged[0.. loadUserRoles(User|String user) { - using (authSchema.dbConnection.createTransaction(readOnly=True)) { - if (user.is(String)) { - if (!(user := authSchema.users.findByName(user))) { - return False; - } - } - - Int[] roleIds = user.roleIds; - if (roleIds.empty) { - return True, []; - } - - Roles roles = authSchema.roles; - HashSet roleNames = new HashSet(); - for (Int roleId : roleIds) { - if (Role role := roles.get(roleId)) { - roleNames += role.roleName; - roleNames += role.altNames; - } + static HashSet locatorsFor(Subject subject) { + HashSet locators = new HashSet(); + for (Credential credential : subject.credentials) { + String scheme = credential.scheme; + for (String locator : credential.locators) { + locators.add(munge(scheme, locator)); } - return True, roleNames.freeze(True); } + return locators; } } \ No newline at end of file diff --git a/lib_webauth/src/main/x/webauth/Role.x b/lib_webauth/src/main/x/webauth/Role.x deleted file mode 100644 index 888da85ece..0000000000 --- a/lib_webauth/src/main/x/webauth/Role.x +++ /dev/null @@ -1,12 +0,0 @@ -/** - * A user role. Used with the `@Restrict` annotation. - */ -const Role - ( - Int roleId, - String roleName, - String[] altNames, // REVIEW this would allow us to collapse several Roles into one - String description, - ) { - // TODO -} diff --git a/lib_webauth/src/main/x/webauth/Roles.x b/lib_webauth/src/main/x/webauth/Roles.x deleted file mode 100644 index 0619adb5c2..0000000000 --- a/lib_webauth/src/main/x/webauth/Roles.x +++ /dev/null @@ -1,88 +0,0 @@ -import oodb.DBMap; - -/** - * Represents a table of user Role objects. - */ -mixin Roles - into DBMap { - /** - * TODO - */ - conditional Role createRole(String|String[] roleName, - String? description = Null - ) { - // split the passed in names into a primary and alternatives - String primaryName; - String[] altNames = []; - if (roleName.is(String)) { - primaryName = roleName; - } else { - switch (Int n = roleName.size) { - default: - altNames = roleName[1.. r.roleName == name || r.altNames.contains(name)); - } - - /** - * Find the roles with the specified names: - * - * if (Role[] roles := roles.findByNames(roleNames)) ... - * - * @param names one more more `roleName` or `altNames` of Roles - * - * @return True iff the specified role names were found - * @return (conditional) an array with one Role for each specified name - */ - conditional Role[] findByNames(Iterable names) { - Role[] roles = new Role[]; - Boolean any = False; - for (String name : names) { - any = True; - - if (Role role := findByName(name)) { - roles += role; - } else { - return False; - } - } - - return any - ? (True, roles) - : False; - } -} - diff --git a/lib_webauth/src/main/x/webauth/User.x b/lib_webauth/src/main/x/webauth/User.x deleted file mode 100644 index 52095c1f15..0000000000 --- a/lib_webauth/src/main/x/webauth/User.x +++ /dev/null @@ -1,75 +0,0 @@ -/** - * A User is a representation of a login identity and password. - * - * TODO - * password history - * Email[] - * Phone[] - * history - * String? note = Null, - * HashInfo[] passwordHistory, - * IPInfo[] recentIPs = [], - * contact info etc. - * - * @param userId the unique internal user identity - * @param userName the unique name (or email address, etc.) used as a user login identity - * @param userHashes the cryptographic digests of the user name - * @param passwordHashes the cryptographic digests of the user password - * @param roleIds the role identities that have been assigned to the user - * @param enabled True indicates that the User is active (can log in) - */ -const User - ( - Int userId, - String userName, - HashInfo userHashes, - HashInfo passwordHashes, - Int[] roleIds = [], - Boolean enabled = True, - ) { - - /** - * Create a new User object based on this User object, but with specific properties modified - * as indicated by the parameters. - * - * @param userId (optional) the new unique internal user identity - * @param userName (optional) the new unique name (or email address, etc.) used as a user - * login identity - * @param userHashes (optional) the cryptographic digests of the user name - * @param passwordHashes (optional) the cryptographic digests of the user password - * @param roles (optional) the new role identities to use for the user - * @param addRoleIds (optional) the roles identities to add to the user - * @param removeRoleIds (optional) the role identities to remove from the user - * @param enabled (optional) True indicates that the User is active (can log in); False - * disables the account - */ - User with( - Int? userId = Null, - String? userName = Null, - HashInfo? userHashes = Null, - HashInfo? passwordHashes = Null, - Int[]? roleIds = Null, - Int|Int[]? addRoleIds = Null, - Int|Int[]? removeRoleIds = Null, - Boolean? enabled = Null, - ) { - if (roleIds != Null || addRoleIds != Null || removeRoleIds != Null) { - HashSet roleIdSet = new HashSet(roleIds ?: this.roleIds); - roleIdSet.add(addRoleIds?.is(Int)?); - roleIdSet.addAll(addRoleIds?.is(Int[])?); - roleIdSet.remove(removeRoleIds?.is(Int)?); - roleIdSet.removeAll(removeRoleIds?.is(Int[])?); - roleIds = roleIdSet.toArray(Constant); - } - - return new User( - userId = userId ?: this.userId, - userName = userName ?: this.userName, - userHashes = userHashes ?: this.userHashes, - passwordHashes = passwordHashes ?: this.passwordHashes, - roleIds = roleIds ?: this.roleIds, - enabled = enabled ?: this.enabled, - ); - } -} - diff --git a/lib_webauth/src/main/x/webauth/UserChange.x b/lib_webauth/src/main/x/webauth/UserChange.x deleted file mode 100644 index 014ac7b6c3..0000000000 --- a/lib_webauth/src/main/x/webauth/UserChange.x +++ /dev/null @@ -1,35 +0,0 @@ -/** - * An entry in the UserHistory table. - */ -const UserChange - ( - Int|Int[] userId, - Form form, - Time timestamp, - String? desc = Null, - Int byUserId = 0, - HashInfo? details = Null, - ) { - /** - * The form of the user change record. - */ - enum Form { - Create, - Destroy, - Enable, - Disable, - ChangePassword, - ClearPassword, - SetPassword, - } - - /** - * @param userId the User key - * - * @return True iff this specific change record is related to the specified user id - */ - Boolean appliesTo(Int userId) { - Int|Int[] userIds = this.userId; - return userIds.is(Int[])?.contains(userId) : userIds == userId; - } -} \ No newline at end of file diff --git a/lib_webauth/src/main/x/webauth/UserHistory.x b/lib_webauth/src/main/x/webauth/UserHistory.x deleted file mode 100644 index d9f554176f..0000000000 --- a/lib_webauth/src/main/x/webauth/UserHistory.x +++ /dev/null @@ -1,22 +0,0 @@ -import ecstasy.collections.CollectImmutableArray; - -import oodb.DBMap; - -/** - * Records of changes related to a particular user. - */ -mixin UserHistory - into DBMap { - /** - * Find the change records related to the specified internal userId. - * - * @param userId the internal user ID - * - * @return an array of zero or more change records - */ - UserChange[] findByUser(Int userId) { - return values.filter(chg -> chg.appliesTo(userId)) - .sorted((chg1, chg2) -> chg1.timestamp <=> chg2.timestamp, - CollectImmutableArray.of(UserChange)); - } -} \ No newline at end of file diff --git a/lib_webauth/src/main/x/webauth/Users.x b/lib_webauth/src/main/x/webauth/Users.x deleted file mode 100644 index 13039dc269..0000000000 --- a/lib_webauth/src/main/x/webauth/Users.x +++ /dev/null @@ -1,84 +0,0 @@ -import crypto.Signer; - -import ecstasy.collections.CollectImmutableArray; - -import oodb.DBMap; - -import DBRealm.HashInfo; - -/** - * A DBMap of User objects. There are conceptually three "primary keys" for a User: - * - * * The (unique) user name (the login name) - * * An internal unique integer identifier assigned to the user - * * - * - * TODO need to protect uniqueness of name - */ -mixin Users - into DBMap { - /** - * Create a user with the specified attributes. - * - * @param realm the [DBRealm] that is using this database for its auth data - * @param userName the user name to register - * @param password (optional) the initial password for the user - * @param roleName (optional) the role or roles that the user is initially assigned - * - * @return True iff the new user could be successfully created - * @return (conditional) the new User object - */ - conditional User createUser(DBRealm realm, - String userName, - String? password = Null, - String|String[] roleName = []) { - if (findByName(userName)) { - return False; - } - - (HashInfo userHashes, HashInfo pwdHashes) = realm.createHashes(userName, password); - - // look up the role names and convert them to role IDs - Int[] roleIds; - String[] roleNames = [roleName.is(String)?] : roleName; - AuthSchema schema = dbParent.as(AuthSchema); - if (Role[] roleList := schema.roles.findByNames(roleNames)) { - roleIds = roleList.map(r -> r.roleId, CollectImmutableArray.of(Int)); - } else { - return False; - } - - Int userId = schema.userId.next(); - User user = new User(userId, userName, userHashes, pwdHashes, roleIds); - return putIfAbsent(userId, user) - ? (True, user) - : False; - } - - /** - * Find the user with the specified name: - * - * if (User user := users.findByName(loginId)) ... - * - * @param name the User's `userName` - */ - conditional User findByName(String name) { - return values.any(u -> u.userName == name); - } - - /** - * Find the users with the specified user hash: - * - * User[] users := users.findByHash(bytes); - * - * @param name a hash that must be present in the User's `userHashes` - */ - immutable User[] findByUserHash(Hash hash) { - return switch(hash.size) { - case 128 / 8: values.filter(u -> u.userHashes.md5 == hash, CollectImmutableArray.of(User)); - case 256 / 8: values.filter(u -> u.userHashes.sha256 == hash - || u.userHashes.sha512_256 == hash, CollectImmutableArray.of(User)); - default: []; - }; - } -} \ No newline at end of file diff --git a/lib_xenia/build.gradle.kts b/lib_xenia/build.gradle.kts index 5e4cb0a051..715a443032 100644 --- a/lib_xenia/build.gradle.kts +++ b/lib_xenia/build.gradle.kts @@ -12,5 +12,6 @@ dependencies { xtcModule(libs.xdk.crypto) xtcModule(libs.xdk.json) xtcModule(libs.xdk.net) + xtcModule(libs.xdk.sec) xtcModule(libs.xdk.web) } diff --git a/lib_xenia/src/main/x/xenia.x b/lib_xenia/src/main/x/xenia.x index b9c5edf9af..de03e093cc 100644 --- a/lib_xenia/src/main/x/xenia.x +++ b/lib_xenia/src/main/x/xenia.x @@ -8,6 +8,7 @@ module xenia.xtclang.org { package crypto import crypto.xtclang.org; package net import net.xtclang.org; package json import json.xtclang.org; + package sec import sec.xtclang.org; package web import web.xtclang.org; import crypto.CertificateManager; @@ -56,7 +57,7 @@ module xenia.xtclang.org { * return response; * } */ - typedef function ResponseOut(Session, RequestIn, Handler) as Interceptor; + typedef function ResponseOut(RequestIn, Handler) as Interceptor; /** * A function that is called with each incoming request is called an `Observer`. Despite the @@ -68,13 +69,7 @@ module xenia.xtclang.org { * or alter the request processing control flow. For purposes of request processing, exceptions * from the `Observer` are ignored, including if the `Observer` throws a [RequestAborted]. */ - typedef function void(Session, RequestIn) as Observer; - - /** - * A function that adds a parameter value to the passed-in tuple of values. Used to collect - * arguments for the endpoint method invocation. - */ - typedef function Tuple(Session, RequestIn, Tuple) as ParameterBinder; + typedef function void(RequestIn) as Observer; /** * A function that converts a result of the endpoint method invocation into a ResponseOut object. diff --git a/lib_xenia/src/main/x/xenia/Catalog.x b/lib_xenia/src/main/x/xenia/Catalog.x index 6c2c045769..ec1aebfe36 100644 --- a/lib_xenia/src/main/x/xenia/Catalog.x +++ b/lib_xenia/src/main/x/xenia/Catalog.x @@ -4,14 +4,27 @@ import ecstasy.reflect.Argument; import net.UriTemplate; import net.UriTemplate.UriParameters; +import sec.Permission; + import web.*; -import WebService.Constructor; +import WebService.Constructor as WSConstructor; /** * The catalog of WebApp endpoints. */ -const Catalog(WebApp webApp, String systemPath, WebServiceInfo[] services, Class[] sessionMixins) { +const Catalog(WebApp webApp, WebServiceInfo[] services, Class[] sessionMixins) { + + /** + * The Restriction could be one of: + * - Null, indicating an absence of any restrictions; + * - a static Permission object computed at Catalog creation time; + * - a function that creates a Permission object at request time; + * - a user defined method called at request time + */ + typedef Permission? | function Permission(RequestIn) | Method, > + as Restriction; + /** * The list of [WebServiceInfo] objects describing the [WebService] classes discovered within * the application. @@ -59,7 +72,7 @@ const Catalog(WebApp webApp, String systemPath, WebServiceInfo[] services, Class */ static const WebServiceInfo(Int id, String path, - Constructor constructor, + WSConstructor constructor, EndpointInfo[] endpoints, EndpointInfo? defaultGet, MethodInfo[] interceptors, @@ -96,10 +109,12 @@ const Catalog(WebApp webApp, String systemPath, WebServiceInfo[] services, Class extends MethodInfo { construct(Endpoint method, Int id, Int wsid, Boolean serviceTls, + Boolean serviceRedirectTls, + Boolean serviceRequiresSession, TrustLevel serviceTrust, MediaType|MediaType[] serviceProduces, MediaType|MediaType[] serviceConsumes, - String|String[] serviceSubjects, + Restriction serviceRestriction, Boolean serviceStreamRequest, Boolean serviceStreamResponse ) { @@ -117,16 +132,30 @@ const Catalog(WebApp webApp, String systemPath, WebServiceInfo[] services, Class : new UriTemplate(templateString); // check if the template matches UriParam's in the method - Int requiredParamCount = 0; + Int requiredParamCount = 0; + Boolean requiredSessionParam = False; + Boolean hasBodyParam = False; for (Parameter param : method.params) { // well-known types are Session and RequestIn (see ChainBundle.ensureCallChain) - if (param.ParamType.is(Type) || - param.ParamType == RequestIn || + if (param.ParamType == RequestIn || param.is(QueryParam) || - param.is(BodyParam) || param.defaultValue()) { continue; } + if (param.is(BodyParam)) { + if (hasBodyParam) { + throw new IllegalState($|The template for method "{method}" has more than \ + |one "@BodyParam" annotated parameter + ); + } + hasBodyParam = True; + continue; + } + + if (param.ParamType.is(Type)) { + requiredSessionParam = True; + continue; + } assert String name := param.hasName(); if (param.is(UriParam)) { @@ -134,9 +163,8 @@ const Catalog(WebApp webApp, String systemPath, WebServiceInfo[] services, Class } if (!template.vars.contains(name)) { throw new IllegalState($|The template for method "{method}" is missing \ - |a variable name "{name}": \ - |"{templateString}" - ); + |a variable name "{name}": "{templateString}" + ); } requiredParamCount++; } @@ -146,6 +174,26 @@ const Catalog(WebApp webApp, String systemPath, WebServiceInfo[] services, Class ? !method.is(HttpsOptional) : method.is(HttpsRequired); + this.redirectTls = serviceRedirectTls || + method.is(HttpsRequired) && method.autoRedirect; + + if (method.is(SessionOptional)) { + if (requiredSessionParam) { + throw new IllegalState($|Invalid "@SessionOptional" annotation for endpoint \ + |"{method}"; parameters require a session + ); + } + if (method.is(SessionRequired)) { + throw new IllegalState($|Contradicting "@SessionRequired" and "@SessionOptional" \ + |annotations for endpoint "{method}" + ); + } + this.requiresSession = False; + } else { + this.requiresSession = serviceRequiresSession || method.is(SessionRequired) || + requiredSessionParam; + } + this.requiredTrust = switch (method.is(_)) { case LoginRequired: TrustLevel.maxOf(serviceTrust, method.security); case LoginOptional: None; // explicitly optional overrides service trust level @@ -158,12 +206,10 @@ const Catalog(WebApp webApp, String systemPath, WebServiceInfo[] services, Class this.consumes = method.is(Consumes) ? method.consumes : serviceConsumes; + this.requiredPermission = method.is(Restrict) + ? computeRestriction(method) + : serviceRestriction; - String|String[] subjects = method.is(Restrict) - ? method.subject - : serviceSubjects; - - this.restrictSubjects = subjects.is(String) ? [subjects] : subjects; this.allowRequestStreaming = method.is(StreamingRequest) || serviceStreamRequest; this.allowResponseStreaming = method.is(StreamingResponse) || serviceStreamResponse; } @@ -209,19 +255,30 @@ const Catalog(WebApp webApp, String systemPath, WebServiceInfo[] services, Class MediaType|MediaType[] produces; /** - * Indicates if this endpoint requires the HTTPS. + * Indicates if this endpoint requires a [Session]. + */ + Boolean requiresSession; + + /** + * Indicates if this endpoint requires HTTPS. */ Boolean requiresTls; + /** + * Indicates if an attempt to access the endpoint should automatically redirect to HTTPS. + */ + Boolean redirectTls; + /** * [TrustLevel] of security that is required by the this endpoint. */ TrustLevel requiredTrust; /** - * If not empty, contains users that this endpoint is restricted to. + * If not `Null` (which means there are no restrictions), contains the permission name or a + * method to check for permissions. */ - String[] restrictSubjects; + Restriction requiredPermission; /** * Indicates if this endpoint allows the request content not to be fully buffered. @@ -247,37 +304,11 @@ const Catalog(WebApp webApp, String systemPath, WebServiceInfo[] services, Class } return False; } - - /** - * Check if any of the specified roles matches the restrictions of this endpoint. - * - * @param roles the set of roles to check against - * - * @return True iff any of the roles matches this endpoint restrictions - */ - Boolean authorized(Set roles) { - if (restrictSubjects.empty) { - return True; - } - - for (String role : restrictSubjects) { - if (roles.contains(role)) { - return True; - } - } - return False; - } } // ----- Catalog building ---------------------------------------------------------------------- - /** - * The default path prefix for the system service. The actual `systemPath` property is computed - * by [buildCatalog()] method to ensure its uniqueness. - */ - static String DefaultSystemPath = "/xverify"; - /** * Build a Catalog for a WebApp. * @@ -285,7 +316,7 @@ const Catalog(WebApp webApp, String systemPath, WebServiceInfo[] services, Class * @param extras (optional) a map of WebService classes for processing requests for * corresponding paths */ - static Catalog buildCatalog(WebApp app, Map, Constructor> extras = []) { + static Catalog buildCatalog(WebApp app, Map, WSConstructor> extras = []) { ClassInfo[] classInfos = new ClassInfo[]; Class[] sessionMixins = new Class[]; @@ -294,7 +325,7 @@ const Catalog(WebApp webApp, String systemPath, WebServiceInfo[] services, Class // collect ClassInfos for "extras"; this should be done first to account for services // without default constructors, which those extra services possibly are if (!extras.empty) { - for ((Class clz, Constructor constructor) : extras) { + for ((Class clz, WSConstructor constructor) : extras) { if (AnnotationTemplate webServiceAnno := clz.annotatedBy(WebService)) { String path = extractPath(webServiceAnno, declaredPaths); classInfos += new ClassInfo(path, clz, constructor); @@ -308,29 +339,20 @@ const Catalog(WebApp webApp, String systemPath, WebServiceInfo[] services, Class // collect the ClassInfos for standard WebServices and Session mixins scanClasses(app.classes, classInfos, sessionMixins, declaredPaths); - // compute the system service name and add the system service info - String systemPath = DefaultSystemPath; - for (Int i = 0; declaredPaths.contains(systemPath); i++) { - systemPath = $"{DefaultSystemPath}_{i}"; - } - classInfos += new ClassInfo(systemPath, SystemService, - SystemService.PublicType.defaultConstructor() ?: assert); + // TODO GG CP add any endpoints on the broker(s) and authenticator(s) // sort the ClassInfos based on their paths (SystemService goes first) - classInfos.sorted((ci1, ci2) -> - ci1.path == systemPath ? Lesser : (ci1.path <=> ci2.path).reversed, inPlace=True); + classInfos.sorted((ci1, ci2) -> (ci1.path <=> ci2.path).reversed, inPlace=True); - // now collect all endpoints + // collect all of the endpoints into a Catalog WebServiceInfo[] webServiceInfos = collectEndpoints(app, classInfos); - assert webServiceInfos[0].path == systemPath; - - return new Catalog(app, systemPath, webServiceInfos, sessionMixins); + return new Catalog(app, webServiceInfos, sessionMixins); } /** * WebService info collected during the scan phase. */ - private static const ClassInfo(String path, Class clz, Constructor constructor); + private static const ClassInfo(String path, Class clz, WSConstructor constructor); /** * Scan all the specified classes for WebServices and add the corresponding information @@ -347,7 +369,7 @@ const Catalog(WebApp webApp, String systemPath, WebServiceInfo[] services, Class assert child.is(Class); Type serviceType = child.PublicType; - if (Constructor constructor := serviceType.defaultConstructor()) { + if (WSConstructor constructor := serviceType.defaultConstructor()) { String path = extractPath(webServiceAnno, declaredPaths); classInfos += new ClassInfo(path, child, constructor); @@ -424,16 +446,17 @@ const Catalog(WebApp webApp, String systemPath, WebServiceInfo[] services, Class */ private static WebServiceInfo[] collectEndpoints(WebApp app, ClassInfo[] classInfos) { typedef MediaType|MediaType[] as MediaTypes; - typedef String|String[] as Subjects; - Class clzWebApp = &app.actualClass; - TrustLevel appTrustLevel = clzWebApp.is(LoginRequired) ? clzWebApp.security : None; - Boolean appTls = clzWebApp.is(HttpsRequired); - MediaTypes appProduces = clzWebApp.is(Produces) ? clzWebApp.produces : []; - MediaTypes appConsumes = clzWebApp.is(Consumes) ? clzWebApp.consumes : []; - Subjects appSubjects = clzWebApp.is(Restrict) ? clzWebApp.subject : []; - Boolean appStreamRequest = clzWebApp.is(StreamingRequest); - Boolean appStreamResponse = clzWebApp.is(StreamingResponse); + Class clzWebApp = &app.actualClass; + TrustLevel appTrustLevel = clzWebApp.is(LoginRequired) ? clzWebApp.security : None; + Boolean appTls = clzWebApp.is(HttpsRequired); + Boolean appRedirectTls = clzWebApp.is(HttpsRequired) && clzWebApp.autoRedirect; + Boolean appRequiresSession = clzWebApp.is(SessionRequired); + MediaTypes appProduces = clzWebApp.is(Produces) ? clzWebApp.produces : []; + MediaTypes appConsumes = clzWebApp.is(Consumes) ? clzWebApp.consumes : []; + Restriction appRestriction = clzWebApp.is(Restrict) ? computeRestriction(clzWebApp) : Null; + Boolean appStreamRequest = clzWebApp.is(StreamingRequest); + Boolean appStreamResponse = clzWebApp.is(StreamingResponse); Int wsid = 0; Int epid = 0; @@ -443,26 +466,51 @@ const Catalog(WebApp webApp, String systemPath, WebServiceInfo[] services, Class Class clz = classInfo.clz; Type serviceType = clz.PublicType; - TrustLevel serviceTrust = appTrustLevel; - Boolean serviceTls = appTls; + TrustLevel serviceTrust = appTrustLevel; + Boolean serviceTls = appTls; + Boolean serviceRedirectTls = appRedirectTls; if (clz.is(LoginRequired)) { - serviceTrust = clz.security; - serviceTls = True; + if (clz.is(LoginOptional)) { + throw new IllegalState($|Contradicting "@LoginRequired" and "@LoginOptional" \ + |annotations for service "{clz}" + ); + } + serviceTrust = clz.security; + serviceTls = True; + serviceRedirectTls = clz.autoRedirect; } else { if (clz.is(LoginOptional)) { serviceTrust = None; } if (clz.is(HttpsOptional)) { + if (clz.is(HttpsRequired)) { + throw new IllegalState($|Contradicting "@HttpsRequired" and "@HttpsOptional" \ + |annotations for service "{clz}" + ); + } serviceTls = False; } else if (clz.is(HttpsRequired)) { - serviceTls = True; + serviceTls = True; + serviceRedirectTls = clz.autoRedirect; } } - MediaTypes serviceProduces = clz.is(Produces) ? clz.produces : appProduces; - MediaTypes serviceConsumes = clz.is(Consumes) ? clz.consumes : appConsumes; - Subjects serviceSubjects = clz.is(Restrict) ? clz.subject : appSubjects; - Boolean serviceStreamRequest = clz.is(StreamingRequest) || appStreamRequest; - Boolean serviceStreamResponse = clz.is(StreamingResponse) || appStreamResponse; + Boolean serviceRequiresSession = appRequiresSession; + if (clz.is(SessionRequired)) { + if (clz.is(SessionOptional)) { + throw new IllegalState($|Contradicting "@SessionRequired" and "@SessionOptional" \ + |annotations for service "{clz}" + ); + } + serviceRequiresSession = True; + } else { + serviceRequiresSession &= !clz.is(SessionOptional); + } + + MediaTypes serviceProduces = clz.is(Produces) ? clz.produces : appProduces; + MediaTypes serviceConsumes = clz.is(Consumes) ? clz.consumes : appConsumes; + Restriction serviceRestriction = clz.is(Restrict) ? computeRestriction(clz) : appRestriction; + Boolean serviceStreamRequest = clz.is(StreamingRequest) || appStreamRequest; + Boolean serviceStreamResponse = clz.is(StreamingResponse) || appStreamResponse; EndpointInfo[] endpoints = new EndpointInfo[]; EndpointInfo? defaultGet = Null; @@ -495,11 +543,12 @@ const Catalog(WebApp webApp, String systemPath, WebServiceInfo[] services, Class } validateEndpoint(method); defaultGet = new EndpointInfo(method, epid++, wsid, - serviceTls, serviceTrust, - serviceProduces, serviceConsumes, serviceSubjects, + serviceTls, serviceRedirectTls, + serviceRequiresSession, serviceTrust, + serviceProduces, serviceConsumes, serviceRestriction, serviceStreamRequest, serviceStreamResponse); } else { - throw new IllegalState($|multiple "Default" endpoints on "{clz}" + throw new IllegalState($|Multiple "Default" endpoints on "{clz}" ); } break; @@ -508,8 +557,9 @@ const Catalog(WebApp webApp, String systemPath, WebServiceInfo[] services, Class validateEndpoint(method); EndpointInfo info = new EndpointInfo(method, epid++, wsid, - serviceTls, serviceTrust, - serviceProduces, serviceConsumes, serviceSubjects, + serviceTls, serviceRedirectTls, + serviceRequiresSession, serviceTrust, + serviceProduces, serviceConsumes, serviceRestriction, serviceStreamRequest, serviceStreamResponse); if (templates.addIfAbsent($"{info.httpMethod.name} {info.template}")) { endpoints.add(info); @@ -528,7 +578,7 @@ const Catalog(WebApp webApp, String systemPath, WebServiceInfo[] services, Class if (onError == Null) { onError = new MethodInfo(method, wsid); } else { - throw new IllegalState($|multiple "OnError" handlers on "{clz}" + throw new IllegalState($|Multiple "OnError" handlers on "{clz}" ); } break; @@ -571,4 +621,52 @@ const Catalog(WebApp webApp, String systemPath, WebServiceInfo[] services, Class } return webServiceInfos; } + + static Restriction computeRestriction(Restrict restrict) { + String?|Method, > permission = restrict.permission; + + if (permission.is(String?)) { + if (!restrict.is(Endpoint)) { + assert permission != Null as $"The @Restrict annotation must specify the permission"; + return new Permission(permission); + } + + if (permission == Null) { + // the permission is not specified by the Restrict; need to compute it + return defaultRestriction(restrict); + } + + // permission target can be parameterized; we need to resolve it at run time + String endpointTarget = restrict.template; + UriTemplate endpointTemplate = new UriTemplate(endpointTarget); + String[] endpointVars = endpointTemplate.vars; + Permission rawPermission = new Permission(permission); + String permissionTarget = rawPermission.target; + UriTemplate permissionTemplate = new UriTemplate(permissionTarget); + String[] permissionVars = permissionTemplate.vars; + if (permissionVars.empty) { + return rawPermission; + } + assert endpointVars.containsAll(permissionVars) as + $|Restrict permission "{permission}" contains elements that are missing in \ + |the endpoint template "{endpointTarget}" + ; + String action = rawPermission.action; + return (request) -> + new Permission(permissionTemplate.format(request.matchResult), action); + } + return permission; // Method, > + } + + static Restriction defaultRestriction(Endpoint method) { + String target = method.template; + UriTemplate template = new UriTemplate(target); + + return template.vars.empty + // e.g. "PUT:/accounts" + ? new Permission(target, method.httpMethod.name) + // e.g. "GET:/accounts/{id}" + : (request) -> new Permission(request.template.format(request.matchResult), + request.method.name); + } } \ No newline at end of file diff --git a/lib_xenia/src/main/x/xenia/ChainBundle.x b/lib_xenia/src/main/x/xenia/ChainBundle.x index ec961508f2..309f5d1b89 100644 --- a/lib_xenia/src/main/x/xenia/ChainBundle.x +++ b/lib_xenia/src/main/x/xenia/ChainBundle.x @@ -1,30 +1,43 @@ import Catalog.EndpointInfo; import Catalog.MethodInfo; +import Catalog.Restriction; import Catalog.WebServiceInfo; import convert.Codec; import convert.Format; -import convert.codecs.FormatCodec; -import convert.codecs.Utf8Codec; + +import ecstasy.collections.CollectArray; import net.UriTemplate; +import sec.Entitlement; +import sec.Permission; +import sec.Principal; +import sec.Realm; + import web.AcceptList; import web.Body; import web.BodyParam; import web.ErrorHandler; +import web.Header; import web.HttpMethod; import web.HttpStatus; import web.MediaType; import web.ParameterBinding; import web.QueryParam; import web.Registry; -import web.Response; -import web.Session; +import web.TrustLevel; import web.UriParam; import web.responses.SimpleResponse; +import web.security.Authenticator; +import web.security.Authenticator.Attempt; +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. @@ -36,24 +49,50 @@ service ChainBundle { * @param index the index of this ChainBundle in the `BundlePool` */ construct(Catalog catalog, Int index) { - this.catalog = catalog; - this.index = index; - - registry = catalog.webApp.registry_; - services = new WebService?[catalog.serviceCount]; - chains = new Handler?[catalog.endpointCount]; - errorHandlers = new ErrorHandler?[catalog.serviceCount]; + this.catalog = catalog; + this.index = index; + this.authenticator = catalog.webApp.authenticator.duplicate(); + this.registry = catalog.webApp.registry_; + this.services = new WebService?[catalog.serviceCount]; + this.chains = new Handler?[catalog.endpointCount]; + this.errorHandlers = new ErrorHandler?[catalog.serviceCount]; } + /** + * A function that adds a permission check to an endpoint call. It returns `False` if the + * endpoint invocation is not restricted and allowed to proceed. Otherwise, it returns a + * (conditional) [ResponseOut] indicating a failure. + */ + typedef function conditional ResponseOut(RequestIn) as RestrictionCheck; + + /** + * A function that adds a parameter value to the passed-in tuple of values. Used to collect + * arguments for the endpoint method invocation. + */ + typedef function Tuple(RequestIn, Tuple) as ParameterBinder; + /** * The Catalog. */ - public/private Catalog catalog; + protected/private Catalog catalog; + + /** + * The application's [Authenticator]; each ChainBundle duplicates the original `Authenticator` + * to avoid contention on a single `Authenticator` in case it is implemented without concurrency + * and has high-latency (e.g. database) operations. + */ + 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. */ - public/private Int index; + public/private @Atomic Int index; /** * The Registry. @@ -86,9 +125,11 @@ service ChainBundle { Int wsid = endpoint.wsid; HttpMethod httpMethod = endpoint.httpMethod; + WebService webService = ensureWebService(wsid); MethodInfo[] interceptorInfos = collectInterceptors(wsid, httpMethod); MethodInfo[] observerInfos = collectObservers(wsid, httpMethod); + // bind the parameters Method method = endpoint.method; ParameterBinder[] binders = new ParameterBinder[]; @@ -99,19 +140,19 @@ service ChainBundle { name ?= param.bindName; if (param.is(QueryParam)) { - binders += (session, request, values) -> + binders += (request, values) -> extractQueryValue(request, name, param, values); continue; } if (param.is(UriParam)) { assert endpoint.template.vars.contains(name); - binders += (session, request, values) -> + binders += (request, values) -> extractPathValue(request, name, param, values); continue; } if (param.is(BodyParam)) { - binders += (session, request, values) -> + binders += (request, values) -> extractBodyValue(request, name, param, values); continue; } @@ -119,43 +160,42 @@ service ChainBundle { } if (endpoint.template.vars.contains(name)) { - binders += (session, request, values) -> + binders += (request, values) -> extractPathValue(request, name, param, values); continue; } if (param.ParamType.is(Type)) { - binders += (session, request, values) -> values.add(session); + binders += (request, values) -> values.add(request.session? : assert); continue; } if (param.ParamType == RequestIn) { - binders += (session, request, values) -> values.add(request); + binders += (request, values) -> values.add(request); continue; } if (param.ParamType defaultValue := param.defaultValue()) { - binders += (session, request, values) -> values.add(defaultValue); + binders += (request, values) -> values.add(defaultValue); continue; } throw new IllegalState($"Unresolved parameter: {name.quoted()} for method {method}"); } - typedef Method, > as InterceptorMethod; - typedef Method, <>> as ObserverMethod; + typedef Method, > as InterceptorMethod; + typedef Method, <>> as ObserverMethod; // start with the innermost endpoint - WebService webService = ensureWebService(wsid); - Function boundMethod = method.bindTarget(webService); - Responder respond = generateResponder(endpoint); + Function boundMethod = method.bindTarget(webService); + Responder respond = generateResponder(endpoint); handle = binders.empty - ? ((session, request) -> respond(request, boundMethod())) - : ((session, request) -> { + ? ((request) -> respond(request, boundMethod())) + : ((request) -> { Tuple values = Tuple:(); for (ParameterBinder bind : binders) { - values = bind(session, request, values); + values = bind(request, values); } return respond(request, boundMethod.invoke(values)); }); @@ -170,7 +210,7 @@ service ChainBundle { ErrorHandler? onError = ensureErrorHandler(wsid); Handler callNext = handle; - handle = (session, request) -> webService.route(session, request, callNext, onError); + handle = (request) -> webService.route(request, callNext, onError); webService = ensureWebService(wsidNext); wsid = wsidNext; @@ -192,12 +232,12 @@ service ChainBundle { }); Handler callNext = handle; - handle = (session, request) -> { + handle = (request) -> { // observers are not handlers and call asynchronously for (Observer observe : observers) { - observe^(session, request); + observe^(request); } - return callNext(session, request); + return callNext(request); }; } @@ -205,20 +245,278 @@ service ChainBundle { ErrorHandler? onError = ensureErrorHandler(wsid); Handler callNext = handle; - handle = (session, request) -> { - assert session.is(SessionImpl); - session.requestBegin_(request); + handle = (request) -> { + SessionImpl? session = request.session.is(SessionImpl) ?: Null; + session?.requestBegin_(request); try { - return webService.route(session, request, callNext, onError); + return webService.route(request, callNext, onError); } finally { - session.requestEnd_(request); + session?.requestEnd_(request); } }; + // but nothing can get through unless the permission is checked + if (RestrictionCheck restrictCheck ?= generateRestrictCheck(endpoint, webService)) { + callNext = handle; + handle = (request) -> { + if (ResponseOut failure := restrictCheck(request)) { + return failure; + } + return callNext(request); + }; + } + chains[endpoint.id] = handle.makeImmutable(); return handle; } + private RestrictionCheck? generateRestrictCheck(EndpointInfo endpoint, WebService webService) { + TrustLevel requiredTrust = endpoint.requiredTrust; + Restriction requiredPermission = endpoint.requiredPermission; + + if (requiredTrust == None) { + // no permission check is required + return Null; + } + + (function Permission(RequestIn))? resolvePermission = Null; // permission-based + (function Boolean())? accessGranted = Null; // custom app logic + if (requiredPermission != Null) { + if (requiredPermission.is(Permission)) { + resolvePermission = (_) -> requiredPermission; + } else if (requiredPermission.is(function Permission(RequestIn))) { + resolvePermission = requiredPermission; + } else { + // Method, > + accessGranted = requiredPermission.bindTarget(webService).as((function Boolean())); + // TODO GG: bind necessary arguments + } + } + + static CollectArray ToAttemptArray = new CollectArray(); + + // 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 + return (request) -> { + // each endpoint has a required trust level, and the session (if there is one) knows its + // own trust level; if a session exists and its trust level is high enough, then use the + // session as a fast path to see if permission is allowed; failure to get permission + // just means we have to fall through to the slow path + Session? session = request.session; + Permission? permission = resolvePermission?(request) : Null; + 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; 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 = []; + 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 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 Alert: + failedAuth(request, session, attempt); + alerts += attempt; + break; + + case InProgress: + case KnownNoSession: + case KnownNoData: + case NoSession: + case NoData: + bestStatus = bestStatus.notLessThan(attempt.status); + break; + + default: + assert; + } + } + + // 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; + } + status := attempt.response.is(HttpStatus); + } + return True, new SimpleResponse(status); + } + + // 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 an internal error for the Realm failing to read the Principal? + } + } + } + if (principals.size > 1) { + // contradicting Principals + return True, new SimpleResponse(BadRequest); + } + + Principal? principal = principals.empty ? Null : principals[0]; + if (session != Null) { + 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); + } + } + + 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 { + attempts = inactives; + } + return True, buildChallenge(attempts); + } + }; + + private ResponseOut buildChallenge(Attempt[] attempts) { + HttpStatus status = Unauthorized; + for (Attempt attempt : attempts) { + if (ResponseOut response := attempt.response.is(ResponseOut)) { + return response; + } + status := attempt.response.is(HttpStatus); + } + SimpleResponse response = new SimpleResponse(status); + for (Attempt attempt : attempts) { + if (String header := attempt.response.is(String)) { + response.header.add(Header.WWWAuthenticate, header); + } else if (String[] headers := attempt.response.is(String[])) { + for (String header : headers) { + response.header.add(Header.WWWAuthenticate, header); + } + } + } + return response; + } + + /** + * Log a failed authentication. + * + * @param request the [RequestIn] that triggered the authentication + * @param session the [Session] related to the request, if any + * @param attempt the failed [Authenticator] [Attempt] + */ + private void failedAuth(RequestIn request, Session? session, Attempt attempt) { + session?.authenticationFailed(request, attempt); + // TODO log a failure against the request/claim combination (request is used for the IP address) + } + + /** + * Determine if the specified [Permission]/check is allowed using information from the + * session. + */ + private Boolean checkSessionApproval(Session session, + Permission? permission, (function Boolean())? accessGranted) { + return checkApproval(session.principal, session.entitlements, permission, accessGranted); + } + + /** + * Determine if the specified [Permission]/check is allowed. + */ + private Boolean checkApproval(Principal? principal, Entitlement[] entitlements, + Permission? permission, (function Boolean())? accessGranted) { + if (permission != Null) { + Realm realm = authenticator.realm; + return principal?.permitted(realm, permission); + return entitlements.any(e -> e.permitted(realm, permission)); + } + return accessGranted?(); + return True; + } + } + /** * Collect interceptors for the specified service. Note, that if a WebService in the path * doesn't have any interceptors, but has an explicitly defined "route" method or an error @@ -268,7 +566,7 @@ service ChainBundle { * Create an error handler for the specified WebService. */ ErrorHandler? ensureErrorHandler(Int wsid) { - typedef Method, > + typedef Method, > as ErrorMethod; if (ErrorHandler onError ?= errorHandlers[wsid]) { diff --git a/lib_xenia/src/main/x/xenia/CookieBroker.x b/lib_xenia/src/main/x/xenia/CookieBroker.x new file mode 100644 index 0000000000..68912f3102 --- /dev/null +++ b/lib_xenia/src/main/x/xenia/CookieBroker.x @@ -0,0 +1,841 @@ +import SessionCookie.CookieId; +import SessionImpl.Match_; + +import web.Get; +import web.Header; +import web.HttpStatus; +import web.WebService; + +import web.responses.SimpleResponse; + +import web.sessions.Broker; + +/** + * The `CookieBroker` is a traditional [Session] [Broker] that uses cookies and HTTP redirects to + * establish and verify HTTP sessions for browser-based (and other cookie- and redirect-friendly) + * HTTP clients. + */ +@WebService("/.well-known/verify-cookies") +service CookieBroker + implements Broker { + + // ----- constructors -------------------------------------------------------------------------- + + /** + * Construct a `CookieBroker` for the specified [WebApp] module. + * + * @param app the containing [WebApp] + */ + construct(WebApp app) { + assert val mgr := app.registry_.getResource("sessionManager"), mgr.is(SessionManager); + construct CookieBroker(mgr); + } + + /** + * Construct a `CookieBroker` that uses the specified [SessionManager]. + * + * @param sessionManager the application's [SessionManager] + */ + construct(SessionManager sessionManager) { + this.sessionManager = sessionManager; + this.plainTextCookieName = sessionManager.plainTextCookieName; + this.encryptedCookieName = sessionManager.encryptedCookieName; + this.consentCookieName = sessionManager.consentCookieName; + } + + @Override + construct(CookieBroker that) { + this.sessionManager = that.sessionManager; + this.plainTextCookieName = that.plainTextCookieName; + this.encryptedCookieName = that.encryptedCookieName; + this.consentCookieName = that.consentCookieName; + } + + // ----- properties ---------------------------------------------------------------------------- + + /** + * The session manager. + */ + protected @Final SessionManager sessionManager; + + /** + * The name of the session cookie for non-TLS traffic (copied from the [SessionManager]). + */ + protected @Final String plainTextCookieName; + + /** + * The name of the session cookie for TLS traffic (copied from the [SessionManager]). + */ + protected @Final String encryptedCookieName; + + /** + * The name of the persistent session cookie (copied from the [SessionManager]). + */ + protected @Final String consentCookieName; + + // ----- Broker interface ---------------------------------------------------------------------- + + @Override + conditional (Session, ResponseOut?) findSession(RequestIn request) { + (String? txtTemp, String? tlsTemp, String? consent, Int failures) = extractSessionCookies(request); + if (txtTemp == Null && tlsTemp == Null && consent == Null) { + return False; + } + + if ((SessionImpl session, Boolean redirect) := findSession(txtTemp, tlsTemp, consent)) { + // if we're already redirecting, defer the version increment that comes from an IP + // address or user agent change; let's first verify that the user agent has the + // correct cookies for the current version of the session + if (!redirect) { + // otherwise, check for an IP address and/or user agent change + redirect = session.updateConnection_(request.userAgent ?: "", request.client); + } + + ResponseOut? response = redirect || failures != 0 + ? buildRedirect(request, session, failures) + : Null; + return True, session, response; + } + return False; + } + + @Override + conditional (Session?, ResponseOut?) requireSession(RequestIn request) { + (SessionImpl|HttpStatus result, Boolean redirect, Int eraseCookies) = ensureSession(request); + if (result.is(HttpStatus)) { + return True, Null, new SimpleResponse(result); + } + val session = result; + + // if we're already redirecting, defer the version increment that comes from an IP + // address or user agent change; let's first verify that the user agent has the + // correct cookies for the current version of the session + if (!redirect) { + // otherwise, check for an IP address and/or user agent change + redirect = session.updateConnection_(request.userAgent ?: "", request.client); + } + ResponseOut? response = redirect + ? buildRedirect(request, session, eraseCookies) + : Null; + return True, session, response; + } + + // ----- WebService endpoints ------------------------------------------------------------------ + + /** + * Implements the cookie verification URL. + * + * @param request the incoming request + * @param redirect the redirection identifier previously registered with the session + * @param version the session version that the request should be validating against + * + * @return an HTTP response + */ + @Get("/{redirect}/{version}") + ResponseOut handle(RequestIn request, Int redirect, Int version) { + HttpStatus|ResponseOut result = validateSessionCookies(request, redirect, version); + return result.is(HttpStatus) + ? new SimpleResponse(result) + : result; + } + + // ----- internal ------------------------------------------------------------------------------ + + /** + * Create a response that will come back to this `WebService` to verify that the cookies were + * successfully received by the user agent. + * + * @param request the incoming request + * @param session the [SessionImpl] indicated by the [RequestIn] + * @param eraseCookies a bitmask of any errors encountered per `CookieId` + */ + protected ResponseOut buildRedirect(RequestIn request, SessionImpl session, Int eraseCookies) { + ResponseOut response = new SimpleResponse(TemporaryRedirect); + Int redirectId = session.prepareRedirect_(request); + Header header = response.header; + Byte desired = session.desiredCookies_(request.tls); + for (CookieId cookieId : CookieId.values) { + if (desired & cookieId.mask != 0) { + if ((SessionCookie cookie, Time? sent, Time? verified) + := session.getCookie_(cookieId), verified == Null) { + header.add(Header.SetCookie, cookie.toString()); + session.cookieSent_(cookie); + } + } else if (eraseCookies & cookieId.mask != 0) { + header.add(Header.SetCookie, eraseCookie(cookieId)); + } + } + header[Header.Location] = pathToThis(redirectId, session.version_); + return response; + } + + /** + * Build a path to this WebService to redirect to in order to verify that cookie changes were + * successful. + * + * @param redirect the redirection identifier registered with the session + * @param version the session version being validated against + */ + protected String pathToThis(Int redirect, Int version) = $"{path}/{redirect}/{version}"; + + /** + * Determine if a cookie name indicates a session cookie. + * + * @param cookieName the name of the cookie + * + * @return True iff the name indicates a session cookie + * @return (conditional) the CookieId + */ + protected conditional CookieId lookupCookie(String cookieName) { + if (cookieName == plainTextCookieName) { + return True, PlainText; + } else if (cookieName == encryptedCookieName) { + return True, Encrypted; + } else if (cookieName == consentCookieName) { + return True, Consent; + } else { + return False; + } + } + + /** + * Obtain the session cookie name for the specified cookie id. + * + * @param id the session CookieId + * + * @return the name of the cookie to use for the specified CookieId + */ + protected String cookieNameFor(CookieId id) { + return switch (id) { + case PlainText: plainTextCookieName; + case Encrypted: encryptedCookieName; + case Consent: consentCookieName; + }; + } + + /** + * Using the request information, find any session cookie values. + * + * @param request the incoming request + * + * @return txtTemp the temporary plaintext session cookie value, or Null if absent + * @return tlsTemp the temporary TSL-protected session cookie value, or Null if absent + * @return consent the persistent consent cookie value, or Null if absent + * @return failures a bitmask of any errors encountered per `CookieId` + */ + protected (String? txtTemp, String? tlsTemp, String? consent, Int failures) extractSessionCookies(RequestIn request) { + String? txtTemp = Null; + String? tlsTemp = Null; + String? consent = Null; + Int failures = 0; + Boolean tls = request.tls; + + NextCookie: for ((String name, String newValue) : request.cookies()) { + if (CookieId cookieId := lookupCookie(name)) { + String? oldValue; + switch (cookieId) { + case PlainText: + oldValue = txtTemp; + txtTemp = newValue; + break; + + case Encrypted: + // Mozilla Firefox bug: TLS-only cookies sent to localhost when TLS is false + if (!tls && request.client.loopback) { + continue NextCookie; + } + + oldValue = tlsTemp; + tlsTemp = newValue; + break; + + case Consent: + // Mozilla Firefox bug: TLS-only cookies sent to localhost when TLS is false + if (!tls && request.client.loopback) { + continue NextCookie; + } + + oldValue = consent; + consent = newValue; + break; + } + + if (oldValue? != newValue) { + // duplicate cookie detected but with a different value; this should be + // impossible + failures |= cookieId.mask; + } + + if (!tls && cookieId.tlsOnly) { + // user agent should have hidden the cookie; this should be impossible + failures |= cookieId.mask; + } + } + } + + return txtTemp, tlsTemp, consent, failures; + } + + /** + * Look up the session specified by the provided cookies. + * + * @param txtTemp the temporary plaintext session cookie value, or Null if absent + * @param tlsTemp the temporary TSL-protected session cookie value, or Null if absent + * @param consent the persistent consent cookie value, or Null if absent + * + * @return True iff the passed cookies are valid and all point to the same session + * @return (conditional) the session, iff the provided cookies all point to the same session + * @return (conditional) True if the user agent's cookies need to be updated + */ + protected conditional (SessionImpl session, Boolean redirect) + findSession(String? txtTemp, String? tlsTemp, String? consent) { + SessionImpl? session = Null; + Boolean redirect = False; + + for (CookieId cookieId : PlainText..Consent) { + String? cookieText = switch(cookieId) { + case PlainText: txtTemp; + case Encrypted: tlsTemp; + case Consent : consent; + }; + + if (cookieText == Null) { + continue; + } + + if (SessionImpl current := sessionManager.getSessionByCookie(cookieText)) { + if (session == Null) { + session = current; + } else if (session.internalId_ != current.internalId_) { + return False; + } + + (Match_ match, SessionCookie? cookie) = current.cookieMatches_(cookieId, cookieText); + switch (match) { + case Correct: + break; + + case Older: + // the redirect will update the cookies to the current one + redirect = True; + break; + + case Newer: + // this can happen if the server crashed after incrementing the session + // version and after sending the cookie(s) back to the user agent, but + // before persistently recording the updated session information + assert cookie != Null; + current.incrementVersion_(cookie.version+1); + redirect = True; + break; + + default: + return False; + } + } else { + return False; + } + } + + return session == Null + ? False + : (True, session, redirect); + } + + /** + * Using the request information, create a new session object. + * + * @param request the incoming request + * + * @return the session object, or a `4xx`-range `HttpStatus` that indicates a failure + */ + protected HttpStatus|SessionImpl createSession(RequestIn request) { + HttpStatus|SessionImpl result = sessionManager.createSession(request); + if (result.is(HttpStatus)) { + return result; + } + + SessionImpl session = result; + session.ensureCookies_(request.tls ? CookieId.BothTemp : CookieId.NoTls); + return session; + } + + /** + * Given the request information and a session, determine if that session needs to be replaced + * with a different session due to a previous split. + * + * @param request the incoming request + * @param session the session implied by that request + * @param present the cookies that are present in the request + * + * @return True iff the session needs to be replaced + * @return result (conditional) the session object, or a `4xx`-range `HttpStatus` that + * indicates a failure + * @return redirect (conditional) True indicates that redirection is required to update the + * session cookies + * @return eraseCookies (conditional) a bit mask of the cookie ID ordinals to delete + */ + protected conditional (SessionImpl|HttpStatus result, + Boolean redirect, + Int eraseCookies, + ) replaceSession(RequestIn request, SessionImpl session, Byte present) { + // check if the session itself has been abandoned and needs to be replaced with a + // new session as the result of a session split + if (HttpStatus|SessionImpl result := session.isAbandoned_(request)) { + if (result.is(HttpStatus)) { + return True, result, False, CookieId.None; + } + + // some cookies from the old session may need to be erased if they are not used + // by the new session + session = result; + return True, session, True, present & ~session.desiredCookies_(request.tls); + } + + return False; + } + + /** + * Obtain a header entry that will erase the session cookie for the specified cookie id. + * + * @param id the session CookieId + * + * @return a header entry that will erase the session cookie + */ + protected String eraseCookie(CookieId id) { + return $"{cookieNameFor(id)}=; expires=Thu, 01 Jan 1970 00:00:00 GMT{id.attributes}"; + } + + /** + * This method is invoked when a cookie is determined to be suspect. + * + * @param request the incoming request + * @param session the session against which the bad cookie is suspected + * @param value the cookie value + * @param failure the [SessionImpl.Match_] value indicating the failure + */ + protected void suspectCookie(RequestIn request, + SessionImpl session, + String value, + SessionImpl.Match_ failure, + ) { + // TODO CP + @Inject Console console; + console.print($|Suspect cookie {value.quoted()} with {failure=}\ + | for session {session.internalId_} + | with known cookies {CookieId.from(session.knownCookies_)} + ); + } + + /** + * Using the request information, look up or create the session object. + * + * @param request the incoming request + * + * @return result the session object, or a `4xx`-range `HttpStatus` that indicates a + * failure + * @return redirect True indicates that redirection is required to update the session + * cookies + * @return eraseCookies a bit mask of the cookie ID ordinals to delete + */ + protected (SessionImpl|HttpStatus result, Boolean redirect, Int eraseCookies) ensureSession(RequestIn request) { + (String? txtTemp, String? tlsTemp, String? consent, Int eraseCookies) + = extractSessionCookies(request); + if (eraseCookies != 0) { + return BadRequest, False, eraseCookies; + } + + Boolean tls = request.tls; + + // 1 2 3TLS description/action + // - - - - ------------------ + // 0 create new session; redirect to verification (send cookie 1) + // 1 create new session; redirect to verification (send cookies 1 and 2) + // x 0 validate cookie 1 + // x 1 validate cookie 1 & verify that cookie 2/3 were NOT already sent & verified (if + // they were, then this is an error, because it indicates the likely theft of the + // plain text cookie); redirect to verification (send cookies 1 and 2, and 3 if + // [cookieConsent] has been set) + // x 0* error (no TLS, so cookie 2 is illegally present; also missing cookie 1) + // x 1 error (missing cookie 1) + // x x 0* error (no TLS, so cookie 2 is illegally present) + // x x 1 validate cookie 1 & 2; if [cookieConsent] has been set, redirect to verification + // x 0* error (no TLS, so cookie 3 is illegally present) + // x 1 validate cookie 3; assume temporary cookies absent due to user agent discarding + // temporary cookies; redirect to verification (send cookies 1 and 2) + // x x 0* error (no TLS, so cookie 3 is illegally present) + // x x 1 validate cookie 1 & 3 (1 must be newer than 3), and verify that cookie 2 was NOT + // already sent & verified; merge session for cookie 1 into session for cookie 3; + // redirect to verification; (send cookies 1 and 2) + // x x 0* error (no TLS, so cookie 2 and 3 are illegally present) + // x x 1 error (missing cookie 1) + // x x x 0* error (no TLS, so cookies 2 and 3 are illegally present) + // x x x 1 validate cookie 1 & 2 & 3 (must be same session) + // + // * handled by the following "illegal" check + + // no cookies implies that we must create a new session + Byte present = (txtTemp == Null ? 0 : CookieId.NoTls) + | (tlsTemp == Null ? 0 : CookieId.TlsTemp) + | (consent == Null ? 0 : CookieId.OnlyConsent); + if (present == CookieId.None) { + return createSession(request), True, CookieId.None; + } + + // based on whether the request came in on plain-text or TLS, it might be illegal for some + // of the cookies to have been included + Byte accept = tls ? CookieId.All : CookieId.NoTls; + Byte illegal = present & ~accept; + if (illegal != 0) { + // these are cases that we have no intelligent way to handle: the browser has sent + // cookies that should not have been sent. we can try to log the error against the + // session, but the only obvious thing to do at this point is to delete the unacceptable + // cookies and start over + SessionImpl? sessionNoTls = Null; + sessionNoTls := sessionManager.getSessionByCookie(txtTemp?); + + for (CookieId cookieId : CookieId.from(illegal)) { + String cookie = switch (cookieId) { + case PlainText: txtTemp; + case Encrypted: tlsTemp; + case Consent: consent; + } ?: assert as $"missing {cookieId} cookie"; + + if (SessionImpl session := sessionManager.getSessionByCookie(cookie)) { + if (sessionNoTls?.internalId_ != session.internalId_) { + // the sessions are different, so report to both sessions that the other + // cookie was for the wrong session + suspectCookie(request, sessionNoTls, cookie , WrongSession); + suspectCookie(request, session , txtTemp?, WrongSession); + } else { + // report to the session (from the illegal cookie) that that cookie was + // unexpected + suspectCookie(request, session, cookie, Unexpected); + } + } + } + + return BadRequest, False, illegal; + } + + // perform a "fast path" check: if all the present cookies point to the same session + if ((SessionImpl session, Boolean redirect) := findSession(txtTemp, tlsTemp, consent)) { + // check for split + if ((HttpStatus|SessionImpl result, _, eraseCookies) + := replaceSession(request, session, present)) { + if (result.is(HttpStatus)) { + // failed to create a session, which is reported back as an error, and + // we'll erase any non-persistent cookies (we don't erase the persistent + // cookie because it may contain consent info) + return result, False, eraseCookies; + } + + session = result; + redirect = True; + } else { + Byte desired = session.desiredCookies_(tls); + session.ensureCookies_(desired); + redirect |= desired != present; + eraseCookies = present & ~desired; + } + + return session, redirect, eraseCookies; + } + + // look up the session by each of the available cookies + SessionImpl? txtSession = sessionManager.getSessionByCookie(txtTemp?)? : Null; + SessionImpl? tlsSession = sessionManager.getSessionByCookie(tlsTemp?)? : Null; + SessionImpl? conSession = sessionManager.getSessionByCookie(consent?)? : Null; + + // common case: there's a persistent session that we found, but there was already a + // temporary session created with a plain text cookie before the user agent connected over + // TLS and sent us the persistent cookie + if (conSession != Null) { + (Match_ match, SessionCookie? cookie) = conSession.cookieMatches_(Consent, consent ?: assert); + switch (match) { + case Correct: + case Older: + case Newer: + // if the session specified by the plain text cookie is different from the + // session specified by the persistent cookie, then it may have more recent + // information that we want to retain (i.e. add to the session specified by + // the persistent cookie) + Boolean shouldMerge = txtSession?.internalId_ != conSession.internalId_ : False; + + // there should NOT be a TLS cookie with a different ID from the persistent cookie, + // since they both go over TLS + if (tlsSession?.internalId_ != conSession.internalId_) { + suspectCookie(request, conSession, tlsTemp ?: assert, WrongSession); + } + + // replace (split) the session if the session has been abandoned + eraseCookies = CookieId.None; + Boolean split = False; + if ((HttpStatus|SessionImpl result, _, eraseCookies) := replaceSession( + request, conSession, present)) { + if (result.is(HttpStatus)) { + // failed to create a session, which is reported back as an error, and + // we'll erase any non-persistent cookies (we don't erase the persistent + // cookie because it may contain consent info) + return result, False, eraseCookies; + } + + conSession = result; + split = True; + } + + if (shouldMerge) { + conSession.merge_(txtSession?); + } + + if (!split) { + // treat this event as significant enough to warrant a new session version; + // this helps to force the bifurcation of the session in the case of cookie + // theft + conSession.incrementVersion_(); + } + + // redirect to make sure all cookies are up to date + return conSession, True, eraseCookies; + } + } + + // remaining cases: either no session was found (even if there are some session cookies), or + // it's possible that sessions have been lost (e.g. from a server restart), and then the + // user agent reestablished a connection on a non-TLS connection and the server created a + // new session in response, and then the user agent just now switched to a TLS connection + // and passed its older TLS cookie(s) from the old lost session (in which case we need to + // just discard those abandoned cookies, after grabbing the consent data from the persistent + // cookie, if there is any) + if (tlsSession == Null && conSession == Null) { + // create a new session if necessary + HttpStatus|SessionImpl result = txtSession == Null + ? sessionManager.createSession(request) + : txtSession; + if (result.is(HttpStatus)) { + // failed to create a session, which is reported back as an error, and we'll erase + // any non-persistent cookies (we don't erase the persistent cookie because it may + // contain consent info) + return result, False, present & CookieId.BothTemp; + } + + SessionImpl session = result; + if ((result, _, eraseCookies) := replaceSession(request, session, present)) { + if (result.is(HttpStatus)) { + // failed to create a session, which is reported back as an error, and we'll erase + // any non-persistent cookies (we don't erase the persistent cookie because it may + // contain consent info) + return result, False, eraseCookies; + } + + session = result; + } + + // don't lose previous consent information if there was any in the persistent cookie + // (the "consent" variable holds the text content of the persistent cookie, and it + // implies that the user agent had previously specified "exclusive agent" mode) + if (consent != Null) { + try { + SessionCookie cookie = new SessionCookie(sessionManager, consent); + session.cookieConsent = cookie.consent; + session.exclusiveAgent = True; + } catch (Exception _) {} + } + + // create the desired cookies, and delete the undesired cookies + Byte desired = session.desiredCookies_(tls); + session.ensureCookies_(desired); + return session, True, present & ~desired; + } + + // every other case: blow it all up and start over + suspectCookie(request, txtSession?, tlsTemp?, WrongSession); + suspectCookie(request, txtSession?, consent?, WrongSession); + suspectCookie(request, tlsSession?, txtTemp?, WrongSession); + suspectCookie(request, tlsSession?, consent?, WrongSession); + + HttpStatus|SessionImpl result = createSession(request); + if (result.is(SessionImpl)) { + Byte desired = result.desiredCookies_(tls); + return result, True, present & ~desired; + } else { + return result, False, present; + } + } + + /** + * Implements the validation check for the session cookies when one or more session cookie has + * been added. + * + * There are five expected outcomes: + * + * * Confirm - 99.9+% of the time, everything is perfect and exactly as expected + * * Repeat - Occasionally, the redirect will need to be repeated because the session version + * has already advanced for some reason before this redirect request was able to be processed + * * Split - If anything is actually _wrong_, then the session is split, and a redirect is + * returned for the user agent to validate against the split session + * * New - If a session can't be found to validate against, or some other unrecoverable problem + * appears to exist, then a new session is created, and a redirect is returned for the user + * agent to validate the new session + * * Abort - If something so fundamental is wrong that none of the above is an option, then the + * method simply aborts with an error code + * + * @param request information about the request + * @param redirect the redirection identifier previously registered with the session + * @param version the session version being validated against + * + * @return one of: an `HttpStatus` error code; a complete `Response`; or a new path to use in + * place of the passed `uriString` + */ + protected HttpStatus|ResponseOut validateSessionCookies(RequestIn request, + Int64 redirect, + Int64 version, + ) { + enum Action {Confirm, Repeat, Split, New} + + static Action evaluate(SessionImpl session, CookieId cookieId, String cookieText) { + (Match_ match, SessionCookie? cookie) = session.cookieMatches_(cookieId, cookieText); + switch (match) { + case Correct: + session.cookieVerified_(cookie ?: assert); + return Confirm; + + case Older: + // need to redirect (again) because the session version was just incremented + // while we were waiting for the current redirect + return Repeat; + + case Newer: + // this is inconceivable, since we just redirected the user agent to confirm its + // cookies, and we supposedly already handled the case that the user agent had + // a "newer" cookie; treat this as a protocol violation + return Split; + + case Corrupt: + case WrongSession: + // this is inconceivable, since we just found the session using this cookie, + // but the cookie doesn't match; assume something so weird is going on that we + // should just give up and return a new session to minimize any further damage + case WrongCookieId: + case Unexpected: + // these are serious violations of the protocol; assume something so weird is + // going on that we should just give up and return a new session to minimize + // any further damage + // TODO report this to the session(s) + default: + return New; + } + } + + // find up to three session cookies that were passed in with the request + (String? txtTemp, String? tlsTemp, String? consent, Int failures) = extractSessionCookies(request); + if (failures != 0) { + // something is deeply wrong with the cookies coming from the user agent, and it's + // unlikely that we can fix them (because by the time that we got here, we supposedly + // would have already tried to fix whatever that mess is) + return BadRequest; + } + + Boolean tls = request.tls; + Action action = Confirm; + + // the plain-text cookie is required; use the plain text cookie to find the session; the + // absence of either the cookie or the session implies that we should create a new session + @Unassigned SessionImpl session; // note: assumed unassigned iff "action==New" + Validate: if (txtTemp != Null, session := sessionManager.getSessionByCookie(txtTemp)) { + // validate the plain text temporary cookie that we just used to look up the session + action = evaluate(session, PlainText, txtTemp); + + // validate the temporary TLS cookie + if (tlsTemp == Null) { + // if the redirect came in on TLS, then the TLS "temporary" cookie should have been + // included; treat it as a protocol violation and split the session + if (tls && action != Repeat) { + action = action.notLessThan(Split); + } + } else { + action = action.notLessThan(evaluate(session, Encrypted, tlsTemp)); + } + + // validate the persistent TLS cookie + if (consent == Null) { + // the consent cookie is required over TLS if the session has sent it out, unless + // we've already determined that we need to redirect yet again) + if (tls && session.usePersistentCookie_ && action != Repeat, + (_, Time? sent) := session.getCookie_(Consent), sent != Null) { + action = action.notLessThan(Split); + } + } else { + action = action.notLessThan(evaluate(session, Consent, consent)); + } + } else { + // unable to locate the specified session, so attempt to create a new session + action = New; + } + + switch (action) { + case Confirm: + // handle the most common case in which the redirect was successful + ResponseOut response = new SimpleResponse(TemporaryRedirect); + response.header[Header.Location] = session.claimRedirect_(redirect)?.toString() : "/"; + return response; + + case Repeat: + // handle the (rare) case in which we need to repeat the redirect, but only if the + // actual session version is ahead of the version that this method was called to + // validate (otherwise, repeating the validation isn't going to change anything) + if (session.version_ > version) { + break; + } + continue; + case Split: + // protocol error: split the session + HttpStatus|SessionImpl result = session.split_(request); + if (result.is(HttpStatus)) { + return result; + } + session = result; + break; + + case New: + // create a new session + HttpStatus|SessionImpl result = sessionManager.createSession(request); + if (result.is(HttpStatus)) { + return result; + } + session = result; + break; + } + + // send desired cookies (unless they've already been verified, in which case we never + // re-send them) + ResponseOut response = new SimpleResponse(TemporaryRedirect); + Header header = response.header; + Byte desired = session.desiredCookies_(tls); + + session.ensureCookies_(desired); + + for (CookieId cookieId : CookieId.from(desired)) { + if ((SessionCookie resendCookie, Time? sent, Time? verified) + := session.getCookie_(cookieId), verified == Null) { + header.add(Header.SetCookie, resendCookie.toString()); + if (sent == Null) { + session.cookieSent_(resendCookie); + } + } + } + + // erase undesired cookies + Byte present = (txtTemp == Null ? 0 : CookieId.NoTls) + | (tlsTemp == Null ? 0 : CookieId.TlsTemp) + | (consent == Null ? 0 : CookieId.OnlyConsent); + for (CookieId cookieId : CookieId.from(present & ~desired)) { + header.add(Header.SetCookie, eraseCookie(cookieId)); + } + + // come back to verify that the user agent received and subsequently sent the + // cookies + header[Header.Location] = pathToThis(redirect, session.version_); + return response; + } +} \ No newline at end of file diff --git a/lib_xenia/src/main/x/xenia/Dispatcher.x b/lib_xenia/src/main/x/xenia/Dispatcher.x index b1c808d9c2..636db3ac59 100644 --- a/lib_xenia/src/main/x/xenia/Dispatcher.x +++ b/lib_xenia/src/main/x/xenia/Dispatcher.x @@ -4,11 +4,6 @@ import Catalog.WebServiceInfo; import HttpServer.RequestInfo; -import SessionCookie.CookieId; - -import SessionImpl.Match_; - -import web.Endpoint; import web.ErrorHandler; import web.Header; import web.HttpStatus; @@ -16,7 +11,7 @@ import web.RequestAborted; import web.responses.SimpleResponse; -import web.security.Authenticator; +import web.sessions.Broker as SessionBroker; import net.UriTemplate.UriParameters; @@ -28,15 +23,10 @@ import net.UriTemplate.UriParameters; service Dispatcher { construct(Catalog catalog, BundlePool bundlePool, - SessionManager sessionManager, - Authenticator authenticator) { - this.catalog = catalog; - this.bundlePool = bundlePool; - this.sessionManager = sessionManager; - this.plainTextCookieName = sessionManager.plainTextCookieName; - this.encryptedCookieName = sessionManager.encryptedCookieName; - this.consentCookieName = sessionManager.consentCookieName; - this.authenticator = authenticator; + SessionManager sessionManager) { + this.catalog = catalog; + this.bundlePool = bundlePool; + this.sessionBroker = catalog.webApp.sessionBroker.duplicate(); } /** @@ -50,57 +40,32 @@ service Dispatcher { protected @Final BundlePool bundlePool; /** - * The session manager. + * The session broker, which encapsulates the process of establishing and identifying sessions. */ - protected @Final SessionManager sessionManager; - - /** - * The name of the session cookie for non-TLS traffic (copied from the session manager). - */ - protected @Final String plainTextCookieName; - - /** - * The name of the session cookie for TLS traffic (copied from the session manager). - */ - protected @Final String encryptedCookieName; - - /** - * The name of the persistent session cookie (copied from the session manager). - */ - protected @Final String consentCookieName; - - /** - * The user authenticator. - */ - protected @Final Authenticator authenticator; + protected/private @Final SessionBroker sessionBroker; /** * Pending request counter. */ - @Atomic Int pendingRequests; + public/private @Atomic Int pendingRequests; /** * Dispatch the "raw" request. */ void dispatch(RequestInfo requestInfo) { - - // REVIEW CP - Boolean tls = requestInfo.tls; - String uriString = requestInfo.uriString; - String methodName = requestInfo.method.name; - - FromTheTop: while (True) { + Boolean tls = requestInfo.tls; + String uriString = requestInfo.uriString; + FromTheBeginning: while (True) { // select the service to delegate request processing to; the service infos are sorted // with the most specific path first (so the first path match wins) Int uriSize = uriString.size; WebServiceInfo? serviceInfo = Null; for (WebServiceInfo info : catalog.services) { - // the info.path, which represents a "directory", never ends with '/' (see Catalog), - // but a legitimately matching uriString may or may not have '/' at the end; for - // example: if the path is "test", then uri values such as "test/s", "test/" and - // "test" should all match (the last two would be treated as equivalent), but - // "tests" should not - + // the info.path, which represents a "directory", never ends with '/' (see the + // extractPath function in Catalog) -- except for the root path "/". a legitimately + // matching uriString may or may not have '/' at the end; for example: if the path + // is "test", then uri values such as "test/s", "test/" and "test" should all match + // (the last two would be treated as equivalent), but "tests" should not String path = info.path; Int pathSize = path.size; if (uriSize == pathSize) { @@ -119,34 +84,12 @@ service Dispatcher { } } - ChainBundle? bundle = Null; + ChainBundle? bundle = Null; @Future ResponseOut response; ProcessRequest: if (serviceInfo == Null) { - RequestIn request = new Http1Request(requestInfo, []); - Session? session = getSessionOrNull(requestInfo); - - response = catalog.webApp.handleUnhandledError^(session, request, HttpStatus.NotFound); + RequestIn request = new Http1Request(requestInfo, sessionBroker); + response = catalog.webApp.handleUnhandledError^(request, HttpStatus.NotFound); } else { - Int wsid = serviceInfo.id; - if (wsid == 0) { - // this is a redirect or other system service call - bundle = bundlePool.allocateBundle(wsid); - - SystemService svc = bundle.ensureWebService(wsid).as(SystemService); - HttpStatus|ResponseOut|String result = svc.handle(this, uriString, requestInfo); - if (result.is(String)) { - uriString = result; - bundlePool.releaseBundle(bundle); - continue FromTheTop; - } - - response = result.is(ResponseOut) - ? result - : new SimpleResponse(result); - - break ProcessRequest; - } - // split what's left of the URI into a path, a query, and a fragment String? query = Null; String? fragment = Null; @@ -160,12 +103,13 @@ service Dispatcher { uriString = uriString[0 ..< queryOffset]; } - if (uriString == "") { + if (uriString.empty) { uriString = "/"; } EndpointInfo endpoint; UriParameters uriParams = []; + Int wsid = serviceInfo.id; FindEndpoint: { Uri uri; try { @@ -175,166 +119,84 @@ service Dispatcher { break ProcessRequest; } - for (EndpointInfo eachEndpoint : serviceInfo.endpoints) { - if (eachEndpoint.httpMethod.name == methodName, - uriParams := eachEndpoint.matches(uri)) { - endpoint = eachEndpoint; + // find a matching endpoint + String methodName = requestInfo.method.name; + for (endpoint : serviceInfo.endpoints) { + if (endpoint.httpMethod.name == methodName, + uriParams := endpoint.matches(uri)) { break FindEndpoint; } } - if (methodName == "GET", - EndpointInfo defaultGet ?= serviceInfo.defaultGet) { - endpoint = defaultGet; + // no matching endpoint; check if there is a default endpoint + if (methodName == "GET", endpoint ?= serviceInfo.defaultGet) { break FindEndpoint; } // there is no matching endpoint - RequestIn request = new Http1Request(requestInfo, []); - Session? session = getSessionOrNull(requestInfo); - MethodInfo? onErrorInfo = catalog.findOnError(wsid); - if (onErrorInfo != Null && session != Null) { + RequestIn request = new Http1Request(requestInfo, sessionBroker); + if (MethodInfo onErrorInfo ?= catalog.findOnError(wsid)) { Int errorWsid = onErrorInfo.wsid; bundle = bundlePool.allocateBundle(errorWsid); - ErrorHandler? onError = bundle.ensureErrorHandler(errorWsid); - if (onError != Null) { - response = onError^(session, request, HttpStatus.NotFound); + if (ErrorHandler onError ?= bundle.ensureErrorHandler(errorWsid)) { + response = onError^(request, HttpStatus.NotFound); break ProcessRequest; + } else { + bundlePool.releaseBundle(bundle); + bundle = Null; } } - - response = catalog.webApp.handleUnhandledError^(session, request, HttpStatus.NotFound); + response = catalog.webApp.handleUnhandledError^(request, HttpStatus.NotFound); break ProcessRequest; } // if the endpoint requires HTTPS (or some other form of TLS), the server responds - // with a redirect to a URL that uses a TLS-enabled protocol + // either with an error or with a redirect to a URL that uses a TLS-enabled protocol if (!tls && endpoint.requiresTls) { - response = new SimpleResponse(PermanentRedirect); - - response.header.put(Header.Location, requestInfo.httpsUrl.toString()); - break ProcessRequest; - } - - // either a valid existing session is identified by the request, or a session will - // be created and a redirect to verify the session's successful creation will occur, - // which will then redirect back to this same request - (HttpStatus|SessionImpl result, Boolean redirect, Int eraseCookies) = ensureSession(requestInfo); - - // handle the error result (no session returned) - if (result.is(HttpStatus)) { - response = new SimpleResponse(result); - for (CookieId cookieId : CookieId.from(eraseCookies)) { - response.header.add(Header.SetCookie, eraseCookie(cookieId)); - } - break ProcessRequest; - } - - SessionImpl session = result; - - // if we're already redirecting, defer the version increment that comes from an IP - // address or user agent change; let's first verify that the user agent has the - // correct cookies for the current version of the session - if (!redirect) { - // check for any IP address and/or user agent change in the connection - redirect = session.updateConnection_(requestInfo.userAgent ?: "", - requestInfo.clientAddress); - } - - if (redirect) { - Int|HttpStatus redirectResult = session.prepareRedirect_(requestInfo); - if (redirectResult.is(HttpStatus)) { - RequestIn request = new Http1Request(requestInfo, []); - response = catalog.webApp.handleUnhandledError^(session, request, redirectResult); - break ProcessRequest; - } - - response = new SimpleResponse(TemporaryRedirect); - Int redirectId = redirectResult; - Header header = response.header; - Byte desired = session.desiredCookies_(tls); - for (CookieId cookieId : CookieId.values) { - if (desired & cookieId.mask != 0) { - if ((SessionCookie cookie, Time? sent, Time? verified) - := session.getCookie_(cookieId), verified == Null) { - header.add(Header.SetCookie, cookie.toString()); - session.cookieSent_(cookie); - } - } else if (eraseCookies & cookieId.mask != 0) { - header.add(Header.SetCookie, eraseCookie(cookieId)); - } - } - - // come back to verify that the user agent received and subsequently sent the - // cookies - Uri newUri = new Uri(path= - $"{catalog.services[0].path}/session/{redirectId}/{session.version_}"); - header[Header.Location] = newUri.toString(); + response = new SimpleResponse(endpoint.redirectTls ? PermanentRedirect : Forbidden); + response.header.add(Header.Location, requestInfo.httpsUrl.toString()); break ProcessRequest; } - // create a Request object to represent the incoming request - RequestIn request = new Http1Request(requestInfo, uriParams); - - // each endpoint has a required trust level, and the session knows its own trust - // level; if the endpoint requirement is higher, then we have to re-authenticate - // the user agent; the server must respond in a manner that causes the client to - // authenticate, including (but not limited to) any of the following manners: - // * with the `Unauthorized` error code (and related information) that indicates - // that the client must authenticate itself - // * with a redirect to a URL that provides the necessary login user interface - if (endpoint.requiredTrust > session.trustLevel) { - import Authenticator.AuthStatus; - AuthStatus|ResponseOut success = authenticator.authenticate(request, session); - switch (success) { - case Allowed: - // Authenticator has verified that the user is authenticated (the - // Authenticator should have already updated the session accordingly) - if (endpoint.requiredTrust > session.trustLevel) { - // the user is authenticated, but the user doesn't have the necessary - // security access - response = new SimpleResponse(Forbidden); - break ProcessRequest; - } - - // the user is authenticated and has the necessary security access; - // continue processing the request - break; - - case Unknown: - // the request didn't have any authorization information or the authorizer - // didn't know how to process it - response = new SimpleResponse(Unauthorized); - break ProcessRequest; - - case Forbidden: - // authentication didn't just fail, but it has been disallowed; respond - // with an HTTP "Forbidden" - response = new SimpleResponse(Forbidden); - break ProcessRequest; - - default: - // "success" isn't a Boolean, it's an HTTP response; send the response - // back to the client as the next step in authenticating the client - response = success.as(ResponseOut); - break ProcessRequest; + // create a Request object to represent the incoming request, and the reason that it + // matched; obtain the corresponding Session object if it exists, or create one if + // a Session is required by the request; there is a "chicken and egg" problem here, + // because to obtain a Session, we need a request, but the Request itself must know + // about the Session that it is associated with + Session? session = Null; + RequestIn request = new Http1Request(requestInfo, sessionBroker, endpoint.template, + uriParams, bindRequired=True); + ResponseOut? sendNow = Null; + // check if there is already a Session for the Request + if ((session, sendNow) := sessionBroker.findSession(request)) { + // sending the "sendNow" response is handled below + } else if (endpoint.requiresSession) { + // the selected endpoint requires a Session, we need to create one + if ((session, sendNow) := sessionBroker.requireSession(request)) { + assert session != Null || sendNow != Null; + } else { + // the Broker could not create a Session; return an error response instead + // of throwing an assertion + sendNow = new SimpleResponse(NotImplemented); } } + request.bindSession(session); - if (!endpoint.authorized(session.roles)) { - // the user doesn't have the necessary security permissions - response = new SimpleResponse(Forbidden); - break ProcessRequest; + if (sendNow == Null) { + // this is the "normal" i.e. "actual" request processing + bundle = bundlePool.allocateBundle(wsid); + Handler handle = bundle.ensureCallChain(endpoint); + response = handle^(request); + } else { + response = sendNow; } - - // this is the "normal" i.e. "actual" request processing - bundle = bundlePool.allocateBundle(wsid); - Handler handle = bundle.ensureCallChain(endpoint); - response = handle^(session, request); } + // at this point, the request processing has been kicked off asynchronously; the + // dispatcher (having done its job) will return immediately, freeing it up to be used + // by another incoming request; when this current request finally finishes, it will + // update statistics and release the remaining resources that it is using pendingRequests++; - &response.whenComplete((r, e) -> { pendingRequests--; bundlePool.releaseBundle(bundle?); @@ -347,7 +209,7 @@ service Dispatcher { @Inject Console console; console.print($|Dispatcher: unhandled exception for \ |"{requestInfo.uriString}": {e} - ); + ); requestInfo.respond(HttpStatus.InternalServerError.code, [], [], []); return; @@ -360,522 +222,4 @@ service Dispatcher { return; } } - - /** - * Use request cookies to identify an existing session, performing only absolutely necessary - * validations. No session validation, security checks, etc. are performed. This method does not - * attempt to redirect, create or destroy a session, etc. - * - * @param request the [RequestInfo] for the current request - * - * @return the [SessionImpl] indicated by the request, or Null if none - */ - private SessionImpl? getSessionOrNull(RequestInfo request) { - if (String[] cookies := request.getHeaderValuesForName(Header.Cookie)) { - for (String cookieHeader : cookies) { - for (String cookie : cookieHeader.split(';')) { - if (Int delim := cookie.indexOf('='), - CookieId cookieId := lookupCookie(cookie[0 ..< delim].trim())) { - if (SessionImpl session := sessionManager.getSessionByCookie( - cookie.substring(delim+1).trim())) { - return session; - } else { - return Null; - } - } - } - } - } - - return Null; - } - - /** - * Using the request information, look up or create the session object. - * - * @param requestInfo the incoming request - * - * @return result the session object, or a `4xx`-range `HttpStatus` that indicates a - * failure - * @return redirect True indicates that redirection is required to update the session - * cookies - * @return eraseCookies a bit mask of the cookie ID ordinals to delete - */ - private (SessionImpl|HttpStatus result, Boolean redirect, Int eraseCookies) - ensureSession(RequestInfo requestInfo) { - (String? txtTemp, String? tlsTemp, String? consent, Int eraseCookies) - = extractSessionCookies(requestInfo); - if (eraseCookies != 0) { - return BadRequest, False, eraseCookies; - } - - Boolean tls = requestInfo.tls; - - // 1 2 3TLS description/action - // - - - - ------------------ - // 0 create new session; redirect to verification (send cookie 1) - // 1 create new session; redirect to verification (send cookies 1 and 2) - // x 0 validate cookie 1 - // x 1 validate cookie 1 & verify that cookie 2/3 were NOT already sent & verified (if - // they were, then this is an error, because it indicates the likely theft of the - // plain text cookie); redirect to verification (send cookies 1 and 2, and 3 if - // [cookieConsent] has been set) - // x 0* error (no TLS, so cookie 2 is illegally present; also missing cookie 1) - // x 1 error (missing cookie 1) - // x x 0* error (no TLS, so cookie 2 is illegally present) - // x x 1 validate cookie 1 & 2; if [cookieConsent] has been set, redirect to verification - // x 0* error (no TLS, so cookie 3 is illegally present) - // x 1 validate cookie 3; assume temporary cookies absent due to user agent discarding - // temporary cookies; redirect to verification (send cookies 1 and 2) - // x x 0* error (no TLS, so cookie 3 is illegally present) - // x x 1 validate cookie 1 & 3 (1 must be newer than 3), and verify that cookie 2 was NOT - // already sent & verified; merge session for cookie 1 into session for cookie 3; - // redirect to verification; (send cookies 1 and 2) - // x x 0* error (no TLS, so cookie 2 and 3 are illegally present) - // x x 1 error (missing cookie 1) - // x x x 0* error (no TLS, so cookies 2 and 3 are illegally present) - // x x x 1 validate cookie 1 & 2 & 3 (must be same session) - // - // * handled by the following "illegal" check - - // no cookies implies that we must create a new session - Byte present = (txtTemp == Null ? 0 : CookieId.NoTls) - | (tlsTemp == Null ? 0 : CookieId.TlsTemp) - | (consent == Null ? 0 : CookieId.OnlyConsent); - if (present == CookieId.None) { - return createSession(requestInfo), True, CookieId.None; - } - - // based on whether the request came in on plain-text or TLS, it might be illegal for some - // of the cookies to have been included - Byte accept = tls ? CookieId.All : CookieId.NoTls; - Byte illegal = present & ~accept; - if (illegal != 0) { - // these are cases that we have no intelligent way to handle: the browser has sent - // cookies that should not have been sent. we can try to log the error against the - // session, but the only obvious thing to do at this point is to delete the unacceptable - // cookies and start over - SessionImpl? sessionNoTls = Null; - sessionNoTls := sessionManager.getSessionByCookie(txtTemp?); - - for (CookieId cookieId : CookieId.from(illegal)) { - String cookie = switch (cookieId) { - case PlainText: txtTemp; - case Encrypted: tlsTemp; - case Consent: consent; - } ?: assert as $"missing {cookieId} cookie"; - - if (SessionImpl session := sessionManager.getSessionByCookie(cookie)) { - if (sessionNoTls?.internalId_ != session.internalId_) { - // the sessions are different, so report to both sessions that the other - // cookie was for the wrong session - suspectCookie(requestInfo, sessionNoTls, cookie , WrongSession); - suspectCookie(requestInfo, session , txtTemp?, WrongSession); - } else { - // report to the session (from the illegal cookie) that that cookie was - // unexpected - suspectCookie(requestInfo, session, cookie, Unexpected); - } - } - } - - return BadRequest, False, illegal; - } - - // perform a "fast path" check: if all the present cookies point to the same session - if ((SessionImpl session, Boolean redirect) := findSession(txtTemp, tlsTemp, consent)) { - // check for split - if ((HttpStatus|SessionImpl result, _, eraseCookies) - := replaceSession(requestInfo, session, present)) { - if (result.is(HttpStatus)) { - // failed to create a session, which is reported back as an error, and - // we'll erase any non-persistent cookies (we don't erase the persistent - // cookie because it may contain consent info) - return result, False, eraseCookies; - } - - session = result; - redirect = True; - } else { - Byte desired = session.desiredCookies_(tls); - session.ensureCookies_(desired); - redirect |= desired != present; - eraseCookies = present & ~desired; - } - - return session, redirect, eraseCookies; - } - - // look up the session by each of the available cookies - SessionImpl? txtSession = sessionManager.getSessionByCookie(txtTemp?)? : Null; - SessionImpl? tlsSession = sessionManager.getSessionByCookie(tlsTemp?)? : Null; - SessionImpl? conSession = sessionManager.getSessionByCookie(consent?)? : Null; - - // common case: there's a persistent session that we found, but there was already a - // temporary session created with a plain text cookie before the user agent connected over - // TLS and sent us the persistent cookie - if (conSession != Null) { - (Match_ match, SessionCookie? cookie) = conSession.cookieMatches_(Consent, consent ?: assert); - switch (match) { - case Correct: - case Older: - case Newer: - // if the session specified by the plain text cookie is different from the - // session specified by the persistent cookie, then it may have more recent - // information that we want to retain (i.e. add to the session specified by - // the persistent cookie) - Boolean shouldMerge = txtSession?.internalId_ != conSession.internalId_ : False; - - // there should NOT be a TLS cookie with a different ID from the persistent cookie, - // since they both go over TLS - if (tlsSession?.internalId_ != conSession.internalId_) { - suspectCookie(requestInfo, conSession, tlsTemp ?: assert, WrongSession); - } - - // replace (split) the session if the session has been abandoned - eraseCookies = CookieId.None; - Boolean split = False; - if ((HttpStatus|SessionImpl result, _, eraseCookies) := replaceSession( - requestInfo, conSession, present)) { - if (result.is(HttpStatus)) { - // failed to create a session, which is reported back as an error, and - // we'll erase any non-persistent cookies (we don't erase the persistent - // cookie because it may contain consent info) - return result, False, eraseCookies; - } - - conSession = result; - split = True; - } - - if (shouldMerge) { - conSession.merge_(txtSession?); - } - - if (!split) { - // treat this event as significant enough to warrant a new session version; - // this helps to force the bifurcation of the session in the case of cookie - // theft - conSession.incrementVersion_(); - } - - // redirect to make sure all cookies are up to date - return conSession, True, eraseCookies; - } - } - - // remaining cases: either no session was found (even if there are some session cookies), or - // it's possible that sessions have been lost (e.g. from a server restart), and then the - // user agent reestablished a connection on a non-TLS connection and the server created a - // new session in response, and then the user agent just now switched to a TLS connection - // and passed its older TLS cookie(s) from the old lost session (in which case we need to - // just discard those abandoned cookies, after grabbing the consent data from the persistent - // cookie, if there is any) - if (tlsSession == Null && conSession == Null) { - // create a new session if necessary - HttpStatus|SessionImpl result = txtSession == Null - ? sessionManager.createSession(requestInfo) - : txtSession; - if (result.is(HttpStatus)) { - // failed to create a session, which is reported back as an error, and we'll erase - // any non-persistent cookies (we don't erase the persistent cookie because it may - // contain consent info) - return result, False, present & CookieId.BothTemp; - } - - SessionImpl session = result; - if ((result, _, eraseCookies) := replaceSession(requestInfo, session, present)) { - if (result.is(HttpStatus)) { - // failed to create a session, which is reported back as an error, and we'll erase - // any non-persistent cookies (we don't erase the persistent cookie because it may - // contain consent info) - return result, False, eraseCookies; - } - - session = result; - } - - // don't lose previous consent information if there was any in the persistent cookie - // (the "consent" variable holds the text content of the persistent cookie, and it - // implies that the user agent had previously specified "exclusive agent" mode) - if (consent != Null) { - try { - SessionCookie cookie = new SessionCookie(sessionManager, consent); - session.cookieConsent = cookie.consent; - session.exclusiveAgent = True; - } catch (Exception _) {} - } - - // create the desired cookies, and delete the undesired cookies - Byte desired = session.desiredCookies_(tls); - session.ensureCookies_(desired); - return session, True, present & ~desired; - } - - // every other case: blow it all up and start over - suspectCookie(requestInfo, txtSession?, tlsTemp?, WrongSession); - suspectCookie(requestInfo, txtSession?, consent?, WrongSession); - suspectCookie(requestInfo, tlsSession?, txtTemp?, WrongSession); - suspectCookie(requestInfo, tlsSession?, consent?, WrongSession); - - HttpStatus|SessionImpl result = createSession(requestInfo); - if (result.is(SessionImpl)) { - Byte desired = result.desiredCookies_(tls); - return result, True, present & ~desired; - } else { - return result, False, present; - } - } - - /** - * Using the request information, find any session cookie values. - * - * @param requestInfo the incoming request - * - * @return txtTemp the temporary plaintext session cookie value, or Null if absent - * @return tlsTemp the temporary TSL-protected session cookie value, or Null if absent - * @return consent the persistent consent cookie value, or Null if absent - * @return failures a bitmask of any errors encountered per `CookieId` - */ - (String? txtTemp, String? tlsTemp, String? consent, Int failures) - extractSessionCookies(RequestInfo requestInfo) { - String? txtTemp = Null; - String? tlsTemp = Null; - String? consent = Null; - Int failures = 0; - Boolean tls = requestInfo.tls; - - if (String[] cookies := requestInfo.getHeaderValuesForName(Header.Cookie)) { - for (String cookieHeader : cookies) { - NextCookie: for (String cookie : cookieHeader.split(';')) { - if (Int delim := cookie.indexOf('='), - CookieId cookieId := lookupCookie(cookie[0 ..< delim].trim())) { - String? oldValue; - String newValue = cookie.substring(delim+1).trim(); - switch (cookieId) { - case PlainText: - oldValue = txtTemp; - txtTemp = newValue; - break; - - case Encrypted: - // firefox bug: TLS-only cookies sent to localhost when TLS is false - if (!tls && requestInfo.clientAddress.loopback) { - continue NextCookie; - } - - oldValue = tlsTemp; - tlsTemp = newValue; - break; - - case Consent: - // firefox bug: TLS-only cookies sent to localhost when TLS is false - if (!tls && requestInfo.clientAddress.loopback) { - continue NextCookie; - } - - oldValue = consent; - consent = newValue; - break; - } - - if (oldValue? != newValue) { - // duplicate cookie detected but with a different value; this should be - // impossible - failures |= cookieId.mask; - } - - if (!tls && cookieId.tlsOnly) { - // user agent should have hidden the cookie; this should be impossible - failures |= cookieId.mask; - } - } - } - } - } - - return txtTemp, tlsTemp, consent, failures; - } - - /** - * Determine if a cookie name indicates a session cookie. - * - * @param cookieName the name of the cookie - * - * @return True iff the name indicates a session cookie - * @return (conditional) the CookieId - */ - conditional CookieId lookupCookie(String cookieName) { - if (cookieName == plainTextCookieName) { - return True, PlainText; - } else if (cookieName == encryptedCookieName) { - return True, Encrypted; - } else if (cookieName == consentCookieName) { - return True, Consent; - } else { - return False; - } - } - - /** - * Obtain the session cookie name for the specified cookie id. - * - * @param id the session CookieId - * - * @return the name of the cookie to use for the specified CookieId - */ - String cookieNameFor(CookieId id) { - return switch (id) { - case PlainText: plainTextCookieName; - case Encrypted: encryptedCookieName; - case Consent: consentCookieName; - }; - } - - /** - * Obtain a header entry that will erase the session cookie for the specified cookie id. - * - * @param id the session CookieId - * - * @return a header entry that will erase the session cookie - */ - String eraseCookie(CookieId id) { - return $"{cookieNameFor(id)}=; expires=Thu, 01 Jan 1970 00:00:00 GMT{id.attributes}"; - } - - /** - * Look up the session specified by the provided cookies. - * - * @param txtTemp the temporary plaintext session cookie value, or Null if absent - * @param tlsTemp the temporary TSL-protected session cookie value, or Null if absent - * @param consent the persistent consent cookie value, or Null if absent - * - * @return True iff the passed cookies are valid and all point to the same session - * @return (conditional) the session, iff the provided cookies all point to the same session - * @return (conditional) True if the user agent's cookies need to be updated - */ - conditional (SessionImpl session, Boolean redirect) - findSession(String? txtTemp, String? tlsTemp, String? consent) { - SessionImpl? session = Null; - Boolean redirect = False; - - for (CookieId cookieId : PlainText..Consent) { - String? cookieText = switch(cookieId) { - case PlainText: txtTemp; - case Encrypted: tlsTemp; - case Consent : consent; - }; - - if (cookieText == Null) { - continue; - } - - if (SessionImpl current := sessionManager.getSessionByCookie(cookieText)) { - if (session == Null) { - session = current; - } else if (session.internalId_ != current.internalId_) { - return False; - } - - (Match_ match, SessionCookie? cookie) = current.cookieMatches_(cookieId, cookieText); - switch (match) { - case Correct: - break; - - case Older: - // the redirect will update the cookies to the current one - redirect = True; - break; - - case Newer: - // this can happen if the server crashed after incrementing the session - // version and after sending the cookie(s) back to the user agent, but - // before persistently recording the updated session information - assert cookie != Null; - current.incrementVersion_(cookie.version+1); - redirect = True; - break; - - default: - return False; - } - } else { - return False; - } - } - - return session == Null - ? False - : (True, session, redirect); - } - - /** - * Using the request information, create a new session object. - * - * @param requestInfo the incoming request - * - * @return the session object, or a `4xx`-range `HttpStatus` that indicates a failure - */ - private HttpStatus|SessionImpl createSession(RequestInfo requestInfo) { - HttpStatus|SessionImpl result = sessionManager.createSession(requestInfo); - if (result.is(HttpStatus)) { - return result; - } - - SessionImpl session = result; - session.ensureCookies_(requestInfo.tls ? CookieId.BothTemp : CookieId.NoTls); - return session; - } - - /** - * Given the request information and a session, determine if that session needs to be replaced - * with a different session due to a previous split. - * - * @param requestInfo the incoming request - * @param session the session implied by that request - * @param present the cookies that are present in the request - * - * @return True iff the session needs to be replaced - * @return result (conditional) the session object, or a `4xx`-range `HttpStatus` that - * indicates a failure - * @return redirect (conditional) True indicates that redirection is required to update the - * session cookies - * @return eraseCookies (conditional) a bit mask of the cookie ID ordinals to delete - */ - private conditional (SessionImpl|HttpStatus result, Boolean redirect, Int eraseCookies) - replaceSession(RequestInfo requestInfo, SessionImpl session, Byte present) { - // check if the session itself has been abandoned and needs to be replaced with a - // new session as the result of a session split - if (HttpStatus|SessionImpl result := session.isAbandoned_(requestInfo)) { - if (result.is(HttpStatus)) { - return True, result, False, CookieId.None; - } - - // some cookies from the old session may need to be erased if they are not used - // by the new session - session = result; - return True, session, True, present & ~session.desiredCookies_(requestInfo.tls); - } - - return False; - } - /** - * This method is invoked when a cookie is determined to be suspect. - * - * @param requestInfo the incoming request - * @param session the session against which the bad cookie is suspected - * @param value the cookie value - * @param failure the [SessionImpl.Match_] value indicating the failure - */ - private void suspectCookie(RequestInfo requestInfo, - SessionImpl session, - String value, - SessionImpl.Match_ failure) { - // TODO CP - @Inject Console console; - console.print($|Suspect cookie {value.quoted()} with {failure=}\ - | for session {session.internalId_} - | with known cookies {CookieId.from(session.knownCookies_)} - ); - } } \ No newline at end of file diff --git a/lib_xenia/src/main/x/xenia/Http1Request.x b/lib_xenia/src/main/x/xenia/Http1Request.x index f97337eb43..07be7a4a0f 100644 --- a/lib_xenia/src/main/x/xenia/Http1Request.x +++ b/lib_xenia/src/main/x/xenia/Http1Request.x @@ -5,6 +5,7 @@ import net.UriTemplate.UriParameters; import web.AcceptList; import web.Body; +import web.Endpoint; import web.Header; import web.HttpMessage; import web.HttpMethod; @@ -12,6 +13,8 @@ import web.MediaType; import web.Protocol; import web.Scheme; +import web.sessions.Broker as SessionBroker; + import HttpServer.RequestInfo; @@ -19,7 +22,13 @@ import HttpServer.RequestInfo; * An implementation of an HTTP/1 (i.e. 0.9, 1.0, 1.1) request, as received by a server, using the * raw request data provided by the `HttpServer.Handler` interface. */ -const Http1Request(RequestInfo info, UriParameters matchResult) +const Http1Request(RequestInfo info, + SessionBroker broker, + UriTemplate template = UriTemplate.ROOT, + UriParameters matchResult = [], + Endpoint? endpoint = Null, + Boolean bindRequired = False, + ) implements RequestIn implements Header implements Body { @@ -27,6 +36,7 @@ const Http1Request(RequestInfo info, UriParameters matchResult) assert() { // TODO handle non-simple bodies e.g. multi-part, streaming assert !info.containsNestedBodies(); + if (Byte[] bytes := info.getBodyBytes()) { this.hasBody = True; this.bytes = bytes; @@ -40,6 +50,41 @@ const Http1Request(RequestInfo info, UriParameters matchResult) } } + /** + * The raw request information. + */ + protected RequestInfo info; + + /** + * Internal: The SessionBroker to use if necessary to look up a Session for this Request. + */ + private SessionBroker broker; + + /** + * Internal: Indicates that a [bindSession] must be called to provide the session. + */ + private Boolean bindRequired; + + /** + * Internal: Used to lazy-compute the session value. + */ + private @Transient Session? session_; + + /** + * Internal: Used to validate the binding of the session value. + */ + private @Transient Boolean sessionReady_; + + /** + * Supply the session value. + */ + void bindSession(Session? session) { + assert bindRequired && !this.&session.assigned && !sessionReady_; + session_ = session; + sessionReady_ = True; + val forceLazy = this.session; + } + /** * Internal. */ @@ -161,6 +206,9 @@ const Http1Request(RequestInfo info, UriParameters matchResult) @Override UInt16 serverPort.get() = info.receivedAtAddress[1]; + @Override + Boolean tls.get() = info.tls; + typedef (String | List) as QueryParameter; @Override @@ -188,9 +236,27 @@ const Http1Request(RequestInfo info, UriParameters matchResult) return []; } + @Override + @Lazy Session? session.calc() { + if (bindRequired) { + assert sessionReady_ as "bindSession() call was required, but never came"; + return session_; + } + + // look up the session using the broker + Session? session = Null; + session := broker.findSession(this); + return session; + } + + @Override + UriTemplate template; + @Override UriParameters matchResult; + @Override + Endpoint? endpoint; // ----- Header interface ---------------------------------------------------------------------- @@ -220,42 +286,37 @@ const Http1Request(RequestInfo info, UriParameters matchResult) } @Override - Iterator valuesOf(String name, Char? expandDelim=Null) { - Iterator iter = entries.iterator().filter(kv -> CaseInsensitive.areEqual(kv[0], name)) - .map(kv -> kv[1]); - - if (expandDelim != Null) { - iter = iter.flatMap(s -> s.split(expandDelim)); + List valuesOf(String name, Char? expandDelim = Null) { + if (String[] values := info.getHeaderValuesForName(name)) { + return expandDelim == Null || values.all(s -> !s.indexOf(expandDelim)) + ? values + : values.flatMap((String s) -> s.split(expandDelim)) // TODO GG shouldn't need "String" + .map(s -> s.trim(), ToStringArray); } - - return iter.map(s -> s.trim()); + return []; } @Override - conditional String firstOf(String name, Char? expandDelim=Null) { + conditional String firstOf(String name, Char? expandDelim = Null) { if (String[] values := info.getHeaderValuesForName(name)) { String value = values.empty ? "" : values[0]; - if (expandDelim != Null) { - values = value.split(expandDelim); - value = values[0].trim(); + if (expandDelim != Null, Int sep := value.indexOf(expandDelim)) { + value = value[0.. new SessionImpl(mgr, id, info); + sessionProducer = (mgr, id, request) -> new SessionImpl(mgr, id, request); } else { Annotation[] annotations = new Annotation[mixinCount] (i -> new Annotation(sessionMixins[i])); Class sessionClass = SessionImpl.annotate(annotations); - sessionProducer = (mgr, id, info) -> { + sessionProducer = (mgr, id, request) -> { assert Struct structure := sessionClass.allocate(); assert structure.is(struct SessionImpl); - SessionImpl.initialize(structure, mgr, id, info); + SessionImpl.initialize(structure, mgr, id, request); return sessionClass.instantiate(structure).as(SessionImpl); }; } - return new SessionManager(new SessionStore(), sessionProducer, route.httpPort, route.httpsPort); + return new SessionManager(new SessionStore(), sessionProducer, route, catalog); } } \ No newline at end of file diff --git a/lib_xenia/src/main/x/xenia/SessionImpl.x b/lib_xenia/src/main/x/xenia/SessionImpl.x index 03e8389979..6c2f9a86cd 100644 --- a/lib_xenia/src/main/x/xenia/SessionImpl.x +++ b/lib_xenia/src/main/x/xenia/SessionImpl.x @@ -5,12 +5,16 @@ import convert.formats.Base64Format; import net.IPAddress; +import sec.Entitlement; +import sec.Principal; + import web.CookieConsent; import web.Header; import web.HttpStatus; import web.TrustLevel; -import HttpServer.RequestInfo; +import web.security.Authenticator.Attempt; + import SessionCookie.CookieId; @@ -35,41 +39,40 @@ service SessionImpl /** * Construct a SessionImpl instance. * - * @param manager the SessionManager - * @param sessionId the internal session identifier - * @param requestInfo the request information + * @param manager the SessionManager + * @param sessionId the internal session identifier + * @param request the request information */ - construct(SessionManager manager, Int64 sessionId, RequestInfo requestInfo) { - initialize(this, manager, sessionId, requestInfo); + construct(SessionManager manager, Int64 sessionId, RequestIn request) { + initialize(this, manager, sessionId, request); } /** * Construction helper, used directly by the constructor, but also via reflection. * - * @param structure the structure of the SessionImpl being constructed - * @param manager the SessionManager - * @param sessionId the internal session identifier - * @param requestInfo the request information - * @param tls True if the request was received over a TLS connection + * @param structure the structure of the SessionImpl being constructed + * @param manager the SessionManager + * @param sessionId the internal session identifier + * @param request the request information + * @param tls True if the request was received over a TLS connection */ static void initialize((struct SessionImpl) structure, SessionManager manager, Int64 sessionId, - RequestInfo requestInfo) { + RequestIn request) { Time now = clock.now; structure.manager_ = manager; structure.created = now; structure.lastUse = now; structure.versionChanged_ = now; - structure.ipAddress = requestInfo.clientAddress; - structure.userAgent = requestInfo.userAgent ?: ""; + structure.ipAddress = request.client; + structure.userAgent = request.userAgent ?: ""; structure.cookieConsent = None; structure.trustLevel = None; - structure.roles = []; structure.internalId_ = sessionId; structure.sessionId = idToString_(sessionId); - structure.prevTLS_ = requestInfo.tls; + structure.prevTLS_ = request.tls; } @@ -334,77 +337,77 @@ service SessionImpl } @Override - public/private String? userId; + public/private Principal? principal; @Override - public/private Time? lastAuthenticated; + public/private Entitlement[] entitlements = []; @Override - TrustLevel trustLevel; + public/private Time? lastAuthenticated; @Override - immutable Set roles; + TrustLevel trustLevel; @Override public/private String sessionId; @Override - void authenticate(String userId, - Boolean exclusiveAgent = False, - TrustLevel trustLevel = Highest, - Set roles = [], + void authenticate(Principal? principal = Null, + Entitlement[] entitlements = [], + Boolean exclusiveAgent = False, + TrustLevel trustLevel = Highest, ) { - if ( this.userId != userId + if ( this.principal != principal + || this.entitlements != entitlements || this.exclusiveAgent != exclusiveAgent || this.trustLevel != trustLevel - || this.roles != roles ) { - if (String oldUser ?= this.userId, oldUser != userId) { - issueEvent_(SessionDeauthenticated, Void, &sessionDeauthenticated(oldUser), + if (this.principal? != principal : False + || !this.entitlements.empty && this.entitlements != entitlements) { + issueEvent_(SessionDeauthenticated, Void, &sessionDeauthenticated(this.principal, this.entitlements), () -> $|An exception in session {this.internalId_} occurred during a\ - | deauthentication event for user {oldUser.quoted()} + | deauthentication event ); } - this.userId = userId; + this.principal = principal; + this.entitlements = entitlements; this.exclusiveAgent = exclusiveAgent; this.trustLevel = trustLevel; - this.roles = roles.is(immutable) ? roles : - roles.is(Freezable) ? roles.freeze() : - new HashSet(roles).freeze(True); this.lastAuthenticated = clock.now; // reset failed attempt count since we succeeded in logging in // TODO - issueEvent_(SessionAuthenticated, Void, &sessionAuthenticated(userId), + issueEvent_(SessionAuthenticated, Void, &sessionAuthenticated(principal, entitlements), () -> $|An exception in session {this.internalId_} occurred during an\ - | authentication event for user {userId.quoted()} + | authentication event ); } } @Override - Boolean authenticationFailed(String? userId) { + Boolean authenticationFailed(RequestIn request, Attempt attempt) { // accumulate failure information, both in absolute terms (number of attempts), and per // user id // TODO - return False; } @Override void deauthenticate() { - if (String oldUser ?= userId) { - userId = Null; + Principal? oldPrincipal = principal; + Entitlement[] oldEntitlements = entitlements; + if (oldPrincipal != Null || !oldEntitlements.empty) { + principal = Null; + entitlements = []; exclusiveAgent = False; trustLevel = None; - roles = []; lastAuthenticated = Null; - issueEvent_(SessionDeauthenticated, Void, &sessionDeauthenticated(oldUser), + issueEvent_(SessionDeauthenticated, Void, &sessionDeauthenticated(oldPrincipal, oldEntitlements), () -> $|An exception in session {this.internalId_} occurred during a\ - | deauthentication event for user {oldUser.quoted()} + | deauthentication event ); } } @@ -444,12 +447,12 @@ service SessionImpl } @Override - void sessionAuthenticated(String user) { + void sessionAuthenticated(Principal? principal, Entitlement[] entitlements) { confirmReached_(SessionAuthenticated); } @Override - void sessionDeauthenticated(String user) { + void sessionDeauthenticated(Principal? principal, Entitlement[] entitlements) { confirmReached_(SessionDeauthenticated); } @@ -688,15 +691,15 @@ service SessionImpl /** * Check if the session needs to be replaced. * - * @param requestInfo the incoming request + * @param request the incoming request * * @return True iff this session is abandoned * @return (conditional) the session object, or a `4xx`-range `HttpStatus` that indicates a * failure */ - conditional SessionImpl|HttpStatus isAbandoned_(RequestInfo requestInfo) { + conditional SessionImpl|HttpStatus isAbandoned_(RequestIn request) { return abandoned_ - ? (True, split_(requestInfo)) + ? (True, split_(request)) : False; } @@ -708,12 +711,12 @@ service SessionImpl * two separate and subsequently diverging copies of the session), and reduces the trust level * to force re-authentication. * - * @param requestInfo the incoming request + * @param request the incoming request * * @return (conditional) the session object, or a `4xx`-range `HttpStatus` that indicates a * failure */ - HttpStatus|SessionImpl split_(RequestInfo requestInfo) { + HttpStatus|SessionImpl split_(RequestIn request) { if (!abandoned_) { // the first thing to do is to notify the session that an apparent theft was attempted; // if desired, the application can strip information out of the session or take other @@ -731,14 +734,14 @@ service SessionImpl } // create the new session - HttpStatus|SessionImpl result = manager_.cloneSession(this, requestInfo); + HttpStatus|SessionImpl result = manager_.cloneSession(this, request); if (result.is(SessionImpl)) { // keep track of splits - this.splits_ += new SessionSplit_(requestInfo.clientAddress, version_, result.internalId_); + this.splits_ += new SessionSplit_(request.client, version_, result.internalId_); // create new cookies - result.ensureCookies_(desiredCookies_(requestInfo.tls)); + result.ensureCookies_(desiredCookies_(request.tls)); // notify the new session that it was forked result.issueEvent_(SessionForked, Void, &sessionForked(), @@ -782,13 +785,11 @@ service SessionImpl } /** - * @param info the current request + * @param request the current request * - * @return a unique (within this session) integer identifier of the redirect, iff this session - * will permit a redirect to occur; otherwise the HttpStatus for an error that must be - * returned as a response to the provided `RequestInfo` + * @return a unique (within this session) integer identifier of the redirect */ - Int|HttpStatus prepareRedirect_(RequestInfo info) { + Int prepareRedirect_(RequestIn request) { // get rid of any excess pending redirects (size limit the array of "in flight" redirects) if (PendingRedirect_[] pending ?= pendingRedirects_, pending.size > MaxRedirects_) { // keep the most recent redirects, but one less than MaxRedirects_ to make room for the @@ -807,7 +808,7 @@ service SessionImpl id = rnd.int(100k).toInt64() + 1; } while (pendingRedirects_?.any(r -> r.id == id)); - PendingRedirect_ pending = new PendingRedirect_(id, info.uri, now); + PendingRedirect_ pending = new PendingRedirect_(id, request.uri, now); pendingRedirects_ = pendingRedirects_? + pending : [pending]; return id; diff --git a/lib_xenia/src/main/x/xenia/SessionManager.x b/lib_xenia/src/main/x/xenia/SessionManager.x index dc51c64494..517bcabf5f 100644 --- a/lib_xenia/src/main/x/xenia/SessionManager.x +++ b/lib_xenia/src/main/x/xenia/SessionManager.x @@ -5,7 +5,6 @@ import convert.formats.Base64Format; import web.HttpStatus; -import HttpServer.RequestInfo; import SessionCookie.CookieId; import SessionImpl.Event_; import SessionStore.IOResult; @@ -50,14 +49,22 @@ service SessionManager implements Closeable { // ----- constructors -------------------------------------------------------------------------- - construct(SessionStore store, SessionProducer instantiateSession, - UInt16 plainPort = 80, UInt16 tlsPort = 443) { + construct(SessionStore store, + SessionProducer instantiateSession, + HostInfo route, + Catalog catalog) { + this.store = store; this.instantiateSession = instantiateSession; + this.route = route; + this.catalog = catalog; + + String httpSuffix = route.httpPort == 80 ? "" : $"_{route.httpPort}"; + String httpsSuffix = route.httpsPort == 443 ? "" : $"_{route.httpsPort}"; - plainTextCookieName = plainPort == 80 ? CookieId.PlainText.cookieName : $"{CookieId.PlainText.cookieName}_{plainPort}"; - encryptedCookieName = tlsPort == 443 ? CookieId.Encrypted.cookieName : $"{CookieId.Encrypted.cookieName}_{tlsPort}"; - consentCookieName = tlsPort == 443 ? CookieId.Consent.cookieName : $"{CookieId.Consent.cookieName}_{tlsPort}"; + this.plainTextCookieName = CookieId.PlainText.cookieName + httpSuffix; + this.encryptedCookieName = CookieId.Encrypted.cookieName + httpsSuffix; + this.consentCookieName = CookieId.Consent.cookieName + httpsSuffix; } @@ -68,12 +75,23 @@ service SessionManager */ protected/private @Final SessionStore store; + typedef function SessionImpl(SessionManager, Int64, RequestIn) as SessionProducer; + /** * The means to instantiate sessions. */ - typedef function SessionImpl(SessionManager, Int64, RequestInfo) as SessionProducer; protected/private @Final SessionProducer instantiateSession; + /** + * The [HostInfo] information about the route that this `SessionManager` is servicing. + */ + public/private HostInfo route; + + /** + * The [Catalog] for the [WebApp] that this `SessionManager` is servicing. + */ + public/private Catalog catalog; + /** * The name of the session cookie for non-TLS traffic. */ @@ -359,14 +377,14 @@ service SessionManager * Instantiate a new [SessionImpl] object, including any [Session] mix-ins that the [WebApp] * contains. * - * @param requestInfo the request information + * @param request the request information * * @return a new [SessionImpl] object, including any mixins declared by the application, or the * [HttpStatus] describing why the session could not be created */ - HttpStatus|SessionImpl createSession(RequestInfo requestInfo) { + HttpStatus|SessionImpl createSession(RequestIn request) { Int64 id = generateId(); - SessionImpl session = instantiateSession(this, id, requestInfo); + SessionImpl session = instantiateSession(this, id, request); sessions.put(id, session); purger.track^(id); @@ -378,13 +396,13 @@ service SessionManager * Instantiate a copy of the passed [SessionImpl] object. * * @param oldSession the session to clone - * @param requestInfo the request information + * @param request the request information * * @return a clone of the [SessionImpl] object, or the [HttpStatus] describing why the session * could not be cloned */ - HttpStatus|SessionImpl cloneSession(SessionImpl oldSession, RequestInfo requestInfo) { - HttpStatus|SessionImpl result = createSession(requestInfo); + HttpStatus|SessionImpl cloneSession(SessionImpl oldSession, RequestIn request) { + HttpStatus|SessionImpl result = createSession(request); if (result.is(HttpStatus)) { return result; } diff --git a/lib_xenia/src/main/x/xenia/SystemService.x b/lib_xenia/src/main/x/xenia/SystemService.x deleted file mode 100644 index 45da4245ad..0000000000 --- a/lib_xenia/src/main/x/xenia/SystemService.x +++ /dev/null @@ -1,255 +0,0 @@ -import HttpServer.RequestInfo; -import SessionCookie.CookieId; -import SessionImpl.Match_; - -import web.Header; -import web.HttpStatus; - -import web.responses.SimpleResponse; - - -/** - * The SystemService provides end points for "system" functionality (like verifying HTTPS/TLS is - * enabled, handling some forms of authentication, etc.), and is automatically added to every - * Xenia-hosted web application. - */ -@WebService("/xverify") -service SystemService { - // ----- properties ---------------------------------------------------------------------------- - - /** - * The cached `Catalog`. - */ - @Lazy Catalog catalog.calc() { - assert val catalog := webApp.registry_.getResource("catalog"); - return catalog.as(Catalog); - } - - /** - * The cached `SessionManager`. - */ - @Lazy SessionManager sessionManager.calc() { - assert val mgr := webApp.registry_.getResource("sessionManager"); - return mgr.as(SessionManager); - } - - - // ----- HTTP endpoints ------------------------------------------------------------------------ - - /** - * Handle a system service call. This is the "endpoint" for the service. - * - * @param uriString the URI that identifies the system service endpoint being routed to - * @param info information about the request - * - * @return one of: an `HttpStatus` error code; a complete `Response`; or a new path to use in - * place of the passed `uriString` - */ - HttpStatus|ResponseOut|String handle(Dispatcher dispatcher, - String uriString, - RequestInfo info, - ) { - String[] parts = uriString.split('/'); - switch (String command = parts.empty ? "" : parts[0]) { - case "session": // e.g. "/xverify/session/12345" comes in as "session/12345" - if (parts.size < 3) { - return NotFound; - } - - Int64 redirect; - Int64 version; - try { - redirect = new Int64(parts[1]); - version = new Int64(parts[2]); - } catch (Exception e) { - return NotFound; - } - - return validateSessionCookies(dispatcher, uriString, info, redirect, version); - - default: - return NotFound; - } - } - - /** - * Implements the validation check for the session cookies when one or more session cookie has - * been added. - * - * There are five expected outcomes: - * - * * Confirm - 99.9+% of the time, everything is perfect and exactly as expected - * * Repeat - Occasionally, the redirect will need to be repeated because the session version - * has already advanced for some reason before this redirect request was able to be processed - * * Split - If anything is actually _wrong_, then the session is split, and a redirect is - * returned for the user agent to validate against the split session - * * New - If a session can't be found to validate against, or some other unrecoverable problem - * appears to exist, then a new session is created, and a redirect is returned for the user - * agent to validate the new session - * * Abort - If something so fundamental is wrong that none of the above is an option, then the - * method simply aborts with an error code - * - * @param uriString the URI that identifies the system service endpoint being routed to - * @param info information about the request - * @param redirect the redirection identifier previously registered with the session - * @param version the session version being validated against - * - * @return one of: an `HttpStatus` error code; a complete `Response`; or a new path to use in - * place of the passed `uriString` - */ - protected HttpStatus|ResponseOut validateSessionCookies(Dispatcher dispatcher, - String uriString, - RequestInfo info, - Int64 redirect, - Int64 version, - ) { - enum Action {Confirm, Repeat, Split, New} - - static Action evaluate(SessionImpl session, CookieId cookieId, String cookieText) { - (Match_ match, SessionCookie? cookie) = session.cookieMatches_(cookieId, cookieText); - switch (match) { - case Correct: - session.cookieVerified_(cookie ?: assert); - return Confirm; - - case Older: - // need to redirect (again) because the session version was just incremented - // while we were waiting for the current redirect - return Repeat; - - case Newer: - // this is inconceivable, since we just redirected the user agent to confirm its - // cookies, and we supposedly already handled the case that the user agent had - // a "newer" cookie; treat this as a protocol violation - return Split; - - case Corrupt: - case WrongSession: - // this is inconceivable, since we just found the session using this cookie, - // but the cookie doesn't match; assume something so weird is going on that we - // should just give up and return a new session to minimize any further damage - case WrongCookieId: - case Unexpected: - // these are serious violations of the protocol; assume something so weird is - // going on that we should just give up and return a new session to minimize - // any further damage - // TODO CP should report this to the session(s) - default: - return New; - } - } - - // find up to three session cookies that were passed in with the request - (String? txtTemp, String? tlsTemp, String? consent, Int failures) - = dispatcher.extractSessionCookies(info); - if (failures != 0) { - // something is deeply wrong with the cookies coming from the user agent, and it's - // unlikely that we can fix them (because by the time that we got here, we supposedly - // would have already tried to fix whatever that mess is) - return BadRequest; - } - - Boolean tls = info.tls; - Action action = Confirm; - - // the plain-text cookie is required; use the plain text cookie to find the session; the - // absence of either the cookie or the session implies that we should create a new session - @Unassigned SessionImpl session; // note: assumed unassigned iff "action==New" - Validate: if (txtTemp != Null, session := sessionManager.getSessionByCookie(txtTemp)) { - // validate the plain text temporary cookie that we just used to look up the session - action = evaluate(session, PlainText, txtTemp); - - // validate the temporary TLS cookie - if (tlsTemp == Null) { - // if the redirect came in on TLS, then the TLS "temporary" cookie should have been - // included; treat it as a protocol violation and split the session - if (tls && action != Repeat) { - action = action.notLessThan(Split); - } - } else { - action = action.notLessThan(evaluate(session, Encrypted, tlsTemp)); - } - - // validate the persistent TLS cookie - if (consent == Null) { - // the consent cookie is required over TLS if the session has sent it out, unless - // we've already determined that we need to redirect yet again) - if (tls && session.usePersistentCookie_ && action != Repeat, - (_, Time? sent) := session.getCookie_(Consent), sent != Null) { - action = action.notLessThan(Split); - } - } else { - action = action.notLessThan(evaluate(session, Consent, consent)); - } - } else { - // unable to locate the specified session, so attempt to create a new session - action = New; - } - - switch (action) { - case Confirm: - // handle the most common case in which the redirect was successful - ResponseOut response = new SimpleResponse(TemporaryRedirect); - response.header[Header.Location] = session.claimRedirect_(redirect)?.toString() : "/"; - return response; - - case Repeat: - // handle the (rare) case in which we need to repeat the redirect, but only if the - // actual session version is ahead of the version that this method was called to - // validate (otherwise, repeating the validation isn't going to change anything) - if (session.version_ > version) { - break; - } - continue; - case Split: - // protocol error: split the session - HttpStatus|SessionImpl result = session.split_(info); - if (result.is(HttpStatus)) { - return result; - } - session = result; - break; - - case New: - // create a new session - HttpStatus|SessionImpl result = sessionManager.createSession(info); - if (result.is(HttpStatus)) { - return result; - } - session = result; - break; - } - - // send desired cookies (unless they've already been verified, in which case we never - // re-send them) - ResponseOut response = new SimpleResponse(TemporaryRedirect); - Header header = response.header; - Byte desired = session.desiredCookies_(tls); - - session.ensureCookies_(desired); - - for (CookieId cookieId : CookieId.from(desired)) { - if ((SessionCookie resendCookie, Time? sent, Time? verified) - := session.getCookie_(cookieId), verified == Null) { - header.add(Header.SetCookie, resendCookie.toString()); - if (sent == Null) { - session.cookieSent_(resendCookie); - } - } - } - - // erase undesired cookies - Byte present = (txtTemp == Null ? 0 : CookieId.NoTls) - | (tlsTemp == Null ? 0 : CookieId.TlsTemp) - | (consent == Null ? 0 : CookieId.OnlyConsent); - for (CookieId cookieId : CookieId.from(present & ~desired)) { - header.add(Header.SetCookie, dispatcher.eraseCookie(cookieId)); - } - - // come back to verify that the user agent received and subsequently sent the - // cookies - Uri newUri = new Uri(path=$"{catalog.services[0].path}/session/{redirect}/{session.version_}"); - header[Header.Location] = newUri.toString(); - return response; - } -} \ No newline at end of file diff --git a/manualTests/src/main/x/webTests/Hello.x b/manualTests/src/main/x/webTests/Hello.x index d958d9cdaa..ba0f143f7a 100644 --- a/manualTests/src/main/x/webTests/Hello.x +++ b/manualTests/src/main/x/webTests/Hello.x @@ -1,9 +1,12 @@ /** * You can run this module with or without port forwarding. - + * * Then start the server by the command: * * xec build/Hello.xtc [routeName:httpPort/httpsPort] [bindName:bindHttpPort/bindHttpsPort] + * + * This is an internal test for development and not an "easy to use" example. Defaults assume port + * forwarding (80 -> 8080, 443 -> 8090). */ module Hello incorporates WebApp { @@ -21,7 +24,9 @@ module Hello import web.responses.*; import web.security.*; + import xenia.CookieBroker; import xenia.Http1Request; + import xenia.SessionManager; package msg import Messages; import msg.Greeting; @@ -59,8 +64,11 @@ module Hello @Inject Directory curDir; Directory dataDir = curDir.dirFor("data"); - WebService.Constructor constructor = () -> new ExtraFiles(dataDir); - xenia.createServer(this, route=route, binding=binding, extras=[ExtraFiles=constructor], + xenia.createServer(this, route=route, binding=binding, + extras=[ + ExtraFiles = () -> new ExtraFiles(dataDir), + CookieBroker = () -> cookieBroker, + ], isTrustedProxy=isTrustedProxy); String portSuffix = route.httpPort == 80 ? "" : $":{route.httpPort}"; @@ -80,9 +88,19 @@ module Hello ); } - Authenticator createAuthenticator() { - return new DigestAuthenticator(new FixedRealm("Hello", ["admin"="addaya"])); - } + private @Lazy CookieBroker cookieBroker.calc() = new CookieBroker(this); + + // ----- WebApp duck-type methods -------------------------------------------------------------- + + Authenticator createAuthenticator() = + new DigestAuthenticator(new FixedRealm("Hello", "admin", "addaya")); +// TODO: test ChainAuthenticator +// new BasicAuthenticator(new FixedRealm("Hello", "admin", "addaya")); + + sessions.Broker createSessionBroker() = cookieBroker; + + + // ----- Web services -------------------------------------------------------------------------- /** * This service allows accessing files in the "data" directory. @@ -112,22 +130,22 @@ module Hello return ("Hi", 1); } - @HttpsRequired + @HttpsRequired(autoRedirect=True) @Get("s") - String secure() { - return "secure"; + ResponseOut secure() { + return home(); } @Get("user") @Produces(Text) String getUser(Session session) { - return session.userId ?: ""; + return session.principal?.name : ""; } @LoginRequired @Get("l") - ResponseOut logMeIn(Session session) { - return home(); + String logMeIn(Session session) { + return $"user={session.principal?.name : ""}"; } @Get("d") @@ -159,7 +177,8 @@ module Hello String[] getEcho(String path) { assert:debug path != "debug"; - assert RequestIn request ?= this.request; + assert RequestIn request ?= this.request, + request := &request.revealAs((protected Http1Request)); Session? session = this.session; return [ @@ -169,26 +188,13 @@ module Hello $"originator={request.originator}", $"client={request.client}", $"server={request.server}", - $"route={request.as(Http1Request).info.routeTrace}", + $"route={request.info.routeTrace}", $"authority={request.authority}", $"path={request.path}", $"protocol={request.protocol}", $"accepts={request.accepts}", $"query={request.queryParams}", - $"user={session?.userId? : ""}", - ]; - } - - @Post("anthropic") - JsonObject simulateClaudeAI(@BodyParam JsonObject message = []) { - assert Doc question := JsonPointer.from("messages/0/content").get(message); - - JsonObject response = ["type"="text", "text"=$"I'm happy to help with '{question}'"]; - return ["id"="msg_01", - "type"="message", - "role"="assistant", - "model"="claude-3-5-sonnet-20241022", - "content"=[response] + $"user={session?.principal?.name : ""}", ]; } } diff --git a/xdk/build.gradle.kts b/xdk/build.gradle.kts index b7591af2f5..c86ee0aed2 100644 --- a/xdk/build.gradle.kts +++ b/xdk/build.gradle.kts @@ -56,6 +56,7 @@ dependencies { xtcModule(libs.xdk.jsondb) xtcModule(libs.xdk.net) xtcModule(libs.xdk.oodb) + xtcModule(libs.xdk.sec) xtcModule(libs.xdk.web) xtcModule(libs.xdk.webauth) xtcModule(libs.xdk.xenia) diff --git a/xdk/settings.gradle.kts b/xdk/settings.gradle.kts index cda1b69403..63cddd9033 100644 --- a/xdk/settings.gradle.kts +++ b/xdk/settings.gradle.kts @@ -28,6 +28,7 @@ listOf( "lib_json", "lib_jsondb", "lib_oodb", + "lib_sec", "lib_web", "lib_webauth", "lib_xenia",