diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 6c3d52c..f0a30a8 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -11,6 +11,17 @@ services: retries: 5 start_period: 20s + redis: + image: redis:8 + restart: unless-stopped + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + server: build: context: ../server @@ -21,14 +32,21 @@ services: - "9090:9090" environment: MONGO_URI: mongodb://mongo:27017/keymanager + REDIS_ADDR: redis:6379 GITEA_HOST: ${GITEA_HOST} PUBLIC_HOST: ${PUBLIC_HOST} GRPC_HOST: ${GRPC_HOST} GRPC_PORT: "9090" HTTP_PORT: "8080" + OIDC_ISSUER: ${OIDC_ISSUER:-} + OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} + OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-} + OIDC_REDIRECT_URL: ${OIDC_REDIRECT_URL:-} depends_on: mongo: condition: service_healthy + redis: + condition: service_healthy web: build: @@ -44,3 +62,4 @@ services: volumes: mongo_data: + redis_data: diff --git a/server/cmd/main.go b/server/cmd/main.go index a2e12d9..c18abf6 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -1,12 +1,14 @@ package main import ( + "context" "log" "os" "time" "github.com/gin-gonic/gin" "github.com/mrhid6/keymanager/server/internal/api" + "github.com/mrhid6/keymanager/server/internal/auth" "github.com/mrhid6/keymanager/server/internal/db" grpcserver "github.com/mrhid6/keymanager/server/internal/grpc" "github.com/mrhid6/keymanager/server/internal/services" @@ -21,6 +23,16 @@ func main() { } log.Println("connected to MongoDB") + redisAddr := getEnv("REDIS_ADDR", "localhost:6379") + if err := auth.InitRedis(redisAddr); err != nil { + log.Fatalf("failed to connect to Redis: %v", err) + } + log.Println("connected to Redis") + + if err := auth.InitOIDC(context.Background()); err != nil { + log.Fatalf("failed to initialise OIDC: %v", err) + } + // Background goroutine to mark offline servers go func() { ticker := time.NewTicker(2 * time.Minute) diff --git a/server/go.mod b/server/go.mod index d126f4a..83305ff 100644 --- a/server/go.mod +++ b/server/go.mod @@ -3,19 +3,24 @@ module github.com/mrhid6/keymanager/server go 1.26 require ( + github.com/coreos/go-oidc/v3 v3.18.0 github.com/gin-gonic/gin v1.10.0 github.com/google/uuid v1.6.0 + github.com/redis/go-redis/v9 v9.20.1 go.mongodb.org/mongo-driver/v2 v2.2.2 + golang.org/x/oauth2 v0.36.0 google.golang.org/grpc v1.64.0 ) require ( github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect @@ -23,7 +28,7 @@ require ( github.com/golang/snappy v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.16.7 // indirect - github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect @@ -35,6 +40,7 @@ require ( github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.uber.org/atomic v1.11.0 // indirect golang.org/x/arch v0.8.0 // indirect golang.org/x/crypto v0.33.0 // indirect golang.org/x/net v0.25.0 // indirect diff --git a/server/go.sum b/server/go.sum index 685f143..62874e0 100644 --- a/server/go.sum +++ b/server/go.sum @@ -1,11 +1,19 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= +github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -15,6 +23,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -37,8 +47,8 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= -github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= @@ -53,6 +63,8 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6 github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsTB1w= +github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -78,8 +90,12 @@ github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gi github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.mongodb.org/mongo-driver/v2 v2.2.2 h1:9cYuS3fl1Xhqwpfazso10V7BHQD58kCgtzhfAmJYz9c= go.mongodb.org/mongo-driver/v2 v2.2.2/go.mod h1:qQkDMhCGWl3FN509DfdPd4GRBLU/41zqF/k8eTRceps= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= @@ -93,6 +109,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= @@ -102,7 +120,6 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/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= diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index d306e61..7c740c8 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -6,6 +6,7 @@ import ( "os" "github.com/gin-gonic/gin" + "github.com/mrhid6/keymanager/server/internal/auth" "github.com/mrhid6/keymanager/server/internal/models" "github.com/mrhid6/keymanager/server/internal/services" ) @@ -13,21 +14,30 @@ import ( func RegisterRoutes(r *gin.Engine) { r.GET("/install", handleInstallScript) - api := r.Group("/api") - { - api.GET("/servers", listServers) - api.POST("/servers", createServer) - api.GET("/servers/new", newServer) - api.POST("/servers/new", newServer) - api.GET("/servers/:id", getServer) - api.DELETE("/servers/:id", deleteServer) - api.POST("/servers/:id/generate-key", generateKey) + // Auth endpoints (no session required) + r.GET("/auth/login", auth.HandleLogin) + r.GET("/auth/callback", auth.HandleCallback) + r.GET("/auth/logout", auth.HandleLogout) + r.GET("/auth/me", auth.HandleMe) - api.GET("/keys", listKeys) - api.POST("/keys", createKey) - api.GET("/keys/:id", getKey) - api.POST("/keys/:id/assign", assignKey) - api.DELETE("/keys/:id/assign/:serverId", revokeAssignment) + // API endpoints protected by session middleware + apiGroup := r.Group("/api") + apiGroup.Use(auth.Middleware()) + { + apiGroup.GET("/servers", listServers) + apiGroup.POST("/servers", createServer) + apiGroup.GET("/servers/new", newServer) + apiGroup.POST("/servers/new", newServer) + apiGroup.GET("/servers/:id", getServer) + apiGroup.DELETE("/servers/:id", deleteServer) + apiGroup.POST("/servers/:id/generate-key", generateKey) + + apiGroup.GET("/keys", listKeys) + apiGroup.POST("/keys", createKey) + apiGroup.GET("/keys/:id", getKey) + apiGroup.DELETE("/keys/:id", deleteKey) + apiGroup.POST("/keys/:id/assign", assignKey) + apiGroup.DELETE("/keys/:id/assign/:serverId", revokeAssignment) } } @@ -163,12 +173,26 @@ func getKey(c *gin.Context) { } assignments, _ := services.GetAssignmentsWithServers(id) - c.JSON(http.StatusOK, gin.H{ - "key": key, - "assignments": assignments, + + type keyResponse struct { + *models.Key + Assignments any `json:"assignments"` + } + c.JSON(http.StatusOK, keyResponse{ + Key: key, + Assignments: assignments, }) } +func deleteKey(c *gin.Context) { + id := c.Param("id") + if err := services.DeleteKey(id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"deleted": true}) +} + func assignKey(c *gin.Context) { keyID := c.Param("id") var body struct { diff --git a/server/internal/auth/middleware.go b/server/internal/auth/middleware.go new file mode 100644 index 0000000..40e2c9d --- /dev/null +++ b/server/internal/auth/middleware.go @@ -0,0 +1,39 @@ +package auth + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +const ctxSessionKey = "km_session" + +func GetSessionFromContext(c *gin.Context) *Session { + v, _ := c.Get(ctxSessionKey) + sess, _ := v.(*Session) + return sess +} + +func Middleware() gin.HandlerFunc { + return func(c *gin.Context) { + if !authEnabled { + c.Next() + return + } + + cookie, err := c.Request.Cookie(sessionCookieName) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"}) + return + } + + sess, err := GetSession(c.Request.Context(), cookie.Value) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session expired"}) + return + } + + c.Set(ctxSessionKey, sess) + c.Next() + } +} diff --git a/server/internal/auth/oidc.go b/server/internal/auth/oidc.go new file mode 100644 index 0000000..5387ae7 --- /dev/null +++ b/server/internal/auth/oidc.go @@ -0,0 +1,154 @@ +package auth + +import ( + "context" + "log" + "net/http" + "os" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/gin-gonic/gin" + "golang.org/x/oauth2" +) + +var ( + oidcProvider *oidc.Provider + oauth2Cfg *oauth2.Config + authEnabled bool +) + +func InitOIDC(ctx context.Context) error { + issuer := os.Getenv("OIDC_ISSUER") + if issuer == "" { + log.Println("OIDC_ISSUER not set; authentication disabled") + return nil + } + + p, err := oidc.NewProvider(ctx, issuer) + if err != nil { + return err + } + oidcProvider = p + oauth2Cfg = &oauth2.Config{ + ClientID: os.Getenv("OIDC_CLIENT_ID"), + ClientSecret: os.Getenv("OIDC_CLIENT_SECRET"), + RedirectURL: os.Getenv("OIDC_REDIRECT_URL"), + Endpoint: p.Endpoint(), + Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + } + authEnabled = true + log.Println("OIDC authentication enabled") + return nil +} + +func Enabled() bool { return authEnabled } + +func HandleLogin(c *gin.Context) { + state, err := randomHex(16) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "state generation failed"}) + return + } + if err := SaveState(c.Request.Context(), state); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "state save failed"}) + return + } + c.Redirect(http.StatusFound, oauth2Cfg.AuthCodeURL(state)) +} + +func HandleCallback(c *gin.Context) { + ctx := c.Request.Context() + + if !ConsumeState(ctx, c.Query("state")) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state"}) + return + } + + token, err := oauth2Cfg.Exchange(ctx, c.Query("code")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "token exchange failed"}) + return + } + + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + c.JSON(http.StatusInternalServerError, gin.H{"error": "missing id_token"}) + return + } + + verifier := oidcProvider.Verifier(&oidc.Config{ClientID: oauth2Cfg.ClientID}) + idToken, err := verifier.Verify(ctx, rawIDToken) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "token verification failed"}) + return + } + + var claims struct { + Sub string `json:"sub"` + Email string `json:"email"` + Name string `json:"name"` + } + if err := idToken.Claims(&claims); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "claims extraction failed"}) + return + } + + sessionID, err := SaveSession(ctx, &Session{ + UserID: claims.Sub, + Email: claims.Email, + Name: claims.Name, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"}) + return + } + + secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" + http.SetCookie(c.Writer, &http.Cookie{ + Name: sessionCookieName, + Value: sessionID, + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + MaxAge: int(sessionTTL.Seconds()), + }) + + frontendURL := os.Getenv("PUBLIC_HOST") + if frontendURL == "" { + frontendURL = "/" + } + c.Redirect(http.StatusFound, frontendURL) +} + +func HandleLogout(c *gin.Context) { + if cookie, err := c.Request.Cookie(sessionCookieName); err == nil { + _ = DeleteSession(c.Request.Context(), cookie.Value) + } + http.SetCookie(c.Writer, &http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + HttpOnly: true, + MaxAge: -1, + }) + c.Redirect(http.StatusFound, "/") +} + +func HandleMe(c *gin.Context) { + if !authEnabled { + c.JSON(http.StatusOK, gin.H{"auth_enabled": false}) + return + } + cookie, err := c.Request.Cookie(sessionCookieName) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"}) + return + } + sess, err := GetSession(c.Request.Context(), cookie.Value) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "session expired"}) + return + } + c.JSON(http.StatusOK, sess) +} diff --git a/server/internal/auth/session.go b/server/internal/auth/session.go new file mode 100644 index 0000000..65b48b7 --- /dev/null +++ b/server/internal/auth/session.go @@ -0,0 +1,79 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "time" + + "github.com/redis/go-redis/v9" +) + +const sessionTTL = 24 * time.Hour +const sessionCookieName = "km_session" +const sessionPrefix = "km:session:" +const statePrefix = "km:state:" + +type Session struct { + UserID string `json:"user_id"` + Email string `json:"email"` + Name string `json:"name"` +} + +var rdb *redis.Client + +func InitRedis(addr string) error { + rdb = redis.NewClient(&redis.Options{Addr: addr}) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return rdb.Ping(ctx).Err() +} + +func randomHex(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func SaveSession(ctx context.Context, sess *Session) (string, error) { + id, err := randomHex(32) + if err != nil { + return "", err + } + data, err := json.Marshal(sess) + if err != nil { + return "", err + } + if err := rdb.Set(ctx, sessionPrefix+id, data, sessionTTL).Err(); err != nil { + return "", err + } + return id, nil +} + +func GetSession(ctx context.Context, id string) (*Session, error) { + data, err := rdb.Get(ctx, sessionPrefix+id).Bytes() + if err != nil { + return nil, err + } + var sess Session + if err := json.Unmarshal(data, &sess); err != nil { + return nil, err + } + return &sess, nil +} + +func DeleteSession(ctx context.Context, id string) error { + return rdb.Del(ctx, sessionPrefix+id).Err() +} + +func SaveState(ctx context.Context, state string) error { + return rdb.Set(ctx, statePrefix+state, "1", 10*time.Minute).Err() +} + +func ConsumeState(ctx context.Context, state string) bool { + n, err := rdb.Del(ctx, statePrefix+state).Result() + return err == nil && n > 0 +} diff --git a/server/internal/services/keys.go b/server/internal/services/keys.go index 92e05c2..5200737 100644 --- a/server/internal/services/keys.go +++ b/server/internal/services/keys.go @@ -63,7 +63,12 @@ func GetKey(keyID string) (*models.Key, error) { return &key, nil } -func ListKeys() ([]models.Key, error) { +type KeyWithCount struct { + models.Key `bson:",inline"` + AssignedCount int `bson:"-" json:"assigned_count"` +} + +func ListKeys() ([]KeyWithCount, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -77,7 +82,16 @@ func ListKeys() ([]models.Key, error) { if err := cursor.All(ctx, &keys); err != nil { return nil, err } - return keys, nil + + result := make([]KeyWithCount, 0, len(keys)) + for _, k := range keys { + count, _ := db.Col("assignments").CountDocuments(ctx, bson.M{ + "key_id": k.KeyID, + "revoked_at": nil, + }) + result = append(result, KeyWithCount{Key: k, AssignedCount: int(count)}) + } + return result, nil } func DeleteKey(keyID string) error { diff --git a/web/app/layout.tsx b/web/app/layout.tsx index b43561d..2f1cd55 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import "./globals.css"; import { Providers } from "@/components/Providers"; +import { AuthProvider } from "@/components/AuthProvider"; import { Sidebar } from "@/components/Sidebar"; export const metadata: Metadata = { @@ -17,12 +18,14 @@ export default function RootLayout({