sftpgo-mirror/internal/dataprovider/apikey.go

211 lines
6 KiB
Go
Raw Normal View History

// Copyright (C) 2019 Nicola Murino
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, version 3.
//
// 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 License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package dataprovider
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/alexedwards/argon2id"
"golang.org/x/crypto/bcrypt"
"github.com/drakkan/sftpgo/v2/internal/logger"
"github.com/drakkan/sftpgo/v2/internal/util"
)
// APIKeyScope defines the supported API key scopes
type APIKeyScope int
// Supported API key scopes
const (
// the API key will be used for an admin
APIKeyScopeAdmin APIKeyScope = iota + 1
// the API key will be used for a user
APIKeyScopeUser
)
// APIKey defines a SFTPGo API key.
// API keys can be used as authentication alternative to short lived tokens
// for REST API
type APIKey struct {
// Database unique identifier
ID int64 `json:"-"`
// Unique key identifier, used for key lookups.
// The generated key is in the format `KeyID.hash(Key)` so we can split
// and lookup by KeyID and then verify if the key matches the recorded hash
KeyID string `json:"id"`
// User friendly key name
Name string `json:"name"`
// we store the hash of the key, this is just like a password
Key string `json:"key,omitempty"`
Scope APIKeyScope `json:"scope"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
// 0 means never used
LastUseAt int64 `json:"last_use_at,omitempty"`
// 0 means never expire
ExpiresAt int64 `json:"expires_at,omitempty"`
Description string `json:"description,omitempty"`
// Username associated with this API key.
// If empty and the scope is APIKeyScopeUser the key is valid for any user
User string `json:"user,omitempty"`
// Admin username associated with this API key.
// If empty and the scope is APIKeyScopeAdmin the key is valid for any admin
Admin string `json:"admin,omitempty"`
// these fields are for internal use
userID int64
adminID int64
plainKey string
}
func (k *APIKey) getACopy() APIKey {
return APIKey{
ID: k.ID,
KeyID: k.KeyID,
Name: k.Name,
Key: k.Key,
Scope: k.Scope,
CreatedAt: k.CreatedAt,
UpdatedAt: k.UpdatedAt,
LastUseAt: k.LastUseAt,
ExpiresAt: k.ExpiresAt,
Description: k.Description,
User: k.User,
Admin: k.Admin,
userID: k.userID,
adminID: k.adminID,
}
}
// RenderAsJSON implements the renderer interface used within plugins
func (k *APIKey) RenderAsJSON(reload bool) ([]byte, error) {
if reload {
apiKey, err := provider.apiKeyExists(k.KeyID)
if err != nil {
providerLog(logger.LevelError, "unable to reload api key before rendering as json: %v", err)
return nil, err
}
apiKey.HideConfidentialData()
return json.Marshal(apiKey)
}
k.HideConfidentialData()
return json.Marshal(k)
}
// HideConfidentialData hides API key confidential data
func (k *APIKey) HideConfidentialData() {
k.Key = ""
}
func (k *APIKey) hashKey() error {
if k.Key != "" && !util.IsStringPrefixInSlice(k.Key, internalHashPwdPrefixes) {
if config.PasswordHashing.Algo == HashingAlgoBcrypt {
hashed, err := bcrypt.GenerateFromPassword([]byte(k.Key), config.PasswordHashing.BcryptOptions.Cost)
if err != nil {
return err
}
k.Key = util.BytesToString(hashed)
} else {
hashed, err := argon2id.CreateHash(k.Key, argon2Params)
if err != nil {
return err
}
k.Key = hashed
}
}
return nil
}
func (k *APIKey) generateKey() {
if k.KeyID != "" || k.Key != "" {
return
}
k.KeyID = util.GenerateUniqueID()
k.Key = util.GenerateUniqueID()
k.plainKey = k.Key
}
// DisplayKey returns the key to show to the user
func (k *APIKey) DisplayKey() string {
return fmt.Sprintf("%v.%v", k.KeyID, k.plainKey)
}
func (k *APIKey) validate() error {
if k.Name == "" {
return util.NewValidationError("name is mandatory")
}
if k.Scope != APIKeyScopeAdmin && k.Scope != APIKeyScopeUser {
return util.NewValidationError(fmt.Sprintf("invalid scope: %v", k.Scope))
}
k.generateKey()
if err := k.hashKey(); err != nil {
return err
}
if k.User != "" && k.Admin != "" {
return util.NewValidationError("an API key can be related to a user or an admin, not both")
}
if k.Scope == APIKeyScopeAdmin {
k.User = ""
}
if k.Scope == APIKeyScopeUser {
k.Admin = ""
}
if k.User != "" {
_, err := provider.userExists(k.User, "")
if err != nil {
return util.NewValidationError(fmt.Sprintf("unable to check API key user %v: %v", k.User, err))
}
}
if k.Admin != "" {
_, err := provider.adminExists(k.Admin)
if err != nil {
return util.NewValidationError(fmt.Sprintf("unable to check API key admin %v: %v", k.Admin, err))
}
}
return nil
}
// Authenticate tries to authenticate the provided plain key
func (k *APIKey) Authenticate(plainKey string) error {
if k.ExpiresAt > 0 && k.ExpiresAt < util.GetTimeAsMsSinceEpoch(time.Now()) {
return fmt.Errorf("API key %q is expired, expiration timestamp: %v current timestamp: %v", k.KeyID,
k.ExpiresAt, util.GetTimeAsMsSinceEpoch(time.Now()))
}
if config.PasswordCaching {
found, match := cachedAPIKeys.Check(k.KeyID, plainKey, k.Key)
if found {
if !match {
return ErrInvalidCredentials
}
return nil
}
}
if strings.HasPrefix(k.Key, bcryptPwdPrefix) {
if err := bcrypt.CompareHashAndPassword([]byte(k.Key), []byte(plainKey)); err != nil {
return ErrInvalidCredentials
}
} else if strings.HasPrefix(k.Key, argonPwdPrefix) {
match, err := argon2id.ComparePasswordAndHash(plainKey, k.Key)
if err != nil || !match {
return ErrInvalidCredentials
}
}
cachedAPIKeys.Add(k.KeyID, plainKey, k.Key)
return nil
}