Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ jobs:
- name: Vet
run: go vet ./...

# Nothing was checking this, and it had rotted badly: echo carried an
# encoded-slash bug exposing static files, golang-jwt v3 an unfixable
# memory-exhaustion bug, x/net the HTTP/2 CONTINUATION flood. All three
# were reachable from this code, and all three sat there for months
# because a version number does not announce itself.
- name: Vulnerabilities
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
"$(go env GOPATH)/bin/govulncheck" ./...

# Tests that need Postgres skip themselves when nothing is listening, so
# this stays fast without a service container. The integration probes
# additionally require PROBE_DSN and are skipped here by design.
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ COPY frontend/ ./
RUN bun run build

# Backend build stage
FROM golang:1.24-alpine AS backend-builder
FROM golang:1.27-alpine AS backend-builder

# Install build dependencies
RUN apk add --no-cache git ca-certificates tzdata
Expand Down
24 changes: 12 additions & 12 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
module geocoding-api

go 1.21
go 1.27.0

require (
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/joho/godotenv v1.5.1
github.com/labstack/echo/v4 v4.11.3
github.com/labstack/echo/v4 v4.15.4
github.com/lib/pq v1.10.9
github.com/stretchr/testify v1.8.4
golang.org/x/crypto v0.14.0
golang.org/x/time v0.3.0
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.57.0
golang.org/x/time v0.15.0
)

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/labstack/gommon v0.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/labstack/gommon v0.5.0 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/net v0.17.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/text v0.13.0 // indirect
golang.org/x/net v0.59.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
58 changes: 22 additions & 36 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,52 +1,38 @@
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/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/labstack/echo/v4 v4.11.3 h1:Upyu3olaqSHkCjs1EJJwQ3WId8b8b1hxbogyommKktM=
github.com/labstack/echo/v4 v4.11.3/go.mod h1:UcGuQ8V6ZNRmSweBIJkPvGfwCMIlFmiqrPqiEBfPYws=
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
github.com/labstack/echo/v4 v4.15.4 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs=
github.com/labstack/echo/v4 v4.15.4/go.mod h1:CuMetKIRwsuO/qlAgMq+KTAalwGoB/h4tC+yPdrTj1g=
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
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/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
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-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
26 changes: 19 additions & 7 deletions services/auth_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,29 @@ import (
"geocoding-api/database"
"geocoding-api/models"

"github.com/golang-jwt/jwt"
"github.com/golang-jwt/jwt/v5"
"github.com/lib/pq"
"golang.org/x/crypto/bcrypt"
)

// AuthService handles authentication and API key management
type AuthService struct{}

// JWTClaims represents the JWT token claims
// JWTClaims represents the JWT token claims.
//
// RegisteredClaims rather than v3's StandardClaims: golang-jwt v3 is
// unmaintained and carries GO-2025-3553 (memory exhaustion parsing headers)
// with no fix available, so the only way out was v5.
//
// The wire format is unchanged. RegisteredClaims marshals the same "exp" and
// "iat" JSON fields StandardClaims did -- it differs only in holding them as
// NumericDate rather than int64 -- so tokens signed before this upgrade keep
// validating and nobody is logged out by the deploy.
type JWTClaims struct {
UserID int `json:"user_id"`
Email string `json:"email"`
IsAdmin bool `json:"is_admin"`
jwt.StandardClaims
jwt.RegisteredClaims
}

// GenerateJWT creates a new JWT token for a user
Expand All @@ -44,9 +53,9 @@ func (as *AuthService) GenerateJWT(user *models.User) (string, error) {
UserID: user.ID,
Email: user.Email,
IsAdmin: user.IsAdmin,
StandardClaims: jwt.StandardClaims{
ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), // Token expires in 24 hours
IssuedAt: time.Now().Unix(),
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), // Token expires in 24 hours
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}

Expand All @@ -71,13 +80,16 @@ func (as *AuthService) ValidateJWT(tokenString string) (*JWTClaims, error) {
}

// Parse token
// WithValidMethods makes the algorithm check an enforced parse option
// rather than only a test inside the keyfunc. v5 also validates exp and iat
// by default, which v3 did not do unless asked.
token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
// Validate signing method
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(secret), nil
})
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))

