feature (sso): authentication middleware

This commit is contained in:
Mickael Kerjean
2021-12-24 02:41:40 +11:00
parent bed13a0bc8
commit e5800c6c3b
33 changed files with 876 additions and 193 deletions

View File

@ -14,6 +14,10 @@
&.success{
background: var(--success);
}
&.error{
background: var(--error);
color: var(--bg-color);
}
img{
max-width: 100%;

View File

@ -53,6 +53,8 @@ export function ErrorPage(WrappedComponent) {
super(props);
this.state = {
error: null,
trace: null,
showTrace: false,
has_back_button: false,
};
}
@ -69,7 +71,10 @@ export function ErrorPage(WrappedComponent) {
}
update(obj) {
this.setState({ error: obj });
this.setState({
error: obj,
trace: new URLSearchParams(location.search).get("trace") || null,
});
}
navigate(e) {
@ -84,17 +89,19 @@ export function ErrorPage(WrappedComponent) {
const message = this.state.error.message || t("There is nothing in here");
return (
<div>
<Link to={`/${window.location.search}`}
<a href="/"
className="backnav" onClick={this.navigate.bind(this)}
>
<Icon name="arrow_left" />{
this.state.has_back_button ? "back" : "home"
}
</Link>
</a>
<Container>
<div className="error-page">
<div className="error-page" onClick={() => this.setState({showTrace: true})}>
<h1>{ t("Oops!") }</h1>
<h2>{ message }</h2>
{ this.state.showTrace && this.state.trace &&
<code> { this.state.trace }</code> }
</div>
</Container>
</div>

View File

@ -106,7 +106,7 @@ export class FormBuilder extends React.Component {
<FormElement render={this.props.render}
onChange={onChange.bind(this)} {...id}
params={struct} target={target} name={ format(struct.label) }
autoComplete={ this.props.autoComplete || "off" } />
autoComplete="off" />
);
}
@ -119,7 +119,6 @@ export class FormBuilder extends React.Component {
const FormElement = (props) => {
const id = props.id !== undefined ? { id: props.id } : {};
const struct = props.params;
const autoCompleteProp = props.autoComplete || "off";
let $input = (
<Input onChange={(e) => props.onChange(e.target.value)} {...id} name={struct.label}
type="text" defaultValue={struct.value} placeholder={ t(struct.placeholder) } />
@ -138,7 +137,7 @@ const FormElement = (props) => {
<Input list={list_id} onChange={(e) => onTextChange(e.target.value)} {...id}
name={struct.label} type="text" value={struct.value || ""}
placeholder={ t(struct.placeholder) } readOnly={struct.readonly}
autoComplete={autoCompleteProp} autoCorrect="off" autoCapitalize="off"
autoComplete="off" autoCorrect="off" autoCapitalize="off"
spellCheck="false" />
);
if (list_id != null) {
@ -190,7 +189,7 @@ const FormElement = (props) => {
$input = (
<Input onChange={(e) => onPasswordChange(e.target.value)} {...id} name={struct.label}
type="password" value={struct.value || ""} placeholder={ t(struct.placeholder) }
autoComplete={autoCompleteProp} autoCorrect="off" autoCapitalize="off"
autoComplete="off" autoCorrect="off" autoCapitalize="off"
spellCheck="false"/>
);
break;
@ -206,7 +205,7 @@ const FormElement = (props) => {
<Textarea {...id} disabledEnter={true} value={struct.value || ""}
onChange={(e) => onLongPasswordChange(e.target.value)} type="text" rows="1"
name={struct.label} placeholder={ t(struct.placeholder) }
autoComplete={autoCompleteProp} autoCorrect="off" autoCapitalize="off"
autoComplete="off" autoCorrect="off" autoCapitalize="off"
spellCheck="false" />
);
break;
@ -216,7 +215,7 @@ const FormElement = (props) => {
<Textarea {...id} disabledEnter={true} value={struct.value || ""}
onChange={(e) => props.onChange(e.target.value)}
type="text" rows="3" name={struct.label} placeholder={ t(struct.placeholder) }
autoComplete={autoCompleteProp} autoCorrect="off" autoCapitalize="off"
autoComplete="off" autoCorrect="off" autoCapitalize="off"
spellCheck="false" />
);
break;

View File

@ -24,7 +24,16 @@ export function format(str = "") {
return str.split("_")
.map((word, index) => {
if (index != 0) return word;
return word[0].toUpperCase() + word.substring(1);
return (word[0] || "").toUpperCase() + word.substring(1);
})
.join(" ");
}
export function objectGet(obj, paths) {
let value = obj;
for (let i=0; i<paths.length; i++) {
if (typeof value !== "object" || value === null) return null;
value = value[paths[i]];
}
return value;
}

View File

@ -1,5 +1,5 @@
export const FormObjToJSON = function(o, fn, i = 0) {
if (i === 0) delete o["constants"];
if (i === 0 && o !== null) delete o["constants"];
const obj = Object.assign({}, o);
Object.keys(obj).map((key) => {
const t = obj[key];

View File

@ -16,7 +16,7 @@ export { invalidate, http_get, http_post, http_delete, http_options } from "./aj
export { prompt, alert, confirm } from "./popup";
export { notify } from "./notify";
export { gid, randomString } from "./random";
export { leftPad, format, copyToClipboard } from "./common";
export { leftPad, format, copyToClipboard, objectGet } from "./common";
export { getMimeType } from "./mimetype";
export { settings_get, settings_put } from "./settings";
export { FormObjToJSON, createFormBackend, autocomplete } from "./form";

View File

@ -39,6 +39,10 @@ class ConfigModel {
window.CONFIG = config.result;
});
}
clear() {
this.debounced_post = null;
}
}
class BackendModel {
@ -50,5 +54,15 @@ class BackendModel {
}
}
class MiddlewareModel {
constructor() {
}
getAllAuthentication() {
return http_get("/api/middlewares/authentication").then((r) => r.result);
}
}
export const Config = new ConfigModel();
export const Backend = new BackendModel();
export const Middleware = new MiddlewareModel();

View File

@ -1,6 +1,6 @@
export { Files } from "./files";
export { Session } from "./session";
export { Share } from "./share";
export { Config, Backend } from "./config";
export { Config, Backend, Middleware } from "./config";
export { Log } from "./log";
export { Admin } from "./admin";

View File

@ -12,6 +12,10 @@ class SessionManager {
.then((data) => data.result);
}
middleware(formData) {
return Promise.resolve("/api/session/auth/?action=redirect&label=" + (formData["label"] || ""));
}
authenticate(params) {
const url = "/api/session";
return http_post(url, params)

View File

@ -1,5 +1,6 @@
.component_page_admin{
display: flex;
.adminpage { max-width: 1400px; }
.page_container{
width: 100%;
background: var(--super-light);

View File

@ -1,8 +1,8 @@
/* eslint-disable max-len */
import React from "react";
import { FormBuilder, Icon, Input, Alert } from "../../components/";
import { Backend, Config } from "../../model/";
import { FormObjToJSON, notify, format, createFormBackend } from "../../helpers/";
import React, { useState, useEffect } from "react";
import { FormBuilder, Icon, Input, Alert, Textarea, Select, Loader } from "../../components/";
import { Backend, Config, Middleware } from "../../model/";
import { FormObjToJSON, notify, format, createFormBackend, objectGet } from "../../helpers/";
import { t } from "../../locales/";
import "./backend.scss";
@ -12,10 +12,11 @@ export class BackendPage extends React.Component {
super(props);
this.state = {
backend_enabled: [],
backend_available: [],
auth_available: ["LDAP", "SAML", "OpenID", "External"],
backend_available: {},
auth_enabled: null,
auth_available: {},
config: null,
isLoading: true,
};
}
@ -23,9 +24,12 @@ export class BackendPage extends React.Component {
Promise.all([
Backend.all(),
Config.all(),
Middleware.getAllAuthentication()
]).then((data) => {
const [backend, config] = data;
const [backend, config, middleware_auth] = data;
delete config["constants"];
this.setState({
isLoading: false,
backend_available: backend,
backend_enabled: window.CONFIG["connections"].filter((b) => b).map((conn) => {
const f = createFormBackend(backend, conn);
@ -35,15 +39,69 @@ export class BackendPage extends React.Component {
return f;
}).filter((a) => a !== null),
config: config,
auth_available: middleware_auth,
auth_enabled: {
// We are storing the config in a fixed schema as we had issues with handling
// different schema for each authentication middleware.
"identity_provider": (function() {
let { type, params } = objectGet(config, ["middleware", "identity_provider"]) || {};
type = objectGet(type, ["value"]);
params = objectGet(params, ["value"]);
if(!type) return {};
const idpParams = JSON.parse(params);
const idpForm = middleware_auth[type] || {};
for(let key in idpParams) {
if (!idpForm[key]) continue;
idpForm[key]["value"] = idpParams[key]
}
return idpForm;
}()),
"attribute_mapping": (function(state) {
let { related_backend, params = {} } = objectGet(config, ["middleware", "attribute_mapping"]) || {};
related_backend = objectGet(related_backend, ["value"]);
params = JSON.parse(objectGet(params, ["value"]) || "{}");
const backendsForm = Object.keys(params).reduce((acc, key) => {
const t = createFormBackend(
backend,
params[key],
)
acc[key] = t[params[key]["type"]];
return acc;
}, {})
let json = {
"related_backend": {
"label": "Related Backend",
"type": "text",
"description": "List of backends to have behind the authentication process. Can be either a backend type of the actual label",
"placeholder": "eg: ftp,sftp,webdav",
"readonly": false,
"default": null,
"value": related_backend,
"multi": true,
"datalist": window.CONFIG["connections"].map((r) => r.label),
"required": true
},
...backendsForm,
}
return json;
})(),
}
});
});
}
onChange(e) {
// refresh the screen to refresh the mutation
// that have happenned down the stack
this.setState({ refresh: Math.random() });
componentWillUnmount() {
this.props.isSaving(false);
Config.clear();
}
refresh() {
// refresh the screen to refresh the mutation
// that have happenned down the stack which react couldn't detect directly
this.setState({ refresh: Math.random() });
}
_buildConfig() {
const json = FormObjToJSON(this.state.config);
json.connections = this.state.backend_enabled.map((backend) => {
const data = FormObjToJSON(backend, (obj, key) => {
@ -56,8 +114,48 @@ export class BackendPage extends React.Component {
const key = Object.keys(data)[0];
return data[key];
});
return json;
}
onUpdateStorageBackend(e) {
this.refresh()
const json = this._buildConfig();
this.props.isSaving(true);
return Config.save(json, true, () => {
this.props.isSaving(false);
}, (err) => {
notify.send(err && err.message || t("Oops"), "error");
this.props.isSaving(false);
});
}
onUpdateAuthenticationMiddleware(middlewareData = null) {
this.refresh();
const json = this._buildConfig();
json["middleware"] = {
"identity_provider": (function() {
const { type, ...other } = objectGet(middlewareData, ["identity_provider"]) || {};
return {
"type": type || null,
"params": JSON.stringify(other),
}
})(),
"attribute_mapping": (function() {
let { related_backend = null, ...params } = objectGet(middlewareData, ["attribute_mapping"]) || {};
if (related_backend !== null) {
return {
"related_backend": related_backend || "N/A",
"params": JSON.stringify(params),
}
}
({ related_backend, ...params } = objectGet(json, ["middleware", "attribute_mapping"]) || {});
return {
"related_backend": related_backend || "N/A",
"params": JSON.stringify(params),
}
})(),
};
// persist config object in the backend
this.props.isSaving(true);
return Config.save(json, true, () => {
this.props.isSaving(false);
@ -75,31 +173,28 @@ export class BackendPage extends React.Component {
label: backend_id.toUpperCase(),
}),
),
}, this.onChange.bind(this));
}, this.onUpdateStorageBackend.bind(this));
}
removeBackend(n) {
this.setState({
backend_enabled: this.state.backend_enabled.filter((_, i) => i !== n),
}, this.onChange.bind(this));
}, this.onUpdateStorageBackend.bind(this));
}
onClickAuthAvailable(auth) {
changeAuthentication(auth) {
this.setState({
auth_enabled: this.state.auth_enabled === auth ? null : auth,
auth_enabled: {
"identity_provider": auth === null ? {} : this.state.auth_available[auth],
"attribute_mapping": objectGet(this.state.auth_enabled, ["attribute_mapping"]) || {},
}
}, () => {
this.onUpdateAuthenticationMiddleware(FormObjToJSON(this.state.auth_enabled))
});
}
render() {
const update = (value, struct) => {
struct.enabled = value;
this.setState({ refresh: Math.random() });
if (value === false) {
struct.value = null;
}
return;
};
const formRender = ($input, props, struct, onChange) => {
const enable = (struct) => {
if (typeof struct.value === "string") {
struct.enabled = true;
@ -107,18 +202,15 @@ export class BackendPage extends React.Component {
}
return !!struct.enabled;
};
const isActiveBackend = (backend_key) => {
return this.state.backend_enabled
.map((b) => Object.keys(b)[0])
.indexOf(backend_key) !== -1;
const update = (value, struct) => {
struct.enabled = value;
this.refresh();
if (value === false) {
struct.value = null;
}
return;
};
const isActiveAuth = (auth_key) => {
return auth_key === this.state.auth_enabled;
};
const renderForm = ($input, props, struct, onChange) => {
let $checkbox = (
<Input type="checkbox" checked={enable(struct)}
style={{ width: "inherit", marginRight: "6px", top: "6px" }}
@ -154,17 +246,56 @@ export class BackendPage extends React.Component {
return (
<div className="component_dashboard">
<h2>Enabled Backends</h2>
{
this.state.isLoading ? (
<Loader />
) : (
<React.Fragment>
<StorageBackend
backend_available={this.state.backend_available}
backend_enabled={this.state.backend_enabled}
backend_add={this.addBackend.bind(this)}
backend_remove={this.removeBackend.bind(this)}
formChange={this.onUpdateStorageBackend.bind(this)}
formRender={formRender}
/>
<AuthenticationMiddleware
authentication_available={this.state.auth_available}
authentication_enabled={this.state.auth_enabled}
authentication_update={this.changeAuthentication.bind(this)}
backend_available={this.state.backend_available}
backend_enabled={this.state.backend_enabled}
formChange={this.onUpdateAuthenticationMiddleware.bind(this)}
formRender={formRender}
/>
</React.Fragment>
)
}
</div>
);
}
}
function StorageBackend({ backend_available, backend_enabled, backend_add, backend_remove, formChange, formRender }) {
const isActiveBackend = (backend_key) => {
return backend_enabled
.map((b) => Object.keys(b)[0])
.indexOf(backend_key) !== -1;
};
return (
<div className="component_storagebackend">
<h2>Storage Backend</h2>
<div className="box-container">
{
Object.keys(this.state.backend_available)
Object.keys(backend_available)
.sort((a, b) => a > b)
.map((backend_available, index) => (
.map((backend_available_current, index) => (
<div key={index}
onClick={this.addBackend.bind(this, backend_available)}
className={"box-item pointer no-select" + (isActiveBackend(backend_available) ? " active": "")}>
onClick={() => backend_add(backend_available_current)}
className={"box-item pointer no-select" + (isActiveBackend(backend_available_current) ? " active": "")}>
<div>
{ backend_available }
{ backend_available_current }
<span className="no-select">
<span className="icon">+</span>
</span>
@ -173,23 +304,133 @@ export class BackendPage extends React.Component {
))
}
</div>
{
backend_enabled.length !== 0 ? (
<div>
<form>
{
backend_enabled.map((backend_enabled_current, index) => {
return (
<div key={index}>
<div className="icons no-select"
onClick={() => backend_remove(index)}>
<Icon name="close" />
</div>
<FormBuilder onChange={formChange}
idx={index}
key={index}
form={{ "": backend_enabled_current }}
render={formRender} />
</div>
);
})
}
</form>
</div>
) : <Alert className="error">There is no storage selected. Where do you want to connect to?</Alert>
}
</div>
)
}
function AuthenticationMiddleware({ authentication_available, authentication_enabled, backend_available, backend_enabled, authentication_update, formChange, formRender }) {
const [formSpec, setFormSpec] = useState(authentication_enabled);
const formChangeHandler = (e) => {
formChange(e[""]);
};
useEffect(() => {
setFormSpec(authentication_enabled);
}, [ authentication_enabled ]);
// we want to update the form in a few scenarios:
// 1. user remove a storage backend
// 2. user add a storage backend
// 3. add a related backend in attribute mapping
// 4. remove a related backend in attribute mapping
// we want to update the form whenever a user change the related_backend input.
// The change could be to either:
// 1. add something to the list => create a new form in the attribute_mapping section
// 2. remove something from the list => remove something in the attribute_mapping section
useEffect(() => {
if(!formSpec["identity_provider"]) return;
const existingValues = (formSpec["attribute_mapping"]["related_backend"]["value"] || "")
.split(/, ?/)
.map((a) => a.trim());
const { identity_provider, attribute_mapping } = formSpec;
const selected = backend_enabled.map((b) => {
const type = Object.keys(b)[0]
return {
label: b[type].label.value,
type: type,
}
})
let needToSave = false
// detect missing form from the existing attribute_mapping
// this happen whenever a user added something in the related_backend input
for(let i=0; i<selected.length; i++) {
if(attribute_mapping[selected[i].label]) continue;
for(let j=0; j<existingValues.length; j++) {
if(selected[i].label === existingValues[j]) {
attribute_mapping[selected[i].label] = backend_available[selected[j].type]
needToSave = true;
}
}
}
// detect out of date attribute_mapping that are still showing but shouldn't
Object.keys(formSpec["attribute_mapping"]).map((key) => {
if(key === "related_backend") return;
if(existingValues.indexOf(key) !== -1) return;
needToSave = true;
delete attribute_mapping[key];
})
if (needToSave === false) return;
const d = {
identity_provider,
attribute_mapping: attribute_mapping,
};
formChange(FormObjToJSON(d))
setFormSpec(d)
}, [ formSpec["attribute_mapping"]["related_backend"]["value"], !formSpec["identity_provider"] ])
useEffect(() => { // autocompletion of the related_backend field
const f = { ...formSpec }
f.attribute_mapping.related_backend.datalist = backend_enabled
.map((r) => r[Object.keys(r)[0]].label.value);
const enabledBackendLabel = backend_enabled.map((b) => b[Object.keys(b)[0]].label.value)
f.attribute_mapping.related_backend.value = (f.attribute_mapping.related_backend.value || "")
.split(/, ?/)
.filter((value) => enabledBackendLabel.indexOf(value) !== -1)
.join(", ")
setFormSpec(f);
}, [ backend_enabled ])
const isActiveAuth = (auth_key) => {
return auth_key === objectGet(authentication_enabled, ["identity_provider", "type", "value"]);
};
return (
<div className="component_authenticationmiddleware" style={{minHeight: "400px"}}>
<h2>Authentication Middleware</h2>
<Alert>
Integrate Filestash with your identity management system
</Alert>
<div className="box-container">
{
this.state.auth_available.map((auth) => (
<div onClick={this.onClickAuthAvailable.bind(this, auth)} key={auth}
className={"box-item pointer no-select" + (isActiveAuth(auth) ? " active": "")}>
Object.keys(authentication_available)
.map((auth_current) => (
<div key={auth_current}
onClick={() => authentication_update(isActiveAuth(auth_current) ? null : auth_current)}
className={"box-item pointer no-select" + (isActiveAuth(auth_current) ? " active": "")}>
<div>
{ auth }
{ auth_current }
<span className="no-select">
<span className="icon">
{ isActiveAuth(auth) === false ? "+" :
{ isActiveAuth(auth_current) === false ? "+" :
<Icon name="delete" /> }
</span>
</span>
@ -198,52 +439,17 @@ export class BackendPage extends React.Component {
))
}
</div>
{
this.state.auth_enabled !== null && (
<React.Fragment>
<Alert className="success">
<i>
<strong>Register your interest:
<a href={`mailto:mickael@kerjean.me?Subject=Filestash - Authentication Middleware - ${this.state.auth_enabled}`}>
mickael@kerjean.me
</a>
</strong>
</i>
</Alert>
</React.Fragment>
objectGet(authentication_enabled, ["identity_provider", "type"]) && (
<div className="authentication-middleware">
<FormBuilder
onChange={formChangeHandler}
form={{ "": formSpec }}
render={formRender}
/>
</div>
)
}
<h2>Backend Configuration</h2>
{
this.state.backend_enabled.length !== 0 ? (
<div>
<form>
{
this.state.backend_enabled.map((backend_enable, index) => {
return (
<div key={index}>
<div className="icons no-select"
onClick={this.removeBackend.bind(this, index)}>
<Icon name="delete" />
</div>
<FormBuilder onChange={this.onChange.bind(this)}
idx={index}
key={index}
form={{ "": backend_enable }}
autoComplete="new-password"
render={renderForm} />
</div>
);
})
}
</form>
</div>
) : <Alert>You need to enable a backend first.</Alert>
}
</div>
);
}
)
}

View File

@ -1,5 +1,4 @@
.component_dashboard{
.alert { margin-top: -15px; }
.box-container {
display: flex;
flex-wrap: wrap;
@ -40,7 +39,8 @@
text-shadow: none;
background: var(--emphasis-primary);
padding: 18px 0;
@media (max-width: 750px){ padding: 8px 0; }
@media (max-width: 900px){ padding: 12px 0; }
@media (max-width: 750px){ padding: 7px 0; }
margin: 6px;
opacity: 0.95;
.icon{
@ -80,15 +80,17 @@
position: absolute;
border-radius: 50%;
padding: 10px;
background: var(--emphasis-primary);
border: 3px solid var(--bg-color);
background: var(--primary);
cursor: pointer;
right: -15px;
top: -5px;
box-shadow: rgba(0, 0, 0, 0.14) 0px 4px 5px 0px, rgba(0, 0, 0, 0.12) 0px 1px 10px 0px, rgba(0, 0, 0, 0.2) 0px 2px 4px -1px;
.component_icon{
height: 20px;
}
}
}
}
.authentication-middleware .formbuilder label span > input[type="checkbox"] { display: none; }
}

View File

@ -46,7 +46,11 @@ export function LogPage({ isSaving = nop }) {
});
fetchLogs();
const id = setInterval(fetchLogs, 5000);
return () => clearInterval(id);
return () => {
clearInterval(id);
Config.clear();
isSaving(false);
}
}, []);
return (

View File

@ -39,8 +39,13 @@ export function SettingsPage({ isSaving = nop }) {
// The constant key contains read only global variable that are
// application wide truth => not editable from the admin area
delete c.constant;
delete c.middleware;
setForm(c);
});
return () => {
Config.clear();
isSaving(false);
}
}, []);
const renderForm = ($input, props, struct, onChange) => (

View File

@ -4,7 +4,7 @@ import ReactCSSTransitionGroup from "react-addons-css-transition-group";
import { Input, Button, Icon, NgIf, Loader } from "../../components/";
import { Config, Admin } from "../../model/";
import { notify, FormObjToJSON, alert } from "../../helpers";
import { notify, FormObjToJSON, alert, objectGet } from "../../helpers";
import { bcrypt_password } from "../../helpers/bcrypt";
import "./setup.scss";
@ -245,12 +245,3 @@ const FormStage = (props) => {
</h4>
);
};
function objectGet(obj, paths) {
let value = obj;
for (let i=0; i<paths.length; i++) {
if (typeof value !== "object") return null;
value = value[paths[i]];
}
return value;
}

View File

@ -29,7 +29,13 @@ function ConnectPageComponent({ error, history }) {
};
const onFormSubmit = (formData) => {
if ("oauth2" in formData) {
if ("middleware" in formData) {
setIsLoading(true);
Session.middleware(formData).then((url) => {
window.location.href = url;
}).catch((err) => error(err));
return;
} else if ("oauth2" in formData) {
setIsLoading(true);
Session.oauth2(formData["oauth2"]).then((url) => {
window.location.href = url;

View File

@ -8,7 +8,7 @@
/* PAGE ANIMATION */
@keyframes PageConnectionPoweredBy {
from { transform: translateY(2px); opacity: 0;}
from { transform: translateX(3px); opacity: 0;}
to {transform: translateY(0); opacity: 1; }
}
@keyframes PageConnectionForm {

View File

@ -26,6 +26,10 @@
color: rgba(0, 0, 0, 0.4);
font-size: 0.9em;
line-height: 20px;
position: fixed;
bottom: 10px;
right: 20px;
strong{
font-weight: normal;
a{ text-decoration: underline; }

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react";
import { Card, Button, FormBuilder } from "../../components/";
import { Card, Button, FormBuilder, Loader } from "../../components/";
import {
settings_get, settings_put, createFormBackend, FormObjToJSON, nop,
} from "../../helpers/";
@ -25,6 +25,7 @@ export function Form({
}) {
const [enabledBackends, setEnabledBackends] = useState([]);
const [selectedTab, setSelectedTab] = useState(null);
const [hasUserInteracted, setHasUserInteracted] = useState(false);
useEffect(() => {
Backend.all().then((backend) => {
@ -69,6 +70,7 @@ export function Form({
onSubmit(formData);
};
const onTypeChange = (tabn) => {
setHasUserInteracted(true);
setSelectedTab(tabn);
};
@ -123,23 +125,49 @@ export function Form({
</Card>
)
}
<Card className="formBody">
<form onSubmit={(e) => onSubmitForm(e)} autoComplete="off" autoCapitalize="off"
spellCheck="false" autoCorrect="off">
{
enabledBackends.map((form, i) => {
const key = Object.keys(form)[0];
if (!form[key]) return null; // TODO: this shouldn't be needed
else if (selectedTab !== i) return null;
return (
<FormBuilder form={form[key]} onChange={onFormChange} key={"form"+i}
render={renderForm} />
);
})
if (selectedTab !== i) return null;
const auth = window.CONFIG["auth"].split(/\s*,\s*/);
if (auth.indexOf(key) !== -1 || auth.indexOf(form[key].label.value) !== -1) {
return hasUserInteracted === false && enabledBackends.length > 1 ? (
<Button onClick={() => onSubmit({ middleware: true, label: form[key].label.value })} theme="emphasis"
style={{padding: "8px"}} key={`sso-${i}`}>
{ t("CONNECT") }
</Button>
) : (
<LoaderWithTimeout key={`loading-${i}`} timeout={100}
callback={() => onSubmit({ middleware: true, label: form[key].label.value })} />
)
}
return (
<Card className="formBody" key={`form${i}`}>
<form onSubmit={(e) => onSubmitForm(e)} autoComplete="off" autoCapitalize="off"
spellCheck="false" autoCorrect="off">
<FormBuilder form={form[key]} onChange={onFormChange}
render={renderForm} />
<Button theme="emphasis">{ t("CONNECT") }</Button>
</form>
</Card>
);
})
}
</div>
);
}
function LoaderWithTimeout({ callback = nop, timeout = 0 }) {
useEffect(() => {
const t = setTimeout(() => callback(), timeout);
return () => {
clearTimeout(t);
}
});
return (
<Loader />
)
}

View File

@ -64,6 +64,9 @@
display: none;
}
}
.component_loader {
margin: 45px 0;
}
}
.component_page_connection_form.form-appear{

View File

@ -5,7 +5,8 @@
flex-direction: column;
h1{margin: 5px 0; font-size: 3.1em;}
h2{margin: 10px 0; font-weight: normal; opacity: 0.9; font-weight: 100;}
h2{margin: 10px 0; font-weight: normal; opacity: 0.9; font-weight: 100; }
code{margin-top: 20px; display: block; background:rgba(255,255,255,0.3);padding: 10px; border: 2px dashed rgba(0,0,0,0.1);}
p{font-style: italic;}
a{border-bottom: 1px dashed;}
}

View File

@ -2,12 +2,19 @@ import React, { useState, useEffect } from "react";
import { Redirect } from "react-router";
import { Session } from "../model/";
import { Loader } from "../components/";
import { Loader, ErrorPage } from "../components/";
import { t } from "../locales/";
export function HomePage() {
export function HomePageComponent({ error }) {
const [redirection, setRedirection] = useState(null);
useEffect(() => {
const p = new URLSearchParams(location.search);
if(p.get("error")) {
error(new Error(t(p.get("error"))));
return;
}
Session.currentUser().then((res) => {
if (res && res.is_authenticated === true) {
setRedirection(res.home ? `/files${res.home}` : "/files");
@ -22,3 +29,5 @@ export function HomePage() {
}
return ( <Redirect to={redirection} /> );
}
export const HomePage = ErrorPage(HomePageComponent);

View File

@ -372,6 +372,7 @@ func (this Configuration) Export() interface{} {
RefreshAfterUpload bool `json:"refresh_after_upload"`
FilePageDefaultSort string `json:"default_sort"`
FilePageDefaultView string `json:"default_view"`
AuthMiddleware interface{} `json:"auth"`
}{
Editor: this.Get("general.editor").String(),
ForkButton: this.Get("general.fork_button").Bool(),
@ -389,6 +390,12 @@ func (this Configuration) Export() interface{} {
RefreshAfterUpload: this.Get("general.refresh_after_upload").Bool(),
FilePageDefaultSort: this.Get("general.filepage_default_sort").String(),
FilePageDefaultView: this.Get("general.filepage_default_view").String(),
AuthMiddleware: func() string {
if this.Get("middleware.identity_provider.type").String() == "" {
return ""
}
return this.Get("middleware.attribute_mapping.related_backend").String()
}(),
}
}

View File

@ -6,11 +6,6 @@ import (
"net/http"
)
const (
PluginTypeBackend = "backend"
PluginTypeMiddleware = "middleware"
)
type Plugin struct {
Type string
Enable bool
@ -18,13 +13,16 @@ type Plugin struct {
type Register struct{}
type Get struct{}
type All struct{}
var Hooks = struct {
Get Get
Register Register
All All
}{
Get: Get{},
Register: Register{},
All: All{},
}
var process_file_content_before_send []func(io.ReadCloser, *App, *http.ResponseWriter, *http.Request) (io.ReadCloser, error)
@ -54,6 +52,16 @@ func (this Get) Starter() []func(*mux.Router) {
return starter_process
}
var authentication_middleware map[string]IAuth = make(map[string]IAuth, 0)
func (this Register) AuthenticationMiddleware(id string, am IAuth) {
authentication_middleware[id] = am
}
func (this All) AuthenticationMiddleware() map[string]IAuth {
return authentication_middleware
}
/*
* UI Overrides
* They are the means by which server plugin change the frontend behaviors.

View File

@ -102,6 +102,9 @@ func Page(stuff string) string {
body { text-align: center; padding-top: 50px; text-align: center; margin: 0; }
h1 { font-weight: 200; line-height: 1em; font-size: 40px; }
p { opacity: 0.8; font-size: 1.05em; }
form { max-width: 500px; margin: 0 auto; padding: 0 10px; text-align: left; }
button { padding: 7px 0px; width: 100%; margin-top: 5px; cursor: pointer; }
input, textarea { display: block; margin: 5px 0; border-radius: 3px; border: 2px solid rgba(0,0,0,0.1); outline: none; padding: 8px 10px; min-width: 100%; max-width: 100%; max-height: 80px; box-sizing: border-box; }
</style>
</head>
<body>

View File

@ -3,6 +3,7 @@ package common
import (
"encoding/json"
"io"
"net/http"
"os"
"time"
)
@ -19,6 +20,12 @@ type IBackend interface {
LoginForm() Form
}
type IAuth interface {
Setup() Form
EntryPoint(req *http.Request, res http.ResponseWriter)
Callback(formData map[string]string, idpParams map[string]string, res http.ResponseWriter) (map[string]string, error)
}
type File struct {
FName string `json:"name"`
FType string `json:"type"`

View File

@ -84,3 +84,13 @@ func AdminBackend(ctx App, res http.ResponseWriter, req *http.Request) {
SendSuccessResultWithEtagAndGzip(res, req, backends)
return
}
func AdminAuthenticationMiddleware(ctx App, res http.ResponseWriter, req *http.Request) {
drivers := Hooks.All.AuthenticationMiddleware()
middlewares := make(map[string]Form, len(drivers))
for id, driver := range drivers {
middlewares[id] = driver.Setup()
}
SendSuccessResultWithEtagAndGzip(res, req, middlewares)
return
}

View File

@ -4,6 +4,7 @@ import (
"encoding/json"
"github.com/gorilla/mux"
. "github.com/mickael-kerjean/filestash/server/common"
. "github.com/mickael-kerjean/filestash/server/middleware"
"github.com/mickael-kerjean/filestash/server/model"
"net/http"
"strings"
@ -19,7 +20,6 @@ func SessionGet(ctx App, res http.ResponseWriter, req *http.Request) {
r := Session{
IsAuth: false,
}
if ctx.Backend == nil {
SendSuccessResult(res, r)
return
@ -100,11 +100,21 @@ func SessionAuthenticate(ctx App, res http.ResponseWriter, req *http.Request) {
}
func SessionLogout(ctx App, res http.ResponseWriter, req *http.Request) {
if ctx.Backend != nil {
if obj, ok := ctx.Backend.(interface{ Close() error }); ok {
go func() {
// user typically expect the logout to feel instant but in our case we still need to make sure
// the connection is closed as lot of backend requires to hold an active session which we cache.
// Whenever somebody logout after say 30 minutes idle, the logout would first create a connection
// then close which can take a few seconds and make for a bad user experience.
// By pushing that connection close in a goroutine, we make sure the logout is much faster for
// the user while still retaining that functionality.
SessionTry(func(c App, _res http.ResponseWriter, _req *http.Request) {
if c.Backend != nil {
if obj, ok := c.Backend.(interface{ Close() error }); ok {
go obj.Close()
}
}
})(ctx, res, req)
}()
http.SetCookie(res, &http.Cookie{
Name: COOKIE_NAME_AUTH,
Value: "",
@ -149,3 +159,165 @@ func SessionOAuthBackend(ctx App, res http.ResponseWriter, req *http.Request) {
}
SendSuccessResult(res, obj.OAuthURL())
}
func SessionAuthMiddleware(ctx App, res http.ResponseWriter, req *http.Request) {
SSOCookieName := "ssoref"
// Step0: Initialisation
_get := req.URL.Query()
plugin := func() IAuth {
selectedPluginId := Config.Get("middleware.identity_provider.type").String()
if selectedPluginId == "" {
return nil
}
for key, plugin := range Hooks.All.AuthenticationMiddleware() {
if key == selectedPluginId {
return plugin
}
}
return nil
}()
if plugin == nil {
http.Redirect(
res, req,
"/?error=Not%20Found&trace=middleware not found",
http.StatusTemporaryRedirect,
)
return
}
formData := map[string]string{}
switch req.Method {
case "GET":
for key, element := range _get {
if len(element) == 0 {
continue
}
formData[key] = element[0]
}
case "POST":
if err := req.ParseForm(); err != nil {
http.Redirect(
res, req,
"/?error=Not%20Valid&trace=parsing body - "+err.Error(),
http.StatusTemporaryRedirect,
)
return
}
for key, values := range req.Form {
if len(values) == 0 {
continue
}
formData[key] = values[0]
}
}
// Step1: Entrypoint of the authentication process is handled by the plugin
if req.Method == "GET" && _get.Get("action") == "redirect" {
if label := _get.Get("label"); label != "" {
http.SetCookie(res, &http.Cookie{
Name: SSOCookieName,
Value: label,
MaxAge: 60 * 10,
Path: COOKIE_PATH,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
}
plugin.EntryPoint(req, res)
return
}
// Step2: End of the authentication process. Could come from:
// - target of a html form. eg: ldap, mysql, ...
// - identity provider redirection uri. eg: oauth2, openid, ...
idpParams := map[string]string{}
if err := json.Unmarshal(
[]byte(Config.Get("middleware.identity_provider.params").String()),
&idpParams,
); err != nil {
http.Redirect(
res, req,
"/?error=Not%20Valid&trace=unpacking idp - "+err.Error(),
http.StatusTemporaryRedirect,
)
return
}
templateBind, err := plugin.Callback(formData, idpParams, res)
if err != nil {
Log.Debug("session::authMiddleware 'callback error - %s'", err.Error())
http.Redirect(res, req, req.URL.Path+"?action=redirect", http.StatusSeeOther)
return
}
Log.Debug("session::authMiddleware 'template bind - \"%+v\"'", templateBind)
// Step3: create a backend connection object
session, err := func(tb map[string]string) (map[string]string, error) {
refCookie, err := req.Cookie(SSOCookieName)
if err != nil {
return map[string]string{}, err
}
globalMapping := map[string]map[string]interface{}{}
if err = json.Unmarshal(
[]byte(Config.Get("middleware.attribute_mapping.params").String()),
&globalMapping,
); err != nil {
return map[string]string{}, err
}
mappingToUse := map[string]string{}
for k, v := range globalMapping[refCookie.Value] {
mappingToUse[k] = NewStringFromInterface(v)
}
mappingToUse["timestamp"] = time.Now().String()
return mappingToUse, nil
}(templateBind)
if err != nil {
Log.Debug("session::authMiddleware 'auth mapping failed %s'", err.Error())
http.Redirect(
res, req,
"/?error=Not%20Valid&trace=mapping_error - "+err.Error(),
http.StatusTemporaryRedirect,
)
return
}
if _, err := model.NewBackend(&ctx, session); err != nil {
Log.Debug("session::authMiddleware 'backend connection failed %+v - %s'", session, err.Error())
http.Redirect(
res, req,
"/?error=Not%20Valid&trace=backend error - "+err.Error(),
http.StatusTemporaryRedirect,
)
return
}
// Step4: persist connection with a cookie
s, err := json.Marshal(session)
if err != nil {
Log.Debug("session::authMiddleware 'session marshal error %+v'", session)
SendErrorResult(res, ErrNotValid)
return
}
obfuscate, err := EncryptString(SECRET_KEY_DERIVATE_FOR_USER, string(s))
if err != nil {
Log.Debug("session::authMiddleware 'encryption error - %s", err.Error())
SendErrorResult(res, ErrNotValid)
return
}
http.SetCookie(res, &http.Cookie{
Name: COOKIE_NAME_AUTH,
Value: obfuscate,
MaxAge: 60 * Config.Get("general.cookie_timeout").Int(),
Path: COOKIE_PATH,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
http.SetCookie(res, &http.Cookie{
Name: SSOCookieName,
Value: "",
MaxAge: -1,
Path: COOKIE_PATH,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
http.Redirect(res, req, "/", http.StatusTemporaryRedirect)
}

View File

@ -30,10 +30,11 @@ func Init(a *App) {
session.HandleFunc("", NewMiddlewareChain(SessionGet, middlewares, *a)).Methods("GET")
middlewares = []Middleware{ApiHeaders, SecureHeaders, SecureAjax, BodyParser}
session.HandleFunc("", NewMiddlewareChain(SessionAuthenticate, middlewares, *a)).Methods("POST")
middlewares = []Middleware{ApiHeaders, SecureHeaders, SecureAjax, SessionTry}
middlewares = []Middleware{ApiHeaders, SecureHeaders, SecureAjax}
session.HandleFunc("", NewMiddlewareChain(SessionLogout, middlewares, *a)).Methods("DELETE")
middlewares = []Middleware{ApiHeaders, SecureHeaders}
session.HandleFunc("/auth/{service}", NewMiddlewareChain(SessionOAuthBackend, middlewares, *a)).Methods("GET")
session.HandleFunc("/auth/", NewMiddlewareChain(SessionAuthMiddleware, middlewares, *a)).Methods("GET", "POST")
// API for admin
middlewares = []Middleware{ApiHeaders, SecureAjax}
@ -87,6 +88,7 @@ func Init(a *App) {
middlewares = []Middleware{ApiHeaders}
r.HandleFunc("/api/config", NewMiddlewareChain(PublicConfigHandler, middlewares, *a)).Methods("GET")
r.HandleFunc("/api/backend", NewMiddlewareChain(AdminBackend, middlewares, *a)).Methods("GET")
r.HandleFunc("/api/middlewares/authentication", NewMiddlewareChain(AdminAuthenticationMiddleware, middlewares, *a)).Methods("GET")
middlewares = []Middleware{StaticHeaders}
r.PathPrefix("/assets").Handler(http.HandlerFunc(NewMiddlewareChain(StaticHandler(FILE_ASSETS), middlewares, *a))).Methods("GET")
r.HandleFunc("/favicon.ico", NewMiddlewareChain(StaticHandler(FILE_ASSETS+"/assets/logo/"), middlewares, *a)).Methods("GET")
@ -110,7 +112,7 @@ func Init(a *App) {
initPluginsRoutes(r, a)
r.PathPrefix("/admin").Handler(http.HandlerFunc(NewMiddlewareChain(IndexHandler(FILE_INDEX), middlewares, *a))).Methods("GET")
r.PathPrefix("/").Handler(http.HandlerFunc(NewMiddlewareChain(IndexHandler(FILE_INDEX), middlewares, *a))).Methods("GET")
r.PathPrefix("/").Handler(http.HandlerFunc(NewMiddlewareChain(IndexHandler(FILE_INDEX), middlewares, *a))).Methods("GET", "POST")
// Routes are served via plugins to avoid getting stuck with plain HTTP. The idea is to
// support many more protocols in the future: HTTPS, HTTP2, TOR or whatever that sounds

View File

@ -2,6 +2,10 @@ package plugin
import (
. "github.com/mickael-kerjean/filestash/server/common"
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_authenticate_admin"
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_authenticate_ldap"
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_authenticate_openid"
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_authenticate_saml"
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_backend_backblaze"
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_backend_dav"
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_backend_dropbox"

View File

@ -0,0 +1,66 @@
package plg_authenticate_ldap
import (
. "github.com/mickael-kerjean/filestash/server/common"
"net/http"
)
func init() {
Hooks.Register.AuthenticationMiddleware("ldap", Ldap{})
}
type Ldap struct{}
func (this Ldap) Setup() Form {
return Form{
Elmnts: []FormElement{
{
Name: "type",
Type: "hidden",
Value: "ldap",
},
{
Name: "Hostname",
Type: "text",
Value: "",
Placeholder: "eg: ldap.example.com",
},
{
Name: "Port",
Type: "text",
Value: "",
Placeholder: "eg: 389",
},
{
Name: "Bind DN",
Type: "text",
Value: "",
Placeholder: "Bind DN",
},
{
Name: "Bind DN Password",
Type: "text",
Value: "",
Placeholder: "Bind CN Password",
},
{
Name: "Base DN",
Type: "text",
Value: "",
Placeholder: "Base DN",
},
},
}
}
func (this Ldap) EntryPoint(req *http.Request, res http.ResponseWriter) {
http.Redirect(
res, req,
"/?error=ldap is available for enterprise customer, see https://www.filestash.app/pricing/?modal=enterprise",
http.StatusTemporaryRedirect,
)
}
func (this Ldap) Callback(formData map[string]string, idpParams map[string]string, res http.ResponseWriter) (map[string]string, error) {
return nil, ErrNotImplemented
}

View File

@ -0,0 +1,57 @@
package plg_authenticate_openid
import (
. "github.com/mickael-kerjean/filestash/server/common"
"net/http"
)
func init() {
Hooks.Register.AuthenticationMiddleware("openid", OpenID{})
}
type OpenID struct{}
func (this OpenID) Setup() Form {
return Form{
Elmnts: []FormElement{
{
Name: "type",
Type: "hidden",
Value: "openid",
},
{
Name: "OpenID Config URL",
Type: "text",
ReadOnly: true,
Value: "<VISIT https://www.filestash.app/pricing>",
Placeholder: "OpenID Configuration URL",
},
{
Name: "Client ID",
Type: "text",
ReadOnly: true,
Value: "<VISIT https://www.filestash.app/pricing>",
Placeholder: "Client ID",
},
{
Name: "Scope",
Type: "text",
ReadOnly: true,
Value: "<VISIT https://www.filestash.app/pricing>",
Placeholder: "OpenID Scope: default 'openid'",
},
},
}
}
func (this OpenID) EntryPoint(req *http.Request, res http.ResponseWriter) {
http.Redirect(
res, req,
"/?error=oidc is available for enterprise customer, see https://www.filestash.app/pricing/?modal=enterprise",
http.StatusTemporaryRedirect,
)
}
func (this OpenID) Callback(formData map[string]string, idpParams map[string]string, res http.ResponseWriter) (map[string]string, error) {
return nil, ErrNotImplemented
}

View File

@ -0,0 +1,50 @@
package plg_authenticate_saml
import (
. "github.com/mickael-kerjean/filestash/server/common"
"net/http"
)
func init() {
Hooks.Register.AuthenticationMiddleware("saml", Saml{})
}
type Saml struct{}
func (this Saml) Setup() Form {
return Form{
Elmnts: []FormElement{
{
Name: "type",
Type: "hidden",
Value: "saml",
},
{
Name: "SP Metadata",
Type: "text",
ReadOnly: true,
Value: "<VISIT https://www.filestash.app/pricing>",
Placeholder: "Metadata you need to import onto your IDP",
},
{
Name: "IDP Metadata",
Type: "text",
ReadOnly: true,
Value: "<VISIT https://www.filestash.app/pricing>",
Placeholder: "Metadata url given by your IDP",
},
},
}
}
func (this Saml) EntryPoint(req *http.Request, res http.ResponseWriter) {
http.Redirect(
res, req,
"/?error=saml is available for enterprise customer, see https://www.filestash.app/pricing/?modal=enterprise",
http.StatusTemporaryRedirect,
)
}
func (this Saml) Callback(formData map[string]string, idpParams map[string]string, res http.ResponseWriter) (map[string]string, error) {
return nil, ErrNotImplemented
}