.\n\n.list-group {\n // No need to set list-style: none; since .list-group-item is block level\n margin-bottom: 20px;\n padding-left: 0; // reset padding because ul and ol\n}\n\n\n// Individual list items\n//\n// Use on `li`s or `div`s within the `.list-group` parent.\n\n.list-group-item {\n position: relative;\n display: block;\n padding: 10px 15px;\n // Place the border on the list items and negative margin up for better styling\n margin-bottom: -1px;\n background-color: @list-group-bg;\n border: 1px solid @list-group-border;\n\n // Round the first and last items\n &:first-child {\n .border-top-radius(@list-group-border-radius);\n }\n &:last-child {\n margin-bottom: 0;\n .border-bottom-radius(@list-group-border-radius);\n }\n}\n\n\n// Interactive list items\n//\n// Use anchor or button elements instead of `li`s or `div`s to create interactive items.\n// Includes an extra `.active` modifier class for showing selected items.\n\na.list-group-item,\nbutton.list-group-item {\n color: @list-group-link-color;\n\n .list-group-item-heading {\n color: @list-group-link-heading-color;\n }\n\n // Hover state\n &:hover,\n &:focus {\n text-decoration: none;\n color: @list-group-link-hover-color;\n background-color: @list-group-hover-bg;\n }\n}\n\nbutton.list-group-item {\n width: 100%;\n text-align: left;\n}\n\n.list-group-item {\n // Disabled state\n &.disabled,\n &.disabled:hover,\n &.disabled:focus {\n background-color: @list-group-disabled-bg;\n color: @list-group-disabled-color;\n cursor: @cursor-disabled;\n\n // Force color to inherit for custom content\n .list-group-item-heading {\n color: inherit;\n }\n .list-group-item-text {\n color: @list-group-disabled-text-color;\n }\n }\n\n // Active class on item itself, not parent\n &.active,\n &.active:hover,\n &.active:focus {\n z-index: 2; // Place active items above their siblings for proper border styling\n color: @list-group-active-color;\n background-color: @list-group-active-bg;\n border-color: @list-group-active-border;\n\n // Force color to inherit for custom content\n .list-group-item-heading,\n .list-group-item-heading > small,\n .list-group-item-heading > .small {\n color: inherit;\n }\n .list-group-item-text {\n color: @list-group-active-text-color;\n }\n }\n}\n\n\n// Contextual variants\n//\n// Add modifier classes to change text and background color on individual items.\n// Organizationally, this must come after the `:hover` states.\n\n.list-group-item-variant(success; @state-success-bg; @state-success-text);\n.list-group-item-variant(info; @state-info-bg; @state-info-text);\n.list-group-item-variant(warning; @state-warning-bg; @state-warning-text);\n.list-group-item-variant(danger; @state-danger-bg; @state-danger-text);\n\n\n// Custom content options\n//\n// Extra classes for creating well-formatted content within `.list-group-item`s.\n\n.list-group-item-heading {\n margin-top: 0;\n margin-bottom: 5px;\n}\n.list-group-item-text {\n margin-bottom: 0;\n line-height: 1.3;\n}\n","// List Groups\n\n.list-group-item-variant(@state; @background; @color) {\n .list-group-item-@{state} {\n color: @color;\n background-color: @background;\n\n a&,\n button& {\n color: @color;\n\n .list-group-item-heading {\n color: inherit;\n }\n\n &:hover,\n &:focus {\n color: @color;\n background-color: darken(@background, 5%);\n }\n &.active,\n &.active:hover,\n &.active:focus {\n color: #fff;\n background-color: @color;\n border-color: @color;\n }\n }\n }\n}\n","//\n// Panels\n// --------------------------------------------------\n\n\n// Base class\n.panel {\n margin-bottom: @line-height-computed;\n background-color: @panel-bg;\n border: 1px solid transparent;\n border-radius: @panel-border-radius;\n .box-shadow(0 1px 1px rgba(0,0,0,.05));\n}\n\n// Panel contents\n.panel-body {\n padding: @panel-body-padding;\n &:extend(.clearfix all);\n}\n\n// Optional heading\n.panel-heading {\n padding: @panel-heading-padding;\n border-bottom: 1px solid transparent;\n .border-top-radius((@panel-border-radius - 1));\n\n > .dropdown .dropdown-toggle {\n color: inherit;\n }\n}\n\n// Within heading, strip any `h*` tag of its default margins for spacing.\n.panel-title {\n margin-top: 0;\n margin-bottom: 0;\n font-size: ceil((@font-size-base * 1.125));\n color: inherit;\n\n > a,\n > small,\n > .small,\n > small > a,\n > .small > a {\n color: inherit;\n }\n}\n\n// Optional footer (stays gray in every modifier class)\n.panel-footer {\n padding: @panel-footer-padding;\n background-color: @panel-footer-bg;\n border-top: 1px solid @panel-inner-border;\n .border-bottom-radius((@panel-border-radius - 1));\n}\n\n\n// List groups in panels\n//\n// By default, space out list group content from panel headings to account for\n// any kind of custom content between the two.\n\n.panel {\n > .list-group,\n > .panel-collapse > .list-group {\n margin-bottom: 0;\n\n .list-group-item {\n border-width: 1px 0;\n border-radius: 0;\n }\n\n // Add border top radius for first one\n &:first-child {\n .list-group-item:first-child {\n border-top: 0;\n .border-top-radius((@panel-border-radius - 1));\n }\n }\n\n // Add border bottom radius for last one\n &:last-child {\n .list-group-item:last-child {\n border-bottom: 0;\n .border-bottom-radius((@panel-border-radius - 1));\n }\n }\n }\n > .panel-heading + .panel-collapse > .list-group {\n .list-group-item:first-child {\n .border-top-radius(0);\n }\n }\n}\n// Collapse space between when there's no additional content.\n.panel-heading + .list-group {\n .list-group-item:first-child {\n border-top-width: 0;\n }\n}\n.list-group + .panel-footer {\n border-top-width: 0;\n}\n\n// Tables in panels\n//\n// Place a non-bordered `.table` within a panel (not within a `.panel-body`) and\n// watch it go full width.\n\n.panel {\n > .table,\n > .table-responsive > .table,\n > .panel-collapse > .table {\n margin-bottom: 0;\n\n caption {\n padding-left: @panel-body-padding;\n padding-right: @panel-body-padding;\n }\n }\n // Add border top radius for first one\n > .table:first-child,\n > .table-responsive:first-child > .table:first-child {\n .border-top-radius((@panel-border-radius - 1));\n\n > thead:first-child,\n > tbody:first-child {\n > tr:first-child {\n border-top-left-radius: (@panel-border-radius - 1);\n border-top-right-radius: (@panel-border-radius - 1);\n\n td:first-child,\n th:first-child {\n border-top-left-radius: (@panel-border-radius - 1);\n }\n td:last-child,\n th:last-child {\n border-top-right-radius: (@panel-border-radius - 1);\n }\n }\n }\n }\n // Add border bottom radius for last one\n > .table:last-child,\n > .table-responsive:last-child > .table:last-child {\n .border-bottom-radius((@panel-border-radius - 1));\n\n > tbody:last-child,\n > tfoot:last-child {\n > tr:last-child {\n border-bottom-left-radius: (@panel-border-radius - 1);\n border-bottom-right-radius: (@panel-border-radius - 1);\n\n td:first-child,\n th:first-child {\n border-bottom-left-radius: (@panel-border-radius - 1);\n }\n td:last-child,\n th:last-child {\n border-bottom-right-radius: (@panel-border-radius - 1);\n }\n }\n }\n }\n > .panel-body + .table,\n > .panel-body + .table-responsive,\n > .table + .panel-body,\n > .table-responsive + .panel-body {\n border-top: 1px solid @table-border-color;\n }\n > .table > tbody:first-child > tr:first-child th,\n > .table > tbody:first-child > tr:first-child td {\n border-top: 0;\n }\n > .table-bordered,\n > .table-responsive > .table-bordered {\n border: 0;\n > thead,\n > tbody,\n > tfoot {\n > tr {\n > th:first-child,\n > td:first-child {\n border-left: 0;\n }\n > th:last-child,\n > td:last-child {\n border-right: 0;\n }\n }\n }\n > thead,\n > tbody {\n > tr:first-child {\n > td,\n > th {\n border-bottom: 0;\n }\n }\n }\n > tbody,\n > tfoot {\n > tr:last-child {\n > td,\n > th {\n border-bottom: 0;\n }\n }\n }\n }\n > .table-responsive {\n border: 0;\n margin-bottom: 0;\n }\n}\n\n\n// Collapsible panels (aka, accordion)\n//\n// Wrap a series of panels in `.panel-group` to turn them into an accordion with\n// the help of our collapse JavaScript plugin.\n\n.panel-group {\n margin-bottom: @line-height-computed;\n\n // Tighten up margin so it's only between panels\n .panel {\n margin-bottom: 0;\n border-radius: @panel-border-radius;\n\n + .panel {\n margin-top: 5px;\n }\n }\n\n .panel-heading {\n border-bottom: 0;\n\n + .panel-collapse > .panel-body,\n + .panel-collapse > .list-group {\n border-top: 1px solid @panel-inner-border;\n }\n }\n\n .panel-footer {\n border-top: 0;\n + .panel-collapse .panel-body {\n border-bottom: 1px solid @panel-inner-border;\n }\n }\n}\n\n\n// Contextual variations\n.panel-default {\n .panel-variant(@panel-default-border; @panel-default-text; @panel-default-heading-bg; @panel-default-border);\n}\n.panel-primary {\n .panel-variant(@panel-primary-border; @panel-primary-text; @panel-primary-heading-bg; @panel-primary-border);\n}\n.panel-success {\n .panel-variant(@panel-success-border; @panel-success-text; @panel-success-heading-bg; @panel-success-border);\n}\n.panel-info {\n .panel-variant(@panel-info-border; @panel-info-text; @panel-info-heading-bg; @panel-info-border);\n}\n.panel-warning {\n .panel-variant(@panel-warning-border; @panel-warning-text; @panel-warning-heading-bg; @panel-warning-border);\n}\n.panel-danger {\n .panel-variant(@panel-danger-border; @panel-danger-text; @panel-danger-heading-bg; @panel-danger-border);\n}\n","// Panels\n\n.panel-variant(@border; @heading-text-color; @heading-bg-color; @heading-border) {\n border-color: @border;\n\n & > .panel-heading {\n color: @heading-text-color;\n background-color: @heading-bg-color;\n border-color: @heading-border;\n\n + .panel-collapse > .panel-body {\n border-top-color: @border;\n }\n .badge {\n color: @heading-bg-color;\n background-color: @heading-text-color;\n }\n }\n & > .panel-footer {\n + .panel-collapse > .panel-body {\n border-bottom-color: @border;\n }\n }\n}\n","// Embeds responsive\n//\n// Credit: Nicolas Gallagher and SUIT CSS.\n\n.embed-responsive {\n position: relative;\n display: block;\n height: 0;\n padding: 0;\n overflow: hidden;\n\n .embed-responsive-item,\n iframe,\n embed,\n object,\n video {\n position: absolute;\n top: 0;\n left: 0;\n bottom: 0;\n height: 100%;\n width: 100%;\n border: 0;\n }\n}\n\n// Modifier class for 16:9 aspect ratio\n.embed-responsive-16by9 {\n padding-bottom: 56.25%;\n}\n\n// Modifier class for 4:3 aspect ratio\n.embed-responsive-4by3 {\n padding-bottom: 75%;\n}\n","//\n// Wells\n// --------------------------------------------------\n\n\n// Base class\n.well {\n min-height: 20px;\n padding: 19px;\n margin-bottom: 20px;\n background-color: @well-bg;\n border: 1px solid @well-border;\n border-radius: @border-radius-base;\n .box-shadow(inset 0 1px 1px rgba(0,0,0,.05));\n blockquote {\n border-color: #ddd;\n border-color: rgba(0,0,0,.15);\n }\n}\n\n// Sizes\n.well-lg {\n padding: 24px;\n border-radius: @border-radius-large;\n}\n.well-sm {\n padding: 9px;\n border-radius: @border-radius-small;\n}\n","//\n// Close icons\n// --------------------------------------------------\n\n\n.close {\n float: right;\n font-size: (@font-size-base * 1.5);\n font-weight: @close-font-weight;\n line-height: 1;\n color: @close-color;\n text-shadow: @close-text-shadow;\n .opacity(.2);\n\n &:hover,\n &:focus {\n color: @close-color;\n text-decoration: none;\n cursor: pointer;\n .opacity(.5);\n }\n\n // Additional properties for button version\n // iOS requires the button element instead of an anchor tag.\n // If you want the anchor version, it requires `href=\"#\"`.\n // See https://developer.mozilla.org/en-US/docs/Web/Events/click#Safari_Mobile\n button& {\n padding: 0;\n cursor: pointer;\n background: transparent;\n border: 0;\n -webkit-appearance: none;\n }\n}\n","//\n// Modals\n// --------------------------------------------------\n\n// .modal-open - body class for killing the scroll\n// .modal - container to scroll within\n// .modal-dialog - positioning shell for the actual modal\n// .modal-content - actual modal w/ bg and corners and shit\n\n// Kill the scroll on the body\n.modal-open {\n overflow: hidden;\n}\n\n// Container that the modal scrolls within\n.modal {\n display: none;\n overflow: hidden;\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: @zindex-modal;\n -webkit-overflow-scrolling: touch;\n\n // Prevent Chrome on Windows from adding a focus outline. For details, see\n // https://github.com/twbs/bootstrap/pull/10951.\n outline: 0;\n\n // When fading in the modal, animate it to slide down\n &.fade .modal-dialog {\n .translate(0, -25%);\n .transition-transform(~\"0.3s ease-out\");\n }\n &.in .modal-dialog { .translate(0, 0) }\n}\n.modal-open .modal {\n overflow-x: hidden;\n overflow-y: auto;\n}\n\n// Shell div to position the modal with bottom padding\n.modal-dialog {\n position: relative;\n width: auto;\n margin: 10px;\n}\n\n// Actual modal\n.modal-content {\n position: relative;\n background-color: @modal-content-bg;\n border: 1px solid @modal-content-fallback-border-color; //old browsers fallback (ie8 etc)\n border: 1px solid @modal-content-border-color;\n border-radius: @border-radius-large;\n .box-shadow(0 3px 9px rgba(0,0,0,.5));\n background-clip: padding-box;\n // Remove focus outline from opened modal\n outline: 0;\n}\n\n// Modal background\n.modal-backdrop {\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: @zindex-modal-background;\n background-color: @modal-backdrop-bg;\n // Fade for backdrop\n &.fade { .opacity(0); }\n &.in { .opacity(@modal-backdrop-opacity); }\n}\n\n// Modal header\n// Top section of the modal w/ title and dismiss\n.modal-header {\n padding: @modal-title-padding;\n border-bottom: 1px solid @modal-header-border-color;\n &:extend(.clearfix all);\n}\n// Close icon\n.modal-header .close {\n margin-top: -2px;\n}\n\n// Title text within header\n.modal-title {\n margin: 0;\n line-height: @modal-title-line-height;\n}\n\n// Modal body\n// Where all modal content resides (sibling of .modal-header and .modal-footer)\n.modal-body {\n position: relative;\n padding: @modal-inner-padding;\n}\n\n// Footer (for actions)\n.modal-footer {\n padding: @modal-inner-padding;\n text-align: right; // right align buttons\n border-top: 1px solid @modal-footer-border-color;\n &:extend(.clearfix all); // clear it in case folks use .pull-* classes on buttons\n\n // Properly space out buttons\n .btn + .btn {\n margin-left: 5px;\n margin-bottom: 0; // account for input[type=\"submit\"] which gets the bottom margin like all other inputs\n }\n // but override that for button groups\n .btn-group .btn + .btn {\n margin-left: -1px;\n }\n // and override it for block buttons as well\n .btn-block + .btn-block {\n margin-left: 0;\n }\n}\n\n// Measure scrollbar width for padding body during modal show/hide\n.modal-scrollbar-measure {\n position: absolute;\n top: -9999px;\n width: 50px;\n height: 50px;\n overflow: scroll;\n}\n\n// Scale up the modal\n@media (min-width: @screen-sm-min) {\n // Automatically set modal's width for larger viewports\n .modal-dialog {\n width: @modal-md;\n margin: 30px auto;\n }\n .modal-content {\n .box-shadow(0 5px 15px rgba(0,0,0,.5));\n }\n\n // Modal sizes\n .modal-sm { width: @modal-sm; }\n}\n\n@media (min-width: @screen-md-min) {\n .modal-lg { width: @modal-lg; }\n}\n","//\n// Tooltips\n// --------------------------------------------------\n\n\n// Base class\n.tooltip {\n position: absolute;\n z-index: @zindex-tooltip;\n display: block;\n // Our parent element can be arbitrary since tooltips are by default inserted as a sibling of their target element.\n // So reset our font and text properties to avoid inheriting weird values.\n .reset-text();\n font-size: @font-size-small;\n\n .opacity(0);\n\n &.in { .opacity(@tooltip-opacity); }\n &.top { margin-top: -3px; padding: @tooltip-arrow-width 0; }\n &.right { margin-left: 3px; padding: 0 @tooltip-arrow-width; }\n &.bottom { margin-top: 3px; padding: @tooltip-arrow-width 0; }\n &.left { margin-left: -3px; padding: 0 @tooltip-arrow-width; }\n}\n\n// Wrapper for the tooltip content\n.tooltip-inner {\n max-width: @tooltip-max-width;\n padding: 3px 8px;\n color: @tooltip-color;\n text-align: center;\n background-color: @tooltip-bg;\n border-radius: @border-radius-base;\n}\n\n// Arrows\n.tooltip-arrow {\n position: absolute;\n width: 0;\n height: 0;\n border-color: transparent;\n border-style: solid;\n}\n// Note: Deprecated .top-left, .top-right, .bottom-left, and .bottom-right as of v3.3.1\n.tooltip {\n &.top .tooltip-arrow {\n bottom: 0;\n left: 50%;\n margin-left: -@tooltip-arrow-width;\n border-width: @tooltip-arrow-width @tooltip-arrow-width 0;\n border-top-color: @tooltip-arrow-color;\n }\n &.top-left .tooltip-arrow {\n bottom: 0;\n right: @tooltip-arrow-width;\n margin-bottom: -@tooltip-arrow-width;\n border-width: @tooltip-arrow-width @tooltip-arrow-width 0;\n border-top-color: @tooltip-arrow-color;\n }\n &.top-right .tooltip-arrow {\n bottom: 0;\n left: @tooltip-arrow-width;\n margin-bottom: -@tooltip-arrow-width;\n border-width: @tooltip-arrow-width @tooltip-arrow-width 0;\n border-top-color: @tooltip-arrow-color;\n }\n &.right .tooltip-arrow {\n top: 50%;\n left: 0;\n margin-top: -@tooltip-arrow-width;\n border-width: @tooltip-arrow-width @tooltip-arrow-width @tooltip-arrow-width 0;\n border-right-color: @tooltip-arrow-color;\n }\n &.left .tooltip-arrow {\n top: 50%;\n right: 0;\n margin-top: -@tooltip-arrow-width;\n border-width: @tooltip-arrow-width 0 @tooltip-arrow-width @tooltip-arrow-width;\n border-left-color: @tooltip-arrow-color;\n }\n &.bottom .tooltip-arrow {\n top: 0;\n left: 50%;\n margin-left: -@tooltip-arrow-width;\n border-width: 0 @tooltip-arrow-width @tooltip-arrow-width;\n border-bottom-color: @tooltip-arrow-color;\n }\n &.bottom-left .tooltip-arrow {\n top: 0;\n right: @tooltip-arrow-width;\n margin-top: -@tooltip-arrow-width;\n border-width: 0 @tooltip-arrow-width @tooltip-arrow-width;\n border-bottom-color: @tooltip-arrow-color;\n }\n &.bottom-right .tooltip-arrow {\n top: 0;\n left: @tooltip-arrow-width;\n margin-top: -@tooltip-arrow-width;\n border-width: 0 @tooltip-arrow-width @tooltip-arrow-width;\n border-bottom-color: @tooltip-arrow-color;\n }\n}\n",".reset-text() {\n font-family: @font-family-base;\n // We deliberately do NOT reset font-size.\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-break: auto;\n line-height: @line-height-base;\n text-align: left; // Fallback for where `start` is not supported\n text-align: start;\n text-decoration: none;\n text-shadow: none;\n text-transform: none;\n white-space: normal;\n word-break: normal;\n word-spacing: normal;\n word-wrap: normal;\n}\n","//\n// Popovers\n// --------------------------------------------------\n\n\n.popover {\n position: absolute;\n top: 0;\n left: 0;\n z-index: @zindex-popover;\n display: none;\n max-width: @popover-max-width;\n padding: 1px;\n // Our parent element can be arbitrary since popovers are by default inserted as a sibling of their target element.\n // So reset our font and text properties to avoid inheriting weird values.\n .reset-text();\n font-size: @font-size-base;\n\n background-color: @popover-bg;\n background-clip: padding-box;\n border: 1px solid @popover-fallback-border-color;\n border: 1px solid @popover-border-color;\n border-radius: @border-radius-large;\n .box-shadow(0 5px 10px rgba(0,0,0,.2));\n\n // Offset the popover to account for the popover arrow\n &.top { margin-top: -@popover-arrow-width; }\n &.right { margin-left: @popover-arrow-width; }\n &.bottom { margin-top: @popover-arrow-width; }\n &.left { margin-left: -@popover-arrow-width; }\n}\n\n.popover-title {\n margin: 0; // reset heading margin\n padding: 8px 14px;\n font-size: @font-size-base;\n background-color: @popover-title-bg;\n border-bottom: 1px solid darken(@popover-title-bg, 5%);\n border-radius: (@border-radius-large - 1) (@border-radius-large - 1) 0 0;\n}\n\n.popover-content {\n padding: 9px 14px;\n}\n\n// Arrows\n//\n// .arrow is outer, .arrow:after is inner\n\n.popover > .arrow {\n &,\n &:after {\n position: absolute;\n display: block;\n width: 0;\n height: 0;\n border-color: transparent;\n border-style: solid;\n }\n}\n.popover > .arrow {\n border-width: @popover-arrow-outer-width;\n}\n.popover > .arrow:after {\n border-width: @popover-arrow-width;\n content: \"\";\n}\n\n.popover {\n &.top > .arrow {\n left: 50%;\n margin-left: -@popover-arrow-outer-width;\n border-bottom-width: 0;\n border-top-color: @popover-arrow-outer-fallback-color; // IE8 fallback\n border-top-color: @popover-arrow-outer-color;\n bottom: -@popover-arrow-outer-width;\n &:after {\n content: \" \";\n bottom: 1px;\n margin-left: -@popover-arrow-width;\n border-bottom-width: 0;\n border-top-color: @popover-arrow-color;\n }\n }\n &.right > .arrow {\n top: 50%;\n left: -@popover-arrow-outer-width;\n margin-top: -@popover-arrow-outer-width;\n border-left-width: 0;\n border-right-color: @popover-arrow-outer-fallback-color; // IE8 fallback\n border-right-color: @popover-arrow-outer-color;\n &:after {\n content: \" \";\n left: 1px;\n bottom: -@popover-arrow-width;\n border-left-width: 0;\n border-right-color: @popover-arrow-color;\n }\n }\n &.bottom > .arrow {\n left: 50%;\n margin-left: -@popover-arrow-outer-width;\n border-top-width: 0;\n border-bottom-color: @popover-arrow-outer-fallback-color; // IE8 fallback\n border-bottom-color: @popover-arrow-outer-color;\n top: -@popover-arrow-outer-width;\n &:after {\n content: \" \";\n top: 1px;\n margin-left: -@popover-arrow-width;\n border-top-width: 0;\n border-bottom-color: @popover-arrow-color;\n }\n }\n\n &.left > .arrow {\n top: 50%;\n right: -@popover-arrow-outer-width;\n margin-top: -@popover-arrow-outer-width;\n border-right-width: 0;\n border-left-color: @popover-arrow-outer-fallback-color; // IE8 fallback\n border-left-color: @popover-arrow-outer-color;\n &:after {\n content: \" \";\n right: 1px;\n border-right-width: 0;\n border-left-color: @popover-arrow-color;\n bottom: -@popover-arrow-width;\n }\n }\n}\n","//\n// Carousel\n// --------------------------------------------------\n\n\n// Wrapper for the slide container and indicators\n.carousel {\n position: relative;\n}\n\n.carousel-inner {\n position: relative;\n overflow: hidden;\n width: 100%;\n\n > .item {\n display: none;\n position: relative;\n .transition(.6s ease-in-out left);\n\n // Account for jankitude on images\n > img,\n > a > img {\n &:extend(.img-responsive);\n line-height: 1;\n }\n\n // WebKit CSS3 transforms for supported devices\n @media all and (transform-3d), (-webkit-transform-3d) {\n .transition-transform(~'0.6s ease-in-out');\n .backface-visibility(~'hidden');\n .perspective(1000px);\n\n &.next,\n &.active.right {\n .translate3d(100%, 0, 0);\n left: 0;\n }\n &.prev,\n &.active.left {\n .translate3d(-100%, 0, 0);\n left: 0;\n }\n &.next.left,\n &.prev.right,\n &.active {\n .translate3d(0, 0, 0);\n left: 0;\n }\n }\n }\n\n > .active,\n > .next,\n > .prev {\n display: block;\n }\n\n > .active {\n left: 0;\n }\n\n > .next,\n > .prev {\n position: absolute;\n top: 0;\n width: 100%;\n }\n\n > .next {\n left: 100%;\n }\n > .prev {\n left: -100%;\n }\n > .next.left,\n > .prev.right {\n left: 0;\n }\n\n > .active.left {\n left: -100%;\n }\n > .active.right {\n left: 100%;\n }\n\n}\n\n// Left/right controls for nav\n// ---------------------------\n\n.carousel-control {\n position: absolute;\n top: 0;\n left: 0;\n bottom: 0;\n width: @carousel-control-width;\n .opacity(@carousel-control-opacity);\n font-size: @carousel-control-font-size;\n color: @carousel-control-color;\n text-align: center;\n text-shadow: @carousel-text-shadow;\n background-color: rgba(0, 0, 0, 0); // Fix IE9 click-thru bug\n // We can't have this transition here because WebKit cancels the carousel\n // animation if you trip this while in the middle of another animation.\n\n // Set gradients for backgrounds\n &.left {\n #gradient > .horizontal(@start-color: rgba(0,0,0,.5); @end-color: rgba(0,0,0,.0001));\n }\n &.right {\n left: auto;\n right: 0;\n #gradient > .horizontal(@start-color: rgba(0,0,0,.0001); @end-color: rgba(0,0,0,.5));\n }\n\n // Hover/focus state\n &:hover,\n &:focus {\n outline: 0;\n color: @carousel-control-color;\n text-decoration: none;\n .opacity(.9);\n }\n\n // Toggles\n .icon-prev,\n .icon-next,\n .glyphicon-chevron-left,\n .glyphicon-chevron-right {\n position: absolute;\n top: 50%;\n margin-top: -10px;\n z-index: 5;\n display: inline-block;\n }\n .icon-prev,\n .glyphicon-chevron-left {\n left: 50%;\n margin-left: -10px;\n }\n .icon-next,\n .glyphicon-chevron-right {\n right: 50%;\n margin-right: -10px;\n }\n .icon-prev,\n .icon-next {\n width: 20px;\n height: 20px;\n line-height: 1;\n font-family: serif;\n }\n\n\n .icon-prev {\n &:before {\n content: '\\2039';// SINGLE LEFT-POINTING ANGLE QUOTATION MARK (U+2039)\n }\n }\n .icon-next {\n &:before {\n content: '\\203a';// SINGLE RIGHT-POINTING ANGLE QUOTATION MARK (U+203A)\n }\n }\n}\n\n// Optional indicator pips\n//\n// Add an unordered list with the following class and add a list item for each\n// slide your carousel holds.\n\n.carousel-indicators {\n position: absolute;\n bottom: 10px;\n left: 50%;\n z-index: 15;\n width: 60%;\n margin-left: -30%;\n padding-left: 0;\n list-style: none;\n text-align: center;\n\n li {\n display: inline-block;\n width: 10px;\n height: 10px;\n margin: 1px;\n text-indent: -999px;\n border: 1px solid @carousel-indicator-border-color;\n border-radius: 10px;\n cursor: pointer;\n\n // IE8-9 hack for event handling\n //\n // Internet Explorer 8-9 does not support clicks on elements without a set\n // `background-color`. We cannot use `filter` since that's not viewed as a\n // background color by the browser. Thus, a hack is needed.\n // See https://developer.mozilla.org/en-US/docs/Web/Events/click#Internet_Explorer\n //\n // For IE8, we set solid black as it doesn't support `rgba()`. For IE9, we\n // set alpha transparency for the best results possible.\n background-color: #000 \\9; // IE8\n background-color: rgba(0,0,0,0); // IE9\n }\n .active {\n margin: 0;\n width: 12px;\n height: 12px;\n background-color: @carousel-indicator-active-bg;\n }\n}\n\n// Optional captions\n// -----------------------------\n// Hidden by default for smaller viewports\n.carousel-caption {\n position: absolute;\n left: 15%;\n right: 15%;\n bottom: 20px;\n z-index: 10;\n padding-top: 20px;\n padding-bottom: 20px;\n color: @carousel-caption-color;\n text-align: center;\n text-shadow: @carousel-text-shadow;\n & .btn {\n text-shadow: none; // No shadow for button elements in carousel-caption\n }\n}\n\n\n// Scale up controls for tablets and up\n@media screen and (min-width: @screen-sm-min) {\n\n // Scale up the controls a smidge\n .carousel-control {\n .glyphicon-chevron-left,\n .glyphicon-chevron-right,\n .icon-prev,\n .icon-next {\n width: (@carousel-control-font-size * 1.5);\n height: (@carousel-control-font-size * 1.5);\n margin-top: (@carousel-control-font-size / -2);\n font-size: (@carousel-control-font-size * 1.5);\n }\n .glyphicon-chevron-left,\n .icon-prev {\n margin-left: (@carousel-control-font-size / -2);\n }\n .glyphicon-chevron-right,\n .icon-next {\n margin-right: (@carousel-control-font-size / -2);\n }\n }\n\n // Show and left align the captions\n .carousel-caption {\n left: 20%;\n right: 20%;\n padding-bottom: 30px;\n }\n\n // Move up the indicators\n .carousel-indicators {\n bottom: 20px;\n }\n}\n","// Clearfix\n//\n// For modern browsers\n// 1. The space content is one way to avoid an Opera bug when the\n// contenteditable attribute is included anywhere else in the document.\n// Otherwise it causes space to appear at the top and bottom of elements\n// that are clearfixed.\n// 2. The use of `table` rather than `block` is only necessary if using\n// `:before` to contain the top-margins of child elements.\n//\n// Source: http://nicolasgallagher.com/micro-clearfix-hack/\n\n.clearfix() {\n &:before,\n &:after {\n content: \" \"; // 1\n display: table; // 2\n }\n &:after {\n clear: both;\n }\n}\n","// Center-align a block level element\n\n.center-block() {\n display: block;\n margin-left: auto;\n margin-right: auto;\n}\n","// CSS image replacement\n//\n// Heads up! v3 launched with only `.hide-text()`, but per our pattern for\n// mixins being reused as classes with the same name, this doesn't hold up. As\n// of v3.0.1 we have added `.text-hide()` and deprecated `.hide-text()`.\n//\n// Source: https://github.com/h5bp/html5-boilerplate/commit/aa0396eae757\n\n// Deprecated as of v3.0.1 (has been removed in v4)\n.hide-text() {\n font: ~\"0/0\" a;\n color: transparent;\n text-shadow: none;\n background-color: transparent;\n border: 0;\n}\n\n// New mixin to use as of v3.0.1\n.text-hide() {\n .hide-text();\n}\n","//\n// Responsive: Utility classes\n// --------------------------------------------------\n\n\n// IE10 in Windows (Phone) 8\n//\n// Support for responsive views via media queries is kind of borked in IE10, for\n// Surface/desktop in split view and for Windows Phone 8. This particular fix\n// must be accompanied by a snippet of JavaScript to sniff the user agent and\n// apply some conditional CSS to *only* the Surface/desktop Windows 8. Look at\n// our Getting Started page for more information on this bug.\n//\n// For more information, see the following:\n//\n// Issue: https://github.com/twbs/bootstrap/issues/10497\n// Docs: http://getbootstrap.com/getting-started/#support-ie10-width\n// Source: http://timkadlec.com/2013/01/windows-phone-8-and-device-width/\n// Source: http://timkadlec.com/2012/10/ie10-snap-mode-and-responsive-design/\n\n@-ms-viewport {\n width: device-width;\n}\n\n\n// Visibility utilities\n// Note: Deprecated .visible-xs, .visible-sm, .visible-md, and .visible-lg as of v3.2.0\n.visible-xs,\n.visible-sm,\n.visible-md,\n.visible-lg {\n .responsive-invisibility();\n}\n\n.visible-xs-block,\n.visible-xs-inline,\n.visible-xs-inline-block,\n.visible-sm-block,\n.visible-sm-inline,\n.visible-sm-inline-block,\n.visible-md-block,\n.visible-md-inline,\n.visible-md-inline-block,\n.visible-lg-block,\n.visible-lg-inline,\n.visible-lg-inline-block {\n display: none !important;\n}\n\n.visible-xs {\n @media (max-width: @screen-xs-max) {\n .responsive-visibility();\n }\n}\n.visible-xs-block {\n @media (max-width: @screen-xs-max) {\n display: block !important;\n }\n}\n.visible-xs-inline {\n @media (max-width: @screen-xs-max) {\n display: inline !important;\n }\n}\n.visible-xs-inline-block {\n @media (max-width: @screen-xs-max) {\n display: inline-block !important;\n }\n}\n\n.visible-sm {\n @media (min-width: @screen-sm-min) and (max-width: @screen-sm-max) {\n .responsive-visibility();\n }\n}\n.visible-sm-block {\n @media (min-width: @screen-sm-min) and (max-width: @screen-sm-max) {\n display: block !important;\n }\n}\n.visible-sm-inline {\n @media (min-width: @screen-sm-min) and (max-width: @screen-sm-max) {\n display: inline !important;\n }\n}\n.visible-sm-inline-block {\n @media (min-width: @screen-sm-min) and (max-width: @screen-sm-max) {\n display: inline-block !important;\n }\n}\n\n.visible-md {\n @media (min-width: @screen-md-min) and (max-width: @screen-md-max) {\n .responsive-visibility();\n }\n}\n.visible-md-block {\n @media (min-width: @screen-md-min) and (max-width: @screen-md-max) {\n display: block !important;\n }\n}\n.visible-md-inline {\n @media (min-width: @screen-md-min) and (max-width: @screen-md-max) {\n display: inline !important;\n }\n}\n.visible-md-inline-block {\n @media (min-width: @screen-md-min) and (max-width: @screen-md-max) {\n display: inline-block !important;\n }\n}\n\n.visible-lg {\n @media (min-width: @screen-lg-min) {\n .responsive-visibility();\n }\n}\n.visible-lg-block {\n @media (min-width: @screen-lg-min) {\n display: block !important;\n }\n}\n.visible-lg-inline {\n @media (min-width: @screen-lg-min) {\n display: inline !important;\n }\n}\n.visible-lg-inline-block {\n @media (min-width: @screen-lg-min) {\n display: inline-block !important;\n }\n}\n\n.hidden-xs {\n @media (max-width: @screen-xs-max) {\n .responsive-invisibility();\n }\n}\n.hidden-sm {\n @media (min-width: @screen-sm-min) and (max-width: @screen-sm-max) {\n .responsive-invisibility();\n }\n}\n.hidden-md {\n @media (min-width: @screen-md-min) and (max-width: @screen-md-max) {\n .responsive-invisibility();\n }\n}\n.hidden-lg {\n @media (min-width: @screen-lg-min) {\n .responsive-invisibility();\n }\n}\n\n\n// Print utilities\n//\n// Media queries are placed on the inside to be mixin-friendly.\n\n// Note: Deprecated .visible-print as of v3.2.0\n.visible-print {\n .responsive-invisibility();\n\n @media print {\n .responsive-visibility();\n }\n}\n.visible-print-block {\n display: none !important;\n\n @media print {\n display: block !important;\n }\n}\n.visible-print-inline {\n display: none !important;\n\n @media print {\n display: inline !important;\n }\n}\n.visible-print-inline-block {\n display: none !important;\n\n @media print {\n display: inline-block !important;\n }\n}\n\n.hidden-print {\n @media print {\n .responsive-invisibility();\n }\n}\n","// Responsive utilities\n\n//\n// More easily include all the states for responsive-utilities.less.\n.responsive-visibility() {\n display: block !important;\n table& { display: table !important; }\n tr& { display: table-row !important; }\n th&,\n td& { display: table-cell !important; }\n}\n\n.responsive-invisibility() {\n display: none !important;\n}\n"]}
\ No newline at end of file
diff --git a/config/defaults.js b/config/defaults.js
index 79b39c9e3..ccfde26ec 100644
--- a/config/defaults.js
+++ b/config/defaults.js
@@ -1,7 +1,15 @@
'use strict'
module.exports = {
- 'AUTH_METHOD': 'tls',
- 'DEFAULT_PORT': 8443,
- 'DEFAULT_URI': 'https://localhost:8443' // default serverUri
+ 'auth': 'oidc',
+ 'localAuth': {
+ 'tls': true,
+ 'password': true
+ },
+ 'configPath': './config',
+ 'dbPath': './.db',
+ 'port': 8443,
+ 'serverUri': 'https://localhost:8443',
+ 'webid': true,
+ 'dataBrowserPath': 'default'
}
diff --git a/test/resources/acl/empty-acl/.acl b/data/.gitkeep
similarity index 100%
rename from test/resources/acl/empty-acl/.acl
rename to data/.gitkeep
diff --git a/default-templates/emails/reset-password.js b/default-templates/emails/reset-password.js
new file mode 100644
index 000000000..fb18972cc
--- /dev/null
+++ b/default-templates/emails/reset-password.js
@@ -0,0 +1,49 @@
+'use strict'
+
+/**
+ * Returns a partial Email object (minus the `to` and `from` properties),
+ * suitable for sending with Nodemailer.
+ *
+ * Used to send a Reset Password email, upon user request
+ *
+ * @param data {Object}
+ *
+ * @param data.resetUrl {string}
+ * @param data.webId {string}
+ *
+ * @return {Object}
+ */
+function render (data) {
+ return {
+ subject: 'Account password reset',
+
+ /**
+ * Text version
+ */
+ text: `Hi,
+
+We received a request to reset your password for your Solid account, ${data.webId}
+
+To reset your password, click on the following link:
+
+${data.resetUrl}
+
+If you did not mean to reset your password, ignore this email, your password will not change.`,
+
+ /**
+ * HTML version
+ */
+ html: `
Hi,
+
+
We received a request to reset your password for your Solid account, ${data.webId}
+
+
To reset your password, click on the following link:
+
+
${data.resetUrl}
+
+
If you did not mean to reset your password, ignore this email, your password will not change.
+`
+ }
+}
+
+module.exports.render = render
diff --git a/default-email-templates/welcome.js b/default-templates/emails/welcome.js
similarity index 95%
rename from default-email-templates/welcome.js
rename to default-templates/emails/welcome.js
index 21ca3ba61..bce554462 100644
--- a/default-email-templates/welcome.js
+++ b/default-templates/emails/welcome.js
@@ -14,7 +14,7 @@
*/
function render (data) {
return {
- subject: `Welcome to Solid`,
+ subject: 'Welcome to Solid',
/**
* Text version of the Welcome email
diff --git a/default-account-template/.acl b/default-templates/new-account/.acl
similarity index 100%
rename from default-account-template/.acl
rename to default-templates/new-account/.acl
diff --git a/default-account-template/.meta b/default-templates/new-account/.meta
similarity index 100%
rename from default-account-template/.meta
rename to default-templates/new-account/.meta
diff --git a/default-account-template/.meta.acl b/default-templates/new-account/.meta.acl
similarity index 100%
rename from default-account-template/.meta.acl
rename to default-templates/new-account/.meta.acl
diff --git a/default-account-template/favicon.ico b/default-templates/new-account/favicon.ico
similarity index 100%
rename from default-account-template/favicon.ico
rename to default-templates/new-account/favicon.ico
diff --git a/default-account-template/favicon.ico.acl b/default-templates/new-account/favicon.ico.acl
similarity index 100%
rename from default-account-template/favicon.ico.acl
rename to default-templates/new-account/favicon.ico.acl
diff --git a/default-account-template/inbox/.acl b/default-templates/new-account/inbox/.acl
similarity index 100%
rename from default-account-template/inbox/.acl
rename to default-templates/new-account/inbox/.acl
diff --git a/default-templates/new-account/index.html b/default-templates/new-account/index.html
new file mode 100644
index 000000000..68e6e858d
--- /dev/null
+++ b/default-templates/new-account/index.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+
Solid User Profile
+
+
+
+
+
Solid User Profile
+
+
+
+
+
+ Welcome to the account of {{webId}}
+
+
+
+
+
+
diff --git a/default-templates/new-account/index.html.acl b/default-templates/new-account/index.html.acl
new file mode 100644
index 000000000..47c7640a2
--- /dev/null
+++ b/default-templates/new-account/index.html.acl
@@ -0,0 +1,22 @@
+@prefix acl:
.
+@prefix foaf: .
+
+<#owner>
+ a acl:Authorization;
+
+ acl:agent
+ <{{webId}}>;
+
+ acl:accessTo ;
+
+ acl:mode
+ acl:Read, acl:Write, acl:Control.
+
+<#public>
+ a acl:Authorization;
+
+ acl:agentClass foaf:Agent; # everyone
+
+ acl:accessTo <./index.html>;
+
+ acl:mode acl:Read.
diff --git a/default-templates/new-account/profile/card b/default-templates/new-account/profile/card
new file mode 100644
index 000000000..063bc61cf
--- /dev/null
+++ b/default-templates/new-account/profile/card
@@ -0,0 +1,25 @@
+@prefix solid: .
+@prefix foaf: .
+@prefix pim: .
+@prefix schema: .
+@prefix ldp: .
+
+<>
+ a foaf:PersonalProfileDocument ;
+ foaf:maker <{{webId}}> ;
+ foaf:primaryTopic <{{webId}}> .
+
+<{{webId}}>
+ a foaf:Person ;
+ a schema:Person ;
+
+ foaf:name "{{name}}" ;
+
+ solid:account > ; # link to the account uri
+ pim:storage > ; # root storage
+
+ ldp:inbox ;
+
+ pim:preferencesFile ; # private settings/preferences
+ solid:publicTypeIndex ;
+ solid:privateTypeIndex .
diff --git a/default-account-template/profile/card.acl b/default-templates/new-account/profile/card.acl
similarity index 100%
rename from default-account-template/profile/card.acl
rename to default-templates/new-account/profile/card.acl
diff --git a/default-templates/new-account/public/.acl b/default-templates/new-account/public/.acl
new file mode 100644
index 000000000..c289aedfe
--- /dev/null
+++ b/default-templates/new-account/public/.acl
@@ -0,0 +1,19 @@
+# ACL resource for the public folder
+@prefix acl: .
+@prefix foaf: .
+
+# The owner has all permissions
+<#owner>
+ a acl:Authorization;
+ acl:agent <{{webId}}>;
+ acl:accessTo <./>;
+ acl:defaultForNew <./>;
+ acl:mode acl:Read, acl:Write, acl:Control.
+
+# The public has read permissions
+<#public>
+ a acl:Authorization;
+ acl:agentClass foaf:Agent;
+ acl:accessTo <./>;
+ acl:defaultForNew <./>;
+ acl:mode acl:Read.
diff --git a/default-account-template/settings/.acl b/default-templates/new-account/settings/.acl
similarity index 100%
rename from default-account-template/settings/.acl
rename to default-templates/new-account/settings/.acl
diff --git a/default-templates/new-account/settings/prefs.ttl b/default-templates/new-account/settings/prefs.ttl
new file mode 100644
index 000000000..b13d3aee6
--- /dev/null
+++ b/default-templates/new-account/settings/prefs.ttl
@@ -0,0 +1,10 @@
+@prefix dct: .
+@prefix pim: .
+@prefix foaf: .
+
+<>
+ a pim:ConfigurationFile;
+
+ dct:title "Preferences file" .
+
+{{#if email}}<{{webId}}> foaf:mbox .{{/if}}
diff --git a/default-account-template/settings/privateTypeIndex.ttl b/default-templates/new-account/settings/privateTypeIndex.ttl
similarity index 100%
rename from default-account-template/settings/privateTypeIndex.ttl
rename to default-templates/new-account/settings/privateTypeIndex.ttl
diff --git a/default-account-template/settings/publicTypeIndex.ttl b/default-templates/new-account/settings/publicTypeIndex.ttl
similarity index 100%
rename from default-account-template/settings/publicTypeIndex.ttl
rename to default-templates/new-account/settings/publicTypeIndex.ttl
diff --git a/default-account-template/settings/publicTypeIndex.ttl.acl b/default-templates/new-account/settings/publicTypeIndex.ttl.acl
similarity index 100%
rename from default-account-template/settings/publicTypeIndex.ttl.acl
rename to default-templates/new-account/settings/publicTypeIndex.ttl.acl
diff --git a/default-templates/server/index.html b/default-templates/server/index.html
new file mode 100644
index 000000000..6101fdcb7
--- /dev/null
+++ b/default-templates/server/index.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+ Welcome to Solid
+
+
+
+
+
Welcome to Solid
+
+
+
+
+
+ If you have not already done so, please create an account.
+
+
+
+
+
+
+
diff --git a/default-templates/server/index.html.acl b/default-templates/server/index.html.acl
new file mode 100644
index 000000000..de9032975
--- /dev/null
+++ b/default-templates/server/index.html.acl
@@ -0,0 +1,11 @@
+@prefix acl: .
+@prefix foaf: .
+
+<#public>
+ a acl:Authorization;
+
+ acl:agentClass foaf:Agent; # everyone
+
+ acl:accessTo <./index.html>;
+
+ acl:mode acl:Read.
diff --git a/default-views/account/register-disabled.hbs b/default-views/account/register-disabled.hbs
new file mode 100644
index 000000000..4a84e3660
--- /dev/null
+++ b/default-views/account/register-disabled.hbs
@@ -0,0 +1,4 @@
+
+ Registering a new account is disabled for the WebID-TLS authentication method.
+ Please restart the server using another mode.
+
diff --git a/default-views/account/register-form.hbs b/default-views/account/register-form.hbs
new file mode 100644
index 000000000..524bcf0cd
--- /dev/null
+++ b/default-views/account/register-form.hbs
@@ -0,0 +1,69 @@
+
diff --git a/default-views/account/register.hbs b/default-views/account/register.hbs
new file mode 100644
index 000000000..12ab66a9f
--- /dev/null
+++ b/default-views/account/register.hbs
@@ -0,0 +1,21 @@
+
+
+
+
+
+ Register
+
+
+
+
+
Register
+
+
+ {{#if registerDisabled}}
+ {{> account/register-disabled}}
+ {{else}}
+ {{> account/register-form}}
+ {{/if}}
+
+
+
diff --git a/default-views/auth/auth-hidden-fields.hbs b/default-views/auth/auth-hidden-fields.hbs
new file mode 100644
index 000000000..35d9fd316
--- /dev/null
+++ b/default-views/auth/auth-hidden-fields.hbs
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/default-views/auth/change-password.hbs b/default-views/auth/change-password.hbs
new file mode 100644
index 000000000..88a0d8292
--- /dev/null
+++ b/default-views/auth/change-password.hbs
@@ -0,0 +1,65 @@
+
+
+
+
+
+ Change Password
+
+
+
+
+
Change Password
+
+
+
+
diff --git a/default-views/auth/consent.hbs b/default-views/auth/consent.hbs
new file mode 100644
index 000000000..615aa74b0
--- /dev/null
+++ b/default-views/auth/consent.hbs
@@ -0,0 +1,33 @@
+
+
+
+
+
+ {{title}}
+
+
+
+
+
+
+
Authorize app to use your Web ID?
+
+
+
+
+
+
diff --git a/default-views/auth/goodbye.hbs b/default-views/auth/goodbye.hbs
new file mode 100644
index 000000000..305cccac0
--- /dev/null
+++ b/default-views/auth/goodbye.hbs
@@ -0,0 +1,20 @@
+
+
+
+
+
+ Logged Out
+
+
+
+
+
You have logged out.
+
+
+
+
+
+
diff --git a/default-views/auth/login-tls.hbs b/default-views/auth/login-tls.hbs
new file mode 100644
index 000000000..6a98e3c6c
--- /dev/null
+++ b/default-views/auth/login-tls.hbs
@@ -0,0 +1,10 @@
+
diff --git a/default-views/auth/login-username-password.hbs b/default-views/auth/login-username-password.hbs
new file mode 100644
index 000000000..19ba04a29
--- /dev/null
+++ b/default-views/auth/login-username-password.hbs
@@ -0,0 +1,23 @@
+
diff --git a/default-views/auth/login.hbs b/default-views/auth/login.hbs
new file mode 100644
index 000000000..e4bf37a65
--- /dev/null
+++ b/default-views/auth/login.hbs
@@ -0,0 +1,58 @@
+
+
+
+
+
+ Login
+
+
+
+
+
Login
+
+
+ {{#if error}}
+
+ {{/if}}
+
+
+ {{#if enablePassword}}
+ {{> auth/login-username-password}}
+ {{/if}}
+
+
+
+ {{#if enableTls}}
+ {{> auth/login-tls}}
+ {{/if}}
+
+
+
+
+
+
+
diff --git a/default-views/auth/password-changed.hbs b/default-views/auth/password-changed.hbs
new file mode 100644
index 000000000..4522cf9ed
--- /dev/null
+++ b/default-views/auth/password-changed.hbs
@@ -0,0 +1,23 @@
+
+
+
+
+
+ Password Changed
+
+
+
+
+
Password Changed
+
+
+
Your password has been changed.
+
+
+
+ Log in
+
+
+
+
+
diff --git a/default-views/auth/reset-link-sent.hbs b/default-views/auth/reset-link-sent.hbs
new file mode 100644
index 000000000..059727515
--- /dev/null
+++ b/default-views/auth/reset-link-sent.hbs
@@ -0,0 +1,17 @@
+
+
+
+
+
+ Reset Link Sent
+
+
+
+
+
Reset Link Sent
+
+
+ A Reset Password link has been sent to your email.
+
+
+
diff --git a/default-views/auth/reset-password.hbs b/default-views/auth/reset-password.hbs
new file mode 100644
index 000000000..97add6cf7
--- /dev/null
+++ b/default-views/auth/reset-password.hbs
@@ -0,0 +1,59 @@
+
+
+
+
+
+ Reset Password
+
+
+
+
+
Reset Password
+
+
+
+
diff --git a/default-views/auth/select-provider.hbs b/default-views/auth/select-provider.hbs
new file mode 100644
index 000000000..a158b3f14
--- /dev/null
+++ b/default-views/auth/select-provider.hbs
@@ -0,0 +1,34 @@
+
+
+
+
+
+ Select Provider
+
+
+
+
+
+
Select Provider
+
+ {{#if error}}
+
+ {{/if}}
+
+
+
+
diff --git a/lib/account-recovery.js b/lib/account-recovery.js
deleted file mode 100644
index 2cad1d98e..000000000
--- a/lib/account-recovery.js
+++ /dev/null
@@ -1,115 +0,0 @@
-module.exports = AccountRecovery
-
-const express = require('express')
-const TokenService = require('./token-service')
-const bodyParser = require('body-parser')
-const path = require('path')
-const debug = require('debug')('solid:account-recovery')
-const utils = require('./utils')
-const sym = require('rdflib').sym
-const url = require('url')
-
-function AccountRecovery (options = {}) {
- const router = express.Router('/')
- const tokenService = new TokenService()
- const generateEmail = function (host, account, email, token) {
- return {
- from: '"Account Recovery" ',
- to: email,
- subject: 'Recover your account',
- text: 'Hello,\n' +
- 'You asked to retrieve your account: ' + account + '\n' +
- 'Copy this address in your browser addressbar:\n\n' +
- 'https://' + path.join(host, '/api/accounts/validateToken?token=' + token) // TODO find a way to get the full url
- // html: ''
- }
- }
-
- router.get('/recover', function (req, res, next) {
- res.set('Content-Type', 'text/html')
- res.sendFile(path.join(__dirname, '../static/account-recovery.html'))
- })
-
- router.post('/recover', bodyParser.urlencoded({ extended: false }), function (req, res, next) {
- debug('getting request for account recovery', req.body.webid)
- const ldp = req.app.locals.ldp
- const emailService = req.app.locals.emailService
- const baseUri = utils.getBaseUri(req)
-
- // if (!req.body.webid) {
- // res.status(406).send('You need to pass an account')
- // return
- // }
-
- // Check if account exists
- let webid = url.parse(req.body.webid)
- let hostname = webid.hostname
-
- ldp.graph(hostname, '/' + ldp.suffixAcl, baseUri, function (err, graph) {
- if (err) {
- debug('cannot find graph of the user', req.body.webid || ldp.root, err)
- res.status(err.status || 500).send('Fail to find user')
- return
- }
-
- // TODO do a query
- let emailAddress
- graph
- .statementsMatching(undefined, sym('http://www.w3.org/ns/auth/acl#agent'))
- .some(function (statement) {
- if (statement.object.uri.startsWith('mailto:')) {
- emailAddress = statement.object.uri
- return true
- }
- })
-
- if (!emailAddress) {
- res.status(406).send('No emailAddress registered in your account')
- return
- }
-
- const token = tokenService.generate({ webid: req.body.webid })
- const email = generateEmail(req.get('host'), req.body.webid, emailAddress, token)
- emailService.sendMail(email, function (err, info) {
- if (err) {
- res.send(500, 'Failed to send the email for account recovery, try again')
- return
- }
-
- res.send('Requested')
- })
- })
- })
-
- router.get('/validateToken', function (req, res, next) {
- if (!req.query.token) {
- res.status(406).send('Token is required')
- return
- }
-
- const tokenContent = tokenService.verify(req.query.token)
-
- if (!tokenContent) {
- debug('token was not found', tokenContent)
- res.status(401).send('Token not valid')
- return
- }
-
- if (tokenContent && !tokenContent.webid) {
- debug('token does not match account', tokenContent)
- res.status(401).send('Token not valid')
- return
- }
-
- debug('token was valid', tokenContent)
-
- tokenService.remove(req.query.token)
-
- req.session.userId = tokenContent.webid // TODO add the full path
- req.session.identified = true
- res.set('User', tokenContent.webid)
- res.redirect(options.redirect)
- })
-
- return router
-}
diff --git a/lib/acl-checker.js b/lib/acl-checker.js
index 8cda9d396..09fd96d97 100644
--- a/lib/acl-checker.js
+++ b/lib/acl-checker.js
@@ -1,172 +1,133 @@
'use strict'
-const async = require('async')
-const path = require('path')
const PermissionSet = require('solid-permissions').PermissionSet
const rdf = require('rdflib')
-const url = require('url')
+const debug = require('./debug').ACL
+const HTTPError = require('./http-error')
const DEFAULT_ACL_SUFFIX = '.acl'
+// An ACLChecker exposes the permissions on a specific resource
class ACLChecker {
- constructor (options = {}) {
- this.debug = options.debug || console.log.bind(console)
+ constructor (resource, options = {}) {
+ this.resource = resource
+ this.host = options.host
+ this.origin = options.origin
this.fetch = options.fetch
this.strictOrigin = options.strictOrigin
this.suffix = options.suffix || DEFAULT_ACL_SUFFIX
}
- can (user, mode, resource, callback, options = {}) {
- const debug = this.debug
- debug('Can ' + (user || 'an agent') + ' ' + mode + ' ' + resource + '?')
- var accessType = 'accessTo'
- var possibleACLs = ACLChecker.possibleACLs(resource, this.suffix)
+ // Returns a fulfilled promise when the user can access the resource
+ // in the given mode, or rejects with an HTTP error otherwise
+ can (user, mode) {
// If this is an ACL, Control mode must be present for any operations
- if (this.isAcl(resource)) {
+ if (this.isAcl(this.resource)) {
mode = 'Control'
}
- var self = this
- async.eachSeries(
- possibleACLs,
-
- // Looks for ACL, if found, looks for a rule
- function tryAcl (acl, next) {
- debug('Check if acl exist: ' + acl)
- // Let's see if there is a file..
- self.fetch(acl, function (err, graph) {
- if (err || !graph || graph.length === 0) {
- if (err) debug('Error: ' + err)
- accessType = 'defaultForNew'
- return next()
- }
- self.checkAccess(
- graph, // The ACL graph
- user, // The webId of the user
- mode, // Read/Write/Append
- resource, // The resource we want to access
- accessType, // accessTo or defaultForNew
- acl, // The current Acl file!
- (err) => { return next(!err || err) },
- options
- )
- })
- },
- function handleNoAccess (err) {
- if (err === false || err === null) {
- debug('No ACL resource found - access not allowed')
- err = new Error('No Access Control Policy found')
- }
- if (err === true) {
- debug('ACL policy found')
- err = null
+
+ // Obtain the permission set for the resource
+ if (!this._permissionSet) {
+ this._permissionSet = this.getNearestACL()
+ .then(acl => this.getPermissionSet(acl))
+ }
+
+ // Check the resource's permissions
+ return this._permissionSet
+ .then(acls => this.checkAccess(acls, user, mode))
+ .catch(() => {
+ if (!user) {
+ throw new HTTPError(401, `Access to ${this.resource} requires authorization`)
+ } else {
+ throw new HTTPError(403, `Access to ${this.resource} denied for ${user}`)
}
- if (err) {
- debug('Error: ' + err.message)
- if (!user || user.length === 0) {
- debug('Authentication required')
- err.status = 401
- err.message = 'Access to ' + resource + ' requires authorization'
+ })
+ }
+
+ // Gets the ACL that applies to the resource
+ getNearestACL () {
+ let isContainer = false
+ // Create a cascade of reject handlers (one for each possible ACL)
+ let nearestACL = Promise.reject()
+ for (const acl of this.getPossibleACLs()) {
+ nearestACL = nearestACL.catch(() => new Promise((resolve, reject) => {
+ debug(`Check if ACL exists: ${acl}`)
+ this.fetch(acl, (err, graph) => {
+ if (err || !graph || !graph.length) {
+ if (err) debug(`Error reading ${acl}: ${err}`)
+ isContainer = true
+ reject(err)
} else {
- debug(mode + ' access denied for: ' + user)
- err.status = 403
- err.message = 'Access denied for ' + user
+ resolve({ acl, graph, isContainer })
}
+ })
+ }))
+ }
+ return nearestACL.catch(e => { throw new Error('No ACL resource found') })
+ }
+
+ // Gets all possible ACL paths that apply to the resource
+ getPossibleACLs () {
+ // Obtain the resource URI and the length of its base
+ let { resource: uri, suffix } = this
+ const [ { length: base } ] = uri.match(/^[^:]+:\/*[^/]+/)
+
+ // If the URI points to a file, append the file's ACL
+ const possibleAcls = []
+ if (!uri.endsWith('/')) {
+ possibleAcls.push(uri.endsWith(suffix) ? uri : uri + suffix)
+ }
+
+ // Append the ACLs of all parent directories
+ for (let i = lastSlash(uri); i >= base; i = lastSlash(uri, i - 1)) {
+ possibleAcls.push(uri.substr(0, i + 1) + suffix)
+ }
+ return possibleAcls
+ }
+
+ // Tests whether the permissions allow a given operation
+ checkAccess (permissionSet, user, mode) {
+ return permissionSet.checkAccess(this.resource, user, mode)
+ .then(hasAccess => {
+ if (hasAccess) {
+ return true
+ } else {
+ throw new Error('ACL file found but no matching policy found')
}
- return callback(err)
})
}
- /**
- * Tests whether a graph (parsed .acl resource) allows a given operation
- * for a given user. Calls the provided callback with `null` if the user
- * has access, otherwise calls it with an error.
- * @method checkAccess
- * @param graph {Graph} Parsed RDF graph of current .acl resource
- * @param user {String} WebID URI of the user accessing the resource
- * @param mode {String} Access mode, e.g. 'Read', 'Write', etc.
- * @param resource {String} URI of the resource being accessed
- * @param accessType {String} One of `accessTo`, or `default`
- * @param acl {String} URI of this current .acl resource
- * @param callback {Function}
- * @param options {Object} Options hashmap
- * @param [options.origin] Request's `Origin:` header
- * @param [options.host] Request's host URI (with protocol)
- */
- checkAccess (graph, user, mode, resource, accessType, acl, callback,
- options = {}) {
- const debug = this.debug
+ // Gets the permission set for the given ACL
+ getPermissionSet ({ acl, graph, isContainer }) {
if (!graph || graph.length === 0) {
debug('ACL ' + acl + ' is empty')
- return callback(new Error('No policy found - empty ACL'))
+ throw new Error('No policy found - empty ACL')
}
- let isContainer = accessType.startsWith('default')
- let aclOptions = {
+ const aclOptions = {
aclSuffix: this.suffix,
graph: graph,
- host: options.host,
- origin: options.origin,
+ host: this.host,
+ origin: this.origin,
rdf: rdf,
strictOrigin: this.strictOrigin,
- isAcl: (uri) => { return this.isAcl(uri) },
- aclUrlFor: (uri) => { return this.aclUrlFor(uri) }
+ isAcl: uri => this.isAcl(uri),
+ aclUrlFor: uri => this.aclUrlFor(uri)
}
- let acls = new PermissionSet(resource, acl, isContainer, aclOptions)
- acls.checkAccess(resource, user, mode)
- .then(hasAccess => {
- if (hasAccess) {
- debug(`${mode} access permitted to ${user}`)
- return callback()
- } else {
- debug(`${mode} access NOT permitted to ${user}` +
- aclOptions.strictOrigin ? ` and origin ${options.origin}` : '')
- return callback(new Error('ACL file found but no matching policy found'))
- }
- })
- .catch(err => {
- debug(`${mode} access denied to ${user}`)
- debug(err)
- return callback(err)
- })
+ return new PermissionSet(this.resource, acl, isContainer, aclOptions)
}
aclUrlFor (uri) {
- if (this.isAcl(uri)) {
- return uri
- } else {
- return uri + this.suffix
- }
+ return this.isAcl(uri) ? uri : uri + this.suffix
}
isAcl (resource) {
- if (typeof resource === 'string') {
- return resource.endsWith(this.suffix)
- } else {
- return false
- }
+ return resource.endsWith(this.suffix)
}
+}
- static possibleACLs (uri, suffix) {
- var first = uri.endsWith(suffix) ? uri : uri + suffix
- var urls = [first]
- var parsedUri = url.parse(uri)
- var baseUrl = (parsedUri.protocol ? parsedUri.protocol + '//' : '') +
- (parsedUri.host || '')
- if (baseUrl + '/' === uri) {
- return urls
- }
-
- var times = parsedUri.pathname.split('/').length
- // TODO: improve temporary solution to stop recursive path walking above root
- if (parsedUri.pathname.endsWith('/')) {
- times--
- }
-
- for (var i = 0; i < times - 1; i++) {
- uri = path.dirname(uri)
- urls.push(uri + (uri[uri.length - 1] === '/' ? suffix : '/' + suffix))
- }
- return urls
- }
+// Returns the index of the last slash before the given position
+function lastSlash (string, pos = string.length) {
+ return string.lastIndexOf('/', pos)
}
module.exports = ACLChecker
diff --git a/lib/api/accounts/user-accounts.js b/lib/api/accounts/user-accounts.js
index e26a4c0e9..e3f704e8f 100644
--- a/lib/api/accounts/user-accounts.js
+++ b/lib/api/accounts/user-accounts.js
@@ -3,7 +3,6 @@
const express = require('express')
const bodyParser = require('body-parser').urlencoded({ extended: false })
const debug = require('../../debug').accounts
-const path = require('path')
const CreateAccountRequest = require('../../requests/create-account-request')
const AddCertificateRequest = require('../../requests/add-cert-request')
@@ -19,6 +18,7 @@ const AddCertificateRequest = require('../../requests/add-cert-request')
function checkAccountExists (accountManager) {
return (req, res, next) => {
let accountUri = req.hostname
+
accountManager.accountUriExists(accountUri)
.then(found => {
if (!found) {
@@ -32,63 +32,6 @@ function checkAccountExists (accountManager) {
}
}
-/**
- * Returns an Express middleware handler for creating a new user account
- * (POST /api/accounts/new).
- *
- * @param accountManager {AccountManager}
- *
- * @return {Function}
- */
-function createAccount (accountManager) {
- return (req, res, next) => {
- let request
-
- try {
- request = CreateAccountRequest.fromParams(req, res, accountManager)
- } catch (err) {
- err.status = err.status || 400
- return next(err)
- }
-
- return request.createAccount()
- .catch(err => {
- err.status = err.status || 400
- next(err)
- })
- }
-}
-
-/**
- * Returns an Express middleware handler for intercepting any GET requests
- * for first time users (in single user mode), and redirecting them to the
- * signup page.
- *
- * @param accountManager {AccountManager}
- *
- * @return {Function}
- */
-function firstTimeSignupRedirect (accountManager) {
- return (req, res, next) => {
- // Only redirect browser (HTML) requests to first-time signup
- if (!req.accepts('text/html')) { return next() }
-
- if (req.path.includes('signup.html')) { return next() }
-
- accountManager.accountExists()
- .then(found => {
- if (!found) {
- debug('(single user mode) Redirecting to account creation')
-
- res.redirect(302, '/signup.html')
- } else {
- next()
- }
- })
- .catch(next)
- }
-}
-
/**
* Returns an Express middleware handler for adding a new certificate to an
* existing account (POST to /api/accounts/cert).
@@ -118,29 +61,10 @@ function newCertificate (accountManager) {
function middleware (accountManager) {
let router = express.Router('/')
- if (accountManager.multiUser) {
- router.get('/', checkAccountExists(accountManager))
- } else {
- // In single user mode, if account has not yet been created, intercept
- // all GET requests and redirect to the Signup form
- accountManager.accountExists()
- .then(found => {
- if (!found) {
- const staticDir = path.join(__dirname, '../../../static')
-
- router.use('/signup.html',
- express.static(path.join(staticDir, 'signup.html')))
- router.use('/signup.html.acl',
- express.static(path.join(staticDir, 'signup.html.acl')))
- router.get('/*', firstTimeSignupRedirect(accountManager))
- }
- })
- .catch(error => {
- debug('Error during accountExists(): ', error)
- })
- }
+ router.get('/', checkAccountExists(accountManager))
- router.post('/api/accounts/new', bodyParser, createAccount(accountManager))
+ router.post('/api/accounts/new', bodyParser, CreateAccountRequest.post)
+ router.get(['/register', '/api/accounts/new'], CreateAccountRequest.get)
router.post('/api/accounts/cert', bodyParser, newCertificate(accountManager))
@@ -150,7 +74,5 @@ function middleware (accountManager) {
module.exports = {
middleware,
checkAccountExists,
- createAccount,
- firstTimeSignupRedirect,
newCertificate
}
diff --git a/lib/api/authn/force-user.js b/lib/api/authn/force-user.js
new file mode 100644
index 000000000..f1d7b41e7
--- /dev/null
+++ b/lib/api/authn/force-user.js
@@ -0,0 +1,21 @@
+const debug = require('../../debug').authentication
+
+/**
+ * Enforces the `--force-user` server flag, hardcoding a webid for all requests,
+ * for testing purposes.
+ */
+function initialize (app, argv) {
+ const forceUserId = argv.forceUser
+ app.use('/', (req, res, next) => {
+ debug(`Identified user (override): ${forceUserId}`)
+ req.session.userId = forceUserId
+ if (argv.auth === 'tls') {
+ res.set('User', forceUserId)
+ }
+ next()
+ })
+}
+
+module.exports = {
+ initialize
+}
diff --git a/lib/api/authn/index.js b/lib/api/authn/index.js
index d3474e2da..db81d0ab8 100644
--- a/lib/api/authn/index.js
+++ b/lib/api/authn/index.js
@@ -1,6 +1,5 @@
-'use strict'
-
module.exports = {
- signin: require('./signin'),
- signout: require('./signout')
+ oidc: require('./webid-oidc'),
+ tls: require('./webid-tls'),
+ forceUser: require('./force-user.js')
}
diff --git a/lib/api/authn/signin.js b/lib/api/authn/signin.js
deleted file mode 100644
index 01e88709f..000000000
--- a/lib/api/authn/signin.js
+++ /dev/null
@@ -1,33 +0,0 @@
-module.exports = signin
-
-const validUrl = require('valid-url')
-const request = require('request')
-const li = require('li')
-
-function signin () {
- return (req, res, next) => {
- if (!validUrl.isUri(req.body.webid)) {
- return res.status(400).send('This is not a valid URI')
- }
-
- request({ method: 'OPTIONS', uri: req.body.webid }, function (err, req) {
- if (err) {
- res.status(400).send('Did not find a valid endpoint')
- return
- }
- if (!req.headers.link) {
- res.status(400).send('The URI requested is not a valid endpoint')
- return
- }
-
- const linkHeaders = li.parse(req.headers.link)
- console.log(linkHeaders)
- if (!linkHeaders['oidc.issuer']) {
- res.status(400).send('The URI requested is not a valid endpoint')
- return
- }
-
- res.redirect(linkHeaders['oidc.issuer'])
- })
- }
-}
diff --git a/lib/api/authn/signout.js b/lib/api/authn/signout.js
deleted file mode 100644
index 16ab7372c..000000000
--- a/lib/api/authn/signout.js
+++ /dev/null
@@ -1,9 +0,0 @@
-module.exports = signout
-
-function signout () {
- return (req, res, next) => {
- req.session.userId = ''
- req.session.identified = false
- res.status(200).send()
- }
-}
diff --git a/lib/api/authn/webid-oidc.js b/lib/api/authn/webid-oidc.js
new file mode 100644
index 000000000..41f46c890
--- /dev/null
+++ b/lib/api/authn/webid-oidc.js
@@ -0,0 +1,182 @@
+'use strict'
+/**
+ * OIDC Relying Party API handler module.
+ */
+
+const express = require('express')
+const bodyParser = require('body-parser').urlencoded({ extended: false })
+const OidcManager = require('../../models/oidc-manager')
+const { LoginRequest } = require('../../requests/login-request')
+
+const PasswordResetEmailRequest = require('../../requests/password-reset-email-request')
+const PasswordChangeRequest = require('../../requests/password-change-request')
+
+const {
+ AuthCallbackRequest,
+ LogoutRequest,
+ SelectProviderRequest
+} = require('oidc-auth-manager').handlers
+
+/**
+ * Sets up OIDC authentication for the given app.
+ *
+ * @param app {Object} Express.js app instance
+ * @param argv {Object} Config options hashmap
+ */
+function initialize (app, argv) {
+ const oidc = OidcManager.fromServerConfig(argv)
+ app.locals.oidc = oidc
+ oidc.initialize()
+
+ // Attach the OIDC API
+ app.use('/', middleware(oidc))
+
+ // Perform the actual authentication
+ app.use('/', oidc.rs.authenticate())
+
+ // Expose session.userId
+ app.use('/', (req, res, next) => {
+ oidc.webIdFromClaims(req.claims)
+ .then(webId => {
+ if (webId) {
+ req.session.userId = webId
+ }
+
+ next()
+ })
+ .catch(err => {
+ let error = new Error('Could not verify Web ID from token claims')
+ error.statusCode = 401
+ error.cause = err
+
+ next(error)
+ })
+ })
+}
+
+/**
+ * Returns a router with OIDC Relying Party and Identity Provider middleware:
+ *
+ * @method middleware
+ *
+ * @param oidc {OidcManager}
+ *
+ * @return {Router} Express router
+ */
+function middleware (oidc) {
+ const router = express.Router('/')
+
+ // User-facing Authentication API
+ router.get('/api/auth/select-provider', SelectProviderRequest.get)
+ router.post('/api/auth/select-provider', bodyParser, SelectProviderRequest.post)
+
+ router.get(['/login', '/signin'], LoginRequest.get)
+
+ router.post('/login/password', bodyParser, LoginRequest.loginPassword)
+
+ router.post('/login/tls', bodyParser, LoginRequest.loginTls)
+
+ router.get('/account/password/reset', PasswordResetEmailRequest.get)
+ router.post('/account/password/reset', bodyParser, PasswordResetEmailRequest.post)
+
+ router.get('/account/password/change', PasswordChangeRequest.get)
+ router.post('/account/password/change', bodyParser, PasswordChangeRequest.post)
+
+ router.get('/logout', LogoutRequest.handle)
+
+ router.get('/goodbye', (req, res) => { res.render('auth/goodbye') })
+
+ // The relying party callback is called at the end of the OIDC signin process
+ router.get('/api/oidc/rp/:issuer_id', AuthCallbackRequest.get)
+
+ // Initialize the OIDC Identity Provider routes/api
+ // router.get('/.well-known/openid-configuration', discover.bind(provider))
+ // router.get('/jwks', jwks.bind(provider))
+ // router.post('/register', register.bind(provider))
+ // router.get('/authorize', authorize.bind(provider))
+ // router.post('/authorize', authorize.bind(provider))
+ // router.post('/token', token.bind(provider))
+ // router.get('/userinfo', userinfo.bind(provider))
+ // router.get('/logout', logout.bind(provider))
+ let oidcProviderApi = require('oidc-op-express')(oidc.provider)
+ router.use('/', oidcProviderApi)
+
+ return router
+}
+
+/**
+ * Sets the `WWW-Authenticate` response header for 401 error responses.
+ * Used by error-pages handler.
+ *
+ * @param req {IncomingRequest}
+ * @param res {ServerResponse}
+ * @param err {Error}
+ */
+function setAuthenticateHeader (req, res, err) {
+ let locals = req.app.locals
+
+ let errorParams = {
+ realm: locals.host.serverUri,
+ scope: 'openid webid',
+ error: err.error,
+ error_description: err.error_description,
+ error_uri: err.error_uri
+ }
+
+ let challengeParams = Object.keys(errorParams)
+ .filter(key => !!errorParams[key])
+ .map(key => `${key}="${errorParams[key]}"`)
+ .join(', ')
+
+ res.set('WWW-Authenticate', 'Bearer ' + challengeParams)
+}
+
+/**
+ * Provides custom logic for error status code overrides.
+ *
+ * @param statusCode {number}
+ * @param req {IncomingRequest}
+ *
+ * @returns {number}
+ */
+function statusCodeOverride (statusCode, req) {
+ if (isEmptyToken(req)) {
+ return 400
+ } else {
+ return statusCode
+ }
+}
+
+/**
+ * Tests whether the `Authorization:` header includes an empty or missing Bearer
+ * token.
+ *
+ * @param req {IncomingRequest}
+ *
+ * @returns {boolean}
+ */
+function isEmptyToken (req) {
+ let header = req.get('Authorization')
+
+ if (!header) { return false }
+
+ if (header.startsWith('Bearer')) {
+ let fragments = header.split(' ')
+
+ if (fragments.length === 1) {
+ return true
+ } else if (!fragments[1]) {
+ return true
+ }
+ }
+
+ return false
+}
+
+module.exports = {
+ initialize,
+ isEmptyToken,
+ middleware,
+ setAuthenticateHeader,
+ statusCodeOverride
+}
diff --git a/lib/api/authn/webid-tls.js b/lib/api/authn/webid-tls.js
new file mode 100644
index 000000000..cbe886d98
--- /dev/null
+++ b/lib/api/authn/webid-tls.js
@@ -0,0 +1,110 @@
+var webid = require('webid/tls')
+var debug = require('../../debug').authentication
+var x509 // optional dependency, load lazily
+
+const CERTIFICATE_MATCHER = /^-----BEGIN CERTIFICATE-----\n(?:[A-Za-z0-9+/=]+\n)+-----END CERTIFICATE-----$/m
+
+function initialize (app, argv) {
+ app.use('/', handler)
+ if (argv.certificateHeader) {
+ app.locals.certificateHeader = argv.certificateHeader.toLowerCase()
+ }
+}
+
+function handler (req, res, next) {
+ // User already logged in? skip
+ if (req.session.userId) {
+ debug('User: ' + req.session.userId)
+ res.set('User', req.session.userId)
+ return next()
+ }
+
+ // No certificate? skip
+ const certificate = getCertificateViaTLS(req) || getCertificateViaHeader(req)
+ if (!certificate) {
+ setEmptySession(req)
+ return next()
+ }
+
+ // Verify webid
+ webid.verify(certificate, function (err, result) {
+ if (err) {
+ debug('Error processing certificate: ' + err.message)
+ setEmptySession(req)
+ return next()
+ }
+ req.session.userId = result
+ debug('Identified user: ' + req.session.userId)
+ res.set('User', req.session.userId)
+ return next()
+ })
+}
+
+// Tries to obtain a client certificate retrieved through the TLS handshake
+function getCertificateViaTLS (req) {
+ const certificate = req.connection.getPeerCertificate &&
+ req.connection.getPeerCertificate()
+ if (certificate && Object.keys(certificate).length > 0) {
+ return certificate
+ }
+ debug('No peer certificate received during TLS handshake.')
+}
+
+// Tries to obtain a client certificate retrieved through an HTTP header
+function getCertificateViaHeader (req) {
+ // Only allow header-based certificates if explicitly enabled
+ const headerName = req.app.locals.certificateHeader
+ if (!headerName) return
+
+ // Try to retrieve the certificate from the header
+ const header = req.headers[headerName]
+ if (!header) {
+ return debug(`No certificate received through the ${headerName} header.`)
+ }
+ // The certificate's newlines have been replaced by tabs
+ // in order to fit in an HTTP header (NGINX does this automatically)
+ const rawCertificate = header.replace(/\t/g, '\n')
+
+ // Ensure the header contains a valid certificate
+ // (x509 unsafely interprets it as a file path otherwise)
+ if (!CERTIFICATE_MATCHER.test(rawCertificate)) {
+ return debug(`Invalid value for the ${headerName} header.`)
+ }
+
+ // Parse and convert the certificate to the format the webid library expects
+ if (!x509) x509 = require('x509')
+ try {
+ const { publicKey, extensions } = x509.parseCert(rawCertificate)
+ return {
+ modulus: publicKey.n,
+ exponent: '0x' + parseInt(publicKey.e, 10).toString(16),
+ subjectaltname: extensions && extensions.subjectAlternativeName
+ }
+ } catch (error) {
+ debug(`Invalid certificate received through the ${headerName} header.`)
+ }
+}
+
+function setEmptySession (req) {
+ req.session.userId = ''
+}
+
+/**
+ * Sets the `WWW-Authenticate` response header for 401 error responses.
+ * Used by error-pages handler.
+ *
+ * @param req {IncomingRequest}
+ * @param res {ServerResponse}
+ */
+function setAuthenticateHeader (req, res) {
+ let locals = req.app.locals
+
+ res.set('WWW-Authenticate', `WebID-TLS realm="${locals.host.serverUri}"`)
+}
+
+module.exports = {
+ initialize,
+ handler,
+ setAuthenticateHeader,
+ setEmptySession
+}
diff --git a/lib/api/index.js b/lib/api/index.js
index 9e6b2a80b..5c0cd0477 100644
--- a/lib/api/index.js
+++ b/lib/api/index.js
@@ -2,6 +2,5 @@
module.exports = {
authn: require('./authn'),
- messages: require('./messages'),
accounts: require('./accounts/user-accounts')
}
diff --git a/lib/api/messages/index.js b/lib/api/messages/index.js
deleted file mode 100644
index f9481ed51..000000000
--- a/lib/api/messages/index.js
+++ /dev/null
@@ -1,104 +0,0 @@
-exports.send = send
-
-const error = require('../../http-error')
-const debug = require('debug')('solid:api:messages')
-const utils = require('../../utils')
-const sym = require('rdflib').sym
-const url = require('url')
-const waterfall = require('run-waterfall')
-
-function send () {
- return (req, res, next) => {
- if (!req.session.userId) {
- next(error(401, 'You need to be authenticated'))
- return
- }
-
- if (!req.body.message || req.body.message.length < 0) {
- next(error(406, 'You need to specify a message'))
- return
- }
-
- if (!req.body.to) {
- next(error(406, 'You need to specify a the destination'))
- return
- }
-
- if (req.body.to.split(':').length !== 2) {
- next(error(406, 'Destination badly formatted'))
- return
- }
-
- waterfall([
- (cb) => getLoggedUserName(req, cb),
- (displayName, cb) => {
- const vars = {
- me: displayName,
- message: req.body.message
- }
-
- if (req.body.to.split(':') === 'mailto' && req.app.locals.emailService) {
- sendEmail(req, vars, cb)
- } else {
- cb(error(406, 'Messaging service not available'))
- }
- }
- ], (err) => {
- if (err) {
- next(err)
- return
- }
-
- res.send('message sent')
- })
- }
-}
-
-function getLoggedUserName (req, callback) {
- const ldp = req.app.locals.ldp
- const baseUri = utils.getBaseUri(req)
- const webid = url.parse(req.session.userId)
-
- ldp.graph(webid.hostname, '/' + webid.pathname, baseUri, function (err, graph) {
- if (err) {
- debug('cannot find graph of the user', req.session.userId || ldp.root, err)
- // TODO for now only users of this IDP can send emails
- callback(error(403, 'Your user cannot perform this operation'))
- return
- }
-
- // TODO do a query
- let displayName
- graph
- .statementsMatching(undefined, sym('http://xmlns.com/foaf/0.1/name'))
- .some(function (statement) {
- if (statement.object.value) {
- displayName = statement.object.value
- return true
- }
- })
-
- if (!displayName) {
- displayName = webid.hostname
- }
- callback(null, displayName)
- })
-}
-
-function sendEmail (req, vars, callback) {
- const emailService = req.app.locals.emailService
- const emailData = {
- from: 'no-reply@' + webid.hostname,
- to: req.body.to.split(':')[1]
- }
- const webid = url.parse(req.session.userId)
-
- emailService.messageTemplate((template) => {
- var send = emailService.mailer.templateSender(
- template,
- { from: emailData.from })
-
- // use template based sender to send a message
- send({ to: emailData.to }, vars, callback)
- })
-}
diff --git a/lib/capability-discovery.js b/lib/capability-discovery.js
index 3e7a185b4..6be374f0d 100644
--- a/lib/capability-discovery.js
+++ b/lib/capability-discovery.js
@@ -10,10 +10,12 @@ const serviceConfigDefaults = {
'accounts': {
// 'changePassword': '/api/account/changePassword',
// 'delete': '/api/accounts/delete',
+
+ // Create new user (see IdentityProvider.post() in identity-provider.js)
'new': '/api/accounts/new',
'recover': '/api/accounts/recover',
- 'signin': '/api/accounts/signin',
- 'signout': '/api/accounts/signout',
+ 'signin': '/login',
+ 'signout': '/logout',
'validateToken': '/api/accounts/validateToken'
}
}
@@ -43,7 +45,7 @@ function capabilityDiscovery () {
* @param next
*/
function serviceCapabilityDocument (serviceConfig) {
- return (req, res, next) => {
+ return (req, res) => {
// Add the server root url
serviceConfig.root = util.getFullUri(req) // TODO make sure we align with the rest
// Add the 'apps' urls section
diff --git a/lib/create-app.js b/lib/create-app.js
index ec4039b19..025148713 100644
--- a/lib/create-app.js
+++ b/lib/create-app.js
@@ -2,29 +2,32 @@ module.exports = createApp
const express = require('express')
const session = require('express-session')
+const handlebars = require('express-handlebars')
const uuid = require('uuid')
const cors = require('cors')
const LDP = require('./ldp')
const LdpMiddleware = require('./ldp-middleware')
-const proxy = require('./handlers/proxy')
+const corsProxy = require('./handlers/cors-proxy')
+const authProxy = require('./handlers/auth-proxy')
const SolidHost = require('./models/solid-host')
const AccountManager = require('./models/account-manager')
const vhost = require('vhost')
-const fs = require('fs-extra')
-const path = require('path')
const EmailService = require('./models/email-service')
-const AccountRecovery = require('./account-recovery')
+const TokenService = require('./models/token-service')
const capabilityDiscovery = require('./capability-discovery')
-const bodyParser = require('body-parser').urlencoded({ extended: false })
const API = require('./api')
-const authentication = require('./handlers/authentication')
const errorPages = require('./handlers/error-pages')
+const config = require('./server-config')
+const defaults = require('../config/defaults')
+const options = require('./handlers/options')
+const debug = require('./debug').authentication
+const path = require('path')
-var corsSettings = cors({
+const corsSettings = cors({
methods: [
'OPTIONS', 'HEAD', 'GET', 'PATCH', 'POST', 'PUT', 'DELETE'
],
- exposedHeaders: 'User, Location, Link, Vary, Last-Modified, ETag, Accept-Patch, Accept-Post, Updates-Via, Allow, Content-Length',
+ exposedHeaders: 'Authorization, User, Location, Link, Vary, Last-Modified, ETag, Accept-Patch, Accept-Post, Updates-Via, Allow, WAC-Allow, Content-Length, WWW-Authenticate',
credentials: true,
maxAge: 1728000,
origin: true,
@@ -32,135 +35,185 @@ var corsSettings = cors({
})
function createApp (argv = {}) {
- argv.host = SolidHost.from({ port: argv.port, serverUri: argv.serverUri })
- argv.templates = initTemplates()
-
- let ldp = new LDP(argv)
- let app = express()
-
- app.use(corsSettings)
-
- app.options('*', (req, res, next) => {
- res.status(204)
- next()
- })
+ // Override default configs (defaults) with passed-in params (argv)
+ argv = Object.assign({}, defaults, argv)
- // Setting options as local variable
- app.locals.ldp = ldp
- app.locals.appUrls = argv.apps // used for service capability discovery
- let multiUser = argv.idp
+ argv.host = SolidHost.from({ port: argv.port, serverUri: argv.serverUri })
- if (argv.email && argv.email.host) {
- app.locals.emailService = new EmailService(argv.templates.email, argv.email)
- }
+ const configPath = config.initConfigDir(argv)
+ argv.templates = config.initTemplateDirs(configPath)
- // Set X-Powered-By
- app.use(function (req, res, next) {
- res.set('X-Powered-By', 'solid-server')
- next()
- })
+ const ldp = new LDP(argv)
- // Set default Allow methods
- app.use(function (req, res, next) {
- res.set('Allow', 'OPTIONS, HEAD, GET, PATCH, POST, PUT, DELETE')
- next()
- })
+ const app = express()
- app.use('/', capabilityDiscovery())
+ initAppLocals(app, argv, ldp)
+ initHeaders(app)
+ initViews(app, configPath)
- // Use session cookies
- let useSecureCookies = argv.webid // argv.webid forces https and secure cookies
- app.use(session(sessionSettings(useSecureCookies, argv.host)))
+ // Serve the public 'common' directory (for shared CSS files, etc)
+ app.use('/common', express.static(path.join(__dirname, '../common')))
- // Adding proxy
- if (ldp.proxy) {
- proxy(app, ldp.proxy)
+ // Add CORS proxy
+ if (argv.proxy) {
+ console.warn('The proxy configuration option has been renamed to corsProxy.')
+ argv.corsProxy = argv.proxy
+ delete argv.proxy
}
-
- if (ldp.webid) {
- var accountRecovery = AccountRecovery({ redirect: '/' })
- // adds GET /api/accounts/recover
- // adds POST /api/accounts/recover
- // adds GET /api/accounts/validateToken
- app.use('/api/accounts/', accountRecovery)
-
- let accountManager = AccountManager.from({
- authMethod: argv.auth,
- emailService: app.locals.emailService,
- host: argv.host,
- accountTemplatePath: argv.templates.account,
- store: ldp,
- multiUser
- })
-
- // Account Management API (create account, new cert)
- app.use('/', API.accounts.middleware(accountManager))
-
- // Authentication API (login/logout)
- app.post('/api/accounts/signin', bodyParser, API.authn.signin())
- app.post('/api/accounts/signout', API.authn.signout())
-
- // Messaging API
- app.post('/api/messages', authentication, bodyParser, API.messages.send())
+ if (argv.corsProxy) {
+ corsProxy(app, argv.corsProxy)
}
+ // Options handler
+ app.options('/*', options)
+
+ // Set up API
if (argv.apiApps) {
app.use('/api/apps', express.static(argv.apiApps))
}
- if (ldp.idp) {
- app.use(vhost('*', LdpMiddleware(corsSettings)))
+ // Authenticate the user
+ if (argv.webid) {
+ initWebId(argv, app, ldp)
+ }
+ // Add Auth proxy (requires authentication)
+ if (argv.authProxy) {
+ authProxy(app, argv.authProxy)
}
+ // Attach the LDP middleware
app.use('/', LdpMiddleware(corsSettings))
// Errors
- app.use(errorPages)
+ app.use(errorPages.handler)
return app
}
-function initTemplates () {
- let accountTemplatePath = ensureTemplateCopiedTo(
- '../default-account-template',
- '../config/account-template'
- )
-
- let emailTemplatesPath = ensureTemplateCopiedTo(
- '../default-email-templates',
- '../config/email-templates'
- )
+/**
+ * Initializes `app.locals` parameters for downstream use (typically by route
+ * handlers).
+ *
+ * @param app {Function} Express.js app instance
+ * @param argv {Object} Config options hashmap
+ * @param ldp {LDP}
+ */
+function initAppLocals (app, argv, ldp) {
+ app.locals.ldp = ldp
+ app.locals.appUrls = argv.apps // used for service capability discovery
+ app.locals.host = argv.host
+ app.locals.authMethod = argv.auth
+ app.locals.localAuth = argv.localAuth
+ app.locals.tokenService = new TokenService()
- return {
- account: accountTemplatePath,
- email: emailTemplatesPath
+ if (argv.email && argv.email.host) {
+ app.locals.emailService = new EmailService(argv.templates.email, argv.email)
}
}
/**
- * Ensures that a template directory has been initialized in `config/` from
- * default templates.
+ * Sets up headers common to all Solid requests (CORS-related, Allow, etc).
*
- * @param defaultTemplateDir {string} Path to a default template directory,
- * relative to `lib/`. For example, '../default-email-templates' contains
- * various email templates pre-defined by the Solid dev team.
+ * @param app {Function} Express.js app instance
+ */
+function initHeaders (app) {
+ app.use(corsSettings)
+
+ app.use((req, res, next) => {
+ // Set X-Powered-By
+ res.set('X-Powered-By', 'solid-server')
+ // Set default Allow methods
+ res.set('Allow', 'OPTIONS, HEAD, GET, PATCH, POST, PUT, DELETE')
+ next()
+ })
+
+ app.use('/', capabilityDiscovery())
+}
+
+/**
+ * Sets up the express rendering engine and views directory.
*
- * @param configTemplateDir {string} Path to a template directory customized
- * to this particular installation (relative to `lib/`). Server operators
- * are encouraged to override/customize these templates in the `config/`
- * directory.
+ * @param app {Function} Express.js app
+ * @param configPath {string}
+ */
+function initViews (app, configPath) {
+ const viewsPath = config.initDefaultViews(configPath)
+
+ app.set('views', viewsPath)
+ app.engine('.hbs', handlebars({
+ extname: '.hbs',
+ partialsDir: viewsPath
+ }))
+ app.set('view engine', '.hbs')
+}
+
+/**
+ * Sets up WebID-related functionality (account creation and authentication)
*
- * @return {string} Returns the absolute path to the customizable template copy
+ * @param argv {Object}
+ * @param app {Function}
+ * @param ldp {LDP}
*/
-function ensureTemplateCopiedTo (defaultTemplateDir, configTemplateDir) {
- let configTemplatePath = path.join(__dirname, configTemplateDir)
- let defaultTemplatePath = path.join(__dirname, defaultTemplateDir)
+function initWebId (argv, app, ldp) {
+ config.ensureWelcomePage(argv)
+
+ // Store the user's session key in a cookie
+ // (for same-domain browsing by people only)
+ const useSecureCookies = argv.webid // argv.webid forces https and secure cookies
+ const sessionHandler = session(sessionSettings(useSecureCookies, argv.host))
+ app.use((req, res, next) => {
+ sessionHandler(req, res, () => {
+ // Reject cookies from third-party applications.
+ // Otherwise, when a user is logged in to their Solid server,
+ // any third-party application could perform authenticated requests
+ // without permission by including the credentials set by the Solid server.
+ const origin = req.headers.origin
+ const userId = req.session.userId
+ if (!argv.host.allowsSessionFor(userId, origin)) {
+ debug(`Rejecting session for ${userId} from ${origin}`)
+ // Destroy session data
+ delete req.session.userId
+ // Ensure this modified session is not saved
+ req.session.save = (done) => done()
+ }
+ next()
+ })
+ })
+
+ let accountManager = AccountManager.from({
+ authMethod: argv.auth,
+ emailService: app.locals.emailService,
+ tokenService: app.locals.tokenService,
+ host: argv.host,
+ accountTemplatePath: argv.templates.account,
+ store: ldp,
+ multiuser: argv.multiuser
+ })
+ app.locals.accountManager = accountManager
- if (!fs.existsSync(configTemplatePath)) {
- fs.copySync(defaultTemplatePath, configTemplatePath)
+ // Account Management API (create account, new cert)
+ app.use('/', API.accounts.middleware(accountManager))
+
+ // Set up authentication-related API endpoints and app.locals
+ initAuthentication(app, argv)
+
+ if (argv.multiuser) {
+ app.use(vhost('*', LdpMiddleware(corsSettings)))
}
+}
- return configTemplatePath
+/**
+ * Sets up authentication-related routes and handlers for the app.
+ *
+ * @param app {Object} Express.js app instance
+ * @param argv {Object} Config options hashmap
+ */
+function initAuthentication (app, argv) {
+ const auth = argv.forceUser ? 'forceUser' : argv.auth
+ if (!(auth in API.authn)) {
+ throw new Error(`Unsupported authentication scheme: ${auth}`)
+ }
+ API.authn[auth].initialize(app, argv)
}
/**
diff --git a/lib/create-server.js b/lib/create-server.js
index 6e4c0b225..216a6f774 100644
--- a/lib/create-server.js
+++ b/lib/create-server.js
@@ -12,7 +12,7 @@ function createServer (argv, app) {
argv = argv || {}
app = app || express()
var ldpApp = createApp(argv)
- var ldp = ldpApp.locals.ldp
+ var ldp = ldpApp.locals.ldp || {}
var mount = argv.mount || '/'
// Removing ending '/'
if (mount.length > 1 &&
@@ -21,9 +21,19 @@ function createServer (argv, app) {
}
app.use(mount, ldpApp)
debug.settings('Base URL (--mount): ' + mount)
- var server = http.createServer(app)
- if (ldp && (ldp.webid || ldp.idp || argv.sslKey || argv.sslCert)) {
+ if (argv.idp) {
+ console.warn('The idp configuration option has been renamed to multiuser.')
+ argv.idp = argv.multiuser
+ delete argv.idp
+ }
+
+ var server
+ var needsTLS = argv.sslKey || argv.sslCert ||
+ (ldp.webid || ldp.multiuser) && !argv.certificateHeader
+ if (!needsTLS) {
+ server = http.createServer(app)
+ } else {
debug.settings('SSL Private Key path: ' + argv.sslKey)
debug.settings('SSL Certificate path: ' + argv.sslCert)
@@ -53,10 +63,10 @@ function createServer (argv, app) {
throw new Error('Can\'t find SSL cert in ' + argv.sslCert)
}
- var credentials = {
+ var credentials = Object.assign({
key: key,
cert: cert
- }
+ }, argv)
if (ldp.webid && ldp.auth === 'tls') {
credentials.requestCert = true
diff --git a/lib/handlers/allow.js b/lib/handlers/allow.js
index ca7fcc0e9..5a6abff8c 100644
--- a/lib/handlers/allow.js
+++ b/lib/handlers/allow.js
@@ -1,14 +1,10 @@
-module.exports.allow = allow
+module.exports = allow
var ACL = require('../acl-checker')
var $rdf = require('rdflib')
var url = require('url')
-var async = require('async')
-var debug = require('../debug').ACL
var utils = require('../utils')
-
-// TODO should this be set?
-process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
+var debug = require('../debug.js').ACL
function allow (mode) {
return function allowHandler (req, res, next) {
@@ -16,34 +12,37 @@ function allow (mode) {
if (!ldp.webid) {
return next()
}
- var baseUri = utils.getBaseUri(req)
- var acl = new ACL({
- debug: debug,
- fetch: fetchDocument(req.hostname, ldp, baseUri),
- suffix: ldp.suffixAcl,
- strictOrigin: ldp.strictOrigin
- })
+ // Determine the actual path of the request
+ var reqPath = res && res.locals && res.locals.path
+ ? res.locals.path
+ : req.path
- getUserId(req, function (err, userId) {
- if (err) return next(err)
+ // Check whether the resource exists
+ ldp.exists(req.hostname, reqPath, (err, ret) => {
+ // Ensure directories always end in a slash
+ const stat = err ? null : ret.stream
+ if (!reqPath.endsWith('/') && stat && stat.isDirectory()) {
+ reqPath += '/'
+ }
- var reqPath = res && res.locals && res.locals.path
- ? res.locals.path
- : req.path
- ldp.exists(req.hostname, reqPath, (err, ret) => {
- if (ret) {
- var stat = ret.stream
- }
- if (!reqPath.endsWith('/') && !err && stat.isDirectory()) {
- reqPath += '/'
- }
- var options = {
- origin: req.get('origin'),
- host: req.protocol + '://' + req.get('host')
- }
- return acl.can(userId, mode, baseUri + reqPath, next, options)
+ // Obtain and store the ACL of the requested resource
+ const baseUri = utils.getBaseUri(req)
+ req.acl = new ACL(baseUri + reqPath, {
+ origin: req.get('origin'),
+ host: req.protocol + '://' + req.get('host'),
+ fetch: fetchDocument(req.hostname, ldp, baseUri),
+ suffix: ldp.suffixAcl,
+ strictOrigin: ldp.strictOrigin
})
+
+ // Ensure the user has the required permission
+ const userId = req.session.userId
+ req.acl.can(userId, mode)
+ .then(() => next(), err => {
+ debug(`${mode} access denied to ${userId || '(none)'}`)
+ next(err)
+ })
})
}
}
@@ -64,86 +63,30 @@ function allow (mode) {
*/
function fetchDocument (host, ldp, baseUri) {
return function fetch (uri, callback) {
- var graph = $rdf.graph()
- async.waterfall([
- function readFile (cb) {
- // If local request, slice off the initial baseUri
- // S(uri).chompLeft(baseUri).s
- var newPath = uri.startsWith(baseUri)
- ? uri.slice(baseUri.length)
- : uri
- // Determine the root file system folder to look in
- // TODO prettify this
- var root = !ldp.idp ? ldp.root : ldp.root + host + '/'
- // Derive the file path for the resource
- var documentPath = utils.uriToFilename(newPath, root)
- var documentUri = url.parse(documentPath)
- documentPath = documentUri.pathname
- return ldp.readFile(documentPath, cb)
- },
- function parseFile (body, cb) {
- try {
- $rdf.parse(body, graph, uri, 'text/turtle')
- } catch (err) {
- return cb(err, graph)
- }
- return cb(null, graph)
- }
- ], callback)
+ readFile(uri, host, ldp, baseUri).then(body => {
+ const graph = $rdf.graph()
+ $rdf.parse(body, graph, uri, 'text/turtle')
+ return graph
+ })
+ .then(graph => callback(null, graph), callback)
}
}
-function getUserId (req, callback) {
- callback(null, req.session.userId)
- // var onBehalfOf = req.get('On-Behalf-Of')
- // if (!onBehalfOf) {
- // return callback(null, req.session.userId)
- // }
- //
- // var delegator = utils.debrack(onBehalfOf)
- // verifyDelegator(req.hostname, delegator, req.session.userId,
- // function (err, res) {
- // if (err) {
- // err.status = 500
- // return callback(err)
- // }
- //
- // if (res) {
- // debug('Request User ID (delegation) :' + delegator)
- // return callback(null, delegator)
- // }
- // return callback(null, req.session.userId)
- // })
+// Reads the given file, returning its contents
+function readFile (uri, host, ldp, baseUri) {
+ return new Promise((resolve, reject) => {
+ // If local request, slice off the initial baseUri
+ // S(uri).chompLeft(baseUri).s
+ var newPath = uri.startsWith(baseUri)
+ ? uri.slice(baseUri.length)
+ : uri
+ // Determine the root file system folder to look in
+ // TODO prettify this
+ var root = !ldp.multiuser ? ldp.root : ldp.root + host + '/'
+ // Derive the file path for the resource
+ var documentPath = utils.uriToFilename(newPath, root)
+ var documentUri = url.parse(documentPath)
+ documentPath = documentUri.pathname
+ ldp.readFile(documentPath, (e, c) => e ? reject(e) : resolve(c))
+ })
}
-
-// function verifyDelegator (host, ldp, baseUri, delegator, delegatee, callback) {
-// fetchDocument(host, ldp, baseUri)(delegator, function (err, delegatorGraph) {
-// // TODO handle error
-// if (err) {
-// err.status = 500
-// return callback(err)
-// }
-//
-// var delegatesStatements = delegatorGraph
-// .each(delegatorGraph.sym(delegator),
-// delegatorGraph.sym('http://www.w3.org/ns/auth/acl#delegates'),
-// undefined)
-//
-// for (var delegateeIndex in delegatesStatements) {
-// var delegateeValue = delegatesStatements[delegateeIndex]
-// if (utils.debrack(delegateeValue.toString()) === delegatee) {
-// callback(null, true)
-// }
-// }
-// // TODO check if this should be false
-// return callback(null, false)
-// })
-// }
-/**
- * Callback used by verifyDelegator.
- * @callback ACL~verifyDelegator_cb
- * @param {Object} err Error occurred when reading the acl file
- * @param {Number} err.status Status code of the error (HTTP way)
- * @param {String} err.message Reason of the error
- * @param {Boolean} result verification has passed or not
- */
diff --git a/lib/handlers/auth-proxy.js b/lib/handlers/auth-proxy.js
new file mode 100644
index 000000000..e6a8e38ca
--- /dev/null
+++ b/lib/handlers/auth-proxy.js
@@ -0,0 +1,47 @@
+// An authentication proxy is a reverse proxy
+// that sends a logged-in Solid user's details to a backend
+module.exports = addAuthProxyHandlers
+
+const proxy = require('http-proxy-middleware')
+const debug = require('../debug')
+
+const PROXY_SETTINGS = {
+ logLevel: 'silent',
+ changeOrigin: true
+}
+
+// Registers Auth Proxy handlers for each target
+function addAuthProxyHandlers (app, targets) {
+ for (const sourcePath in targets) {
+ addAuthProxyHandler(app, sourcePath, targets[sourcePath])
+ }
+}
+
+// Registers an Auth Proxy handler for the given target
+function addAuthProxyHandler (app, sourcePath, target) {
+ debug.settings(`Add auth proxy from ${sourcePath} to ${target}`)
+
+ // Proxy to the target, removing the source path
+ // (e.g., /my/proxy/path resolves to http://my.proxy/path)
+ const sourcePathLength = sourcePath.length
+ const settings = Object.assign({
+ target,
+ onProxyReq: addAuthHeaders,
+ onProxyReqWs: addAuthHeaders,
+ pathRewrite: path => path.substr(sourcePathLength)
+ }, PROXY_SETTINGS)
+
+ // Activate the proxy
+ app.use(`${sourcePath}*`, proxy(settings))
+}
+
+// Adds a headers with authentication information
+function addAuthHeaders (proxyReq, req) {
+ const { session = {}, headers = {} } = req
+ if (session.userId) {
+ proxyReq.setHeader('User', session.userId)
+ }
+ if (headers.host) {
+ proxyReq.setHeader('Forwarded', `host=${headers.host}`)
+ }
+}
diff --git a/lib/handlers/authentication.js b/lib/handlers/authentication.js
deleted file mode 100644
index fef1df026..000000000
--- a/lib/handlers/authentication.js
+++ /dev/null
@@ -1,64 +0,0 @@
-module.exports = handler
-
-var webid = require('webid/tls')
-var debug = require('../debug').authentication
-var error = require('../http-error')
-
-function handler (req, res, next) {
- var ldp = req.app.locals.ldp
-
- if (ldp.forceUser) {
- req.session.userId = ldp.forceUser
- req.session.identified = true
- debug('Identified user: ' + req.session.userId)
- res.set('User', req.session.userId)
- return next()
- }
-
- // No webid required? skip
- if (!ldp.webid) {
- setEmptySession(req)
- return next()
- }
-
- // User already logged in? skip
- if (req.session.userId && req.session.identified) {
- debug('User: ' + req.session.userId)
- res.set('User', req.session.userId)
- return next()
- }
-
- if (ldp.auth === 'tls') {
- var certificate = req.connection.getPeerCertificate()
- // Certificate is empty? skip
- if (certificate === null || Object.keys(certificate).length === 0) {
- debug('No client certificate found in the request. Did the user click on a cert?')
- setEmptySession(req)
- return next()
- }
-
- // Verify webid
- webid.verify(certificate, function (err, result) {
- if (err) {
- debug('Error processing certificate: ' + err.message)
- setEmptySession(req)
- return next()
- }
- req.session.userId = result
- req.session.identified = true
- debug('Identified user: ' + req.session.userId)
- res.set('User', req.session.userId)
- return next()
- })
- } else if (ldp.auth === 'oidc') {
- setEmptySession(req)
- return next()
- } else {
- return next(error(500, 'Authentication method not supported'))
- }
-}
-
-function setEmptySession (req) {
- req.session.userId = ''
- req.session.identified = false
-}
diff --git a/lib/handlers/cors-proxy.js b/lib/handlers/cors-proxy.js
new file mode 100644
index 000000000..488b4a392
--- /dev/null
+++ b/lib/handlers/cors-proxy.js
@@ -0,0 +1,67 @@
+module.exports = addCorsProxyHandler
+
+const proxy = require('http-proxy-middleware')
+const cors = require('cors')
+const debug = require('../debug')
+const url = require('url')
+const dns = require('dns')
+const isIp = require('is-ip')
+const ipRange = require('ip-range-check')
+const validUrl = require('valid-url')
+
+const CORS_SETTINGS = {
+ methods: 'GET',
+ exposedHeaders: 'Authorization, User, Location, Link, Vary, Last-Modified, Content-Length',
+ maxAge: 1728000,
+ origin: true
+}
+const PROXY_SETTINGS = {
+ target: 'dynamic',
+ logLevel: 'silent',
+ changeOrigin: true,
+ router: req => req.destination.target,
+ pathRewrite: (path, req) => req.destination.path
+}
+const LOCAL_IP_RANGES = [
+ '10.0.0.0/8',
+ '127.0.0.0/8',
+ '172.16.0.0/12',
+ '192.168.0.0/16'
+]
+
+// Adds a CORS proxy handler to the application on the given path
+function addCorsProxyHandler (app, path) {
+ const corsHandler = cors(CORS_SETTINGS)
+ const proxyHandler = proxy(PROXY_SETTINGS)
+
+ debug.settings(`CORS proxy listening at ${path}?uri={uri}`)
+ app.get(path, extractProxyConfig, corsHandler, proxyHandler)
+}
+
+// Extracts proxy configuration parameters from the request
+function extractProxyConfig (req, res, next) {
+ // Retrieve and validate the destination URL
+ const uri = req.query.uri
+ debug.settings(`Proxy request for ${uri}`)
+ if (!validUrl.isUri(uri)) {
+ return res.status(400).send(`Invalid URL passed: ${uri || '(none)'}`)
+ }
+
+ // Parse the URL and retrieve its host's IP address
+ const { protocol, host, hostname, path } = url.parse(uri)
+ if (isIp(hostname)) {
+ addProxyConfig(null, hostname)
+ } else {
+ dns.lookup(hostname, addProxyConfig)
+ }
+
+ // Verifies and adds the proxy configuration to the request
+ function addProxyConfig (error, hostAddress) {
+ // Ensure the host is not a local IP
+ if (error || LOCAL_IP_RANGES.some(r => ipRange(hostAddress, r))) {
+ return res.status(400).send(`Cannot proxy ${uri}`)
+ }
+ req.destination = { path, target: `${protocol}//${host}` }
+ next()
+ }
+}
diff --git a/lib/handlers/error-pages.js b/lib/handlers/error-pages.js
index 18a5f3bc4..ab8908672 100644
--- a/lib/handlers/error-pages.js
+++ b/lib/handlers/error-pages.js
@@ -1,38 +1,210 @@
-module.exports = handler
+const debug = require('../debug').server
+const fs = require('fs')
+const util = require('../utils')
+const Auth = require('../api/authn')
-var debug = require('../debug').server
-var fs = require('fs')
+// Authentication methods that require a Provider Select page
+const SELECT_PROVIDER_AUTH_METHODS = ['oidc']
+/**
+ * Serves as a last-stop error handler for all other middleware.
+ *
+ * @param err {Error}
+ * @param req {IncomingRequest}
+ * @param res {ServerResponse}
+ * @param next {Function}
+ */
function handler (err, req, res, next) {
- debug('Error page because of ' + err.message)
+ debug('Error page because of:', err)
- var ldp = req.app.locals.ldp
+ let locals = req.app.locals
+ let authMethod = locals.authMethod
+ let ldp = locals.ldp
- // If the user specifies this function
- // then, they can customize the error programmatically
+ // If the user specifies this function,
+ // they can customize the error programmatically
if (ldp.errorHandler) {
+ debug('Using custom error handler')
return ldp.errorHandler(err, req, res, next)
}
+ let statusCode = statusCodeFor(err, req, authMethod)
+
+ if (statusCode === 401) {
+ debug(err, 'error:', err.error, 'desc:', err.error_description)
+ setAuthenticateHeader(req, res, err)
+ }
+
+ if (requiresSelectProvider(authMethod, statusCode, req)) {
+ return redirectToSelectProvider(req, res)
+ }
+
// If noErrorPages is set,
- // then use built-in express default error handler
+ // then return the response directly
if (ldp.noErrorPages) {
- return res
- .status(err.status)
- .send(err.message + '\n' || '')
+ sendErrorResponse(statusCode, res, err)
+ } else {
+ sendErrorPage(statusCode, res, err, ldp)
+ }
+}
+
+/**
+ * Returns the HTTP status code for a given request error.
+ *
+ * @param err {Error}
+ * @param req {IncomingRequest}
+ * @param authMethod {string}
+ *
+ * @returns {number}
+ */
+function statusCodeFor (err, req, authMethod) {
+ let statusCode = err.status || err.statusCode || 500
+
+ if (authMethod === 'oidc') {
+ statusCode = Auth.oidc.statusCodeOverride(statusCode, req)
}
- // Check if error page exists
- var errorPage = ldp.errorPages + err.status.toString() + '.html'
- fs.readFile(errorPage, 'utf8', function (readErr, text) {
- if (readErr) {
- return res
- .status(err.status)
- .send(err.message || '')
- }
-
- res.status(err.status)
- res.header('Content-Type', 'text/html')
- res.send(text)
+ return statusCode
+}
+
+/**
+ * Tests whether a given authentication method requires a Select Provider
+ * page redirect for 401 error responses.
+ *
+ * @param authMethod {string}
+ * @param statusCode {number}
+ * @param req {IncomingRequest}
+ *
+ * @returns {boolean}
+ */
+function requiresSelectProvider (authMethod, statusCode, req) {
+ if (statusCode !== 401) { return false }
+
+ if (!SELECT_PROVIDER_AUTH_METHODS.includes(authMethod)) { return false }
+
+ if (!req.accepts('text/html')) { return false }
+
+ return true
+}
+
+/**
+ * Dispatches the writing of the `WWW-Authenticate` response header (used for
+ * 401 Unauthorized responses).
+ *
+ * @param req {IncomingRequest}
+ * @param res {ServerResponse}
+ * @param err {Error}
+ */
+function setAuthenticateHeader (req, res, err) {
+ let locals = req.app.locals
+ let authMethod = locals.authMethod
+
+ switch (authMethod) {
+ case 'oidc':
+ Auth.oidc.setAuthenticateHeader(req, res, err)
+ break
+ case 'tls':
+ Auth.tls.setAuthenticateHeader(req, res)
+ break
+ default:
+ break
+ }
+}
+
+/**
+ * Sends the HTTP status code and error message in the response.
+ *
+ * @param statusCode {number}
+ * @param res {ServerResponse}
+ * @param err {Error}
+ */
+function sendErrorResponse (statusCode, res, err) {
+ res.status(statusCode)
+ res.send(err.message + '\n')
+}
+
+/**
+ * Sends the HTTP status code and error message as a custom error page.
+ *
+ * @param statusCode {number}
+ * @param res {ServerResponse}
+ * @param err {Error}
+ * @param ldp {LDP}
+ */
+function sendErrorPage (statusCode, res, err, ldp) {
+ let errorPage = ldp.errorPages + statusCode.toString() + '.html'
+
+ return new Promise((resolve) => {
+ fs.readFile(errorPage, 'utf8', (readErr, text) => {
+ if (readErr) {
+ // Fall back on plain error response
+ return resolve(sendErrorResponse(statusCode, res, err))
+ }
+
+ res.status(statusCode)
+ res.header('Content-Type', 'text/html')
+ res.send(text)
+ resolve()
+ })
})
}
+
+/**
+ * Sends a 401 response with an HTML http-equiv type redirect body, to
+ * redirect any users requesting a resource directly in the browser to the
+ * Select Provider page and login workflow.
+ * Implemented as a 401 + redirect body instead of a 302 to provide a useful
+ * 401 response to REST/XHR clients.
+ *
+ * @param req {IncomingRequest}
+ * @param res {ServerResponse}
+ */
+function redirectToSelectProvider (req, res) {
+ res.status(401)
+ res.header('Content-Type', 'text/html')
+
+ let currentUrl = util.fullUrlForReq(req)
+ req.session.returnToUrl = currentUrl
+
+ let locals = req.app.locals
+ let loginUrl = locals.host.serverUri +
+ '/api/auth/select-provider?returnToUrl=' + currentUrl
+ debug('Redirecting to Select Provider: ' + loginUrl)
+
+ let body = redirectBody(loginUrl)
+ res.send(body)
+}
+
+/**
+ * Returns a response body for redirecting browsers to a Select Provider /
+ * login workflow page. Uses either a JS location.href redirect or an
+ * http-equiv type html redirect for no-script conditions.
+ *
+ * @param url {string}
+ *
+ * @returns {string} Response body
+ */
+function redirectBody (url) {
+ return `
+
+
+
+Redirecting...
+If you are not redirected automatically,
+follow the link to login
+`
+}
+
+module.exports = {
+ handler,
+ redirectBody,
+ redirectToSelectProvider,
+ requiresSelectProvider,
+ sendErrorPage,
+ sendErrorResponse,
+ setAuthenticateHeader
+}
diff --git a/lib/handlers/get.js b/lib/handlers/get.js
index 7b565d617..1c43d958a 100644
--- a/lib/handlers/get.js
+++ b/lib/handlers/get.js
@@ -5,14 +5,13 @@ var glob = require('glob')
var _path = require('path')
var $rdf = require('rdflib')
var S = require('string')
-var async = require('async')
var Negotiator = require('negotiator')
const url = require('url')
const mime = require('mime-types')
var debug = require('debug')('solid:get')
var debugGlob = require('debug')('solid:glob')
-var acl = require('./allow')
+var allow = require('./allow')
var utils = require('../utils.js')
var translate = require('../utils.js').translate
@@ -138,7 +137,7 @@ function handler (req, res, next) {
function globHandler (req, res, next) {
var ldp = req.app.locals.ldp
- var root = !ldp.idp ? ldp.root : ldp.root + req.hostname + '/'
+ var root = !ldp.multiuser ? ldp.root : ldp.root + req.hostname + '/'
var filename = utils.uriToFilename(req.path, root)
var uri = utils.getFullUri(req)
const requestUri = url.resolve(uri, req.path)
@@ -161,35 +160,28 @@ function globHandler (req, res, next) {
let reqOrigin = utils.getBaseUri(req)
debugGlob('found matches ' + matches)
- async.each(matches, function (match, done) {
+ Promise.all(matches.map(match => new Promise((resolve, reject) => {
var baseUri = utils.filenameToBaseUri(match, reqOrigin, root)
fs.readFile(match, {encoding: 'utf8'}, function (err, fileData) {
if (err) {
debugGlob('error ' + err)
- return done(null)
+ return resolve()
}
aclAllow(match, req, res, function (allowed) {
if (!S(match).endsWith('.ttl') || !allowed) {
- return done(null)
+ return resolve()
}
try {
- $rdf.parse(
- fileData,
- globGraph,
- baseUri,
- 'text/turtle')
+ $rdf.parse(fileData, globGraph, baseUri, 'text/turtle')
} catch (parseErr) {
- debugGlob('error in parsing the files' + parseErr)
+ debugGlob(`error parsing ${match}: ${parseErr}`)
}
- return done(null)
+ return resolve()
})
})
- }, function () {
- var data = $rdf.serialize(
- undefined,
- globGraph,
- requestUri,
- 'text/turtle')
+ })))
+ .then(() => {
+ var data = $rdf.serialize(undefined, globGraph, requestUri, 'text/turtle')
// TODO this should be added as a middleware in the routes
res.setHeader('Content-Type', 'text/turtle')
debugGlob('returning turtle')
@@ -207,10 +199,10 @@ function aclAllow (match, req, res, callback) {
return callback(true)
}
- var root = ldp.idp ? ldp.root + req.hostname + '/' : ldp.root
+ var root = ldp.multiuser ? ldp.root + req.hostname + '/' : ldp.root
var relativePath = '/' + _path.relative(root, match)
res.locals.path = relativePath
- acl.allow('Read', req, res, function (err) {
+ allow('Read', req, res, function (err) {
callback(err)
})
}
diff --git a/lib/handlers/options.js b/lib/handlers/options.js
index 3a5694521..d6ba464bb 100644
--- a/lib/handlers/options.js
+++ b/lib/handlers/options.js
@@ -4,8 +4,28 @@ const utils = require('../utils')
module.exports = handler
function handler (req, res, next) {
+ linkServiceEndpoint(req, res)
+ linkAuthProvider(req, res)
+ linkSparqlEndpoint(res)
+
+ res.status(204)
+
+ next()
+}
+
+function linkAuthProvider (req, res) {
+ let locals = req.app.locals
+ if (locals.authMethod === 'oidc') {
+ let oidcProviderUri = locals.host.serverUri
+ addLink(res, oidcProviderUri, 'http://openid.net/specs/connect/1.0/issuer')
+ }
+}
+
+function linkServiceEndpoint (req, res) {
let serviceEndpoint = `${utils.getFullUri(req)}/.well-known/solid`
addLink(res, serviceEndpoint, 'service')
+}
+
+function linkSparqlEndpoint (res) {
res.header('Accept-Patch', 'application/sparql-update')
- next()
}
diff --git a/lib/handlers/patch.js b/lib/handlers/patch.js
index 16148f500..a5a320bcb 100644
--- a/lib/handlers/patch.js
+++ b/lib/handlers/patch.js
@@ -1,198 +1,159 @@
-module.exports = handler
-
-var mime = require('mime-types')
-var fs = require('fs')
-var $rdf = require('rdflib')
-var debug = require('../debug').handlers
-var utils = require('../utils.js')
-var error = require('../http-error')
-const waterfall = require('run-waterfall')
+// Express handler for LDP PATCH requests
-const DEFAULT_CONTENT_TYPE = 'text/turtle'
-
-function handler (req, res, next) {
- req.setEncoding('utf8')
- req.text = ''
- req.on('data', function (chunk) {
- req.text += chunk
- })
+module.exports = handler
- req.on('end', function () {
- patchHandler(req, res, next)
- })
+const bodyParser = require('body-parser')
+const mime = require('mime-types')
+const fs = require('fs')
+const debug = require('../debug').handlers
+const utils = require('../utils.js')
+const error = require('../http-error')
+const $rdf = require('rdflib')
+const crypto = require('crypto')
+
+const DEFAULT_TARGET_TYPE = 'text/turtle'
+
+// Patch parsers by request body content type
+const PATCH_PARSERS = {
+ 'application/sparql-update': require('./patch/sparql-update-parser.js'),
+ 'text/n3': require('./patch/n3-patch-parser.js')
}
+// Handles a PATCH request
function patchHandler (req, res, next) {
- var ldp = req.app.locals.ldp
- debug('PATCH -- ' + req.originalUrl)
- debug('PATCH -- text length: ' + (req.text ? req.text.length : 'undefined2'))
+ debug(`PATCH -- ${req.originalUrl}`)
res.header('MS-Author-Via', 'SPARQL')
- var root = !ldp.idp ? ldp.root : ldp.root + req.hostname + '/'
- var filename = utils.uriToFilename(req.path, root)
- var targetContentType = mime.lookup(filename) || DEFAULT_CONTENT_TYPE
- var patchContentType = req.get('content-type')
- ? req.get('content-type').split(';')[0].trim() // Ignore parameters
- : ''
- var targetURI = utils.getBaseUri(req) + req.originalUrl
-
- debug('PATCH -- Content-type ' + patchContentType + ' patching target ' + targetContentType + ' <' + targetURI + '>')
-
- if (patchContentType === 'application/sparql') {
- sparql(filename, targetURI, req.text, function (err, result) {
- if (err) {
- return next(err)
- }
- res.json(result)
- return next()
- })
- } else if (patchContentType === 'application/sparql-update') {
- return sparqlUpdate(filename, targetURI, req.text, function (err, patchKB) {
- if (err) {
- return next(err)
- }
-
- // subscription.publishDelta(req, res, patchKB, targetURI)
- debug('PATCH -- applied OK (sync)')
- res.send('Patch applied OK\n')
- return next()
- })
- } else {
- return next(error(400, 'Unknown patch content type: ' + patchContentType))
+ // Obtain details of the target resource
+ const ldp = req.app.locals.ldp
+ const root = !ldp.multiuser ? ldp.root : `${ldp.root}${req.hostname}/`
+ const target = {}
+ target.file = utils.uriToFilename(req.path, root)
+ target.uri = utils.getBaseUri(req) + req.originalUrl
+ target.contentType = mime.lookup(target.file) || DEFAULT_TARGET_TYPE
+ debug('PATCH -- Target <%s> (%s)', target.uri, target.contentType)
+
+ // Obtain details of the patch document
+ const patch = {}
+ patch.text = req.body ? req.body.toString() : ''
+ patch.uri = `${target.uri}#patch-${hash(patch.text)}`
+ patch.contentType = (req.get('content-type') || '').match(/^[^;\s]*/)[0]
+ debug('PATCH -- Received patch (%d bytes, %s)', patch.text.length, patch.contentType)
+ const parsePatch = PATCH_PARSERS[patch.contentType]
+ if (!parsePatch) {
+ return next(error(415, `Unsupported patch content type: ${patch.contentType}`))
}
-} // postOrPatch
-
-function sparql (filename, targetURI, text, callback) {
- debug('PATCH -- parsing query ...')
- var patchURI = targetURI // @@@ beware the triples from the patch ending up in the same place
- var patchKB = $rdf.graph()
- var targetKB = $rdf.graph()
- var targetContentType = mime.lookup(filename) || DEFAULT_CONTENT_TYPE
- var query = $rdf.SPARQLToQuery(text, false, patchKB, patchURI) // last param not used ATM
-
- fs.readFile(filename, {encoding: 'utf8'}, function (err, dataIn) {
- if (err) {
- return callback(error(404, 'Patch: Original file read error:' + err))
- }
- debug('PATCH -- File read OK ' + dataIn.length)
- debug('PATCH -- parsing target file ...')
-
- try {
- $rdf.parse(dataIn, targetKB, targetURI, targetContentType)
- } catch (e) {
- debug('Patch: Target ' + targetContentType + ' file syntax error:' + e)
- return callback(error(500, 'Patch: Target ' + targetContentType + ' file syntax error:' + e))
- }
- debug('PATCH -- Target parsed OK ')
+ // Parse the target graph and the patch document,
+ // and verify permission for performing this specific patch
+ Promise.all([
+ readGraph(target),
+ parsePatch(target.uri, patch.uri, patch.text)
+ .then(patchObject => checkPermission(target, req, patchObject))
+ ])
+ // Patch the graph and write it back to the file
+ .then(([graph, patchObject]) => applyPatch(patchObject, graph, target))
+ .then(graph => writeGraph(graph, target))
+ // Send the result to the client
+ .then(result => { res.send(result) })
+ .then(next, next)
+}
- var bindingsArray = []
+// Reads the request body and calls the actual patch handler
+function handler (req, res, next) {
+ readEntity(req, res, () => patchHandler(req, res, next))
+}
+const readEntity = bodyParser.text({ type: () => true })
- var onBindings = function (bindings) {
- var b = {}
- var v
- var x
- for (v in bindings) {
- if (bindings.hasOwnProperty(v)) {
- x = bindings[v]
- b[v] = x.uri ? {'type': 'uri', 'value': x.uri} : { 'type': 'literal', 'value': x.value }
- if (x.lang) {
- b[v]['xml:lang'] = x.lang
- }
- if (x.dt) {
- b[v].dt = x.dt.uri // @@@ Correct? @@ check
- }
+// Reads the RDF graph in the given resource
+function readGraph (resource) {
+ // Read the resource's file
+ return new Promise((resolve, reject) =>
+ fs.readFile(resource.file, {encoding: 'utf8'}, function (err, fileContents) {
+ if (err) {
+ // If the file does not exist, assume empty contents
+ // (it will be created after a successful patch)
+ if (err.code === 'ENOENT') {
+ fileContents = ''
+ // Fail on all other errors
+ } else {
+ return reject(error(500, `Original file read error: ${err}`))
}
}
- debug('PATCH -- bindings: ' + JSON.stringify(b))
- bindingsArray.push(b)
- }
-
- var onDone = function () {
- debug('PATCH -- Query done, no. bindings: ' + bindingsArray.length)
- return callback(null, {
- 'head': {
- 'vars': query.vars.map(function (v) {
- return v.toNT()
- })
- },
- 'results': {
- 'bindings': bindingsArray
- }
- })
+ debug('PATCH -- Read target file (%d bytes)', fileContents.length)
+ resolve(fileContents)
+ })
+ )
+ // Parse the resource's file contents
+ .then((fileContents) => {
+ const graph = $rdf.graph()
+ debug('PATCH -- Reading %s with content type %s', resource.uri, resource.contentType)
+ try {
+ $rdf.parse(fileContents, graph, resource.uri, resource.contentType)
+ } catch (err) {
+ throw error(500, `Patch: Target ${resource.contentType} file syntax error: ${err}`)
}
-
- var fetcher = new $rdf.Fetcher(targetKB, 10000, true)
- targetKB.query(query, onBindings, fetcher, onDone)
+ debug('PATCH -- Parsed target file')
+ return graph
})
}
-function sparqlUpdate (filename, targetURI, text, callback) {
- var patchURI = targetURI // @@@ beware the triples from the patch ending up in the same place
- var patchKB = $rdf.graph()
- var targetKB = $rdf.graph()
- var targetContentType = mime.lookup(filename) || DEFAULT_CONTENT_TYPE
-
- debug('PATCH -- parsing patch ...')
- var patchObject
- try {
- // Must parse relative to document's base address but patch doc should get diff URI
- patchObject = $rdf.sparqlUpdateParser(text, patchKB, patchURI)
- } catch (e) {
- return callback(error(400, 'Patch format syntax error:\n' + e + '\n'))
+// Verifies whether the user is allowed to perform the patch on the target
+function checkPermission (target, request, patchObject) {
+ // If no ACL object was passed down, assume permissions are okay.
+ if (!request.acl) return Promise.resolve(patchObject)
+ // At this point, we already assume append access,
+ // as this can be checked upfront before parsing the patch.
+ // Now that we know the details of the patch,
+ // we might need to perform additional checks.
+ let checks = []
+ const { acl, session: { userId } } = request
+ // Read access is required for DELETE and WHERE.
+ // If we would allows users without read access,
+ // they could use DELETE or WHERE to trigger 200 or 409,
+ // and thereby guess the existence of certain triples.
+ // DELETE additionally requires write access.
+ if (patchObject.delete) {
+ checks = [acl.can(userId, 'Read'), acl.can(userId, 'Write')]
+ } else if (patchObject.where) {
+ checks = [acl.can(userId, 'Read')]
}
- debug('PATCH -- reading target file ...')
-
- waterfall([
- (cb) => {
- fs.stat(filename, (err) => {
- if (!err) return cb()
-
- fs.writeFile(filename, '', (err) => {
- if (err) {
- return cb(error(err, 'Error creating the patch target'))
- }
- cb()
- })
- })
- },
- (cb) => {
- fs.readFile(filename, {encoding: 'utf8'}, function (err, dataIn) {
- if (err) {
- return cb(error(500, 'Error reading the patch target'))
- }
-
- debug('PATCH -- target read OK ' + dataIn.length + ' bytes. Parsing...')
+ return Promise.all(checks).then(() => patchObject)
+}
- try {
- $rdf.parse(dataIn, targetKB, targetURI, targetContentType)
- } catch (e) {
- debug('Patch: Target ' + targetContentType + ' file syntax error:' + e)
- return cb(error(500, 'Patch: Target ' + targetContentType + ' file syntax error:' + e))
- }
+// Applies the patch to the RDF graph
+function applyPatch (patchObject, graph, target) {
+ debug('PATCH -- Applying patch')
+ return new Promise((resolve, reject) =>
+ graph.applyPatch(patchObject, graph.sym(target.uri), (err) => {
+ if (err) {
+ const message = err.message || err // returns string at the moment
+ debug(`PATCH -- FAILED. Returning 409. Message: '${message}'`)
+ return reject(error(409, `The patch could not be applied. ${message}`))
+ }
+ resolve(graph)
+ })
+ )
+}
- var target = patchKB.sym(targetURI)
- debug('PATCH -- Target parsed OK, patching... ')
+// Writes the RDF graph to the given resource
+function writeGraph (graph, resource) {
+ debug('PATCH -- Writing patched file')
+ return new Promise((resolve, reject) => {
+ const resourceSym = graph.sym(resource.uri)
+ const serialized = $rdf.serialize(resourceSym, graph, resource.uri, resource.contentType)
- targetKB.applyPatch(patchObject, target, function (err) {
- if (err) {
- var message = err.message || err // returns string at the moment
- debug('PATCH FAILED. Returning 409. Message: \'' + message + '\'')
- return cb(error(409, 'Error when applying the patch'))
- }
- debug('PATCH -- Patched. Writeback URI base ' + targetURI)
- var data = $rdf.serialize(target, targetKB, targetURI, targetContentType)
- // debug('Writeback data: ' + data)
+ fs.writeFile(resource.file, serialized, {encoding: 'utf8'}, function (err) {
+ if (err) {
+ return reject(error(500, `Failed to write file after patch: ${err}`))
+ }
+ debug('PATCH -- applied successfully')
+ resolve('Patch applied successfully.\n')
+ })
+ })
+}
- fs.writeFile(filename, data, {encoding: 'utf8'}, function (err, data) {
- if (err) {
- return cb(error(500, 'Failed to write file back after patch: ' + err))
- }
- debug('PATCH -- applied OK (sync)')
- return cb(null, patchKB)
- })
- })
- })
- }
- ], callback)
+// Creates a hash of the given text
+function hash (text) {
+ return crypto.createHash('md5').update(text).digest('hex')
}
diff --git a/lib/handlers/patch/n3-patch-parser.js b/lib/handlers/patch/n3-patch-parser.js
new file mode 100644
index 000000000..91df7c5b6
--- /dev/null
+++ b/lib/handlers/patch/n3-patch-parser.js
@@ -0,0 +1,48 @@
+// Parses a text/n3 patch
+
+module.exports = parsePatchDocument
+
+const $rdf = require('rdflib')
+const error = require('../../http-error')
+
+const PATCH_NS = 'http://www.w3.org/ns/solid/terms#'
+const PREFIXES = `PREFIX solid: <${PATCH_NS}>\n`
+
+// Parses the given N3 patch document
+function parsePatchDocument (targetURI, patchURI, patchText) {
+ // Parse the N3 document into triples
+ return new Promise((resolve, reject) => {
+ const patchGraph = $rdf.graph()
+ $rdf.parse(patchText, patchGraph, patchURI, 'text/n3')
+ resolve(patchGraph)
+ })
+ .catch(err => { throw error(400, `Patch document syntax error: ${err}`) })
+
+ // Query the N3 document for insertions and deletions
+ .then(patchGraph => queryForFirstResult(patchGraph, `${PREFIXES}
+ SELECT ?insert ?delete ?where WHERE {
+ ?patch solid:patches <${targetURI}>.
+ OPTIONAL { ?patch solid:inserts ?insert. }
+ OPTIONAL { ?patch solid:deletes ?delete. }
+ OPTIONAL { ?patch solid:where ?where. }
+ }`)
+ .catch(err => { throw error(400, `No patch for ${targetURI} found.`, err) })
+ )
+
+ // Return the insertions and deletions as an rdflib patch document
+ .then(result => {
+ const {'?insert': insert, '?delete': deleted, '?where': where} = result
+ if (!insert && !deleted) {
+ throw error(400, 'Patch should at least contain inserts or deletes.')
+ }
+ return {insert, delete: deleted, where}
+ })
+}
+
+// Queries the store with the given SPARQL query and returns the first result
+function queryForFirstResult (store, sparql) {
+ return new Promise((resolve, reject) => {
+ const query = $rdf.SPARQLToQuery(sparql, false, store)
+ store.query(query, resolve, null, () => reject(new Error('No results.')))
+ })
+}
diff --git a/lib/handlers/patch/sparql-update-parser.js b/lib/handlers/patch/sparql-update-parser.js
new file mode 100644
index 000000000..365e9c82d
--- /dev/null
+++ b/lib/handlers/patch/sparql-update-parser.js
@@ -0,0 +1,18 @@
+// Parses an application/sparql-update patch
+
+module.exports = parsePatchDocument
+
+const $rdf = require('rdflib')
+const error = require('../../http-error')
+
+// Parses the given SPARQL UPDATE document
+function parsePatchDocument (targetURI, patchURI, patchText) {
+ return new Promise((resolve, reject) => {
+ const baseURI = patchURI.replace(/#.*/, '')
+ try {
+ resolve($rdf.sparqlUpdateParser(patchText, $rdf.graph(), baseURI))
+ } catch (err) {
+ reject(error(400, `Patch document syntax error: ${err}`))
+ }
+ })
+}
diff --git a/lib/handlers/proxy.js b/lib/handlers/proxy.js
deleted file mode 100644
index e4049e9a4..000000000
--- a/lib/handlers/proxy.js
+++ /dev/null
@@ -1,85 +0,0 @@
-module.exports = addProxy
-
-const cors = require('cors')
-const http = require('http')
-const https = require('https')
-const debug = require('../debug')
-const url = require('url')
-const isIp = require('is-ip')
-const ipRange = require('ip-range-check')
-const validUrl = require('valid-url')
-
-function addProxy (app, path) {
- debug.settings('XSS/CORS Proxy listening at /' + path + '?uri={uri}')
- app.get(
- path,
- cors({
- methods: ['GET'],
- exposedHeaders: 'User, Location, Link, Vary, Last-Modified, Content-Length',
- maxAge: 1728000,
- origin: true
- }),
- (req, res) => {
- if (!validUrl.isUri(req.query.uri)) {
- return res
- .status(406)
- .send('The uri passed is not valid')
- }
-
- debug.settings('proxy received: ' + req.originalUrl)
-
- const hostname = url.parse(req.query.uri).hostname
-
- if (isIp(hostname) && (
- ipRange(hostname, '10.0.0.0/8') ||
- ipRange(hostname, '172.16.0.0/12') ||
- ipRange(hostname, '192.168.0.0/16')
- )) {
- return res
- .status(406)
- .send('Cannot proxy this IP')
- }
- const uri = req.query.uri
- if (!uri) {
- return res
- .status(400)
- .send('Proxy has no uri param ')
- }
-
- debug.settings('Proxy destination URI: ' + uri)
-
- const protocol = uri.split(':')[0]
- let request
- if (protocol === 'http') {
- request = http.get
- } else if (protocol === 'https') {
- request = https.get
- } else {
- return res.send(400)
- }
-
- // Set the headers and uri of the proxied request
- const opts = url.parse(uri)
- opts.headers = req.headers
- // See https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html
- delete opts.headers.connection
- delete opts.headers.host
-
- const _req = request(opts, (_res) => {
- res.status(_res.statusCode)
- // Set the response with the same header of proxied response
- Object.keys(_res.headers).forEach((header) => {
- if (!res.get(header)) {
- res.setHeader(header.trim(), _res.headers[header])
- }
- })
- _res.pipe(res)
- })
-
- _req.on('error', (e) => {
- res.send(500, 'Cannot proxy')
- })
-
- _req.end()
- })
-}
diff --git a/lib/header.js b/lib/header.js
index 4523acb52..c46350bbf 100644
--- a/lib/header.js
+++ b/lib/header.js
@@ -2,6 +2,7 @@ module.exports.addLink = addLink
module.exports.addLinks = addLinks
module.exports.parseMetadataFromHeader = parseMetadataFromHeader
module.exports.linksHandler = linksHandler
+module.exports.addPermissions = addPermissions
var li = require('li')
var path = require('path')
@@ -11,6 +12,9 @@ var debug = require('./debug.js')
var utils = require('./utils.js')
var error = require('./http-error')
+const MODES = ['Read', 'Write', 'Append', 'Control']
+const PERMISSIONS = MODES.map(m => m.toLowerCase())
+
function addLink (res, value, rel) {
var oldLink = res.get('Link')
if (oldLink === undefined) {
@@ -40,7 +44,7 @@ function addLinks (res, fileMetadata) {
function linksHandler (req, res, next) {
var ldp = req.app.locals.ldp
- var root = !ldp.idp ? ldp.root : ldp.root + req.hostname + '/'
+ var root = !ldp.multiuser ? ldp.root : ldp.root + req.hostname + '/'
var filename = utils.uriToFilename(req.url, root)
filename = path.join(filename, req.path)
@@ -95,3 +99,28 @@ function parseMetadataFromHeader (linkHeader) {
}
return fileMetadata
}
+
+// Adds a header that describes the user's permissions
+function addPermissions (req, res, next) {
+ const { acl, session } = req
+ if (!acl) return next()
+
+ // Turn permissions for the public and the user into a header
+ const resource = utils.getFullUri(req)
+ Promise.all([
+ getPermissionsFor(acl, null, resource),
+ getPermissionsFor(acl, session.userId, resource)
+ ])
+ .then(([publicPerms, userPerms]) => {
+ debug.ACL(`Permissions for ${session.userId || '(none)'}: ${userPerms}`)
+ debug.ACL(`Permissions for public: ${publicPerms}`)
+ res.set('WAC-Allow', `user="${userPerms}",public="${publicPerms}"`)
+ })
+ .then(next, next)
+}
+
+// Gets the permissions string for the given user and resource
+function getPermissionsFor (acl, user, resource) {
+ return Promise.all(MODES.map(mode => acl.can(user, mode).catch(e => false)))
+ .then(allowed => PERMISSIONS.filter((_, i) => allowed[i]).join(' '))
+}
diff --git a/lib/ldp-middleware.js b/lib/ldp-middleware.js
index 88dd6475c..f9135fc15 100644
--- a/lib/ldp-middleware.js
+++ b/lib/ldp-middleware.js
@@ -2,13 +2,11 @@ module.exports = LdpMiddleware
var express = require('express')
var header = require('./header')
-var acl = require('./handlers/allow')
-var authentication = require('./handlers/authentication')
+var allow = require('./handlers/allow')
var get = require('./handlers/get')
var post = require('./handlers/post')
var put = require('./handlers/put')
var del = require('./handlers/delete')
-var options = require('./handlers/options')
var patch = require('./handlers/patch')
var index = require('./handlers/index')
var copy = require('./handlers/copy')
@@ -19,26 +17,16 @@ function LdpMiddleware (corsSettings) {
// Add Link headers
router.use(header.linksHandler)
- // TODO edit cors
- // router.use((req, res, next) => {
- // edit cors according to ACL
- // })
if (corsSettings) {
router.use(corsSettings)
}
- router.use('/*', authentication)
- router.copy('/*', acl.allow('Write'), copy)
- router.get('/*', index, acl.allow('Read'), get)
- router.post('/*', acl.allow('Append'), post)
- router.patch('/*', acl.allow('Write'), patch)
- router.put('/*', acl.allow('Write'), put)
- router.delete('/*', acl.allow('Write'), del)
- router.options('/*', options)
-
- // TODO: in the process of being deprecated
- // Convert json-ld and nquads to turtle
- // router.use('/*', parse.parseHandler)
+ router.copy('/*', allow('Write'), copy)
+ router.get('/*', index, allow('Read'), header.addPermissions, get)
+ router.post('/*', allow('Append'), post)
+ router.patch('/*', allow('Append'), patch)
+ router.put('/*', allow('Write'), put)
+ router.delete('/*', allow('Write'), del)
return router
}
diff --git a/lib/ldp.js b/lib/ldp.js
index 3811896dc..368b0997f 100644
--- a/lib/ldp.js
+++ b/lib/ldp.js
@@ -3,7 +3,6 @@ var path = require('path')
const url = require('url')
var fs = require('fs')
var $rdf = require('rdflib')
-var async = require('async')
// var url = require('url')
var mkdirp = require('fs-extra').mkdirp
var uuid = require('uuid')
@@ -13,7 +12,6 @@ var error = require('./http-error')
var stringToStream = require('./utils').stringToStream
var serialize = require('./utils').serialize
var extend = require('extend')
-var doWhilst = require('async').doWhilst
var rimraf = require('rimraf')
var ldpContainer = require('./ldp-container')
var parse = require('./utils').parse
@@ -75,20 +73,20 @@ class LDP {
this.skin = true
}
- if (this.webid && !this.auth) {
- this.auth = 'tls'
- }
-
- if (this.proxy && this.proxy[ 0 ] !== '/') {
- this.proxy = '/' + this.proxy
+ if (this.corsProxy && this.corsProxy[ 0 ] !== '/') {
+ this.corsProxy = '/' + this.corsProxy
}
+ debug.settings('Server URI: ' + this.serverUri)
+ debug.settings('Auth method: ' + this.auth)
+ debug.settings('Db path: ' + this.dbPath)
+ debug.settings('Config path: ' + this.configPath)
debug.settings('Suffix Acl: ' + this.suffixAcl)
debug.settings('Suffix Meta: ' + this.suffixMeta)
debug.settings('Filesystem Root: ' + this.root)
debug.settings('Allow WebID authentication: ' + !!this.webid)
debug.settings('Live-updates: ' + !!this.live)
- debug.settings('Identity Provider: ' + !!this.idp)
+ debug.settings('Multi-user: ' + !!this.multiuser)
debug.settings('Default file browser app: ' + this.fileBrowser)
debug.settings('Suppress default data browser app: ' + this.suppressDataBrowser)
debug.settings('Default data browser app file path: ' + this.dataBrowserPath)
@@ -144,7 +142,7 @@ class LDP {
listContainer (filename, reqUri, uri, containerData, contentType, callback) {
var ldp = this
// var host = url.parse(uri).hostname
- // var root = !ldp.idp ? ldp.root : ldp.root + host + '/'
+ // var root = !ldp.multiuser ? ldp.root : ldp.root + host + '/'
// var baseUri = utils.filenameToBaseUri(filename, uri, root)
var resourceGraph = $rdf.graph()
@@ -156,42 +154,40 @@ class LDP {
return callback(error(500, "Can't parse container"))
}
- async.waterfall(
- [
- // add container stats
- function (next) {
- ldpContainer.addContainerStats(ldp, reqUri, filename, resourceGraph, next)
- },
- // reading directory
- function (next) {
- ldpContainer.readdir(filename, next)
- },
- // Iterate through all the files
- function (files, next) {
- async.each(
- files,
- function (file, cb) {
- let fileUri = url.resolve(reqUri, encodeURIComponent(file))
- ldpContainer.addFile(ldp, resourceGraph, reqUri, fileUri, uri,
- filename, file, cb)
- },
- next)
- }
- ],
- function (err, data) {
+ // add container stats
+ new Promise((resolve, reject) =>
+ ldpContainer.addContainerStats(ldp, reqUri, filename, resourceGraph,
+ err => err ? reject(err) : resolve())
+ )
+ // read directory
+ .then(() => new Promise((resolve, reject) =>
+ ldpContainer.readdir(filename,
+ (err, files) => err ? reject(err) : resolve(files))
+ ))
+ // iterate through all the files
+ .then(files => {
+ return Promise.all(files.map(file =>
+ new Promise((resolve, reject) => {
+ const fileUri = url.resolve(reqUri, encodeURIComponent(file))
+ ldpContainer.addFile(ldp, resourceGraph, reqUri, fileUri, uri,
+ filename, file, err => err ? reject(err) : resolve())
+ })
+ ))
+ })
+ .catch(() => { throw error(500, "Can't list container") })
+ .then(() => new Promise((resolve, reject) => {
+ // TODO 'text/turtle' is fixed, should be contentType instead
+ // This forces one more translation turtle -> desired
+ serialize(resourceGraph, reqUri, 'text/turtle', function (err, result) {
if (err) {
- return callback(error(500, "Can't list container"))
+ debug.handlers('GET -- Error serializing container: ' + err)
+ reject(error(500, "Can't serialize container"))
+ } else {
+ resolve(result)
}
- // TODO 'text/turtle' is fixed, should be contentType instead
- // This forces one more translation turtle -> desired
- serialize(resourceGraph, reqUri, 'text/turtle', function (err, result) {
- if (err) {
- debug.handlers('GET -- Error serializing container: ' + err)
- return callback(error(500, "Can't serialize container"))
- }
- return callback(null, result)
- })
})
+ }))
+ .then(result => callback(null, result), callback)
}
post (hostname, containerPath, slug, stream, container, callback) {
@@ -263,7 +259,7 @@ class LDP {
put (host, resourcePath, stream, callback) {
var ldp = this
- var root = !ldp.idp ? ldp.root : ldp.root + host + '/'
+ var root = !ldp.multiuser ? ldp.root : ldp.root + host + '/'
var filePath = utils.uriToFilename(resourcePath, root, host)
// PUT requests not supported on containers. Use POST instead
@@ -348,21 +344,13 @@ class LDP {
baseUri = undefined
}
- var root = ldp.idp ? ldp.root + host + '/' : ldp.root
+ var root = ldp.multiuser ? ldp.root + host + '/' : ldp.root
var filename = utils.uriToFilename(reqPath, root)
- async.waterfall([
- // Read file
- function (cb) {
- return ldp.readFile(filename, cb)
- },
- // Parse file
- function (body, cb) {
- parse(body, baseUri, contentType, function (err, graph) {
- cb(err, graph)
- })
- }
- ], callback)
+ ldp.readFile(filename, (err, body) => {
+ if (err) return callback(err)
+ parse(body, baseUri, contentType, callback)
+ })
}
get (options, callback) {
@@ -375,7 +363,7 @@ class LDP {
var range = options.range
}
var ldp = this
- var root = !ldp.idp ? ldp.root : ldp.root + host + '/'
+ var root = !ldp.multiuser ? ldp.root : ldp.root + host + '/'
var filename = utils.uriToFilename(reqPath, root)
ldp.stat(filename, function (err, stats) {
@@ -395,7 +383,7 @@ class LDP {
if (err) {
metaFile = ''
}
- let absContainerUri = url.resolve(baseUri, reqPath)
+ let absContainerUri = baseUri + reqPath
ldp.listContainer(filename, absContainerUri, baseUri, metaFile, contentType,
function (err, data) {
if (err) {
@@ -442,7 +430,7 @@ class LDP {
delete (host, resourcePath, callback) {
var ldp = this
- var root = !ldp.idp ? ldp.root : ldp.root + host + '/'
+ var root = !ldp.multiuser ? ldp.root : ldp.root + host + '/'
var filename = utils.uriToFilename(resourcePath, root)
ldp.stat(filename, function (err, stats) {
if (err) {
@@ -498,34 +486,25 @@ class LDP {
getAvailablePath (host, containerURI, slug, callback) {
var self = this
- var exists
-
- if (!slug) {
- slug = uuid.v1()
- }
-
- var newPath = path.join(containerURI, slug)
+ slug = slug || uuid.v1()
- // TODO: maybe a nicer code
- doWhilst(
- function (next) {
+ function ensureNotExists (newPath) {
+ return new Promise(resolve => {
self.exists(host, newPath, function (err) {
- exists = !err
-
- if (exists) {
- var id = uuid.v1().split('-')[ 0 ] + '-'
+ // If an error occurred, the resource does not exist yet
+ if (err) {
+ resolve(newPath)
+ // Otherwise, generate a new path
+ } else {
+ const id = uuid.v1().split('-')[ 0 ] + '-'
newPath = path.join(containerURI, id + slug)
+ resolve(ensureNotExists(newPath))
}
-
- next()
})
- },
- function () {
- return exists === true
- },
- function () {
- callback(newPath)
})
+ }
+
+ return ensureNotExists(path.join(containerURI, slug)).then(callback)
}
}
module.exports = LDP
diff --git a/lib/models/account-manager.js b/lib/models/account-manager.js
index 5bbcc4f68..6793bdd58 100644
--- a/lib/models/account-manager.js
+++ b/lib/models/account-manager.js
@@ -11,6 +11,7 @@ const AccountTemplate = require('./account-template')
const debug = require('./../debug').accounts
const DEFAULT_PROFILE_CONTENT_TYPE = 'text/turtle'
+const DEFAULT_ADMIN_USERNAME = 'admin'
/**
* Manages account creation (determining whether accounts exist, creating
@@ -22,11 +23,12 @@ class AccountManager {
/**
* @constructor
* @param [options={}] {Object}
- * @param [options.authMethod] {string} Primary authentication method (e.g. 'tls')
+ * @param [options.authMethod] {string} Primary authentication method (e.g. 'oidc')
* @param [options.emailService] {EmailService}
+ * @param [options.tokenService] {TokenService}
* @param [options.host] {SolidHost}
- * @param [options.multiUser=false] {boolean} (argv.idp) Is the server running
- * in multiUser mode (users can sign up for accounts) or single user
+ * @param [options.multiuser=false] {boolean} (argv.multiuser) Is the server running
+ * in multiuser mode (users can sign up for accounts) or single user
* (such as a personal website).
* @param [options.store] {LDP}
* @param [options.pathCard] {string}
@@ -41,19 +43,20 @@ class AccountManager {
}
this.host = options.host
this.emailService = options.emailService
- this.authMethod = options.authMethod || defaults.AUTH_METHOD
- this.multiUser = options.multiUser || false
+ this.tokenService = options.tokenService
+ this.authMethod = options.authMethod || defaults.auth
+ this.multiuser = options.multiuser || false
this.store = options.store
this.pathCard = options.pathCard || 'profile/card'
this.suffixURI = options.suffixURI || '#me'
- this.accountTemplatePath = options.accountTemplatePath || './default-account-template/'
+ this.accountTemplatePath = options.accountTemplatePath || './default-templates/new-account/'
}
/**
* Factory method for new account manager creation. Usage:
*
* ```
- * let options = { host, multiUser, store }
+ * let options = { host, multiuser, store }
* let accontManager = AccountManager.from(options)
* ```
*
@@ -134,7 +137,7 @@ class AccountManager {
accountDirFor (accountName) {
let accountDir
- if (this.multiUser) {
+ if (this.multiuser) {
let uri = this.accountUriFor(accountName)
let hostname = url.parse(uri).hostname
accountDir = path.join(this.store.root, hostname)
@@ -162,11 +165,11 @@ class AccountManager {
* @param [accountName] {string}
*
* @throws {Error} If `this.host` has not been initialized with serverUri,
- * or if in multiUser mode and accountName is not provided.
+ * or if in multiuser mode and accountName is not provided.
* @return {string}
*/
accountUriFor (accountName) {
- let accountUri = this.multiUser
+ let accountUri = this.multiuser
? this.host.accountUriFor(accountName)
: this.host.serverUri // single user mode
@@ -190,7 +193,8 @@ class AccountManager {
* @param [accountName] {string}
*
* @throws {Error} via accountUriFor()
- * @return {string}
+ *
+ * @return {string|null}
*/
accountWebIdFor (accountName) {
let accountUri = this.accountUriFor(accountName)
@@ -200,6 +204,22 @@ class AccountManager {
return webIdUri.format()
}
+ /**
+ * Returns the root .acl URI for a given user account (the account recovery
+ * email is stored there).
+ *
+ * @param userAccount {UserAccount}
+ *
+ * @throws {Error} via accountUriFor()
+ *
+ * @return {string} Root .acl URI
+ */
+ rootAclFor (userAccount) {
+ let accountUri = this.accountUriFor(userAccount.username)
+
+ return url.resolve(accountUri, this.store.suffixAcl)
+ }
+
/**
* Adds a newly generated WebID-TLS certificate to the user's profile graph.
*
@@ -209,6 +229,10 @@ class AccountManager {
* @return {Promise}
*/
addCertKeyToProfile (certificate, userAccount) {
+ if (!certificate) {
+ throw new TypeError('Cannot add empty certificate to user profile')
+ }
+
return this.getProfileGraphFor(userAccount)
.then(profileGraph => {
return this.addCertKeyToGraph(certificate, profileGraph)
@@ -302,9 +326,16 @@ class AccountManager {
* Creates and returns a `UserAccount` instance from submitted user data
* (typically something like `req.body`, from a signup form).
*
- * @param userData {Object} Options hashmap, like `req.body`
+ * @param userData {Object} Options hashmap, like `req.body`.
+ * Either a `username` or a `webid` property is required.
+ *
+ * @param [userData.username] {string}
+ * @param [uesrData.webid] {string}
+ *
+ * @param [userData.email] {string}
+ * @param [userData.name] {string}
*
- * @throws {Error} (via `accountWebIdFor()`) If in multiUser mode and no
+ * @throws {Error} (via `accountWebIdFor()`) If in multiuser mode and no
* username passed
*
* @return {UserAccount}
@@ -313,12 +344,50 @@ class AccountManager {
let userConfig = {
username: userData.username,
email: userData.email,
- name: userData.name
+ name: userData.name,
+ externalWebId: userData.externalWebId,
+ localAccountId: userData.localAccountId,
+ webId: userData.webid || userData.webId || userData.externalWebId
}
- userConfig.webId = userData.webid || this.accountWebIdFor(userData.username)
+
+ try {
+ userConfig.webId = userConfig.webId || this.accountWebIdFor(userConfig.username)
+ } catch (err) {
+ if (err.message === 'Cannot construct uri for blank account name') {
+ throw new Error('Username or web id is required')
+ } else {
+ throw err
+ }
+ }
+
+ if (userConfig.username) {
+ if (userConfig.externalWebId && !userConfig.localAccountId) {
+ // External Web ID exists, derive the local account id from username
+ userConfig.localAccountId = this.accountWebIdFor(userConfig.username)
+ .split('//')[1] // drop the https://
+ }
+ } else { // no username - derive it from web id
+ if (userConfig.externalWebId) {
+ userConfig.username = userConfig.externalWebId
+ } else {
+ userConfig.username = this.usernameFromWebId(userConfig.webId)
+ }
+ }
+
return UserAccount.from(userConfig)
}
+ usernameFromWebId (webId) {
+ if (!this.multiuser) {
+ return DEFAULT_ADMIN_USERNAME
+ }
+
+ let profileUrl = url.parse(webId)
+ let hostname = profileUrl.hostname
+
+ return hostname.split('.')[0]
+ }
+
/**
* Creates a user account storage folder (from a default account template).
*
@@ -340,6 +409,113 @@ class AccountManager {
})
}
+ /**
+ * Generates an expiring one-time-use token for password reset purposes
+ * (the user's Web ID is saved in the token service).
+ *
+ * @param userAccount {UserAccount}
+ *
+ * @return {string} Generated token
+ */
+ generateResetToken (userAccount) {
+ return this.tokenService.generate({ webId: userAccount.webId })
+ }
+
+ /**
+ * Validates that a token exists and is not expired, and returns the saved
+ * token contents, or throws an error if invalid.
+ * Does not consume / clear the token.
+ *
+ * @param token {string}
+ *
+ * @throws {Error} If missing or invalid token
+ *
+ * @return {Object|false} Saved token data object if verified, false otherwise
+ */
+ validateResetToken (token) {
+ let tokenValue = this.tokenService.verify(token)
+
+ if (!tokenValue) {
+ throw new Error('Invalid or expired reset token')
+ }
+
+ return tokenValue
+ }
+
+ /**
+ * Returns a password reset URL (to be emailed to the user upon request)
+ *
+ * @param token {string} One-time-use expiring token, via the TokenService
+ * @param returnToUrl {string}
+ *
+ * @return {string}
+ */
+ passwordResetUrl (token, returnToUrl) {
+ let resetUrl = url.resolve(this.host.serverUri,
+ `/account/password/change?token=${token}`)
+
+ if (returnToUrl) {
+ resetUrl += `&returnToUrl=${returnToUrl}`
+ }
+
+ return resetUrl
+ }
+
+ /**
+ * Parses and returns an account recovery email stored in a user's root .acl
+ *
+ * @param userAccount {UserAccount}
+ *
+ * @return {Promise}
+ */
+ loadAccountRecoveryEmail (userAccount) {
+ return Promise.resolve()
+ .then(() => {
+ let rootAclUri = this.rootAclFor(userAccount)
+
+ return this.store.getGraph(rootAclUri)
+ })
+ .then(rootAclGraph => {
+ let matches = rootAclGraph.match(null, ns.acl('agent'))
+
+ let recoveryMailto = matches.find(agent => {
+ return agent.object.value.startsWith('mailto:')
+ })
+
+ if (recoveryMailto) {
+ recoveryMailto = recoveryMailto.object.value.replace('mailto:', '')
+ }
+
+ return recoveryMailto
+ })
+ }
+
+ sendPasswordResetEmail (userAccount, returnToUrl) {
+ return Promise.resolve()
+ .then(() => {
+ if (!this.emailService) {
+ throw new Error('Email service is not set up')
+ }
+
+ if (!userAccount.email) {
+ throw new Error('Account recovery email has not been provided')
+ }
+
+ return this.generateResetToken(userAccount)
+ })
+ .then(resetToken => {
+ let resetUrl = this.passwordResetUrl(resetToken, returnToUrl)
+
+ let emailData = {
+ to: userAccount.email,
+ webId: userAccount.webId,
+ resetUrl
+ }
+
+ return this.emailService.sendWithTemplate('reset-password', emailData)
+ })
+ }
+
/**
* Sends a Welcome email (on new user signup).
*
diff --git a/lib/models/account-template.js b/lib/models/account-template.js
index af9956ca0..cb6ee37aa 100644
--- a/lib/models/account-template.js
+++ b/lib/models/account-template.js
@@ -12,7 +12,7 @@ const TEMPLATE_FILES = [ 'card' ]
/**
* Performs account folder initialization from an account template
- * (see `./default-account-template/`, for example).
+ * (see `./default-templates/new-account/`, for example).
*
* @class AccountTemplate
*/
@@ -171,8 +171,17 @@ class AccountTemplate {
* @return {string} Result, e.g. 'Hello, Alice'
*/
processTemplate (source) {
- let template = Handlebars.compile(source)
- return template(this.substitutions)
+ let template, result
+
+ try {
+ template = Handlebars.compile(source)
+ result = template(this.substitutions)
+ } catch (error) {
+ console.log('Error processing template: ', error)
+ return source
+ }
+
+ return result
}
/**
diff --git a/lib/models/authenticator.js b/lib/models/authenticator.js
new file mode 100644
index 000000000..4e43c34d2
--- /dev/null
+++ b/lib/models/authenticator.js
@@ -0,0 +1,331 @@
+'use strict'
+
+const debug = require('./../debug').authentication
+const validUrl = require('valid-url')
+const webid = require('webid/tls')
+const provider = require('oidc-auth-manager/src/preferred-provider')
+const { domainMatches } = require('oidc-auth-manager/src/oidc-manager')
+
+/**
+ * Abstract Authenticator class, representing a local login strategy.
+ * To subclass, implement `fromParams()` and `findValidUser()`.
+ * Used by the `LoginRequest` handler class.
+ *
+ * @abstract
+ * @class Authenticator
+ */
+class Authenticator {
+ constructor (options) {
+ this.accountManager = options.accountManager
+ }
+
+ /**
+ * @param req {IncomingRequest}
+ * @param options {Object}
+ */
+ static fromParams (req, options) {
+ throw new Error('Must override method')
+ }
+
+ /**
+ * @returns {Promise}
+ */
+ findValidUser () {
+ throw new Error('Must override method')
+ }
+}
+
+/**
+ * Authenticates user via Username+Password.
+ */
+class PasswordAuthenticator extends Authenticator {
+ /**
+ * @constructor
+ * @param options {Object}
+ *
+ * @param [options.username] {string} Unique identifier submitted by user
+ * from the Login form. Can be one of:
+ * - An account name (e.g. 'alice'), if server is in Multi-User mode
+ * - A WebID URI (e.g. 'https://alice.example.com/#me')
+ *
+ * @param [options.password] {string} Plaintext password as submitted by user
+ *
+ * @param [options.userStore] {UserStore}
+ *
+ * @param [options.accountManager] {AccountManager}
+ */
+ constructor (options) {
+ super(options)
+
+ this.userStore = options.userStore
+ this.username = options.username
+ this.password = options.password
+ }
+
+ /**
+ * Factory method, returns an initialized instance of PasswordAuthenticator
+ * from an incoming http request.
+ *
+ * @param req {IncomingRequest}
+ * @param [req.body={}] {Object}
+ * @param [req.body.username] {string}
+ * @param [req.body.password] {string}
+ *
+ * @param options {Object}
+ *
+ * @param [options.accountManager] {AccountManager}
+ * @param [options.userStore] {UserStore}
+ *
+ * @return {PasswordAuthenticator}
+ */
+ static fromParams (req, options) {
+ let body = req.body || {}
+
+ options.username = body.username
+ options.password = body.password
+
+ return new PasswordAuthenticator(options)
+ }
+
+ /**
+ * Ensures required parameters are present,
+ * and throws an error if not.
+ *
+ * @throws {Error} If missing required params
+ */
+ validate () {
+ let error
+
+ if (!this.username) {
+ error = new Error('Username required')
+ error.statusCode = 400
+ throw error
+ }
+
+ if (!this.password) {
+ error = new Error('Password required')
+ error.statusCode = 400
+ throw error
+ }
+ }
+
+ /**
+ * Loads a user from the user store, and if one is found and the
+ * password matches, returns a `UserAccount` instance for that user.
+ *
+ * @throws {Error} If failures to load user are encountered
+ *
+ * @return {Promise}
+ */
+ findValidUser () {
+ let error
+ let userOptions
+
+ return Promise.resolve()
+ .then(() => this.validate())
+ .then(() => {
+ if (validUrl.isUri(this.username)) {
+ // A WebID URI was entered into the username field
+ userOptions = { webId: this.username }
+ } else {
+ // A regular username
+ userOptions = { username: this.username }
+ }
+
+ let user = this.accountManager.userAccountFrom(userOptions)
+
+ debug(`Attempting to login user: ${user.id}`)
+
+ return this.userStore.findUser(user.id)
+ })
+ .then(foundUser => {
+ if (!foundUser) {
+ error = new Error('No user found for that username')
+ error.statusCode = 400
+ throw error
+ }
+
+ return this.userStore.matchPassword(foundUser, this.password)
+ })
+ .then(validUser => {
+ if (!validUser) {
+ error = new Error('User found but no password match')
+ error.statusCode = 400
+ throw error
+ }
+
+ debug('User found, password matches')
+
+ return this.accountManager.userAccountFrom(validUser)
+ })
+ }
+}
+
+/**
+ * Authenticates a user via a WebID-TLS client side certificate.
+ */
+class TlsAuthenticator extends Authenticator {
+ /**
+ * @constructor
+ * @param options {Object}
+ *
+ * @param [options.accountManager] {AccountManager}
+ *
+ * @param [options.connection] {Socket} req.connection
+ */
+ constructor (options) {
+ super(options)
+
+ this.connection = options.connection
+ }
+
+ /**
+ * Factory method, returns an initialized instance of TlsAuthenticator
+ * from an incoming http request.
+ *
+ * @param req {IncomingRequest}
+ * @param req.connection {Socket}
+ *
+ * @param options {Object}
+ * @param [options.accountManager] {AccountManager}
+ *
+ * @return {TlsAuthenticator}
+ */
+ static fromParams (req, options) {
+ options.connection = req.connection
+
+ return new TlsAuthenticator(options)
+ }
+
+ /**
+ * Requests a client certificate from the current TLS connection via
+ * renegotiation, extracts and verifies the user's WebID URI,
+ * and makes sure that WebID is hosted on this server.
+ *
+ * @throws {Error} If error is encountered extracting the WebID URI from
+ * certificate, or if the user's account is hosted by a remote system.
+ *
+ * @return {Promise}
+ */
+ findValidUser () {
+ return this.renegotiateTls()
+
+ .then(() => this.getCertificate())
+
+ .then(cert => this.extractWebId(cert))
+
+ .then(webId => this.loadUser(webId))
+ }
+
+ /**
+ * Renegotiates the current TLS connection to ask for a client certificate.
+ *
+ * @throws {Error}
+ *
+ * @returns {Promise}
+ */
+ renegotiateTls () {
+ let connection = this.connection
+
+ return new Promise((resolve, reject) => {
+ // Typically, certificates for WebID-TLS are not signed or self-signed,
+ // and would hence be rejected by Node.js for security reasons.
+ // However, since WebID-TLS instead dereferences the profile URL to validate ownership,
+ // we can safely skip the security check.
+ connection.renegotiate({ requestCert: true, rejectUnauthorized: false }, (error) => {
+ if (error) {
+ debug('Error renegotiating TLS:', error)
+
+ return reject(error)
+ }
+
+ resolve()
+ })
+ })
+ }
+
+ /**
+ * Requests and returns a client TLS certificate from the current connection.
+ *
+ * @throws {Error} If no certificate is presented, or if it is empty.
+ *
+ * @return {Promise}
+ */
+ getCertificate () {
+ let certificate = this.connection.getPeerCertificate()
+
+ if (!certificate || !Object.keys(certificate).length) {
+ debug('No client certificate detected')
+
+ throw new Error('No client certificate detected. ' +
+ '(You may need to restart your browser to retry.)')
+ }
+
+ return certificate
+ }
+
+ /**
+ * Extracts (and verifies) the WebID URI from a client certificate.
+ *
+ * @param certificate {X509Certificate}
+ *
+ * @return {Promise} WebID URI
+ */
+ extractWebId (certificate) {
+ return new Promise((resolve, reject) => {
+ this.verifyWebId(certificate, (error, webId) => {
+ if (error) {
+ debug('Error processing certificate:', error)
+
+ return reject(error)
+ }
+
+ resolve(webId)
+ })
+ })
+ }
+
+ /**
+ * Performs WebID-TLS verification (requests the WebID Profile from the
+ * WebID URI extracted from certificate, and makes sure the public key
+ * from the profile matches the key from certificate).
+ *
+ * @param certificate {X509Certificate}
+ * @param callback {Function} Gets invoked with signature `callback(error, webId)`
+ */
+ verifyWebId (certificate, callback) {
+ debug('Verifying WebID URI')
+
+ webid.verify(certificate, callback)
+ }
+
+ discoverProviderFor (webId) {
+ return provider.discoverProviderFor(webId)
+ }
+
+ /**
+ * Returns a user account instance for a given Web ID.
+ *
+ * @param webId {string}
+ *
+ * @return {UserAccount}
+ */
+ loadUser (webId) {
+ const serverUri = this.accountManager.host.serverUri
+
+ if (domainMatches(serverUri, webId)) {
+ // This is a locally hosted Web ID
+ return this.accountManager.userAccountFrom({ webId })
+ } else {
+ debug(`WebID URI ${JSON.stringify(webId)} is not a local account, using it as an external WebID`)
+
+ return this.accountManager.userAccountFrom({ webId, username: webId, externalWebId: true })
+ }
+ }
+}
+
+module.exports = {
+ Authenticator,
+ PasswordAuthenticator,
+ TlsAuthenticator
+}
diff --git a/lib/models/email-service.js b/lib/models/email-service.js
index 1d43cbfad..8eb9865d3 100644
--- a/lib/models/email-service.js
+++ b/lib/models/email-service.js
@@ -119,7 +119,7 @@ class EmailService {
emailFromTemplate (templateName, data) {
let template = this.readTemplate(templateName)
- return template.render(data)
+ return Object.assign({}, template.render(data), data)
}
/**
diff --git a/lib/models/oidc-manager.js b/lib/models/oidc-manager.js
new file mode 100644
index 000000000..e355694c7
--- /dev/null
+++ b/lib/models/oidc-manager.js
@@ -0,0 +1,48 @@
+'use strict'
+
+const url = require('url')
+const path = require('path')
+const debug = require('../debug').authentication
+
+const OidcManager = require('oidc-auth-manager')
+
+/**
+ * Returns an instance of the OIDC Authentication Manager, initialized from
+ * argv / config.json server parameters.
+ *
+ * @param argv {Object} Config hashmap
+ *
+ * @param argv.host {SolidHost} Initialized SolidHost instance, including
+ * `serverUri`.
+ *
+ * @param [argv.dbPath='./db/oidc'] {string} Path to the auth-related storage
+ * directory (users, tokens, client registrations, etc, will be stored there).
+ *
+ * @param argv.saltRounds {number} Number of bcrypt password salt rounds
+ *
+ * @return {OidcManager} Initialized instance, includes a UserStore,
+ * OIDC Clients store, a Resource Authenticator, and an OIDC Provider.
+ */
+function fromServerConfig (argv) {
+ let providerUri = argv.host.serverUri
+ let authCallbackUri = url.resolve(providerUri, '/api/oidc/rp')
+ let postLogoutUri = url.resolve(providerUri, '/goodbye')
+
+ let dbPath = path.join(argv.dbPath, 'oidc')
+
+ let options = {
+ debug,
+ providerUri,
+ dbPath,
+ authCallbackUri,
+ postLogoutUri,
+ saltRounds: argv.saltRounds,
+ host: { debug }
+ }
+
+ return OidcManager.from(options)
+}
+
+module.exports = {
+ fromServerConfig
+}
diff --git a/lib/models/solid-host.js b/lib/models/solid-host.js
index 972d75ca1..1a926e97e 100644
--- a/lib/models/solid-host.js
+++ b/lib/models/solid-host.js
@@ -14,11 +14,12 @@ class SolidHost {
* @constructor
* @param [options={}]
* @param [options.port] {number}
- * @param [options.serverUri] {string}
+ * @param [options.serverUri] {string} Fully qualified URI that this Solid
+ * server is listening on, e.g. `https://databox.me`
*/
constructor (options = {}) {
- this.port = options.port || defaults.DEFAULT_PORT
- this.serverUri = options.serverUri || defaults.DEFAULT_URI
+ this.port = options.port || defaults.port
+ this.serverUri = options.serverUri || defaults.serverUri
this.parsedUri = url.parse(this.serverUri)
this.host = this.parsedUri.host
@@ -37,7 +38,7 @@ class SolidHost {
}
/**
- * Composes and returns an account URI for a given username, in multiUser mode.
+ * Composes and returns an account URI for a given username, in multi-user mode.
* Usage:
*
* ```
@@ -60,6 +61,35 @@ class SolidHost {
}
return this.parsedUri.protocol + '//' + accountName + '.' + this.host
}
+ /**
+ * Determines whether the given user is allowed to restore a session
+ * from the given origin.
+ *
+ * @param userId {?string}
+ * @param origin {?string}
+ * @return {boolean}
+ */
+ allowsSessionFor (userId, origin) {
+ // Allow no user or an empty origin
+ if (!userId || !origin) return true
+ // Allow the server's main domain
+ if (origin === this.serverUri) return true
+ // Allow the user's subdomain
+ const userIdHost = userId.replace(/([^:/])\/.*/, '$1')
+ if (origin === userIdHost) return true
+ // Disallow everything else
+ return false
+ }
+
+ /**
+ * Returns the /authorize endpoint URL object (used in login requests, etc).
+ *
+ * @return {URL}
+ */
+ get authEndpoint () {
+ let authUrl = url.resolve(this.serverUri, '/authorize')
+ return url.parse(authUrl)
+ }
/**
* Returns a cookie domain, based on the current host's serverUri.
diff --git a/lib/token-service.js b/lib/models/token-service.js
similarity index 54%
rename from lib/token-service.js
rename to lib/models/token-service.js
index c39b5396a..aefd5dd3d 100644
--- a/lib/token-service.js
+++ b/lib/models/token-service.js
@@ -1,26 +1,32 @@
'use strict'
const moment = require('moment')
-const uid = require('uid-safe').sync
-const extend = require('extend')
+const ulid = require('ulid')
class TokenService {
constructor () {
this.tokens = {}
}
- generate (opts = {}) {
- const token = uid(20)
- this.tokens[token] = {
+
+ generate (data = {}) {
+ const token = ulid()
+
+ const value = {
exp: moment().add(20, 'minutes')
}
- this.tokens[token] = extend(this.tokens[token], opts)
+
+ this.tokens[token] = Object.assign({}, value, data)
return token
}
+
verify (token) {
const now = new Date()
- if (this.tokens[token] && now < this.tokens[token].exp) {
- return this.tokens[token]
+
+ let tokenValue = this.tokens[token]
+
+ if (tokenValue && now < tokenValue.exp) {
+ return tokenValue
} else {
return false
}
diff --git a/lib/models/user-account.js b/lib/models/user-account.js
index adf13d82c..6fec8967f 100644
--- a/lib/models/user-account.js
+++ b/lib/models/user-account.js
@@ -13,12 +13,16 @@ class UserAccount {
* @param [options.webId] {string}
* @param [options.name] {string}
* @param [options.email] {string}
+ * @param [options.externalWebId] {string}
+ * @param [options.localAccountId] {string}
*/
constructor (options = {}) {
this.username = options.username
this.webId = options.webId
this.name = options.name
this.email = options.email
+ this.externalWebId = options.externalWebId
+ this.localAccountId = options.localAccountId
}
/**
@@ -41,6 +45,37 @@ class UserAccount {
return this.name || this.username || this.email || 'Solid account'
}
+ /**
+ * Returns the id key for the user account (for use with the user store, for
+ * example), consisting of the WebID URI minus the protocol and slashes.
+ * Usage:
+ *
+ * ```
+ * userAccount.webId = 'https://alice.example.com/profile/card#me'
+ * userAccount.id // -> 'alice.example.com/profile/card#me'
+ * ```
+ *
+ * @return {string}
+ */
+ get id () {
+ if (!this.webId) { return null }
+
+ let parsed = url.parse(this.webId)
+ let id = parsed.host + parsed.pathname
+ if (parsed.hash) {
+ id += parsed.hash
+ }
+ return id
+ }
+
+ get accountUri () {
+ if (!this.webId) { return null }
+
+ let parsed = url.parse(this.webId)
+
+ return parsed.protocol + '//' + parsed.host
+ }
+
/**
* Returns the URI of the WebID Profile for this account.
* Usage:
diff --git a/lib/requests/auth-request.js b/lib/requests/auth-request.js
new file mode 100644
index 000000000..cb8a532de
--- /dev/null
+++ b/lib/requests/auth-request.js
@@ -0,0 +1,211 @@
+'use strict'
+
+const url = require('url')
+const debug = require('./../debug').authentication
+
+/**
+ * Hidden form fields from the login page that must be passed through to the
+ * Authentication request.
+ *
+ * @type {Array}
+ */
+const AUTH_QUERY_PARAMS = ['response_type', 'display', 'scope',
+ 'client_id', 'redirect_uri', 'state', 'nonce', 'request']
+
+/**
+ * Base authentication request (used for login and password reset workflows).
+ */
+class AuthRequest {
+ /**
+ * @constructor
+ * @param [options.response] {ServerResponse} middleware `res` object
+ * @param [options.session] {Session} req.session
+ * @param [options.userStore] {UserStore}
+ * @param [options.accountManager] {AccountManager}
+ * @param [options.returnToUrl] {string}
+ * @param [options.authQueryParams] {Object} Key/value hashmap of parsed query
+ * parameters that will be passed through to the /authorize endpoint.
+ */
+ constructor (options) {
+ this.response = options.response
+ this.session = options.session || {}
+ this.userStore = options.userStore
+ this.accountManager = options.accountManager
+ this.returnToUrl = options.returnToUrl
+ this.authQueryParams = options.authQueryParams || {}
+ this.localAuth = options.localAuth
+ }
+
+ /**
+ * Extracts a given parameter from the request - either from a GET query param,
+ * a POST body param, or an express registered `/:param`.
+ * Usage:
+ *
+ * ```
+ * AuthRequest.parseParameter(req, 'client_id')
+ * // -> 'client123'
+ * ```
+ *
+ * @param req {IncomingRequest}
+ * @param parameter {string} Parameter key
+ *
+ * @return {string|null}
+ */
+ static parseParameter (req, parameter) {
+ let query = req.query || {}
+ let body = req.body || {}
+ let params = req.params || {}
+
+ return query[parameter] || body[parameter] || params[parameter] || null
+ }
+
+ /**
+ * Extracts the options in common to most auth-related requests.
+ *
+ * @param req
+ * @param res
+ *
+ * @return {Object}
+ */
+ static requestOptions (req, res) {
+ let userStore, accountManager, localAuth
+
+ if (req.app && req.app.locals) {
+ let locals = req.app.locals
+
+ if (locals.oidc) {
+ userStore = locals.oidc.users
+ }
+
+ accountManager = locals.accountManager
+
+ localAuth = locals.localAuth
+ }
+
+ let authQueryParams = AuthRequest.extractAuthParams(req)
+ let returnToUrl = AuthRequest.parseParameter(req, 'returnToUrl')
+
+ let options = {
+ response: res,
+ session: req.session,
+ userStore,
+ accountManager,
+ returnToUrl,
+ authQueryParams,
+ localAuth
+ }
+
+ return options
+ }
+
+ /**
+ * Initializes query params required by Oauth2/OIDC type work flow from the
+ * request body.
+ * Only authorized params are loaded, all others are discarded.
+ *
+ * @param req {IncomingRequest}
+ *
+ * @return {Object}
+ */
+ static extractAuthParams (req) {
+ let params
+ if (req.method === 'POST') {
+ params = req.body
+ } else {
+ params = req.query
+ }
+
+ if (!params) { return {} }
+
+ let extracted = {}
+
+ let paramKeys = AUTH_QUERY_PARAMS
+ let value
+
+ for (let p of paramKeys) {
+ value = params[p]
+ // value = value === 'undefined' ? undefined : value
+ extracted[p] = value
+ }
+
+ return extracted
+ }
+
+ /**
+ * Calls the appropriate form to display to the user.
+ * Serves as an error handler for this request workflow.
+ *
+ * @param error {Error}
+ */
+ error (error) {
+ error.statusCode = error.statusCode || 400
+
+ this.renderForm(error)
+ }
+
+ /**
+ * Initializes a session (for subsequent authentication/authorization) with
+ * a given user's credentials.
+ *
+ * @param userAccount {UserAccount}
+ */
+ initUserSession (userAccount) {
+ let session = this.session
+
+ debug('Initializing user session with webId: ', userAccount.webId)
+
+ session.userId = userAccount.webId
+ session.subject = {
+ _id: userAccount.webId
+ }
+
+ return userAccount
+ }
+
+ /**
+ * Returns this installation's /authorize url. Used for redirecting post-login
+ * and post-signup.
+ *
+ * @return {string}
+ */
+ authorizeUrl () {
+ let host = this.accountManager.host
+ let authUrl = host.authEndpoint
+
+ authUrl.query = this.authQueryParams
+
+ return url.format(authUrl)
+ }
+
+ /**
+ * Returns this installation's /register url. Used for redirecting post-signup.
+ *
+ * @return {string}
+ */
+ registerUrl () {
+ let host = this.accountManager.host
+ let signupUrl = url.parse(url.resolve(host.serverUri, '/register'))
+
+ signupUrl.query = this.authQueryParams
+
+ return url.format(signupUrl)
+ }
+
+ /**
+ * Returns this installation's /login url.
+ *
+ * @return {string}
+ */
+ loginUrl () {
+ let host = this.accountManager.host
+ let signupUrl = url.parse(url.resolve(host.serverUri, '/login'))
+
+ signupUrl.query = this.authQueryParams
+
+ return url.format(signupUrl)
+ }
+}
+
+AuthRequest.AUTH_QUERY_PARAMS = AUTH_QUERY_PARAMS
+
+module.exports = AuthRequest
diff --git a/lib/requests/create-account-request.js b/lib/requests/create-account-request.js
index 84ea25fe8..33470a5e6 100644
--- a/lib/requests/create-account-request.js
+++ b/lib/requests/create-account-request.js
@@ -1,34 +1,37 @@
'use strict'
+const AuthRequest = require('./auth-request')
const WebIdTlsCertificate = require('../models/webid-tls-certificate')
const debug = require('../debug').accounts
/**
- * Represents a 'create new user account' http request (a POST to the
- * `/accounts/api/new` endpoint).
+ * Represents a 'create new user account' http request (either a POST to the
+ * `/accounts/api/new` endpoint, or a GET to `/register`).
*
* Intended just for browser-based requests; to create new user accounts from
* a command line, use the `AccountManager` class directly.
*
* This is an abstract class, subclasses are created (for example
- * `CreateTlsAccountRequest`) depending on which Authentication mode the server
+ * `CreateOidcAccountRequest`) depending on which Authentication mode the server
* is running in.
*
* @class CreateAccountRequest
*/
-class CreateAccountRequest {
+class CreateAccountRequest extends AuthRequest {
/**
* @param [options={}] {Object}
* @param [options.accountManager] {AccountManager}
* @param [options.userAccount] {UserAccount}
* @param [options.session] {Session} e.g. req.session
* @param [options.response] {HttpResponse}
+ * @param [options.returnToUrl] {string} If present, redirect the agent to
+ * this url on successful account creation
*/
constructor (options) {
- this.accountManager = options.accountManager
+ super(options)
+
+ this.username = options.username
this.userAccount = options.userAccount
- this.session = options.session
- this.response = options.response
}
/**
@@ -37,32 +40,78 @@ class CreateAccountRequest {
*
* @param req
* @param res
- * @param accountManager {AccountManager}
*
- * @throws {TypeError} If required parameters are missing (`userAccountFrom()`),
- * or it encounters an unsupported authentication scheme.
+ * @throws {Error} If required parameters are missing (via
+ * `userAccountFrom()`), or it encounters an unsupported authentication
+ * scheme.
*
* @return {CreateAccountRequest|CreateTlsAccountRequest}
*/
- static fromParams (req, res, accountManager) {
- let userAccount = accountManager.userAccountFrom(req.body)
-
- let options = {
- accountManager,
- userAccount,
- session: req.session,
- response: res
+ static fromParams (req, res) {
+ let options = AuthRequest.requestOptions(req, res)
+
+ let locals = req.app.locals
+ let authMethod = locals.authMethod
+ let accountManager = locals.accountManager
+
+ let body = req.body || {}
+
+ options.username = body.username
+
+ if (options.username) {
+ options.userAccount = accountManager.userAccountFrom(body)
}
- switch (accountManager.authMethod) {
+ switch (authMethod) {
+ case 'oidc':
+ options.password = body.password
+ return new CreateOidcAccountRequest(options)
case 'tls':
- options.spkac = req.body.spkac
+ options.spkac = body.spkac
return new CreateTlsAccountRequest(options)
default:
throw new TypeError('Unsupported authentication scheme')
}
}
+ static post (req, res) {
+ let request = CreateAccountRequest.fromParams(req, res)
+
+ return Promise.resolve()
+ .then(() => request.validate())
+ .then(() => request.createAccount())
+ .catch(error => request.error(error))
+ }
+
+ static get (req, res) {
+ let request = CreateAccountRequest.fromParams(req, res)
+
+ return Promise.resolve()
+ .then(() => request.renderForm())
+ .catch(error => request.error(error))
+ }
+
+ /**
+ * Renders the Register form
+ */
+ renderForm (error) {
+ let authMethod = this.accountManager.authMethod
+
+ let params = Object.assign({}, this.authQueryParams,
+ {
+ returnToUrl: this.returnToUrl,
+ loginUrl: this.loginUrl(),
+ registerDisabled: authMethod === 'tls'
+ })
+
+ if (error) {
+ params.error = error.message
+ this.response.status(error.statusCode)
+ }
+
+ this.response.render('account/register', params)
+ }
+
/**
* Creates an account for a given user (from a POST to `/api/accounts/new`)
*
@@ -76,9 +125,9 @@ class CreateAccountRequest {
return Promise.resolve(userAccount)
.then(this.cancelIfAccountExists.bind(this))
- .then(this.generateCredentials.bind(this))
.then(this.createAccountStorage.bind(this))
- .then(this.initSession.bind(this))
+ .then(this.saveCredentialsFor.bind(this))
+ .then(this.initUserSession.bind(this))
.then(this.sendResponse.bind(this))
.then(userAccount => {
// 'return' not used deliberately, no need to block and wait for email
@@ -123,7 +172,7 @@ class CreateAccountRequest {
* @param userAccount {UserAccount} Instance of the account to be created
*
* @throws {Error} If errors were encountering while creating new account
- * resources, or saving generated credentials.
+ * resources.
*
* @return {Promise} Chainable
*/
@@ -133,31 +182,76 @@ class CreateAccountRequest {
error.message = 'Error creating account storage: ' + error.message
throw error
})
- .then(() => {
- // Once the account folder has been initialized,
- // save the public keys or other generated credentials to the profile
- return this.saveCredentialsFor(userAccount)
- })
.then(() => {
debug('Account storage resources created')
return userAccount
})
}
+}
+/**
+ * Models a Create Account request for a server using WebID-OIDC (OpenID Connect)
+ * as a primary authentication mode. Handles saving user credentials to the
+ * `UserStore`, etc.
+ *
+ * @class CreateOidcAccountRequest
+ * @extends CreateAccountRequest
+ */
+class CreateOidcAccountRequest extends CreateAccountRequest {
/**
- * Initializes the session with the newly created user's credentials
+ * @constructor
*
- * @param userAccount {UserAccount} Instance of the account to be created
+ * @param [options={}] {Object} See `CreateAccountRequest` constructor docstring
+ * @param [options.password] {string} Password, as entered by the user at signup
+ */
+ constructor (options) {
+ super(options)
+
+ this.password = options.password
+ }
+
+ /**
+ * Validates the Login request (makes sure required parameters are present),
+ * and throws an error if not.
*
- * @return {UserAccount} Chainable
+ * @throws {Error} If missing required params
*/
- initSession (userAccount) {
- let session = this.session
+ validate () {
+ let error
+
+ if (!this.username) {
+ error = new Error('Username required')
+ error.statusCode = 400
+ throw error
+ }
+
+ if (!this.password) {
+ error = new Error('Password required')
+ error.statusCode = 400
+ throw error
+ }
+ }
+
+ /**
+ * Generate salted password hash, etc.
+ *
+ * @param userAccount {UserAccount}
+ *
+ * @return {Promise}
+ */
+ saveCredentialsFor (userAccount) {
+ return this.userStore.createUser(userAccount, this.password)
+ .then(() => {
+ debug('User credentials stored')
+ return userAccount
+ })
+ }
- if (!session) { return userAccount }
+ sendResponse (userAccount) {
+ let redirectUrl = this.returnToUrl ||
+ this.accountManager.accountUriFor(userAccount.username)
+ this.response.redirect(redirectUrl)
- session.userId = userAccount.webId
- session.identified = true
return userAccount
}
}
@@ -176,21 +270,27 @@ class CreateTlsAccountRequest extends CreateAccountRequest {
* @param [options={}] {Object} See `CreateAccountRequest` constructor docstring
* @param [options.spkac] {string}
*/
- constructor (options = {}) {
+ constructor (options) {
super(options)
+
this.spkac = options.spkac
this.certificate = null
}
/**
- * Generates required user credentials (WebID-TLS certificate, etc).
- *
- * @param userAccount {UserAccount}
+ * Validates the Login request (makes sure required parameters are present),
+ * and throws an error if not.
*
- * @return {Promise} Chainable
+ * @throws {Error} If missing required params
*/
- generateCredentials (userAccount) {
- return this.generateTlsCertificate(userAccount)
+ validate () {
+ let error
+
+ if (!this.username) {
+ error = new Error('Username required')
+ error.statusCode = 400
+ throw error
+ }
}
/**
@@ -201,8 +301,8 @@ class CreateTlsAccountRequest extends CreateAccountRequest {
* @param userAccount {UserAccount}
* @param userAccount.webId {string} An agent's WebID URI
*
- * @throws {Error} HTTP 400 error if errors were encountering during certificate
- * generation.
+ * @throws {Error} HTTP 400 error if errors were encountering during
+ * certificate generation.
*
* @return {Promise} Chainable
*/
@@ -231,22 +331,28 @@ class CreateTlsAccountRequest extends CreateAccountRequest {
}
/**
- * If a WebID-TLS certificate was generated, saves it to the user's profile
+ * Generates a WebID-TLS certificate and saves it to the user's profile
* graph.
*
* @param userAccount {UserAccount}
*
- * @return {Promise}
+ * @return {Promise} Chainable
*/
saveCredentialsFor (userAccount) {
- if (!this.certificate) {
- return Promise.resolve(null)
- }
-
- return this.accountManager
- .addCertKeyToProfile(this.certificate, userAccount)
+ return this.generateTlsCertificate(userAccount)
+ .then(userAccount => {
+ if (this.certificate) {
+ return this.accountManager
+ .addCertKeyToProfile(this.certificate, userAccount)
+ .then(() => {
+ debug('Saved generated WebID-TLS certificate to profile')
+ })
+ } else {
+ debug('No certificate generated, no need to save to profile')
+ }
+ })
.then(() => {
- debug('Saved generated WebID-TLS certificate to profile')
+ return userAccount
})
}
diff --git a/lib/requests/login-request.js b/lib/requests/login-request.js
new file mode 100644
index 000000000..c78aa4ea8
--- /dev/null
+++ b/lib/requests/login-request.js
@@ -0,0 +1,208 @@
+'use strict'
+
+const debug = require('./../debug').authentication
+
+const AuthRequest = require('./auth-request')
+const { PasswordAuthenticator, TlsAuthenticator } = require('../models/authenticator')
+
+const PASSWORD_AUTH = 'password'
+const TLS_AUTH = 'tls'
+
+/**
+ * Models a local Login request
+ */
+class LoginRequest extends AuthRequest {
+ /**
+ * @constructor
+ * @param options {Object}
+ *
+ * @param [options.response] {ServerResponse} middleware `res` object
+ * @param [options.session] {Session} req.session
+ * @param [options.userStore] {UserStore}
+ * @param [options.accountManager] {AccountManager}
+ * @param [options.returnToUrl] {string}
+ * @param [options.authQueryParams] {Object} Key/value hashmap of parsed query
+ * parameters that will be passed through to the /authorize endpoint.
+ * @param [options.authenticator] {Authenticator} Auth strategy by which to
+ * log in
+ */
+ constructor (options) {
+ super(options)
+
+ this.authenticator = options.authenticator
+ }
+
+ /**
+ * Factory method, returns an initialized instance of LoginRequest
+ * from an incoming http request.
+ *
+ * @param req {IncomingRequest}
+ * @param res {ServerResponse}
+ * @param authMethod {string}
+ *
+ * @return {LoginRequest}
+ */
+ static fromParams (req, res, authMethod) {
+ let options = AuthRequest.requestOptions(req, res)
+
+ switch (authMethod) {
+ case PASSWORD_AUTH:
+ options.authenticator = PasswordAuthenticator.fromParams(req, options)
+ break
+
+ case TLS_AUTH:
+ options.authenticator = TlsAuthenticator.fromParams(req, options)
+ break
+
+ default:
+ options.authenticator = null
+ break
+ }
+
+ return new LoginRequest(options)
+ }
+
+ /**
+ * Handles a Login GET request on behalf of a middleware handler, displays
+ * the Login page.
+ * Usage:
+ *
+ * ```
+ * app.get('/login', LoginRequest.get)
+ * ```
+ *
+ * @param req {IncomingRequest}
+ * @param res {ServerResponse}
+ */
+ static get (req, res) {
+ const request = LoginRequest.fromParams(req, res)
+
+ request.renderForm()
+ }
+
+ /**
+ * Handles a Login via Username+Password.
+ * Errors encountered are displayed on the Login form.
+ * Usage:
+ *
+ * ```
+ * app.post('/login/password', LoginRequest.loginPassword)
+ * ```
+ *
+ * @param req
+ * @param res
+ *
+ * @return {Promise}
+ */
+ static loginPassword (req, res) {
+ debug('Logging in via username + password')
+
+ let request = LoginRequest.fromParams(req, res, PASSWORD_AUTH)
+
+ return LoginRequest.login(request)
+ }
+
+ /**
+ * Handles a Login via WebID-TLS.
+ * Errors encountered are displayed on the Login form.
+ * Usage:
+ *
+ * ```
+ * app.post('/login/tls', LoginRequest.loginTls)
+ * ```
+ *
+ * @param req
+ * @param res
+ *
+ * @return {Promise}
+ */
+ static loginTls (req, res) {
+ debug('Logging in via WebID-TLS certificate')
+
+ let request = LoginRequest.fromParams(req, res, TLS_AUTH)
+
+ return LoginRequest.login(request)
+ }
+
+ /**
+ * Performs the login operation -- loads and validates the
+ * appropriate user, inits the session with credentials, and redirects the
+ * user to continue their auth flow.
+ *
+ * @param request {LoginRequest}
+ *
+ * @return {Promise}
+ */
+ static login (request) {
+ return request.authenticator.findValidUser()
+
+ .then(validUser => {
+ request.initUserSession(validUser)
+
+ request.redirectPostLogin(validUser)
+ })
+
+ .catch(error => request.error(error))
+ }
+
+ /**
+ * Returns a URL to redirect the user to after login.
+ * Either uses the provided `redirect_uri` auth query param, or simply
+ * returns the user profile URI if none was provided.
+ *
+ * @param validUser {UserAccount}
+ *
+ * @return {string}
+ */
+ postLoginUrl (validUser) {
+ let uri
+
+ if (this.authQueryParams['client_id']) {
+ // Login request is part of an app's auth flow
+ uri = this.authorizeUrl()
+ } else if (validUser) {
+ // Login request is a user going to /login in browser
+ // uri = this.accountManager.accountUriFor(validUser.username)
+ uri = validUser.accountUri
+ }
+
+ return uri
+ }
+
+ /**
+ * Redirects the Login request to continue on the OIDC auth workflow.
+ */
+ redirectPostLogin (validUser) {
+ let uri = this.postLoginUrl(validUser)
+
+ debug('Login successful, redirecting to ', uri)
+
+ this.response.redirect(uri)
+ }
+
+ /**
+ * Renders the login form
+ */
+ renderForm (error) {
+ let params = Object.assign({}, this.authQueryParams,
+ {
+ registerUrl: this.registerUrl(),
+ returnToUrl: this.returnToUrl,
+ enablePassword: this.localAuth.password,
+ enableTls: this.localAuth.tls
+ })
+
+ if (error) {
+ params.error = error.message
+ this.response.status(error.statusCode)
+ }
+
+ this.response.render('auth/login', params)
+ }
+}
+
+module.exports = {
+ LoginRequest,
+ PASSWORD_AUTH,
+ TLS_AUTH
+}
diff --git a/lib/requests/password-change-request.js b/lib/requests/password-change-request.js
new file mode 100644
index 000000000..5ac1a76a9
--- /dev/null
+++ b/lib/requests/password-change-request.js
@@ -0,0 +1,201 @@
+'use strict'
+
+const AuthRequest = require('./auth-request')
+const debug = require('./../debug').accounts
+
+class PasswordChangeRequest extends AuthRequest {
+ /**
+ * @constructor
+ * @param options {Object}
+ * @param options.accountManager {AccountManager}
+ * @param options.userStore {UserStore}
+ * @param options.response {ServerResponse} express response object
+ * @param [options.token] {string} One-time reset password token (from email)
+ * @param [options.returnToUrl] {string}
+ * @param [options.newPassword] {string} New password to save
+ */
+ constructor (options) {
+ super(options)
+
+ this.token = options.token
+ this.returnToUrl = options.returnToUrl
+
+ this.validToken = false
+
+ this.newPassword = options.newPassword
+ }
+
+ /**
+ * Factory method, returns an initialized instance of PasswordChangeRequest
+ * from an incoming http request.
+ *
+ * @param req {IncomingRequest}
+ * @param res {ServerResponse}
+ *
+ * @return {PasswordChangeRequest}
+ */
+ static fromParams (req, res) {
+ let locals = req.app.locals
+ let accountManager = locals.accountManager
+ let userStore = locals.oidc.users
+
+ let returnToUrl = this.parseParameter(req, 'returnToUrl')
+ let token = this.parseParameter(req, 'token')
+ let oldPassword = this.parseParameter(req, 'password')
+ let newPassword = this.parseParameter(req, 'newPassword')
+
+ let options = {
+ accountManager,
+ userStore,
+ returnToUrl,
+ token,
+ oldPassword,
+ newPassword,
+ response: res
+ }
+
+ return new PasswordChangeRequest(options)
+ }
+
+ /**
+ * Handles a Change Password GET request on behalf of a middleware handler.
+ *
+ * @param req {IncomingRequest}
+ * @param res {ServerResponse}
+ *
+ * @return {Promise}
+ */
+ static get (req, res) {
+ const request = PasswordChangeRequest.fromParams(req, res)
+
+ return Promise.resolve()
+ .then(() => request.validateToken())
+ .then(() => request.renderForm())
+ .catch(error => request.error(error))
+ }
+
+ /**
+ * Handles a Change Password POST request on behalf of a middleware handler.
+ *
+ * @param req {IncomingRequest}
+ * @param res {ServerResponse}
+ *
+ * @return {Promise}
+ */
+ static post (req, res) {
+ const request = PasswordChangeRequest.fromParams(req, res)
+
+ return PasswordChangeRequest.handlePost(request)
+ }
+
+ /**
+ * Performs the 'Change Password' operation, after the user submits the
+ * password change form. Validates the parameters (the one-time token,
+ * the new password), changes the password, and renders the success view.
+ *
+ * @param request {PasswordChangeRequest}
+ *
+ * @return {Promise}
+ */
+ static handlePost (request) {
+ return Promise.resolve()
+ .then(() => request.validatePost())
+ .then(() => request.validateToken())
+ .then(tokenContents => request.changePassword(tokenContents))
+ .then(() => request.renderSuccess())
+ .catch(error => request.error(error))
+ }
+
+ /**
+ * Validates the 'Change Password' parameters, and throws an error if any
+ * validation fails.
+ *
+ * @throws {Error}
+ */
+ validatePost () {
+ if (!this.newPassword) {
+ throw new Error('Please enter a new password')
+ }
+ }
+
+ /**
+ * Validates the one-time Password Reset token that was emailed to the user.
+ * If the token service has a valid token saved for the given key, it returns
+ * the token object value (which contains the user's WebID URI, etc).
+ * If no token is saved, returns `false`.
+ *
+ * @return {Promise