From a7f1398ba42c70fb52f0d6ea2e94cb746cea7e71 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Tue, 7 Jun 2022 02:26:30 -0600 Subject: [PATCH] refactor!: external auth for ACME Challenges, and other mgmt routes --- cmd/mgmt/mgmt.go | 12 +- cmd/telebit/telebit.go | 4 +- internal/{mgmt => acmeroutes}/acmeroutes.go | 80 ++- internal/authutil/authorizer.go | 85 ++++ internal/authutil/authutil.go | 12 + internal/authutil/request.go | 68 +++ internal/mgmt/auth.go | 27 +- internal/mgmt/devices.go | 3 +- internal/mgmt/mgmt.go | 10 +- internal/mgmt/route.go | 519 ++++++++++---------- internal/telebit/admin.go | 8 +- internal/telebit/authorizer.go | 41 -- internal/telebit/telebit.go | 73 +-- 13 files changed, 512 insertions(+), 430 deletions(-) rename internal/{mgmt => acmeroutes}/acmeroutes.go (69%) create mode 100644 internal/authutil/authorizer.go create mode 100644 internal/authutil/authutil.go create mode 100644 internal/authutil/request.go delete mode 100644 internal/telebit/authorizer.go diff --git a/cmd/mgmt/mgmt.go b/cmd/mgmt/mgmt.go index 4fad3be..6c98c7f 100644 --- a/cmd/mgmt/mgmt.go +++ b/cmd/mgmt/mgmt.go @@ -10,6 +10,7 @@ import ( "os" "strings" + "git.rootprojects.org/root/telebit/internal/acmeroutes" "git.rootprojects.org/root/telebit/internal/mgmt" "git.rootprojects.org/root/telebit/internal/mgmt/authstore" @@ -34,7 +35,7 @@ var ( serviceName = "telebit-mgmt" // serviceDesc - serviceDesc = "Telebit Device Management" + //serviceDesc = "Telebit Device Management" ) func ver() string { @@ -151,7 +152,12 @@ func main() { _ = store.SetMaster(secret) defer store.Close() - mgmt.Init(store, provider) + var authURL string + if 0 == len(authURL) { + authURL = os.Getenv("AUTH_URL") + } + mgmt.Init(store) + acmeroutes.Init(provider) if len(challengesPort) > 0 { go func() { @@ -178,7 +184,7 @@ func main() { r.Get("/api/version", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("TODO (json): " + ver() + "\n")) }) - mgmt.RouteAll(r) + mgmt.RouteAll(r, authURL) fmt.Fprintf(os.Stderr, "failed: %s", http.ListenAndServe(lnAddr, r)) } diff --git a/cmd/telebit/telebit.go b/cmd/telebit/telebit.go index 0c31f8a..b9dec10 100644 --- a/cmd/telebit/telebit.go +++ b/cmd/telebit/telebit.go @@ -709,7 +709,7 @@ func fetchDirectivesAndRun() { return } - err = mgmt.Ping(config.authURL, token) + err = authutil.Ping(config.authURL, token) if nil != err { fmt.Fprintf(os.Stderr, "failed to ping mgmt server: %s\n", err) //os.Exit(exitRetry) @@ -722,7 +722,7 @@ func fetchDirectivesAndRun() { // re-create token unless no secret was supplied token, err = authstore.HMACToken(config.pairwiseSecret, config.leeway) } - err = mgmt.Ping(config.authURL, token) + err = authutil.Ping(config.authURL, token) if nil != err { fmt.Fprintf(os.Stderr, "failed to ping mgmt server: %s\n", err) //os.Exit(exitRetry) diff --git a/internal/mgmt/acmeroutes.go b/internal/acmeroutes/acmeroutes.go similarity index 69% rename from internal/mgmt/acmeroutes.go rename to internal/acmeroutes/acmeroutes.go index e3610ae..b624ca0 100644 --- a/internal/mgmt/acmeroutes.go +++ b/internal/acmeroutes/acmeroutes.go @@ -1,4 +1,4 @@ -package mgmt +package acmeroutes import ( "context" @@ -6,8 +6,10 @@ import ( "errors" "fmt" "net/http" + "path/filepath" "strings" + "git.rootprojects.org/root/telebit/internal/authutil" "git.rootprojects.org/root/telebit/internal/http01fs" "github.com/go-acme/lego/v3/challenge" @@ -37,11 +39,50 @@ type Identifier struct { Value string `json:"value"` } +/* type acmeProvider struct { BaseURL string provider challenge.Provider } +*/ +var provider challenge.Provider = nil +var presenters = make(chan *Challenge) +var cleanups = make(chan *Challenge) + +// Init initializes some package variables +func Init(p challenge.Provider) { + provider = p + + go func() { + for { + // TODO make parallel? + // TODO make cancellable? + ch := <-presenters + if nil != provider { + err := provider.Present(ch.Domain, ch.Token, ch.KeyAuth) + ch.error <- err + } else { + ch.error <- fmt.Errorf("missing acme challenge provider for present") + } + } + }() + + go func() { + for { + // TODO make parallel? + // TODO make cancellable? + ch := <-cleanups + if nil != provider { + ch.error <- provider.CleanUp(ch.Domain, ch.Token, ch.KeyAuth) + } else { + ch.error <- fmt.Errorf("missing acme challenge provider for cleanup") + } + } + }() +} + +/* func (p *acmeProvider) Present(domain, token, keyAuth string) error { return p.provider.Present(domain, token, keyAuth) } @@ -49,8 +90,33 @@ func (p *acmeProvider) Present(domain, token, keyAuth string) error { func (p *acmeProvider) CleanUp(domain, token, keyAuth string) error { return p.provider.CleanUp(domain, token, keyAuth) } +*/ -func handleACMEChallengeRoutes(r chi.Router) { +// GetACMEChallenges fetches stored HTTP-01 challenges +func GetACMEChallenges(w http.ResponseWriter, r *http.Request) { + //token := chi.URLParam(r, "token") + host := r.Host + /* + // TODO TrustProxy option? + xHost := r.Header.Get("X-Forwarded-Host") + //log.Printf("[debug] Host: %q\n[debug] X-Host: %q", host, xHost) + if len(xHost) > 0 { + host = xHost + } + */ + + // disallow FS characters + if strings.ContainsAny(host, "/:|\\") { + host = "" + } + tokenPath := filepath.Join(tmpBase, host) + + fsrv := http.FileServer(http.Dir(tokenPath)) + fsrv.ServeHTTP(w, r) +} + +// HandleACMEChallengeRoutes allows storing ACME challenges for relay +func HandleACMEChallengeRoutes(r chi.Router) { handleACMEChallenges := func(r chi.Router) { r.Post("/{domain}", createChallenge) @@ -78,7 +144,7 @@ func createChallenge(w http.ResponseWriter, r *http.Request) { domain := chi.URLParam(r, "domain") ctx := r.Context() - claims, ok := ctx.Value(MWKey("claims")).(*MgmtClaims) + claims, ok := ctx.Value(authutil.MWKey("claims")).(*authutil.Claims) if !ok || !isSlugAllowed(domain, claims.Slug) { msg := `{ "error": "invalid domain", "code":"E_BAD_REQUEST"}` http.Error(w, msg+"\n", http.StatusUnprocessableEntity) @@ -103,8 +169,8 @@ func createChallenge(w http.ResponseWriter, r *http.Request) { if "" == ch.Token || "" == ch.KeyAuth { err = errors.New("missing token and/or key auth") } else if strings.Contains(ch.Type, "http") { - provider := &http01fs.Provider - provider.Present(context.Background(), acme.Challenge{ + http01Provider := &http01fs.Provider + http01Provider.Present(context.Background(), acme.Challenge{ Token: ch.Token, KeyAuthorization: ch.KeyAuth, Identifier: acme.Identifier{ @@ -146,8 +212,8 @@ func deleteChallenge(w http.ResponseWriter, r *http.Request) { if "" == ch.Token || "" == ch.KeyAuth { err = errors.New("missing token and/or key auth") } else if strings.Contains(ch.Type, "http") { - provider := &http01fs.Provider - provider.CleanUp(context.Background(), acme.Challenge{ + http01Provider := &http01fs.Provider + http01Provider.CleanUp(context.Background(), acme.Challenge{ Token: ch.Token, KeyAuthorization: ch.KeyAuth, Identifier: acme.Identifier{ diff --git a/internal/authutil/authorizer.go b/internal/authutil/authorizer.go new file mode 100644 index 0000000..1839834 --- /dev/null +++ b/internal/authutil/authorizer.go @@ -0,0 +1,85 @@ +package authutil + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + + "git.rootprojects.org/root/telebit/internal/dbg" +) + +// Authorizer is called when a new client connects and we need to know something about it +type Authorizer func(*http.Request) (*Grants, error) + +var NotAuthorizedContent = []byte("{ \"error\": \"not authorized\" }\n") + +// NewAuthorizer will create a new (proxiable) token verifier +func NewAuthorizer(authURL string) Authorizer { + return func(r *http.Request) (*Grants, error) { + // do we have a valid wss_client? + + fmt.Printf("[authz] Authorization = %s\n", r.Header.Get("Authorization")) + var tokenString string + if auth := strings.Split(r.Header.Get("Authorization"), " "); len(auth) > 1 { + // TODO handle Basic auth tokens as well + tokenString = auth[1] + } + if "" == tokenString { + // Browsers do not allow Authorization Headers and must use access_token query string + tokenString = r.URL.Query().Get("access_token") + } + if "" != r.URL.Query().Get("access_token") { + r.URL.Query().Set("access_token", "[redacted]") + } + + fmt.Printf("[authz] authURL = %s\n", authURL) + fmt.Printf("[authz] token = %s\n", tokenString) + grants, err := Inspect(authURL, tokenString) + + if nil != err { + fmt.Printf("[authorizer] error inspecting %q: %s\ntoken: %s\n", authURL, err, tokenString) + return nil, err + } + if "" != r.URL.Query().Get("access_token") { + r.URL.Query().Set("access_token", "[redacted:"+grants.Subject+"]") + } + + return grants, err + } +} + +// Grants are verified token Claims +type Grants struct { + Subject string `json:"sub"` + Audience string `json:"aud"` + Domains []string `json:"domains"` + Ports []int `json:"ports"` +} + +// Inspect will verify a token and return its details, decoded +func Inspect(authURL, token string) (*Grants, error) { + inspectURL := strings.TrimSuffix(authURL, "/inspect") + "/inspect" + if dbg.Debug { + fmt.Fprintf(os.Stderr, "[debug] telebit.Inspect(\n\tinspectURL = %s,\n\ttoken = %s,\n)\n", inspectURL, token) + } + msg, err := Request("GET", inspectURL, token, nil) + if nil != err { + return nil, err + } + if nil == msg { + return nil, fmt.Errorf("invalid response") + } + + grants := &Grants{} + err = json.NewDecoder(msg).Decode(grants) + if err != nil { + return nil, err + } + if "" == grants.Subject { + fmt.Fprintf(os.Stderr, "TODO update mgmt server to show Subject: %q\n", msg) + grants.Subject = strings.Split(grants.Domains[0], ".")[0] + } + return grants, nil +} diff --git a/internal/authutil/authutil.go b/internal/authutil/authutil.go new file mode 100644 index 0000000..f44b13b --- /dev/null +++ b/internal/authutil/authutil.go @@ -0,0 +1,12 @@ +package authutil + +import "github.com/dgrijalva/jwt-go" + +// MWKey is a type guard for context.Value +type MWKey string + +// Claims includes a Slug, for backwards compatibility +type Claims struct { + Slug string `json:"slug"` + jwt.StandardClaims +} diff --git a/internal/authutil/request.go b/internal/authutil/request.go new file mode 100644 index 0000000..f604513 --- /dev/null +++ b/internal/authutil/request.go @@ -0,0 +1,68 @@ +package authutil + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "time" +) + +type SuccessResponse struct { + Success bool `json:"success"` +} + +// Request makes an HTTP request the way we like... +func Request(method, fullurl, token string, payload io.Reader) (io.Reader, error) { + HTTPClient := &http.Client{ + Timeout: 15 * time.Second, + } + req, err := http.NewRequest(method, fullurl, payload) + if err != nil { + return nil, err + } + if len(token) > 0 { + req.Header.Set("Authorization", "Bearer "+token) + } + if nil != payload { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("%d: failed to read response body: %w", resp.StatusCode, err) + } + + if resp.StatusCode >= http.StatusBadRequest { + return nil, fmt.Errorf("%d: request failed: %v", resp.StatusCode, string(body)) + } + + return bytes.NewBuffer(body), nil +} + +func Ping(authURL, token string) error { + msg, err := Request("POST", authURL+"/ping", token, nil) + if nil != err { + return err + } + if nil == msg { + return fmt.Errorf("invalid response") + } + resp := SuccessResponse{} + err = json.NewDecoder(msg).Decode(&resp) + if err != nil { + return err + } + if true != resp.Success { + return fmt.Errorf("expected successful response") + } + return nil +} diff --git a/internal/mgmt/auth.go b/internal/mgmt/auth.go index 9b02b9c..79aac57 100644 --- a/internal/mgmt/auth.go +++ b/internal/mgmt/auth.go @@ -7,34 +7,11 @@ import ( "io/ioutil" "os" + "git.rootprojects.org/root/telebit/internal/authutil" "git.rootprojects.org/root/telebit/internal/dbg" "git.rootprojects.org/root/telebit/internal/mgmt/authstore" - "git.rootprojects.org/root/telebit/internal/telebit" ) -type SuccessResponse struct { - Success bool `json:"success"` -} - -func Ping(authURL, token string) error { - msg, err := telebit.Request("POST", authURL+"/ping", token, nil) - if nil != err { - return err - } - if nil == msg { - return fmt.Errorf("invalid response") - } - resp := SuccessResponse{} - err = json.NewDecoder(msg).Decode(&resp) - if err != nil { - return err - } - if true != resp.Success { - return fmt.Errorf("expected successful response") - } - return nil -} - func Register(authURL, secret, ppid string) (kid string, err error) { pub := authstore.ToPublicKeyString(ppid) jsons := fmt.Sprintf(`{ "machine_ppid": "%s", "public_key": "%s" }`, ppid, pub) @@ -43,7 +20,7 @@ func Register(authURL, secret, ppid string) (kid string, err error) { if dbg.Debug { fmt.Fprintf(os.Stderr, "[debug] authURL=%s, secret=%s, ppid=%s\n", fullURL, secret, jsons) } - msg, err := telebit.Request("POST", fullURL, "", jsonb) + msg, err := authutil.Request("POST", fullURL, "", jsonb) if nil != err { return "", err } diff --git a/internal/mgmt/devices.go b/internal/mgmt/devices.go index 4bacc38..eaa8b02 100644 --- a/internal/mgmt/devices.go +++ b/internal/mgmt/devices.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "git.rootprojects.org/root/telebit/internal/authutil" "git.rootprojects.org/root/telebit/internal/mgmt/authstore" "github.com/go-chi/chi" @@ -21,7 +22,7 @@ func handleDeviceRoutes(r chi.Router) { r.Use(func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - claims, ok := ctx.Value(MWKey("claims")).(*MgmtClaims) + claims, ok := ctx.Value(authutil.MWKey("claims")).(*authutil.Claims) if !ok || "*" != claims.Slug { msg := `{"error":"missing or invalid authorization token", "code":"E_TOKEN"}` http.Error(w, msg+"\n", http.StatusUnprocessableEntity) diff --git a/internal/mgmt/mgmt.go b/internal/mgmt/mgmt.go index 6e008a6..8a5f47b 100644 --- a/internal/mgmt/mgmt.go +++ b/internal/mgmt/mgmt.go @@ -2,14 +2,10 @@ package mgmt import ( "git.rootprojects.org/root/telebit/internal/mgmt/authstore" - - "github.com/go-acme/lego/v3/challenge" ) var store authstore.Store -var provider challenge.Provider = nil - // DeviceDomain is the base hostname used for devices, such as devices.example.com // which has devices as foo.devices.example.com var DeviceDomain string @@ -18,11 +14,7 @@ var DeviceDomain string // ( currently NOT used, but will be used for wss://RELAY_DOMAIN/ ) var RelayDomain string -// MWKey is a type guard -type MWKey string - // Init initializes some package variables -func Init(s authstore.Store, p challenge.Provider) { +func Init(s authstore.Store) { store = s - provider = p } diff --git a/internal/mgmt/route.go b/internal/mgmt/route.go index 6007021..50c1a33 100644 --- a/internal/mgmt/route.go +++ b/internal/mgmt/route.go @@ -7,10 +7,11 @@ import ( "log" "net/http" "os" - "path/filepath" "strings" "time" + "git.rootprojects.org/root/telebit/internal/acmeroutes" + "git.rootprojects.org/root/telebit/internal/authutil" "git.rootprojects.org/root/telebit/internal/dbg" "git.rootprojects.org/root/telebit/internal/mgmt/authstore" @@ -19,15 +20,7 @@ import ( "github.com/go-chi/chi/middleware" ) -// MgmtClaims includes a Slug, for backwards compatibility -type MgmtClaims struct { - Slug string `json:"slug"` - jwt.StandardClaims -} - -var presenters = make(chan *Challenge) -var cleanups = make(chan *Challenge) - +// RouteStatic handles the not-so-dynamic routes func RouteStatic(r chi.Router) chi.Router { r.Route("/", func(r chi.Router) { @@ -35,276 +28,270 @@ func RouteStatic(r chi.Router) chi.Router { r.Use(middleware.Timeout(15 * time.Second)) r.Use(middleware.Recoverer) - r.Get("/.well-known/acme-challenge/{token}", getACMEChallenges) + r.Get("/.well-known/acme-challenge/{token}", acmeroutes.GetACMEChallenges) }) return r } -func getACMEChallenges(w http.ResponseWriter, r *http.Request) { - //token := chi.URLParam(r, "token") - host := r.Host - /* - // TODO TrustProxy option? - xHost := r.Header.Get("X-Forwarded-Host") - //log.Printf("[debug] Host: %q\n[debug] X-Host: %q", host, xHost) - if len(xHost) > 0 { - host = xHost - } - */ - - // disallow FS characters - if strings.ContainsAny(host, "/:|\\") { - host = "" - } - tokenPath := filepath.Join(tmpBase, host) - - fsrv := http.FileServer(http.Dir(tokenPath)) - fsrv.ServeHTTP(w, r) -} - -func RouteAll(r chi.Router) { - - go func() { - for { - // TODO make parallel? - // TODO make cancellable? - ch := <-presenters - if nil != provider { - err := provider.Present(ch.Domain, ch.Token, ch.KeyAuth) - ch.error <- err - } else { - ch.error <- fmt.Errorf("missing acme challenge provider for present") - } - } - }() - - go func() { - for { - // TODO make parallel? - // TODO make cancellable? - ch := <-cleanups - if nil != provider { - ch.error <- provider.CleanUp(ch.Domain, ch.Token, ch.KeyAuth) - } else { - ch.error <- fmt.Errorf("missing acme challenge provider for cleanup") - } - } - }() - +// RouteAll will generate the routes +func RouteAll(r chi.Router, authURL string) { r.Route("/", func(r chi.Router) { r.Use(middleware.Logger) r.Use(middleware.Timeout(15 * time.Second)) r.Use(middleware.Recoverer) - r.Get("/.well-known/acme-challenge/{token}", getACMEChallenges) + r.Get("/.well-known/acme-challenge/{token}", acmeroutes.GetACMEChallenges) r.Route("/api", func(r chi.Router) { - r.Use(func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") + r.Use(newClaimsGetter(authURL)) - var tokenString string - if auth := strings.Split(r.Header.Get("Authorization"), " "); len(auth) > 1 { - // TODO handle Basic auth tokens as well - tokenString = auth[1] - } - - //var err2 error = nil - tok, err := jwt.ParseWithClaims( - tokenString, - &MgmtClaims{}, - func(token *jwt.Token) (interface{}, error) { - if dbg.Debug { - fmt.Println("parsed jwt", token) - } - kid, ok := token.Header["kid"].(string) - if !ok { - return nil, fmt.Errorf("missing jwt header 'kid' (key id)") - } - auth, err := store.Get(kid) - if nil != err { - return nil, fmt.Errorf("invalid jwt header 'kid' (key id)") - } - - claims := token.Claims.(*MgmtClaims) - jti := claims.Id - if "" == jti { - return nil, fmt.Errorf("missing jwt payload 'jti' (jwt id / nonce)") - } - iat := claims.IssuedAt - if 0 == iat { - return nil, fmt.Errorf("missing jwt payload 'iat' (issued at)") - } - exp := claims.ExpiresAt - if 0 == exp { - return nil, fmt.Errorf("missing jwt payload 'exp' (expires at)") - } - - if "" != claims.Slug { - return nil, fmt.Errorf("extra jwt payload 'slug' (unknown)") - } - claims.Slug = auth.Slug - if "" != claims.Subject && auth.Slug != claims.Subject { - return nil, fmt.Errorf("invalid jwt payload 'sub' (mismatch)") - } - claims.Subject = claims.Slug - claims.Issuer = DeviceDomain - claims.Audience = fmt.Sprintf("wss://%s/ws", RelayDomain) - - /* - // a little misdirection there - mac := hmac.New(sha256.New, auth.MachinePPID) - _ = mac.Write([]byte(auth.SharedKey)) - _ = mac.Write([]byte(fmt.Sprintf("%d", exp))) - return []byte(auth.SharedKey), nil - */ - - //fmt.Println("ppid:", auth.MachinePPID) - - return []byte(auth.MachinePPID), nil - }, - ) - - ctx := r.Context() - if nil != tok { - if tok.Valid { - ctx = context.WithValue(ctx, MWKey("token"), tok) - ctx = context.WithValue(ctx, MWKey("claims"), tok.Claims) - ctx = context.WithValue(ctx, MWKey("valid"), true) - fmt.Println("[auth] Token is fully valid:") - fmt.Println(ctx.Value(MWKey("claims"))) - } else { - fmt.Println("[auth] Token was parsed, but NOT valid:", tok) - } - } - if nil != err { - fmt.Println("[auth] Token is NOT valid due to error:", err) - ctx = context.WithValue(ctx, MWKey("error"), err) - } - - next.ServeHTTP(w, r.WithContext(ctx)) - }) - }) - - handleACMEChallengeRoutes(r) + acmeroutes.HandleACMEChallengeRoutes(r) handleDeviceRoutes(r) - - r.Get("/inspect", func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - claims, ok := ctx.Value(MWKey("claims")).(*MgmtClaims) - if !ok { - msg := `{"error":"failure to ping: 3", "code":"E_TOKEN"}` - fmt.Println("touch no claims", claims) - http.Error(w, msg+"\n", http.StatusBadRequest) - return - } - - w.Write([]byte(fmt.Sprintf( - `{ "sub": "%s", "aud": "%s", "domains": [ "%s.%s" ], "ports": [] }`+"\n", - claims.Subject, - claims.Audience, - claims.Slug, - DeviceDomain, - ))) - }) - - r.Route("/register-device", func(r chi.Router) { - // r.Use() // must NOT have slug '*' - - r.Post("/{otp}", func(w http.ResponseWriter, r *http.Request) { - sharedKey := chi.URLParam(r, "otp") - original, err := store.Get(sharedKey) - if nil != err { - msg := `{"error":"not found", "code":"E_NOT_FOUND"}` - log.Printf("/api/register-device/\n") - log.Println(err) - http.Error(w, msg, http.StatusNotFound) - return - } - if len(original.MachinePPID) > 0 { - msg := `{"error":"the presented key has already been used", "code":"E_EXIST"}` - log.Printf("/api/register-device/\n") - log.Println(err) - http.Error(w, msg, http.StatusUnprocessableEntity) - return - } - - auth := &authstore.Authorization{} - decoder := json.NewDecoder(r.Body) - err = decoder.Decode(&auth) - // MachinePPID and PublicKey are required. ID must NOT be set. Slug is ignored. - epoch := time.Time{} - auth.SharedKey = sharedKey - if nil != err || "" != auth.ID || "" == auth.MachinePPID || - "" == auth.PublicKey || "" == auth.SharedKey || - epoch != auth.CreatedAt || epoch != auth.UpdatedAt || epoch != auth.DeletedAt { - msg, _ := json.Marshal(&struct { - Error string `json:"error"` - }{ - Error: "expected JSON in the format {\"machine_ppid\":\"\",\"public_key\":\"\"}", - }) - http.Error(w, string(msg), http.StatusUnprocessableEntity) - return - } - - // TODO hash the PPID and check against the Public Key? - pub := authstore.ToPublicKeyString(auth.MachinePPID) - if pub != auth.PublicKey { - msg, _ := json.Marshal(&struct { - Error string `json:"error"` - }{ - Error: "expected `public_key` to be the first 24 bytes of the hash of the `machine_ppid`", - }) - http.Error(w, string(msg), http.StatusUnprocessableEntity) - return - } - original.PublicKey = auth.PublicKey - original.MachinePPID = auth.MachinePPID - err = store.Set(original) - if nil != err { - msg := `{"error":"not really sure what happened, but it didn't go well (check the logs)", "code":"E_SERVER"}` - log.Printf("/api/register-device/\n") - log.Println(err) - http.Error(w, msg, http.StatusInternalServerError) - return - } - - result, _ := json.Marshal(auth) - w.Write(result) - }) - }) - - r.Post("/ping", func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - claims, ok := ctx.Value(MWKey("claims")).(*MgmtClaims) - if !ok { - msg := `{"error":"failure to ping: 1", "code":"E_TOKEN"}` - http.Error(w, msg+"\n", http.StatusBadRequest) - return - } - - fmt.Println("ping pong??", claims) - err := store.Touch(claims.Slug) - if nil != err { - fmt.Fprintf(os.Stderr, "touch err %s\n", err) - var msg string - if err == authstore.ErrNotFound { - // if the token is valid, touch should ALWAYS work - msg = `{"error":"failure to ping: inconsistent database data", "code":"E_SERVER"}` - } else { - msg = `{"error":"failure to ping: 2", "code":"E_SERVER"}` - } - http.Error(w, msg+"\n", http.StatusBadRequest) - return - } - - w.Write([]byte(`{ "success": true }` + "\n")) - }) - - r.Get("/", func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("Hello\n")) - }) + if len(authURL) > 0 { + fmt.Printf("Delegating auth to %s\nIgnoring '/api/inspect'\n", authURL) + } else { + r.Get("/inspect", inspectToken) + } + r.Route("/register-device", registerDevice) + r.Post("/ping", pong) + r.Get("/", hello) }) }) } + +// TODO consolidate 'newClaimsGetter' and 'getVerifiedClaims' +func newClaimsGetter(authURL string) func(next http.Handler) http.Handler { + if 0 == len(authURL) { + return getVerifiedClaims + } + + authorizer := authutil.NewAuthorizer(authURL) + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + grants, err := authorizer(r) + if nil != err { + log.Println("authorization failed", err) + w.Write(authutil.NotAuthorizedContent) + return + } + + // TODO use Claims rather than Grants + claims := &authutil.Claims{ + Slug: grants.Subject, + StandardClaims: jwt.StandardClaims{ + Subject: grants.Subject, + Audience: grants.Audience, + }, + } + ctx := r.Context() + ctx = context.WithValue(ctx, authutil.MWKey("claims"), claims) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func getVerifiedClaims(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + var tokenString string + if auth := strings.Split(r.Header.Get("Authorization"), " "); len(auth) > 1 { + // TODO handle Basic auth tokens as well + tokenString = auth[1] + } + + //var err2 error = nil + tok, err := jwt.ParseWithClaims( + tokenString, + &authutil.Claims{}, + func(token *jwt.Token) (interface{}, error) { + if dbg.Debug { + fmt.Println("parsed jwt", token) + } + kid, ok := token.Header["kid"].(string) + if !ok { + return nil, fmt.Errorf("missing jwt header 'kid' (key id)") + } + auth, err := store.Get(kid) + if nil != err { + return nil, fmt.Errorf("invalid jwt header 'kid' (key id)") + } + + claims := token.Claims.(*authutil.Claims) + jti := claims.Id + if "" == jti { + return nil, fmt.Errorf("missing jwt payload 'jti' (jwt id / nonce)") + } + iat := claims.IssuedAt + if 0 == iat { + return nil, fmt.Errorf("missing jwt payload 'iat' (issued at)") + } + exp := claims.ExpiresAt + if 0 == exp { + return nil, fmt.Errorf("missing jwt payload 'exp' (expires at)") + } + + if "" != claims.Slug { + return nil, fmt.Errorf("extra jwt payload 'slug' (unknown)") + } + claims.Slug = auth.Slug + if "" != claims.Subject && auth.Slug != claims.Subject { + return nil, fmt.Errorf("invalid jwt payload 'sub' (mismatch)") + } + claims.Subject = claims.Slug + claims.Issuer = DeviceDomain + claims.Audience = fmt.Sprintf("wss://%s/ws", RelayDomain) + + /* + // a little misdirection there + mac := hmac.New(sha256.New, auth.MachinePPID) + _ = mac.Write([]byte(auth.SharedKey)) + _ = mac.Write([]byte(fmt.Sprintf("%d", exp))) + return []byte(auth.SharedKey), nil + */ + + //fmt.Println("ppid:", auth.MachinePPID) + + return []byte(auth.MachinePPID), nil + }, + ) + + ctx := r.Context() + if nil != tok { + if tok.Valid { + ctx = context.WithValue(ctx, authutil.MWKey("token"), tok) + ctx = context.WithValue(ctx, authutil.MWKey("claims"), tok.Claims) + ctx = context.WithValue(ctx, authutil.MWKey("valid"), true) + fmt.Println("[auth] Token is fully valid:") + fmt.Println(ctx.Value(authutil.MWKey("claims"))) + } else { + fmt.Println("[auth] Token was parsed, but NOT valid:", tok) + } + } + if nil != err { + fmt.Println("[auth] Token is NOT valid due to error:", err) + ctx = context.WithValue(ctx, authutil.MWKey("error"), err) + } + + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func inspectToken(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + claims, ok := ctx.Value(authutil.MWKey("claims")).(*authutil.Claims) + if !ok { + msg := `{"error":"failure to ping: 3", "code":"E_TOKEN"}` + fmt.Println("touch no claims", claims) + http.Error(w, msg+"\n", http.StatusBadRequest) + return + } + + w.Write([]byte(fmt.Sprintf( + `{ "sub": "%s", "aud": "%s", "domains": [ "%s.%s" ], "ports": [] }`+"\n", + claims.Subject, + claims.Audience, + claims.Slug, + DeviceDomain, + ))) +} + +func registerDevice(r chi.Router) { + // r.Use() // must NOT have slug '*' + + r.Post("/{otp}", func(w http.ResponseWriter, r *http.Request) { + sharedKey := chi.URLParam(r, "otp") + original, err := store.Get(sharedKey) + if nil != err { + msg := `{"error":"not found", "code":"E_NOT_FOUND"}` + log.Printf("/api/register-device/\n") + log.Println(err) + http.Error(w, msg, http.StatusNotFound) + return + } + if len(original.MachinePPID) > 0 { + msg := `{"error":"the presented key has already been used", "code":"E_EXIST"}` + log.Printf("/api/register-device/\n") + log.Println(err) + http.Error(w, msg, http.StatusUnprocessableEntity) + return + } + + auth := &authstore.Authorization{} + decoder := json.NewDecoder(r.Body) + err = decoder.Decode(&auth) + // MachinePPID and PublicKey are required. ID must NOT be set. Slug is ignored. + epoch := time.Time{} + auth.SharedKey = sharedKey + if nil != err || "" != auth.ID || "" == auth.MachinePPID || + "" == auth.PublicKey || "" == auth.SharedKey || + epoch != auth.CreatedAt || epoch != auth.UpdatedAt || epoch != auth.DeletedAt { + msg, _ := json.Marshal(&struct { + Error string `json:"error"` + }{ + Error: "expected JSON in the format {\"machine_ppid\":\"\",\"public_key\":\"\"}", + }) + http.Error(w, string(msg), http.StatusUnprocessableEntity) + return + } + + // TODO hash the PPID and check against the Public Key? + pub := authstore.ToPublicKeyString(auth.MachinePPID) + if pub != auth.PublicKey { + msg, _ := json.Marshal(&struct { + Error string `json:"error"` + }{ + Error: "expected `public_key` to be the first 24 bytes of the hash of the `machine_ppid`", + }) + http.Error(w, string(msg), http.StatusUnprocessableEntity) + return + } + original.PublicKey = auth.PublicKey + original.MachinePPID = auth.MachinePPID + err = store.Set(original) + if nil != err { + msg := `{"error":"not really sure what happened, but it didn't go well (check the logs)", "code":"E_SERVER"}` + log.Printf("/api/register-device/\n") + log.Println(err) + http.Error(w, msg, http.StatusInternalServerError) + return + } + + result, _ := json.Marshal(auth) + w.Write(result) + }) +} + +func pong(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + claims, ok := ctx.Value(authutil.MWKey("claims")).(*authutil.Claims) + if !ok { + msg := `{"error":"failure to ping: 1", "code":"E_TOKEN"}` + http.Error(w, msg+"\n", http.StatusBadRequest) + return + } + + fmt.Println("ping pong??", claims) + err := store.Touch(claims.Slug) + if nil != err { + fmt.Fprintf(os.Stderr, "touch err %s\n", err) + var msg string + if err == authstore.ErrNotFound { + // if the token is valid, touch should ALWAYS work + msg = `{"error":"failure to ping: inconsistent database data", "code":"E_SERVER"}` + } else { + msg = `{"error":"failure to ping: 2", "code":"E_SERVER"}` + } + http.Error(w, msg+"\n", http.StatusBadRequest) + return + } + + w.Write([]byte(`{ "success": true }` + "\n")) +} + +func hello(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("Hello\n")) +} diff --git a/internal/telebit/admin.go b/internal/telebit/admin.go index 5dfe4ce..12b7469 100644 --- a/internal/telebit/admin.go +++ b/internal/telebit/admin.go @@ -14,6 +14,7 @@ import ( "time" "git.rootprojects.org/root/telebit/assets/admin" + "git.rootprojects.org/root/telebit/internal/authutil" "git.rootprojects.org/root/telebit/internal/dbg" "github.com/go-chi/chi" @@ -88,14 +89,14 @@ func RouteAdmin(authURL string, r chi.Router) { grants, err := authorizer(r) if nil != err { log.Println("authorization failed", err) - w.Write(apiNotAuthorizedContent) + w.Write(authutil.NotAuthorizedContent) return } // TODO define Admins in a better way if "*" != grants.Subject { log.Println("only admins allowed", err) - w.Write(apiNotAuthorizedContent) + w.Write(authutil.NotAuthorizedContent) return } @@ -121,7 +122,6 @@ func RouteAdmin(authURL string, r chi.Router) { } var apiNotFoundContent = []byte("{ \"error\": \"not found\" }\n") -var apiNotAuthorizedContent = []byte("{ \"error\": \"not authorized\" }\n") func apiNotFoundHandler(w http.ResponseWriter, r *http.Request) { w.Write(apiNotFoundContent) @@ -232,7 +232,7 @@ func upgradeWebsocket(w http.ResponseWriter, r *http.Request) { grants, err := authorizer(r) if nil != err { log.Println("WebSocket authorization failed", err) - w.Write(apiNotAuthorizedContent) + w.Write(authutil.NotAuthorizedContent) return } diff --git a/internal/telebit/authorizer.go b/internal/telebit/authorizer.go deleted file mode 100644 index 1908615..0000000 --- a/internal/telebit/authorizer.go +++ /dev/null @@ -1,41 +0,0 @@ -package telebit - -import ( - "fmt" - "net/http" - "strings" -) - -func NewAuthorizer(authURL string) Authorizer { - return func(r *http.Request) (*Grants, error) { - // do we have a valid wss_client? - - fmt.Printf("[authz] Authorization = %s\n", r.Header.Get("Authorization")) - var tokenString string - if auth := strings.Split(r.Header.Get("Authorization"), " "); len(auth) > 1 { - // TODO handle Basic auth tokens as well - tokenString = auth[1] - } - if "" == tokenString { - // Browsers do not allow Authorization Headers and must use access_token query string - tokenString = r.URL.Query().Get("access_token") - } - if "" != r.URL.Query().Get("access_token") { - r.URL.Query().Set("access_token", "[redacted]") - } - - fmt.Printf("[authz] authURL = %s\n", authURL) - fmt.Printf("[authz] token = %s\n", tokenString) - grants, err := Inspect(authURL, tokenString) - - if nil != err { - fmt.Printf("[authorizer] error inspecting %q: %s\ntoken: %s\n", authURL, err, tokenString) - return nil, err - } - if "" != r.URL.Query().Get("access_token") { - r.URL.Query().Set("access_token", "[redacted:"+grants.Subject+"]") - } - - return grants, err - } -} diff --git a/internal/telebit/telebit.go b/internal/telebit/telebit.go index 5e84ffc..be478b3 100644 --- a/internal/telebit/telebit.go +++ b/internal/telebit/telebit.go @@ -1,13 +1,10 @@ package telebit import ( - "bytes" "crypto/tls" - "encoding/json" "errors" "fmt" "io" - "io/ioutil" "net" "net/http" "net/http/httputil" @@ -16,7 +13,6 @@ import ( "strings" "time" - "git.rootprojects.org/root/telebit/internal/dbg" httpshim "git.rootprojects.org/root/telebit/internal/tunnel" "github.com/coolaj86/certmagic" @@ -48,9 +44,6 @@ type Handler interface { // HandlerFunc should handle, proxy, or terminate the connection type HandlerFunc func(net.Conn) error -// Authorizer is called when a new client connects and we need to know something about it -type Authorizer func(*http.Request) (*Grants, error) - // Serve calls f(conn). func (f HandlerFunc) Serve(conn net.Conn) error { return f(conn) @@ -415,6 +408,7 @@ func TerminateTLS(client net.Conn, acme *ACME) net.Conn { } } +// NewCertMagic creates our flavor of a CertMagic instance func NewCertMagic(acme *ACME) (*certmagic.Config, error) { if !acme.Agree { fmt.Fprintf( @@ -458,68 +452,3 @@ func NewCertMagic(acme *ACME) (*certmagic.Config, error) { magic.Issuer = certmagic.NewACMEManager(magic, manager) return magic, nil } - -type Grants struct { - Subject string `json:"sub"` - Audience string `json:"aud"` - Domains []string `json:"domains"` - Ports []int `json:"ports"` -} - -func Inspect(authURL, token string) (*Grants, error) { - inspectURL := strings.TrimSuffix(authURL, "/inspect") + "/inspect" - if dbg.Debug { - fmt.Fprintf(os.Stderr, "[debug] telebit.Inspect(\n\tinspectURL = %s,\n\ttoken = %s,\n)\n", inspectURL, token) - } - msg, err := Request("GET", inspectURL, token, nil) - if nil != err { - return nil, err - } - if nil == msg { - return nil, fmt.Errorf("invalid response") - } - - grants := &Grants{} - err = json.NewDecoder(msg).Decode(grants) - if err != nil { - return nil, err - } - if "" == grants.Subject { - fmt.Fprintf(os.Stderr, "TODO update mgmt server to show Subject: %q\n", msg) - grants.Subject = strings.Split(grants.Domains[0], ".")[0] - } - return grants, nil -} - -func Request(method, fullurl, token string, payload io.Reader) (io.Reader, error) { - HTTPClient := &http.Client{ - Timeout: 15 * time.Second, - } - req, err := http.NewRequest(method, fullurl, payload) - if err != nil { - return nil, err - } - if len(token) > 0 { - req.Header.Set("Authorization", "Bearer "+token) - } - if nil != payload { - req.Header.Set("Content-Type", "application/json") - } - - resp, err := HTTPClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("%d: failed to read response body: %w", resp.StatusCode, err) - } - - if resp.StatusCode >= http.StatusBadRequest { - return nil, fmt.Errorf("%d: request failed: %v", resp.StatusCode, string(body)) - } - - return bytes.NewBuffer(body), nil -}