From 3aa1d92c9894d14f1d92d942efd0072284cb3799 Mon Sep 17 00:00:00 2001 From: user Date: Mon, 27 Apr 2026 13:23:47 +0900 Subject: [PATCH] feat: add Google Sign-In with JWT auth and Neon DB persistence --- go.mod | 34 +++++++- go.sum | 75 +++++++++++++++- internal/app/server.go | 15 +++- internal/auth/handler.go | 35 ++++++++ internal/auth/service.go | 128 ++++++++++++++++++++++++++++ internal/auth/types.go | 24 ++++++ internal/config/config.go | 4 + internal/db/migrate.go | 29 +++++-- internal/db/migrations/002_auth.sql | 10 +++ internal/webapp/static/app.js | 50 ++++++++++- internal/webapp/static/index.html | 8 ++ 11 files changed, 393 insertions(+), 19 deletions(-) create mode 100644 internal/auth/handler.go create mode 100644 internal/auth/service.go create mode 100644 internal/auth/types.go create mode 100644 internal/db/migrations/002_auth.sql diff --git a/go.mod b/go.mod index 6c1e51b..a16b040 100644 --- a/go.mod +++ b/go.mod @@ -2,13 +2,39 @@ module tutor go 1.25.0 -require github.com/jackc/pgx/v5 v5.9.2 +require ( + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.9.2 + github.com/joho/godotenv v1.5.1 + google.golang.org/api v0.276.0 +) require ( + cloud.google.com/go/auth v0.20.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect + github.com/googleapis/gax-go/v2 v2.21.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/joho/godotenv v1.5.1 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/text v0.29.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/grpc v1.80.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index 9ea2463..1f5f94b 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,33 @@ +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= +github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI= +github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -18,10 +45,50 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +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.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= +go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +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.276.0 h1:nVArUtfLEihtW+b0DdcqRGK1xoEm2+ltAihyztq7MKY= +google.golang.org/api v0.276.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI= +google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/app/server.go b/internal/app/server.go index 71c931c..5320d72 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -4,6 +4,8 @@ import ( "log" "net/http" + "github.com/jackc/pgx/v5/pgxpool" + "tutor/internal/auth" "tutor/internal/config" "tutor/internal/db" "tutor/internal/httpapi" @@ -22,12 +24,14 @@ func NewServer(cfg config.Config) *http.Server { var memoryStore learnermemory.Store var ontologyStore ontology.Store var assetsStore teachingassets.Store + var pool *pgxpool.Pool if cfg.DatabaseURL != "" { - pool, err := db.Open(cfg.DatabaseURL) + p, err := db.Open(cfg.DatabaseURL) if err != nil { log.Fatalf("open database: %v", err) } + pool = p if err := db.Migrate(pool); err != nil { log.Fatalf("migrate database: %v", err) } @@ -51,8 +55,15 @@ func NewServer(cfg config.Config) *http.Server { service := interview.NewService(interviewStore, runner, memory) handler := httpapi.NewHandler(cfg, service, memory, progress, onto, assets) + mux := handler.Routes().(*http.ServeMux) + if pool != nil && cfg.GoogleClientID != "" && cfg.JWTSecret != "" { + authService := auth.NewService(pool, cfg.GoogleClientID, cfg.JWTSecret) + authService.RegisterRoutes(mux) + log.Println("auth routes registered") + } + return &http.Server{ Addr: cfg.HTTPAddr, - Handler: handler.Routes(), + Handler: mux, } } diff --git a/internal/auth/handler.go b/internal/auth/handler.go new file mode 100644 index 0000000..1daad54 --- /dev/null +++ b/internal/auth/handler.go @@ -0,0 +1,35 @@ +package auth + +import ( + "encoding/json" + "net/http" +) + +func (s *Service) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/v1/auth/google", s.handleGoogleLogin) +} + +func (s *Service) handleGoogleLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req GoogleLoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, "invalid request", http.StatusBadRequest) + return + } + resp, err := s.HandleGoogleLogin(r.Context(), req) + if err != nil { + writeError(w, err.Error(), http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +func writeError(w http.ResponseWriter, message string, code int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + json.NewEncoder(w).Encode(map[string]string{"error": message}) +} diff --git a/internal/auth/service.go b/internal/auth/service.go new file mode 100644 index 0000000..3a930a6 --- /dev/null +++ b/internal/auth/service.go @@ -0,0 +1,128 @@ +package auth + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "google.golang.org/api/idtoken" +) + +type Service struct { + pool *pgxpool.Pool + googleClientID string + jwtSecret string +} + +func NewService(pool *pgxpool.Pool, googleClientID, jwtSecret string) *Service { + return &Service{ + pool: pool, + googleClientID: googleClientID, + jwtSecret: jwtSecret, + } +} + +func (s *Service) HandleGoogleLogin(ctx context.Context, req GoogleLoginRequest) (SessionResponse, error) { + if strings.TrimSpace(req.IDToken) == "" || s.googleClientID == "" { + return SessionResponse{}, errors.New("google id token is required") + } + + payload, err := idtoken.Validate(ctx, req.IDToken, s.googleClientID) + if err != nil { + return SessionResponse{}, err + } + + email := "" + if v, ok := payload.Claims["email"].(string); ok { + email = strings.ToLower(strings.TrimSpace(v)) + } + name := "" + if v, ok := payload.Claims["name"].(string); ok { + name = strings.TrimSpace(v) + } + subject := payload.Subject + + if email == "" { + return SessionResponse{}, errors.New("email not found in token") + } + if name == "" { + name = strings.Split(email, "@")[0] + } + + user, err := s.upsertUser(ctx, email, name, subject) + if err != nil { + return SessionResponse{}, err + } + + token, err := SignToken(s.jwtSecret, Claims{ + UserID: user.ID, + Email: user.Email, + Exp: time.Now().Add(7 * 24 * time.Hour).Unix(), + }) + if err != nil { + return SessionResponse{}, err + } + + return SessionResponse{Token: token, User: user}, nil +} + +func (s *Service) upsertUser(ctx context.Context, email, displayName, subject string) (User, error) { + var user User + err := s.pool.QueryRow(ctx, ` + INSERT INTO users (email, display_name, provider, provider_subject) + VALUES ($1, $2, 'google', $3) + ON CONFLICT (email) DO UPDATE SET + display_name = EXCLUDED.display_name, + provider_subject = EXCLUDED.provider_subject + RETURNING id, email, display_name + `, email, displayName, subject).Scan(&user.ID, &user.Email, &user.DisplayName) + if err != nil { + return User{}, fmt.Errorf("upsert user: %w", err) + } + return user, nil +} + +func SignToken(secret string, claims Claims) (string, error) { + body, err := json.Marshal(claims) + if err != nil { + return "", err + } + payload := base64.RawURLEncoding.EncodeToString(body) + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(payload)) + sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + return payload + "." + sig, nil +} + +func VerifyToken(secret, token string) (Claims, error) { + parts := strings.Split(token, ".") + if len(parts) != 2 { + return Claims{}, errors.New("invalid token") + } + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(parts[0])) + expected := mac.Sum(nil) + actual, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil || !hmac.Equal(expected, actual) { + return Claims{}, errors.New("invalid signature") + } + body, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return Claims{}, err + } + var claims Claims + if err := json.Unmarshal(body, &claims); err != nil { + return Claims{}, err + } + if claims.Exp < time.Now().Unix() { + return Claims{}, fmt.Errorf("token expired") + } + return claims, nil +} diff --git a/internal/auth/types.go b/internal/auth/types.go new file mode 100644 index 0000000..27c0948 --- /dev/null +++ b/internal/auth/types.go @@ -0,0 +1,24 @@ +package auth + +import "github.com/google/uuid" + +type GoogleLoginRequest struct { + IDToken string `json:"id_token"` +} + +type SessionResponse struct { + Token string `json:"token"` + User User `json:"user"` +} + +type User struct { + ID uuid.UUID `json:"id"` + Email string `json:"email"` + DisplayName string `json:"display_name"` +} + +type Claims struct { + UserID uuid.UUID `json:"user_id"` + Email string `json:"email"` + Exp int64 `json:"exp"` +} diff --git a/internal/config/config.go b/internal/config/config.go index 4647722..b9f8f2d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,6 +19,8 @@ type Config struct { ModelKey string ImageModelKey string ThirdOneBin string + GoogleClientID string + JWTSecret string } func LoadFromEnv() Config { @@ -30,6 +32,8 @@ func LoadFromEnv() Config { ModelKey: envOrDefault("TUTOR_MODEL_KEY", defaultModelKey), ImageModelKey: envOrDefault("TUTOR_IMAGE_MODEL_KEY", defaultImageModelKey), ThirdOneBin: envOrDefault("THIRDONE_BIN", defaultThirdOneBin), + GoogleClientID: envOrDefault("GOOGLE_CLIENT_ID", ""), + JWTSecret: envOrDefault("JWT_SECRET", ""), } } diff --git a/internal/db/migrate.go b/internal/db/migrate.go index 28ec5d5..5332650 100644 --- a/internal/db/migrate.go +++ b/internal/db/migrate.go @@ -2,19 +2,38 @@ package db import ( "context" - _ "embed" + "embed" "fmt" + "path" + "sort" "github.com/jackc/pgx/v5/pgxpool" ) -//go:embed migrations/001_init.sql -var initSQL string +//go:embed migrations/*.sql +var migrationsFS embed.FS func Migrate(pool *pgxpool.Pool) error { - _, err := pool.Exec(context.Background(), initSQL) + files, err := migrationsFS.ReadDir("migrations") if err != nil { - return fmt.Errorf("run migration: %w", err) + return fmt.Errorf("read migrations dir: %w", err) + } + + sort.Slice(files, func(i, j int) bool { + return files[i].Name() < files[j].Name() + }) + + for _, f := range files { + if path.Ext(f.Name()) != ".sql" { + continue + } + data, err := migrationsFS.ReadFile("migrations/" + f.Name()) + if err != nil { + return fmt.Errorf("read migration %s: %w", f.Name(), err) + } + if _, err := pool.Exec(context.Background(), string(data)); err != nil { + return fmt.Errorf("run migration %s: %w", f.Name(), err) + } } return nil } diff --git a/internal/db/migrations/002_auth.sql b/internal/db/migrations/002_auth.sql new file mode 100644 index 0000000..52d67c2 --- /dev/null +++ b/internal/db/migrations/002_auth.sql @@ -0,0 +1,10 @@ +-- Auth: users table + +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT UNIQUE NOT NULL, + display_name TEXT NOT NULL, + provider TEXT NOT NULL DEFAULT 'google', + provider_subject TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/internal/webapp/static/app.js b/internal/webapp/static/app.js index 589e545..9137ed4 100644 --- a/internal/webapp/static/app.js +++ b/internal/webapp/static/app.js @@ -25,6 +25,9 @@ const els = { status: document.querySelector("#status-line"), error: document.querySelector("#error-line"), title: document.querySelector("#session-title"), + gSignIn: document.querySelector(".g_id_signin"), + userInfo: document.querySelector("#user-info"), + logoutButton: document.querySelector("#logout-button"), }; const readinessClassMap = { @@ -359,11 +362,48 @@ function evidenceBlock(evidence = []) { return `

Evidence

`; } +window.handleCredentialResponse = async (response) => { + try { + const res = await request("/api/v1/auth/google", { + method: "POST", + body: JSON.stringify({ id_token: response.credential }), + }); + localStorage.setItem("tutor_token", res.token); + localStorage.setItem("tutor_user", JSON.stringify(res.user)); + setStatus(`Signed in as ${res.user.email}`); + renderAuth(); + } catch (err) { + showError(err.message); + } +}; + +function renderAuth() { + const user = JSON.parse(localStorage.getItem("tutor_user") || "null"); + const token = localStorage.getItem("tutor_token"); + if (user && token) { + els.gSignIn.style.display = "none"; + els.userInfo.style.display = "block"; + els.userInfo.textContent = user.email; + els.logoutButton.style.display = "inline-block"; + } else { + els.gSignIn.style.display = "block"; + els.userInfo.style.display = "none"; + els.logoutButton.style.display = "none"; + } +} + +els.logoutButton.addEventListener("click", () => { + localStorage.removeItem("tutor_token"); + localStorage.removeItem("tutor_user"); + renderAuth(); + setStatus("Signed out"); +}); + async function request(url, options = {}) { - const response = await fetch(url, { - headers: { "Content-Type": "application/json" }, - ...options, - }); + const token = localStorage.getItem("tutor_token"); + const headers = { "Content-Type": "application/json" }; + if (token) headers["Authorization"] = `Bearer ${token}`; + const response = await fetch(url, { headers, ...options }); const body = await response.json(); if (!response.ok) { throw new Error(body.error || `Request failed: ${response.status}`); @@ -398,3 +438,5 @@ function escapeHTML(value) { .replaceAll('"', """) .replaceAll("'", "'"); } + +renderAuth(); diff --git a/internal/webapp/static/index.html b/internal/webapp/static/index.html index e957d4c..87c3b20 100644 --- a/internal/webapp/static/index.html +++ b/internal/webapp/static/index.html @@ -7,12 +7,20 @@ +