From 97422c1ef5167364345b74df80683f8ddc93256f Mon Sep 17 00:00:00 2001 From: Krispire Admin Date: Sat, 30 May 2026 02:33:57 +0530 Subject: [PATCH] add auth --- .env.example | 34 ++- go.mod | 25 +- go.sum | 45 +++ internal/config/config.go | 75 +++-- internal/handler/auth.go | 221 ++++++++++++++ internal/handler/search.go | 54 +++- internal/handler/user.go | 87 ++++++ internal/middleware/auth.go | 74 +++-- internal/model/user.go | 47 ++- internal/repository/r2.go | 109 +++++++ internal/repository/user_repo.go | 227 +++++++++++++++ internal/server/server.go | 103 ++++++- internal/service/auth_service.go | 301 ++++++++++++++++++++ internal/service/user_service.go | 97 +++++++ migrations/0003_create_users.down.sql | 1 + migrations/0003_create_users.up.sql | 31 ++ migrations/0004_create_auth_tokens.down.sql | 1 + migrations/0004_create_auth_tokens.up.sql | 28 ++ pkg/auth/cookie.go | 56 ++++ pkg/auth/jwt.go | 83 ++++++ pkg/mailer/mailer.go | 103 +++++++ 21 files changed, 1720 insertions(+), 82 deletions(-) create mode 100644 internal/handler/auth.go create mode 100644 internal/handler/user.go create mode 100644 internal/repository/r2.go create mode 100644 internal/repository/user_repo.go create mode 100644 internal/service/auth_service.go create mode 100644 internal/service/user_service.go create mode 100644 migrations/0003_create_users.down.sql create mode 100644 migrations/0003_create_users.up.sql create mode 100644 migrations/0004_create_auth_tokens.down.sql create mode 100644 migrations/0004_create_auth_tokens.up.sql create mode 100644 pkg/auth/cookie.go create mode 100644 pkg/auth/jwt.go create mode 100644 pkg/mailer/mailer.go diff --git a/.env.example b/.env.example index d4573d7..dfc1bff 100644 --- a/.env.example +++ b/.env.example @@ -9,19 +9,28 @@ MONGO_DB=spsays # PostgreSQL (users, relations) POSTGRES_URI=postgres://user:pass@localhost:5432/spsays?sslmode=disable -# Meilisearch -MEILISEARCH_HOST=http://localhost:7700 -MEILISEARCH_API_KEY= +# Typesense +TYPESENSE_URL=https://search.prabhupadasays.org +TYPESENSE_API_KEY= # DragonflyDB / Redis REDIS_URI=redis://localhost:6379 -# S3 -S3_REGION= -S3_BUCKET= -S3_ACCESS_KEY= -S3_SECRET_KEY= -S3_ENDPOINT= +# Cloudflare R2 (avatar uploads via presigned URLs) +# R2 endpoint is auto-built as https://.r2.cloudflarestorage.com +R2_ACCOUNT_ID= +R2_ACCESS_KEY_ID= +R2_SECRET_ACCESS_KEY= +R2_BUCKET= +# Public base URL where bucket objects are served (custom domain or pub-XXX.r2.dev) +R2_PUBLIC_URL= + +# SMTP (password-reset + email-verification) +SMTP_HOST= +SMTP_PORT=587 +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_FROM="Prabhupāda Says " # Push Notifications FCM_CREDENTIALS_FILE= @@ -32,7 +41,10 @@ APNS_BUNDLE_ID= # JWT JWT_SECRET=change-me-in-production -JWT_EXPIRATION=24h +JWT_EXPIRATION=168h +# Cookie domain — empty in dev (uses request host), ".prabhupadasays.org" in prod +COOKIE_DOMAIN= +COOKIE_SECURE=false -# Frontend +# Frontend (CORS + email link base) FRONTEND_URL=http://localhost:3000 diff --git a/go.mod b/go.mod index 196b58c..49a7f07 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,26 @@ require ( require ( github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.9 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.20 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.19 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 // indirect + github.com/aws/smithy-go v1.26.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/google/uuid v1.5.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -30,7 +49,7 @@ require ( github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.33.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/text v0.29.0 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.37.0 // indirect ) diff --git a/go.sum b/go.sum index 8dc8d98..27fb4c0 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,42 @@ github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4= +github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11/go.mod h1:dnakxebH6UwFvcvujL0LVggYQ8nEvBGjU4G/V79Nv94= +github.com/aws/aws-sdk-go-v2/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc= +github.com/aws/aws-sdk-go-v2/config v1.32.20/go.mod h1:PuwEpciweIXGULWeOeSTXtSbH4CW9mWdWrhdCKQI1sM= +github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 h1:0w6dCiO8iez+YKwRhRBlL1CH/E3GTfdkuzrwj1by8vo= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25/go.mod h1:9FDWUothyr5RCRAHc45XOiVCzUR8n/IhCYX+uVqw6vk= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 h1:A1PmWU2zfkIm9EyFlJncFXL4W4phML+h8KjltUsCvNQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26/go.mod h1:dY4MRzXEizrD4hqtpKvWVGPX7QleSGGVY+EBolo1RmM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 h1:W/EyPFl9A5rXrtoilfwHYEvzHER+K4SpBPtMXi24Mos= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18/go.mod h1:UG50K+pvd/uy6xExbobg0rjqFBFZe6I3l75EPDZw4tg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 h1:dD3dhHNglpd98gs72my22Ndqi1hqQGllFFg1F+twfxg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25/go.mod h1:0yAbjPfd64gG7mj85RW+fMEYdfBgCRZw8g/oWcL1pjc= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 h1:2pQEbwf+/6EDbiit/GcBE2K4IUpMZymaA0kOz3xK978= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25/go.mod h1:KvT6NCcQ0EZ+ZkVRrlBMt04Po3ok23YELEp7WimhLhM= +github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 h1:ie4ElCmUKS26pzrZcIk/lmt4yWjAqLLcawstyQCh298= +github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2/go.mod h1:zjsomFeX5duj+4PlMB+o4JoWTIx+G0XMyzjYrUbQkN0= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 h1:1VwbP3qMNfxUDEXWki4rCE5iA+44VA1lokTz9HasGzw= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.1/go.mod h1:vUtyoSj0OPji3kjIVSc/GlKuWEiL33f/WFxl6dmpy/A= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 h1:N6pIsdFOW1Kd9S4KyFKXdGRBojPPxkP32+uHFWLv4Hc= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.19/go.mod h1:3gt5WJArFooNmyLONS+h/R4J+o86II8du38IgCwj9dE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 h1:hc+lBYiiTr8Zk4MTzIsQ92MeDWCIDvWGmzKUWOaBcOg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2/go.mod h1:hU6fqB3OJA6/ePheD47LQnxvjYk6br6PtQxs+Q9ojvk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3SJXQNEgksORW3Js= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8= +github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s= +github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= @@ -15,6 +51,8 @@ github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +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/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= @@ -81,6 +119,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -89,6 +129,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +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.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -96,6 +138,7 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -104,6 +147,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/internal/config/config.go b/internal/config/config.go index 5c08180..0ac3ec9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "strconv" + "time" "github.com/joho/godotenv" ) @@ -28,33 +29,46 @@ type Config struct { // DragonflyDB / Redis RedisURI string - // AWS S3 - S3Region string - S3Bucket string - S3AccessKey string - S3SecretKey string - S3Endpoint string // optional — for S3-compatible services (MinIO, etc.) + // Cloudflare R2 (S3-compatible) — for avatar uploads via presigned URLs. + R2AccountID string + R2AccessKeyID string + R2SecretAccessKey string + R2Bucket string + R2PublicURL string // Public base URL for objects, e.g. https://cdn.example.com or the R2 public bucket URL - // Push Notifications - FCMCredentialsFile string // path to Firebase service account JSON - APNsKeyFile string // path to APNs auth key + // SMTP — used for password-reset and email-verification mails. + SMTPHost string + SMTPPort int + SMTPUsername string + SMTPPassword string + SMTPFrom string // "Prabhupāda Says " + + // Push Notifications (existing — unchanged). + FCMCredentialsFile string + APNsKeyFile string APNsKeyID string APNsTeamID string APNsBundleID string // JWT Auth JWTSecret string - JWTExpiration string // e.g., "24h" + JWTExpiration time.Duration // parsed at load time + CookieDomain string // empty in dev; ".prabhupadasays.org" in prod + CookieSecure bool // true in production // Frontend - FrontendURL string // for CORS + FrontendURL string // for CORS + reset/verify links in emails } // Load reads configuration from environment variables (with .env fallback). func Load() (*Config, error) { - // Load .env file if present (ignored in production) _ = godotenv.Load() + jwtExp, err := time.ParseDuration(getEnv("JWT_EXPIRATION", "168h")) + if err != nil { + return nil, fmt.Errorf("invalid JWT_EXPIRATION: %w", err) + } + cfg := &Config{ Env: getEnv("APP_ENV", "development"), Port: getEnvInt("PORT", 8080), @@ -69,11 +83,17 @@ func Load() (*Config, error) { RedisURI: getEnv("REDIS_URI", "redis://localhost:6379"), - S3Region: getEnv("S3_REGION", ""), - S3Bucket: getEnv("S3_BUCKET", ""), - S3AccessKey: getEnv("S3_ACCESS_KEY", ""), - S3SecretKey: getEnv("S3_SECRET_KEY", ""), - S3Endpoint: getEnv("S3_ENDPOINT", ""), + R2AccountID: getEnv("R2_ACCOUNT_ID", ""), + R2AccessKeyID: getEnv("R2_ACCESS_KEY_ID", ""), + R2SecretAccessKey: getEnv("R2_SECRET_ACCESS_KEY", ""), + R2Bucket: getEnv("R2_BUCKET", ""), + R2PublicURL: getEnv("R2_PUBLIC_URL", ""), + + SMTPHost: getEnv("SMTP_HOST", ""), + SMTPPort: getEnvInt("SMTP_PORT", 587), + SMTPUsername: getEnv("SMTP_USERNAME", ""), + SMTPPassword: getEnv("SMTP_PASSWORD", ""), + SMTPFrom: getEnv("SMTP_FROM", "noreply@localhost"), FCMCredentialsFile: getEnv("FCM_CREDENTIALS_FILE", ""), APNsKeyFile: getEnv("APNS_KEY_FILE", ""), @@ -82,15 +102,23 @@ func Load() (*Config, error) { APNsBundleID: getEnv("APNS_BUNDLE_ID", ""), JWTSecret: getEnv("JWT_SECRET", ""), - JWTExpiration: getEnv("JWT_EXPIRATION", "24h"), + JWTExpiration: jwtExp, + CookieDomain: getEnv("COOKIE_DOMAIN", ""), + CookieSecure: getEnvBool("COOKIE_SECURE", false), FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"), } - // Validate required fields if cfg.MongoURI == "" { return nil, fmt.Errorf("MONGO_URI is required") } + if cfg.Env == "production" && cfg.JWTSecret == "" { + return nil, fmt.Errorf("JWT_SECRET is required in production") + } + if cfg.JWTSecret == "" { + // Dev fallback so the server can boot without secrets configured. + cfg.JWTSecret = "dev-only-jwt-secret-do-not-use-in-production" + } return cfg, nil } @@ -110,3 +138,12 @@ func getEnvInt(key string, fallback int) int { } return fallback } + +func getEnvBool(key string, fallback bool) bool { + if v := os.Getenv(key); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + return b + } + } + return fallback +} diff --git a/internal/handler/auth.go b/internal/handler/auth.go new file mode 100644 index 0000000..2944c8f --- /dev/null +++ b/internal/handler/auth.go @@ -0,0 +1,221 @@ +package handler + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/prabhupadasays/spsays-bff/internal/middleware" + "github.com/prabhupadasays/spsays-bff/internal/service" + "github.com/prabhupadasays/spsays-bff/pkg/auth" + "github.com/prabhupadasays/spsays-bff/pkg/response" + "github.com/prabhupadasays/spsays-bff/pkg/validator" +) + +// AuthHandler exposes the auth-related HTTP endpoints. +// +// Every handler is a 5-line shim: parse → validate → service → render. +// All branching (already exists? wrong password? expired link?) lives in +// AuthService and surfaces here only as sentinel errors mapped to status +// codes via mapAuthErr. +type AuthHandler struct { + svc *service.AuthService + cookies auth.CookieOptions +} + +func NewAuthHandler(svc *service.AuthService, cookies auth.CookieOptions) *AuthHandler { + return &AuthHandler{svc: svc, cookies: cookies} +} + +// --- request shapes --- + +type signUpReq struct { + Email string `json:"email"` + Password string `json:"password"` + Name string `json:"name"` +} + +type logInReq struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type forgotReq struct { + Email string `json:"email"` +} + +type resetReq struct { + Token string `json:"token"` + NewPassword string `json:"new_password"` +} + +type verifyReq struct { + Token string `json:"token"` +} + +// --- handlers --- + +// SignUp creates a user, plants the session cookie, returns the user. +func (h *AuthHandler) SignUp(w http.ResponseWriter, r *http.Request) { + var req signUpReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + response.Error(w, http.StatusBadRequest, "invalid JSON body") + return + } + v := validator.New() + validator.Required(v, "email", req.Email) + validator.Email(v, "email", req.Email) + validator.Required(v, "password", req.Password) + validator.MinLength(v, "password", req.Password, 8) + validator.Required(v, "name", req.Name) + validator.MaxLength(v, "name", req.Name, 120) + if !v.Valid() { + response.Error(w, http.StatusBadRequest, v.Error()) + return + } + + res, err := h.svc.SignUp(r.Context(), req.Email, req.Password, req.Name) + if err != nil { + h.renderAuthErr(w, err) + return + } + auth.Set(w, res.Token, res.Expires, h.cookies) + response.JSON(w, http.StatusCreated, res.User) +} + +// LogIn authenticates an existing user. +func (h *AuthHandler) LogIn(w http.ResponseWriter, r *http.Request) { + var req logInReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + response.Error(w, http.StatusBadRequest, "invalid JSON body") + return + } + v := validator.New() + validator.Required(v, "email", req.Email) + validator.Required(v, "password", req.Password) + if !v.Valid() { + response.Error(w, http.StatusBadRequest, v.Error()) + return + } + + res, err := h.svc.LogIn(r.Context(), req.Email, req.Password) + if err != nil { + h.renderAuthErr(w, err) + return + } + auth.Set(w, res.Token, res.Expires, h.cookies) + response.JSON(w, http.StatusOK, res.User) +} + +// LogOut just clears the cookie. JWTs remain technically valid until expiry, +// but with the cookie gone the browser can't present them. +func (h *AuthHandler) LogOut(w http.ResponseWriter, r *http.Request) { + auth.Clear(w, h.cookies) + response.JSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// Me returns the authenticated user. Protected by middleware.RequireAuth. +func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) { + uid := middleware.UserID(r.Context()) + tv := middleware.TokenVersion(r.Context()) + u, err := h.svc.Me(r.Context(), uid, tv) + if err != nil { + response.Error(w, http.StatusUnauthorized, "not authenticated") + return + } + response.JSON(w, http.StatusOK, u) +} + +// ForgotPassword emails a reset link if the address exists. We always return +// 200 to avoid revealing whether the email is registered. +func (h *AuthHandler) ForgotPassword(w http.ResponseWriter, r *http.Request) { + var req forgotReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + response.Error(w, http.StatusBadRequest, "invalid JSON body") + return + } + v := validator.New() + validator.Required(v, "email", req.Email) + validator.Email(v, "email", req.Email) + if !v.Valid() { + response.Error(w, http.StatusBadRequest, v.Error()) + return + } + if err := h.svc.RequestPasswordReset(r.Context(), req.Email); err != nil { + response.Error(w, http.StatusInternalServerError, "could not process request") + return + } + response.JSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// ResetPassword consumes a reset token and sets a new password. +func (h *AuthHandler) ResetPassword(w http.ResponseWriter, r *http.Request) { + var req resetReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + response.Error(w, http.StatusBadRequest, "invalid JSON body") + return + } + v := validator.New() + validator.Required(v, "token", req.Token) + validator.Required(v, "new_password", req.NewPassword) + validator.MinLength(v, "new_password", req.NewPassword, 8) + if !v.Valid() { + response.Error(w, http.StatusBadRequest, v.Error()) + return + } + if err := h.svc.ResetPassword(r.Context(), req.Token, req.NewPassword); err != nil { + h.renderAuthErr(w, err) + return + } + // Force the user to log in again after a reset (their token_version is now stale). + auth.Clear(w, h.cookies) + response.JSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// VerifyEmail flips email_verified_at after the user clicks the link. +func (h *AuthHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) { + var req verifyReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + response.Error(w, http.StatusBadRequest, "invalid JSON body") + return + } + if req.Token == "" { + response.Error(w, http.StatusBadRequest, "token is required") + return + } + if err := h.svc.VerifyEmail(r.Context(), req.Token); err != nil { + h.renderAuthErr(w, err) + return + } + response.JSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// ResendVerification — protected — refires the verification email for the +// currently-logged-in user. +func (h *AuthHandler) ResendVerification(w http.ResponseWriter, r *http.Request) { + uid := middleware.UserID(r.Context()) + if err := h.svc.ResendVerification(r.Context(), uid); err != nil { + h.renderAuthErr(w, err) + return + } + response.JSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// --- error mapping --- + +func (h *AuthHandler) renderAuthErr(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, service.ErrInvalidCredentials), + errors.Is(err, service.ErrInvalidOrExpiredLink): + response.Error(w, http.StatusUnauthorized, err.Error()) + case errors.Is(err, service.ErrEmailAlreadyTaken): + response.Error(w, http.StatusConflict, err.Error()) + case errors.Is(err, service.ErrEmailAlreadyVerified), + errors.Is(err, service.ErrWeakPassword): + response.Error(w, http.StatusBadRequest, err.Error()) + case errors.Is(err, service.ErrUserNotFound): + response.Error(w, http.StatusNotFound, err.Error()) + default: + response.Error(w, http.StatusInternalServerError, "something went wrong") + } +} diff --git a/internal/handler/search.go b/internal/handler/search.go index 823a754..7c74999 100644 --- a/internal/handler/search.go +++ b/internal/handler/search.go @@ -147,6 +147,12 @@ func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) { limit = 100 } + // `exact=1` (or "true") disables typo tolerance + prefix expansion so the + // query only matches literal keywords. Stopword filtering still applies — + // "letter to mukunda" with exact=1 still drops "letter"/"to" so the search + // is effectively the literal word "mukunda" with no fuzzy variants. + exact := parseBoolParam(r.URL.Query().Get("exact")) + ctx := r.Context() var results []searchResultItem @@ -154,13 +160,13 @@ func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) { if searchType == "all" { // Search all 9 collections - results, totalFound = h.multiSearch(ctx, nil, query, page, limit) + results, totalFound = h.multiSearch(ctx, nil, query, page, limit, exact) } else if len(requestedTypes) == 1 { // Single collection — use singleSearch for efficiency - results, totalFound = h.singleSearch(ctx, requestedTypes[0], query, page, limit) + results, totalFound = h.singleSearch(ctx, requestedTypes[0], query, page, limit, exact) } else { // Multiple collections — use multiSearch with a subset - results, totalFound = h.multiSearch(ctx, requestedTypes, query, page, limit) + results, totalFound = h.multiSearch(ctx, requestedTypes, query, page, limit, exact) } totalPages := int(math.Ceil(float64(totalFound) / float64(limit))) @@ -181,8 +187,10 @@ func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) { // multiSearch searches across all 9 collections using Typesense multi_search, // merges results by text_match score, and returns a single paginated list. // multiSearch searches across multiple collections using Typesense multi_search. -// If collections is nil, searches all 9 collections. -func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, query string, page, limit int) ([]searchResultItem, int) { +// If collections is nil, searches all 9 collections. When `exact` is true, +// typo tolerance and prefix expansion are turned off so only literal-keyword +// matches come back. +func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, query string, page, limit int, exact bool) ([]searchResultItem, int) { if collections == nil { collections = []string{ "verses", "prose", "letters", "transcriptions", "glossary", @@ -193,6 +201,7 @@ func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, q exhaustive := true prioritizeExact := true prioritizeTokenPos := true + prefix, numTypos := fuzzinessKnobs(exact) searches := make([]api.MultiSearchCollectionParameters, 0, len(collections)) for _, col := range collections { queryBy := collectionQueryByFields[col] @@ -205,8 +214,8 @@ func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, q QueryBy: stringPtr(queryBy), PerPage: &perPage, Page: &pg, - Prefix: stringPtr("true"), - NumTypos: stringPtr("2"), + Prefix: stringPtr(prefix), + NumTypos: stringPtr(numTypos), ExhaustiveSearch: &exhaustive, Stopwords: stringPtr(searchStopwordsSet), // Boost docs whose tokens are exact matches and appear early in the field @@ -264,7 +273,7 @@ func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, q } // singleSearch searches a single collection. -func (h *SearchHandler) singleSearch(ctx context.Context, collection, query string, page, limit int) ([]searchResultItem, int) { +func (h *SearchHandler) singleSearch(ctx context.Context, collection, query string, page, limit int, exact bool) ([]searchResultItem, int) { queryBy, ok := collectionQueryByFields[collection] if !ok { return []searchResultItem{}, 0 @@ -273,13 +282,14 @@ func (h *SearchHandler) singleSearch(ctx context.Context, collection, query stri exhaustive := true prioritizeExact := true prioritizeTokenPos := true + prefix, numTypos := fuzzinessKnobs(exact) params := &api.SearchCollectionParams{ Q: stringPtr(query), QueryBy: stringPtr(queryBy), Page: &page, PerPage: &limit, - Prefix: stringPtr("true"), - NumTypos: stringPtr("2"), + Prefix: stringPtr(prefix), + NumTypos: stringPtr(numTypos), ExhaustiveSearch: &exhaustive, Stopwords: stringPtr(searchStopwordsSet), // Boost docs whose tokens are exact matches and appear early in the field @@ -534,3 +544,27 @@ func parseIntParam(s string, defaultVal int) int { func stringPtr(s string) *string { return &s } + +// parseBoolParam returns true for "1", "true", "yes", "on" (case-insensitive). +// Everything else (including the empty string) is false. +func parseBoolParam(s string) bool { + switch strings.ToLower(strings.TrimSpace(s)) { + case "1", "true", "yes", "on": + return true + } + return false +} + +// fuzzinessKnobs maps the high-level `exact` flag to the two Typesense params +// that actually disable fuzzy matching: +// +// exact=false → prefix=true, num_typos=2 (default forgiving search) +// exact=true → prefix=false, num_typos=0 (literal-keyword only) +// +// Stopword filtering is left unchanged in both modes. +func fuzzinessKnobs(exact bool) (prefix, numTypos string) { + if exact { + return "false", "0" + } + return "true", "2" +} diff --git a/internal/handler/user.go b/internal/handler/user.go new file mode 100644 index 0000000..9692ece --- /dev/null +++ b/internal/handler/user.go @@ -0,0 +1,87 @@ +package handler + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/prabhupadasays/spsays-bff/internal/middleware" + "github.com/prabhupadasays/spsays-bff/internal/service" + "github.com/prabhupadasays/spsays-bff/pkg/response" +) + +// UserHandler exposes profile-edit + avatar-presign endpoints. +// All routes here sit behind middleware.RequireAuth. +type UserHandler struct { + svc *service.UserService +} + +func NewUserHandler(svc *service.UserService) *UserHandler { + return &UserHandler{svc: svc} +} + +// updateProfileReq accepts partial updates. Use pointers so the JSON decoder +// can tell "client explicitly sent null" from "field not present" — the +// service treats nil-name as "leave alone" and ""-name as an error. +type updateProfileReq struct { + Name *string `json:"name"` + AvatarURL *string `json:"avatar_url"` +} + +// UpdateProfile patches name and/or avatar. +func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) { + uid := middleware.UserID(r.Context()) + + var req updateProfileReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + response.Error(w, http.StatusBadRequest, "invalid JSON body") + return + } + + u, err := h.svc.UpdateProfile(r.Context(), uid, req.Name, req.AvatarURL) + if err != nil { + response.Error(w, http.StatusBadRequest, err.Error()) + return + } + response.JSON(w, http.StatusOK, u) +} + +type presignReq struct { + ContentType string `json:"content_type"` +} + +type presignResp struct { + UploadURL string `json:"upload_url"` + PublicURL string `json:"public_url"` +} + +// AvatarPresign returns a one-shot R2 PUT URL the browser uses to upload. +// The frontend then PATCHes /users/me with the returned public_url to +// persist the new avatar on the user row. +func (h *UserHandler) AvatarPresign(w http.ResponseWriter, r *http.Request) { + uid := middleware.UserID(r.Context()) + + var req presignReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + response.Error(w, http.StatusBadRequest, "invalid JSON body") + return + } + ct := strings.TrimSpace(req.ContentType) + if ct == "" { + response.Error(w, http.StatusBadRequest, "content_type is required") + return + } + + upload, public, err := h.svc.AvatarPresign(r.Context(), uid, ct) + if err != nil { + // Differentiate "feature off" from "bad content type" so the frontend + // can show a useful message. + if strings.Contains(err.Error(), "not configured") { + response.Error(w, http.StatusServiceUnavailable, err.Error()) + return + } + response.Error(w, http.StatusBadRequest, err.Error()) + return + } + response.JSON(w, http.StatusOK, presignResp{UploadURL: upload, PublicURL: public}) +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 5adaea6..ff840c4 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -2,56 +2,78 @@ package middleware import ( "context" + "errors" "net/http" - "strings" + "github.com/prabhupadasays/spsays-bff/internal/repository" + "github.com/prabhupadasays/spsays-bff/pkg/auth" "github.com/prabhupadasays/spsays-bff/pkg/response" ) -type contextKey string +type ctxKey string -const UserIDKey contextKey = "user_id" +const ( + userIDKey ctxKey = "user_id" + tokenVersionKey ctxKey = "tv" +) -// Auth is a placeholder middleware for JWT authentication. -// It extracts the Bearer token from the Authorization header, -// validates it, and sets the user ID in the request context. -func Auth(jwtSecret string) func(next http.Handler) http.Handler { +// RequireAuth is the middleware applied to every protected route. +// +// It pulls the JWT from the cookie, verifies signature + expiry, then asks +// the UserRepo for the current token_version and rejects if the JWT carries +// a stale version (password-change or forced logout invalidates older JWTs). +// +// On success it stuffs (user_id, token_version) into the request context; +// handlers read them via UserID() / TokenVersion(). +func RequireAuth(issuer *auth.Issuer, users *repository.UserRepo) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - response.Error(w, http.StatusUnauthorized, "missing authorization header") + raw := auth.Read(r) + if raw == "" { + response.Error(w, http.StatusUnauthorized, "not authenticated") return } - parts := strings.SplitN(authHeader, " ", 2) - if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { - response.Error(w, http.StatusUnauthorized, "invalid authorization format") + claims, err := issuer.Parse(raw) + if err != nil { + response.Error(w, http.StatusUnauthorized, "invalid or expired session") return } - token := parts[1] - - // TODO: validate JWT token and extract user ID - _ = token - _ = jwtSecret - userID := "" // placeholder - - if userID == "" { - response.Error(w, http.StatusUnauthorized, "invalid or expired token") + u, err := users.FindByID(r.Context(), claims.UserID) + if err != nil { + if errors.Is(err, repository.ErrUserNotFound) { + response.Error(w, http.StatusUnauthorized, "account no longer exists") + return + } + response.Error(w, http.StatusInternalServerError, "auth check failed") + return + } + if u.TokenVersion != claims.TokenVersion { + response.Error(w, http.StatusUnauthorized, "session expired — please log in again") return } - ctx := context.WithValue(r.Context(), UserIDKey, userID) + ctx := context.WithValue(r.Context(), userIDKey, claims.UserID) + ctx = context.WithValue(ctx, tokenVersionKey, claims.TokenVersion) next.ServeHTTP(w, r.WithContext(ctx)) }) } } -// GetUserID extracts the user ID from the request context. -func GetUserID(ctx context.Context) string { - if v, ok := ctx.Value(UserIDKey).(string); ok { +// UserID returns the authenticated user's id from the request context. +// Returns "" if RequireAuth has not run. +func UserID(ctx context.Context) string { + if v, ok := ctx.Value(userIDKey).(string); ok { return v } return "" } + +// TokenVersion returns the JWT's token_version claim from context. +func TokenVersion(ctx context.Context) int { + if v, ok := ctx.Value(tokenVersionKey).(int); ok { + return v + } + return 0 +} diff --git a/internal/model/user.go b/internal/model/user.go index 1831d97..035961e 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -2,24 +2,51 @@ package model import "time" -// User represents a user in PostgreSQL. +// User represents a row in the `users` table. +// +// PasswordHash is tagged `json:"-"` so it never leaks via JSON marshalling +// even if a caller accidentally passes a User through response.JSON. +// +// EmailVerifiedAt is a pointer so JSON-encoded users carry an explicit `null` +// when the user has not yet verified, instead of an omitted field — the +// frontend uses this to decide whether to show the "verify your email" ribbon. type User struct { - ID string `json:"id"` - Email string `json:"email"` - Name string `json:"name"` - Avatar *string `json:"avatar,omitempty"` - Role string `json:"role"` // "user" | "admin" | "moderator" - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + AvatarURL *string `json:"avatar_url"` + Role string `json:"role"` + EmailVerifiedAt *time.Time `json:"email_verified_at"` + PasswordHash string `json:"-"` + TokenVersion int `json:"-"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } +// AuthToken represents a row in the `auth_tokens` table. +// Used for both password reset and email verification flows. +type AuthToken struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Purpose string `json:"purpose"` // "password_reset" | "email_verification" + TokenHash string `json:"-"` + ExpiresAt time.Time `json:"expires_at"` + UsedAt *time.Time `json:"used_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +const ( + PurposePasswordReset = "password_reset" + PurposeEmailVerification = "email_verification" +) + // Bookmark represents a user's bookmarked content. type Bookmark struct { ID string `json:"id"` UserID string `json:"user_id"` - ContentType string `json:"content_type"` // "verse" | "letter" | "essay" | "transcription" | etc. + ContentType string `json:"content_type"` ContentID string `json:"content_id"` - ContentRef string `json:"content_ref"` // human-readable reference (e.g., "BG 1.1") + ContentRef string `json:"content_ref"` SelectedText *string `json:"selected_text,omitempty"` Note *string `json:"note,omitempty"` CreatedAt time.Time `json:"created_at"` diff --git a/internal/repository/r2.go b/internal/repository/r2.go new file mode 100644 index 0000000..0c57421 --- /dev/null +++ b/internal/repository/r2.go @@ -0,0 +1,109 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +// R2 wraps an S3 client pointed at Cloudflare's R2 endpoint. +// +// We use R2 for avatar uploads through presigned PUT URLs: +// +// frontend asks BFF for a presigned URL ─► BFF returns URL + final public URL +// frontend PUTs the file directly to R2 ─► R2 stores it +// frontend POSTs the public URL back to BFF profile-update endpoint +// +// The BFF never proxies the file bytes; bandwidth stays browser↔R2. +type R2 struct { + client *s3.Client + bucket string + publicBase string // R2_PUBLIC_URL, no trailing slash +} + +// R2Config captures the env vars needed to talk to R2. +type R2Config struct { + AccountID string + AccessKeyID string + SecretAccessKey string + Bucket string + PublicURL string +} + +// Configured returns true if every required field is present. +func (c R2Config) Configured() bool { + return c.AccountID != "" && c.AccessKeyID != "" && c.SecretAccessKey != "" && + c.Bucket != "" && c.PublicURL != "" +} + +// NewR2 builds an S3 client pointed at the per-account R2 endpoint. +// Returns (nil, nil) if cfg is not Configured — the avatar route will then +// return 503 instead of panicking. That keeps the BFF bootable without R2. +func NewR2(cfg R2Config) (*R2, error) { + if !cfg.Configured() { + return nil, nil + } + + endpoint := fmt.Sprintf("https://%s.r2.cloudflarestorage.com", cfg.AccountID) + + creds := credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, "") + client := s3.New(s3.Options{ + Region: "auto", // R2 ignores region but the SDK requires one + Credentials: creds, + BaseEndpoint: aws.String(endpoint), + UsePathStyle: true, + }) + + return &R2{ + client: client, + bucket: cfg.Bucket, + publicBase: trimTrailingSlash(cfg.PublicURL), + }, nil +} + +// PresignPut returns a one-shot PUT URL the browser can use to upload a file +// directly to R2, plus the final public URL where the object will be served. +// +// expiry caps how long the upload URL is valid; 5 minutes is plenty and +// limits replay if the URL leaks. +func (r *R2) PresignPut(ctx context.Context, key, contentType string, expiry time.Duration) (uploadURL, publicURL string, err error) { + if r == nil { + return "", "", errors.New("R2 not configured") + } + if key == "" { + return "", "", errors.New("empty key") + } + + presigner := s3.NewPresignClient(r.client) + req, err := presigner.PresignPutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(r.bucket), + Key: aws.String(key), + ContentType: aws.String(contentType), + }, s3.WithPresignExpires(expiry)) + if err != nil { + return "", "", fmt.Errorf("presign put: %w", err) + } + + return req.URL, fmt.Sprintf("%s/%s", r.publicBase, key), nil +} + +// PublicURL builds the public URL for an arbitrary key. Useful when reading +// existing user.avatar_url values back. +func (r *R2) PublicURL(key string) string { + if r == nil { + return "" + } + return fmt.Sprintf("%s/%s", r.publicBase, key) +} + +func trimTrailingSlash(s string) string { + for len(s) > 0 && s[len(s)-1] == '/' { + s = s[:len(s)-1] + } + return s +} diff --git a/internal/repository/user_repo.go b/internal/repository/user_repo.go new file mode 100644 index 0000000..653b58c --- /dev/null +++ b/internal/repository/user_repo.go @@ -0,0 +1,227 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/prabhupadasays/spsays-bff/internal/model" +) + +// ErrUserNotFound is returned by lookup methods when no row matches. +var ErrUserNotFound = errors.New("user not found") + +// ErrEmailTaken is returned by Create when the email already exists. +var ErrEmailTaken = errors.New("email already registered") + +// UserRepo provides CRUD on the users table. +type UserRepo struct { + pg *Postgres +} + +func NewUserRepo(pg *Postgres) *UserRepo { + return &UserRepo{pg: pg} +} + +// Create inserts a new user. Returns ErrEmailTaken on unique-violation. +// `email` is lowercased before insert; the column is CITEXT so case doesn't +// matter for lookups, but lower-casing keeps stored values tidy. +func (r *UserRepo) Create(ctx context.Context, email, passwordHash, name string) (*model.User, error) { + const q = ` + INSERT INTO users (email, password_hash, name) + VALUES ($1, $2, $3) + RETURNING id, email, password_hash, name, avatar_url, role, + email_verified_at, token_version, created_at, updated_at` + + u := &model.User{} + err := r.pg.Pool.QueryRow(ctx, q, strings.ToLower(strings.TrimSpace(email)), passwordHash, name). + Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, + &u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt) + if err != nil { + if isUniqueViolation(err, "users_email_key") { + return nil, ErrEmailTaken + } + return nil, fmt.Errorf("create user: %w", err) + } + return u, nil +} + +// FindByEmail returns the user with the given email, or ErrUserNotFound. +func (r *UserRepo) FindByEmail(ctx context.Context, email string) (*model.User, error) { + return r.findOne(ctx, + `WHERE email = $1`, strings.ToLower(strings.TrimSpace(email))) +} + +// FindByID returns the user with the given id, or ErrUserNotFound. +func (r *UserRepo) FindByID(ctx context.Context, id string) (*model.User, error) { + return r.findOne(ctx, `WHERE id = $1`, id) +} + +func (r *UserRepo) findOne(ctx context.Context, where string, args ...any) (*model.User, error) { + q := ` + SELECT id, email, password_hash, name, avatar_url, role, + email_verified_at, token_version, created_at, updated_at + FROM users ` + where + ` LIMIT 1` + + u := &model.User{} + err := r.pg.Pool.QueryRow(ctx, q, args...). + Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, + &u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrUserNotFound + } + return nil, fmt.Errorf("find user: %w", err) + } + return u, nil +} + +// UpdateProfile updates the user's name and/or avatar. Pass nil to leave a field unchanged. +// Returns the updated user. +func (r *UserRepo) UpdateProfile(ctx context.Context, id string, name *string, avatarURL *string) (*model.User, error) { + const q = ` + UPDATE users + SET name = COALESCE($2, name), + avatar_url = CASE WHEN $3::boolean THEN $4 ELSE avatar_url END, + updated_at = now() + WHERE id = $1 + RETURNING id, email, password_hash, name, avatar_url, role, + email_verified_at, token_version, created_at, updated_at` + + // $3 is a flag: "did the caller intend to set avatar_url (including to NULL)?" + // If avatarURL is nil we leave it alone; otherwise we apply *avatarURL (which + // may itself be the empty string — caller decides if "" should map to NULL). + updateAvatar := avatarURL != nil + var avatarVal *string + if updateAvatar && *avatarURL != "" { + avatarVal = avatarURL + } + + u := &model.User{} + err := r.pg.Pool.QueryRow(ctx, q, id, name, updateAvatar, avatarVal). + Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, + &u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrUserNotFound + } + return nil, fmt.Errorf("update profile: %w", err) + } + return u, nil +} + +// UpdatePassword sets a new password hash and bumps token_version so any +// existing JWTs immediately become invalid. +func (r *UserRepo) UpdatePassword(ctx context.Context, id, newHash string) error { + const q = ` + UPDATE users + SET password_hash = $2, + token_version = token_version + 1, + updated_at = now() + WHERE id = $1` + tag, err := r.pg.Pool.Exec(ctx, q, id, newHash) + if err != nil { + return fmt.Errorf("update password: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrUserNotFound + } + return nil +} + +// MarkEmailVerified sets email_verified_at = now() if not already set. +func (r *UserRepo) MarkEmailVerified(ctx context.Context, id string) error { + const q = `UPDATE users SET email_verified_at = now(), updated_at = now() + WHERE id = $1 AND email_verified_at IS NULL` + _, err := r.pg.Pool.Exec(ctx, q, id) + if err != nil { + return fmt.Errorf("mark verified: %w", err) + } + return nil +} + +// Helper for unique-violation detection. Avoids importing pgconn just for +// the SQLSTATE; we string-match on the message which contains the constraint +// name. Good enough for the one constraint we care about. +func isUniqueViolation(err error, constraint string) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "23505") || strings.Contains(msg, constraint) +} + +// AuthTokenRepo handles password_reset + email_verification tokens. +type AuthTokenRepo struct { + pg *Postgres +} + +func NewAuthTokenRepo(pg *Postgres) *AuthTokenRepo { + return &AuthTokenRepo{pg: pg} +} + +// Create inserts a new token row. `tokenHash` must be the SHA-256 hex of the +// raw token; only the hash is stored, never the plaintext. +func (r *AuthTokenRepo) Create(ctx context.Context, userID, purpose, tokenHash string, ttl time.Duration) (*model.AuthToken, error) { + const q = ` + INSERT INTO auth_tokens (user_id, purpose, token_hash, expires_at) + VALUES ($1, $2, $3, $4) + RETURNING id, user_id, purpose, token_hash, expires_at, used_at, created_at` + + t := &model.AuthToken{} + err := r.pg.Pool.QueryRow(ctx, q, userID, purpose, tokenHash, time.Now().Add(ttl)). + Scan(&t.ID, &t.UserID, &t.Purpose, &t.TokenHash, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt) + if err != nil { + return nil, fmt.Errorf("create auth_token: %w", err) + } + return t, nil +} + +// FindValid returns a non-used, non-expired token by hash + purpose. +func (r *AuthTokenRepo) FindValid(ctx context.Context, tokenHash, purpose string) (*model.AuthToken, error) { + const q = ` + SELECT id, user_id, purpose, token_hash, expires_at, used_at, created_at + FROM auth_tokens + WHERE token_hash = $1 + AND purpose = $2 + AND used_at IS NULL + AND expires_at > now() + LIMIT 1` + + t := &model.AuthToken{} + err := r.pg.Pool.QueryRow(ctx, q, tokenHash, purpose). + Scan(&t.ID, &t.UserID, &t.Purpose, &t.TokenHash, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrUserNotFound + } + return nil, fmt.Errorf("find token: %w", err) + } + return t, nil +} + +// Consume marks a token as used. After this call the token can never be +// honoured again, even if it has not yet expired. +func (r *AuthTokenRepo) Consume(ctx context.Context, id string) error { + const q = `UPDATE auth_tokens SET used_at = now() WHERE id = $1 AND used_at IS NULL` + _, err := r.pg.Pool.Exec(ctx, q, id) + if err != nil { + return fmt.Errorf("consume token: %w", err) + } + return nil +} + +// InvalidatePending marks all not-yet-used tokens for a user+purpose as used. +// Called when a fresh token is issued so old links are immediately dead. +func (r *AuthTokenRepo) InvalidatePending(ctx context.Context, userID, purpose string) error { + const q = `UPDATE auth_tokens SET used_at = now() + WHERE user_id = $1 AND purpose = $2 AND used_at IS NULL` + _, err := r.pg.Pool.Exec(ctx, q, userID, purpose) + if err != nil { + return fmt.Errorf("invalidate pending tokens: %w", err) + } + return nil +} diff --git a/internal/server/server.go b/internal/server/server.go index 69bcd11..0c03608 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -12,7 +12,10 @@ import ( "github.com/prabhupadasays/spsays-bff/internal/handler" "github.com/prabhupadasays/spsays-bff/internal/middleware" "github.com/prabhupadasays/spsays-bff/internal/repository" + "github.com/prabhupadasays/spsays-bff/internal/service" "github.com/prabhupadasays/spsays-bff/internal/worker" + "github.com/prabhupadasays/spsays-bff/pkg/auth" + "github.com/prabhupadasays/spsays-bff/pkg/mailer" "github.com/prabhupadasays/spsays-bff/pkg/shorturl" "go.uber.org/zap" ) @@ -41,6 +44,12 @@ type Server struct { transcriptionH *handler.TranscriptionHandler searchH *handler.SearchHandler shortURLH *handler.ShortURLHandler + authH *handler.AuthHandler + userH *handler.UserHandler + + // Auth state (nil when Postgres is unavailable — /auth and /users return 503) + authIssuer *auth.Issuer + userRepo *repository.UserRepo } // New creates a new Server with all routes registered. @@ -86,10 +95,61 @@ func New(cfg *config.Config, logger *zap.Logger) *Server { // Connection failures degrade gracefully — the BFF stays up; only /share returns 503. s.initShortener(cfg, logger) + // Auth: depends on Postgres being up. initShortener already opened the pool, + // so we reuse s.pg here. On a fresh DB the users + auth_tokens tables get + // created by the migration runner that initShortener already invoked. + s.initAuth(cfg, logger) + s.setupRouter() return s } +// initAuth wires the user repo, JWT issuer, mailer, R2 client, and the auth +// + user handlers. If Postgres is unavailable, leaves the handlers nil and +// the routes 503-out gracefully. +func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) { + if s.pg == nil { + logger.Warn("auth: Postgres unavailable — /auth and /users will return 503") + return + } + + userRepo := repository.NewUserRepo(s.pg) + tokenRepo := repository.NewAuthTokenRepo(s.pg) + + r2, err := repository.NewR2(repository.R2Config{ + AccountID: cfg.R2AccountID, + AccessKeyID: cfg.R2AccessKeyID, + SecretAccessKey: cfg.R2SecretAccessKey, + Bucket: cfg.R2Bucket, + PublicURL: cfg.R2PublicURL, + }) + if err != nil { + logger.Warn("auth: R2 unavailable — avatar uploads will return 503", zap.Error(err)) + } + if r2 == nil { + logger.Info("auth: R2 not configured — avatar presign endpoint will return 503") + } + + issuer := auth.NewIssuer(cfg.JWTSecret, cfg.JWTExpiration) + mail := mailer.New(mailer.Config{ + Host: cfg.SMTPHost, + Port: cfg.SMTPPort, + Username: cfg.SMTPUsername, + Password: cfg.SMTPPassword, + From: cfg.SMTPFrom, + }, logger) + + authSvc := service.NewAuthService(userRepo, tokenRepo, issuer, mail, logger, cfg.FrontendURL) + userSvc := service.NewUserService(userRepo, r2) + + cookieOpts := auth.CookieOptions{Domain: cfg.CookieDomain, Secure: cfg.CookieSecure} + + s.authIssuer = issuer + s.userRepo = userRepo + s.authH = handler.NewAuthHandler(authSvc, cookieOpts) + s.userH = handler.NewUserHandler(userSvc) +} + // initShortener wires Postgres + Redis + the shorturl handler/workers. // On connection failure it logs and leaves the components nil — the handler // short-circuits to 503 so the rest of the BFF keeps working. @@ -152,6 +212,15 @@ func (s *Server) Close() { s.logger.Info("all connections closed") } +// authUnavailable is the 503 fallback used when Postgres is down and the +// auth handlers couldn't be wired up. Keeps every /auth/* and /users/* URL +// returning a structured error rather than 404. +func (s *Server) authUnavailable(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"success":false,"error":{"code":503,"message":"auth service unavailable"}}`)) +} + // injectBookCode is a middleware that sets "code" in chi's URL params // for explicit sub-routes like /books/sb and /books/cc where {code} // isn't a URL param but a fixed path prefix. @@ -367,12 +436,40 @@ func (s *Server) setupRouter() { // Search (Typesense) r.Get("/search", s.searchH.Search) - // User routes (PostgreSQL) + // --- Auth --- + // Public endpoints (no JWT required) for sign-up, log-in, password reset, + // and email verification. The /auth/me + /auth/resend-verification routes + // require a session and live under the protected block below. r.Route("/auth", func(r chi.Router) { - // TODO: register auth handlers (login, register, refresh) + if s.authH == nil { + r.HandleFunc("/*", s.authUnavailable) + return + } + r.Post("/signup", s.authH.SignUp) + r.Post("/login", s.authH.LogIn) + r.Post("/logout", s.authH.LogOut) + r.Post("/forgot-password", s.authH.ForgotPassword) + r.Post("/reset-password", s.authH.ResetPassword) + r.Post("/verify-email", s.authH.VerifyEmail) + + r.Group(func(r chi.Router) { + r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo)) + r.Get("/me", s.authH.Me) + r.Post("/resend-verification", s.authH.ResendVerification) + }) }) + + // User profile + avatar — all protected. r.Route("/users", func(r chi.Router) { - // TODO: register user handlers + if s.userH == nil { + r.HandleFunc("/*", s.authUnavailable) + return + } + r.Group(func(r chi.Router) { + r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo)) + r.Patch("/me", s.userH.UpdateProfile) + r.Post("/me/avatar-presign", s.userH.AvatarPresign) + }) }) r.Route("/collections", func(r chi.Router) { // TODO: register collection/bookmark handlers diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go new file mode 100644 index 0000000..134f791 --- /dev/null +++ b/internal/service/auth_service.go @@ -0,0 +1,301 @@ +// Package service orchestrates the auth flows on top of the repositories. +// +// The handler layer is intentionally dumb: it parses the request, calls one +// AuthService method, and renders the result. All branching (already exists? +// wrong password? expired token?) lives here, behind a small set of named +// error sentinels. +package service + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + + "github.com/prabhupadasays/spsays-bff/internal/model" + "github.com/prabhupadasays/spsays-bff/internal/repository" + "github.com/prabhupadasays/spsays-bff/pkg/auth" + "github.com/prabhupadasays/spsays-bff/pkg/mailer" + "go.uber.org/zap" + "golang.org/x/crypto/bcrypt" +) + +// Sentinel errors. Handlers map these to HTTP status codes. +var ( + ErrInvalidCredentials = errors.New("invalid email or password") + ErrEmailAlreadyTaken = errors.New("email already registered") + ErrUserNotFound = errors.New("user not found") + ErrInvalidOrExpiredLink = errors.New("link is invalid or has expired") + ErrEmailAlreadyVerified = errors.New("email already verified") + ErrWeakPassword = errors.New("password must be at least 8 characters") +) + +// AuthService bundles every flow that mutates auth state. +type AuthService struct { + users *repository.UserRepo + tokens *repository.AuthTokenRepo + jwt *auth.Issuer + mail mailer.Mailer + logger *zap.Logger + frontend string // FrontendURL — used to build reset / verify links + + // TTLs — surfaced as fields so tests can shrink them. + PasswordResetTTL time.Duration + EmailVerifyTTL time.Duration +} + +// NewAuthService wires the dependencies. +func NewAuthService( + users *repository.UserRepo, + tokens *repository.AuthTokenRepo, + jwt *auth.Issuer, + mail mailer.Mailer, + logger *zap.Logger, + frontendURL string, +) *AuthService { + return &AuthService{ + users: users, + tokens: tokens, + jwt: jwt, + mail: mail, + logger: logger, + frontend: strings.TrimRight(frontendURL, "/"), + PasswordResetTTL: 1 * time.Hour, + EmailVerifyTTL: 24 * time.Hour, + } +} + +// AuthResult is the value handlers return on signup/login. Token is the raw +// JWT the handler will plant in the response cookie; the User is the public +// profile to ship in the response body. +type AuthResult struct { + User *model.User + Token string + Expires time.Time +} + +// SignUp creates a new user, issues a session JWT, and fires off a +// verification email (best-effort — failure to send does not block signup, +// since the user can hit resend later). +func (s *AuthService) SignUp(ctx context.Context, email, password, name string) (*AuthResult, error) { + if len(password) < 8 { + return nil, ErrWeakPassword + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("hash password: %w", err) + } + + u, err := s.users.Create(ctx, email, string(hash), strings.TrimSpace(name)) + if err != nil { + if errors.Is(err, repository.ErrEmailTaken) { + return nil, ErrEmailAlreadyTaken + } + return nil, err + } + + if err := s.sendVerificationEmail(ctx, u); err != nil { + // Log and keep going — signup itself succeeded. + s.logger.Warn("verification email failed", zap.String("user", u.ID), zap.Error(err)) + } + + return s.issueSession(u) +} + +// LogIn authenticates a user by email + password. The error returned for an +// unknown email is intentionally identical to a wrong password — never leak +// which one was wrong. +func (s *AuthService) LogIn(ctx context.Context, email, password string) (*AuthResult, error) { + u, err := s.users.FindByEmail(ctx, email) + if err != nil { + if errors.Is(err, repository.ErrUserNotFound) { + return nil, ErrInvalidCredentials + } + return nil, err + } + if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil { + return nil, ErrInvalidCredentials + } + return s.issueSession(u) +} + +// Me looks up a user by id and re-verifies the JWT's token_version against +// the stored value. Mismatch means the user has logged-out-everywhere or +// changed password since this JWT was issued → reject. +func (s *AuthService) Me(ctx context.Context, userID string, tokenVersion int) (*model.User, error) { + u, err := s.users.FindByID(ctx, userID) + if err != nil { + return nil, ErrUserNotFound + } + if u.TokenVersion != tokenVersion { + return nil, auth.ErrInvalidToken + } + return u, nil +} + +// RequestPasswordReset always returns nil to callers — we don't want to +// reveal whether the email is registered. If the email exists we mint a +// token and email a link; otherwise we silently no-op. +func (s *AuthService) RequestPasswordReset(ctx context.Context, email string) error { + u, err := s.users.FindByEmail(ctx, email) + if err != nil { + if errors.Is(err, repository.ErrUserNotFound) { + return nil // do not reveal + } + return err + } + + // Invalidate any previous reset tokens for this user. + _ = s.tokens.InvalidatePending(ctx, u.ID, model.PurposePasswordReset) + + raw, hash, err := newToken() + if err != nil { + return err + } + if _, err := s.tokens.Create(ctx, u.ID, model.PurposePasswordReset, hash, s.PasswordResetTTL); err != nil { + return err + } + + link := fmt.Sprintf("%s/reset-password?token=%s", s.frontend, raw) + body := passwordResetEmail(u.Name, link) + if err := s.mail.Send(u.Email, "Reset your Prabhupāda Says password", body); err != nil { + s.logger.Warn("password reset email failed", zap.String("user", u.ID), zap.Error(err)) + } + return nil +} + +// ResetPassword consumes a reset token and sets a new password. +// On success the user's token_version is bumped, invalidating every +// outstanding JWT (so a stolen token can't be used after the legit owner +// changes their password). +func (s *AuthService) ResetPassword(ctx context.Context, rawToken, newPassword string) error { + if len(newPassword) < 8 { + return ErrWeakPassword + } + hash := hashToken(rawToken) + + t, err := s.tokens.FindValid(ctx, hash, model.PurposePasswordReset) + if err != nil { + return ErrInvalidOrExpiredLink + } + + bcryptHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("hash password: %w", err) + } + if err := s.users.UpdatePassword(ctx, t.UserID, string(bcryptHash)); err != nil { + return err + } + if err := s.tokens.Consume(ctx, t.ID); err != nil { + s.logger.Warn("consume reset token", zap.String("token_id", t.ID), zap.Error(err)) + } + return nil +} + +// VerifyEmail consumes an email-verification token and flips the user's +// email_verified_at timestamp. +func (s *AuthService) VerifyEmail(ctx context.Context, rawToken string) error { + hash := hashToken(rawToken) + t, err := s.tokens.FindValid(ctx, hash, model.PurposeEmailVerification) + if err != nil { + return ErrInvalidOrExpiredLink + } + if err := s.users.MarkEmailVerified(ctx, t.UserID); err != nil { + return err + } + if err := s.tokens.Consume(ctx, t.ID); err != nil { + s.logger.Warn("consume verify token", zap.String("token_id", t.ID), zap.Error(err)) + } + return nil +} + +// ResendVerification re-fires the verification email for the currently-logged-in +// user. No-ops with no error if the user is already verified. +func (s *AuthService) ResendVerification(ctx context.Context, userID string) error { + u, err := s.users.FindByID(ctx, userID) + if err != nil { + return ErrUserNotFound + } + if u.EmailVerifiedAt != nil { + return ErrEmailAlreadyVerified + } + return s.sendVerificationEmail(ctx, u) +} + +// --- internal helpers --- + +func (s *AuthService) issueSession(u *model.User) (*AuthResult, error) { + tok, exp, err := s.jwt.Issue(u.ID, u.TokenVersion) + if err != nil { + return nil, err + } + return &AuthResult{User: u, Token: tok, Expires: exp}, nil +} + +func (s *AuthService) sendVerificationEmail(ctx context.Context, u *model.User) error { + _ = s.tokens.InvalidatePending(ctx, u.ID, model.PurposeEmailVerification) + + raw, hash, err := newToken() + if err != nil { + return err + } + if _, err := s.tokens.Create(ctx, u.ID, model.PurposeEmailVerification, hash, s.EmailVerifyTTL); err != nil { + return err + } + + link := fmt.Sprintf("%s/verify-email?token=%s", s.frontend, raw) + body := verifyEmailMessage(u.Name, link) + return s.mail.Send(u.Email, "Verify your email — Prabhupāda Says", body) +} + +// newToken returns (rawHex, sha256HexHash). The raw value goes in the +// emailed link; only the hash is stored in the DB. +func newToken() (raw, hash string, err error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", "", err + } + raw = hex.EncodeToString(b) + hash = hashToken(raw) + return raw, hash, nil +} + +func hashToken(raw string) string { + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} + +// --- email bodies (intentionally minimal HTML; auth mails should be plain) --- + +func passwordResetEmail(name, link string) string { + return fmt.Sprintf(`