if err != nil {
return nil, err
Expand Down
115 changes: 115 additions & 0 deletions services/jwt_compat_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package services

import (
"testing"
"time"

"geocoding-api/models"

"github.com/golang-jwt/jwt/v5"
)

// A token signed before the v3 -> v5 upgrade must still validate afterwards.
//
// v5 stores exp and iat as NumericDate where v3 used int64, but both marshal
// to the same JSON numbers, so the wire format is unchanged. If that were
// wrong, the deploy would invalidate every session in flight and log out every
// signed-in user at once -- so it is asserted rather than assumed. The token
// below is built the way v3 built one: raw numeric claims, no NumericDate.
func TestTokensSignedByTheOldLibraryStillValidate(t *testing.T) {
t.Setenv("JWT_SECRET", "compat-test-secret")

legacy := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": 42,
"email": "legacy@example.com",
"is_admin": true,
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Add(-time.Minute).Unix(),
})
tokenString, err := legacy.SignedString([]byte("compat-test-secret"))
if err != nil {
t.Fatalf("sign legacy token: %v", err)
}

claims, err := Auth.ValidateJWT(tokenString)
if err != nil {
t.Fatalf("a token in the pre-upgrade format was rejected: %v", err)
}
if claims.UserID != 42 || claims.Email != "legacy@example.com" || !claims.IsAdmin {
t.Errorf("claims did not survive the round trip: %+v", claims)
}
}

// Round trip through the new code path.
func TestGeneratedTokenValidates(t *testing.T) {
t.Setenv("JWT_SECRET", "compat-test-secret")

token, err := Auth.GenerateJWT(&models.User{ID: 7, Email: "user@example.com", IsAdmin: false})
if err != nil {
t.Fatalf("generate: %v", err)
}
claims, err := Auth.ValidateJWT(token)
if err != nil {
t.Fatalf("validate: %v", err)
}
if claims.UserID != 7 || claims.Email != "user@example.com" || claims.IsAdmin {
t.Errorf("unexpected claims: %+v", claims)
}
}

// v3 did not check expiry unless asked; v5 does it by default. Confirm an
// expired token is actually refused rather than silently accepted.
func TestExpiredTokenIsRejected(t *testing.T) {
t.Setenv("JWT_SECRET", "compat-test-secret")

expired := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": 1,
"email": "expired@example.com",
"exp": time.Now().Add(-time.Hour).Unix(),
"iat": time.Now().Add(-2 * time.Hour).Unix(),
})
tokenString, _ := expired.SignedString([]byte("compat-test-secret"))

if _, err := Auth.ValidateJWT(tokenString); err == nil {
t.Error("an expired token was accepted")
}
}

// The classic JWT attack: swap the algorithm to one the verifier will accept
// with a key it should not use. WithValidMethods makes the refusal a parse
// option rather than a check the keyfunc has to remember to perform.
func TestAlgorithmConfusionIsRejected(t *testing.T) {
t.Setenv("JWT_SECRET", "compat-test-secret")

none := jwt.NewWithClaims(jwt.SigningMethodNone, jwt.MapClaims{
"user_id": 1,
"email": "attacker@example.com",
"is_admin": true,
"exp": time.Now().Add(time.Hour).Unix(),
})
tokenString, err := none.SignedString(jwt.UnsafeAllowNoneSignatureType)
if err != nil {
t.Fatalf("sign alg=none token: %v", err)
}

if _, err := Auth.ValidateJWT(tokenString); err == nil {
t.Error("a token signed with alg=none was accepted")
}
}

// A token signed with the wrong key must fail regardless of how well formed it
// is otherwise.
func TestWrongSecretIsRejected(t *testing.T) {
t.Setenv("JWT_SECRET", "compat-test-secret")

forged := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": 1,
"is_admin": true,
"exp": time.Now().Add(time.Hour).Unix(),
})
tokenString, _ := forged.SignedString([]byte("not-the-real-secret"))

if _, err := Auth.ValidateJWT(tokenString); err == nil {
t.Error("a token signed with the wrong secret was accepted")
}
}
Loading