182 lines
5.5 KiB
Go
182 lines
5.5 KiB
Go
// Package auth defines the verified principal boundary for Sense HTTP APIs.
|
|
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
PermissionDevicesRead = "sense.devices.read"
|
|
PermissionDevicesWrite = "sense.devices.write"
|
|
)
|
|
|
|
var (
|
|
ErrUnauthenticated = errors.New("unauthenticated")
|
|
logicalIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$`)
|
|
)
|
|
|
|
type Principal struct {
|
|
SubjectID string
|
|
ActorType string
|
|
TenantID string
|
|
SiteIDs []string
|
|
Permissions map[string]struct{}
|
|
}
|
|
|
|
func (p Principal) AllowsSite(siteID string) bool {
|
|
for _, allowed := range p.SiteIDs {
|
|
if allowed == "*" || allowed == siteID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (p Principal) Has(permission string) bool {
|
|
_, ok := p.Permissions[permission]
|
|
return ok
|
|
}
|
|
|
|
type Authenticator interface {
|
|
Authenticate(context.Context, string) (Principal, error)
|
|
}
|
|
|
|
type staticFile struct {
|
|
Version int `json:"version"`
|
|
Principals []staticPrincipal `json:"principals"`
|
|
}
|
|
|
|
type staticPrincipal struct {
|
|
TokenSHA256 string `json:"token_sha256"`
|
|
SubjectID string `json:"subject_id"`
|
|
ActorType string `json:"actor_type"`
|
|
TenantID string `json:"tenant_id"`
|
|
SiteIDs []string `json:"site_ids"`
|
|
Permissions []string `json:"permissions"`
|
|
}
|
|
|
|
type staticEntry struct {
|
|
digest [sha256.Size]byte
|
|
principal Principal
|
|
}
|
|
|
|
// StaticSHA256 authenticates opaque tokens against externally provisioned
|
|
// SHA-256 digests. The source file is read only during process startup.
|
|
type StaticSHA256 struct {
|
|
entries []staticEntry
|
|
}
|
|
|
|
func LoadStaticSHA256(path string) (*StaticSHA256, error) {
|
|
contents, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, errors.New("read static authentication registry")
|
|
}
|
|
decoder := json.NewDecoder(strings.NewReader(string(contents)))
|
|
decoder.DisallowUnknownFields()
|
|
var document staticFile
|
|
if err := decoder.Decode(&document); err != nil {
|
|
return nil, errors.New("decode static authentication registry")
|
|
}
|
|
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
|
return nil, errors.New("static authentication registry has trailing JSON")
|
|
}
|
|
if document.Version != 1 || len(document.Principals) == 0 {
|
|
return nil, errors.New("static authentication registry must contain version 1 principals")
|
|
}
|
|
entries := make([]staticEntry, 0, len(document.Principals))
|
|
seenDigests := make(map[string]struct{}, len(document.Principals))
|
|
for index, value := range document.Principals {
|
|
entry, validationErr := parseStaticPrincipal(value)
|
|
if validationErr != nil {
|
|
return nil, fmt.Errorf("invalid static authentication principal %d: %w", index, validationErr)
|
|
}
|
|
if _, exists := seenDigests[value.TokenSHA256]; exists {
|
|
return nil, errors.New("duplicate static authentication token digest")
|
|
}
|
|
seenDigests[value.TokenSHA256] = struct{}{}
|
|
entries = append(entries, entry)
|
|
}
|
|
return &StaticSHA256{entries: entries}, nil
|
|
}
|
|
|
|
func parseStaticPrincipal(value staticPrincipal) (staticEntry, error) {
|
|
var entry staticEntry
|
|
if len(value.TokenSHA256) != sha256.Size*2 || value.TokenSHA256 != strings.ToLower(value.TokenSHA256) {
|
|
return entry, errors.New("token_sha256 must be 64 lowercase hexadecimal characters")
|
|
}
|
|
digest, err := hex.DecodeString(value.TokenSHA256)
|
|
if err != nil {
|
|
return entry, errors.New("token_sha256 must be hexadecimal")
|
|
}
|
|
copy(entry.digest[:], digest)
|
|
if strings.TrimSpace(value.SubjectID) == "" || len(value.SubjectID) > 200 {
|
|
return entry, errors.New("subject_id must contain 1 to 200 characters")
|
|
}
|
|
if value.ActorType != "user" && value.ActorType != "service" {
|
|
return entry, errors.New("actor_type must be user or service")
|
|
}
|
|
if !logicalIDPattern.MatchString(value.TenantID) {
|
|
return entry, errors.New("tenant_id is invalid")
|
|
}
|
|
if len(value.SiteIDs) == 0 {
|
|
return entry, errors.New("site_ids must not be empty")
|
|
}
|
|
sites := make([]string, 0, len(value.SiteIDs))
|
|
seenSites := make(map[string]struct{}, len(value.SiteIDs))
|
|
for _, siteID := range value.SiteIDs {
|
|
if siteID != "*" && !logicalIDPattern.MatchString(siteID) {
|
|
return entry, errors.New("site_ids contains an invalid site")
|
|
}
|
|
if _, exists := seenSites[siteID]; exists {
|
|
return entry, errors.New("site_ids contains a duplicate")
|
|
}
|
|
seenSites[siteID] = struct{}{}
|
|
sites = append(sites, siteID)
|
|
}
|
|
permissions := make(map[string]struct{}, len(value.Permissions))
|
|
for _, permission := range value.Permissions {
|
|
if permission != PermissionDevicesRead && permission != PermissionDevicesWrite {
|
|
return entry, errors.New("permissions contains an unsupported value")
|
|
}
|
|
if _, exists := permissions[permission]; exists {
|
|
return entry, errors.New("permissions contains a duplicate")
|
|
}
|
|
permissions[permission] = struct{}{}
|
|
}
|
|
if len(permissions) == 0 {
|
|
return entry, errors.New("permissions must not be empty")
|
|
}
|
|
entry.principal = Principal{
|
|
SubjectID: value.SubjectID, ActorType: value.ActorType, TenantID: value.TenantID,
|
|
SiteIDs: sites, Permissions: permissions,
|
|
}
|
|
return entry, nil
|
|
}
|
|
|
|
func (a *StaticSHA256) Authenticate(_ context.Context, token string) (Principal, error) {
|
|
if len(token) < 22 || len(token) > 4096 {
|
|
return Principal{}, ErrUnauthenticated
|
|
}
|
|
digest := sha256.Sum256([]byte(token))
|
|
match := -1
|
|
for index := range a.entries {
|
|
if subtle.ConstantTimeCompare(digest[:], a.entries[index].digest[:]) == 1 {
|
|
match = index
|
|
}
|
|
}
|
|
if match < 0 {
|
|
return Principal{}, ErrUnauthenticated
|
|
}
|
|
return a.entries[match].principal, nil
|
|
}
|