Gate everything behind domain-locked google sign-in
This commit is contained in:
+7
-1
@@ -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=<spreadsheet id> 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
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
+4
-14
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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 <spreadsheet id> 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)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#014E54">
|
||||
<title>Helios</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #014E54;
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
}
|
||||
.panel {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border-radius: 1rem;
|
||||
padding: 3rem 3.5rem;
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
.panel h1 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 2rem;
|
||||
}
|
||||
.panel p {
|
||||
color: #cfe0e2;
|
||||
margin: 0 0 2rem;
|
||||
}
|
||||
.button-holder {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="panel">
|
||||
<h1>Helios</h1>
|
||||
<p>Sign in with your school Google account.</p>
|
||||
<div id="g_id_onload" data-client_id="{{.ClientID}}" data-login_uri="{{.LoginURI}}" data-auto_prompt="false"></div>
|
||||
<div class="button-holder">
|
||||
<div class="g_id_signin" data-type="standard" data-theme="filled_black" data-size="large" data-text="continue_with"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://accounts.google.com/gsi/client" async></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user