415 lines
10 KiB
Go
415 lines
10 KiB
Go
|
package api
|
||
|
|
||
|
import (
|
||
|
"context"
|
||
|
"crypto/rand"
|
||
|
"database/sql"
|
||
|
"encoding/json"
|
||
|
"errors"
|
||
|
"fmt"
|
||
|
"io"
|
||
|
"log"
|
||
|
"net/http"
|
||
|
"strconv"
|
||
|
"strings"
|
||
|
"time"
|
||
|
|
||
|
"git.rootprojects.org/root/keypairs"
|
||
|
"git.rootprojects.org/root/keypairs/keyfetch"
|
||
|
|
||
|
"github.com/go-chi/chi"
|
||
|
)
|
||
|
|
||
|
// TrustProxy will respect X-Forwarded-* headers
|
||
|
var TrustProxy bool
|
||
|
|
||
|
// OIDCWhitelist is a list of allowed issuers
|
||
|
var OIDCWhitelist string
|
||
|
var issuers keyfetch.Whitelist
|
||
|
|
||
|
// RandReader is a crypto/rand.Reader by default
|
||
|
var RandReader io.Reader = rand.Reader
|
||
|
|
||
|
var startedAt = time.Now()
|
||
|
var defaultMaxBytes int64 = 1 << 20
|
||
|
var apiIsReady bool
|
||
|
|
||
|
// Init will add the API routes to the given router
|
||
|
func Init(pub keypairs.PublicKey, r chi.Router) http.Handler {
|
||
|
|
||
|
// TODO more of this stuff should be options for the API
|
||
|
{
|
||
|
// block-scoped so we don't keep temp vars around
|
||
|
var err error
|
||
|
list := strings.Fields(strings.ReplaceAll(strings.TrimSpace(OIDCWhitelist), ",", " "))
|
||
|
issuers, err = keyfetch.NewWhitelist(list)
|
||
|
if nil != err {
|
||
|
log.Fatal("error parsing oidc whitelist:", err)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// OIDC Routes
|
||
|
if nil != pub {
|
||
|
fmt.Println("Public Key Thumbprint:", pub.Thumbprint())
|
||
|
fmt.Println("OIDC enabled at /.well-known/openid-configuration")
|
||
|
r.Get("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
||
|
baseURL := getBaseURL(r)
|
||
|
w.Header().Set("Content-Type", "application/json")
|
||
|
w.Write([]byte(fmt.Sprintf(
|
||
|
`{ "issuer": "%s", "jwks_uri": "%s/.well-known/jwks.json" }`+"\n",
|
||
|
baseURL, baseURL,
|
||
|
)))
|
||
|
})
|
||
|
fmt.Println("JWKs enabled at /.well-known/jwks.json")
|
||
|
r.Get("/.well-known/jwks.json", func(w http.ResponseWriter, r *http.Request) {
|
||
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
||
|
// non-standard: add expiry for when key should be fetched again
|
||
|
// TODO expiry should also go in the HTTP caching headers
|
||
|
exp := time.Now().Add(2 * time.Hour)
|
||
|
b := pubToOIDC(pub, exp)
|
||
|
|
||
|
// it's the little things
|
||
|
w.Write(append(b, '\n'))
|
||
|
})
|
||
|
}
|
||
|
|
||
|
r.Route("/api", func(r chi.Router) {
|
||
|
r.Use(limitResponseSize)
|
||
|
r.Use(jsonAllTheThings)
|
||
|
|
||
|
/*
|
||
|
n, err := countAdmins()
|
||
|
if nil != err {
|
||
|
log.Fatal("could not connect to database on boot:", err)
|
||
|
}
|
||
|
apiIsReady = n > 0
|
||
|
*/
|
||
|
|
||
|
// Unauthenticated routes
|
||
|
r.Route("/public", func(r chi.Router) {
|
||
|
r.Post("/setup", publicSetup)
|
||
|
|
||
|
r.Get("/ping", ping)
|
||
|
})
|
||
|
|
||
|
// Require admin-level permission
|
||
|
r.Route("/admin", func(r chi.Router) {
|
||
|
r.Use(errorUnlessReady())
|
||
|
r.Use(mustAdmin())
|
||
|
|
||
|
r.Get("/ping", ping)
|
||
|
})
|
||
|
|
||
|
// Any authenticated user
|
||
|
r.Route("/user", func(r chi.Router) {
|
||
|
r.Use(errorUnlessReady())
|
||
|
r.Use(canImpersonate())
|
||
|
r.Get("/ping", ping)
|
||
|
|
||
|
// TODO get ALL of the user's data
|
||
|
//r.Get("/", userComplete)
|
||
|
})
|
||
|
})
|
||
|
|
||
|
return r
|
||
|
}
|
||
|
|
||
|
// Reset sets the API back to its initial state
|
||
|
func Reset() {
|
||
|
apiIsReady = false
|
||
|
}
|
||
|
|
||
|
// utils
|
||
|
|
||
|
func getBaseURL(r *http.Request) string {
|
||
|
var scheme string
|
||
|
if nil != r.TLS ||
|
||
|
(TrustProxy && "https" == r.Header.Get("X-Forwarded-Proto")) {
|
||
|
scheme = "https:"
|
||
|
} else {
|
||
|
scheme = "http:"
|
||
|
}
|
||
|
return fmt.Sprintf(
|
||
|
"%s//%s",
|
||
|
scheme,
|
||
|
r.Host,
|
||
|
)
|
||
|
}
|
||
|
|
||
|
func mustAuthn(r *http.Request) (*http.Request, error) {
|
||
|
authzParts := strings.Split(r.Header.Get("Authorization"), " ")
|
||
|
if 2 != len(authzParts) {
|
||
|
return nil, fmt.Errorf("Bad Request: missing Auhorization header")
|
||
|
}
|
||
|
|
||
|
jwt := authzParts[1]
|
||
|
// TODO should probably add an error to keypairs
|
||
|
jws := keypairs.JWTToJWS(jwt)
|
||
|
if nil == jws {
|
||
|
return nil, fmt.Errorf("Bad Request: malformed Authorization header")
|
||
|
}
|
||
|
|
||
|
if err := jws.DecodeComponents(); nil != err {
|
||
|
return nil, fmt.Errorf("Bad Request: malformed JWT")
|
||
|
}
|
||
|
|
||
|
kid, _ := jws.Header["kid"].(string)
|
||
|
if "" == kid {
|
||
|
return nil, fmt.Errorf("Bad Request: missing 'kid' identifier")
|
||
|
}
|
||
|
|
||
|
iss, _ := jws.Claims["iss"].(string)
|
||
|
// TODO beware domain fronting, we should set domain statically
|
||
|
// See https://pkg.go.dev/git.rootprojects.org/root/keypairs@v0.6.2/keyfetch
|
||
|
// (Caddy does protect against Domain-Fronting by default:
|
||
|
// https://github.com/caddyserver/caddy/issues/2500)
|
||
|
if "" == iss || !issuers.IsTrustedIssuer(iss, r) {
|
||
|
return nil, fmt.Errorf("Bad Request: 'iss' is not a trusted issuer")
|
||
|
}
|
||
|
|
||
|
pub, err := keyfetch.OIDCJWK(kid, iss)
|
||
|
if nil != err {
|
||
|
return nil, fmt.Errorf("Bad Request: 'kid' could not be matched to a known public key")
|
||
|
}
|
||
|
|
||
|
errs := keypairs.VerifyClaims(pub, jws)
|
||
|
if nil != errs {
|
||
|
strs := []string{}
|
||
|
for _, err := range errs {
|
||
|
strs = append(strs, err.Error())
|
||
|
}
|
||
|
return nil, fmt.Errorf("invalid jwt:\n%s", strings.Join(strs, "\n\t"))
|
||
|
}
|
||
|
|
||
|
email, _ := jws.Claims["email"].(string)
|
||
|
ppid, _ := jws.Claims["sub"].(string)
|
||
|
if "" == email || "" == ppid {
|
||
|
return nil, fmt.Errorf("valid signed token, but missing claim for either 'email' or 'sub'")
|
||
|
}
|
||
|
|
||
|
ctx := context.WithValue(r.Context(), userPPID, ppid)
|
||
|
return r.WithContext(ctx), nil
|
||
|
}
|
||
|
|
||
|
func pubToOIDC(pub keypairs.PublicKey, exp time.Time) []byte {
|
||
|
exps := strconv.FormatInt(exp.Unix(), 10)
|
||
|
jsons := string(keypairs.MarshalJWKPublicKey(pub))
|
||
|
|
||
|
// this isn't as fragile as it looks, just adding some OIDC keys and such
|
||
|
|
||
|
// make prettier
|
||
|
jsons = strings.Replace(jsons, `{"`, `{ "`, 1)
|
||
|
jsons = strings.Replace(jsons, `",`, `" ,`, -1)
|
||
|
|
||
|
// nix trailing }
|
||
|
jsons = jsons[0 : len(jsons)-1]
|
||
|
// add on the OIDC stuff (exp is non-standard, but used by pocketid)
|
||
|
jsons = `{ "keys": [ ` +
|
||
|
jsons + fmt.Sprintf(`, "ext": true , "key_ops": ["verify"], "exp": %s }`, exps) +
|
||
|
" ] }"
|
||
|
|
||
|
return []byte(jsons)
|
||
|
}
|
||
|
|
||
|
// HTTPResponse gives a basic status message
|
||
|
// TODO sanitize all error messages and define error codes
|
||
|
type HTTPResponse struct {
|
||
|
Error string `json:"error,omitempty"`
|
||
|
Code string `json:"code,omitempty"`
|
||
|
Success bool `json:"success"`
|
||
|
Data interface{} `json:"result,omitempty"`
|
||
|
}
|
||
|
|
||
|
func ping(w http.ResponseWriter, r *http.Request) {
|
||
|
ctx := r.Context()
|
||
|
// as of yet, only known to be a user
|
||
|
ppid, _ := ctx.Value(userPPID).(string)
|
||
|
|
||
|
w.Write([]byte(fmt.Sprintf(
|
||
|
`{ "success": true, "uptime": %.0f, "ppid": %q }`+"\n",
|
||
|
time.Since(startedAt).Seconds(), ppid,
|
||
|
)))
|
||
|
}
|
||
|
|
||
|
func noImpl(w http.ResponseWriter, r *http.Request) {
|
||
|
w.Write([]byte(
|
||
|
`{ "success": false, "error": "not implemented" }` + "\n",
|
||
|
))
|
||
|
}
|
||
|
|
||
|
func publicSetup(w http.ResponseWriter, r *http.Request) {
|
||
|
if apiIsReady {
|
||
|
// default is already 404, methinks
|
||
|
http.Error(w, "Not Found", http.StatusNotFound)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
r, err := mustAuthn(r)
|
||
|
if nil != err {
|
||
|
userError(w, err)
|
||
|
return
|
||
|
}
|
||
|
ctx := r.Context()
|
||
|
ppid := ctx.Value(userPPID).(string)
|
||
|
if "" == ppid {
|
||
|
if nil != err {
|
||
|
userError(w, err)
|
||
|
return
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if err := adminBootstrap(ppid); nil != err {
|
||
|
serverError("publicSetup.bootstrap", w, err)
|
||
|
return
|
||
|
}
|
||
|
apiIsReady = true
|
||
|
|
||
|
w.Write([]byte(fmt.Sprintf(
|
||
|
`{ "success": true, "sub": %q }`+"\n",
|
||
|
ppid,
|
||
|
)))
|
||
|
}
|
||
|
|
||
|
func reply(w http.ResponseWriter, msg interface{}) {
|
||
|
w.WriteHeader(http.StatusOK)
|
||
|
b, _ := json.MarshalIndent(&HTTPResponse{
|
||
|
Success: true,
|
||
|
Data: msg,
|
||
|
}, "", " ")
|
||
|
w.Write(append(b, '\n'))
|
||
|
}
|
||
|
|
||
|
func userError(w http.ResponseWriter, err error) {
|
||
|
w.WriteHeader(http.StatusBadRequest)
|
||
|
b, _ := json.Marshal(&HTTPResponse{
|
||
|
Error: err.Error(),
|
||
|
})
|
||
|
w.Write(append(b, '\n'))
|
||
|
}
|
||
|
|
||
|
func dbError(hint string, w http.ResponseWriter, err error) {
|
||
|
serverError(hint, w, err)
|
||
|
}
|
||
|
|
||
|
func serverError(hint string, w http.ResponseWriter, err error) {
|
||
|
// TODO check constraint errors and such, as those are likely user errors
|
||
|
if sql.ErrNoRows == err {
|
||
|
userError(w, fmt.Errorf("E_NOT_FOUND: %s", err))
|
||
|
return
|
||
|
} else if strings.Contains(err.Error(), "constraint") {
|
||
|
userError(w, fmt.Errorf("E_DUPLICATE_NAME: %s", err))
|
||
|
return
|
||
|
}
|
||
|
log.Printf("[%s] error: %v", hint, err)
|
||
|
w.WriteHeader(http.StatusInternalServerError)
|
||
|
b, _ := json.Marshal(&HTTPResponse{
|
||
|
Error: err.Error(),
|
||
|
})
|
||
|
w.Write(append(b, '\n'))
|
||
|
}
|
||
|
|
||
|
var errSetID = errors.New("you may not set the player's ID")
|
||
|
|
||
|
// Middleware
|
||
|
|
||
|
func errorUnlessReady() func(next http.Handler) http.Handler {
|
||
|
return func(next http.Handler) http.Handler {
|
||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
if !apiIsReady {
|
||
|
http.Error(w, "Not Found", http.StatusNotFound)
|
||
|
return
|
||
|
}
|
||
|
next.ServeHTTP(w, r)
|
||
|
})
|
||
|
}
|
||
|
}
|
||
|
|
||
|
type adminCtx string
|
||
|
|
||
|
const (
|
||
|
adminPPID adminCtx = "ppid"
|
||
|
)
|
||
|
|
||
|
type userCtx string
|
||
|
|
||
|
const (
|
||
|
userPPID userCtx = "ppid"
|
||
|
)
|
||
|
|
||
|
func canImpersonate() func(next http.Handler) http.Handler {
|
||
|
return actAsAdmin(false)
|
||
|
}
|
||
|
|
||
|
func mustAdmin() func(next http.Handler) http.Handler {
|
||
|
return actAsAdmin(true)
|
||
|
}
|
||
|
|
||
|
func countAdmins() (int, error) {
|
||
|
return 0, errors.New("not implemented")
|
||
|
}
|
||
|
|
||
|
func adminBootstrap(ppid string) error {
|
||
|
return errors.New("not implemented")
|
||
|
}
|
||
|
|
||
|
func actAsAdmin(must bool) func(next http.Handler) http.Handler {
|
||
|
return func(next http.Handler) http.Handler {
|
||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
var err error
|
||
|
r, err = mustAuthn(r)
|
||
|
if nil != err {
|
||
|
userError(w, err)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
ctx := r.Context()
|
||
|
// as of yet, only known to be a user
|
||
|
ppid, _ := ctx.Value(userPPID).(string)
|
||
|
|
||
|
//ok, err := isAdmin(ppid)
|
||
|
ok, err := false, errors.New("not implemented")
|
||
|
if nil != err {
|
||
|
serverError("actAsAdmin", w, err)
|
||
|
return
|
||
|
}
|
||
|
if !ok {
|
||
|
if must {
|
||
|
userError(w, errors.New("you're not an admin"))
|
||
|
return
|
||
|
}
|
||
|
next.ServeHTTP(w, r)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
// we now know this is an admin, adjust accordingly
|
||
|
ctx = context.WithValue(r.Context(), adminPPID, ppid)
|
||
|
|
||
|
// also, an admin can impersonate
|
||
|
//uemail := r.URL.Query().Get("user_email")
|
||
|
uppid := r.URL.Query().Get("manager_id")
|
||
|
if "" == uppid {
|
||
|
uppid = ppid
|
||
|
}
|
||
|
|
||
|
ctx = context.WithValue(ctx, userPPID, uppid)
|
||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||
|
})
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func jsonAllTheThings(next http.Handler) http.Handler {
|
||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
// just setting a default, other handlers can change this
|
||
|
w.Header().Set("Content-Type", "application/json")
|
||
|
next.ServeHTTP(w, r)
|
||
|
})
|
||
|
}
|
||
|
|
||
|
func limitResponseSize(next http.Handler) http.Handler {
|
||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
r.Body = http.MaxBytesReader(w, r.Body, defaultMaxBytes)
|
||
|
next.ServeHTTP(w, r)
|
||
|
})
|
||
|
}
|