feat: add Google Sign-In with JWT auth and Neon DB persistence
This commit is contained in:
34
go.mod
34
go.mod
@@ -2,13 +2,39 @@ module tutor
|
|||||||
|
|
||||||
go 1.25.0
|
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 (
|
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/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/joho/godotenv v1.5.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
golang.org/x/sync v0.17.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
|
||||||
golang.org/x/text v0.29.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
|
||||||
)
|
)
|
||||||
|
|||||||
75
go.sum
75
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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
|
||||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
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/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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"tutor/internal/auth"
|
||||||
"tutor/internal/config"
|
"tutor/internal/config"
|
||||||
"tutor/internal/db"
|
"tutor/internal/db"
|
||||||
"tutor/internal/httpapi"
|
"tutor/internal/httpapi"
|
||||||
@@ -22,12 +24,14 @@ func NewServer(cfg config.Config) *http.Server {
|
|||||||
var memoryStore learnermemory.Store
|
var memoryStore learnermemory.Store
|
||||||
var ontologyStore ontology.Store
|
var ontologyStore ontology.Store
|
||||||
var assetsStore teachingassets.Store
|
var assetsStore teachingassets.Store
|
||||||
|
var pool *pgxpool.Pool
|
||||||
|
|
||||||
if cfg.DatabaseURL != "" {
|
if cfg.DatabaseURL != "" {
|
||||||
pool, err := db.Open(cfg.DatabaseURL)
|
p, err := db.Open(cfg.DatabaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("open database: %v", err)
|
log.Fatalf("open database: %v", err)
|
||||||
}
|
}
|
||||||
|
pool = p
|
||||||
if err := db.Migrate(pool); err != nil {
|
if err := db.Migrate(pool); err != nil {
|
||||||
log.Fatalf("migrate database: %v", err)
|
log.Fatalf("migrate database: %v", err)
|
||||||
}
|
}
|
||||||
@@ -51,8 +55,15 @@ func NewServer(cfg config.Config) *http.Server {
|
|||||||
service := interview.NewService(interviewStore, runner, memory)
|
service := interview.NewService(interviewStore, runner, memory)
|
||||||
handler := httpapi.NewHandler(cfg, service, memory, progress, onto, assets)
|
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{
|
return &http.Server{
|
||||||
Addr: cfg.HTTPAddr,
|
Addr: cfg.HTTPAddr,
|
||||||
Handler: handler.Routes(),
|
Handler: mux,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
internal/auth/handler.go
Normal file
35
internal/auth/handler.go
Normal file
@@ -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})
|
||||||
|
}
|
||||||
128
internal/auth/service.go
Normal file
128
internal/auth/service.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
24
internal/auth/types.go
Normal file
24
internal/auth/types.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ type Config struct {
|
|||||||
ModelKey string
|
ModelKey string
|
||||||
ImageModelKey string
|
ImageModelKey string
|
||||||
ThirdOneBin string
|
ThirdOneBin string
|
||||||
|
GoogleClientID string
|
||||||
|
JWTSecret string
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadFromEnv() Config {
|
func LoadFromEnv() Config {
|
||||||
@@ -30,6 +32,8 @@ func LoadFromEnv() Config {
|
|||||||
ModelKey: envOrDefault("TUTOR_MODEL_KEY", defaultModelKey),
|
ModelKey: envOrDefault("TUTOR_MODEL_KEY", defaultModelKey),
|
||||||
ImageModelKey: envOrDefault("TUTOR_IMAGE_MODEL_KEY", defaultImageModelKey),
|
ImageModelKey: envOrDefault("TUTOR_IMAGE_MODEL_KEY", defaultImageModelKey),
|
||||||
ThirdOneBin: envOrDefault("THIRDONE_BIN", defaultThirdOneBin),
|
ThirdOneBin: envOrDefault("THIRDONE_BIN", defaultThirdOneBin),
|
||||||
|
GoogleClientID: envOrDefault("GOOGLE_CLIENT_ID", ""),
|
||||||
|
JWTSecret: envOrDefault("JWT_SECRET", ""),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,19 +2,38 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
_ "embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path"
|
||||||
|
"sort"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed migrations/001_init.sql
|
//go:embed migrations/*.sql
|
||||||
var initSQL string
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
func Migrate(pool *pgxpool.Pool) error {
|
func Migrate(pool *pgxpool.Pool) error {
|
||||||
_, err := pool.Exec(context.Background(), initSQL)
|
files, err := migrationsFS.ReadDir("migrations")
|
||||||
if err != nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
10
internal/db/migrations/002_auth.sql
Normal file
10
internal/db/migrations/002_auth.sql
Normal file
@@ -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()
|
||||||
|
);
|
||||||
@@ -25,6 +25,9 @@ const els = {
|
|||||||
status: document.querySelector("#status-line"),
|
status: document.querySelector("#status-line"),
|
||||||
error: document.querySelector("#error-line"),
|
error: document.querySelector("#error-line"),
|
||||||
title: document.querySelector("#session-title"),
|
title: document.querySelector("#session-title"),
|
||||||
|
gSignIn: document.querySelector(".g_id_signin"),
|
||||||
|
userInfo: document.querySelector("#user-info"),
|
||||||
|
logoutButton: document.querySelector("#logout-button"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const readinessClassMap = {
|
const readinessClassMap = {
|
||||||
@@ -359,11 +362,48 @@ function evidenceBlock(evidence = []) {
|
|||||||
return `<section><h2>Evidence</h2><ul class="small-list">${evidence.map((item) => `<li>${escapeHTML(item.quote || item.id)}</li>`).join("")}</ul></section>`;
|
return `<section><h2>Evidence</h2><ul class="small-list">${evidence.map((item) => `<li>${escapeHTML(item.quote || item.id)}</li>`).join("")}</ul></section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = {}) {
|
async function request(url, options = {}) {
|
||||||
const response = await fetch(url, {
|
const token = localStorage.getItem("tutor_token");
|
||||||
headers: { "Content-Type": "application/json" },
|
const headers = { "Content-Type": "application/json" };
|
||||||
...options,
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
});
|
const response = await fetch(url, { headers, ...options });
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(body.error || `Request failed: ${response.status}`);
|
throw new Error(body.error || `Request failed: ${response.status}`);
|
||||||
@@ -398,3 +438,5 @@ function escapeHTML(value) {
|
|||||||
.replaceAll('"', """)
|
.replaceAll('"', """)
|
||||||
.replaceAll("'", "'");
|
.replaceAll("'", "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderAuth();
|
||||||
|
|||||||
@@ -7,12 +7,20 @@
|
|||||||
<link rel="stylesheet" href="/assets/styles.css" />
|
<link rel="stylesheet" href="/assets/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||||
<main class="workspace">
|
<main class="workspace">
|
||||||
<aside class="setup-pane" aria-label="Diagnostic setup">
|
<aside class="setup-pane" aria-label="Diagnostic setup">
|
||||||
<p class="eyebrow">Tutor Platform</p>
|
<p class="eyebrow">Tutor Platform</p>
|
||||||
<h1>Interview practice</h1>
|
<h1>Interview practice</h1>
|
||||||
<p class="lede">Start a focused backend interview loop and turn one answer into evidence.</p>
|
<p class="lede">Start a focused backend interview loop and turn one answer into evidence.</p>
|
||||||
|
|
||||||
|
<div id="auth-area" class="auth-area">
|
||||||
|
<div id="g_id_onload" data-client_id="13671390758-bp1ed6psn43bl86r8a9kv81o40nkea90.apps.googleusercontent.com" data-callback="handleCredentialResponse"></div>
|
||||||
|
<div class="g_id_signin" data-type="standard"></div>
|
||||||
|
<div id="user-info" class="user-info" style="display:none;"></div>
|
||||||
|
<button id="logout-button" class="small-button" type="button" style="display:none;">Sign out</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form id="session-form" class="stacked-form">
|
<form id="session-form" class="stacked-form">
|
||||||
<label>
|
<label>
|
||||||
User ID
|
User ID
|
||||||
|
|||||||
Reference in New Issue
Block a user