From c88cbaa973bd15bdeb21dcda68afb2814e96ab81 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 31 Aug 2023 21:05:16 +0200 Subject: [PATCH] feat(api tokens): properly hash tokens --- pkg/migration/20230831155832.go | 18 +++-- pkg/models/api_tokens.go | 53 +++++++++++-- pkg/models/error.go | 30 +++++++ pkg/utils/random.go | 79 +++++++++++++++++++ pkg/utils/random_string.go | 49 ------------ .../{random_string_test.go => random_test.go} | 0 6 files changed, 166 insertions(+), 63 deletions(-) create mode 100644 pkg/utils/random.go delete mode 100644 pkg/utils/random_string.go rename pkg/utils/{random_string_test.go => random_test.go} (100%) diff --git a/pkg/migration/20230831155832.go b/pkg/migration/20230831155832.go index da466e8f475..cda7ba1ced6 100644 --- a/pkg/migration/20230831155832.go +++ b/pkg/migration/20230831155832.go @@ -23,14 +23,16 @@ import ( ) type api_tokens20230831155832 struct { - ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"token"` - Title string `xorm:"not null" json:"title"` - Key string `xorm:"not null varchar(50)" json:"key"` - Permissions map[string][]string `xorm:"json not null" json:"permissions"` - ExpiresAt time.Time `xorm:"not null" json:"expires_at"` - Created time.Time `xorm:"created not null" json:"created"` - Updated time.Time `xorm:"updated not null" json:"updated"` - OwnerID int64 `xorm:"bigint not null" json:"-"` + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"token"` + Title string `xorm:"not null" json:"title"` + TokenSalt string `xorm:"not null" json:"-"` + TokenHash string `xorm:"not null unique" json:"-"` + TokenLastEight string `xorm:"not null index varchar(8)" json:"-"` + Permissions map[string][]string `xorm:"json not null" json:"permissions"` + ExpiresAt time.Time `xorm:"not null" json:"expires_at"` + Created time.Time `xorm:"created not null" json:"created"` + Updated time.Time `xorm:"updated not null" json:"updated"` + OwnerID int64 `xorm:"bigint not null" json:"-"` } func (api_tokens20230831155832) TableName() string { diff --git a/pkg/models/api_tokens.go b/pkg/models/api_tokens.go index 074afc30d60..ed2e5cb8e79 100644 --- a/pkg/models/api_tokens.go +++ b/pkg/models/api_tokens.go @@ -17,6 +17,10 @@ package models import ( + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "golang.org/x/crypto/pbkdf2" "time" "code.vikunja.io/api/pkg/db" @@ -35,7 +39,10 @@ type APIToken struct { // A human-readable name for this token Title string `xorm:"not null" json:"title" valid:"required"` // The actual api key. Only visible after creation. - Key string `xorm:"not null varchar(50)" json:"key,omitempty"` + Token string `xorm:"-" json:"key,omitempty"` + TokenSalt string `xorm:"not null" json:"-"` + TokenHash string `xorm:"not null unique" json:"-"` + TokenLastEight string `xorm:"not null index varchar(8)" json:"-"` // The permissions this token has. Possible values are available via the /routes endpoint and consist of the keys of the list from that endpoint. For example, if the token should be able to read all tasks as well as update existing tasks, you should add `{"tasks":["read_all","update"]}`. Permissions APIPermissions `xorm:"json not null" json:"permissions" valid:"required"` // The date when this key expires. @@ -77,7 +84,20 @@ func GetAPITokenByID(s *xorm.Session, id int64) (token *APIToken, err error) { // @Router /tokens [put] func (t *APIToken) Create(s *xorm.Session, a web.Auth) (err error) { t.ID = 0 - t.Key = "tk_" + utils.MakeRandomString(32) + + salt, err := utils.CryptoRandomString(10) + if err != nil { + return err + } + token, err := utils.CryptoRandomBytes(20) + if err != nil { + return err + } + t.TokenSalt = salt + t.Token = "tk_" + hex.EncodeToString(token) + t.TokenHash = HashToken(t.Token, t.TokenSalt) + t.TokenLastEight = t.Token[len(t.Token)-8:] + t.OwnerID = a.GetID() // TODO: validate permissions @@ -86,6 +106,11 @@ func (t *APIToken) Create(s *xorm.Session, a web.Auth) (err error) { return err } +func HashToken(token, salt string) string { + tempHash := pbkdf2.Key([]byte(token), []byte(salt), 10000, 50, sha256.New) + return hex.EncodeToString(tempHash) +} + // ReadAll returns all api tokens the current user has created // @Summary Get all api tokens of the current user // @Description Returns all api tokens the current user has created. @@ -115,10 +140,6 @@ func (t *APIToken) ReadAll(s *xorm.Session, a web.Auth, search string, page int, return nil, 0, 0, err } - for _, token := range tokens { - token.Key = "" - } - totalCount, err := query.Count(&APIToken{}) return tokens, len(tokens), totalCount, err } @@ -139,3 +160,23 @@ func (t *APIToken) Delete(s *xorm.Session, a web.Auth) (err error) { _, err = s.Where("id = ? AND owner_id = ?", t.ID, a.GetID()).Delete(&APIToken{}) return err } + +// GetTokenFromTokenString returns the full token object from the original token string. +func GetTokenFromTokenString(s *xorm.Session, token string) (apiToken *APIToken, err error) { + lastEight := token[len(token)-8:] + + tokens := []*APIToken{} + err = s.Where("token_last_eight = ?", lastEight).Find(&tokens) + if err != nil { + return nil, err + } + + for _, t := range tokens { + tempHash := HashToken(token, t.TokenSalt) + if subtle.ConstantTimeCompare([]byte(t.TokenHash), []byte(tempHash)) == 1 { + return t, nil + } + } + + return nil, &ErrAPITokenInvalid{} +} diff --git a/pkg/models/error.go b/pkg/models/error.go index 1bfa1a59f29..6bc62d58d2f 100644 --- a/pkg/models/error.go +++ b/pkg/models/error.go @@ -1655,3 +1655,33 @@ func (err ErrLinkShareTokenInvalid) HTTPError() web.HTTPError { Message: "The provided link share token is invalid.", } } + +// ================ +// API Token Errors +// ================ + +// ErrAPITokenInvalid represents an error where an api token is invalid +type ErrAPITokenInvalid struct { +} + +// IsErrAPITokenInvalid checks if an error is ErrAPITokenInvalid. +func IsErrAPITokenInvalid(err error) bool { + _, ok := err.(*ErrAPITokenInvalid) + return ok +} + +func (err *ErrAPITokenInvalid) Error() string { + return "Provided API token is invalid" +} + +// ErrCodeAPITokenInvalid holds the unique world-error code of this error +const ErrCodeAPITokenInvalid = 14001 + +// HTTPError holds the http error description +func (err ErrAPITokenInvalid) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusBadRequest, + Code: ErrCodeAPITokenInvalid, + Message: "The provided api token is invalid.", + } +} diff --git a/pkg/utils/random.go b/pkg/utils/random.go new file mode 100644 index 00000000000..bed090df98c --- /dev/null +++ b/pkg/utils/random.go @@ -0,0 +1,79 @@ +// 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 utils + +import ( + "code.vikunja.io/api/pkg/log" + + "crypto/rand" + "math/big" +) + +const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" +const ( + letterIdxBits = 6 // 6 bits to represent a letter index + letterIdxMask = 1<. - -package utils - -import ( - "math/rand" - "time" -) - -const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" -const ( - letterIdxBits = 6 // 6 bits to represent a letter index - letterIdxMask = 1<= 0; { - if remain == 0 { - cache, remain = source.Int63(), letterIdxMax - } - if idx := int(cache & letterIdxMask); idx < len(letterBytes) { - b[i] = letterBytes[idx] - i-- - } - cache >>= letterIdxBits - remain-- - } - - return string(b) -} diff --git a/pkg/utils/random_string_test.go b/pkg/utils/random_test.go similarity index 100% rename from pkg/utils/random_string_test.go rename to pkg/utils/random_test.go