Commit ae82fc77 authored by Nicolas Pernoud's avatar Nicolas Pernoud
Browse files

feat: allow webdav authentication with password instead of token

parent 183b635d
Pipeline #9882 passed with stages
in 3 minutes and 8 seconds
......@@ -97,20 +97,7 @@ The users roles **must** be recovered in an "memberOf" claim array obtained when
### Mounting webdav share on your OS
Vestibule allow using the authentication token in an basic auth header to allow mounting webdavs.
Here is an example of a script login in the user and using the token from the cookie to mount webdav on Ubuntu Linux :
```bash
#/bin/bash
LOGIN_URL=https://vestibule.127.0.0.1.nip.io:1443/Login
DAV_URL=davs://userdav.vestibule.127.0.0.1.nip.io:1443
LOGIN=admin
PASSWORD=password
SENT_BODY="{\"login\":\"${LOGIN}\",\"password\":\"${PASSWORD}\"}"
HTTP_RESPONSE=$(curl -k --header "Content-Type: application/json" --request POST --data ${SENT_BODY} --silent --output /dev/null --cookie-jar - $LOGIN_URL)
TOKEN=$(echo $HTTP_RESPONSE | awk '{ print $NF }')
printf "1\n${LOGIN}\n${TOKEN}\n" | gio mount $DAV_URL
```
Vestibule allow using the login with the password **OR** the authentication token in an basic auth header to allow mounting webdavs.
### Override branding
......
......@@ -17,6 +17,8 @@ import (
"github.com/nicolaspernoud/vestibule/pkg/tester"
"github.com/nicolaspernoud/vestibule/pkg/tokens"
b64 "encoding/base64"
"github.com/nicolaspernoud/vestibule/internal/mocks"
)
......@@ -30,7 +32,7 @@ var (
initialUsers = reg.ReplaceAllString(string(initialUsersBuff), "")
newUser = `{"id":"3","login":"new_user","memberOf":["USERS"],"password":"test"}`
newDav = `{"id": 5,"host":"writableadmindav.vestibule.io","root":"./testdata/data","secured":true,"writable":true,"roles":["ADMINS"]}`
noH = tester.Header{Key: "", Value: ""}
noH map[string]string
)
func init() {
......@@ -61,9 +63,10 @@ func TestAll(t *testing.T) {
userTests := createUserTests(t)
os.Setenv("USERINFO_URL", oAuth2Server.URL+"/admininfo")
adminTests := createAdminTests(t)
directWebdavTests := createDirectWebdavTests(t)
// RUN THE TESTS CONCURRENTLY
var wg sync.WaitGroup
functions := []func(wg *sync.WaitGroup){oauth2Tests, unloggedTests, userTests, adminTests}
functions := []func(wg *sync.WaitGroup){oauth2Tests, unloggedTests, userTests, adminTests, directWebdavTests}
for _, f := range functions {
wg.Add(1)
go f(&wg)
......@@ -156,7 +159,7 @@ func createUserTests(t *testing.T) func(wg *sync.WaitGroup) {
response := do("GET", "/api/common/WhoAmI", noH, "", 200, "")
token := auth.TokenData{}
json.Unmarshal([]byte(response), &token)
xsrfHeader := tester.Header{Key: "XSRF-TOKEN", Value: token.XSRFToken}
xsrfHeader := map[string]string{"XSRF-TOKEN": token.XSRFToken}
// Try to get the apps without XSRF token (must fail)
do("GET", "/api/admin/apps", noH, "", 401, "XSRF")
// Try to get the apps (must fail)
......@@ -198,19 +201,19 @@ func createUserTests(t *testing.T) func(wg *sync.WaitGroup) {
// Try to get the user informations (must pass)
do("GET", "/api/common/WhoAmI", xsrfHeader, "", 200, `{"id":`)
// Try to get a share token (must pass)
shareHeader := tester.Header{Key: "Authorization", Value: "Bearer " + do("POST", "/api/common/Share", xsrfHeader, `{"sharedfor":"guest","url":"userdav.vestibule.io/mydata/test.txt","lifespan":1,"readonly":true}`, 200, "")}
shareHeader := map[string]string{"Authorization": "Bearer " + do("POST", "/api/common/Share", xsrfHeader, `{"sharedfor":"guest","url":"userdav.vestibule.io/mydata/test.txt","lifespan":1,"readonly":true}`, 200, "")}
// Try get the specified resource without cookie (must fail)
doNoJar("GET", "userdav.vestibule.io/mydata/test.txt", xsrfHeader, "", 401, "error extracting token")
// Try to use the share token for the specified ressource (must pass)
doNoJar("GET", "userdav.vestibule.io/mydata/test.txt", shareHeader, "", 200, "This is a test")
// Try to use the share token for the specified ressource with query (must pass)
doNoJar("GET", "userdav.vestibule.io/mydata/test.txt?token="+url.QueryEscape(strings.TrimPrefix(shareHeader.Value, "Bearer ")), noH, "", 200, "This is a test")
doNoJar("GET", "userdav.vestibule.io/mydata/test.txt?token="+url.QueryEscape(strings.TrimPrefix(shareHeader["Authorization"], "Bearer ")), noH, "", 200, "This is a test")
// Try to use the readonly share token to alter the specified ressource (must fail)
doNoJar("PUT", "userdav.vestibule.io/mydata/test.txt", shareHeader, "Altered content", 403, "token is read only")
// Try to use the share token for an other ressource (must fail)*/
doNoJar("GET", "userdav.vestibule.io/mydata/another_test.txt", shareHeader, "", 401, "token restricted to url")
// Try to get a share token for a forbidden -admin only- resource (should pass, but token should be useless)
shareHeader = tester.Header{Key: "Authorization", Value: "Bearer " + do("POST", "/api/common/Share", xsrfHeader, `{"sharedfor":"guest","url":"admindav.vestibule.io/mydata/test.txt","lifespan":1,"readonly":true}`, 200, "")}
shareHeader = map[string]string{"Authorization": "Bearer " + do("POST", "/api/common/Share", xsrfHeader, `{"sharedfor":"guest","url":"admindav.vestibule.io/mydata/test.txt","lifespan":1,"readonly":true}`, 200, "")}
// Try to use the previous token for a forbidden resource (must fail)
doNoJar("GET", "admindav.vestibule.io/mydata/test.txt", shareHeader, "", 403, "no user role among")
}
......@@ -247,7 +250,7 @@ func createAdminTests(t *testing.T) func(wg *sync.WaitGroup) {
response := do("GET", "/api/common/WhoAmI", noH, "", 200, "")
token := auth.TokenData{}
json.Unmarshal([]byte(response), &token)
xsrfHeader := tester.Header{Key: "XSRF-TOKEN", Value: token.XSRFToken}
xsrfHeader := map[string]string{"XSRF-TOKEN": token.XSRFToken}
// Try to get the apps (must pass)
do("GET", "/api/admin/apps/", xsrfHeader, "", 200, "[{\"id\":1")
// Try to create an app without the XSRF-TOKEN (must fail)
......@@ -272,6 +275,8 @@ func createAdminTests(t *testing.T) func(wg *sync.WaitGroup) {
do("POST", "/api/admin/users/", xsrfHeader, `{"id":"4","login":"new_user","memberOf":["USERS"],"password":"test"}`, 400, `login already exists`)
// Try to delete an user (must pass)
do("DELETE", "/api/admin/users/3", xsrfHeader, "", 200, `[{"id":"1",`)
// Try to log with the deleted user (must fail)
do("POST", "/Login", noH, `{"login": "new_user","password": "test"}`, 403, "")
// Try to get the user informations (must pass)
do("GET", "/api/common/WhoAmI", xsrfHeader, "", 200, `{"id":`)
// Try to access an authorized dav (must pass)
......@@ -303,7 +308,46 @@ func createAdminTests(t *testing.T) func(wg *sync.WaitGroup) {
}
}
func createTester(t *testing.T) (*httptest.Server, func(method string, url string, header tester.Header, payload string, expectedStatus int, expectedBody string) string, func(method string, url string, header tester.Header, payload string, expectedStatus int, expectedBody string) string) {
/**
DIRECT WEBDAV TESTS (this tests are to check that a direct access webdav works)
**/
func createDirectWebdavTests(t *testing.T) func(wg *sync.WaitGroup) {
// Users
authFromUser := func(user auth.User) string {
data := user.Login + ":" + user.Password
return "Basic " + b64.StdEncoding.EncodeToString([]byte(data))
}
correctAuthHeader := map[string]string{"Authorization": authFromUser(auth.User{Login: "user", Password: "password"}), "User-Agent": "LibreOffice"}
wrongAuthHeader := map[string]string{"Authorization": authFromUser(auth.User{Login: "user", Password: "wrong_password"}), "User-Agent": "LibreOffice"}
wrongUserAgent := map[string]string{"Authorization": authFromUser(auth.User{Login: "user", Password: "password"}), "User-Agent": "Other"}
// Create the tester
ts, do, _ := createTester(t)
return func(wg *sync.WaitGroup) {
defer ts.Close() // Close the tester
defer wg.Done()
// Try to get the apps (must fail)
do("GET", "/api/admin/apps", correctAuthHeader, "", 403, "no user role among")
// Try to access the davs list (must pass)
do("GET", "/api/common/davs", correctAuthHeader, "", 200, "[{\"id\":1")
// Try to access a forbidden dav (must fail)
do("GET", "admindav.vestibule.io", correctAuthHeader, "", 403, "no user role among")
// Try to access the main page (must pass)
do("GET", "/", correctAuthHeader, "", 200, "<!DOCTYPE html>")
// Try to access an authorized app (must pass)
do("GET", "api.vestibule.io", correctAuthHeader, "", 200, "{")
// Try to access an authorized dav (must pass)
do("GET", "userdav.vestibule.io/mydata/test.txt?inline", correctAuthHeader, "", 200, "This is a test")
// Try to access a forbidden dav (must fail)
do("GET", "admindav.vestibule.io/mydata/test.txt", correctAuthHeader, "", 403, "no user role among")
// Try to access an authorized dav with wrong password (must fail)
do("GET", "userdav.vestibule.io/mydata/test.txt?inline", wrongAuthHeader, "", 401, "webdav client authentication")
// Try to access an authorized dav with wrong user agent (must fail)
do("GET", "userdav.vestibule.io/mydata/test.txt?inline", wrongUserAgent, "", 401, "error extracting token")
}
}
func createTester(t *testing.T) (*httptest.Server, func(method string, url string, headers map[string]string, payload string, expectedStatus int, expectedBody string) string, func(method string, url string, headers map[string]string, payload string, expectedStatus int, expectedBody string) string) {
// Create the server
mux := CreateRootMux(1443, "./testdata/apps.json", "./testdata/davs.json", "../../web")
ts := httptest.NewServer(mux.Mux)
......
......@@ -14,8 +14,6 @@ import (
"github.com/nicolaspernoud/vestibule/pkg/tester"
)
var noH = tester.Header{Key: "", Value: ""}
func TestServer(t *testing.T) {
// Create the proxy target servers
target := httptest.NewServer(http.HandlerFunc(testHandler))
......@@ -62,22 +60,22 @@ func TestServer(t *testing.T) {
// Create tests
var tests = []struct {
url string
authHeader tester.Header
authHeader map[string]string
code int
body string
}{
{"http://test.proxy/", noH, 200, "OK"},
{"http://foo.test.proxy/", noH, 404, "Not found."},
{"http://footest.proxy/", noH, 404, "Not found."},
{"http://test.wildcard/", noH, 200, "OK"},
{"http://foo.test.wildcard/", noH, 200, "OK"},
{"http://test.static/", noH, 200, "contents of index.html"},
{"http://test.net/", noH, 404, "Not found."},
{"http://test.proxy/", nil, 200, "OK"},
{"http://foo.test.proxy/", nil, 404, "Not found."},
{"http://footest.proxy/", nil, 404, "Not found."},
{"http://test.wildcard/", nil, 200, "OK"},
{"http://foo.test.wildcard/", nil, 200, "OK"},
{"http://test.static/", nil, 200, "contents of index.html"},
{"http://test.net/", nil, 404, "Not found."},
}
// Run tests
for _, test := range tests {
tester.DoRequestOnHandler(t, s, "GET", test.url, test.authHeader, "", test.code, test.body)
tester.DoRequestOnHandler(t, s, "GET", test.url, nil, "", test.code, test.body)
}
// Create redirect tests
......
......@@ -2,6 +2,7 @@ package auth
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
......@@ -62,13 +63,17 @@ func ValidateAuthMiddleware(next http.Handler, allowedRoles []string, checkXSRF
roleChecker := func(w http.ResponseWriter, r *http.Request) {
user := TokenData{}
checkXSRF, err := tokens.Manager.ExtractAndValidateToken(r, authTokenKey, &user, checkXSRF)
if err != nil {
// Handle WebDav authentication
if isWebdav(r.UserAgent()) {
// Handle WebDav authentication
if err != nil && isWebdav(r.UserAgent()) {
// Test if the user password is directly given in the request, if so populate the user
user, err = getUserDirectly(r.Header.Get("Authorization"))
if err != nil {
w.Header().Set("WWW-Authenticate", `Basic realm="server"`)
http.Error(w, "webdav client authentication", 401)
return
}
}
if err != nil {
// Handle CORS preflight requests
if r.Method == "OPTIONS" {
return
......@@ -209,10 +214,28 @@ func GetTokenData(r *http.Request) (TokenData, error) {
// isWebdav works out if an user agent is a webdav user agent
func isWebdav(ua string) bool {
for _, a := range []string{"vfs", "Microsoft-WebDAV", "Konqueror", "LibreOffice"} {
for _, a := range []string{"vfs", "Microsoft-WebDAV", "Konqueror", "LibreOffice", "Rei.Fs.WebDAV"} {
if strings.Contains(ua, a) {
return true
}
}
return false
}
// getUserDirectly directly checks if an user is allowed to connect
func getUserDirectly(authorizationHeader string) (TokenData, error) {
authHeader := strings.Split(authorizationHeader, " ")
var user User
if authHeader[0] == "Basic" && len(authHeader) == 2 {
decoded, err := base64.StdEncoding.DecodeString(authHeader[1])
if err == nil {
auth := strings.Split(string(decoded), ":")
sentUser := User{Login: auth[0], Password: auth[1]}
foundUser, err := MatchUser(sentUser)
if err == nil {
return (TokenData{User: foundUser}), nil
}
}
}
return TokenData{User: user}, errors.New("could not retrieve user directly from basic auth header")
}
......@@ -8,11 +8,13 @@ import (
"reflect"
"testing"
b64 "encoding/base64"
"github.com/nicolaspernoud/vestibule/pkg/common"
"github.com/nicolaspernoud/vestibule/pkg/tester"
)
var noH = tester.Header{Key: "", Value: ""}
var noH map[string]string
func TestCheckUserHasRole(t *testing.T) {
type args struct {
......@@ -130,3 +132,39 @@ func Test_isWebdav(t *testing.T) {
})
}
}
func Test_getUserDirectly(t *testing.T) {
UsersFile = `../../configs/users.json`
sentUser := User{Login: "user", Password: "password", Roles: []string{"USERS"}}
sentAdmin := User{Login: "admin", Password: "password", Roles: []string{"ADMINS"}}
wrongUser := User{Login: "user", Password: "wrong_password"}
authFromUser := func(user User) string {
data := user.Login + ":" + user.Password
return "Basic " + b64.StdEncoding.EncodeToString([]byte(data))
}
type args struct {
authorizationHeader string
}
tests := []struct {
name string
args args
want TokenData
wantErr bool
}{
{"user", args{authorizationHeader: authFromUser(sentUser)}, TokenData{User: sentUser}, false},
{"admin", args{authorizationHeader: authFromUser(sentAdmin)}, TokenData{User: sentAdmin}, false},
{"wrong_user", args{authorizationHeader: authFromUser(wrongUser)}, TokenData{User: wrongUser}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := getUserDirectly(tt.args.authorizationHeader)
if (err != nil) != tt.wantErr {
t.Errorf("getUserDirectly() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && !(got.Login == tt.want.Login && got.Roles[0] == tt.want.Roles[0]) {
t.Errorf("getUserDirectly() = %v, want %v", got, tt.want)
}
})
}
}
......@@ -3,7 +3,6 @@ package auth
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"sort"
......@@ -21,6 +20,7 @@ var (
//UsersFile is the file containing the users
UsersFile = "./configs/users.json"
tokenLifetime time.Duration
cachedUsers []User
)
func setTokenLifetime() time.Duration {
......@@ -32,6 +32,13 @@ func setTokenLifetime() time.Duration {
return time.Duration(days*24) * time.Hour
}
func refreshCache() {
err := common.Load(UsersFile, &cachedUsers)
if err != nil {
log.Logger.Fatalln("could not load users")
}
}
func init() {
tokenLifetime = setTokenLifetime()
}
......@@ -83,8 +90,10 @@ func ProcessUsers(w http.ResponseWriter, req *http.Request) {
SendUsers(w, req)
case "POST":
AddUser(w, req)
refreshCache()
case "DELETE":
DeleteUser(w, req)
refreshCache()
default:
http.Error(w, "method not allowed", 400)
}
......@@ -188,14 +197,11 @@ func DeleteUser(w http.ResponseWriter, req *http.Request) {
// MatchUser attempt to find the given user against users in configuration file
func MatchUser(sentUser User) (User, error) {
var emptyUser User
var users []User
err := common.Load(UsersFile, &users)
if err != nil {
fmt.Println(err.Error())
return emptyUser, err
if len(cachedUsers) == 0 {
refreshCache()
}
for _, user := range users {
var emptyUser User
for _, user := range cachedUsers {
if user.Login == sentUser.Login {
notFound := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(sentUser.Password))
if notFound == nil {
......
......@@ -17,7 +17,7 @@ func TestEncryption(t *testing.T) {
port := url.Port()
// wrap the testing function
do := tester.CreateServerTester(t, port, "vestibule.io", nil)
noH := tester.Header{Key: "", Value: ""}
var noH map[string]string
// Try to access a crypted file on a encrypted unsecured dav (must pass)
do("PUT", "/test-ciphered.txt", noH, "content is encrypted !", 201, "")
// Try to access a crypted file on a encrypted unsecured dav (must pass)
......
......@@ -9,5 +9,5 @@ import (
func TestGetInfo(t *testing.T) {
handler := http.HandlerFunc(GetInfo)
tester.DoRequestOnHandler(t, handler, "GET", "/", tester.Header{Key: "", Value: ""}, "", http.StatusOK, `{"uptime"`)
tester.DoRequestOnHandler(t, handler, "GET", "/", nil, "", http.StatusOK, `{"uptime"`)
}
......@@ -13,20 +13,14 @@ import (
"time"
)
//Header is a http header
type Header struct {
Key string
Value string
}
// DoRequestOnHandler does a request on a router (or handler) and check the response
func DoRequestOnHandler(t *testing.T, router http.Handler, method string, route string, header Header, payload string, expectedStatus int, expectedBody string) string {
func DoRequestOnHandler(t *testing.T, router http.Handler, method string, route string, headers map[string]string, payload string, expectedStatus int, expectedBody string) string {
req, err := http.NewRequest(method, route, strings.NewReader(payload))
if err != nil {
t.Fatal(err)
}
if header.Key != "" {
req.Header.Set(header.Key, header.Value)
for i, v := range headers {
req.Header.Set(i, v)
}
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
......@@ -40,7 +34,7 @@ func DoRequestOnHandler(t *testing.T, router http.Handler, method string, route
}
// DoRequestOnServer does a request on listening server
func DoRequestOnServer(t *testing.T, hostname string, port string, jar *cookiejar.Jar, method string, testURL string, header Header, payload string, expectedStatus int, expectedBody string) string {
func DoRequestOnServer(t *testing.T, hostname string, port string, jar *cookiejar.Jar, method string, testURL string, headers map[string]string, payload string, expectedStatus int, expectedBody string) string {
dialer := &net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
......@@ -64,8 +58,8 @@ func DoRequestOnServer(t *testing.T, hostname string, port string, jar *cookieja
if err != nil {
t.Fatal(err)
}
if header.Key != "" {
req.Header.Set(header.Key, header.Value)
for i, v := range headers {
req.Header.Set(i, v)
}
var client *http.Client
if jar != nil {
......@@ -89,8 +83,8 @@ func DoRequestOnServer(t *testing.T, hostname string, port string, jar *cookieja
}
// CreateServerTester wraps DoRequestOnServer to factorize t, port and jar
func CreateServerTester(t *testing.T, hostname string, port string, jar *cookiejar.Jar) func(method string, url string, header Header, payload string, expectedStatus int, expectedBody string) string {
return func(method string, url string, header Header, payload string, expectedStatus int, expectedBody string) string {
return DoRequestOnServer(t, port, hostname, jar, method, url, header, payload, expectedStatus, expectedBody)
func CreateServerTester(t *testing.T, hostname string, port string, jar *cookiejar.Jar) func(method string, url string, headers map[string]string, payload string, expectedStatus int, expectedBody string) string {
return func(method string, url string, headers map[string]string, payload string, expectedStatus int, expectedBody string) string {
return DoRequestOnServer(t, port, hostname, jar, method, url, headers, payload, expectedStatus, expectedBody)
}
}
......@@ -134,8 +134,8 @@ func (m manager) ExtractAndValidateToken(r *http.Request, cookieName string, v i
if authHeader[0] == "Basic" && len(authHeader) == 2 {
decoded, err := base64.StdEncoding.DecodeString(authHeader[1])
if err == nil {
authHeader = strings.Split(string(decoded), ":")
return authHeader[1], false, nil
auth := strings.Split(string(decoded), ":")
return auth[1], false, nil
}
}
return "", false, errors.New("could not extract token")
......@@ -214,6 +214,9 @@ func Decrypt(data []byte, key []byte) ([]byte, error) {
return []byte{}, err
}
nonceSize := gcm.NonceSize()
if len(data) <= nonceSize {
return []byte{}, err
}
nonce, cipherData := data[:nonceSize], data[nonceSize:]
plainData, err := gcm.Open(nil, nonce, cipherData, nil)
if err != nil {
......
[ ] Gatling performance tests (proxy overhead)
[ ] Allow translations
[/] Documentation and video
[/] Allow onlyoffice opening => add converting
......@@ -32,7 +32,7 @@
<div class="navbar-brand">
<div class="navbar-item">
<a class="button is-primary is-rounded is-outlined" href="https://www.github.com/nicolaspernoud/Vestibule" target="_blank" rel="noopener noreferrer">
<span>4.4.1</span>
<span>4.5.0</span>
<span class="icon">
<svg
class="svg-inline--fa fa-github fa-w-16"
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment