Boilerplate for how I like to write a backend web service.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

414 lines
10 KiB

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)
})
}