AJ ONeal
пре 4 година
28 измењених фајлова са 1418 додато и 218 уклоњено
@ -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
|
|||
|
|||
//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 |
|||
|
@ -1,5 +1,5 @@ |
|||
// +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 |
|||
|
@ -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" |
|||
#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 |
|||
|
|||
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/jmoiron/sqlx v1.2.0 |
|||
github.com/joho/godotenv v1.3.0 |
|||
github.com/lib/pq v1.8.0 |
|||
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 // indirect |
|||
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 |
|||
) |
|||
|
@ -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> |
|||
<html> |
|||
<head> |
|||
<meta charset="UTF-8" /> |
|||
<title>Example</title> |
|||
<link |
|||
rel="stylesheet" |
|||
href="./vendor/css/bootswatch.com/4/materia/bootstrap.min.css" |
|||
/> |
|||
</head> |
|||
<body> |
|||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary"> |
|||
<a class="navbar-brand" href="#">Example</a> |
|||
<button |
|||
class="navbar-toggler" |
|||
type="button" |
|||
data-toggle="collapse" |
|||
data-target="#navbarColor01" |
|||
aria-controls="navbarColor01" |
|||
aria-expanded="false" |
|||
aria-label="Toggle navigation" |
|||
> |
|||
<span class="navbar-toggler-icon"></span> |
|||
</button> |
|||
<head> |
|||
<meta charset="UTF-8" /> |
|||
<title>Example</title> |
|||
<link |
|||
rel="stylesheet" |
|||
href="./vendor/css/bootswatch.com/4/materia/bootstrap.min.css" |
|||
/> |
|||
</head> |
|||
<body> |
|||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary"> |
|||
<a class="navbar-brand" href="#">Example</a> |
|||
<button |
|||
class="navbar-toggler" |
|||
type="button" |
|||
data-toggle="collapse" |
|||
data-target="#navbarColor01" |
|||
aria-controls="navbarColor01" |
|||
aria-expanded="false" |
|||
aria-label="Toggle navigation" |
|||
> |
|||
<span class="navbar-toggler-icon"></span> |
|||
</button> |
|||
|
|||
<div class="collapse navbar-collapse" id="navbarColor01"> |
|||
<ul class="navbar-nav mr-auto"> |
|||
<li class="nav-item active"> |
|||
<a class="nav-link" href="#nav-foobar">Foobar</a> |
|||
</li> |
|||
</ul> |
|||
<form class="js-signin form-inline my-2 my-lg-0"> |
|||
<input |
|||
class="form-control mr-sm-2" |
|||
type="email" |
|||
name="email" |
|||
placeholder="email" |
|||
/> |
|||
<button |
|||
class="btn btn-secondary my-2 my-sm-0" |
|||
type="submit" |
|||
> |
|||
Sign in |
|||
</button> |
|||
<div class="collapse navbar-collapse" id="navbarColor01"> |
|||
<ul class="navbar-nav mr-auto"> |
|||
<li class="nav-item active"> |
|||
<a class="nav-link" href="#nav-foobar">Foobar</a> |
|||
</li> |
|||
</ul> |
|||
<form class="js-signin form-inline my-2 my-lg-0"> |
|||
<input |
|||
class="form-control mr-sm-2" |
|||
type="email" |
|||
name="email" |
|||
placeholder="email" |
|||
/> |
|||
<button class="btn btn-secondary my-2 my-sm-0" type="submit"> |
|||
Sign in |
|||
</button> |
|||
</form> |
|||
</div> |
|||
</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> |
|||
</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> |
|||
</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> |
|||
</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> |
|||
<script src="./js/app.js"></script> |
|||
</body> |
|||
</div> |
|||
</div> |
|||
<script src="https://mock.pocketid.app/pocket/consumer.js"></script> |
|||
<script src="./js/app.js"></script> |
|||
</body> |
|||
</html> |
|||
|
@ -1,49 +1,111 @@ |
|||
(function () { |
|||
"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); |
|||
} |
|||
"use strict"; |
|||
|
|||
function displayToken(token) { |
|||
$$(".js-token").forEach(function (el) { |
|||
el.innerText = token; |
|||
}); |
|||
// AJQuery
|
|||
function $(sel, el) { |
|||
if (!el) { |
|||
el = document; |
|||
} |
|||
return el.querySelector(sel); |
|||
} |
|||
function $$(sel, el) { |
|||
if (!el) { |
|||
el = document; |
|||
} |
|||
return el.querySelectorAll(sel); |
|||
} |
|||
|
|||
Pocket.onToken(function (token) { |
|||
// TODO Pocket v1.0 will make this obsolete
|
|||
localStorage.setItem("pocket-token", token); |
|||
displayToken(); |
|||
function displayToken(token) { |
|||
$$(".js-token").forEach(function (el) { |
|||
el.innerText = token; |
|||
}); |
|||
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'
|
|||
$("form.js-signin").addEventListener("submit", function (ev) { |
|||
ev.preventDefault(); |
|||
ev.stopPropagation(); |
|||
// requires div with class 'pocket'
|
|||
$("form.js-signin").addEventListener("submit", function (ev) { |
|||
ev.preventDefault(); |
|||
ev.stopPropagation(); |
|||
|
|||
var email = $("[name=email]").value; |
|||
Pocket.openSignin(ev, { email: email }); |
|||
var email = $("[name=email]").value; |
|||
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) { |
|||
ev.preventDefault(); |
|||
ev.stopPropagation(); |
|||
# Admin-only |
|||
GET /admin/ping (authenticated) Health Check |
|||
GET /admin/users []Users => List ALL Users |
|||
|
|||
window.fetch("/api/public/status").then(async function (resp) { |
|||
var res = await resp.json(); |
|||
$(".js-server-health").innerText = JSON.stringify(res, null, 2); |
|||
}); |
|||
# User |
|||
GET /user/ping (authenticated) Health Check |
|||
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