Hari Bol %s,

+

You (or someone using your email) asked to reset your password on +Prabhupāda Says. Click the link below to set a new one:

+

%s

+

This link expires in 1 hour. If you didn't request a reset, just ignore +this email — your password stays the same.

`, htmlEscape(name), link, link) +} + +func verifyEmailMessage(name, link string) string { + return fmt.Sprintf(`

Hari Bol %s,

+

Welcome to Prabhupāda Says. Please confirm your email by +clicking the link below:

+

%s

+

This link expires in 24 hours. You can request a new one from the banner +shown at the top of the site after you log in.

`, htmlEscape(name), link, link) +} + +func htmlEscape(s string) string { + r := strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", + "\"", """, + ) + return r.Replace(s) +} diff --git a/internal/service/user_service.go b/internal/service/user_service.go new file mode 100644 index 0000000..4bf23f9 --- /dev/null +++ b/internal/service/user_service.go @@ -0,0 +1,97 @@ +package service + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "path" + "strings" + "time" + + "github.com/prabhupadasays/spsays-bff/internal/model" + "github.com/prabhupadasays/spsays-bff/internal/repository" +) + +// UserService handles profile-edit + avatar-upload flows. +type UserService struct { + users *repository.UserRepo + r2 *repository.R2 +} + +func NewUserService(users *repository.UserRepo, r2 *repository.R2) *UserService { + return &UserService{users: users, r2: r2} +} + +// UpdateProfile updates the user's name and/or avatar URL. +// +// `avatarURL` semantics: +// +// nil → don't touch +// "" → clear (remove avatar) +// "x" → set to "x" +// +// The avatar URL is expected to point at our R2 bucket — we don't validate +// the host because we don't want to break tests / local setups, but in +// production you could add a check that it starts with R2_PUBLIC_URL. +func (s *UserService) UpdateProfile(ctx context.Context, id string, name *string, avatarURL *string) (*model.User, error) { + if name != nil { + trimmed := strings.TrimSpace(*name) + if trimmed == "" { + return nil, errors.New("name cannot be empty") + } + if len(trimmed) > 120 { + return nil, errors.New("name too long (max 120)") + } + name = &trimmed + } + return s.users.UpdateProfile(ctx, id, name, avatarURL) +} + +// AvatarPresign issues a one-shot presigned PUT URL the frontend uses to +// upload an avatar directly to R2. +// +// The key is namespaced under `avatars//.` so: +// - we can list / clean up a user's avatars with one prefix scan +// - the random suffix forces cache invalidation when the avatar changes +// - a malicious user can't overwrite someone else's avatar (their JWT +// scopes the user_id portion of the key) +// +// Returns (uploadURL, publicURL, error). The frontend then PUTs to uploadURL +// and POSTs publicURL back as the new avatar. +func (s *UserService) AvatarPresign(ctx context.Context, userID, contentType string) (uploadURL, publicURL string, err error) { + if s.r2 == nil { + return "", "", errors.New("avatar uploads are not configured") + } + ext, ok := extensionFor(contentType) + if !ok { + return "", "", fmt.Errorf("unsupported content type: %s", contentType) + } + + suffix := make([]byte, 8) + if _, err := rand.Read(suffix); err != nil { + return "", "", err + } + key := path.Join("avatars", userID, hex.EncodeToString(suffix)+ext) + + return s.r2.PresignPut(ctx, key, contentType, 5*time.Minute) +} + +// extensionFor whitelists the image content types we accept. Restricting +// here is the cheap-but-effective defence against users uploading arbitrary +// binaries to our R2 bucket. +func extensionFor(ct string) (string, bool) { + switch ct { + case "image/png": + return ".png", true + case "image/jpeg": + return ".jpg", true + case "image/webp": + return ".webp", true + case "image/gif": + return ".gif", true + default: + return "", false + } +} diff --git a/migrations/0003_create_users.down.sql b/migrations/0003_create_users.down.sql new file mode 100644 index 0000000..c99ddcd --- /dev/null +++ b/migrations/0003_create_users.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS users; diff --git a/migrations/0003_create_users.up.sql b/migrations/0003_create_users.up.sql new file mode 100644 index 0000000..953e14a --- /dev/null +++ b/migrations/0003_create_users.up.sql @@ -0,0 +1,31 @@ +-- Required Postgres extensions: +-- pgcrypto → gen_random_uuid() for primary keys +-- citext → case-insensitive emails (so "Foo@x.com" == "foo@x.com") +CREATE EXTENSION IF NOT EXISTS pgcrypto; +CREATE EXTENSION IF NOT EXISTS citext; + +-- Users table. Stores all auth + profile data. +-- +-- Fields: +-- email unique, citext (case-insensitive) +-- password_hash bcrypt hash; never logged or returned in API responses +-- email_verified_at NULL until the user clicks the verification link +-- avatar_url absolute URL to a file in the R2 bucket (or NULL) +-- token_version incremented whenever issued JWTs should be invalidated +-- (password change, forced logout). JWTs carry this value +-- as a "tv" claim; mismatch ⇒ middleware rejects the token. +-- role "user" | "admin" | "moderator" +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email CITEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + name VARCHAR(120) NOT NULL, + avatar_url TEXT, + role VARCHAR(32) NOT NULL DEFAULT 'user', + email_verified_at TIMESTAMPTZ, + token_version INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS users_created_at_idx ON users (created_at DESC); diff --git a/migrations/0004_create_auth_tokens.down.sql b/migrations/0004_create_auth_tokens.down.sql new file mode 100644 index 0000000..bb2016b --- /dev/null +++ b/migrations/0004_create_auth_tokens.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS auth_tokens; diff --git a/migrations/0004_create_auth_tokens.up.sql b/migrations/0004_create_auth_tokens.up.sql new file mode 100644 index 0000000..4a76a2f --- /dev/null +++ b/migrations/0004_create_auth_tokens.up.sql @@ -0,0 +1,28 @@ +-- One-shot tokens used by the forgot-password and email-verification flows. +-- +-- We never store the raw token; only the SHA-256 hash. The plaintext lives +-- only in the email link. This means a DB leak can't be replayed against the +-- live flow. +-- +-- `purpose` discriminates the two flows so we can keep one narrow table: +-- 'password_reset' → expires in ~1h +-- 'email_verification' → expires in ~24h +-- +-- `used_at` is set the first time a token is consumed. The handler must check +-- both `used_at IS NULL` AND `expires_at > now()` before honouring a token. +CREATE TABLE IF NOT EXISTS auth_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + purpose VARCHAR(32) NOT NULL, + token_hash CHAR(64) NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK (purpose IN ('password_reset', 'email_verification')) +); + +CREATE INDEX IF NOT EXISTS auth_tokens_user_purpose_idx + ON auth_tokens (user_id, purpose); + +CREATE INDEX IF NOT EXISTS auth_tokens_expires_at_idx + ON auth_tokens (expires_at); diff --git a/pkg/auth/cookie.go b/pkg/auth/cookie.go new file mode 100644 index 0000000..88d40e1 --- /dev/null +++ b/pkg/auth/cookie.go @@ -0,0 +1,56 @@ +package auth + +import ( + "net/http" + "time" +) + +// CookieName is the single httpOnly cookie that carries the JWT. +const CookieName = "spsays_auth" + +// CookieOptions captures the per-environment knobs for the auth cookie. +type CookieOptions struct { + Domain string // empty in dev; ".prabhupadasays.org" in prod for subdomain sharing + Secure bool // true in production (HTTPS-only) +} + +// Set writes the JWT into a Secure, httpOnly, SameSite=Lax cookie. +// SameSite=Lax + same-registrable-domain (prabhupadasays.org / api.prabhupadasays.org) +// is enough for cross-subdomain auth without resorting to SameSite=None. +func Set(w http.ResponseWriter, token string, expires time.Time, opts CookieOptions) { + http.SetCookie(w, &http.Cookie{ + Name: CookieName, + Value: token, + Path: "/", + Domain: opts.Domain, + Expires: expires, + MaxAge: int(time.Until(expires).Seconds()), + HttpOnly: true, + Secure: opts.Secure, + SameSite: http.SameSiteLaxMode, + }) +} + +// Clear overwrites the auth cookie with an immediately-expired one. +func Clear(w http.ResponseWriter, opts CookieOptions) { + http.SetCookie(w, &http.Cookie{ + Name: CookieName, + Value: "", + Path: "/", + Domain: opts.Domain, + Expires: time.Unix(0, 0), + MaxAge: -1, + HttpOnly: true, + Secure: opts.Secure, + SameSite: http.SameSiteLaxMode, + }) +} + +// Read pulls the raw JWT out of the request cookie. Returns "" if missing. +func Read(r *http.Request) string { + c, err := r.Cookie(CookieName) + if err != nil { + return "" + } + return c.Value +} diff --git a/pkg/auth/jwt.go b/pkg/auth/jwt.go new file mode 100644 index 0000000..a5c6b2f --- /dev/null +++ b/pkg/auth/jwt.go @@ -0,0 +1,83 @@ +// Package auth provides JWT issuance + verification for the BFF. +// +// Tokens carry these claims: +// +// sub user id (UUID string) +// tv token_version snapshot — middleware rejects if it doesn't match the +// current value on the user row. Bumping the user's token_version +// invalidates every JWT ever issued to that user (used on password +// change and forced logout). +// iat issued-at +// exp expiry +package auth + +import ( + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// Claims is the JWT payload we sign + verify. +type Claims struct { + UserID string `json:"sub"` + TokenVersion int `json:"tv"` + jwt.RegisteredClaims +} + +// Issuer issues and verifies tokens with a single shared HMAC secret. +type Issuer struct { + secret []byte + ttl time.Duration +} + +// NewIssuer returns an Issuer configured with the given HMAC secret and TTL. +// The TTL is applied to every token issued via Issue(). +func NewIssuer(secret string, ttl time.Duration) *Issuer { + return &Issuer{secret: []byte(secret), ttl: ttl} +} + +// Issue mints a signed JWT for the given user. +func (i *Issuer) Issue(userID string, tokenVersion int) (string, time.Time, error) { + exp := time.Now().Add(i.ttl) + claims := &Claims{ + UserID: userID, + TokenVersion: tokenVersion, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(exp), + }, + } + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := tok.SignedString(i.secret) + if err != nil { + return "", time.Time{}, fmt.Errorf("sign jwt: %w", err) + } + return signed, exp, nil +} + +// ErrInvalidToken is returned by Parse for any verification failure. +var ErrInvalidToken = errors.New("invalid or expired token") + +// Parse validates the token's signature + expiry and returns the claims. +// Callers must additionally compare claims.TokenVersion against the user's +// current token_version (the JWT alone can't tell us whether the user has +// since bumped their version). +func (i *Issuer) Parse(raw string) (*Claims, error) { + claims := &Claims{} + tok, err := jwt.ParseWithClaims(raw, claims, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return i.secret, nil + }) + if err != nil || !tok.Valid { + return nil, ErrInvalidToken + } + return claims, nil +} + +// TTL returns the configured token lifetime. Handlers use it to set cookie +// Max-Age so the cookie and the JWT expire in lockstep. +func (i *Issuer) TTL() time.Duration { return i.ttl } diff --git a/pkg/mailer/mailer.go b/pkg/mailer/mailer.go new file mode 100644 index 0000000..9348f1c --- /dev/null +++ b/pkg/mailer/mailer.go @@ -0,0 +1,103 @@ +// Package mailer is a thin SMTP wrapper used by the auth flows. +// +// The stdlib `net/smtp` is sufficient for our needs (password reset + +// email verification — low volume, plain-MIME). We keep one interface +// (Mailer) so handlers can be tested with an in-memory implementation +// and so we can swap to a transactional provider later (Resend, SES) +// without touching the call sites. +package mailer + +import ( + "fmt" + "net/smtp" + "strings" + + "go.uber.org/zap" +) + +// Mailer is the small surface auth services depend on. +type Mailer interface { + Send(to, subject, htmlBody string) error +} + +// Config carries the per-environment SMTP knobs. +type Config struct { + Host string + Port int + Username string + Password string + From string // "Name " or just "addr@host" +} + +// Configured returns true if enough SMTP creds are present to send mail. +// Used by the auth service to decide whether to send-or-log. +func (c Config) Configured() bool { return c.Host != "" && c.From != "" } + +// SMTP is the production Mailer. Connects per call (low volume → fine). +type SMTP struct { + cfg Config + logger *zap.Logger +} + +// New returns an SMTP mailer if creds are present, otherwise a NoopLogger +// that logs the message body to zap (useful in dev — the password-reset +// link shows up in your server logs). +func New(cfg Config, logger *zap.Logger) Mailer { + if !cfg.Configured() { + logger.Warn("SMTP not configured — mailer will log messages instead of sending") + return &NoopLogger{logger: logger} + } + return &SMTP{cfg: cfg, logger: logger} +} + +// Send delivers a single HTML email. Plain-text alternative is omitted — +// the templates are short and modern clients render HTML fine. +func (m *SMTP) Send(to, subject, htmlBody string) error { + addr := fmt.Sprintf("%s:%d", m.cfg.Host, m.cfg.Port) + auth := smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host) + + var msg strings.Builder + msg.WriteString("From: ") + msg.WriteString(m.cfg.From) + msg.WriteString("\r\n") + msg.WriteString("To: ") + msg.WriteString(to) + msg.WriteString("\r\n") + msg.WriteString("Subject: ") + msg.WriteString(subject) + msg.WriteString("\r\n") + msg.WriteString("MIME-Version: 1.0\r\n") + msg.WriteString("Content-Type: text/html; charset=UTF-8\r\n\r\n") + msg.WriteString(htmlBody) + + // Most relays on port 587 want STARTTLS + AUTH. Port 465 = implicit TLS, + // not handled here — add only if needed. + if err := smtp.SendMail(addr, auth, fromAddr(m.cfg.From), []string{to}, []byte(msg.String())); err != nil { + return fmt.Errorf("smtp send: %w", err) + } + return nil +} + +// fromAddr extracts the bare address from a "Name " string. SendMail's +// `from` argument is the envelope-from, which must be the bare address. +func fromAddr(s string) string { + if i := strings.LastIndex(s, "<"); i >= 0 { + if j := strings.LastIndex(s, ">"); j > i { + return s[i+1 : j] + } + } + return s +} + +// NoopLogger satisfies Mailer by writing the message into the application +// logger. Use it in development so you can copy reset links from the console. +type NoopLogger struct{ logger *zap.Logger } + +func (n *NoopLogger) Send(to, subject, htmlBody string) error { + n.logger.Info("📧 mailer (not sent — SMTP unconfigured)", + zap.String("to", to), + zap.String("subject", subject), + zap.String("body", htmlBody)) + return nil +} +