AJ ONeal
4 years ago
28 changed files with 1418 additions and 218 deletions
@ -0,0 +1,37 @@ |
|||||
|
# This is an example goreleaser.yaml file with some sane defaults. |
||||
|
# Make sure to check the documentation at http://goreleaser.com |
||||
|
before: |
||||
|
hooks: |
||||
|
# You may remove this if you don't use go modules. |
||||
|
- go mod download |
||||
|
# you may remove this if you don't need go generate |
||||
|
- go generate ./... |
||||
|
builds: |
||||
|
- env: |
||||
|
- CGO_ENABLED=0 |
||||
|
goos: |
||||
|
- linux |
||||
|
- windows |
||||
|
- darwin |
||||
|
goarch: |
||||
|
- amd64 |
||||
|
- arm64 |
||||
|
- arm |
||||
|
archives: |
||||
|
- replacements: |
||||
|
darwin: Darwin |
||||
|
linux: Linux |
||||
|
windows: Windows |
||||
|
386: i386 |
||||
|
amd64: x86_64 |
||||
|
arm64: aarch64 |
||||
|
checksum: |
||||
|
name_template: 'checksums.txt' |
||||
|
snapshot: |
||||
|
name_template: "{{ .Tag }}-next" |
||||
|
changelog: |
||||
|
sort: asc |
||||
|
filters: |
||||
|
exclude: |
||||
|
- '^docs:' |
||||
|
- '^test:' |
@ -0,0 +1,2 @@ |
|||||
|
public/vendor |
||||
|
vendor/ |
@ -1,5 +1,5 @@ |
|||||
// +build !dev
|
// +build !dev
|
||||
|
|
||||
//go:generate go run -mod vendor github.com/shurcooL/vfsgen/cmd/vfsgendev -source="git.coolaj86.com/coolaj86/goserv/assets".Assets
|
//go:generate go run -mod vendor github.com/shurcooL/vfsgen/cmd/vfsgendev -source="git.example.com/example/goserv/assets".Assets
|
||||
|
|
||||
package assets |
package assets |
||||
|
@ -1,5 +1,5 @@ |
|||||
// +build !dev
|
// +build !dev
|
||||
|
|
||||
//go:generate go run -mod vendor github.com/shurcooL/vfsgen/cmd/vfsgendev -source="git.coolaj86.com/coolaj86/goserv/assets/configfs".Assets
|
//go:generate go run -mod vendor github.com/shurcooL/vfsgen/cmd/vfsgendev -source="git.example.com/example/goserv/assets/configfs".Assets
|
||||
|
|
||||
package configfs |
package configfs |
||||
|
@ -0,0 +1,3 @@ |
|||||
|
-- this is only used for the tests |
||||
|
DROP TABLE IF EXISTS "authn"; |
||||
|
DROP TABLE IF EXISTS "events"; |
@ -0,0 +1,6 @@ |
|||||
|
// See also
|
||||
|
//
|
||||
|
// internal/api: http://localhost:6060/pkg/git.example.com/example/project/internal/api
|
||||
|
//
|
||||
|
// internal/db: http://localhost:6060/pkg/git.example.com/example/project/internal/db
|
||||
|
package main |
@ -1,2 +1,22 @@ |
|||||
PORT="3000" |
PORT="3000" |
||||
#LISTEN=":3000" |
#LISTEN=":3000" |
||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres |
||||
|
TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres_test |
||||
|
|
||||
|
TRUST_PROXY=false |
||||
|
|
||||
|
# Supports OIDC-compliant SSO issuers / providers |
||||
|
# (Auth0, Google, etc) |
||||
|
# (should provide .well-known/openid-configuration and .well-known/jwks.json) |
||||
|
OIDC_WHITELIST=https://mock.pocketid.app |
||||
|
|
||||
|
# Public Key may be provided in addition to or in lieu of OIDC_WHITELIST |
||||
|
# can be RSA or ECDSA, either a filename or JWK/JSON (or PEM, but good luck escaping the newlines) |
||||
|
# |
||||
|
# go install -mod=vendor git.rootprojects.org/root/keypairs/cmd/keypairs |
||||
|
# keypairs gen -o priv.jwk.json --pub pub.jwk.json |
||||
|
# |
||||
|
#PUBLIC_KEY='{"crv":"P-256","kid":"kN4qj1w01Ry6ElG9I3qAVJOZFYLDklPFUdHaKozWtmc","kty":"EC","use":"sig","x":"SzzNgrOM_N0GwQWZPGFcdIKmfoQD6aXIzYm4gzGyPgQ","y":"erYeb884pk0BGMewDzEh_qYDB0aOFIxFjrXdqIzkmbw"}' |
||||
|
PUBLIC_KEY=./pub.jwk.json |
||||
|
|
||||
|
#--demo is explicit |
||||
|
@ -0,0 +1,5 @@ |
|||||
|
# Install `keypairs` |
||||
|
go install -mod=vendor git.rootprojects.org/root/keypairs/cmd/keypairs |
||||
|
|
||||
|
# Generate a keypair |
||||
|
keypairs gen -o key.jwk.json --pub pub.jwk.json |
@ -0,0 +1,4 @@ |
|||||
|
#!/bin/bash |
||||
|
bash ./examples/build.sh |
||||
|
bash ./examples/genkeys.sh |
||||
|
bash ./examples/test.sh |
@ -0,0 +1,45 @@ |
|||||
|
#!/bin/bash |
||||
|
|
||||
|
export BASE_URL=http://localhost:7070 |
||||
|
#export BASE_URL=https://example.com |
||||
|
#CURL_OPTS="-sS" |
||||
|
CURL_OPTS="" |
||||
|
|
||||
|
mkdir -p ./tmp/ |
||||
|
|
||||
|
# Sign an Admin token |
||||
|
echo '{ "sub": "admin_ppid", "email": "me@example.com", "iss": "'"${BASE_URL}"'" }' > ./tmp/admin.claims.json |
||||
|
keypairs sign --exp 1h ./key.jwk.json ./tmp/admin.claims.json > ./tmp/admin.jwt.txt 2> ./tmp/admin.jws.json |
||||
|
export ADMIN_TOKEN=$(cat ./tmp/admin.jwt.txt) |
||||
|
|
||||
|
# verify the Admin token |
||||
|
#keypairs verify ./pub.jwk.json ./admin.jwt.txt |
||||
|
|
||||
|
|
||||
|
# Sign a User token |
||||
|
echo '{ "sub": "random_ppid", "email": "me@example.com", "iss": "'"${BASE_URL}"'" }' > ./tmp/user.claims.json |
||||
|
keypairs sign --exp 1h ./key.jwk.json ./tmp/user.claims.json > ./tmp/user.jwt.txt 2> ./tmp/user.jws.json |
||||
|
export USER_TOKEN=$(cat ./tmp/user.jwt.txt) |
||||
|
|
||||
|
# verify the User token |
||||
|
#keypairs verify ./pub.jwk.json ./user.jwt.txt |
||||
|
|
||||
|
|
||||
|
EID=$(cat ./user.jws.json | grep sub | cut -d'"' -f 4) |
||||
|
|
||||
|
echo "" |
||||
|
echo 'DELETE /api/public/reset (only works in --demo mode, deletes all data)' |
||||
|
curl $CURL_OPTS -X DELETE "${BASE_URL}/api/public/reset" |
||||
|
echo "" |
||||
|
|
||||
|
echo "" |
||||
|
echo "Bootstrap with a new admin (only works once)" |
||||
|
curl -f $CURL_OPTS -X POST "${BASE_URL}/api/public/setup" \ |
||||
|
-H "Authorization: Bearer ${ADMIN_TOKEN}" |
||||
|
echo "" |
||||
|
|
||||
|
echo "Create a new user" |
||||
|
curl $CURL_OPTS -X POST "${BASE_URL}/api/users" \ |
||||
|
-H "Authorization: Bearer ${USER_TOKEN}" \ |
||||
|
-d '{ "display_name": "Jo Doe" }' |
||||
|
echo "" |
@ -1,14 +1,17 @@ |
|||||
module git.coolaj86.com/coolaj86/goserv |
module git.example.com/example/goserv |
||||
|
|
||||
go 1.15 |
go 1.15 |
||||
|
|
||||
require ( |
require ( |
||||
|
git.rootprojects.org/root/go-gitver v1.1.3 // indirect |
||||
|
git.rootprojects.org/root/go-gitver/v2 v2.0.1 |
||||
|
git.rootprojects.org/root/keypairs v0.6.3 |
||||
github.com/go-chi/chi v4.1.2+incompatible |
github.com/go-chi/chi v4.1.2+incompatible |
||||
github.com/jmoiron/sqlx v1.2.0 |
github.com/jmoiron/sqlx v1.2.0 |
||||
github.com/joho/godotenv v1.3.0 |
github.com/joho/godotenv v1.3.0 |
||||
github.com/lib/pq v1.8.0 |
github.com/lib/pq v1.8.0 |
||||
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 // indirect |
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 // indirect |
||||
github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546 |
github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546 |
||||
golang.org/x/tools v0.0.0-20200925191224-5d1fdd8fa346 // indirect |
golang.org/x/tools v0.0.0-20201001230009-b5b87423c93b |
||||
google.golang.org/appengine v1.6.6 // indirect |
google.golang.org/appengine v1.6.6 // indirect |
||||
) |
) |
||||
|
@ -0,0 +1,414 @@ |
|||||
|
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) |
||||
|
}) |
||||
|
} |
@ -0,0 +1,164 @@ |
|||||
|
package api |
||||
|
|
||||
|
import ( |
||||
|
"bytes" |
||||
|
"encoding/json" |
||||
|
"errors" |
||||
|
"fmt" |
||||
|
"io/ioutil" |
||||
|
"log" |
||||
|
mathrand "math/rand" |
||||
|
"net/http" |
||||
|
"net/http/httptest" |
||||
|
"net/url" |
||||
|
"os" |
||||
|
"strings" |
||||
|
"testing" |
||||
|
|
||||
|
"git.example.com/example/goserv/internal/db" |
||||
|
"git.rootprojects.org/root/keypairs" |
||||
|
"git.rootprojects.org/root/keypairs/keyfetch" |
||||
|
|
||||
|
"github.com/go-chi/chi" |
||||
|
) |
||||
|
|
||||
|
var srv *httptest.Server |
||||
|
|
||||
|
var testKey keypairs.PrivateKey |
||||
|
var testPub keypairs.PublicKey |
||||
|
var testWhitelist keyfetch.Whitelist |
||||
|
|
||||
|
func init() { |
||||
|
// In tests it's nice to get the same "random" values, every time
|
||||
|
RandReader = testReader{} |
||||
|
mathrand.Seed(0) |
||||
|
} |
||||
|
|
||||
|
func TestMain(m *testing.M) { |
||||
|
connStr := needsTestDB(m) |
||||
|
if strings.Contains(connStr, "@localhost/") || strings.Contains(connStr, "@localhost:") { |
||||
|
connStr += "?sslmode=disable" |
||||
|
} else { |
||||
|
connStr += "?sslmode=required" |
||||
|
} |
||||
|
|
||||
|
if err := db.Init(connStr); nil != err { |
||||
|
log.Fatal("db connection error", err) |
||||
|
return |
||||
|
} |
||||
|
if err := db.DropAllTables(db.PleaseDoubleCheckTheDatabaseURLDontDropProd(connStr)); nil != err { |
||||
|
log.Fatal(err) |
||||
|
} |
||||
|
if err := db.Init(connStr); nil != err { |
||||
|
log.Fatal("db connection error", err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
var err error |
||||
|
testKey = keypairs.NewDefaultPrivateKey() |
||||
|
testPub = keypairs.NewPublicKey(testKey.Public()) |
||||
|
r := chi.NewRouter() |
||||
|
srv = httptest.NewServer(Init(testPub, r)) |
||||
|
testWhitelist, err = keyfetch.NewWhitelist(nil, []string{srv.URL}) |
||||
|
if nil != err { |
||||
|
log.Fatal("bad whitelist", err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
os.Exit(m.Run()) |
||||
|
} |
||||
|
|
||||
|
// public APIs
|
||||
|
|
||||
|
func Test_Public_Ping(t *testing.T) { |
||||
|
if err := testPing("public"); nil != err { |
||||
|
t.Fatal(err) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// test types
|
||||
|
|
||||
|
type testReader struct{} |
||||
|
|
||||
|
func (testReader) Read(p []byte) (n int, err error) { |
||||
|
return mathrand.Read(p) |
||||
|
} |
||||
|
|
||||
|
func testPing(which string) error { |
||||
|
urlstr := fmt.Sprintf("/api/%s/ping", which) |
||||
|
res, err := testReq("GET", urlstr, "", nil, 200) |
||||
|
if nil != err { |
||||
|
return err |
||||
|
} |
||||
|
|
||||
|
data := map[string]interface{}{} |
||||
|
if err := json.NewDecoder(res.Body).Decode(&data); nil != err { |
||||
|
return err |
||||
|
} |
||||
|
|
||||
|
if success, ok := data["success"].(bool); !ok || !success { |
||||
|
log.Printf("Bad Response\n\tURL:%s\n\tBody:\n%#v", urlstr, data) |
||||
|
return errors.New("bad response: missing success") |
||||
|
} |
||||
|
|
||||
|
if ppid, _ := data["ppid"].(string); "" != ppid { |
||||
|
return fmt.Errorf("the effective user ID isn't what it should be: %q != %q", ppid, "") |
||||
|
} |
||||
|
|
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func testReq(method, pathname string, jwt string, payload []byte, expectedStatus int) (*http.Response, error) { |
||||
|
client := srv.Client() |
||||
|
urlstr, _ := url.Parse(srv.URL + pathname) |
||||
|
|
||||
|
if "" == method { |
||||
|
method = "GET" |
||||
|
} |
||||
|
|
||||
|
req := &http.Request{ |
||||
|
Method: method, |
||||
|
URL: urlstr, |
||||
|
Body: ioutil.NopCloser(bytes.NewReader(payload)), |
||||
|
Header: http.Header{}, |
||||
|
} |
||||
|
|
||||
|
if len(jwt) > 0 { |
||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", jwt)) |
||||
|
} |
||||
|
res, err := client.Do(req) |
||||
|
if nil != err { |
||||
|
return nil, err |
||||
|
} |
||||
|
if expectedStatus > 0 { |
||||
|
if expectedStatus != res.StatusCode { |
||||
|
data, _ := ioutil.ReadAll(res.Body) |
||||
|
log.Printf("Bad Response: %d\n\tURL:%s\n\tBody:\n%s", res.StatusCode, urlstr, string(data)) |
||||
|
return nil, fmt.Errorf("bad status code: %d", res.StatusCode) |
||||
|
} |
||||
|
} |
||||
|
return res, nil |
||||
|
} |
||||
|
|
||||
|
func needsTestDB(m *testing.M) string { |
||||
|
connStr := os.Getenv("TEST_DATABASE_URL") |
||||
|
if "" == connStr { |
||||
|
log.Fatal(`no connection string defined |
||||
|
|
||||
|
You must set TEST_DATABASE_URL to run db tests. |
||||
|
|
||||
|
You may find this helpful: |
||||
|
|
||||
|
psql 'postgres://postgres:postgres@localhost:5432/postgres'
|
||||
|
|
||||
|
DROP DATABASE IF EXISTS postgres_test; |
||||
|
CREATE DATABASE postgres_test; |
||||
|
\q |
||||
|
|
||||
|
Then your test database URL will be |
||||
|
|
||||
|
export TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres_test
|
||||
|
`) |
||||
|
} |
||||
|
return connStr |
||||
|
} |
@ -0,0 +1,37 @@ |
|||||
|
package api |
||||
|
|
||||
|
import ( |
||||
|
"encoding/base64" |
||||
|
"time" |
||||
|
|
||||
|
"git.example.com/example/goserv/internal/db" |
||||
|
) |
||||
|
|
||||
|
func newID() string { |
||||
|
// Postgres returns IDs on inserts but,
|
||||
|
// for portability and ease of association,
|
||||
|
// we'll create our own.
|
||||
|
b := make([]byte, 16) |
||||
|
_, _ = RandReader.Read(b) |
||||
|
id := base64.RawURLEncoding.EncodeToString(b) |
||||
|
return id |
||||
|
} |
||||
|
|
||||
|
// NotDeleted supplements a WHERE clause
|
||||
|
const NotDeleted = ` |
||||
|
( "deleted_at" IS NULL OR "deleted_at" = '0001-01-01 00:00:00+00' OR "deleted_at" = '1970-01-01 00:00:00+00' ) |
||||
|
` |
||||
|
|
||||
|
func logEvent(action, table, recordID, by string, at time.Time) (string, error) { |
||||
|
id := newID() |
||||
|
|
||||
|
if _, err := db.DB.Exec(` |
||||
|
INSERT INTO "events" ("id", "action", "table", "record", "by", "at") |
||||
|
VALUES ($1, $2, $3, $4, $5, $6)`, |
||||
|
id, action, table, recordID, by, at, |
||||
|
); nil != err { |
||||
|
return "", err |
||||
|
} |
||||
|
|
||||
|
return id, nil |
||||
|
} |
@ -0,0 +1,66 @@ |
|||||
|
package db |
||||
|
|
||||
|
import ( |
||||
|
"errors" |
||||
|
"fmt" |
||||
|
"os" |
||||
|
"strings" |
||||
|
"testing" |
||||
|
) |
||||
|
|
||||
|
func TestMain(m *testing.M) { |
||||
|
if err := testConnectAndInit(); nil != err { |
||||
|
fmt.Fprintf(os.Stderr, err.Error()) |
||||
|
os.Exit(1) |
||||
|
return |
||||
|
} |
||||
|
os.Exit(m.Run()) |
||||
|
} |
||||
|
|
||||
|
func needsTestDB() (string, error) { |
||||
|
connStr := os.Getenv("TEST_DATABASE_URL") |
||||
|
if "" == connStr { |
||||
|
return "", errors.New(`no connection string defined |
||||
|
|
||||
|
You must set TEST_DATABASE_URL to run db tests. |
||||
|
|
||||
|
You may find this helpful: |
||||
|
|
||||
|
psql 'postgres://postgres:postgres@localhost:5432/postgres'
|
||||
|
|
||||
|
DROP DATABASE IF EXISTS postgres_test; |
||||
|
CREATE DATABASE postgres_test; |
||||
|
\q |
||||
|
|
||||
|
Then your test database URL will be |
||||
|
|
||||
|
export TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres_test`)
|
||||
|
} |
||||
|
return connStr, nil |
||||
|
} |
||||
|
|
||||
|
func testConnectAndInit() error { |
||||
|
connStr, err := needsTestDB() |
||||
|
if nil != err { |
||||
|
return err |
||||
|
} |
||||
|
if strings.Contains(connStr, "@localhost/") || strings.Contains(connStr, "@localhost:") { |
||||
|
connStr += "?sslmode=disable" |
||||
|
} else { |
||||
|
connStr += "?sslmode=required" |
||||
|
} |
||||
|
|
||||
|
if err := Init(connStr); nil != err { |
||||
|
return fmt.Errorf("db connection error: %w", err) |
||||
|
} |
||||
|
|
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func TestDropAll(t *testing.T) { |
||||
|
connStr := os.Getenv("TEST_DATABASE_URL") |
||||
|
|
||||
|
if err := DropAllTables(PleaseDoubleCheckTheDatabaseURLDontDropProd(connStr)); nil != err { |
||||
|
t.Fatal(err) |
||||
|
} |
||||
|
} |
@ -1,82 +1,76 @@ |
|||||
<!DOCTYPE htmtl> |
<!DOCTYPE htmtl> |
||||
<html> |
<html> |
||||
<head> |
<head> |
||||
<meta charset="UTF-8" /> |
<meta charset="UTF-8" /> |
||||
<title>Example</title> |
<title>Example</title> |
||||
<link |
<link |
||||
rel="stylesheet" |
rel="stylesheet" |
||||
href="./vendor/css/bootswatch.com/4/materia/bootstrap.min.css" |
href="./vendor/css/bootswatch.com/4/materia/bootstrap.min.css" |
||||
/> |
/> |
||||
</head> |
</head> |
||||
<body> |
<body> |
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary"> |
<nav class="navbar navbar-expand-lg navbar-dark bg-primary"> |
||||
<a class="navbar-brand" href="#">Example</a> |
<a class="navbar-brand" href="#">Example</a> |
||||
<button |
<button |
||||
class="navbar-toggler" |
class="navbar-toggler" |
||||
type="button" |
type="button" |
||||
data-toggle="collapse" |
data-toggle="collapse" |
||||
data-target="#navbarColor01" |
data-target="#navbarColor01" |
||||
aria-controls="navbarColor01" |
aria-controls="navbarColor01" |
||||
aria-expanded="false" |
aria-expanded="false" |
||||
aria-label="Toggle navigation" |
aria-label="Toggle navigation" |
||||
> |
> |
||||
<span class="navbar-toggler-icon"></span> |
<span class="navbar-toggler-icon"></span> |
||||
</button> |
</button> |
||||
|
|
||||
<div class="collapse navbar-collapse" id="navbarColor01"> |
<div class="collapse navbar-collapse" id="navbarColor01"> |
||||
<ul class="navbar-nav mr-auto"> |
<ul class="navbar-nav mr-auto"> |
||||
<li class="nav-item active"> |
<li class="nav-item active"> |
||||
<a class="nav-link" href="#nav-foobar">Foobar</a> |
<a class="nav-link" href="#nav-foobar">Foobar</a> |
||||
</li> |
</li> |
||||
</ul> |
</ul> |
||||
<form class="js-signin form-inline my-2 my-lg-0"> |
<form class="js-signin form-inline my-2 my-lg-0"> |
||||
<input |
<input |
||||
class="form-control mr-sm-2" |
class="form-control mr-sm-2" |
||||
type="email" |
type="email" |
||||
name="email" |
name="email" |
||||
placeholder="email" |
placeholder="email" |
||||
/> |
/> |
||||
<button |
<button class="btn btn-secondary my-2 my-sm-0" type="submit"> |
||||
class="btn btn-secondary my-2 my-sm-0" |
Sign in |
||||
type="submit" |
</button> |
||||
> |
</form> |
||||
Sign in |
</div> |
||||
</button> |
</nav> |
||||
|
<div class="container"> |
||||
|
<div class="row"> |
||||
|
<div class="pocket"></div> |
||||
|
</div> |
||||
|
<div class="row"> |
||||
|
<div class="col-xs-12"> |
||||
|
<div class="card border-primary mb-6"> |
||||
|
<a id="nav-foobar" |
||||
|
><h3 class="card-header"> |
||||
|
Server Health |
||||
|
<form class="js-healthcheck"> |
||||
|
<button type="submit" class="float-right btn btn-primary"> |
||||
|
Check |
||||
|
</button> |
||||
</form> |
</form> |
||||
|
</h3></a |
||||
|
> |
||||
|
<div class="js-pre card-body"> |
||||
|
<h5 class="card-title">Check Server Status</h5> |
||||
|
<div class="card-text"> |
||||
|
<pre><code>curl https://example.com/api/public/ping</code></pre> |
||||
|
<pre><code class="js-server-health">-</code></pre> |
||||
|
</div> |
||||
</div> |
</div> |
||||
</nav> |
</div> |
||||
<div class="container"> |
|
||||
<div class="row"> |
|
||||
<div class="pocket"></div> |
|
||||
</div> |
|
||||
<div class="row"> |
|
||||
<div class="col-xs-12"> |
|
||||
<div class="card border-primary mb-6"> |
|
||||
<a id="nav-foobar" |
|
||||
><h3 class="card-header"> |
|
||||
Server Health |
|
||||
<form class="js-healthcheck"> |
|
||||
<button |
|
||||
type="submit" |
|
||||
class="float-right btn btn-primary" |
|
||||
> |
|
||||
Check |
|
||||
</button> |
|
||||
</form> |
|
||||
</h3></a |
|
||||
> |
|
||||
<div class="card-body"> |
|
||||
<h5 class="card-title">Check Server Status</h5> |
|
||||
<div class="card-text"> |
|
||||
<pre><code>curl https://example.com/api/public/status</code></pre> |
|
||||
<pre><code class="js-server-health">-</code></pre> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
</div> |
||||
<script src="https://mock.pocketid.app/pocket/consumer.js"></script> |
</div> |
||||
<script src="./js/app.js"></script> |
</div> |
||||
</body> |
<script src="https://mock.pocketid.app/pocket/consumer.js"></script> |
||||
|
<script src="./js/app.js"></script> |
||||
|
</body> |
||||
</html> |
</html> |
||||
|
@ -1,49 +1,111 @@ |
|||||
(function () { |
(function () { |
||||
"use strict"; |
"use strict"; |
||||
|
|
||||
// AJQuery
|
|
||||
function $(sel, el) { |
|
||||
if (!el) { |
|
||||
el = document; |
|
||||
} |
|
||||
return el.querySelector(sel); |
|
||||
} |
|
||||
function $$(sel, el) { |
|
||||
if (!el) { |
|
||||
el = document; |
|
||||
} |
|
||||
return el.querySelectorAll(sel); |
|
||||
} |
|
||||
|
|
||||
function displayToken(token) { |
// AJQuery
|
||||
$$(".js-token").forEach(function (el) { |
function $(sel, el) { |
||||
el.innerText = token; |
if (!el) { |
||||
}); |
el = document; |
||||
} |
} |
||||
|
return el.querySelector(sel); |
||||
|
} |
||||
|
function $$(sel, el) { |
||||
|
if (!el) { |
||||
|
el = document; |
||||
|
} |
||||
|
return el.querySelectorAll(sel); |
||||
|
} |
||||
|
|
||||
Pocket.onToken(function (token) { |
function displayToken(token) { |
||||
// TODO Pocket v1.0 will make this obsolete
|
$$(".js-token").forEach(function (el) { |
||||
localStorage.setItem("pocket-token", token); |
el.innerText = token; |
||||
displayToken(); |
|
||||
}); |
}); |
||||
displayToken(localStorage.getItem("pocket-token")); |
} |
||||
|
|
||||
|
Pocket.onToken(function (token) { |
||||
|
// TODO Pocket v1.0 will make this obsolete
|
||||
|
localStorage.setItem("pocket-token", token); |
||||
|
displayToken(); |
||||
|
}); |
||||
|
displayToken(localStorage.getItem("pocket-token")); |
||||
|
|
||||
// requires div with class 'pocket'
|
// requires div with class 'pocket'
|
||||
$("form.js-signin").addEventListener("submit", function (ev) { |
$("form.js-signin").addEventListener("submit", function (ev) { |
||||
ev.preventDefault(); |
ev.preventDefault(); |
||||
ev.stopPropagation(); |
ev.stopPropagation(); |
||||
|
|
||||
var email = $("[name=email]").value; |
var email = $("[name=email]").value; |
||||
Pocket.openSignin(ev, { email: email }); |
Pocket.openSignin(ev, { email: email }); |
||||
|
}); |
||||
|
|
||||
|
$("form.js-healthcheck").addEventListener("submit", function (ev) { |
||||
|
ev.preventDefault(); |
||||
|
ev.stopPropagation(); |
||||
|
|
||||
|
window.fetch("/api/public/ping").then(async function (resp) { |
||||
|
var res = await resp.json(); |
||||
|
$(".js-server-health").innerText = JSON.stringify(res, null, 2); |
||||
}); |
}); |
||||
|
}); |
||||
|
` |
||||
|
# Demo Mode Only |
||||
|
DELETE /public/reset Drop database and re-initialize |
||||
|
|
||||
|
# Public |
||||
|
GET /public/ping Health Check |
||||
|
POST /public/setup <= (none) Bootstrap |
||||
|
|
||||
$("form.js-healthcheck").addEventListener("submit", function (ev) { |
# Admin-only |
||||
ev.preventDefault(); |
GET /admin/ping (authenticated) Health Check |
||||
ev.stopPropagation(); |
GET /admin/users []Users => List ALL Users |
||||
|
|
||||
window.fetch("/api/public/status").then(async function (resp) { |
# User |
||||
var res = await resp.json(); |
GET /user/ping (authenticated) Health Check |
||||
$(".js-server-health").innerText = JSON.stringify(res, null, 2); |
GET /user => User User profile |
||||
}); |
` |
||||
|
.trim() |
||||
|
.split(/\n/) |
||||
|
.forEach(function (line) { |
||||
|
line = line.trim(); |
||||
|
if ("#" === line[0] || !line.trim()) { |
||||
|
return; |
||||
|
} |
||||
|
line = line.replace(/(<=)?\s*\(none\)\s*(=>)?/g, ""); |
||||
|
var parts = line.split(/\s+/g); |
||||
|
var method = parts[0]; |
||||
|
if ("GET" != method) { |
||||
|
method = "-X " + method + " "; |
||||
|
} else { |
||||
|
method = ""; |
||||
|
} |
||||
|
var pathname = parts[1]; |
||||
|
var auth = pathname.match(/(public|user|admin)/)[1]; |
||||
|
if ("admin" == auth) { |
||||
|
auth = " \\\n -H 'Authorization: Bearer ADMIN_TOKEN'"; |
||||
|
} else if ("user" == auth) { |
||||
|
auth = " \\\n -H 'Authorization: Bearer USER_TOKEN'"; |
||||
|
} else { |
||||
|
auth = ""; |
||||
|
} |
||||
|
document.body.querySelector(".js-pre").innerHTML += ( |
||||
|
` |
||||
|
<div class="card-text"> |
||||
|
<pre><code>curl -X POST https://example.com/api</code></pre>
|
||||
|
<pre><code class="js-` +
|
||||
|
pathname.replace(/\//g, "-") + |
||||
|
`">-</code></pre>
|
||||
|
</div> |
||||
|
` |
||||
|
) |
||||
|
.replace( |
||||
|
/https:\/\/example\.com/g, |
||||
|
location.protocol + "//" + location.host |
||||
|
) |
||||
|
.replace(/-X POST /g, method) |
||||
|
.replace(/\/api/g, "/api" + pathname + auth); |
||||
}); |
}); |
||||
|
/* |
||||
|
document.body.querySelector(".js-pre").innerHTML = document.body |
||||
|
.querySelector(".js-pre") |
||||
|
.innerHTML |
||||
|
*/ |
||||
})(); |
})(); |
||||
|
Loading…
Reference in new issue