Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .gcloudignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# This file specifies files that are *not* uploaded to Google Cloud Platform
# using gcloud. It follows the same syntax as .gitignore, with the addition of
# "#!include" directives (which insert the entries of the given .gitignore-style
# file at that point).
#
# For more information, run:
# $ gcloud topic gcloudignore
#
.gcloudignore
# If you would like to upload your .git directory, .gitignore file or files
# from your .gitignore file, remove the corresponding line
# below:
.git
.gitignore

# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, build with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ appropriately to the (relatively few) needed JSON API calls.
The TiddlyWeb JSON API envisions a multiuser system in which different users have
access to different sets of tiddlers. This Go server contains none of that:
it assumes that all users have full access to everything, although it does record
who created which tiddlers. The only access control is that the app.yaml here
requires HTTPS and administrator login for all URLs, and as a “belt and suspenders” measure,
the app itself also refuses to serve to non-admins, as checked by user.IsAdmin.
who created which tiddlers.

See the "Re Authentication" comment in tiddly.go for information about
making the server publicly read-only (it's not quite perfect).
Authentication is controlled by [Google IAP][iap] as a “belt and suspenders”
measure. When deploying the application you will need to enable and [configure
IAP][configure-iap] with the addresses you want to have access.

[iap]: https://cloud.google.com/go/getting-started/authenticate-users-with-iap
[configure-iap]: https://cloud.google.com/go/getting-started/authenticate-users-with-iap

## Data model

Expand All @@ -40,7 +42,7 @@ tiddler content on demand.

Create an Google App Engine standard app and deploy with

appcfg.py -A your-app -V your-version update .
gcloud --project=your-app app deploy

Then visit https://your-app.appspot.com/. As noted above, only admins
will have access to the content.
Expand Down Expand Up @@ -97,4 +99,3 @@ The process for preparing a new index.html is:
- Open the downloaded file in the web browser.
- Repeat, adding any more plugins.
- Copy the final download to index.html.

7 changes: 2 additions & 5 deletions app.yaml
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
application: tiddlywiki-gae
version: 2016-12-22
runtime: go
api_version: go1
runtime: go113

handlers:
- url: /.*
login: admin
secure: always
script: _go_app
script: auto
9 changes: 9 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
module github.com/philips/tiddly

go 1.14

require (
cloud.google.com/go v0.57.0
github.com/dgrijalva/jwt-go v3.2.0+incompatible
google.golang.org/appengine v1.6.6
)
288 changes: 288 additions & 0 deletions go.sum

Large diffs are not rendered by default.

138 changes: 138 additions & 0 deletions googleiap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Copyright 2019 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package main

import (
"encoding/json"
"fmt"
"log"
"net/http"
"time"

"cloud.google.com/go/compute/metadata"
"github.com/dgrijalva/jwt-go"
)

// the functions in this file are copied, with light modification to fit this
// application, from Google's IAP example application:
// https://raw.githubusercontent.com/GoogleCloudPlatform/golang-samples/95634648f8e9140844db8af8244130491457e251/getting-started/authenticating-users/main.go

// iap holds the Cloud IAP certificates and audience field for this app, which
// are needed to verify authentication headers set by Cloud IAP.
type iap struct {
certs map[string]string
aud string
}

// newIAP creates a new iap, returning an error if either the Cloud IAP
// certificates or audience field cannot be obtained.
func newIAP() (*iap, error) {
certs, err := certificates()
if err != nil {
return nil, err
}

aud, err := audience()
if err != nil {
return nil, err
}

a := &iap{
certs: certs,
aud: aud,
}
return a, nil
}

func (i *iap) Email(r *http.Request) (string, bool) {
assertion := r.Header.Get("X-Goog-IAP-JWT-Assertion")
if assertion == "" {
log.Fatal("No Cloud IAP header found.")
return "", false
}

email, _, err := validateAssertion(assertion, i.certs, i.aud)
if err != nil {
log.Println("Could not validate assertion. Check app logs.")
return "", false
}

return email, true
}

// audience returns the expected audience value for this service.
func audience() (string, error) {
projectNumber, err := metadata.NumericProjectID()
if err != nil {
return "", fmt.Errorf("metadata.NumericProjectID: %v", err)
}

projectID, err := metadata.ProjectID()
if err != nil {
return "", fmt.Errorf("metadata.ProjectID: %v", err)
}

return "/projects/" + projectNumber + "/apps/" + projectID, nil
}

// certificates returns Cloud IAP's cryptographic public keys.
func certificates() (map[string]string, error) {
const url = "https://www.gstatic.com/iap/verify/public_key"
client := http.Client{
Timeout: 5 * time.Second,
}
resp, err := client.Get(url)
if err != nil {
return nil, fmt.Errorf("Get: %v", err)
}

var certs map[string]string
dec := json.NewDecoder(resp.Body)
if err := dec.Decode(&certs); err != nil {
return nil, fmt.Errorf("Decode: %v", err)
}

return certs, nil
}

// validateAssertion validates assertion was signed by Google and returns the
// associated email and userID.
func validateAssertion(assertion string, certs map[string]string, aud string) (email string, userID string, err error) {
token, err := jwt.Parse(assertion, func(token *jwt.Token) (interface{}, error) {
keyID := token.Header["kid"].(string)

_, ok := token.Method.(*jwt.SigningMethodECDSA)
if !ok {
return nil, fmt.Errorf("unexpected signing method: %q", token.Header["alg"])
}

cert := certs[keyID]
return jwt.ParseECPublicKeyFromPEM([]byte(cert))
})

if err != nil {
return "", "", err
}

claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return "", "", fmt.Errorf("could not extract claims (%T): %+v", token.Claims, token.Claims)
}

if claims["aud"].(string) != aud {
return "", "", fmt.Errorf("mismatched audience. aud field %q does not match %q", claims["aud"], aud)
}
return claims["email"].(string), claims["sub"].(string), nil
}
98 changes: 39 additions & 59 deletions tiddly.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,70 +2,26 @@
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package tiddly
package main

