Spaces:
Sleeping
Sleeping
Commit ·
3776b6b
1
Parent(s): d6b440a
Add application file
Browse files- Dockerfile +38 -0
- cmd/main.go +48 -0
- go.mod +26 -0
- go.sum +62 -0
- internal/auth/dto/me_response.go +8 -0
- internal/auth/handler/auth_handler.go +313 -0
- internal/auth/middleware/auth_middleware.go +59 -0
- internal/auth/middleware/csrf_middleware.go +32 -0
- internal/auth/models/session.go +16 -0
- internal/auth/repository/session_repository.go +70 -0
- internal/auth/service/auth_service.go +95 -0
- internal/auth/service/jwks.go +97 -0
- internal/config/config.go +28 -0
- internal/database/mongodb.go +51 -0
- internal/shared/crypto/encryption.go +74 -0
- internal/shared/middleware/cors.go +20 -0
- internal/shared/middleware/rate_limiter.go +40 -0
- internal/shared/middleware/security_headers.go +17 -0
- internal/shared/utils/random.go +16 -0
Dockerfile
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM golang:1.21-alpine AS builder
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
RUN apk add --no-cache git
|
| 6 |
+
|
| 7 |
+
COPY go.mod go.sum ./
|
| 8 |
+
RUN go mod download
|
| 9 |
+
|
| 10 |
+
COPY . .
|
| 11 |
+
|
| 12 |
+
RUN go build -o server ./cmd/main.go
|
| 13 |
+
|
| 14 |
+
FROM alpine:3.18
|
| 15 |
+
|
| 16 |
+
WORKDIR /app
|
| 17 |
+
|
| 18 |
+
COPY --from=builder /app/server .
|
| 19 |
+
|
| 20 |
+
ENV PORT=7860
|
| 21 |
+
ENV MONGO_URI=mongodb+srv://learnifymedhub_db_user:learnifymedhub_db_user@cluster0.ubsovhe.mongodb.net/?appName=Cluster0
|
| 22 |
+
ENV MONGO_DB=bff_secure
|
| 23 |
+
ENV KEYCLOAK_URL=https://learnifymedhub-kc.hf.space
|
| 24 |
+
ENV KEYCLOAK_REALM=master
|
| 25 |
+
ENV KEYCLOAK_CLIENT_ID=prepnic-web
|
| 26 |
+
ENV KEYCLOAK_CLIENT_SECRET=AgZOzRF6xejhjbSVB0QzuZt6Mpo31H95
|
| 27 |
+
ENV KEYCLOAK_REDIRECT_URL=https://8080-firebase-q-1771049796673.cluster-ancjwrkgr5dvux4qug5rbzyc2y.cloudworkstations.dev/auth/callback
|
| 28 |
+
ENV SESSION_SECRET=12345678901234567890123456789012
|
| 29 |
+
ENV CSRF_SECRET=ANOTHER_32BYTE_RANDOM_SECRET_123
|
| 30 |
+
ENV COOKIE_DOMAIN=.cloudworkstations.dev
|
| 31 |
+
ENV FRONTEND_ORIGIN=https://4200-firebase-q-1771049796673.cluster-ancjwrkgr5dvux4qug5rbzyc2y.cloudworkstations.dev
|
| 32 |
+
ENV SESSION_TTL_HOURS=24
|
| 33 |
+
ENV KEYCLOAK_BACKCHANNEL_SECRET=AgZOzRF6xejhjbSVB0QzuZt6Mpo31H95
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
EXPOSE 7860
|
| 37 |
+
|
| 38 |
+
CMD ["./server"]
|
cmd/main.go
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// cmd/main.go
|
| 2 |
+
package main
|
| 3 |
+
|
| 4 |
+
import (
|
| 5 |
+
"log"
|
| 6 |
+
"net/http"
|
| 7 |
+
"os"
|
| 8 |
+
|
| 9 |
+
"server/internal/auth/handler"
|
| 10 |
+
authMiddleware "server/internal/auth/middleware"
|
| 11 |
+
"server/internal/config"
|
| 12 |
+
"server/internal/database"
|
| 13 |
+
sharedMiddleware "server/internal/shared/middleware"
|
| 14 |
+
|
| 15 |
+
"github.com/go-chi/chi/v5"
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
func main() {
|
| 19 |
+
config.Load()
|
| 20 |
+
database.Connect()
|
| 21 |
+
|
| 22 |
+
r := chi.NewRouter()
|
| 23 |
+
|
| 24 |
+
// Global middlewares
|
| 25 |
+
r.Use(sharedMiddleware.CORS())
|
| 26 |
+
r.Use(sharedMiddleware.SecurityHeaders)
|
| 27 |
+
r.Use(sharedMiddleware.RateLimiter)
|
| 28 |
+
|
| 29 |
+
// Public auth routes
|
| 30 |
+
r.Route("/auth", func(r chi.Router) {
|
| 31 |
+
r.Get("/login", handler.Login)
|
| 32 |
+
r.Get("/callback", handler.Callback)
|
| 33 |
+
r.Post("/logout", handler.Logout)
|
| 34 |
+
r.Post("/backchannel-logout", handler.BackchannelLogout)
|
| 35 |
+
})
|
| 36 |
+
|
| 37 |
+
// Protected routes
|
| 38 |
+
r.Group(func(r chi.Router) {
|
| 39 |
+
r.Use(authMiddleware.RequireAuthWithRefresh)
|
| 40 |
+
r.Use(authMiddleware.RequireCSRF)
|
| 41 |
+
r.Get("/auth/me", handler.Me)
|
| 42 |
+
r.Post("/auth/logout", handler.Logout)
|
| 43 |
+
})
|
| 44 |
+
|
| 45 |
+
port := os.Getenv("PORT")
|
| 46 |
+
log.Println("Server running on :", port)
|
| 47 |
+
log.Fatal(http.ListenAndServe(":"+port, r))
|
| 48 |
+
}
|
go.mod
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
module server
|
| 2 |
+
|
| 3 |
+
go 1.24.0
|
| 4 |
+
|
| 5 |
+
require (
|
| 6 |
+
github.com/MicahParks/keyfunc v1.9.0
|
| 7 |
+
github.com/go-chi/chi/v5 v5.2.5
|
| 8 |
+
github.com/go-chi/cors v1.2.2
|
| 9 |
+
github.com/golang-jwt/jwt/v4 v4.4.2
|
| 10 |
+
github.com/joho/godotenv v1.5.1
|
| 11 |
+
go.mongodb.org/mongo-driver v1.17.9
|
| 12 |
+
golang.org/x/time v0.14.0
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
require (
|
| 16 |
+
github.com/golang/snappy v0.0.4 // indirect
|
| 17 |
+
github.com/klauspost/compress v1.16.7 // indirect
|
| 18 |
+
github.com/montanaflynn/stats v0.7.1 // indirect
|
| 19 |
+
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
| 20 |
+
github.com/xdg-go/scram v1.1.2 // indirect
|
| 21 |
+
github.com/xdg-go/stringprep v1.0.4 // indirect
|
| 22 |
+
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
| 23 |
+
golang.org/x/crypto v0.26.0 // indirect
|
| 24 |
+
golang.org/x/sync v0.8.0 // indirect
|
| 25 |
+
golang.org/x/text v0.17.0 // indirect
|
| 26 |
+
)
|
go.sum
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
|
| 2 |
+
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
|
| 3 |
+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
| 4 |
+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
| 5 |
+
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
| 6 |
+
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
| 7 |
+
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
| 8 |
+
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
| 9 |
+
github.com/golang-jwt/jwt/v4 v4.4.2 h1:rcc4lwaZgFMCZ5jxF9ABolDcIHdBytAFgqFPbSJQAYs=
|
| 10 |
+
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
| 11 |
+
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
| 12 |
+
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
| 13 |
+
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
| 14 |
+
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
| 15 |
+
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
| 16 |
+
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
| 17 |
+
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
|
| 18 |
+
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
| 19 |
+
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
| 20 |
+
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
| 21 |
+
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
| 22 |
+
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
| 23 |
+
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
| 24 |
+
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
| 25 |
+
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
| 26 |
+
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
| 27 |
+
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
| 28 |
+
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
| 29 |
+
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
| 30 |
+
go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
|
| 31 |
+
go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
|
| 32 |
+
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
| 33 |
+
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
| 34 |
+
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
| 35 |
+
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
| 36 |
+
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
| 37 |
+
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
| 38 |
+
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
| 39 |
+
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
| 40 |
+
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
| 41 |
+
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
| 42 |
+
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
| 43 |
+
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
| 44 |
+
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
| 45 |
+
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
| 46 |
+
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
| 47 |
+
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
| 48 |
+
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
| 49 |
+
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
| 50 |
+
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
| 51 |
+
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
| 52 |
+
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
| 53 |
+
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
| 54 |
+
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
| 55 |
+
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
|
| 56 |
+
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
| 57 |
+
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
| 58 |
+
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
| 59 |
+
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
| 60 |
+
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
| 61 |
+
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
| 62 |
+
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
internal/auth/dto/me_response.go
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// internal/auth/dto/me_response.go
|
| 2 |
+
package dto
|
| 3 |
+
|
| 4 |
+
type MeResponse struct {
|
| 5 |
+
ID string `json:"id"`
|
| 6 |
+
Email string `json:"email"`
|
| 7 |
+
Username string `json:"username"`
|
| 8 |
+
}
|
internal/auth/handler/auth_handler.go
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// internal/auth/handler/auth_handler.go
|
| 2 |
+
package handler
|
| 3 |
+
|
| 4 |
+
import (
|
| 5 |
+
"context"
|
| 6 |
+
"encoding/json"
|
| 7 |
+
"errors"
|
| 8 |
+
"fmt"
|
| 9 |
+
"io"
|
| 10 |
+
"net/http"
|
| 11 |
+
"net/url"
|
| 12 |
+
"os"
|
| 13 |
+
"time"
|
| 14 |
+
"log"
|
| 15 |
+
|
| 16 |
+
"server/internal/auth/models"
|
| 17 |
+
"server/internal/auth/repository"
|
| 18 |
+
"server/internal/auth/service"
|
| 19 |
+
"server/internal/shared/crypto"
|
| 20 |
+
"server/internal/shared/utils"
|
| 21 |
+
"go.mongodb.org/mongo-driver/mongo"
|
| 22 |
+
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
func Login(w http.ResponseWriter, r *http.Request) {
|
| 26 |
+
state, _ := utils.SecureRandom(32)
|
| 27 |
+
codeVerifier, _ := utils.SecureRandom(64)
|
| 28 |
+
codeChallenge := crypto.SHA256Base64URL(codeVerifier)
|
| 29 |
+
|
| 30 |
+
http.SetCookie(w, &http.Cookie{
|
| 31 |
+
Name: "oauth_state",
|
| 32 |
+
Value: state,
|
| 33 |
+
HttpOnly: true,
|
| 34 |
+
Secure: true,
|
| 35 |
+
SameSite: http.SameSiteNoneMode,
|
| 36 |
+
Path: "/",
|
| 37 |
+
MaxAge: 300,
|
| 38 |
+
})
|
| 39 |
+
http.SetCookie(w, &http.Cookie{
|
| 40 |
+
Name: "pkce_verifier",
|
| 41 |
+
Value: codeVerifier,
|
| 42 |
+
HttpOnly: true,
|
| 43 |
+
Secure: true,
|
| 44 |
+
SameSite: http.SameSiteNoneMode,
|
| 45 |
+
Path: "/",
|
| 46 |
+
MaxAge: 300,
|
| 47 |
+
})
|
| 48 |
+
|
| 49 |
+
authURL := fmt.Sprintf("%s/realms/%s/protocol/openid-connect/auth?client_id=%s&response_type=code&scope=openid profile email&redirect_uri=%s&state=%s&code_challenge=%s&code_challenge_method=S256",
|
| 50 |
+
os.Getenv("KEYCLOAK_URL"),
|
| 51 |
+
os.Getenv("KEYCLOAK_REALM"),
|
| 52 |
+
os.Getenv("KEYCLOAK_CLIENT_ID"),
|
| 53 |
+
url.QueryEscape(os.Getenv("KEYCLOAK_REDIRECT_URL")),
|
| 54 |
+
state,
|
| 55 |
+
codeChallenge,
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
func Callback(w http.ResponseWriter, r *http.Request) {
|
| 62 |
+
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
| 63 |
+
defer cancel()
|
| 64 |
+
|
| 65 |
+
code := r.URL.Query().Get("code")
|
| 66 |
+
state := r.URL.Query().Get("state")
|
| 67 |
+
|
| 68 |
+
stateCookie, err := r.Cookie("oauth_state")
|
| 69 |
+
if err != nil || state != stateCookie.Value {
|
| 70 |
+
http.Error(w, "Invalid state", http.StatusBadRequest)
|
| 71 |
+
return
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
pkceCookie, err := r.Cookie("pkce_verifier")
|
| 75 |
+
if err != nil {
|
| 76 |
+
http.Error(w, "Missing PKCE verifier", http.StatusBadRequest)
|
| 77 |
+
return
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
tokenResp, err := exchangeCodeForToken(code, pkceCookie.Value)
|
| 81 |
+
if err != nil {
|
| 82 |
+
http.Error(w, err.Error(), http.StatusInternalServerError)
|
| 83 |
+
return
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
claims, err := verifyIDToken(tokenResp.IDToken)
|
| 87 |
+
if err != nil {
|
| 88 |
+
http.Error(w, fmt.Sprintf("Invalid ID token: %v", err), http.StatusUnauthorized)
|
| 89 |
+
return
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
userID := claims["sub"].(string)
|
| 93 |
+
email := claims["email"].(string)
|
| 94 |
+
username := claims["preferred_username"].(string)
|
| 95 |
+
sid, _ := claims["sid"].(string)
|
| 96 |
+
|
| 97 |
+
sessionID, _ := utils.SecureRandom(32)
|
| 98 |
+
csrfToken, _ := utils.SecureRandom(32)
|
| 99 |
+
|
| 100 |
+
encAccess, _ := crypto.Encrypt(tokenResp.AccessToken)
|
| 101 |
+
encRefresh, _ := crypto.Encrypt(tokenResp.RefreshToken)
|
| 102 |
+
|
| 103 |
+
expiresAt := time.Now().Add(time.Hour * 24)
|
| 104 |
+
accessExpires := time.Now().Add(time.Second * time.Duration(tokenResp.ExpiresIn))
|
| 105 |
+
|
| 106 |
+
session := models.Session{
|
| 107 |
+
SessionID: sessionID,
|
| 108 |
+
UserID: userID,
|
| 109 |
+
Email: email,
|
| 110 |
+
Username: username,
|
| 111 |
+
KeycloakSID: sid,
|
| 112 |
+
EncryptedAccess: encAccess,
|
| 113 |
+
EncryptedRefresh: encRefresh,
|
| 114 |
+
AccessExpiresAt: accessExpires,
|
| 115 |
+
ExpiresAt: expiresAt,
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
if err := repository.Save(ctx, session); err != nil {
|
| 119 |
+
http.Error(w, fmt.Sprintf("Failed to save session: %v", err), http.StatusInternalServerError)
|
| 120 |
+
return
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
http.SetCookie(w, &http.Cookie{
|
| 124 |
+
Name: "session_id",
|
| 125 |
+
Value: sessionID,
|
| 126 |
+
Path: "/",
|
| 127 |
+
HttpOnly: true,
|
| 128 |
+
Secure: true,
|
| 129 |
+
SameSite: http.SameSiteNoneMode,
|
| 130 |
+
Domain: os.Getenv("COOKIE_DOMAIN"),
|
| 131 |
+
})
|
| 132 |
+
|
| 133 |
+
http.SetCookie(w, &http.Cookie{
|
| 134 |
+
Name: "XSRF-TOKEN",
|
| 135 |
+
Value: csrfToken,
|
| 136 |
+
Path: "/",
|
| 137 |
+
HttpOnly: false,
|
| 138 |
+
Secure: true,
|
| 139 |
+
SameSite: http.SameSiteNoneMode,
|
| 140 |
+
Domain: os.Getenv("COOKIE_DOMAIN"),
|
| 141 |
+
})
|
| 142 |
+
|
| 143 |
+
clearCookie(w, "oauth_state")
|
| 144 |
+
clearCookie(w, "pkce_verifier")
|
| 145 |
+
|
| 146 |
+
http.Redirect(w, r, os.Getenv("FRONTEND_ORIGIN"), http.StatusFound)
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
func Me(w http.ResponseWriter, r *http.Request) {
|
| 150 |
+
user, err := service.GetCurrentUser(r.Context())
|
| 151 |
+
if err != nil {
|
| 152 |
+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
| 153 |
+
return
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
w.Header().Set("Content-Type", "application/json")
|
| 157 |
+
json.NewEncoder(w).Encode(user)
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
func Logout(w http.ResponseWriter, r *http.Request) {
|
| 162 |
+
// 1. Delete local session
|
| 163 |
+
cookie, err := r.Cookie("session_id")
|
| 164 |
+
if err == nil {
|
| 165 |
+
_ = repository.DeleteSession(r.Context(), cookie.Value)
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
clearCookie := func(name string, httpOnly bool) {
|
| 169 |
+
http.SetCookie(w, &http.Cookie{
|
| 170 |
+
Name: name,
|
| 171 |
+
Value: "",
|
| 172 |
+
Path: "/",
|
| 173 |
+
HttpOnly: httpOnly,
|
| 174 |
+
Secure: true, // only HTTPS
|
| 175 |
+
MaxAge: -1,
|
| 176 |
+
SameSite: http.SameSiteNoneMode,
|
| 177 |
+
Domain: os.Getenv("COOKIE_DOMAIN"),
|
| 178 |
+
})
|
| 179 |
+
}
|
| 180 |
+
clearCookie("session_id", true)
|
| 181 |
+
clearCookie("XSRF-TOKEN", false)
|
| 182 |
+
|
| 183 |
+
logoutURL := fmt.Sprintf("%s/realms/%s/protocol/openid-connect/logout?redirect_uri=%s",
|
| 184 |
+
os.Getenv("KEYCLOAK_URL"),
|
| 185 |
+
os.Getenv("KEYCLOAK_REALM"),
|
| 186 |
+
url.QueryEscape(os.Getenv("FRONTEND_ORIGIN")),
|
| 187 |
+
)
|
| 188 |
+
http.Redirect(w, r, logoutURL, http.StatusFound)
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
func clearCookie(w http.ResponseWriter, name string) {
|
| 193 |
+
http.SetCookie(w, &http.Cookie{
|
| 194 |
+
Name: name,
|
| 195 |
+
Value: "",
|
| 196 |
+
MaxAge: -1,
|
| 197 |
+
Path: "/",
|
| 198 |
+
HttpOnly: true,
|
| 199 |
+
Secure: true,
|
| 200 |
+
SameSite: http.SameSiteNoneMode,
|
| 201 |
+
Domain: os.Getenv("COOKIE_DOMAIN"),
|
| 202 |
+
})
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
type tokenResponse struct {
|
| 206 |
+
AccessToken string `json:"access_token"`
|
| 207 |
+
RefreshToken string `json:"refresh_token"`
|
| 208 |
+
ExpiresIn int `json:"expires_in"`
|
| 209 |
+
IDToken string `json:"id_token"`
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
func exchangeCodeForToken(code, verifier string) (*tokenResponse, error) {
|
| 213 |
+
tokenURL := fmt.Sprintf("%s/realms/%s/protocol/openid-connect/token",
|
| 214 |
+
os.Getenv("KEYCLOAK_URL"),
|
| 215 |
+
os.Getenv("KEYCLOAK_REALM"),
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
data := url.Values{}
|
| 219 |
+
data.Set("grant_type", "authorization_code")
|
| 220 |
+
data.Set("client_id", os.Getenv("KEYCLOAK_CLIENT_ID"))
|
| 221 |
+
data.Set("client_secret", os.Getenv("KEYCLOAK_CLIENT_SECRET"))
|
| 222 |
+
data.Set("code", code)
|
| 223 |
+
data.Set("redirect_uri", os.Getenv("KEYCLOAK_REDIRECT_URL"))
|
| 224 |
+
data.Set("code_verifier", verifier)
|
| 225 |
+
|
| 226 |
+
resp, err := http.PostForm(tokenURL, data)
|
| 227 |
+
if err != nil {
|
| 228 |
+
return nil, err
|
| 229 |
+
}
|
| 230 |
+
defer resp.Body.Close()
|
| 231 |
+
|
| 232 |
+
body, _ := io.ReadAll(resp.Body)
|
| 233 |
+
if resp.StatusCode != http.StatusOK {
|
| 234 |
+
return nil, errors.New("token exchange failed: " + string(body))
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
var tr tokenResponse
|
| 238 |
+
if err := json.Unmarshal(body, &tr); err != nil {
|
| 239 |
+
return nil, err
|
| 240 |
+
}
|
| 241 |
+
return &tr, nil
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
func verifyIDToken(idToken string) (map[string]interface{}, error) {
|
| 245 |
+
claims, err := service.VerifyIDTokenJWKS(idToken)
|
| 246 |
+
if err != nil {
|
| 247 |
+
return nil, err
|
| 248 |
+
}
|
| 249 |
+
return claims, nil
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
func BackchannelLogout(w http.ResponseWriter, r *http.Request) {
|
| 253 |
+
ctx := r.Context()
|
| 254 |
+
|
| 255 |
+
if err := r.ParseForm(); err != nil {
|
| 256 |
+
http.Error(w, "Invalid form", http.StatusBadRequest)
|
| 257 |
+
return
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
logoutToken := r.FormValue("logout_token")
|
| 261 |
+
if logoutToken == "" {
|
| 262 |
+
http.Error(w, "Missing logout_token", http.StatusBadRequest)
|
| 263 |
+
return
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
claims, err := service.VerifyIDTokenJWKS(logoutToken)
|
| 267 |
+
if err != nil {
|
| 268 |
+
log.Println("Invalid logout token:", err)
|
| 269 |
+
http.Error(w, "Invalid token", http.StatusUnauthorized)
|
| 270 |
+
return
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
expectedIssuer := fmt.Sprintf("%s/realms/%s",
|
| 274 |
+
os.Getenv("KEYCLOAK_URL"),
|
| 275 |
+
os.Getenv("KEYCLOAK_REALM"),
|
| 276 |
+
)
|
| 277 |
+
if claims["iss"] != expectedIssuer {
|
| 278 |
+
http.Error(w, "Invalid issuer", http.StatusUnauthorized)
|
| 279 |
+
return
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
if claims["aud"] != os.Getenv("KEYCLOAK_CLIENT_ID") {
|
| 283 |
+
http.Error(w, "Invalid audience", http.StatusUnauthorized)
|
| 284 |
+
return
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
events, ok := claims["events"].(map[string]interface{})
|
| 288 |
+
if !ok {
|
| 289 |
+
http.Error(w, "Invalid logout token structure", http.StatusUnauthorized)
|
| 290 |
+
return
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
if _, ok := events["http://schemas.openid.net/event/backchannel-logout"]; !ok {
|
| 294 |
+
http.Error(w, "Not a backchannel logout event", http.StatusUnauthorized)
|
| 295 |
+
return
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
sid, ok := claims["sid"].(string)
|
| 299 |
+
if !ok || sid == "" {
|
| 300 |
+
http.Error(w, "Missing sid", http.StatusBadRequest)
|
| 301 |
+
return
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
if err := repository.DeleteByKeycloakSID(ctx, sid); err != nil {
|
| 305 |
+
if !errors.Is(err, mongo.ErrNoDocuments) {
|
| 306 |
+
log.Println("DB delete error:", err)
|
| 307 |
+
http.Error(w, "Internal error", http.StatusInternalServerError)
|
| 308 |
+
return
|
| 309 |
+
}
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
w.WriteHeader(http.StatusOK)
|
| 313 |
+
}
|
internal/auth/middleware/auth_middleware.go
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// internal/auth/middleware/auth_middleware.go
|
| 2 |
+
package middleware
|
| 3 |
+
|
| 4 |
+
import (
|
| 5 |
+
"context"
|
| 6 |
+
"net/http"
|
| 7 |
+
"time"
|
| 8 |
+
|
| 9 |
+
"server/internal/auth/service"
|
| 10 |
+
"server/internal/auth/repository"
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
func RequireAuthWithRefresh(next http.Handler) http.Handler {
|
| 14 |
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
| 15 |
+
cookie, err := r.Cookie("session_id")
|
| 16 |
+
if err != nil {
|
| 17 |
+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
| 18 |
+
return
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
ctx := r.Context()
|
| 22 |
+
session, err := service.ValidateSession(ctx, cookie.Value)
|
| 23 |
+
if err != nil {
|
| 24 |
+
http.SetCookie(w, &http.Cookie{Name: "session_id", Value: "", Path: "/", MaxAge: -1})
|
| 25 |
+
http.SetCookie(w, &http.Cookie{Name: "XSRF-TOKEN", Value: "", Path: "/", MaxAge: -1})
|
| 26 |
+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
| 27 |
+
return
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
if time.Now().After(session.AccessExpiresAt) {
|
| 31 |
+
refreshToken, err := service.DecryptToken(session.EncryptedRefresh)
|
| 32 |
+
if err != nil {
|
| 33 |
+
http.Error(w, "Invalid session tokens", http.StatusUnauthorized)
|
| 34 |
+
return
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
newAccess, newRefresh, newAccessExpiry, err := service.RefreshAccessToken(refreshToken)
|
| 38 |
+
if err != nil {
|
| 39 |
+
http.Error(w, "Failed to refresh access token", http.StatusUnauthorized)
|
| 40 |
+
return
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
encAccess, _ := service.EncryptToken(newAccess)
|
| 44 |
+
encRefresh, _ := service.EncryptToken(newRefresh)
|
| 45 |
+
|
| 46 |
+
session.EncryptedAccess = encAccess
|
| 47 |
+
session.EncryptedRefresh = encRefresh
|
| 48 |
+
session.AccessExpiresAt = newAccessExpiry
|
| 49 |
+
|
| 50 |
+
if err := repository.UpdateSession(ctx, session); err != nil {
|
| 51 |
+
http.Error(w, "Failed to update session", http.StatusInternalServerError)
|
| 52 |
+
return
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
ctx = context.WithValue(ctx, "current_session", session)
|
| 57 |
+
next.ServeHTTP(w, r.WithContext(ctx))
|
| 58 |
+
})
|
| 59 |
+
}
|
internal/auth/middleware/csrf_middleware.go
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// internal/auth/middleware/csrf_middleware.go
|
| 2 |
+
package middleware
|
| 3 |
+
|
| 4 |
+
import (
|
| 5 |
+
"log"
|
| 6 |
+
"net/http"
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
func RequireCSRF(next http.Handler) http.Handler {
|
| 10 |
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
| 11 |
+
|
| 12 |
+
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
|
| 13 |
+
next.ServeHTTP(w, r)
|
| 14 |
+
return
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
headerToken := r.Header.Get("X-XSRF-TOKEN")
|
| 18 |
+
cookie, err := r.Cookie("XSRF-TOKEN")
|
| 19 |
+
if err != nil || cookie.Value == "" {
|
| 20 |
+
http.Error(w, "CSRF cookie missing", http.StatusForbidden)
|
| 21 |
+
return
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
if headerToken == "" || headerToken != cookie.Value {
|
| 25 |
+
log.Println("header token: ", headerToken, "cookie value:", cookie.Value)
|
| 26 |
+
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
| 27 |
+
return
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
next.ServeHTTP(w, r)
|
| 31 |
+
})
|
| 32 |
+
}
|
internal/auth/models/session.go
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// internal/auth/models/session.go
|
| 2 |
+
package models
|
| 3 |
+
|
| 4 |
+
import "time"
|
| 5 |
+
|
| 6 |
+
type Session struct {
|
| 7 |
+
SessionID string `bson:"session_id"`
|
| 8 |
+
KeycloakSID string `bson:"keycloak_sid"`
|
| 9 |
+
UserID string `bson:"user_id"`
|
| 10 |
+
Email string `bson:"email"`
|
| 11 |
+
Username string `bson:"username"`
|
| 12 |
+
EncryptedAccess string `bson:"encrypted_access"`
|
| 13 |
+
EncryptedRefresh string `bson:"encrypted_refresh"`
|
| 14 |
+
AccessExpiresAt time.Time `bson:"access_expires_at"`
|
| 15 |
+
ExpiresAt time.Time `bson:"expires_at"`
|
| 16 |
+
}
|
internal/auth/repository/session_repository.go
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// internal/auth/repository/session_repository.go
|
| 2 |
+
package repository
|
| 3 |
+
|
| 4 |
+
import (
|
| 5 |
+
"context"
|
| 6 |
+
|
| 7 |
+
"server/internal/auth/models"
|
| 8 |
+
"server/internal/database"
|
| 9 |
+
"go.mongodb.org/mongo-driver/bson"
|
| 10 |
+
"go.mongodb.org/mongo-driver/mongo"
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
func Save(ctx context.Context, session models.Session) error {
|
| 14 |
+
_, err := database.DB.Collection("sessions").InsertOne(ctx, session)
|
| 15 |
+
return err
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
func Update(ctx context.Context, session models.Session) error {
|
| 19 |
+
_, err := database.DB.Collection("sessions").ReplaceOne(ctx,
|
| 20 |
+
bson.M{"session_id": session.SessionID}, session)
|
| 21 |
+
return err
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
func FindByID(ctx context.Context, id string) (*models.Session, error) {
|
| 25 |
+
var session models.Session
|
| 26 |
+
err := database.DB.Collection("sessions").
|
| 27 |
+
FindOne(ctx, bson.M{"session_id": id}).
|
| 28 |
+
Decode(&session)
|
| 29 |
+
if err != nil {
|
| 30 |
+
return nil, err
|
| 31 |
+
}
|
| 32 |
+
return &session, nil
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
func DeleteByID(ctx context.Context, id string) error {
|
| 36 |
+
_, err := database.DB.Collection("sessions").DeleteOne(ctx, bson.M{"session_id": id})
|
| 37 |
+
return err
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
func UpdateSession(ctx context.Context, session *models.Session) error {
|
| 41 |
+
_, err := database.DB.Collection("sessions").UpdateOne(
|
| 42 |
+
ctx,
|
| 43 |
+
bson.M{"session_id": session.SessionID},
|
| 44 |
+
bson.M{"$set": session},
|
| 45 |
+
)
|
| 46 |
+
return err
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
func DeleteSession(ctx context.Context, sessionID string) error {
|
| 50 |
+
res, err := database.DB.Collection("sessions").DeleteOne(ctx, bson.M{"session_id": sessionID})
|
| 51 |
+
if err != nil {
|
| 52 |
+
return err
|
| 53 |
+
}
|
| 54 |
+
if res.DeletedCount == 0 {
|
| 55 |
+
return mongo.ErrNoDocuments
|
| 56 |
+
}
|
| 57 |
+
return nil
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
func DeleteByKeycloakSID(ctx context.Context, sid string) error {
|
| 61 |
+
res, err := database.DB.Collection("sessions").
|
| 62 |
+
DeleteOne(ctx, bson.M{"keycloak_sid": sid})
|
| 63 |
+
if err != nil {
|
| 64 |
+
return err
|
| 65 |
+
}
|
| 66 |
+
if res.DeletedCount == 0 {
|
| 67 |
+
return mongo.ErrNoDocuments
|
| 68 |
+
}
|
| 69 |
+
return nil
|
| 70 |
+
}
|
internal/auth/service/auth_service.go
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// internal/auth/service/auth_service.go
|
| 2 |
+
package service
|
| 3 |
+
|
| 4 |
+
import (
|
| 5 |
+
"context"
|
| 6 |
+
"errors"
|
| 7 |
+
"time"
|
| 8 |
+
"encoding/json"
|
| 9 |
+
"io"
|
| 10 |
+
"net/http"
|
| 11 |
+
"net/url"
|
| 12 |
+
"os"
|
| 13 |
+
|
| 14 |
+
"server/internal/auth/dto"
|
| 15 |
+
"server/internal/auth/models"
|
| 16 |
+
"server/internal/auth/repository"
|
| 17 |
+
"server/internal/shared/crypto"
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
func RefreshAccessToken(refreshToken string) (string, string, time.Time, error) {
|
| 21 |
+
tokenURL := os.Getenv("KEYCLOAK_URL") + "/realms/" + os.Getenv("KEYCLOAK_REALM") + "/protocol/openid-connect/token"
|
| 22 |
+
|
| 23 |
+
data := url.Values{}
|
| 24 |
+
data.Set("grant_type", "refresh_token")
|
| 25 |
+
data.Set("client_id", os.Getenv("KEYCLOAK_CLIENT_ID"))
|
| 26 |
+
data.Set("client_secret", os.Getenv("KEYCLOAK_CLIENT_SECRET"))
|
| 27 |
+
data.Set("refresh_token", refreshToken)
|
| 28 |
+
|
| 29 |
+
resp, err := http.PostForm(tokenURL, data)
|
| 30 |
+
if err != nil {
|
| 31 |
+
return "", "", time.Time{}, err
|
| 32 |
+
}
|
| 33 |
+
defer resp.Body.Close()
|
| 34 |
+
|
| 35 |
+
body, _ := io.ReadAll(resp.Body)
|
| 36 |
+
if resp.StatusCode != http.StatusOK {
|
| 37 |
+
return "", "", time.Time{}, errors.New("failed to refresh token: " + string(body))
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
var tr struct {
|
| 41 |
+
AccessToken string `json:"access_token"`
|
| 42 |
+
RefreshToken string `json:"refresh_token"`
|
| 43 |
+
ExpiresIn int `json:"expires_in"`
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
if err := json.Unmarshal(body, &tr); err != nil {
|
| 47 |
+
return "", "", time.Time{}, err
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
newAccess := tr.AccessToken
|
| 51 |
+
newRefresh := tr.RefreshToken
|
| 52 |
+
expiry := time.Now().Add(time.Second * time.Duration(tr.ExpiresIn))
|
| 53 |
+
|
| 54 |
+
return newAccess, newRefresh, expiry, nil
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
func ValidateSession(ctx context.Context, sessionID string) (*models.Session, error) {
|
| 58 |
+
session, err := repository.FindByID(ctx, sessionID)
|
| 59 |
+
if err != nil {
|
| 60 |
+
return nil, errors.New("session not found")
|
| 61 |
+
}
|
| 62 |
+
if session.ExpiresAt.Before(time.Now()) {
|
| 63 |
+
return nil, errors.New("session expired")
|
| 64 |
+
}
|
| 65 |
+
return session, nil
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
func GetCurrentUser(ctx context.Context) (*dto.MeResponse, error) {
|
| 69 |
+
session, err := SessionFromContext(ctx)
|
| 70 |
+
if err != nil {
|
| 71 |
+
return nil, err
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
return &dto.MeResponse{
|
| 75 |
+
ID: session.UserID,
|
| 76 |
+
Email: session.Email,
|
| 77 |
+
Username: session.Username,
|
| 78 |
+
}, nil
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
func EncryptToken(token string) (string, error) {
|
| 82 |
+
return crypto.Encrypt(token)
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
func DecryptToken(enc string) (string, error) {
|
| 86 |
+
return crypto.Decrypt(enc)
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
func SessionFromContext(ctx context.Context) (*models.Session, error) {
|
| 90 |
+
session, ok := ctx.Value("current_session").(*models.Session)
|
| 91 |
+
if !ok || session == nil {
|
| 92 |
+
return nil, errors.New("session not found in context")
|
| 93 |
+
}
|
| 94 |
+
return session, nil
|
| 95 |
+
}
|
internal/auth/service/jwks.go
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// internal/auth/service/jwks.go
|
| 2 |
+
package service
|
| 3 |
+
|
| 4 |
+
import (
|
| 5 |
+
"errors"
|
| 6 |
+
"net/http"
|
| 7 |
+
"os"
|
| 8 |
+
"time"
|
| 9 |
+
|
| 10 |
+
"github.com/MicahParks/keyfunc"
|
| 11 |
+
"github.com/golang-jwt/jwt/v4"
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
var JWKSClient *keyfunc.JWKS
|
| 15 |
+
|
| 16 |
+
func InitializeJWKS() error {
|
| 17 |
+
issuer := os.Getenv("KEYCLOAK_URL") + "/realms/" + os.Getenv("KEYCLOAK_REALM")
|
| 18 |
+
jwksURL := issuer + "/protocol/openid-connect/certs"
|
| 19 |
+
|
| 20 |
+
var err error
|
| 21 |
+
JWKSClient, err = keyfunc.Get(jwksURL, keyfunc.Options{
|
| 22 |
+
RefreshInterval: time.Hour, // refresh every hour
|
| 23 |
+
RefreshErrorHandler: func(err error) {},
|
| 24 |
+
RefreshTimeout: 10 * time.Second,
|
| 25 |
+
Client: &http.Client{Timeout: 10 * time.Second},
|
| 26 |
+
})
|
| 27 |
+
if err != nil {
|
| 28 |
+
return errors.New("failed to get JWKS: " + err.Error())
|
| 29 |
+
}
|
| 30 |
+
return nil
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
func VerifyIDTokenJWKS(idToken string) (jwt.MapClaims, error) {
|
| 34 |
+
if JWKSClient == nil {
|
| 35 |
+
if err := InitializeJWKS(); err != nil {
|
| 36 |
+
return nil, err
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
token, err := jwt.Parse(idToken, JWKSClient.Keyfunc)
|
| 41 |
+
if err != nil {
|
| 42 |
+
return nil, err
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
claims, ok := token.Claims.(jwt.MapClaims)
|
| 46 |
+
if !ok || !token.Valid {
|
| 47 |
+
return nil, errors.New("invalid token")
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
now := time.Now().Unix()
|
| 51 |
+
skew := int64(60) // allow 60 seconds drift
|
| 52 |
+
|
| 53 |
+
if !claims.VerifyExpiresAt(now-skew, true) {
|
| 54 |
+
return nil, errors.New("token expired")
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
nbf, ok := claims["nbf"].(float64)
|
| 58 |
+
if ok && int64(nbf) > now+skew {
|
| 59 |
+
return nil, errors.New("token not valid yet (nbf)")
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
iat, ok := claims["iat"].(float64)
|
| 63 |
+
if !ok {
|
| 64 |
+
return nil, errors.New("iat claim missing or invalid")
|
| 65 |
+
}
|
| 66 |
+
if int64(iat) > now+skew {
|
| 67 |
+
return nil, errors.New("token issued in the future (iat)")
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
issuer := os.Getenv("KEYCLOAK_URL") + "/realms/" + os.Getenv("KEYCLOAK_REALM")
|
| 71 |
+
clientID := os.Getenv("KEYCLOAK_CLIENT_ID")
|
| 72 |
+
|
| 73 |
+
if claims["iss"] != issuer {
|
| 74 |
+
return nil, errors.New("invalid issuer")
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
if aud, ok := claims["aud"].(string); ok {
|
| 78 |
+
if aud != clientID {
|
| 79 |
+
return nil, errors.New("invalid audience")
|
| 80 |
+
}
|
| 81 |
+
} else if audArray, ok := claims["aud"].([]interface{}); ok {
|
| 82 |
+
valid := false
|
| 83 |
+
for _, a := range audArray {
|
| 84 |
+
if a.(string) == clientID {
|
| 85 |
+
valid = true
|
| 86 |
+
break
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
if !valid {
|
| 90 |
+
return nil, errors.New("invalid audience")
|
| 91 |
+
}
|
| 92 |
+
} else {
|
| 93 |
+
return nil, errors.New("invalid audience format")
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
return claims, nil
|
| 97 |
+
}
|
internal/config/config.go
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// internal/config/config.go
|
| 2 |
+
package config
|
| 3 |
+
|
| 4 |
+
import (
|
| 5 |
+
"log"
|
| 6 |
+
"os"
|
| 7 |
+
|
| 8 |
+
"github.com/joho/godotenv"
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
func Load() {
|
| 12 |
+
if err := godotenv.Load(); err != nil {
|
| 13 |
+
log.Println("No .env file found, using environment variables")
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
required := []string{
|
| 17 |
+
"PORT", "MONGO_URI", "MONGO_DB", "KEYCLOAK_URL",
|
| 18 |
+
"KEYCLOAK_REALM", "KEYCLOAK_CLIENT_ID", "KEYCLOAK_CLIENT_SECRET",
|
| 19 |
+
"KEYCLOAK_REDIRECT_URL", "SESSION_SECRET", "CSRF_SECRET",
|
| 20 |
+
"COOKIE_DOMAIN", "FRONTEND_ORIGIN", "SESSION_TTL_HOURS",
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
for _, k := range required {
|
| 24 |
+
if os.Getenv(k) == "" {
|
| 25 |
+
log.Fatalf("Environment variable %s is required", k)
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
}
|
internal/database/mongodb.go
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// internal/database/mongodb.go
|
| 2 |
+
package database
|
| 3 |
+
|
| 4 |
+
import (
|
| 5 |
+
"context"
|
| 6 |
+
"crypto/tls"
|
| 7 |
+
"log"
|
| 8 |
+
"os"
|
| 9 |
+
"time"
|
| 10 |
+
|
| 11 |
+
"go.mongodb.org/mongo-driver/mongo"
|
| 12 |
+
"go.mongodb.org/mongo-driver/mongo/options"
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
var DB *mongo.Database
|
| 16 |
+
|
| 17 |
+
func Connect() {
|
| 18 |
+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
| 19 |
+
defer cancel()
|
| 20 |
+
|
| 21 |
+
clientOpts := options.Client().
|
| 22 |
+
ApplyURI(os.Getenv("MONGO_URI")).
|
| 23 |
+
SetTLSConfig(&tls.Config{MinVersion: tls.VersionTLS12})
|
| 24 |
+
|
| 25 |
+
client, err := mongo.Connect(ctx, clientOpts)
|
| 26 |
+
if err != nil {
|
| 27 |
+
log.Fatal(err)
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
DB = client.Database(os.Getenv("MONGO_DB"))
|
| 31 |
+
|
| 32 |
+
coll := DB.Collection("sessions")
|
| 33 |
+
indexOpts := options.Index().SetExpireAfterSeconds(0)
|
| 34 |
+
_, err = coll.Indexes().CreateOne(ctx, mongo.IndexModel{
|
| 35 |
+
Keys: map[string]interface{}{"expires_at": 1},
|
| 36 |
+
Options: indexOpts,
|
| 37 |
+
})
|
| 38 |
+
if err != nil {
|
| 39 |
+
log.Fatal("Failed to create TTL index: ", err)
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
_, err = coll.Indexes().CreateOne(ctx, mongo.IndexModel{
|
| 43 |
+
Keys: map[string]interface{}{"session_id": 1},
|
| 44 |
+
Options: options.Index().SetUnique(true),
|
| 45 |
+
})
|
| 46 |
+
if err != nil {
|
| 47 |
+
log.Fatal("Failed to create session_id index: ", err)
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
log.Println("Connected to MongoDB")
|
| 51 |
+
}
|
internal/shared/crypto/encryption.go
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// internal/shared/crypto/encryption.go
|
| 2 |
+
package crypto
|
| 3 |
+
|
| 4 |
+
import (
|
| 5 |
+
"crypto/aes"
|
| 6 |
+
"crypto/cipher"
|
| 7 |
+
"crypto/rand"
|
| 8 |
+
"encoding/base64"
|
| 9 |
+
"errors"
|
| 10 |
+
"io"
|
| 11 |
+
"os"
|
| 12 |
+
"crypto/sha256"
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
func Encrypt(plain string) (string, error) {
|
| 16 |
+
key := []byte(os.Getenv("SESSION_SECRET"))
|
| 17 |
+
if len(key) != 32 {
|
| 18 |
+
return "", errors.New("SESSION_SECRET must be 32 bytes")
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
block, err := aes.NewCipher(key)
|
| 22 |
+
if err != nil {
|
| 23 |
+
return "", err
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
gcm, err := cipher.NewGCM(block)
|
| 27 |
+
if err != nil {
|
| 28 |
+
return "", err
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
nonce := make([]byte, gcm.NonceSize())
|
| 32 |
+
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
| 33 |
+
return "", err
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
ciphertext := gcm.Seal(nonce, nonce, []byte(plain), nil)
|
| 37 |
+
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
func Decrypt(enc string) (string, error) {
|
| 41 |
+
key := []byte(os.Getenv("SESSION_SECRET"))
|
| 42 |
+
data, err := base64.StdEncoding.DecodeString(enc)
|
| 43 |
+
if err != nil {
|
| 44 |
+
return "", err
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
block, err := aes.NewCipher(key)
|
| 48 |
+
if err != nil {
|
| 49 |
+
return "", err
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
gcm, err := cipher.NewGCM(block)
|
| 53 |
+
if err != nil {
|
| 54 |
+
return "", err
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
nonceSize := gcm.NonceSize()
|
| 58 |
+
if len(data) < nonceSize {
|
| 59 |
+
return "", errors.New("invalid ciphertext")
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
|
| 63 |
+
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
|
| 64 |
+
if err != nil {
|
| 65 |
+
return "", err
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
return string(plain), nil
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
func SHA256Base64URL(input string) string {
|
| 72 |
+
hash := sha256.Sum256([]byte(input))
|
| 73 |
+
return base64.RawURLEncoding.EncodeToString(hash[:])
|
| 74 |
+
}
|
internal/shared/middleware/cors.go
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// internal/shared/middleware/cors.go
|
| 2 |
+
package middleware
|
| 3 |
+
|
| 4 |
+
import (
|
| 5 |
+
"net/http"
|
| 6 |
+
"os"
|
| 7 |
+
|
| 8 |
+
"github.com/go-chi/cors"
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
func CORS() func(http.Handler) http.Handler {
|
| 12 |
+
return cors.Handler(cors.Options{
|
| 13 |
+
AllowedOrigins: []string{os.Getenv("FRONTEND_ORIGIN")},
|
| 14 |
+
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
| 15 |
+
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-XSRF-TOKEN"},
|
| 16 |
+
ExposedHeaders: []string{"X-XSRF-TOKEN"},
|
| 17 |
+
AllowCredentials: true,
|
| 18 |
+
MaxAge: 300,
|
| 19 |
+
})
|
| 20 |
+
}
|
internal/shared/middleware/rate_limiter.go
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// internal/shared/middleware/rate_limiter.go
|
| 2 |
+
package middleware
|
| 3 |
+
|
| 4 |
+
import (
|
| 5 |
+
"net"
|
| 6 |
+
"net/http"
|
| 7 |
+
"sync"
|
| 8 |
+
|
| 9 |
+
"golang.org/x/time/rate"
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
var (
|
| 13 |
+
ips = make(map[string]*rate.Limiter)
|
| 14 |
+
mu sync.Mutex
|
| 15 |
+
rateLimit = rate.Limit(5)
|
| 16 |
+
burst = 10
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
func getLimiter(ip string) *rate.Limiter {
|
| 20 |
+
mu.Lock()
|
| 21 |
+
defer mu.Unlock()
|
| 22 |
+
if limiter, exists := ips[ip]; exists {
|
| 23 |
+
return limiter
|
| 24 |
+
}
|
| 25 |
+
limiter := rate.NewLimiter(rateLimit, burst)
|
| 26 |
+
ips[ip] = limiter
|
| 27 |
+
return limiter
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
func RateLimiter(next http.Handler) http.Handler {
|
| 31 |
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
| 32 |
+
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
|
| 33 |
+
limiter := getLimiter(ip)
|
| 34 |
+
if !limiter.Allow() {
|
| 35 |
+
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
|
| 36 |
+
return
|
| 37 |
+
}
|
| 38 |
+
next.ServeHTTP(w, r)
|
| 39 |
+
})
|
| 40 |
+
}
|
internal/shared/middleware/security_headers.go
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// internal/shared/middleware/security_headers.go
|
| 2 |
+
package middleware
|
| 3 |
+
|
| 4 |
+
import "net/http"
|
| 5 |
+
|
| 6 |
+
func SecurityHeaders(next http.Handler) http.Handler {
|
| 7 |
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
| 8 |
+
|
| 9 |
+
w.Header().Set("X-Frame-Options", "DENY")
|
| 10 |
+
w.Header().Set("X-Content-Type-Options", "nosniff")
|
| 11 |
+
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
| 12 |
+
w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
|
| 13 |
+
w.Header().Set("Content-Security-Policy", "default-src 'self'")
|
| 14 |
+
|
| 15 |
+
next.ServeHTTP(w, r)
|
| 16 |
+
})
|
| 17 |
+
}
|
internal/shared/utils/random.go
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// internal/shared/utils/random.go
|
| 2 |
+
package utils
|
| 3 |
+
|
| 4 |
+
import (
|
| 5 |
+
"crypto/rand"
|
| 6 |
+
"encoding/base64"
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
func SecureRandom(n int) (string, error) {
|
| 10 |
+
b := make([]byte, n)
|
| 11 |
+
_, err := rand.Read(b)
|
| 12 |
+
if err != nil {
|
| 13 |
+
return "", err
|
| 14 |
+
}
|
| 15 |
+
return base64.RawURLEncoding.EncodeToString(b), nil
|
| 16 |
+
}
|