feat(api tokens): properly hash tokens
This commit is contained in:
parent
e6b25bd57b
commit
c88cbaa973
@ -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 {
|
||||
|
@ -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{}
|
||||
}
|
||||
|
@ -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
79
pkg/utils/random.go
Normal 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
|
||||
}
|
@ -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)
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user