163 lines
4.2 KiB
Go
163 lines
4.2 KiB
Go
// Package publicapi authenticates public API clients and internal workers.
|
|
// Runtime configuration is parsed once and injected into an Authenticator;
|
|
// this package deliberately does not read process environment variables.
|
|
package publicapi
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
const ownerPartLimit = 96
|
|
|
|
type PublicClient struct {
|
|
ID string
|
|
Key string
|
|
}
|
|
|
|
type Config struct {
|
|
APIKeys string
|
|
InternalWorkerToken string
|
|
Production bool
|
|
}
|
|
|
|
type AuthError struct {
|
|
Status int
|
|
Message string
|
|
}
|
|
|
|
func (e *AuthError) Error() string {
|
|
return e.Message
|
|
}
|
|
|
|
type Authenticator struct {
|
|
clients []PublicClient
|
|
internalWorkerToken string
|
|
production bool
|
|
}
|
|
|
|
func NewAuthenticator(config Config) *Authenticator {
|
|
return &Authenticator{
|
|
clients: ParseClients(config.APIKeys),
|
|
internalWorkerToken: strings.TrimSpace(config.InternalWorkerToken),
|
|
production: config.Production,
|
|
}
|
|
}
|
|
|
|
func ParseClients(configured string) []PublicClient {
|
|
entries := strings.FieldsFunc(configured, func(character rune) bool {
|
|
return character == ',' || character == '\n'
|
|
})
|
|
clients := make([]PublicClient, 0, len(entries))
|
|
for _, rawEntry := range entries {
|
|
entry := strings.TrimSpace(rawEntry)
|
|
if entry == "" {
|
|
continue
|
|
}
|
|
|
|
id := "default"
|
|
key := entry
|
|
if separator := strings.IndexByte(entry, ':'); separator >= 0 {
|
|
id = strings.TrimSpace(entry[:separator])
|
|
key = strings.TrimSpace(entry[separator+1:])
|
|
}
|
|
if id == "" || key == "" {
|
|
continue
|
|
}
|
|
clients = append(clients, PublicClient{ID: id, Key: key})
|
|
}
|
|
return clients
|
|
}
|
|
|
|
func (a *Authenticator) Authenticate(request *http.Request) (PublicClient, string, error) {
|
|
presented := publicCredential(request)
|
|
if presented == "" {
|
|
return PublicClient{}, "", &AuthError{Status: http.StatusUnauthorized, Message: "Missing API key."}
|
|
}
|
|
for _, client := range a.clients {
|
|
if secureEqual(client.Key, presented) {
|
|
return client, OwnerID(client.ID), nil
|
|
}
|
|
}
|
|
return PublicClient{}, "", &AuthError{Status: http.StatusUnauthorized, Message: "Invalid API key."}
|
|
}
|
|
|
|
func (a *Authenticator) AssertInternalWorker(request *http.Request) error {
|
|
if a.internalWorkerToken == "" && !a.production {
|
|
return nil
|
|
}
|
|
if a.internalWorkerToken == "" {
|
|
return &AuthError{Status: http.StatusInternalServerError, Message: "Worker token is not configured."}
|
|
}
|
|
|
|
presented := request.Header.Get("x-zhinian-worker-token")
|
|
if presented == "" {
|
|
presented = bearerToken(request)
|
|
}
|
|
if presented == "" || !secureEqual(a.internalWorkerToken, presented) {
|
|
return &AuthError{Status: http.StatusUnauthorized, Message: "Invalid worker token."}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func OwnerID(id string) string {
|
|
part := sanitizeOwnerPart(id)
|
|
if part == "" {
|
|
part = "unknown"
|
|
}
|
|
return "api:" + part
|
|
}
|
|
|
|
func publicCredential(request *http.Request) string {
|
|
if token := bearerToken(request); token != "" {
|
|
return token
|
|
}
|
|
return request.Header.Get("x-zhinian-api-key")
|
|
}
|
|
|
|
func bearerToken(request *http.Request) string {
|
|
authorization := request.Header.Get("authorization")
|
|
separator := strings.IndexAny(authorization, " \t\r\n\v\f")
|
|
if separator <= 0 || !strings.EqualFold(authorization[:separator], "Bearer") {
|
|
return ""
|
|
}
|
|
if strings.TrimLeft(authorization[separator:], " \t\r\n\v\f") == authorization[separator:] {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(authorization[separator:])
|
|
}
|
|
|
|
func secureEqual(expected, presented string) bool {
|
|
if len(expected) != len(presented) {
|
|
return false
|
|
}
|
|
return subtle.ConstantTimeCompare([]byte(expected), []byte(presented)) == 1
|
|
}
|
|
|
|
func sanitizeOwnerPart(value string) string {
|
|
part := make([]byte, 0, min(len(value), ownerPartLimit))
|
|
invalidRun := false
|
|
for _, character := range value {
|
|
if isOwnerCharacter(character) {
|
|
invalidRun = false
|
|
if len(part) < ownerPartLimit {
|
|
part = append(part, byte(character))
|
|
}
|
|
continue
|
|
}
|
|
if !invalidRun && len(part) < ownerPartLimit {
|
|
part = append(part, '_')
|
|
}
|
|
invalidRun = true
|
|
}
|
|
return string(part)
|
|
}
|
|
|
|
func isOwnerCharacter(character rune) bool {
|
|
return character >= 'A' && character <= 'Z' ||
|
|
character >= 'a' && character <= 'z' ||
|
|
character >= '0' && character <= '9' ||
|
|
strings.ContainsRune("_.:@-", character)
|
|
}
|