mirror of
https://github.com/luxfi/vm.git
synced 2026-10-30 15:13:40 +00:00
api/* — all seven service/handler files swap codec/jsonrpc for luxfi/utils/json (Uint32, Uint64, NewCodec, RawMessage). api carries no codec.Manager use; the api types only quote integers for the JSON-RPC boundary. components/index/index.go — codec/wrappers -> luxfi/utils/wrappers. Used only for wrappers.LongLen byte-slice sizing. rpc/runtime/subprocess/linux_stopper.go — drop wrappers.Errs in favor of errors.Join (stdlib since Go 1.20) for the SIGTERM + Wait fan-in. One fewer dependency on a doomed module. vm no longer directly imports any codec/* subpackage. codec is still pulled in indirectly through chains/atomic (Wave 2E, hand-rolled binary marshal already in but go.sum still pins codec) and through the proto/p test fixtures.
305 lines
8.1 KiB
Go
305 lines
8.1 KiB
Go
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package auth
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"path"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
jwt "github.com/golang-jwt/jwt/v4"
|
|
"github.com/gorilla/rpc/v2"
|
|
"github.com/luxfi/log"
|
|
"github.com/luxfi/math/set"
|
|
"github.com/luxfi/password"
|
|
"github.com/luxfi/timer/mockable"
|
|
"github.com/luxfi/utils/json"
|
|
)
|
|
|
|
const (
|
|
headerKey = "Authorization"
|
|
headerValStart = "Bearer "
|
|
|
|
// number of bytes to use when generating a new random token ID
|
|
tokenIDByteLen = 20
|
|
|
|
// defaultTokenLifespan is how long a token lives before it expires
|
|
defaultTokenLifespan = time.Hour * 12
|
|
|
|
maxEndpoints = 128
|
|
)
|
|
|
|
var (
|
|
errNoToken = errors.New("auth token not provided")
|
|
errAuthHeaderNotParsable = fmt.Errorf(
|
|
"couldn't parse auth token. Header \"%s\" should be \"%sTOKEN.GOES.HERE\"",
|
|
headerKey,
|
|
headerValStart,
|
|
)
|
|
errInvalidSigningMethod = errors.New("auth token didn't specify the HS256 signing method correctly")
|
|
errTokenRevoked = errors.New("the provided auth token was revoked")
|
|
errTokenInsufficientPermission = errors.New("the provided auth token does not allow access to this endpoint")
|
|
errWrongPassword = errors.New("incorrect password")
|
|
errSamePassword = errors.New("new password can't be same as old password")
|
|
errNoEndpoints = errors.New("must name at least one endpoint")
|
|
errTooManyEndpoints = fmt.Errorf("can only name at most %d endpoints", maxEndpoints)
|
|
|
|
_ Auth = (*auth)(nil)
|
|
)
|
|
|
|
type Auth interface {
|
|
// Create and return a new token that allows access to each API endpoint for
|
|
// [duration] such that the API's path ends with an element of [endpoints].
|
|
// If one of the elements of [endpoints] is "*", all APIs are accessible.
|
|
NewToken(pw string, duration time.Duration, endpoints []string) (string, error)
|
|
|
|
// Revokes [token]; it will not be accepted as authorization for future API
|
|
// calls. If the token is invalid, this is a no-op. If a token is revoked
|
|
// and then the password is changed, and then changed back to the current
|
|
// password, the token will be un-revoked. Therefore, passwords shouldn't be
|
|
// re-used before previously revoked tokens have expired.
|
|
RevokeToken(pw, token string) error
|
|
|
|
// Authenticates [token] for access to [url].
|
|
AuthenticateToken(token, url string) error
|
|
|
|
// Change the password required to create and revoke tokens.
|
|
// [oldPW] is the current password.
|
|
// [newPW] is the new password. It can't be the empty string and it can't be
|
|
// unreasonably long.
|
|
// Changing the password makes tokens issued under a previous password
|
|
// invalid.
|
|
ChangePassword(oldPW, newPW string) error
|
|
|
|
// Create the API endpoint for this auth handler.
|
|
CreateHandler() (http.Handler, error)
|
|
|
|
// WrapHandler wraps an http.Handler. Before passing a request to the
|
|
// provided handler, the auth token is authenticated.
|
|
WrapHandler(h http.Handler) http.Handler
|
|
}
|
|
|
|
type auth struct {
|
|
// Used to mock time.
|
|
clock mockable.Clock
|
|
|
|
log log.Logger
|
|
endpoint string
|
|
|
|
lock sync.RWMutex
|
|
// Can be changed via API call.
|
|
password password.Hash
|
|
// Set of token IDs that have been revoked
|
|
revoked set.Set[string]
|
|
}
|
|
|
|
func New(log log.Logger, endpoint, pw string) (Auth, error) {
|
|
a := &auth{
|
|
log: log,
|
|
endpoint: endpoint,
|
|
revoked: set.NewSet[string](),
|
|
}
|
|
return a, a.password.Set(pw)
|
|
}
|
|
|
|
func NewFromHash(log log.Logger, endpoint string, pw password.Hash) Auth {
|
|
return &auth{
|
|
log: log,
|
|
endpoint: endpoint,
|
|
password: pw,
|
|
revoked: set.NewSet[string](),
|
|
}
|
|
}
|
|
|
|
func (a *auth) NewToken(pw string, duration time.Duration, endpoints []string) (string, error) {
|
|
if pw == "" {
|
|
return "", password.ErrEmptyPassword
|
|
}
|
|
if l := len(endpoints); l == 0 {
|
|
return "", errNoEndpoints
|
|
} else if l > maxEndpoints {
|
|
return "", errTooManyEndpoints
|
|
}
|
|
|
|
a.lock.RLock()
|
|
defer a.lock.RUnlock()
|
|
|
|
if !a.password.Check(pw) {
|
|
return "", errWrongPassword
|
|
}
|
|
|
|
canAccessAll := false
|
|
for _, endpoint := range endpoints {
|
|
if endpoint == "*" {
|
|
canAccessAll = true
|
|
break
|
|
}
|
|
}
|
|
|
|
idBytes := [tokenIDByteLen]byte{}
|
|
if _, err := rand.Read(idBytes[:]); err != nil {
|
|
return "", fmt.Errorf("failed to generate the unique token ID due to %w", err)
|
|
}
|
|
id := base64.RawURLEncoding.EncodeToString(idBytes[:])
|
|
|
|
claims := endpointClaims{
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(a.clock.Time().Add(duration)),
|
|
ID: id,
|
|
},
|
|
}
|
|
if canAccessAll {
|
|
claims.Endpoints = []string{"*"}
|
|
} else {
|
|
claims.Endpoints = endpoints
|
|
}
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, &claims)
|
|
return token.SignedString(a.password.Password[:]) // Sign the token and return its string repr.
|
|
}
|
|
|
|
func (a *auth) RevokeToken(tokenStr, pw string) error {
|
|
if tokenStr == "" {
|
|
return errNoToken
|
|
}
|
|
if pw == "" {
|
|
return password.ErrEmptyPassword
|
|
}
|
|
|
|
a.lock.Lock()
|
|
defer a.lock.Unlock()
|
|
|
|
if !a.password.Check(pw) {
|
|
return errWrongPassword
|
|
}
|
|
|
|
// See if token is well-formed and signature is right
|
|
token, err := jwt.ParseWithClaims(tokenStr, &endpointClaims{}, a.getTokenKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// If the token isn't valid, it has essentially already been revoked.
|
|
if !token.Valid {
|
|
return nil
|
|
}
|
|
|
|
claims, ok := token.Claims.(*endpointClaims)
|
|
if !ok {
|
|
return fmt.Errorf("expected auth token's claims to be type endpointClaims but is %T", token.Claims)
|
|
}
|
|
a.revoked.Add(claims.ID)
|
|
return nil
|
|
}
|
|
|
|
func (a *auth) AuthenticateToken(tokenStr, url string) error {
|
|
a.lock.RLock()
|
|
defer a.lock.RUnlock()
|
|
|
|
token, err := jwt.ParseWithClaims(tokenStr, &endpointClaims{}, a.getTokenKey)
|
|
if err != nil { // Probably because signature wrong
|
|
return err
|
|
}
|
|
|
|
// Make sure this token gives access to the requested endpoint
|
|
claims, ok := token.Claims.(*endpointClaims)
|
|
if !ok {
|
|
// Error is intentionally dropped here as there is nothing left to do
|
|
// with it.
|
|
return fmt.Errorf("expected auth token's claims to be type endpointClaims but is %T", token.Claims)
|
|
}
|
|
|
|
_, revoked := a.revoked[claims.ID]
|
|
if revoked {
|
|
return errTokenRevoked
|
|
}
|
|
|
|
for _, endpoint := range claims.Endpoints {
|
|
if endpoint == "*" || strings.HasSuffix(url, endpoint) {
|
|
return nil
|
|
}
|
|
}
|
|
return errTokenInsufficientPermission
|
|
}
|
|
|
|
func (a *auth) ChangePassword(oldPW, newPW string) error {
|
|
if oldPW == newPW {
|
|
return errSamePassword
|
|
}
|
|
|
|
a.lock.Lock()
|
|
defer a.lock.Unlock()
|
|
|
|
if !a.password.Check(oldPW) {
|
|
return errWrongPassword
|
|
}
|
|
if err := password.IsValid(newPW, password.OK); err != nil {
|
|
return err
|
|
}
|
|
if err := a.password.Set(newPW); err != nil {
|
|
return err
|
|
}
|
|
|
|
// All the revoked tokens are now invalid; no need to mark specifically as
|
|
// revoked.
|
|
a.revoked.Clear()
|
|
return nil
|
|
}
|
|
|
|
func (a *auth) CreateHandler() (http.Handler, error) {
|
|
server := rpc.NewServer()
|
|
codec := json.NewCodec()
|
|
server.RegisterCodec(codec, "application/json")
|
|
server.RegisterCodec(codec, "application/json;charset=UTF-8")
|
|
return server, server.RegisterService(
|
|
&Service{auth: a},
|
|
"auth",
|
|
)
|
|
}
|
|
|
|
func (a *auth) WrapHandler(h http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Don't require auth token to hit auth endpoint
|
|
if path.Base(r.URL.Path) == a.endpoint {
|
|
h.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// Should be "Bearer AUTH.TOKEN.HERE"
|
|
rawHeader := r.Header.Get(headerKey)
|
|
if rawHeader == "" {
|
|
writeUnauthorizedResponse(w, errNoToken)
|
|
return
|
|
}
|
|
if !strings.HasPrefix(rawHeader, headerValStart) {
|
|
// Error is intentionally dropped here as there is nothing left to
|
|
// do with it.
|
|
writeUnauthorizedResponse(w, errAuthHeaderNotParsable)
|
|
return
|
|
}
|
|
// Returns actual auth token. Slice guaranteed to not go OOB
|
|
tokenStr := rawHeader[len(headerValStart):]
|
|
|
|
if err := a.AuthenticateToken(tokenStr, r.URL.Path); err != nil {
|
|
writeUnauthorizedResponse(w, err)
|
|
return
|
|
}
|
|
|
|
h.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// getTokenKey returns the key to use when making and parsing tokens
|
|
func (a *auth) getTokenKey(t *jwt.Token) (interface{}, error) {
|
|
if t.Method != jwt.SigningMethodHS256 {
|
|
return nil, errInvalidSigningMethod
|
|
}
|
|
return a.password.Password[:], nil
|
|
}
|