mirror of
https://github.com/juanfont/headscale.git
synced 2026-09-30 03:49:36 +09:00
393dd3e2d9
API keys, pre-auth keys and OAuth clients/tokens share one table and verify path. Secrets carry 256 bits of crypto/rand entropy, so a SHA-256 digest needs no stretching; bcrypt/argon2id rows rehash on use until 0.32.
248 lines
7.5 KiB
Go
248 lines
7.5 KiB
Go
package db
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const (
|
|
apiKeyPrefix = "hskey-api-" //nolint:gosec // This is a prefix, not a credential
|
|
|
|
// Legacy format constant: the prefix length of pre-hskey "prefix.secret" keys.
|
|
// TODO(kradalby): remove in 0.32 with legacy key formats (announced).
|
|
legacyAPIPrefixLength = 7
|
|
)
|
|
|
|
var (
|
|
ErrAPIKeyFailedToParse = errors.New("failed to parse ApiKey")
|
|
ErrAPIKeyGenerationFailed = errors.New("failed to generate API key")
|
|
ErrAPIKeyExpired = errors.New("API key expired")
|
|
ErrAPIKeyNotFound = fmt.Errorf("API key not found: %w", gorm.ErrRecordNotFound)
|
|
)
|
|
|
|
// CreateAPIKey creates a new [types.APIKey] in a user, and returns it.
|
|
func (hsdb *HSDatabase) CreateAPIKey(
|
|
expiration *time.Time,
|
|
) (string, *types.APIKey, error) {
|
|
keyStr, identifier, hash := generateSecret(apiKeyPrefix)
|
|
|
|
cred := types.Credential{
|
|
Kind: types.CredentialAPIKey,
|
|
Identifier: identifier,
|
|
Hash: hash,
|
|
Expiration: expiration,
|
|
}
|
|
|
|
if err := hsdb.DB.Save(&cred).Error; err != nil { //nolint:noinlineerr
|
|
return "", nil, fmt.Errorf("saving API key to database: %w", err)
|
|
}
|
|
|
|
return keyStr, credentialToAPIKey(&cred), nil
|
|
}
|
|
|
|
// ListAPIKeys returns the list of [types.APIKey] values for a user.
|
|
func (hsdb *HSDatabase) ListAPIKeys() ([]types.APIKey, error) {
|
|
var creds []types.Credential
|
|
|
|
err := hsdb.DB.Where("kind = ?", types.CredentialAPIKey).Order("id").Find(&creds).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
keys := make([]types.APIKey, 0, len(creds))
|
|
for i := range creds {
|
|
keys = append(keys, *credentialToAPIKey(&creds[i]))
|
|
}
|
|
|
|
return keys, nil
|
|
}
|
|
|
|
// GetAPIKey returns a [types.APIKey] for a given key.
|
|
func (hsdb *HSDatabase) GetAPIKey(prefix string) (*types.APIKey, error) {
|
|
var cred types.Credential
|
|
if result := hsdb.DB.First(&cred, "kind = ? AND identifier = ?", types.CredentialAPIKey, prefix); result.Error != nil {
|
|
return nil, result.Error
|
|
}
|
|
|
|
return credentialToAPIKey(&cred), nil
|
|
}
|
|
|
|
// GetAPIKeyByID returns a [types.APIKey] for a given id.
|
|
func (hsdb *HSDatabase) GetAPIKeyByID(id uint64) (*types.APIKey, error) {
|
|
var cred types.Credential
|
|
// Query on an explicit primary-key clause: a struct condition would drop a
|
|
// zero-valued ID, making the lookup unconditional and returning the first
|
|
// row instead of not-found.
|
|
if result := hsdb.DB.First(&cred, "kind = ? AND id = ?", types.CredentialAPIKey, id); result.Error != nil {
|
|
return nil, result.Error
|
|
}
|
|
|
|
return credentialToAPIKey(&cred), nil
|
|
}
|
|
|
|
// DestroyAPIKey destroys a [types.APIKey]. Returns [ErrAPIKeyNotFound] if the
|
|
// [types.APIKey] does not exist.
|
|
func (hsdb *HSDatabase) DestroyAPIKey(key types.APIKey) error {
|
|
res := hsdb.DB.Unscoped().
|
|
Delete(&types.Credential{}, "kind = ? AND id = ?", types.CredentialAPIKey, key.ID)
|
|
|
|
return apiKeyAffected(res)
|
|
}
|
|
|
|
// ExpireAPIKey marks a [types.APIKey] as expired.
|
|
func (hsdb *HSDatabase) ExpireAPIKey(key *types.APIKey) error {
|
|
res := hsdb.DB.Model(&types.Credential{}).
|
|
Where("kind = ? AND id = ?", types.CredentialAPIKey, key.ID).
|
|
Update("expiration", time.Now())
|
|
|
|
return apiKeyAffected(res)
|
|
}
|
|
|
|
// apiKeyAffected maps a write that matched no API key row to [ErrAPIKeyNotFound].
|
|
func apiKeyAffected(res *gorm.DB) error {
|
|
if res.Error != nil {
|
|
return res.Error
|
|
}
|
|
|
|
if res.RowsAffected == 0 {
|
|
return ErrAPIKeyNotFound
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (hsdb *HSDatabase) ValidateAPIKey(keyStr string) (bool, error) {
|
|
key, err := validateAPIKey(hsdb.DB, keyStr)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
if key.Expiration != nil && key.Expiration.Before(time.Now()) {
|
|
return false, nil
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
|
|
// AuthenticateAPIKey validates keyStr and returns the matching, unexpired
|
|
// [types.APIKey] (with its owning UserID populated). Unlike ValidateAPIKey it
|
|
// returns the key itself, so the v2 API can act as the key's owning user. A
|
|
// non-nil error means the key is missing, malformed, or expired.
|
|
func (hsdb *HSDatabase) AuthenticateAPIKey(keyStr string) (*types.APIKey, error) {
|
|
key, err := validateAPIKey(hsdb.DB, keyStr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if key.Expiration != nil && key.Expiration.Before(time.Now()) {
|
|
return nil, ErrAPIKeyExpired
|
|
}
|
|
|
|
return key, nil
|
|
}
|
|
|
|
// SetAPIKeyUser sets the owning user of an API key. Used when an admin mints a
|
|
// key on behalf of a user (headscale apikeys create --user).
|
|
func (hsdb *HSDatabase) SetAPIKeyUser(keyID uint64, userID types.UserID) error {
|
|
res := hsdb.DB.Model(&types.Credential{}).
|
|
Where("kind = ? AND id = ?", types.CredentialAPIKey, keyID).
|
|
Update("user_id", uint(userID))
|
|
|
|
return apiKeyAffected(res)
|
|
}
|
|
|
|
// ParseAPIKeyPrefix extracts the database prefix from a display prefix.
|
|
// Handles formats: "hskey-api-{12chars}-***", "hskey-api-{12chars}", or just "{12chars}".
|
|
// Returns the 12-character prefix suitable for database lookup.
|
|
func ParseAPIKeyPrefix(displayPrefix string) (string, error) {
|
|
// If it's already just the 12-character prefix, return it
|
|
if len(displayPrefix) == keyIdentifierLength && isValidBase64URLSafe(displayPrefix) {
|
|
return displayPrefix, nil
|
|
}
|
|
|
|
// If it starts with the API key prefix, parse it
|
|
if strings.HasPrefix(displayPrefix, apiKeyPrefix) {
|
|
// Remove the "hskey-api-" prefix
|
|
_, remainder, found := strings.Cut(displayPrefix, apiKeyPrefix)
|
|
if !found {
|
|
return "", fmt.Errorf("%w: invalid display prefix format", ErrAPIKeyFailedToParse)
|
|
}
|
|
|
|
// Extract just the first 12 characters (the actual prefix)
|
|
if len(remainder) < keyIdentifierLength {
|
|
return "", fmt.Errorf("%w: prefix too short", ErrAPIKeyFailedToParse)
|
|
}
|
|
|
|
prefix := remainder[:keyIdentifierLength]
|
|
|
|
// Validate it's base64 URL-safe
|
|
if !isValidBase64URLSafe(prefix) {
|
|
return "", fmt.Errorf("%w: prefix contains invalid characters", ErrAPIKeyFailedToParse)
|
|
}
|
|
|
|
return prefix, nil
|
|
}
|
|
|
|
// For legacy 7-character prefixes or other formats, return as-is
|
|
// TODO(kradalby): remove in 0.32 with legacy key formats (announced).
|
|
return displayPrefix, nil
|
|
}
|
|
|
|
// validateAPIKey validates an API key and returns the key if valid.
|
|
// Handles both new (hskey-api-{prefix}-{secret}) and legacy (prefix.secret) formats.
|
|
func validateAPIKey(db *gorm.DB, keyStr string) (*types.APIKey, error) {
|
|
// Validate input is not empty
|
|
if keyStr == "" {
|
|
return nil, ErrAPIKeyFailedToParse
|
|
}
|
|
|
|
// Check for new format: hskey-api-{prefix}-{secret}
|
|
_, prefixAndSecret, found := strings.Cut(keyStr, apiKeyPrefix)
|
|
|
|
if !found {
|
|
// Legacy format: prefix.secret
|
|
// TODO(kradalby): remove in 0.32 with legacy key formats (announced).
|
|
return validateLegacyAPIKey(db, keyStr)
|
|
}
|
|
|
|
prefix, secret, err := parsePrefixedKey(prefixAndSecret, ErrAPIKeyFailedToParse)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cred, err := authenticateCredential(db, types.CredentialAPIKey, prefix, secret, ErrAPIKeyNotFound)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return credentialToAPIKey(cred), nil
|
|
}
|
|
|
|
// validateLegacyAPIKey validates a legacy format API key (prefix.secret).
|
|
//
|
|
// TODO(kradalby): remove in 0.32 with legacy key formats (announced).
|
|
func validateLegacyAPIKey(db *gorm.DB, keyStr string) (*types.APIKey, error) {
|
|
// Legacy format uses "." as separator
|
|
prefix, secret, found := strings.Cut(keyStr, ".")
|
|
if !found {
|
|
return nil, ErrAPIKeyFailedToParse
|
|
}
|
|
|
|
// Legacy prefix is 7 chars
|
|
if len(prefix) != legacyAPIPrefixLength {
|
|
return nil, fmt.Errorf("%w: legacy prefix length mismatch", ErrAPIKeyFailedToParse)
|
|
}
|
|
|
|
cred, err := authenticateCredential(db, types.CredentialAPIKey, prefix, secret, ErrAPIKeyNotFound)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return credentialToAPIKey(cred), nil
|
|
}
|