feat: allow to password protect shares (#1252)
This changes allows to password protect shares. It works by: * Allowing to optionally pass a password when creating a share * If set, the password + salt that is configured via a new flag will be hashed via bcrypt and the hash stored together with the rest of the share * Additionally, a random 96 byte long token gets generated and stored as part of the share * When the backend retrieves an unauthenticated request for a share that has authentication configured, it will return a http 401 * The frontend detects this and will show a login prompt * The actual download links are protected via an url arg that contains the previously generated token. This allows us to avoid buffering the download in the browser and allows pasting the link without breaking itpull/1304/head
parent
977ec33918
commit
d8f415f8ab
|
@ -2,7 +2,7 @@ version: 2
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
docker:
|
docker:
|
||||||
- image: golangci/golangci-lint:v1.27.0
|
- image: golangci/golangci-lint:v1.31.0
|
||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
- run: golangci-lint run -v
|
- run: golangci-lint run -v
|
||||||
|
@ -89,4 +89,4 @@ workflows:
|
||||||
tags:
|
tags:
|
||||||
only: /^v.*/
|
only: /^v.*/
|
||||||
branches:
|
branches:
|
||||||
ignore: /.*/
|
ignore: /.*/
|
||||||
|
|
|
@ -9,7 +9,7 @@ import (
|
||||||
// Auther is the authentication interface.
|
// Auther is the authentication interface.
|
||||||
type Auther interface {
|
type Auther interface {
|
||||||
// Auth is called to authenticate a request.
|
// Auth is called to authenticate a request.
|
||||||
Auth(r *http.Request, s *users.Storage, root string) (*users.User, error)
|
Auth(r *http.Request, s users.Store, root string) (*users.User, error)
|
||||||
// LoginPage indicates if this auther needs a login page.
|
// LoginPage indicates if this auther needs a login page.
|
||||||
LoginPage() bool
|
LoginPage() bool
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,7 +26,7 @@ type JSONAuth struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth authenticates the user via a json in content body.
|
// Auth authenticates the user via a json in content body.
|
||||||
func (a JSONAuth) Auth(r *http.Request, sto *users.Storage, root string) (*users.User, error) {
|
func (a JSONAuth) Auth(r *http.Request, sto users.Store, root string) (*users.User, error) {
|
||||||
var cred jsonCred
|
var cred jsonCred
|
||||||
|
|
||||||
if r.Body == nil {
|
if r.Body == nil {
|
||||||
|
|
|
@ -14,7 +14,7 @@ const MethodNoAuth settings.AuthMethod = "noauth"
|
||||||
type NoAuth struct{}
|
type NoAuth struct{}
|
||||||
|
|
||||||
// Auth uses authenticates user 1.
|
// Auth uses authenticates user 1.
|
||||||
func (a NoAuth) Auth(r *http.Request, sto *users.Storage, root string) (*users.User, error) {
|
func (a NoAuth) Auth(r *http.Request, sto users.Store, root string) (*users.User, error) {
|
||||||
return sto.Get(root, uint(1))
|
return sto.Get(root, uint(1))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -18,7 +18,7 @@ type ProxyAuth struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth authenticates the user via an HTTP header.
|
// Auth authenticates the user via an HTTP header.
|
||||||
func (a ProxyAuth) Auth(r *http.Request, sto *users.Storage, root string) (*users.User, error) {
|
func (a ProxyAuth) Auth(r *http.Request, sto users.Store, root string) (*users.User, error) {
|
||||||
username := r.Header.Get(a.Header)
|
username := r.Header.Get(a.Header)
|
||||||
user, err := sto.Get(root, username)
|
user, err := sto.Get(root, username)
|
||||||
if err == errors.ErrNotExist {
|
if err == errors.ErrNotExist {
|
||||||
|
|
|
@ -38,6 +38,7 @@ type FileInfo struct {
|
||||||
Subtitles []string `json:"subtitles,omitempty"`
|
Subtitles []string `json:"subtitles,omitempty"`
|
||||||
Content string `json:"content,omitempty"`
|
Content string `json:"content,omitempty"`
|
||||||
Checksums map[string]string `json:"checksums,omitempty"`
|
Checksums map[string]string `json:"checksums,omitempty"`
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileOptions are the options when getting a file info.
|
// FileOptions are the options when getting a file info.
|
||||||
|
@ -47,6 +48,7 @@ type FileOptions struct {
|
||||||
Modify bool
|
Modify bool
|
||||||
Expand bool
|
Expand bool
|
||||||
ReadHeader bool
|
ReadHeader bool
|
||||||
|
Token string
|
||||||
Checker rules.Checker
|
Checker rules.Checker
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -72,6 +74,7 @@ func NewFileInfo(opts FileOptions) (*FileInfo, error) {
|
||||||
IsDir: info.IsDir(),
|
IsDir: info.IsDir(),
|
||||||
Size: info.Size(),
|
Size: info.Size(),
|
||||||
Extension: filepath.Ext(info.Name()),
|
Extension: filepath.Ext(info.Name()),
|
||||||
|
Token: opts.Token,
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.Expand {
|
if opts.Expand {
|
||||||
|
|
|
@ -77,8 +77,13 @@ export function download (format, ...files) {
|
||||||
if (format !== null) {
|
if (format !== null) {
|
||||||
url += `algo=${format}&`
|
url += `algo=${format}&`
|
||||||
}
|
}
|
||||||
|
if (store.state.jwt !== ''){
|
||||||
|
url += `auth=${store.state.jwt}&`
|
||||||
|
}
|
||||||
|
if (store.state.token !== ''){
|
||||||
|
url += `token=${store.state.token}`
|
||||||
|
}
|
||||||
|
|
||||||
url += `auth=${store.state.jwt}`
|
|
||||||
window.open(url)
|
window.open(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,8 +4,10 @@ export async function list() {
|
||||||
return fetchJSON('/api/shares')
|
return fetchJSON('/api/shares')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getHash(hash) {
|
export async function getHash(hash, password = "") {
|
||||||
return fetchJSON(`/api/public/share/${hash}`)
|
return fetchJSON(`/api/public/share/${hash}`, {
|
||||||
|
headers: {'X-SHARE-PASSWORD': password},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function get(url) {
|
export async function get(url) {
|
||||||
|
@ -23,14 +25,18 @@ export async function remove(hash) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function create(url, expires = '', unit = 'hours') {
|
export async function create(url, password = '', expires = '', unit = 'hours') {
|
||||||
url = removePrefix(url)
|
url = removePrefix(url)
|
||||||
url = `/api/share${url}`
|
url = `/api/share${url}`
|
||||||
if (expires !== '') {
|
if (expires !== '') {
|
||||||
url += `?expires=${expires}&unit=${unit}`
|
url += `?expires=${expires}&unit=${unit}`
|
||||||
}
|
}
|
||||||
|
let body = '{}';
|
||||||
|
if (password != '' || expires !== '' || unit !== 'hours') {
|
||||||
|
body = JSON.stringify({password: password, expires: expires, unit: unit})
|
||||||
|
}
|
||||||
return fetchJSON(url, {
|
return fetchJSON(url, {
|
||||||
method: 'POST'
|
method: 'POST',
|
||||||
|
body: body,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,11 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="card floating" id="share">
|
<div class="card floating share__promt__card" id="share">
|
||||||
<div class="card-title">
|
<div class="card-title">
|
||||||
<h2>{{ $t('buttons.share') }}</h2>
|
<h2>{{ $t('buttons.share') }}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<ul>
|
<ul>
|
||||||
<li v-if="!hasPermanent">
|
|
||||||
<a @click="getPermalink" :aria-label="$t('buttons.permalink')">{{ $t('buttons.permalink') }}</a>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li v-for="link in links" :key="link.hash">
|
<li v-for="link in links" :key="link.hash">
|
||||||
<a :href="buildLink(link.hash)" target="_blank">
|
<a :href="buildLink(link.hash)" target="_blank">
|
||||||
|
@ -27,6 +24,13 @@
|
||||||
:title="$t('buttons.copyToClipboard')"><i class="material-icons">content_paste</i></button>
|
:title="$t('buttons.copyToClipboard')"><i class="material-icons">content_paste</i></button>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<li v-if="!hasPermanent">
|
||||||
|
<div>
|
||||||
|
<input type="password" :placeholder="$t('prompts.optionalPassword')" v-model="passwordPermalink">
|
||||||
|
<a @click="getPermalink" :aria-label="$t('buttons.permalink')">{{ $t('buttons.permalink') }}</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<input v-focus
|
<input v-focus
|
||||||
type="number"
|
type="number"
|
||||||
|
@ -40,6 +44,7 @@
|
||||||
<option value="hours">{{ $t('time.hours') }}</option>
|
<option value="hours">{{ $t('time.hours') }}</option>
|
||||||
<option value="days">{{ $t('time.days') }}</option>
|
<option value="days">{{ $t('time.days') }}</option>
|
||||||
</select>
|
</select>
|
||||||
|
<input type="password" :placeholder="$t('prompts.optionalPassword')" v-model="password">
|
||||||
<button class="action"
|
<button class="action"
|
||||||
@click="submit"
|
@click="submit"
|
||||||
:aria-label="$t('buttons.create')"
|
:aria-label="$t('buttons.create')"
|
||||||
|
@ -72,7 +77,9 @@ export default {
|
||||||
unit: 'hours',
|
unit: 'hours',
|
||||||
hasPermanent: false,
|
hasPermanent: false,
|
||||||
links: [],
|
links: [],
|
||||||
clip: null
|
clip: null,
|
||||||
|
password: '',
|
||||||
|
passwordPermalink: ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
@ -121,7 +128,7 @@ export default {
|
||||||
if (!this.time) return
|
if (!this.time) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.create(this.url, this.time, this.unit)
|
const res = await api.create(this.url, this.password, this.time, this.unit)
|
||||||
this.links.push(res)
|
this.links.push(res)
|
||||||
this.sort()
|
this.sort()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -130,7 +137,7 @@ export default {
|
||||||
},
|
},
|
||||||
getPermalink: async function () {
|
getPermalink: async function () {
|
||||||
try {
|
try {
|
||||||
const res = await api.create(this.url)
|
const res = await api.create(this.url, this.passwordPermalink)
|
||||||
this.links.push(res)
|
this.links.push(res)
|
||||||
this.sort()
|
this.sort()
|
||||||
this.hasPermanent = true
|
this.hasPermanent = true
|
||||||
|
|
|
@ -62,4 +62,17 @@
|
||||||
|
|
||||||
.share__box__items #listing.list .item .modified {
|
.share__box__items #listing.list .item .modified {
|
||||||
width: 25%;
|
width: 25%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.share__wrong__password {
|
||||||
|
background: var(--red);
|
||||||
|
color: #fff;
|
||||||
|
padding: .5em;
|
||||||
|
text-align: center;
|
||||||
|
animation: .2s opac forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share__promt__card {
|
||||||
|
max-width: max-content !important;
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
|
|
@ -29,6 +29,7 @@
|
||||||
"selectMultiple": "Select multiple",
|
"selectMultiple": "Select multiple",
|
||||||
"share": "Share",
|
"share": "Share",
|
||||||
"shell": "Toggle shell",
|
"shell": "Toggle shell",
|
||||||
|
"submit": "Submit",
|
||||||
"switchView": "Switch view",
|
"switchView": "Switch view",
|
||||||
"toggleSidebar": "Toggle sidebar",
|
"toggleSidebar": "Toggle sidebar",
|
||||||
"update": "Update",
|
"update": "Update",
|
||||||
|
@ -142,7 +143,8 @@
|
||||||
"show": "Show",
|
"show": "Show",
|
||||||
"size": "Size",
|
"size": "Size",
|
||||||
"upload": "Upload",
|
"upload": "Upload",
|
||||||
"uploadMessage": "Select an option to upload."
|
"uploadMessage": "Select an option to upload.",
|
||||||
|
"optionalPassword": "Optional password"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"images": "Images",
|
"images": "Images",
|
||||||
|
|
|
@ -25,7 +25,8 @@ const state = {
|
||||||
showMessage: null,
|
showMessage: null,
|
||||||
showConfirm: null,
|
showConfirm: null,
|
||||||
previewMode: false,
|
previewMode: false,
|
||||||
hash: ''
|
hash: '',
|
||||||
|
token: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new Vuex.Store({
|
export default new Vuex.Store({
|
||||||
|
|
|
@ -46,6 +46,7 @@ const mutations = {
|
||||||
state.user = value
|
state.user = value
|
||||||
},
|
},
|
||||||
setJWT: (state, value) => (state.jwt = value),
|
setJWT: (state, value) => (state.jwt = value),
|
||||||
|
setToken: (state, value ) => (state.token = value),
|
||||||
multiple: (state, value) => (state.multiple = value),
|
multiple: (state, value) => (state.multiple = value),
|
||||||
addSelected: (state, value) => (state.selected.push(value)),
|
addSelected: (state, value) => (state.selected.push(value)),
|
||||||
addPlugin: (state, value) => {
|
addPlugin: (state, value) => {
|
||||||
|
|
|
@ -74,6 +74,24 @@
|
||||||
<div v-else-if="error">
|
<div v-else-if="error">
|
||||||
<not-found v-if="error.message === '404'"></not-found>
|
<not-found v-if="error.message === '404'"></not-found>
|
||||||
<forbidden v-else-if="error.message === '403'"></forbidden>
|
<forbidden v-else-if="error.message === '403'"></forbidden>
|
||||||
|
<div v-else-if="error.message === '401'">
|
||||||
|
<div class="card floating" id="password">
|
||||||
|
<div v-if="attemptedPasswordLogin" class="share__wrong__password">{{ $t('login.wrongCredentials') }}</div>
|
||||||
|
<div class="card-title">
|
||||||
|
<h2>{{ $t('login.password') }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-content">
|
||||||
|
<input v-focus type="password" :placeholder="$t('login.password')" v-model="password" @keyup.enter="fetchData">
|
||||||
|
</div>
|
||||||
|
<div class="card-action">
|
||||||
|
<button class="button button--flat"
|
||||||
|
@click="fetchData"
|
||||||
|
:aria-label="$t('buttons.submit')"
|
||||||
|
:title="$t('buttons.submit')">{{ $t('buttons.submit') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<internal-error v-else></internal-error>
|
<internal-error v-else></internal-error>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -102,7 +120,9 @@ export default {
|
||||||
data: () => ({
|
data: () => ({
|
||||||
error: null,
|
error: null,
|
||||||
path: '',
|
path: '',
|
||||||
showLimit: 500
|
showLimit: 500,
|
||||||
|
password: '',
|
||||||
|
attemptedPasswordLogin: false
|
||||||
}),
|
}),
|
||||||
watch: {
|
watch: {
|
||||||
'$route': 'fetchData'
|
'$route': 'fetchData'
|
||||||
|
@ -129,7 +149,11 @@ export default {
|
||||||
return 'insert_drive_file'
|
return 'insert_drive_file'
|
||||||
},
|
},
|
||||||
link: function () {
|
link: function () {
|
||||||
return `${baseURL}/api/public/dl/${this.hash}${this.path}`
|
let queryArg = '';
|
||||||
|
if (this.token !== ''){
|
||||||
|
queryArg = `?token=${this.token}`
|
||||||
|
}
|
||||||
|
return `${baseURL}/api/public/dl/${this.hash}${this.path}${queryArg}`
|
||||||
},
|
},
|
||||||
fullLink: function () {
|
fullLink: function () {
|
||||||
return window.location.origin + this.link
|
return window.location.origin + this.link
|
||||||
|
@ -193,8 +217,13 @@ export default {
|
||||||
this.error = null
|
this.error = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let file = await api.getHash(encodeURIComponent(this.$route.params.pathMatch))
|
if (this.password !== ''){
|
||||||
|
this.attemptedPasswordLogin = true
|
||||||
|
}
|
||||||
|
let file = await api.getHash(encodeURIComponent(this.$route.params.pathMatch), this.password)
|
||||||
this.path = file.path
|
this.path = file.path
|
||||||
|
this.token = file.token || ''
|
||||||
|
this.$store.commit('setToken', this.token)
|
||||||
if (file.isDir) file.items = file.items.map((item, index) => {
|
if (file.isDir) file.items = file.items.map((item, index) => {
|
||||||
item.index = index
|
item.index = index
|
||||||
item.url = `/share/${this.hash}${this.path}/${encodeURIComponent(item.name)}`
|
item.url = `/share/${this.hash}${this.path}/${encodeURIComponent(item.name)}`
|
||||||
|
|
|
@ -51,7 +51,7 @@ func handle(fn handleFunc, prefix string, store *storage.Storage, server *settin
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
settings, err := store.Settings.Get()
|
settings, err := store.Settings.Get()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalln("ERROR: couldn't get settings")
|
log.Fatalf("ERROR: couldn't get settings: %v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,14 +1,17 @@
|
||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
"github.com/filebrowser/filebrowser/v2/files"
|
"github.com/filebrowser/filebrowser/v2/files"
|
||||||
|
"github.com/filebrowser/filebrowser/v2/share"
|
||||||
)
|
)
|
||||||
|
|
||||||
var withHashFile = func(fn handleFunc) handleFunc {
|
var withHashFile = func(fn handleFunc) handleFunc {
|
||||||
|
@ -19,6 +22,11 @@ var withHashFile = func(fn handleFunc) handleFunc {
|
||||||
return errToStatus(err), err
|
return errToStatus(err), err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
status, err := authenticateShareRequest(r, link)
|
||||||
|
if status != 0 || err != nil {
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
|
||||||
user, err := d.store.Users.Get(d.server.Root, link.UserID)
|
user, err := d.store.Users.Get(d.server.Root, link.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errToStatus(err), err
|
return errToStatus(err), err
|
||||||
|
@ -33,6 +41,7 @@ var withHashFile = func(fn handleFunc) handleFunc {
|
||||||
Expand: true,
|
Expand: true,
|
||||||
ReadHeader: d.server.TypeDetectionByHeader,
|
ReadHeader: d.server.TypeDetectionByHeader,
|
||||||
Checker: d,
|
Checker: d,
|
||||||
|
Token: link.Token,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errToStatus(err), err
|
return errToStatus(err), err
|
||||||
|
@ -48,6 +57,7 @@ var withHashFile = func(fn handleFunc) handleFunc {
|
||||||
Modify: d.user.Perm.Modify,
|
Modify: d.user.Perm.Modify,
|
||||||
Expand: true,
|
Expand: true,
|
||||||
Checker: d,
|
Checker: d,
|
||||||
|
Token: link.Token,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errToStatus(err), err
|
return errToStatus(err), err
|
||||||
|
@ -94,3 +104,26 @@ var publicDlHandler = withHashFile(func(w http.ResponseWriter, r *http.Request,
|
||||||
|
|
||||||
return rawDirHandler(w, r, d, file)
|
return rawDirHandler(w, r, d, file)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
|
||||||
|
if l.PasswordHash == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.URL.Query().Get("token") == l.Token {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
password := r.Header.Get("X-SHARE-PASSWORD")
|
||||||
|
if password == "" {
|
||||||
|
return http.StatusUnauthorized, nil
|
||||||
|
}
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(l.PasswordHash), []byte(password)); err != nil {
|
||||||
|
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
|
||||||
|
return http.StatusUnauthorized, nil
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,136 @@
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/asdine/storm"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
|
||||||
|
"github.com/filebrowser/filebrowser/v2/settings"
|
||||||
|
"github.com/filebrowser/filebrowser/v2/share"
|
||||||
|
"github.com/filebrowser/filebrowser/v2/storage/bolt"
|
||||||
|
"github.com/filebrowser/filebrowser/v2/users"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPublicShareHandlerAuthentication(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const passwordBcrypt = "$2y$10$TFAmdCbyd/mEZDe5fUeZJu.MaJQXRTwdqb/IQV.eTn6dWrF58gCSe" //nolint:gosec
|
||||||
|
testCases := map[string]struct {
|
||||||
|
share *share.Link
|
||||||
|
req *http.Request
|
||||||
|
expectedStatusCode int
|
||||||
|
}{
|
||||||
|
"Public share, no auth required": {
|
||||||
|
share: &share.Link{Hash: "h", UserID: 1},
|
||||||
|
req: newHTTPRequest(t),
|
||||||
|
expectedStatusCode: 200,
|
||||||
|
},
|
||||||
|
"Private share, no auth provided, 401": {
|
||||||
|
share: &share.Link{Hash: "h", UserID: 1, PasswordHash: passwordBcrypt, Token: "123"},
|
||||||
|
req: newHTTPRequest(t),
|
||||||
|
expectedStatusCode: 401,
|
||||||
|
},
|
||||||
|
"Private share, authentication via token": {
|
||||||
|
share: &share.Link{Hash: "h", UserID: 1, PasswordHash: passwordBcrypt, Token: "123"},
|
||||||
|
req: newHTTPRequest(t, func(r *http.Request) { r.URL.RawQuery = "token=123" }),
|
||||||
|
expectedStatusCode: 200,
|
||||||
|
},
|
||||||
|
"Private share, authentication via invalid token, 401": {
|
||||||
|
share: &share.Link{Hash: "h", UserID: 1, PasswordHash: passwordBcrypt, Token: "123"},
|
||||||
|
req: newHTTPRequest(t, func(r *http.Request) { r.URL.RawQuery = "token=1234" }),
|
||||||
|
expectedStatusCode: 401,
|
||||||
|
},
|
||||||
|
"Private share, authentication via password": {
|
||||||
|
share: &share.Link{Hash: "h", UserID: 1, PasswordHash: passwordBcrypt, Token: "123"},
|
||||||
|
req: newHTTPRequest(t, func(r *http.Request) { r.Header.Set("X-SHARE-PASSWORD", "password") }),
|
||||||
|
expectedStatusCode: 200,
|
||||||
|
},
|
||||||
|
"Private share, authentication via invalid password, 401": {
|
||||||
|
share: &share.Link{Hash: "h", UserID: 1, PasswordHash: passwordBcrypt, Token: "123"},
|
||||||
|
req: newHTTPRequest(t, func(r *http.Request) { r.Header.Set("X-SHARE-PASSWORD", "wrong-password") }),
|
||||||
|
expectedStatusCode: 401,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, tc := range testCases {
|
||||||
|
for handlerName, handler := range map[string]handleFunc{"public share handler": publicShareHandler, "public dl handler": publicDlHandler} {
|
||||||
|
name, tc, handlerName, handler := name, tc, handlerName, handler
|
||||||
|
t.Run(fmt.Sprintf("%s: %s", handlerName, name), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "db")
|
||||||
|
db, err := storm.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := db.Close(); err != nil { //nolint:shadow
|
||||||
|
t.Errorf("failed to close db: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
storage, err := bolt.NewStorage(db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get storage: %v", err)
|
||||||
|
}
|
||||||
|
if err := storage.Share.Save(tc.share); err != nil {
|
||||||
|
t.Fatalf("failed to save share: %v", err)
|
||||||
|
}
|
||||||
|
if err := storage.Users.Save(&users.User{Username: "username", Password: "pw"}); err != nil {
|
||||||
|
t.Fatalf("failed to save user: %v", err)
|
||||||
|
}
|
||||||
|
if err := storage.Settings.Save(&settings.Settings{Key: []byte("key")}); err != nil {
|
||||||
|
t.Fatalf("failed to save settings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
storage.Users = &customFSUser{
|
||||||
|
Store: storage.Users,
|
||||||
|
fs: &afero.MemMapFs{},
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
handler := handle(handler, "", storage, &settings.Server{})
|
||||||
|
|
||||||
|
handler.ServeHTTP(recorder, tc.req)
|
||||||
|
result := recorder.Result()
|
||||||
|
defer result.Body.Close()
|
||||||
|
if result.StatusCode != tc.expectedStatusCode {
|
||||||
|
t.Errorf("expected status code %d, got status code %d", tc.expectedStatusCode, result.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHTTPRequest(t *testing.T, requestModifiers ...func(*http.Request)) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
r, err := http.NewRequest(http.MethodGet, "h", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to construct request: %v", err)
|
||||||
|
}
|
||||||
|
for _, modify := range requestModifiers {
|
||||||
|
modify(r)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
type customFSUser struct {
|
||||||
|
users.Store
|
||||||
|
fs afero.Fs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cu *customFSUser) Get(baseScope string, id interface{}) (*users.User, error) {
|
||||||
|
user, err := cu.Store.Get(baseScope, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
user.Fs = cu.fs
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
|
@ -3,6 +3,8 @@ package http
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
"sort"
|
"sort"
|
||||||
|
@ -10,6 +12,8 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
"github.com/filebrowser/filebrowser/v2/errors"
|
"github.com/filebrowser/filebrowser/v2/errors"
|
||||||
"github.com/filebrowser/filebrowser/v2/share"
|
"github.com/filebrowser/filebrowser/v2/share"
|
||||||
)
|
)
|
||||||
|
@ -79,10 +83,15 @@ var shareDeleteHandler = withPermShare(func(w http.ResponseWriter, r *http.Reque
|
||||||
|
|
||||||
var sharePostHandler = withPermShare(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
var sharePostHandler = withPermShare(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||||
var s *share.Link
|
var s *share.Link
|
||||||
rawExpire := r.URL.Query().Get("expires")
|
var body share.CreateBody
|
||||||
unit := r.URL.Query().Get("unit")
|
if r.Body != nil {
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
return http.StatusBadRequest, fmt.Errorf("failed to decode body: %w", err)
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
if rawExpire == "" {
|
if body.Expires == "" {
|
||||||
var err error
|
var err error
|
||||||
s, err = d.store.Share.GetPermanent(r.URL.Path, d.user.ID)
|
s, err = d.store.Share.GetPermanent(r.URL.Path, d.user.ID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
@ -103,14 +112,15 @@ var sharePostHandler = withPermShare(func(w http.ResponseWriter, r *http.Request
|
||||||
|
|
||||||
var expire int64 = 0
|
var expire int64 = 0
|
||||||
|
|
||||||
if rawExpire != "" {
|
if body.Expires != "" {
|
||||||
num, err := strconv.Atoi(rawExpire)
|
//nolint:govet
|
||||||
|
num, err := strconv.Atoi(body.Expires)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return http.StatusInternalServerError, err
|
return http.StatusInternalServerError, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var add time.Duration
|
var add time.Duration
|
||||||
switch unit {
|
switch body.Unit {
|
||||||
case "seconds":
|
case "seconds":
|
||||||
add = time.Second * time.Duration(num)
|
add = time.Second * time.Duration(num)
|
||||||
case "minutes":
|
case "minutes":
|
||||||
|
@ -124,11 +134,27 @@ var sharePostHandler = withPermShare(func(w http.ResponseWriter, r *http.Request
|
||||||
expire = time.Now().Add(add).Unix()
|
expire = time.Now().Add(add).Unix()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hash, status, err := getSharePasswordHash(body)
|
||||||
|
if err != nil {
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var token string
|
||||||
|
if len(hash) > 0 {
|
||||||
|
tokenBuffer := make([]byte, 96)
|
||||||
|
if _, err := rand.Read(tokenBuffer); err != nil {
|
||||||
|
return http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
token = base64.URLEncoding.EncodeToString(tokenBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
s = &share.Link{
|
s = &share.Link{
|
||||||
Path: r.URL.Path,
|
Path: r.URL.Path,
|
||||||
Hash: str,
|
Hash: str,
|
||||||
Expire: expire,
|
Expire: expire,
|
||||||
UserID: d.user.ID,
|
UserID: d.user.ID,
|
||||||
|
PasswordHash: string(hash),
|
||||||
|
Token: token,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := d.store.Share.Save(s); err != nil {
|
if err := d.store.Share.Save(s); err != nil {
|
||||||
|
@ -137,3 +163,16 @@ var sharePostHandler = withPermShare(func(w http.ResponseWriter, r *http.Request
|
||||||
|
|
||||||
return renderJSON(w, r, s)
|
return renderJSON(w, r, s)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
func getSharePasswordHash(body share.CreateBody) (data []byte, statuscode int, err error) {
|
||||||
|
if body.Password == "" {
|
||||||
|
return nil, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return nil, http.StatusInternalServerError, fmt.Errorf("failed to hash password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash, 0, nil
|
||||||
|
}
|
||||||
|
|
|
@ -1,9 +1,20 @@
|
||||||
package share
|
package share
|
||||||
|
|
||||||
|
type CreateBody struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
Expires string `json:"expires"`
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
}
|
||||||
|
|
||||||
// Link is the information needed to build a shareable link.
|
// Link is the information needed to build a shareable link.
|
||||||
type Link struct {
|
type Link struct {
|
||||||
Hash string `json:"hash" storm:"id,index"`
|
Hash string `json:"hash" storm:"id,index"`
|
||||||
Path string `json:"path" storm:"index"`
|
Path string `json:"path" storm:"index"`
|
||||||
UserID uint `json:"userID"`
|
UserID uint `json:"userID"`
|
||||||
Expire int64 `json:"expire"`
|
Expire int64 `json:"expire"`
|
||||||
|
PasswordHash string `json:"password_hash,omitempty"`
|
||||||
|
// Token is a random value that will only be set when PasswordHash is set. It is
|
||||||
|
// URL-Safe and is used to download links in password-protected shares via a
|
||||||
|
// query arg.
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,7 @@ import (
|
||||||
// Storage is a storage powered by a Backend which makes the necessary
|
// Storage is a storage powered by a Backend which makes the necessary
|
||||||
// verifications when fetching and saving data to ensure consistency.
|
// verifications when fetching and saving data to ensure consistency.
|
||||||
type Storage struct {
|
type Storage struct {
|
||||||
Users *users.Storage
|
Users users.Store
|
||||||
Share *share.Storage
|
Share *share.Storage
|
||||||
Auth *auth.Storage
|
Auth *auth.Storage
|
||||||
Settings *settings.Storage
|
Settings *settings.Storage
|
||||||
|
|
|
@ -17,6 +17,15 @@ type StorageBackend interface {
|
||||||
DeleteByUsername(string) error
|
DeleteByUsername(string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Store interface {
|
||||||
|
Get(baseScope string, id interface{}) (user *User, err error)
|
||||||
|
Gets(baseScope string) ([]*User, error)
|
||||||
|
Update(user *User, fields ...string) error
|
||||||
|
Save(user *User) error
|
||||||
|
Delete(id interface{}) error
|
||||||
|
LastUpdate(id uint) int64
|
||||||
|
}
|
||||||
|
|
||||||
// Storage is a users storage.
|
// Storage is a users storage.
|
||||||
type Storage struct {
|
type Storage struct {
|
||||||
back StorageBackend
|
back StorageBackend
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
package users
|
||||||
|
|
||||||
|
// Interface is implemented by storage
|
||||||
|
var _ Store = &Storage{}
|
Loading…
Reference in New Issue