feat(api tokens): properly hash tokens

This commit is contained in:
kolaente 2023-08-31 21:05:16 +02:00
parent e6b25bd57b
commit c88cbaa973
Signed by untrusted user: konrad
GPG Key ID: F40E70337AB24C9B
6 changed files with 166 additions and 63 deletions

View File

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

View File

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

View File

@ -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.",
}
}

79
pkg/utils/random.go Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
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<<letterIdxBits - 1 // All 1-bits, as many as letterIdxBits
letterIdxMax = 63 / letterIdxBits // # of letter indices fitting in 63 bits
)
// MakeRandomString return a random string
// Deprecated: use CryptoRandomString instead
func MakeRandomString(n int) string {
str, err := CryptoRandomString(int64(n))
if err != nil {
log.Errorf("Could not generate random string: %s", err)
}
return str
}
// CryptoRandomInt returns a crypto random integer between 0 and limit, inclusive
// Copied from https://github.com/go-gitea/gitea/blob/main/modules/util/util.go#L121-L127
func CryptoRandomInt(limit int64) (int64, error) {
rInt, err := rand.Int(rand.Reader, big.NewInt(limit))
if err != nil {
return 0, err
}
return rInt.Int64(), nil
}
const alphanumericalChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
// CryptoRandomString generates a crypto random alphanumerical string, each byte is generated by [0,61] range
// Copied from https://github.com/go-gitea/gitea/blob/main/modules/util/util.go#L131-L143
func CryptoRandomString(length int64) (string, error) {
buf := make([]byte, length)
limit := int64(len(alphanumericalChars))
for i := range buf {
num, err := CryptoRandomInt(limit)
if err != nil {
return "", err
}
buf[i] = alphanumericalChars[num]
}
return string(buf), nil
}
// CryptoRandomBytes generates `length` crypto bytes
// This differs from CryptoRandomString, as each byte in CryptoRandomString is generated by [0,61] range
// This function generates totally random bytes, each byte is generated by [0,255] range
// Copied from https://github.com/go-gitea/gitea/blob/main/modules/util/util.go#L145-L152
func CryptoRandomBytes(length int64) ([]byte, error) {
buf := make([]byte, length)
_, err := rand.Read(buf)
return buf, err
}

View File

@ -1,49 +0,0 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present 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 <https://www.gnu.org/licenses/>.
package utils
import (
"math/rand"
"time"
)
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
const (
letterIdxBits = 6 // 6 bits to represent a letter index
letterIdxMask = 1<<letterIdxBits - 1 // All 1-bits, as many as letterIdxBits
letterIdxMax = 63 / letterIdxBits // # of letter indices fitting in 63 bits
)
// MakeRandomString return a random string
func MakeRandomString(n int) string {
source := rand.New(rand.NewSource(time.Now().UnixNano()))
b := make([]byte, n)
// A rand.Int63() generates 63 random bits, enough for letterIdxMax letters!
for i, cache, remain := n-1, source.Int63(), letterIdxMax; i >= 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)
}