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
  it
This commit is contained in:
Alvaro Aleman 2021-03-02 06:00:18 -05:00 committed by GitHub
parent 977ec33918
commit d8f415f8ab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 340 additions and 41 deletions

View File

@ -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
@ -89,4 +89,4 @@ workflows:
tags:
only: /^v.*/
branches:
ignore: /.*/
ignore: /.*/

View File

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

View File

@ -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 {

View File

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

View File

@ -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 {

View File

@ -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.
@ -47,6 +48,7 @@ type FileOptions struct {
Modify bool
Expand bool
ReadHeader bool
Token string
Checker rules.Checker
}
@ -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 {

View File

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

View File

@ -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) {
@ -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,
})
}

View File

@ -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">
@ -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"
@ -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')"
@ -72,7 +77,9 @@ export default {
unit: 'hours',
hasPermanent: false,
links: [],
clip: null
clip: null,
password: '',
passwordPermalink: ''
}
},
computed: {
@ -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) {
@ -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

View File

@ -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;
}

View File

@ -29,6 +29,7 @@
"selectMultiple": "Select multiple",
"share": "Share",
"shell": "Toggle shell",
"submit": "Submit",
"switchView": "Switch view",
"toggleSidebar": "Toggle sidebar",
"update": "Update",
@ -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",

View File

@ -25,7 +25,8 @@ const state = {
showMessage: null,
showConfirm: null,
previewMode: false,
hash: ''
hash: '',
token: '',
}
export default new Vuex.Store({

View File

@ -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) => {

View File

@ -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>
@ -102,7 +120,9 @@ export default {
data: () => ({
error: null,
path: '',
showLimit: 500
showLimit: 500,
password: '',
attemptedPasswordLogin: false
}),
watch: {
'$route': 'fetchData'
@ -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
@ -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)}`

View File

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

View File

@ -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 {
@ -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
@ -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
@ -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
@ -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
}

136
http/public_test.go Normal file
View File

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

View File

@ -3,6 +3,8 @@ package http
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"path"
"sort"
@ -10,6 +12,8 @@ import (
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/filebrowser/filebrowser/v2/errors"
"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 s *share.Link
rawExpire := r.URL.Query().Get("expires")
unit := r.URL.Query().Get("unit")
var body share.CreateBody
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
s, err = d.store.Share.GetPermanent(r.URL.Path, d.user.ID)
if err == nil {
@ -103,14 +112,15 @@ var sharePostHandler = withPermShare(func(w http.ResponseWriter, r *http.Request
var expire int64 = 0
if rawExpire != "" {
num, err := strconv.Atoi(rawExpire)
if body.Expires != "" {
//nolint:govet
num, err := strconv.Atoi(body.Expires)
if err != nil {
return http.StatusInternalServerError, err
}
var add time.Duration
switch unit {
switch body.Unit {
case "seconds":
add = time.Second * time.Duration(num)
case "minutes":
@ -124,11 +134,27 @@ var sharePostHandler = withPermShare(func(w http.ResponseWriter, r *http.Request
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{
Path: r.URL.Path,
Hash: str,
Expire: expire,
UserID: d.user.ID,
Path: r.URL.Path,
Hash: str,
Expire: expire,
UserID: d.user.ID,
PasswordHash: string(hash),
Token: token,
}
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)
})
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
}

View File

@ -1,9 +1,20 @@
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.
type Link struct {
Hash string `json:"hash" storm:"id,index"`
Path string `json:"path" storm:"index"`
UserID uint `json:"userID"`
Expire int64 `json:"expire"`
Hash string `json:"hash" storm:"id,index"`
Path string `json:"path" storm:"index"`
UserID uint `json:"userID"`
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"`
}

View File

@ -10,7 +10,7 @@ import (
// Storage is a storage powered by a Backend which makes the necessary
// verifications when fetching and saving data to ensure consistency.
type Storage struct {
Users *users.Storage
Users users.Store
Share *share.Storage
Auth *auth.Storage
Settings *settings.Storage

View File

@ -17,6 +17,15 @@ type StorageBackend interface {
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.
type Storage struct {
back StorageBackend

4
users/storage_test.go Normal file
View File

@ -0,0 +1,4 @@
package users
// Interface is implemented by storage
var _ Store = &Storage{}