diff --git a/pkg/routes/api/v1/user_caldav_token.go b/pkg/routes/api/v1/user_caldav_token.go new file mode 100644 index 00000000..adf76a47 --- /dev/null +++ b/pkg/routes/api/v1/user_caldav_token.go @@ -0,0 +1,112 @@ +// 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 v1 + +import ( + "net/http" + "strconv" + + "code.vikunja.io/api/pkg/models" + + "code.vikunja.io/api/pkg/user" + "code.vikunja.io/web/handler" + "github.com/labstack/echo/v4" +) + +// GenerateCaldavToken is the handler to create a caldav token +// @Summary Generate a caldav token +// @Description Generates a caldav token which can be used for the caldav api. It is not possible to see the token again after it was generated. +// @tags user +// @Accept json +// @Produce json +// @Security JWTKeyAuth +// @Success 200 {object} user.Token +// @Failure 400 {object} web.HTTPError "Something's invalid." +// @Failure 404 {object} web.HTTPError "User does not exist." +// @Failure 500 {object} models.Message "Internal server error." +// @Router /user/settings/token/caldav [put] +func GenerateCaldavToken(c echo.Context) (err error) { + + u, err := user.GetCurrentUser(c) + if err != nil { + return handler.HandleHTTPError(err, c) + } + + token, err := user.GenerateNewCaldavToken(u) + if err != nil { + return handler.HandleHTTPError(err, c) + } + + return c.JSON(http.StatusCreated, token) +} + +// GetCaldavTokens is the handler to return a list of all caldav tokens for the current user +// @Summary Returns the caldav tokens for the current user +// @Description Return the IDs and created dates of all caldav tokens for the current user. +// @tags user +// @Accept json +// @Produce json +// @Security JWTKeyAuth +// @Success 200 {array} user.Token +// @Failure 400 {object} web.HTTPError "Something's invalid." +// @Failure 404 {object} web.HTTPError "User does not exist." +// @Failure 500 {object} models.Message "Internal server error." +// @Router /user/settings/token/caldav [get] +func GetCaldavTokens(c echo.Context) error { + u, err := user.GetCurrentUser(c) + if err != nil { + return handler.HandleHTTPError(err, c) + } + + tokens, err := user.GetCaldavTokens(u) + if err != nil { + return handler.HandleHTTPError(err, c) + } + + return c.JSON(http.StatusCreated, tokens) +} + +// DeleteCaldavToken is the handler to delete a caldv token +// @Summary Delete a caldav token by id +// @tags user +// @Accept json +// @Produce json +// @Security JWTKeyAuth +// @Param id path int true "Token ID" +// @Success 200 {object} models.Message +// @Failure 400 {object} web.HTTPError "Something's invalid." +// @Failure 404 {object} web.HTTPError "User does not exist." +// @Failure 500 {object} models.Message "Internal server error." +// @Router /user/settings/token/caldav/{id} [get] +func DeleteCaldavToken(c echo.Context) error { + u, err := user.GetCurrentUser(c) + if err != nil { + return handler.HandleHTTPError(err, c) + } + + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + return handler.HandleHTTPError(err, c) + } + + err = user.DeleteCaldavTokenByID(u, id) + if err != nil { + return handler.HandleHTTPError(err, c) + } + + return c.JSON(http.StatusOK, &models.Message{Message: "The token was deleted successfully."}) +} diff --git a/pkg/routes/caldav/auth.go b/pkg/routes/caldav/auth.go new file mode 100644 index 00000000..8b9a9d4e --- /dev/null +++ b/pkg/routes/caldav/auth.go @@ -0,0 +1,70 @@ +// 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 caldav + +import ( + "errors" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/user" + + "github.com/labstack/echo/v4" + "golang.org/x/crypto/bcrypt" +) + +func BasicAuth(username, password string, c echo.Context) (bool, error) { + creds := &user.Login{ + Username: username, + Password: password, + } + s := db.NewSession() + defer s.Close() + u, err := user.CheckUserCredentials(s, creds) + if err != nil && !user.IsErrWrongUsernameOrPassword(err) { + log.Errorf("Error during basic auth for caldav: %v", err) + return false, nil + } + + if err == nil { + c.Set("userBasicAuth", u) + return true, nil + } + + tokens, err := user.GetCaldavTokens(u) + if err != nil { + log.Errorf("Error while getting tokens for caldav auth: %v", err) + return false, nil + } + + // Looping over all tokens until we find one that matches + for _, token := range tokens { + err = bcrypt.CompareHashAndPassword([]byte(token.Token), []byte(password)) + if err != nil { + if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { + continue + } + log.Errorf("Error while verifying tokens for caldav auth: %v", err) + return false, nil + } + + c.Set("userBasicAuth", u) + return true, nil + } + + return false, nil +} diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 46b6c88d..d5f79a8c 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -75,7 +75,6 @@ import ( apiv1 "code.vikunja.io/api/pkg/routes/api/v1" "code.vikunja.io/api/pkg/routes/caldav" _ "code.vikunja.io/api/pkg/swagger" // To generate swagger docs - "code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/version" "code.vikunja.io/web" "code.vikunja.io/web/handler" @@ -194,7 +193,7 @@ func RegisterRoutes(e *echo.Echo) { if config.ServiceEnableCaldav.GetBool() { // Caldav routes wkg := e.Group("/.well-known") - wkg.Use(middleware.BasicAuth(caldavBasicAuth)) + wkg.Use(middleware.BasicAuth(caldav.BasicAuth)) wkg.Any("/caldav", caldav.PrincipalHandler) wkg.Any("/caldav/", caldav.PrincipalHandler) c := e.Group("/dav") @@ -323,6 +322,9 @@ func registerAPIRoutes(a *echo.Group) { u.POST("/export/request", apiv1.RequestUserDataExport) u.POST("/export/download", apiv1.DownloadUserDataExport) u.GET("/timezones", apiv1.GetAvailableTimezones) + u.PUT("/settings/token/caldav", apiv1.GenerateCaldavToken) + u.GET("/settings/token/caldav", apiv1.GetCaldavTokens) + u.DELETE("/settings/token/caldav/:id", apiv1.DeleteCaldavToken) if config.ServiceEnableTotp.GetBool() { u.GET("/settings/totp", apiv1.UserTOTP) @@ -663,7 +665,7 @@ func registerMigrations(m *echo.Group) { func registerCalDavRoutes(c *echo.Group) { // Basic auth middleware - c.Use(middleware.BasicAuth(caldavBasicAuth)) + c.Use(middleware.BasicAuth(caldav.BasicAuth)) // THIS is the entry point for caldav clients, otherwise lists will show up double c.Any("", caldav.EntryHandler) @@ -675,26 +677,3 @@ func registerCalDavRoutes(c *echo.Group) { c.Any("/lists/:list/", caldav.ListHandler) c.Any("/lists/:list/:task", caldav.TaskHandler) // Mostly used for editing } - -func caldavBasicAuth(username, password string, c echo.Context) (bool, error) { - creds := &user.Login{ - Username: username, - Password: password, - } - s := db.NewSession() - defer s.Close() - u, err := user.CheckUserCredentials(s, creds) - if err != nil { - _ = s.Rollback() - log.Errorf("Error during basic auth for caldav: %v", err) - return false, nil - } - - if err := s.Commit(); err != nil { - return false, err - } - - // Save the user in echo context for later use - c.Set("userBasicAuth", u) - return true, nil -} diff --git a/pkg/swagger/docs.go b/pkg/swagger/docs.go index 5b2b39e2..f45fdfeb 100644 --- a/pkg/swagger/docs.go +++ b/pkg/swagger/docs.go @@ -7026,6 +7026,153 @@ var doc = `{ } } }, + "/user/settings/token/caldav": { + "get": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Return the IDs and created dates of all caldav tokens for the current user.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Returns the caldav tokens for the current user", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/user.Token" + } + } + }, + "400": { + "description": "Something's invalid.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "404": { + "description": "User does not exist.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal server error.", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + }, + "put": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Generates a caldav token which can be used for the caldav api. It is not possible to see the token again after it was generated.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Generate a caldav token", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/user.Token" + } + }, + "400": { + "description": "Something's invalid.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "404": { + "description": "User does not exist.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal server error.", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, + "/user/settings/token/caldav/{id}": { + "get": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Delete a caldav token by id", + "parameters": [ + { + "type": "integer", + "description": "Token ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Message" + } + }, + "400": { + "description": "Something's invalid.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "404": { + "description": "User does not exist.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal server error.", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, "/user/settings/totp": { "get": { "security": [ @@ -8918,6 +9065,20 @@ var doc = `{ } } }, + "user.Token": { + "type": "object", + "properties": { + "created": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "token": { + "type": "string" + } + } + }, "user.User": { "type": "object", "properties": { diff --git a/pkg/swagger/swagger.json b/pkg/swagger/swagger.json index 72a59730..d37a426c 100644 --- a/pkg/swagger/swagger.json +++ b/pkg/swagger/swagger.json @@ -7010,6 +7010,153 @@ } } }, + "/user/settings/token/caldav": { + "get": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Return the IDs and created dates of all caldav tokens for the current user.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Returns the caldav tokens for the current user", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/user.Token" + } + } + }, + "400": { + "description": "Something's invalid.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "404": { + "description": "User does not exist.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal server error.", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + }, + "put": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Generates a caldav token which can be used for the caldav api. It is not possible to see the token again after it was generated.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Generate a caldav token", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/user.Token" + } + }, + "400": { + "description": "Something's invalid.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "404": { + "description": "User does not exist.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal server error.", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, + "/user/settings/token/caldav/{id}": { + "get": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Delete a caldav token by id", + "parameters": [ + { + "type": "integer", + "description": "Token ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Message" + } + }, + "400": { + "description": "Something's invalid.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "404": { + "description": "User does not exist.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal server error.", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, "/user/settings/totp": { "get": { "security": [ @@ -8902,6 +9049,20 @@ } } }, + "user.Token": { + "type": "object", + "properties": { + "created": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "token": { + "type": "string" + } + } + }, "user.User": { "type": "object", "properties": { diff --git a/pkg/swagger/swagger.yaml b/pkg/swagger/swagger.yaml index adb7e0d5..8df979f8 100644 --- a/pkg/swagger/swagger.yaml +++ b/pkg/swagger/swagger.yaml @@ -1210,6 +1210,15 @@ definitions: passcode: type: string type: object + user.Token: + properties: + created: + type: string + id: + type: integer + token: + type: string + type: object user.User: properties: created: @@ -6079,6 +6088,101 @@ paths: summary: Change general user settings of the current user. tags: - user + /user/settings/token/caldav: + get: + consumes: + - application/json + description: Return the IDs and created dates of all caldav tokens for the current + user. + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/user.Token' + type: array + "400": + description: Something's invalid. + schema: + $ref: '#/definitions/web.HTTPError' + "404": + description: User does not exist. + schema: + $ref: '#/definitions/web.HTTPError' + "500": + description: Internal server error. + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Returns the caldav tokens for the current user + tags: + - user + put: + consumes: + - application/json + description: Generates a caldav token which can be used for the caldav api. + It is not possible to see the token again after it was generated. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/user.Token' + "400": + description: Something's invalid. + schema: + $ref: '#/definitions/web.HTTPError' + "404": + description: User does not exist. + schema: + $ref: '#/definitions/web.HTTPError' + "500": + description: Internal server error. + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Generate a caldav token + tags: + - user + /user/settings/token/caldav/{id}: + get: + consumes: + - application/json + parameters: + - description: Token ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Message' + "400": + description: Something's invalid. + schema: + $ref: '#/definitions/web.HTTPError' + "404": + description: User does not exist. + schema: + $ref: '#/definitions/web.HTTPError' + "500": + description: Internal server error. + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Delete a caldav token by id + tags: + - user /user/settings/totp: get: consumes: diff --git a/pkg/user/caldav_token.go b/pkg/user/caldav_token.go new file mode 100644 index 00000000..4943773e --- /dev/null +++ b/pkg/user/caldav_token.go @@ -0,0 +1,40 @@ +// 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 user + +import "code.vikunja.io/api/pkg/db" + +func GenerateNewCaldavToken(u *User) (token *Token, err error) { + s := db.NewSession() + defer s.Close() + + return generateHashedToken(s, u, TokenCaldavAuth) +} + +func GetCaldavTokens(u *User) (tokens []*Token, err error) { + s := db.NewSession() + defer s.Close() + + return getTokensForKind(s, u, TokenCaldavAuth) +} + +func DeleteCaldavTokenByID(u *User, id int64) error { + s := db.NewSession() + defer s.Close() + + return removeTokenByID(s, u, TokenCaldavAuth, id) +} diff --git a/pkg/user/delete.go b/pkg/user/delete.go index c54160de..3e172720 100644 --- a/pkg/user/delete.go +++ b/pkg/user/delete.go @@ -87,7 +87,7 @@ func notifyUsersScheduledForDeletion() { // RequestDeletion creates a user deletion confirm token and sends a notification to the user func RequestDeletion(s *xorm.Session, user *User) (err error) { - token, err := generateNewToken(s, user, TokenAccountDeletion) + token, err := generateToken(s, user, TokenAccountDeletion) if err != nil { return err } diff --git a/pkg/user/token.go b/pkg/user/token.go index 076df953..dfdb6dc6 100644 --- a/pkg/user/token.go +++ b/pkg/user/token.go @@ -34,17 +34,19 @@ const ( TokenPasswordReset TokenEmailConfirm TokenAccountDeletion + TokenCaldavAuth tokenSize = 64 ) // Token is a token a user can use to do things like verify their email or resetting their password type Token struct { - ID int64 `xorm:"bigint autoincr not null unique pk"` - UserID int64 `xorm:"not null"` - Token string `xorm:"varchar(450) not null index"` - Kind TokenKind `xorm:"not null"` - Created time.Time `xorm:"created not null"` + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id"` + UserID int64 `xorm:"not null" json:"-"` + Token string `xorm:"varchar(450) not null index" json:"-"` + ClearTextToken string `xorm:"-" json:"token"` + Kind TokenKind `xorm:"not null" json:"-"` + Created time.Time `xorm:"created not null" json:"created"` } // TableName returns the real table name for user tokens @@ -52,12 +54,28 @@ func (t *Token) TableName() string { return "user_tokens" } -func generateNewToken(s *xorm.Session, u *User, kind TokenKind) (token *Token, err error) { - token = &Token{ +func genToken(u *User, kind TokenKind) *Token { + return &Token{ UserID: u.ID, Kind: kind, Token: utils.MakeRandomString(tokenSize), } +} + +func generateToken(s *xorm.Session, u *User, kind TokenKind) (token *Token, err error) { + token = genToken(u, kind) + + _, err = s.Insert(token) + return +} + +func generateHashedToken(s *xorm.Session, u *User, kind TokenKind) (token *Token, err error) { + token = genToken(u, kind) + token.ClearTextToken = token.Token + token.Token, err = HashPassword(token.ClearTextToken) + if err != nil { + return nil, err + } _, err = s.Insert(token) return @@ -74,12 +92,26 @@ func getToken(s *xorm.Session, token string, kind TokenKind) (t *Token, err erro return } +func getTokensForKind(s *xorm.Session, u *User, kind TokenKind) (tokens []*Token, err error) { + tokens = []*Token{} + + err = s.Where("kind = ? AND user_id = ?", kind, u.ID). + Find(&tokens) + return +} + func removeTokens(s *xorm.Session, u *User, kind TokenKind) (err error) { _, err = s.Where("user_id = ? AND kind = ?", u.ID, kind). Delete(&Token{}) return } +func removeTokenByID(s *xorm.Session, u *User, kind TokenKind, id int64) (err error) { + _, err = s.Where("id = ? AND user_id = ? AND kind = ?", id, u.ID, kind). + Delete(&Token{}) + return +} + // RegisterTokenCleanupCron registers a cron function to clean up all password reset tokens older than 24 hours func RegisterTokenCleanupCron() { const logPrefix = "[User Token Cleanup Cron] " diff --git a/pkg/user/update_email.go b/pkg/user/update_email.go index 6b4b35d9..f1572ec1 100644 --- a/pkg/user/update_email.go +++ b/pkg/user/update_email.go @@ -63,7 +63,7 @@ func UpdateEmail(s *xorm.Session, update *EmailUpdate) (err error) { } update.User.Status = StatusEmailConfirmationRequired - token, err := generateNewToken(s, update.User, TokenEmailConfirm) + token, err := generateToken(s, update.User, TokenEmailConfirm) if err != nil { return } diff --git a/pkg/user/user.go b/pkg/user/user.go index 1a7c31bf..7be296e9 100644 --- a/pkg/user/user.go +++ b/pkg/user/user.go @@ -324,7 +324,7 @@ func CheckUserCredentials(s *xorm.Session, u *Login) (*User, error) { if IsErrWrongUsernameOrPassword(err) { handleFailedPassword(user) } - return nil, err + return user, err } return user, nil diff --git a/pkg/user/user_create.go b/pkg/user/user_create.go index 1ccecdaf..213e9cee 100644 --- a/pkg/user/user_create.go +++ b/pkg/user/user_create.go @@ -81,7 +81,7 @@ func CreateUser(s *xorm.Session, user *User) (newUser *User, err error) { } user.Status = StatusEmailConfirmationRequired - token, err := generateNewToken(s, user, TokenEmailConfirm) + token, err := generateToken(s, user, TokenEmailConfirm) if err != nil { return nil, err } diff --git a/pkg/user/user_password_reset.go b/pkg/user/user_password_reset.go index 277d0bb7..0fc3290b 100644 --- a/pkg/user/user_password_reset.go +++ b/pkg/user/user_password_reset.go @@ -112,7 +112,7 @@ func RequestUserPasswordResetTokenByEmail(s *xorm.Session, tr *PasswordTokenRequ // RequestUserPasswordResetToken sends a user a password reset email. func RequestUserPasswordResetToken(s *xorm.Session, user *User) (err error) { - token, err := generateNewToken(s, user, TokenPasswordReset) + token, err := generateToken(s, user, TokenPasswordReset) if err != nil { return }