Skip to content

Commit

Permalink
feat: allow to password protect shares (filebrowser#1252)
Browse files Browse the repository at this point in the history
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
  it
  • Loading branch information
alvaroaleman committed Mar 2, 2021
1 parent 977ec33 commit d8f415f
Show file tree
Hide file tree
Showing 22 changed files with 340 additions and 41 deletions.
4 changes: 2 additions & 2 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ version: 2
jobs:
lint:
docker:
- image: golangci/golangci-lint:v1.27.0
- image: golangci/golangci-lint:v1.31.0
steps:
- checkout
- run: golangci-lint run -v
Expand Down Expand Up @@ -89,4 +89,4 @@ workflows:
tags:
only: /^v.*/
branches:
ignore: /.*/
ignore: /.*/
2 changes: 1 addition & 1 deletion auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
// Auther is the authentication interface.
type Auther interface {
// 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() bool
}
2 changes: 1 addition & 1 deletion auth/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ type JSONAuth struct {
}

// 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

if r.Body == nil {
Expand Down
2 changes: 1 addition & 1 deletion auth/none.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const MethodNoAuth settings.AuthMethod = "noauth"
type NoAuth struct{}

// 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))
}

Expand Down
2 changes: 1 addition & 1 deletion auth/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ type ProxyAuth struct {
}

// 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)
user, err := sto.Get(root, username)
if err == errors.ErrNotExist {
Expand Down
3 changes: 3 additions & 0 deletions files/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type FileInfo struct {
Subtitles []string `json:"subtitles,omitempty"`
Content string `json:"content,omitempty"`
Checksums map[string]string `json:"checksums,omitempty"`
Token string `json:"token,omitempty"`
}

// FileOptions are the options when getting a file info.
Expand All @@ -47,6 +48,7 @@ type FileOptions struct {
Modify bool
Expand bool
ReadHeader bool
Token string
Checker rules.Checker
}

Expand All @@ -72,6 +74,7 @@ func NewFileInfo(opts FileOptions) (*FileInfo, error) {
IsDir: info.IsDir(),
Size: info.Size(),
Extension: filepath.Ext(info.Name()),
Token: opts.Token,
}

if opts.Expand {
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/api/files.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,13 @@ export function download (format, ...files) {
if (format !== null) {
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)
}

Expand Down
16 changes: 11 additions & 5 deletions frontend/src/api/share.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ export async function list() {
return fetchJSON('/api/shares')
}

export async function getHash(hash) {
return fetchJSON(`/api/public/share/${hash}`)
export async function getHash(hash, password = "") {
return fetchJSON(`/api/public/share/${hash}`, {
headers: {'X-SHARE-PASSWORD': password},
})
}

export async function get(url) {
Expand All @@ -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 = `/api/share${url}`
if (expires !== '') {
url += `?expires=${expires}&unit=${unit}`
}

let body = '{}';
if (password != '' || expires !== '' || unit !== 'hours') {
body = JSON.stringify({password: password, expires: expires, unit: unit})
}
return fetchJSON(url, {
method: 'POST'
method: 'POST',
body: body,
})
}
21 changes: 14 additions & 7 deletions frontend/src/components/prompts/Share.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
<template>
<div class="card floating" id="share">
<div class="card floating share__promt__card" id="share">
<div class="card-title">
<h2>{{ $t('buttons.share') }}</h2>
</div>

<div class="card-content">
<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">
<a :href="buildLink(link.hash)" target="_blank">
Expand All @@ -27,6 +24,13 @@
:title="$t('buttons.copyToClipboard')"><i class="material-icons">content_paste</i></button>
</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>
<input v-focus
type="number"
Expand All @@ -40,6 +44,7 @@
<option value="hours">{{ $t('time.hours') }}</option>
<option value="days">{{ $t('time.days') }}</option>
</select>
<input type="password" :placeholder="$t('prompts.optionalPassword')" v-model="password">
<button class="action"
@click="submit"
:aria-label="$t('buttons.create')"
Expand Down Expand Up @@ -72,7 +77,9 @@ export default {
unit: 'hours',
hasPermanent: false,
links: [],
clip: null
clip: null,
password: '',
passwordPermalink: ''
}
},
computed: {
Expand Down Expand Up @@ -121,7 +128,7 @@ export default {
if (!this.time) return
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.sort()
} catch (e) {
Expand All @@ -130,7 +137,7 @@ export default {
},
getPermalink: async function () {
try {
const res = await api.create(this.url)
const res = await api.create(this.url, this.passwordPermalink)
this.links.push(res)
this.sort()
this.hasPermanent = true
Expand Down
15 changes: 14 additions & 1 deletion frontend/src/css/_share.css
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,17 @@

.share__box__items #listing.list .item .modified {
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;
}
4 changes: 3 additions & 1 deletion frontend/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"selectMultiple": "Select multiple",
"share": "Share",
"shell": "Toggle shell",
"submit": "Submit",
"switchView": "Switch view",
"toggleSidebar": "Toggle sidebar",
"update": "Update",
Expand Down Expand Up @@ -142,7 +143,8 @@
"show": "Show",
"size": "Size",
"upload": "Upload",
"uploadMessage": "Select an option to upload."
"uploadMessage": "Select an option to upload.",
"optionalPassword": "Optional password"
},
"search": {
"images": "Images",
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/store/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ const state = {
showMessage: null,
showConfirm: null,
previewMode: false,
hash: ''
hash: '',
token: '',
}

export default new Vuex.Store({
Expand Down
1 change: 1 addition & 0 deletions frontend/src/store/mutations.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const mutations = {
state.user = value
},
setJWT: (state, value) => (state.jwt = value),
setToken: (state, value ) => (state.token = value),
multiple: (state, value) => (state.multiple = value),
addSelected: (state, value) => (state.selected.push(value)),
addPlugin: (state, value) => {
Expand Down
35 changes: 32 additions & 3 deletions frontend/src/views/Share.vue
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,24 @@
<div v-else-if="error">
<not-found v-if="error.message === '404'"></not-found>
<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>
</div>
</template>
Expand Down Expand Up @@ -102,7 +120,9 @@ export default {
data: () => ({
error: null,
path: '',
showLimit: 500
showLimit: 500,
password: '',
attemptedPasswordLogin: false
}),
watch: {
'$route': 'fetchData'
Expand All @@ -129,7 +149,11 @@ export default {
return 'insert_drive_file'
},
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 () {
return window.location.origin + this.link
Expand Down Expand Up @@ -193,8 +217,13 @@ export default {
this.error = null
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.token = file.token || ''
this.$store.commit('setToken', this.token)
if (file.isDir) file.items = file.items.map((item, index) => {
item.index = index
item.url = `/share/${this.hash}${this.path}/${encodeURIComponent(item.name)}`
Expand Down
2 changes: 1 addition & 1 deletion http/data.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
settings, err := store.Settings.Get()
if err != nil {
log.Fatalln("ERROR: couldn't get settings")
log.Fatalf("ERROR: couldn't get settings: %v\n", err)
return
}

Expand Down
33 changes: 33 additions & 0 deletions http/public.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
package http

import (
"errors"
"net/http"
"path"
"path/filepath"
"strings"

"github.com/spf13/afero"
"golang.org/x/crypto/bcrypt"

"github.com/filebrowser/filebrowser/v2/files"
"github.com/filebrowser/filebrowser/v2/share"
)

var withHashFile = func(fn handleFunc) handleFunc {
Expand All @@ -19,6 +22,11 @@ var withHashFile = func(fn handleFunc) handleFunc {
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)
if err != nil {
return errToStatus(err), err
Expand All @@ -33,6 +41,7 @@ var withHashFile = func(fn handleFunc) handleFunc {
Expand: true,
ReadHeader: d.server.TypeDetectionByHeader,
Checker: d,
Token: link.Token,
})
if err != nil {
return errToStatus(err), err
Expand All @@ -48,6 +57,7 @@ var withHashFile = func(fn handleFunc) handleFunc {
Modify: d.user.Perm.Modify,
Expand: true,
Checker: d,
Token: link.Token,
})
if err != nil {
return errToStatus(err), err
Expand Down Expand Up @@ -94,3 +104,26 @@ var publicDlHandler = withHashFile(func(w http.ResponseWriter, r *http.Request,

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
}
Loading

0 comments on commit d8f415f

Please sign in to comment.