mirror of
https://github.com/mickael-kerjean/filestash.git
synced 2025-11-02 03:54:59 +08:00
feature (sso): authentication middleware
This commit is contained in:
@ -14,6 +14,10 @@
|
||||
&.success{
|
||||
background: var(--success);
|
||||
}
|
||||
&.error{
|
||||
background: var(--error);
|
||||
color: var(--bg-color);
|
||||
}
|
||||
|
||||
img{
|
||||
max-width: 100%;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
.component_page_admin{
|
||||
display: flex;
|
||||
.adminpage { max-width: 1400px; }
|
||||
.page_container{
|
||||
width: 100%;
|
||||
background: var(--super-light);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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) => (
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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 />
|
||||
)
|
||||
}
|
||||
|
||||
@ -64,6 +64,9 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.component_loader {
|
||||
margin: 45px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.component_page_connection_form.form-appear{
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
h1{margin: 5px 0; font-size: 3.1em;}
|
||||
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;}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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()
|
||||
}(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
66
server/plugin/plg_authenticate_ldap/index.go
Normal file
66
server/plugin/plg_authenticate_ldap/index.go
Normal 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
|
||||
}
|
||||
57
server/plugin/plg_authenticate_openid/index.go
Normal file
57
server/plugin/plg_authenticate_openid/index.go
Normal 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
|
||||
}
|
||||
50
server/plugin/plg_authenticate_saml/index.go
Normal file
50
server/plugin/plg_authenticate_saml/index.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user