diff --git a/docs/dev.md b/docs/dev.md index 091b588..723da0f 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -8,6 +8,12 @@ Toolchain prerequisites: see [setup.md](setup.md). The server listens on http://localhost:8080 (override with `PORT`). Templates, static assets, and sample data are read from disk on every request — edit a file and refresh the browser; no restart needed. +## Auth + +Everything — pages, static assets, and the API — sits behind Google sign-in restricted to the school's Google Workspace domain. Unauthenticated requests get the login page (API paths get a 401). The OAuth 2.0 Web application client (authorized JavaScript origins must include `http://localhost:8080` for local development) is read from `creds/oauth-client.json` — the JSON downloaded from the Cloud console — or from `GOOGLE_CLIENT_ID` when set; the server refuses to start with neither. After Google sign-in the server issues its own HMAC-signed session cookie; set `SESSION_KEY` to keep sessions valid across restarts and instances (without it each start generates a random key). + +To capture authenticated pages with the screenshot tooling, launch the capture browser (`go run ./tools/capturebrowser`), sign in to the local server there once, and use `tools/browse` or `tools/screenshot -remote` — the session cookie lives in the capture profile. Plain `tools/screenshot` runs a fresh headless browser with no session and captures the login page. + ## Local data The server reads local data from `sampledata/`, mirroring the production Sheets layout: one directory per app, one CSV file per table, first row is the schema. It goes through the same data-source interface production backends implement, so app code never knows which backend it is talking to. @@ -16,7 +22,7 @@ The server reads local data from `sampledata/`, mirroring the production Sheets DIRECTORY_SHEET= go run . -switches the directory app to the Google Sheets source. At startup the directory tables are read from the spreadsheet and normalized into the in-memory data model (see `docs/data.md`); the server refuses to start if that load fails, and the model reloads every five minutes. Requires a service account key in `creds/` (any `*.json`; the directory is gitignored) with the Sheets API enabled and the spreadsheet shared read-only with the service account. Real data never leaves the process: nothing is written to disk. +switches the directory app to the Google Sheets source. At startup the directory tables are read from the spreadsheet and normalized into the in-memory data model (see `docs/data.md`); the server refuses to start if that load fails, and the model reloads every five minutes. Requires the service account key at `creds/service-account.json` (the directory is gitignored) with the Sheets API enabled and the spreadsheet shared read-only with the service account. Real data never leaves the process: nothing is written to disk. ## Layout diff --git a/go.sum b/go.sum index a79ec99..e90f985 100644 --- a/go.sum +++ b/go.sum @@ -51,6 +51,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= @@ -76,6 +78,8 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.293.0 h1:p9XIWOf63U4OgYx120ZwVU8+vl4XTPmWfgVPnmOAS9w= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..4df255a --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,163 @@ +// Package auth gates the server behind google sign-in restricted to the school domain. +package auth + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "fmt" + "html/template" + "log" + "net/http" + "strconv" + "strings" + "time" + + "google.golang.org/api/idtoken" +) + +const ( + Domain = "heliosschool.org" + cookieName = "session" + sessionLength = 30 * 24 * time.Hour +) + +type contextKey struct{} + +type Auth struct { + clientID string + key []byte +} + +func New(clientID string, key []byte) *Auth { + return &Auth{clientID: clientID, key: key} +} + +func Email(r *http.Request) string { + email, _ := r.Context().Value(contextKey{}).(string) + return email +} + +func (a *Auth) Register(mux *http.ServeMux) { + mux.HandleFunc("POST /auth/login", a.login) + mux.HandleFunc("POST /auth/logout", a.logout) +} + +func (a *Auth) Wrap(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/auth/login" { + next.ServeHTTP(w, r) + return + } + email := a.sessionEmail(r) + if email == "" { + if strings.Contains(r.URL.Path, "/api/") { + http.Error(w, "unauthenticated", http.StatusUnauthorized) + return + } + a.loginPage(w, r) + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), contextKey{}, email))) + }) +} + +func (a *Auth) loginPage(w http.ResponseWriter, r *http.Request) { + t, err := template.ParseFiles("web/login.html") + if err != nil { + log.Printf("[ERROR] parse login page: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + scheme := "http" + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + scheme = "https" + } + err = t.Execute(w, map[string]string{ + "ClientID": a.clientID, + "LoginURI": scheme + "://" + r.Host + "/auth/login", + }) + if err != nil { + log.Printf("[ERROR] render login page: %v", err) + } +} + +func (a *Auth) login(w http.ResponseWriter, r *http.Request) { + csrf, err := r.Cookie("g_csrf_token") + if err != nil || csrf.Value == "" || csrf.Value != r.FormValue("g_csrf_token") { + http.Error(w, "csrf check failed", http.StatusBadRequest) + return + } + payload, err := idtoken.Validate(r.Context(), r.FormValue("credential"), a.clientID) + if err != nil { + log.Printf("[ERROR] validate id token: %v", err) + http.Error(w, "invalid credential", http.StatusUnauthorized) + return + } + email, _ := payload.Claims["email"].(string) + verified, _ := payload.Claims["email_verified"].(bool) + hd, _ := payload.Claims["hd"].(string) + if !verified || hd != Domain || !strings.HasSuffix(email, "@"+Domain) { + http.Error(w, "account is not in the school domain", http.StatusForbidden) + return + } + expiry := time.Now().Add(sessionLength).Unix() + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: a.token(email, expiry), + Path: "/", + HttpOnly: true, + Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https", + SameSite: http.SameSiteLaxMode, + MaxAge: int(sessionLength.Seconds()), + }) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (a *Auth) logout(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", HttpOnly: true, MaxAge: -1}) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (a *Auth) token(email string, expiry int64) string { + payload := fmt.Sprintf("%s|%d", email, expiry) + return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + a.sign(payload) +} + +func (a *Auth) sign(payload string) string { + mac := hmac.New(sha256.New, a.key) + mac.Write([]byte(payload)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +func (a *Auth) sessionEmail(r *http.Request) string { + cookie, err := r.Cookie(cookieName) + if err != nil { + return "" + } + parts := strings.SplitN(cookie.Value, ".", 2) + if len(parts) != 2 { + return "" + } + decoded, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return "" + } + payload := string(decoded) + if !hmac.Equal([]byte(a.sign(payload)), []byte(parts[1])) { + return "" + } + fields := strings.Split(payload, "|") + if len(fields) != 2 { + return "" + } + expiry, err := strconv.ParseInt(fields[1], 10, 64) + if err != nil || time.Now().Unix() > expiry { + return "" + } + if !strings.HasSuffix(fields[0], "@"+Domain) { + return "" + } + return fields[0] +} diff --git a/internal/data/sheet.go b/internal/data/sheet.go index 1347569..95cf997 100644 --- a/internal/data/sheet.go +++ b/internal/data/sheet.go @@ -3,32 +3,22 @@ package data import ( "context" "fmt" - "path/filepath" "strings" "google.golang.org/api/option" "google.golang.org/api/sheets/v4" ) +const KeyFile = "creds/service-account.json" + type Sheet struct { service *sheets.Service spreadsheets map[string]string } -func KeyFile() (string, error) { - matches, err := filepath.Glob("creds/*.json") - if err != nil { - return "", err - } - if len(matches) == 0 { - return "", fmt.Errorf("no service account key found in creds/") - } - return matches[0], nil -} - -func NewSheet(keyFile string, spreadsheets map[string]string) (*Sheet, error) { +func NewSheet(spreadsheets map[string]string) (*Sheet, error) { service, err := sheets.NewService(context.Background(), - option.WithCredentialsFile(keyFile), + option.WithCredentialsFile(KeyFile), option.WithScopes(sheets.SpreadsheetsReadonlyScope)) if err != nil { return nil, err diff --git a/main.go b/main.go index c2ce51c..ebd0ac6 100644 --- a/main.go +++ b/main.go @@ -2,10 +2,13 @@ package main import ( + "crypto/rand" + "encoding/json" "log" "net/http" "os" + "heliosian/internal/auth" "heliosian/internal/data" "heliosian/internal/directory" ) @@ -15,23 +18,52 @@ func directorySource() data.Source { if sheetID == "" { return data.Dir{Root: "sampledata"} } - keyFile, err := data.KeyFile() - if err != nil { - log.Fatalf("[ERROR] %v", err) - } - source, err := data.NewSheet(keyFile, map[string]string{"directory": sheetID}) + source, err := data.NewSheet(map[string]string{"directory": sheetID}) if err != nil { log.Fatalf("[ERROR] load directory sheet: %v", err) } return source } +func sessionKey() []byte { + if key := os.Getenv("SESSION_KEY"); key != "" { + return []byte(key) + } + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + log.Fatalf("[ERROR] generate session key: %v", err) + } + log.Printf("SESSION_KEY not set, using a random key; sessions reset on restart") + return key +} + +func clientID() string { + if id := os.Getenv("GOOGLE_CLIENT_ID"); id != "" { + return id + } + raw, err := os.ReadFile("creds/oauth-client.json") + if err != nil { + log.Fatalf("[ERROR] read creds/oauth-client.json (or set GOOGLE_CLIENT_ID): %v", err) + } + var parsed struct { + Web struct { + ClientID string `json:"client_id"` + } `json:"web"` + } + if err := json.Unmarshal(raw, &parsed); err != nil || parsed.Web.ClientID == "" { + log.Fatal("[ERROR] creds/oauth-client.json is not an oauth web client file") + } + return parsed.Web.ClientID +} + func main() { + authn := auth.New(clientID(), sessionKey()) cache, err := directory.NewCache(directorySource()) if err != nil { log.Fatalf("[ERROR] load directory data: %v", err) } mux := http.NewServeMux() + authn.Register(mux) directory.Register(mux, cache) mux.Handle("GET /{$}", http.RedirectHandler("/directory/", http.StatusFound)) port := os.Getenv("PORT") @@ -39,5 +71,5 @@ func main() { port = "8080" } log.Printf("listening on http://localhost:%s", port) - log.Fatal(http.ListenAndServe(":"+port, mux)) + log.Fatal(http.ListenAndServe(":"+port, authn.Wrap(mux))) } diff --git a/tools/sheets/main.go b/tools/sheets/main.go index 0725b3c..ea2400b 100644 --- a/tools/sheets/main.go +++ b/tools/sheets/main.go @@ -6,20 +6,13 @@ import ( "flag" "fmt" "log" - "path/filepath" "strings" + "heliosian/internal/data" "google.golang.org/api/option" "google.golang.org/api/sheets/v4" ) -func keyFile() string { - matches, err := filepath.Glob("creds/*.json") - if err != nil || len(matches) == 0 { - log.Fatal("[ERROR] no service account key found in creds/") - } - return matches[0] -} func main() { sheet := flag.String("sheet", "", "spreadsheet id") @@ -32,7 +25,7 @@ func main() { log.Fatal("[ERROR] -sheet is required") } svc, err := sheets.NewService(context.Background(), - option.WithCredentialsFile(keyFile()), + option.WithCredentialsFile(data.KeyFile), option.WithScopes(sheets.SpreadsheetsReadonlyScope)) if err != nil { log.Fatalf("[ERROR] create sheets client: %v", err) diff --git a/web/login.html b/web/login.html new file mode 100644 index 0000000..22a3916 --- /dev/null +++ b/web/login.html @@ -0,0 +1,50 @@ + + + + + + +Helios + + + +
+

Helios

+

Sign in with your school Google account.

+
+
+ +
+
+ + +