import (
"bytes"
"crypto/md5"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
"net/url"
"os"
"strings"

"google.golang.org/appengine"
"google.golang.org/appengine/datastore"
"google.golang.org/appengine/user"
)

// Re Authentication
//
// There are currently three redundant layers of authentication checks here.
//
// 1. app.yaml says 'login: admin'.
// 2. The installed handlers are wrapped in authCheck during registration in func init.
// 3. The write operations contain an extra mustBeAdmin check.
//
// The redundancy is mainly cautionary, to contain accidents.
//
// It should be possible to make a world-readable, admin-writable TiddlyWiki
// by removing 1 and 2 and double-checking 3.
//
// If you remove 'login: admin' from app.yaml you can replace it with 'login: required',
// requiring a login from any viewer, or you can delete the line entirely,
// making it possible to fetch pages with no authentication.
// In that case, users who do have write access (admins) will need to take the extra
// step of logging in. One way to do this is to make the /auth URL require login
// and have them start there when visiting, by listing that separately in app.yaml
// before the default handler:
//
// handlers:
// - url: /auth
// login: admin
// secure: always
// script: _go_app
//
// - url: /.*
// secure: always
// script: _go_app
//
// If you do this, then unauthenticated users will be able to read content,
// and TiddlyWiki will let them edit content in their browser, but writes back
// to the server will fail, producing yellow pop-up error messages in the
// browser window. In general these are probably good, but this includes
// attempts to update $:/StoryList, which happens as viewers click around
// in the wiki. It seems like the TiddlyWeb plugin or the core syncer module
// would need changes to understand a new "read-only" mode.

func init() {
http.HandleFunc("/", authCheck(main))
http.HandleFunc("/auth", authCheck(auth))
http.HandleFunc("/status", authCheck(status))
http.HandleFunc("/recipes/all/tiddlers/", authCheck(tiddler))
http.HandleFunc("/recipes/all/tiddlers.json", authCheck(tiddlerList))
http.HandleFunc("/bags/bag/tiddlers/", authCheck(deleteTiddler))
}
var tiddlyIAP *iap

func authCheck(f http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
Expand All @@ -77,22 +33,46 @@ func authCheck(f http.HandlerFunc) http.HandlerFunc {
}

func mustBeAdmin(w http.ResponseWriter, r *http.Request) bool {
ctx := appengine.NewContext(r)
u := user.Current(ctx)
if u == nil || !user.IsAdmin(ctx) {
http.Error(w, "permission denied", 403)
return false
}
return true
_, ok := tiddlyIAP.Email(r)
return ok
}

type Tiddler struct {
Rev int `datastore:"Rev,noindex"`
Meta string `datastore:"Meta,noindex"`
Text string `datastore:"Text,noindex"`
Rev int `datastore:"Rev,noindex"`
Meta string `datastore:"Meta,noindex"`
Text string `datastore:"Text,noindex"`
Tags []string `datastore:"Tags,noindex"`
}

func main() {
var err error
tiddlyIAP, err = newIAP()
if err != nil {
log.Fatal(err)
}

http.HandleFunc("/", authCheck(index))
http.HandleFunc("/auth", authCheck(auth))
http.HandleFunc("/status", authCheck(status))
http.HandleFunc("/recipes/all/tiddlers/", authCheck(tiddler))
http.HandleFunc("/recipes/all/tiddlers.json", authCheck(tiddlerList))
http.HandleFunc("/bags/bag/tiddlers/", authCheck(deleteTiddler))

port := os.Getenv("PORT")
if port == "" {
port = "8080"
log.Printf("Defaulting to port %s", port)
}

appengine.Main()

log.Printf("Listening on port %s", port)
if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatal(err)
}
}

func main(w http.ResponseWriter, r *http.Request) {
func index(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
http.Error(w, "bad method", 405)
return
Expand Down