diff --git a/docs/content/doc/usage/errors.md b/docs/content/doc/usage/errors.md index 9b072cd0d..9219bf9b2 100644 --- a/docs/content/doc/usage/errors.md +++ b/docs/content/doc/usage/errors.md @@ -150,3 +150,10 @@ This document describes the different errors Vikunja can return. |-----------|------------------|-------------| | 12001 | 412 | The subscription entity type is invalid. | | 12002 | 412 | The user is already subscribed to the entity itself or a parent entity. | + +## Link Shares + +| ErrorCode | HTTP Status Code | Description | +|-----------|------------------|-------------| +| 13001 | 412 | This link share requires a password for authentication, but none was provided. | +| 13002 | 403 | The provided link share password was invalid. | diff --git a/pkg/integrations/integrations.go b/pkg/integrations/integrations.go index ad4f4fbc4..f9c8a69ea 100644 --- a/pkg/integrations/integrations.go +++ b/pkg/integrations/integrations.go @@ -114,8 +114,8 @@ func bootstrapTestRequest(t *testing.T, method string, payload string, queryPara return } -func newTestRequest(t *testing.T, method string, handler func(ctx echo.Context) error, payload string) (rec *httptest.ResponseRecorder, err error) { - c, rec := bootstrapTestRequest(t, method, payload, nil) +func newTestRequest(t *testing.T, method string, handler func(ctx echo.Context) error, payload string, queryParams url.Values, urlParams map[string]string) (rec *httptest.ResponseRecorder, err error) { + rec, c := testRequestSetup(t, method, payload, queryParams, urlParams) err = handler(c) return } diff --git a/pkg/integrations/link_sharing_auth_test.go b/pkg/integrations/link_sharing_auth_test.go new file mode 100644 index 000000000..271ef7475 --- /dev/null +++ b/pkg/integrations/link_sharing_auth_test.go @@ -0,0 +1,59 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2021 Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public Licensee as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package integrations + +import ( + "code.vikunja.io/api/pkg/models" + apiv1 "code.vikunja.io/api/pkg/routes/api/v1" + "github.com/stretchr/testify/assert" + "net/http" + "testing" +) + +func TestLinkSharingAuth(t *testing.T) { + t.Run("Without Password", func(t *testing.T) { + rec, err := newTestRequest(t, http.MethodPost, apiv1.AuthenticateLinkShare, ``, nil, map[string]string{"share": "test"}) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), `"token":"`) + assert.Contains(t, rec.Body.String(), `"list_id":1`) + }) + t.Run("Without Password, Password Provided", func(t *testing.T) { + rec, err := newTestRequest(t, http.MethodPost, apiv1.AuthenticateLinkShare, `{"password":"somethingsomething"}`, nil, map[string]string{"share": "test"}) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), `"token":"`) + assert.Contains(t, rec.Body.String(), `"list_id":1`) + }) + t.Run("With Password, No Password Provided", func(t *testing.T) { + _, err := newTestRequest(t, http.MethodPost, apiv1.AuthenticateLinkShare, ``, nil, map[string]string{"share": "testWithPassword"}) + assert.Error(t, err) + assertHandlerErrorCode(t, err, models.ErrCodeLinkSharePasswordRequired) + }) + t.Run("With Password, Password Provided", func(t *testing.T) { + rec, err := newTestRequest(t, http.MethodPost, apiv1.AuthenticateLinkShare, `{"password":"1234"}`, nil, map[string]string{"share": "testWithPassword"}) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), `"token":"`) + assert.Contains(t, rec.Body.String(), `"list_id":1`) + }) + t.Run("With Wrong Password", func(t *testing.T) { + _, err := newTestRequest(t, http.MethodPost, apiv1.AuthenticateLinkShare, `{"password":"someWrongPassword"}`, nil, map[string]string{"share": "testWithPassword"}) + assert.Error(t, err) + assertHandlerErrorCode(t, err, models.ErrCodeLinkSharePasswordInvalid) + }) +} diff --git a/pkg/integrations/login_test.go b/pkg/integrations/login_test.go index 34ad3206c..8d1ffa1a7 100644 --- a/pkg/integrations/login_test.go +++ b/pkg/integrations/login_test.go @@ -30,12 +30,12 @@ func TestLogin(t *testing.T) { rec, err := newTestRequest(t, http.MethodPost, apiv1.Login, `{ "username": "user1", "password": "1234" -}`) +}`, nil, nil) assert.NoError(t, err) assert.Contains(t, rec.Body.String(), "token") }) t.Run("Empty payload", func(t *testing.T) { - _, err := newTestRequest(t, http.MethodPost, apiv1.Login, `{}`) + _, err := newTestRequest(t, http.MethodPost, apiv1.Login, `{}`, nil, nil) assert.Error(t, err) assertHandlerErrorCode(t, err, user.ErrCodeNoUsernamePassword) }) @@ -43,7 +43,7 @@ func TestLogin(t *testing.T) { _, err := newTestRequest(t, http.MethodPost, apiv1.Login, `{ "username": "userWichDoesNotExist", "password": "1234" -}`) +}`, nil, nil) assert.Error(t, err) assertHandlerErrorCode(t, err, user.ErrCodeWrongUsernameOrPassword) }) @@ -51,7 +51,7 @@ func TestLogin(t *testing.T) { _, err := newTestRequest(t, http.MethodPost, apiv1.Login, `{ "username": "user1", "password": "wrong" -}`) +}`, nil, nil) assert.Error(t, err) assertHandlerErrorCode(t, err, user.ErrCodeWrongUsernameOrPassword) }) @@ -59,7 +59,7 @@ func TestLogin(t *testing.T) { _, err := newTestRequest(t, http.MethodPost, apiv1.Login, `{ "username": "user5", "password": "1234" -}`) +}`, nil, nil) assert.Error(t, err) assertHandlerErrorCode(t, err, user.ErrCodeEmailNotConfirmed) }) diff --git a/pkg/integrations/register_test.go b/pkg/integrations/register_test.go index 191c51498..774b662a7 100644 --- a/pkg/integrations/register_test.go +++ b/pkg/integrations/register_test.go @@ -31,12 +31,12 @@ func TestRegister(t *testing.T) { "username": "newUser", "password": "1234", "email": "email@example.com" -}`) +}`, nil, nil) assert.NoError(t, err) assert.Contains(t, rec.Body.String(), `"username":"newUser"`) }) t.Run("Empty payload", func(t *testing.T) { - _, err := newTestRequest(t, http.MethodPost, apiv1.RegisterUser, `{}`) + _, err := newTestRequest(t, http.MethodPost, apiv1.RegisterUser, `{}`, nil, nil) assert.Error(t, err) assertHandlerErrorCode(t, err, user.ErrCodeNoUsernamePassword) }) @@ -45,7 +45,7 @@ func TestRegister(t *testing.T) { "username": "", "password": "1234", "email": "email@example.com" -}`) +}`, nil, nil) assert.Error(t, err) assertHandlerErrorCode(t, err, user.ErrCodeNoUsernamePassword) }) @@ -54,7 +54,7 @@ func TestRegister(t *testing.T) { "username": "newUser", "password": "", "email": "email@example.com" -}`) +}`, nil, nil) assert.Error(t, err) assertHandlerErrorCode(t, err, user.ErrCodeNoUsernamePassword) }) @@ -63,7 +63,7 @@ func TestRegister(t *testing.T) { "username": "newUser", "password": "1234", "email": "" -}`) +}`, nil, nil) assert.Error(t, err) assertHandlerErrorCode(t, err, user.ErrCodeNoUsernamePassword) }) @@ -72,7 +72,7 @@ func TestRegister(t *testing.T) { "username": "user1", "password": "1234", "email": "email@example.com" -}`) +}`, nil, nil) assert.Error(t, err) assertHandlerErrorCode(t, err, user.ErrorCodeUsernameExists) }) @@ -81,7 +81,7 @@ func TestRegister(t *testing.T) { "username": "newUser", "password": "1234", "email": "user1@example.com" -}`) +}`, nil, nil) assert.Error(t, err) assertHandlerErrorCode(t, err, user.ErrorCodeUserEmailExists) }) diff --git a/pkg/integrations/user_confirm_email_test.go b/pkg/integrations/user_confirm_email_test.go index 00bfd7932..0fb65c019 100644 --- a/pkg/integrations/user_confirm_email_test.go +++ b/pkg/integrations/user_confirm_email_test.go @@ -28,23 +28,23 @@ import ( func TestUserConfirmEmail(t *testing.T) { t.Run("Normal test", func(t *testing.T) { - rec, err := newTestRequest(t, http.MethodPost, apiv1.UserConfirmEmail, `{"token": "tiepiQueed8ahc7zeeFe1eveiy4Ein8osooxegiephauph2Ael"}`) + rec, err := newTestRequest(t, http.MethodPost, apiv1.UserConfirmEmail, `{"token": "tiepiQueed8ahc7zeeFe1eveiy4Ein8osooxegiephauph2Ael"}`, nil, nil) assert.NoError(t, err) assert.Contains(t, rec.Body.String(), `The email was confirmed successfully.`) }) t.Run("Empty payload", func(t *testing.T) { - _, err := newTestRequest(t, http.MethodPost, apiv1.UserConfirmEmail, `{}`) + _, err := newTestRequest(t, http.MethodPost, apiv1.UserConfirmEmail, `{}`, nil, nil) assert.Error(t, err) assert.Equal(t, http.StatusPreconditionFailed, err.(*echo.HTTPError).Code) assertHandlerErrorCode(t, err, user.ErrCodeInvalidEmailConfirmToken) }) t.Run("Empty token", func(t *testing.T) { - _, err := newTestRequest(t, http.MethodPost, apiv1.UserConfirmEmail, `{"token": ""}`) + _, err := newTestRequest(t, http.MethodPost, apiv1.UserConfirmEmail, `{"token": ""}`, nil, nil) assert.Error(t, err) assertHandlerErrorCode(t, err, user.ErrCodeInvalidEmailConfirmToken) }) t.Run("Invalid token", func(t *testing.T) { - _, err := newTestRequest(t, http.MethodPost, apiv1.UserConfirmEmail, `{"token": "invalidToken"}`) + _, err := newTestRequest(t, http.MethodPost, apiv1.UserConfirmEmail, `{"token": "invalidToken"}`, nil, nil) assert.Error(t, err) assertHandlerErrorCode(t, err, user.ErrCodeInvalidEmailConfirmToken) }) diff --git a/pkg/integrations/user_password_request_token_test.go b/pkg/integrations/user_password_request_token_test.go index 59ee3a02d..5d5535e45 100644 --- a/pkg/integrations/user_password_request_token_test.go +++ b/pkg/integrations/user_password_request_token_test.go @@ -28,22 +28,22 @@ import ( func TestUserRequestResetPasswordToken(t *testing.T) { t.Run("Normal requesting a password reset token", func(t *testing.T) { - rec, err := newTestRequest(t, http.MethodPost, apiv1.UserRequestResetPasswordToken, `{"email": "user1@example.com"}`) + rec, err := newTestRequest(t, http.MethodPost, apiv1.UserRequestResetPasswordToken, `{"email": "user1@example.com"}`, nil, nil) assert.NoError(t, err) assert.Contains(t, rec.Body.String(), `Token was sent.`) }) t.Run("Empty payload", func(t *testing.T) { - _, err := newTestRequest(t, http.MethodPost, apiv1.UserRequestResetPasswordToken, `{}`) + _, err := newTestRequest(t, http.MethodPost, apiv1.UserRequestResetPasswordToken, `{}`, nil, nil) assert.Error(t, err) assertHandlerErrorCode(t, err, user.ErrCodeNoUsernamePassword) }) t.Run("Invalid email address", func(t *testing.T) { - _, err := newTestRequest(t, http.MethodPost, apiv1.UserRequestResetPasswordToken, `{"email": "user1example.com"}`) + _, err := newTestRequest(t, http.MethodPost, apiv1.UserRequestResetPasswordToken, `{"email": "user1example.com"}`, nil, nil) assert.Error(t, err) assert.Equal(t, http.StatusBadRequest, err.(*echo.HTTPError).Code) }) t.Run("No user with that email address", func(t *testing.T) { - _, err := newTestRequest(t, http.MethodPost, apiv1.UserRequestResetPasswordToken, `{"email": "user1000@example.com"}`) + _, err := newTestRequest(t, http.MethodPost, apiv1.UserRequestResetPasswordToken, `{"email": "user1000@example.com"}`, nil, nil) assert.Error(t, err) assertHandlerErrorCode(t, err, user.ErrCodeUserDoesNotExist) }) diff --git a/pkg/integrations/user_password_reset_test.go b/pkg/integrations/user_password_reset_test.go index ad50a5f14..f3e593d1c 100644 --- a/pkg/integrations/user_password_reset_test.go +++ b/pkg/integrations/user_password_reset_test.go @@ -31,12 +31,12 @@ func TestUserPasswordReset(t *testing.T) { rec, err := newTestRequest(t, http.MethodPost, apiv1.UserResetPassword, `{ "new_password": "1234", "token": "passwordresettesttoken" -}`) +}`, nil, nil) assert.NoError(t, err) assert.Contains(t, rec.Body.String(), `The password was updated successfully.`) }) t.Run("Empty payload", func(t *testing.T) { - _, err := newTestRequest(t, http.MethodPost, apiv1.UserResetPassword, `{}`) + _, err := newTestRequest(t, http.MethodPost, apiv1.UserResetPassword, `{}`, nil, nil) assert.Error(t, err) assert.Equal(t, http.StatusBadRequest, err.(*echo.HTTPError).Code) }) @@ -44,7 +44,7 @@ func TestUserPasswordReset(t *testing.T) { _, err := newTestRequest(t, http.MethodPost, apiv1.UserResetPassword, `{ "new_password": "", "token": "passwordresettesttoken" -}`) +}`, nil, nil) assert.Error(t, err) assertHandlerErrorCode(t, err, user.ErrCodeNoUsernamePassword) }) @@ -52,7 +52,7 @@ func TestUserPasswordReset(t *testing.T) { _, err := newTestRequest(t, http.MethodPost, apiv1.UserResetPassword, `{ "new_password": "1234", "token": "invalidtoken" -}`) +}`, nil, nil) assert.Error(t, err) assertHandlerErrorCode(t, err, user.ErrCodeInvalidPasswordResetToken) }) diff --git a/pkg/models/error.go b/pkg/models/error.go index 81e481536..8f97d24c2 100644 --- a/pkg/models/error.go +++ b/pkg/models/error.go @@ -1512,3 +1512,61 @@ func (err ErrSubscriptionAlreadyExists) HTTPError() web.HTTPError { Message: "You're already subscribed.", } } + +// ================= +// Link Share errors +// ================= + +// ErrLinkSharePasswordRequired represents an error where a link share authentication requires a password and none was provided +type ErrLinkSharePasswordRequired struct { + ShareID int64 +} + +// IsErrLinkSharePasswordRequired checks if an error is ErrLinkSharePasswordRequired. +func IsErrLinkSharePasswordRequired(err error) bool { + _, ok := err.(*ErrLinkSharePasswordRequired) + return ok +} + +func (err *ErrLinkSharePasswordRequired) Error() string { + return fmt.Sprintf("Link Share requires a password for authentication [ShareID: %d]", err.ShareID) +} + +// ErrCodeLinkSharePasswordRequired holds the unique world-error code of this error +const ErrCodeLinkSharePasswordRequired = 13001 + +// HTTPError holds the http error description +func (err ErrLinkSharePasswordRequired) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusPreconditionFailed, + Code: ErrCodeLinkSharePasswordRequired, + Message: "This link share requires a password for authentication, but none was provided.", + } +} + +// ErrLinkSharePasswordInvalid represents an error where a subscription entity type is unknown +type ErrLinkSharePasswordInvalid struct { + ShareID int64 +} + +// IsErrLinkSharePasswordInvalid checks if an error is ErrLinkSharePasswordInvalid. +func IsErrLinkSharePasswordInvalid(err error) bool { + _, ok := err.(*ErrLinkSharePasswordInvalid) + return ok +} + +func (err *ErrLinkSharePasswordInvalid) Error() string { + return fmt.Sprintf("Provided Link Share password did not match the saved one [ShareID: %d]", err.ShareID) +} + +// ErrCodeLinkSharePasswordInvalid holds the unique world-error code of this error +const ErrCodeLinkSharePasswordInvalid = 13002 + +// HTTPError holds the http error description +func (err ErrLinkSharePasswordInvalid) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusForbidden, + Code: ErrCodeLinkSharePasswordInvalid, + Message: "The provided link share password is invalid.", + } +} diff --git a/pkg/models/link_sharing.go b/pkg/models/link_sharing.go index 90de1ffed..85208f89c 100644 --- a/pkg/models/link_sharing.go +++ b/pkg/models/link_sharing.go @@ -17,6 +17,8 @@ package models import ( + "errors" + "golang.org/x/crypto/bcrypt" "time" "code.vikunja.io/api/pkg/user" @@ -304,3 +306,20 @@ func GetLinkSharesByIDs(s *xorm.Session, ids []int64) (shares map[int64]*LinkSha err = s.In("id", ids).Find(&shares) return } + +// VerifyLinkSharePassword checks if a password of a link share matches a provided one. +func VerifyLinkSharePassword(share *LinkSharing, password string) (err error) { + if password == "" { + return &ErrLinkSharePasswordRequired{ShareID: share.ID} + } + + err = bcrypt.CompareHashAndPassword([]byte(share.Password), []byte(password)) + if err != nil { + if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { + return &ErrLinkSharePasswordInvalid{ShareID: share.ID} + } + return err + } + + return nil +} diff --git a/pkg/routes/api/v1/link_sharing_auth.go b/pkg/routes/api/v1/link_sharing_auth.go index 8a009f317..2cc73629c 100644 --- a/pkg/routes/api/v1/link_sharing_auth.go +++ b/pkg/routes/api/v1/link_sharing_auth.go @@ -34,32 +34,44 @@ type LinkShareToken struct { ListID int64 `json:"list_id"` } +// LinkShareAuth represents everything required to authenticate a link share +type LinkShareAuth struct { + Hash string `param:"share" json:"-"` + Password string `json:"password"` +} + // AuthenticateLinkShare gives a jwt auth token for valid share hashes // @Summary Get an auth token for a share // @Description Get a jwt auth token for a shared list from a share hash. // @tags sharing // @Accept json // @Produce json +// @Param password body v1.LinkShareAuth true "The password for link shares which require one." // @Param share path string true "The share hash" // @Success 200 {object} auth.Token "The valid jwt auth token." // @Failure 400 {object} web.HTTPError "Invalid link share object provided." // @Failure 500 {object} models.Message "Internal error" // @Router /shares/{share}/auth [post] func AuthenticateLinkShare(c echo.Context) error { - hash := c.Param("share") + sh := &LinkShareAuth{} + err := c.Bind(sh) + if err != nil { + return handler.HandleHTTPError(err, c) + } s := db.NewSession() defer s.Close() - share, err := models.GetLinkShareByHash(s, hash) + share, err := models.GetLinkShareByHash(s, sh.Hash) if err != nil { - _ = s.Rollback() return handler.HandleHTTPError(err, c) } - if err := s.Commit(); err != nil { - _ = s.Rollback() - return handler.HandleHTTPError(err, c) + if share.SharingType == models.SharingTypeWithPassword { + err := models.VerifyLinkSharePassword(share, sh.Password) + if err != nil { + return handler.HandleHTTPError(err, c) + } } t, err := auth.NewLinkShareJWTAuthtoken(share) @@ -67,6 +79,8 @@ func AuthenticateLinkShare(c echo.Context) error { return handler.HandleHTTPError(err, c) } + share.Password = "" + return c.JSON(http.StatusOK, LinkShareToken{ Token: auth.Token{Token: t}, LinkSharing: share,