From 68c24540ca5edd3f9f09ad1413f3ef6582132e12 Mon Sep 17 00:00:00 2001 From: motoki317 Date: Mon, 24 Jul 2023 19:47:41 +0900 Subject: [PATCH 01/14] impl oidc --- cmd/config.go | 7 ++ cmd/serve.go | 2 +- cmd/serve_wire.go | 1 + cmd/wire_gen.go | 2 + go.mod | 8 +- go.sum | 20 +++- migration/current.go | 1 + migration/v35.go | 48 +++++++++ model/oauth2.go | 15 ++- router/config.go | 3 + router/oauth2/authorization_endpoint.go | 18 ++-- router/oauth2/authorization_endpoint_test.go | 2 +- router/oauth2/oauth2.go | 18 ++++ router/oauth2/oidc.go | 31 ++++++ router/oauth2/token_endpoint.go | 103 ++++++++++--------- router/router.go | 4 + router/v3/public.go | 10 ++ router/v3/router.go | 4 + router/v3/users.go | 9 ++ router/wire_gen.go | 3 + service/oidc/userinfo.go | 65 ++++++++++++ service/rbac/role/profile.go | 12 +++ service/rbac/role/role.go | 5 + service/services.go | 2 + service/services_wire.go | 1 + utils/jwt/signer.go | 24 ++++- utils/utils.go | 11 ++ 27 files changed, 363 insertions(+), 66 deletions(-) create mode 100644 migration/v35.go create mode 100644 router/oauth2/oidc.go create mode 100644 service/oidc/userinfo.go create mode 100644 service/rbac/role/profile.go diff --git a/cmd/config.go b/cmd/config.go index e6fee2858..7aed276c2 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -21,6 +21,8 @@ import ( "github.com/traPtitech/traQ/service/fcm" "github.com/traPtitech/traQ/service/imaging" "github.com/traPtitech/traQ/service/message" + "github.com/traPtitech/traQ/service/oidc" + "github.com/traPtitech/traQ/service/rbac" "github.com/traPtitech/traQ/service/search" "github.com/traPtitech/traQ/service/variable" "github.com/traPtitech/traQ/utils/storage" @@ -469,6 +471,10 @@ func provideImageProcessorConfig(c *Config) imaging.Config { } } +func provideOIDCService(c *Config, repo repository.Repository, rbac rbac.RBAC) *oidc.Service { + return oidc.NewOIDCService(repo, c.Origin, rbac) +} + func provideAuthGithubProviderConfig(c *Config) auth.GithubProviderConfig { return auth.GithubProviderConfig{ ClientID: c.ExternalAuth.GitHub.ClientID, @@ -530,6 +536,7 @@ func provideRouterExternalAuthConfig(c *Config) router.ExternalAuthConfig { func provideRouterConfig(c *Config) *router.Config { return &router.Config{ + Origin: c.Origin, Development: c.DevMode, Version: Version, Revision: Revision, diff --git a/cmd/serve.go b/cmd/serve.go index ac3d504b7..ef1485b16 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -81,7 +81,7 @@ func serveCommand() *cobra.Command { } logger.Info("repository was set up") - // JWT for QRCode + // JWT if priv := c.JWT.Keys.Private; priv != "" { privRaw, err := os.ReadFile(priv) if err != nil { diff --git a/cmd/serve_wire.go b/cmd/serve_wire.go index 6c3c5cbea..b4ed25efc 100644 --- a/cmd/serve_wire.go +++ b/cmd/serve_wire.go @@ -54,6 +54,7 @@ func newServer(hub *hub.Hub, db *gorm.DB, repo repository.Repository, fs storage provideServerOriginString, provideFirebaseCredentialsFilePathString, provideImageProcessorConfig, + provideOIDCService, provideRouterConfig, provideESEngineConfig, wire.Struct(new(service.Services), "*"), diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 809230bb5..bd0672ae8 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -85,6 +85,7 @@ func newServer(hub2 *hub.Hub, db *gorm.DB, repo repository.Repository, fs storag if err != nil { return nil, err } + oidcService := provideOIDCService(c2, repo, rbacRBAC) esEngineConfig := provideESEngineConfig(c2) engine, err := initSearchServiceIfAvailable(messageManager, manager, repo, logger, esEngineConfig) if err != nil { @@ -104,6 +105,7 @@ func newServer(hub2 *hub.Hub, db *gorm.DB, repo repository.Repository, fs storag MessageManager: messageManager, Notification: notificationService, OGP: ogpService, + OIDC: oidcService, RBAC: rbacRBAC, Search: engine, ViewerManager: viewerManager, diff --git a/go.mod b/go.mod index ce3c40371..934fbb1d1 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.22.2 require ( cloud.google.com/go/profiler v0.4.0 firebase.google.com/go v3.13.0+incompatible + github.com/MicahParks/jwkset v0.3.1 github.com/NYTimes/gziphandler v1.1.1 github.com/aws/aws-sdk-go-v2 v1.26.1 github.com/aws/aws-sdk-go-v2/config v1.27.11 @@ -50,6 +51,7 @@ require ( github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.18.2 github.com/stretchr/testify v1.9.0 + github.com/zitadel/oidc v1.13.4 go.uber.org/zap v1.27.0 golang.org/x/crypto v0.23.0 golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 @@ -127,6 +129,7 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect github.com/googleapis/gax-go/v2 v2.12.4 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hpcloud/tail v1.0.0 // indirect github.com/imdario/mergo v0.3.12 // indirect @@ -157,8 +160,8 @@ require ( github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sanity-io/litter v1.5.5 // indirect - github.com/sergi/go-diff v1.0.0 // indirect - github.com/sirupsen/logrus v1.8.1 // indirect + github.com/sergi/go-diff v1.2.0 // indirect + github.com/sirupsen/logrus v1.9.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect @@ -192,6 +195,7 @@ require ( google.golang.org/protobuf v1.34.1 // indirect gopkg.in/fsnotify.v1 v1.4.7 // indirect gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/square/go-jose.v2 v2.6.0 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect moul.io/http2curl/v2 v2.3.0 // indirect diff --git a/go.sum b/go.sum index 09a3ba7f5..2585d1b98 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,8 @@ firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIw github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/MicahParks/jwkset v0.3.1 h1:DIVazR/elD8CLWPblrVo610TzovIDYMcvlM4X0UT0vQ= +github.com/MicahParks/jwkset v0.3.1/go.mod h1:Ob0sxSgMmQZFg4GO59PVBnfm+jtdQ1MJbfZDU90tEwM= github.com/Microsoft/go-winio v0.6.0 h1:slsWYD/zyx7lCXoZVlvQrj0hPTM1HI4+v1sIda2yDvg= github.com/Microsoft/go-winio v0.6.0/go.mod h1:cTAf44im0RAYeL23bpB+fzCyDH2MJiz2BO69KH/soAE= github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= @@ -211,6 +213,8 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw= github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= github.com/google/pprof v0.0.0-20230602150820-91b7bce49751 h1:hR7/MlvK23p6+lIw9SN1TigNLn9ZnF3W4SYRKq2gAHs= @@ -231,6 +235,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfF github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= github.com/googleapis/gax-go/v2 v2.12.4 h1:9gWcmF85Wvq4ryPFvGFaOgPIs1AQX0d0bcbGw4Z96qg= github.com/googleapis/gax-go/v2 v2.12.4/go.mod h1:KYEYLorsnIGDi/rPC8b5TdlB9kbKoFubselGIoBMCwI= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/guregu/null v4.0.0+incompatible h1:4zw0ckM7ECd6FNNddc3Fu4aty9nTlpkkzH7dPn4/4Gw= @@ -366,11 +372,13 @@ github.com/sanity-io/litter v1.5.5/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF github.com/sapphi-red/midec v0.5.2 h1:7R69uT6BMyWT+XGkBTI14TqgRNCBa5qo+bFgr5OSPIg= github.com/sapphi-red/midec v0.5.2/go.mod h1:LjZZZoars2NdhvLzAsC7MoGmxHzWUqiRY6r73gXqBmo= github.com/seccomp/libseccomp-golang v0.9.2-0.20220502022130-f33da4d89646/go.mod h1:JA8cRccbGaA1s33RQf7Y1+q9gHmZX1yB/z9WDN1C6fg= -github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= +github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= @@ -393,6 +401,7 @@ github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRci github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= @@ -434,6 +443,8 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zitadel/oidc v1.13.4 h1:+k2GKqP9Ld9S2MSFlj+KaNsoZ3J9oy+Ezw51EzSFuC8= +github.com/zitadel/oidc v1.13.4/go.mod h1:3h2DhUcP02YV6q/CA/BG4yla0o6rXjK+DkJGK/dwJfw= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg= @@ -555,6 +566,7 @@ golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/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-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -649,6 +661,7 @@ google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFW google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= @@ -656,9 +669,12 @@ gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI= +gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= diff --git a/migration/current.go b/migration/current.go index 3e2c7e6e7..c4508b7d4 100644 --- a/migration/current.go +++ b/migration/current.go @@ -45,6 +45,7 @@ func Migrations() []*gormigrate.Migration { v32(), // ユーザーの表示名上限を32文字に v33(), // 未読テーブルにチャンネルIDカラムを追加 / インデックス類の更新 / 不要なレコードの削除 v34(), // 未読テーブルのcreated_atカラムをメッセージテーブルを元に更新 / カラム名を変更 + v35(), // OIDC実装のためProfileロールを追加 } } diff --git a/migration/v35.go b/migration/v35.go new file mode 100644 index 000000000..8a4a1b1ab --- /dev/null +++ b/migration/v35.go @@ -0,0 +1,48 @@ +package migration + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// v35 OIDC実装のためProfileロールを追加 +func v35() *gormigrate.Migration { + return &gormigrate.Migration{ + ID: "35", + Migrate: func(db *gorm.DB) error { + v := v35UserRole{ + Name: "profile", + Oauth2Scope: true, + System: true, + Permissions: []v35RolePermission{ + { + Role: "profile", + Permission: "get_me", + }, + }, + } + return db.Create(v).Error + }, + } +} + +type v35UserRole struct { + Name string `gorm:"type:varchar(30);not null;primaryKey"` + Oauth2Scope bool `gorm:"type:boolean;not null;default:false"` + System bool `gorm:"type:boolean;not null;default:false"` + + Permissions []v35RolePermission `gorm:"constraint:user_role_permissions_role_user_roles_name_foreign,OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:Role;references:Name"` +} + +func (*v35UserRole) TableName() string { + return "user_roles" +} + +type v35RolePermission struct { + Role string `gorm:"type:varchar(30);not null;primaryKey"` + Permission string `gorm:"type:varchar(30);not null;primaryKey"` +} + +func (*v35RolePermission) TableName() string { + return "user_role_permissions" +} diff --git a/model/oauth2.go b/model/oauth2.go index 689cb488a..68d3cf6ad 100644 --- a/model/oauth2.go +++ b/model/oauth2.go @@ -11,6 +11,7 @@ import ( vd "github.com/go-ozzo/ozzo-validation/v4" "github.com/gofrs/uuid" + "github.com/samber/lo" "gorm.io/gorm" "github.com/traPtitech/traQ/utils/validator" @@ -28,6 +29,11 @@ type AccessScope string // AccessScopes AccessScopeのセット type AccessScopes map[AccessScope]struct{} +// SupportedAccessScopes 対応するスコープ一覧を返します +func SupportedAccessScopes() []string { + return []string{"read", "write", "manage_bot", "openid", "profile"} +} + // Value database/sql/driver.Valuer 実装 func (arr AccessScopes) Value() (driver.Value, error) { return arr.String(), nil @@ -112,7 +118,8 @@ func (arr AccessScopes) StringArray() (r []string) { // Validate github.com/go-ozzo/ozzo-validation.Validatable 実装 func (arr AccessScopes) Validate() error { // TODO カスタムスコープに対応 - return vd.Validate(arr.StringArray(), vd.Each(vd.Required, vd.In("read", "write", "manage_bot"))) + scopes := lo.Map(SupportedAccessScopes(), func(s string, _ int) any { return s }) + return vd.Validate(arr.StringArray(), vd.Each(vd.Required, vd.In(scopes...))) } // OAuth2Authorize OAuth2 認可データの構造体 @@ -226,9 +233,13 @@ func (t *OAuth2Token) GetAvailableScopes(request AccessScopes) (result AccessSco return } +func (t *OAuth2Token) Deadline() time.Time { + return t.CreatedAt.Add(time.Duration(t.ExpiresIn) * time.Second) +} + // IsExpired 有効期限が切れているかどうか func (t *OAuth2Token) IsExpired() bool { - return t.CreatedAt.Add(time.Duration(t.ExpiresIn) * time.Second).Before(time.Now()) + return t.Deadline().Before(time.Now()) } // IsRefreshEnabled リフレッシュトークンが有効かどうか diff --git a/router/config.go b/router/config.go index 94a04c762..0b1ec16b0 100644 --- a/router/config.go +++ b/router/config.go @@ -8,6 +8,8 @@ import ( // Config APIサーバー設定 type Config struct { + // Origin サーバーオリジン (e.g. https://q.trap.jp) + Origin string // 開発モードかどうか Development bool // Version サーバーバージョン @@ -66,6 +68,7 @@ func (c ExternalAuthConfig) ValidProviders() map[string]bool { func provideOAuth2Config(c *Config) oauth2.Config { return oauth2.Config{ + Origin: c.Origin, AccessTokenExp: c.AccessTokenExp, IsRefreshEnabled: c.IsRefreshEnabled, } diff --git a/router/oauth2/authorization_endpoint.go b/router/oauth2/authorization_endpoint.go index d1d56c470..577eaf145 100644 --- a/router/oauth2/authorization_endpoint.go +++ b/router/oauth2/authorization_endpoint.go @@ -11,6 +11,7 @@ import ( vd "github.com/go-ozzo/ozzo-validation/v4" "github.com/google/go-querystring/query" "github.com/labstack/echo/v4" + "github.com/samber/lo" "go.uber.org/zap" "github.com/traPtitech/traQ/model" @@ -52,16 +53,17 @@ func (r authorizeRequest) Validate() error { } type responseType struct { - Code bool - Token bool - None bool + Code bool + Token bool + IDToken bool + None bool } func (t responseType) valid() bool { if t.None { - return !t.Code && !t.Token + return !t.Code && !t.Token && !t.IDToken } - return t.Code || t.Token + return t.Code || t.Token || t.IDToken } // AuthorizationEndpointHandler 認可エンドポイントのハンドラ @@ -102,7 +104,7 @@ func (h *Handler) AuthorizationEndpointHandler(c echo.Context) error { // PKCE確認 if len(req.CodeChallengeMethod) > 0 { - if req.CodeChallengeMethod != "plain" && req.CodeChallengeMethod != "S256" { + if !lo.Contains(supportedCodeChallengeMethods, req.CodeChallengeMethod) { q.Set("error", errInvalidRequest) redirectURI.RawQuery = q.Encode() return c.Redirect(http.StatusFound, redirectURI.String()) @@ -132,13 +134,15 @@ func (h *Handler) AuthorizationEndpointHandler(c echo.Context) error { } // ResponseType確認 - types := responseType{false, false, false} + var types responseType for _, v := range strings.Fields(req.ResponseType) { switch v { case "code": types.Code = true case "token": types.Token = true + case "id_token": + types.IDToken = true case "none": types.None = true default: diff --git a/router/oauth2/authorization_endpoint_test.go b/router/oauth2/authorization_endpoint_test.go index 5d22c3847..cbfc4555e 100644 --- a/router/oauth2/authorization_endpoint_test.go +++ b/router/oauth2/authorization_endpoint_test.go @@ -530,7 +530,7 @@ func TestHandlers_AuthorizationDecideHandler(t *testing.T) { Scopes: scopesReadWrite, ValidScopes: scopesRead, State: "state", - Types: responseType{true, false, false}, + Types: responseType{Code: true}, AccessTime: time.Now(), Nonce: "nonce", }, diff --git a/router/oauth2/oauth2.go b/router/oauth2/oauth2.go index 17a876522..21b97a1f3 100644 --- a/router/oauth2/oauth2.go +++ b/router/oauth2/oauth2.go @@ -11,6 +11,7 @@ import ( "github.com/traPtitech/traQ/router/extension" "github.com/traPtitech/traQ/router/middlewares" "github.com/traPtitech/traQ/router/session" + oidc2 "github.com/traPtitech/traQ/service/oidc" "github.com/traPtitech/traQ/service/rbac" ) @@ -38,15 +39,31 @@ const ( authorizationCodeExp = 60 * 5 ) +var ( + supportedResponseTypes = []string{"code"} + supportedResponseModes = []string{"query"} + supportedScopes = model.SupportedAccessScopes() + supportedGrantTypes = []string{ + grantTypeAuthorizationCode, + grantTypePassword, + grantTypeClientCredentials, + grantTypeRefreshToken, + } + supportedCodeChallengeMethods = []string{"plain", "S256"} +) + type Handler struct { RBAC rbac.RBAC Repo repository.Repository Logger *zap.Logger SessStore session.Store + OIDC *oidc2.Service Config } type Config struct { + // Origin サーバーオリジン (e.g. https://q.trap.jp) + Origin string // AccessTokenExp アクセストークンの有効時間(秒) AccessTokenExp int // IsRefreshEnabled リフレッシュトークンを発行するかどうか @@ -59,6 +76,7 @@ func (h *Handler) Setup(e *echo.Group) { e.POST("/authorize", h.AuthorizationEndpointHandler) e.POST("/token", h.TokenEndpointHandler) e.POST("/revoke", h.RevokeTokenEndpointHandler) + e.GET("/oidc/discovery", h.OIDCDiscovery) } // splitAndValidateScope スペース区切りのスコープ文字列を分解し、検証します diff --git a/router/oauth2/oidc.go b/router/oauth2/oidc.go new file mode 100644 index 000000000..88b4a1bf8 --- /dev/null +++ b/router/oauth2/oidc.go @@ -0,0 +1,31 @@ +package oauth2 + +import ( + "net/http" + + "github.com/labstack/echo/v4" + "github.com/zitadel/oidc/pkg/oidc" + + "github.com/traPtitech/traQ/utils" + "github.com/traPtitech/traQ/utils/jwt" +) + +// OIDCDiscovery OpenID Connect Discovery のハンドラ +func (h *Handler) OIDCDiscovery(c echo.Context) error { + return c.JSON(http.StatusOK, &oidc.DiscoveryConfiguration{ + Issuer: h.Origin, + AuthorizationEndpoint: h.Origin + "/api/v3/oauth2/authorize", + TokenEndpoint: h.Origin + "/api/v3/oauth2/token", + UserinfoEndpoint: h.Origin + "/api/v3/users/me/oidc", + RevocationEndpoint: h.Origin + "/api/v3/oauth2/revoke", + EndSessionEndpoint: h.Origin + "/api/v3/logout", + JwksURI: h.Origin + "/api/v3/jwks", + ScopesSupported: supportedScopes, + ResponseTypesSupported: supportedResponseTypes, + ResponseModesSupported: supportedResponseModes, + GrantTypesSupported: utils.Map(supportedGrantTypes, func(s string) oidc.GrantType { return oidc.GrantType(s) }), + SubjectTypesSupported: []string{"public"}, + IDTokenSigningAlgValuesSupported: jwt.SupportedAlgorithms(), + CodeChallengeMethodsSupported: utils.Map(supportedCodeChallengeMethods, func(s string) oidc.CodeChallengeMethod { return oidc.CodeChallengeMethod(s) }), + }) +} diff --git a/router/oauth2/token_endpoint.go b/router/oauth2/token_endpoint.go index 8d31647aa..77fd0e6e8 100644 --- a/router/oauth2/token_endpoint.go +++ b/router/oauth2/token_endpoint.go @@ -5,11 +5,15 @@ import ( vd "github.com/go-ozzo/ozzo-validation/v4" "github.com/gofrs/uuid" + "github.com/golang-jwt/jwt/v5" "github.com/labstack/echo/v4" "go.uber.org/zap" + "github.com/traPtitech/traQ/model" "github.com/traPtitech/traQ/repository" "github.com/traPtitech/traQ/router/extension" + "github.com/traPtitech/traQ/utils" + jwt2 "github.com/traPtitech/traQ/utils/jwt" ) type oauth2ErrorResponse struct { @@ -23,6 +27,7 @@ type tokenResponse struct { TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in,omitempty"` RefreshToken string `json:"refresh_token,omitempty"` + IDToken string `json:"id_token,omitempty"` Scope string `json:"scope,omitempty"` } @@ -45,6 +50,50 @@ func (h *Handler) TokenEndpointHandler(c echo.Context) error { } } +func (h *Handler) issueIDToken(client *model.OAuth2Client, token *model.OAuth2Token, userID uuid.UUID) (string, error) { + claims := jwt.MapClaims{ + "iss": h.Origin, + "sub": userID.String(), + "aud": client.ID, + "exp": token.Deadline().Unix(), + "iat": token.CreatedAt.Unix(), + } + if token.Scopes.Contains("profile") { + userInfo, err := h.OIDC.GetUserInfo(userID) + if err != nil { + return "", err + } + claims = utils.MergeMap(userInfo, claims) + } + return jwt2.Sign(claims) +} + +func (h *Handler) issueToken(client *model.OAuth2Client, userID uuid.UUID, scopes, originalScopes model.AccessScopes, allowRefreshToken bool) (*tokenResponse, error) { + token, err := h.Repo.IssueToken(client, userID, client.RedirectURI, scopes, h.AccessTokenExp, h.IsRefreshEnabled) + if err != nil { + return nil, err + } + res := &tokenResponse{ + TokenType: authScheme, + AccessToken: token.AccessToken, + ExpiresIn: token.ExpiresIn, + } + if len(originalScopes) != len(token.Scopes) { + res.Scope = token.Scopes.String() + } + if allowRefreshToken && token.IsRefreshEnabled() { + res.RefreshToken = token.RefreshToken + } + if scopes.Contains("openid") { + idToken, err := h.issueIDToken(client, token, userID) + if err != nil { + return nil, err + } + res.IDToken = idToken + } + return res, nil +} + type tokenEndpointAuthorizationCodeHandlerRequest struct { Code string `form:"code"` RedirectURI string `form:"redirect_uri"` @@ -119,23 +168,11 @@ func (h *Handler) tokenEndpointAuthorizationCodeHandler(c echo.Context) error { } // トークン発行 - newToken, err := h.Repo.IssueToken(client, code.UserID, client.RedirectURI, code.Scopes, h.AccessTokenExp, h.IsRefreshEnabled) + res, err := h.issueToken(client, code.UserID, code.Scopes, code.OriginalScopes, true) if err != nil { h.L(c).Error(err.Error(), zap.Error(err)) return c.JSON(http.StatusInternalServerError, oauth2ErrorResponse{ErrorType: errServerError}) } - - res := &tokenResponse{ - TokenType: authScheme, - AccessToken: newToken.AccessToken, - ExpiresIn: newToken.ExpiresIn, - } - if len(code.OriginalScopes) != len(newToken.Scopes) { - res.Scope = newToken.Scopes.String() - } - if newToken.IsRefreshEnabled() { - res.RefreshToken = newToken.RefreshToken - } return c.JSON(http.StatusOK, res) } @@ -212,23 +249,11 @@ func (h *Handler) tokenEndpointPasswordHandler(c echo.Context) error { } // トークン発行 - newToken, err := h.Repo.IssueToken(client, user.GetID(), client.RedirectURI, validScopes, h.AccessTokenExp, h.IsRefreshEnabled) + res, err := h.issueToken(client, user.GetID(), validScopes, reqScopes, true) if err != nil { h.L(c).Error(err.Error(), zap.Error(err)) return c.JSON(http.StatusInternalServerError, oauth2ErrorResponse{ErrorType: errServerError}) } - - res := &tokenResponse{ - TokenType: authScheme, - AccessToken: newToken.AccessToken, - ExpiresIn: newToken.ExpiresIn, - } - if len(reqScopes) != len(validScopes) { - res.Scope = newToken.Scopes.String() - } - if newToken.IsRefreshEnabled() { - res.RefreshToken = newToken.RefreshToken - } return c.JSON(http.StatusOK, res) } @@ -282,20 +307,11 @@ func (h *Handler) tokenEndpointClientCredentialsHandler(c echo.Context) error { } // トークン発行 - newToken, err := h.Repo.IssueToken(client, uuid.Nil, client.RedirectURI, validScopes, h.AccessTokenExp, false) + res, err := h.issueToken(client, uuid.Nil, validScopes, reqScopes, false) if err != nil { h.L(c).Error(err.Error(), zap.Error(err)) return c.JSON(http.StatusInternalServerError, oauth2ErrorResponse{ErrorType: errServerError}) } - - res := &tokenResponse{ - TokenType: authScheme, - AccessToken: newToken.AccessToken, - ExpiresIn: newToken.ExpiresIn, - } - if len(reqScopes) != len(validScopes) { - res.Scope = newToken.Scopes.String() - } return c.JSON(http.StatusOK, res) } @@ -368,26 +384,15 @@ func (h *Handler) tokenEndpointRefreshTokenHandler(c echo.Context) error { } // トークン発行 - newToken, err := h.Repo.IssueToken(client, token.UserID, token.RedirectURI, newScopes, h.AccessTokenExp, h.IsRefreshEnabled) + res, err := h.issueToken(client, token.UserID, newScopes, token.Scopes, true) if err != nil { h.L(c).Error(err.Error(), zap.Error(err)) return c.JSON(http.StatusInternalServerError, oauth2ErrorResponse{ErrorType: errServerError}) } + // 旧リフレッシュトークン削除 if err := h.Repo.DeleteTokenByRefresh(req.RefreshToken); err != nil { h.L(c).Error(err.Error(), zap.Error(err)) return c.JSON(http.StatusInternalServerError, oauth2ErrorResponse{ErrorType: errServerError}) } - - res := &tokenResponse{ - TokenType: authScheme, - AccessToken: newToken.AccessToken, - ExpiresIn: newToken.ExpiresIn, - } - if len(token.Scopes) != len(newToken.Scopes) { - res.Scope = newToken.Scopes.String() - } - if newToken.IsRefreshEnabled() { - res.RefreshToken = newToken.RefreshToken - } return c.JSON(http.StatusOK, res) } diff --git a/router/router.go b/router/router.go index da9f71c14..3037bd450 100644 --- a/router/router.go +++ b/router/router.go @@ -34,6 +34,10 @@ type Router struct { func Setup(hub *hub.Hub, db *gorm.DB, repo repository.Repository, ss *service.Services, logger *zap.Logger, config *Config) *echo.Echo { r := newRouter(hub, db, repo, ss, logger.Named("router"), config) + r.e.GET("/.well-known/openid-configuration", func(c echo.Context) error { + return c.Redirect(http.StatusFound, "/api/v3/oauth2/oidc/discovery") + }) + api := r.e.Group("/api") api.GET("/metrics", echoprometheus.NewHandler()) api.GET("/ping", func(c echo.Context) error { return c.String(http.StatusOK, http.StatusText(http.StatusOK)) }) diff --git a/router/v3/public.go b/router/v3/public.go index 5feacc3f8..442f64836 100644 --- a/router/v3/public.go +++ b/router/v3/public.go @@ -10,6 +10,7 @@ import ( "github.com/traPtitech/traQ/router/consts" "github.com/traPtitech/traQ/router/extension/herror" "github.com/traPtitech/traQ/service/file" + "github.com/traPtitech/traQ/utils/jwt" ) // GetVersion GET /version @@ -28,6 +29,15 @@ func (h *Handlers) GetVersion(c echo.Context) error { }) } +// GetJWKS GET /jwks +func (h *Handlers) GetJWKS(c echo.Context) error { + jwks, err := jwt.JWKSet(c.Request().Context()) + if err != nil { + return herror.InternalServerError(err) + } + return c.JSON(http.StatusOK, jwks) +} + // GetPublicUserIcon GET /public/icon/{username} func (h *Handlers) GetPublicUserIcon(c echo.Context) error { username := c.Param("username") diff --git a/router/v3/router.go b/router/v3/router.go index 721d1b4b7..ac1973d83 100644 --- a/router/v3/router.go +++ b/router/v3/router.go @@ -16,6 +16,7 @@ import ( "github.com/traPtitech/traQ/service/imaging" "github.com/traPtitech/traQ/service/message" "github.com/traPtitech/traQ/service/ogp" + "github.com/traPtitech/traQ/service/oidc" "github.com/traPtitech/traQ/service/rbac" "github.com/traPtitech/traQ/service/rbac/permission" "github.com/traPtitech/traQ/service/search" @@ -34,6 +35,7 @@ type Handlers struct { Logger *zap.Logger OC *counter.OnlineCounter OGP ogp.Service + OIDC *oidc.Service VM *viewer.Manager WebRTC *webrtcv3.Manager Imaging imaging.Processor @@ -113,6 +115,7 @@ func (h *Handlers) Setup(e *echo.Group) { { apiUsersMe.GET("", h.GetMe, requires(permission.GetMe)) apiUsersMe.PATCH("", h.EditMe, requires(permission.EditMe)) + apiUsersMe.GET("/oidc", h.GetMeOIDC, requires(permission.GetMe)) apiUsersMe.GET("/stamp-history", h.GetMyStampHistory, requires(permission.GetMyStampHistory)) apiUsersMe.GET("/qr-code", h.GetMyQRCode, requires(permission.GetUserQRCode), blockBot) apiUsersMe.GET("/icon", h.GetMyIcon, requires(permission.DownloadFile)) @@ -375,6 +378,7 @@ func (h *Handlers) Setup(e *echo.Group) { apiNoAuth := e.Group("/v3") { apiNoAuth.GET("/version", h.GetVersion) + apiNoAuth.GET("/jwks", h.GetJWKS) if h.Config.AllowSignUp { apiNoAuth.POST("/users", h.CreateUser, noLogin) } diff --git a/router/v3/users.go b/router/v3/users.go index 3a85892fd..542f42f39 100644 --- a/router/v3/users.go +++ b/router/v3/users.go @@ -117,6 +117,15 @@ func (h *Handlers) GetMe(c echo.Context) error { }) } +// GetMeOIDC GET /users/me/oidc +func (h *Handlers) GetMeOIDC(c echo.Context) error { + userInfo, err := h.OIDC.GetUserInfo(getRequestUserID(c)) + if err != nil { + return herror.InternalServerError(err) + } + return c.JSON(http.StatusOK, userInfo) +} + // PatchMeRequest PATCH /users/me リクエストボディ type PatchMeRequest struct { DisplayName optional.Of[string] `json:"displayName"` diff --git a/router/wire_gen.go b/router/wire_gen.go index 78d67fa64..a8a2b7137 100644 --- a/router/wire_gen.go +++ b/router/wire_gen.go @@ -48,6 +48,7 @@ func newRouter(hub2 *hub.Hub, db *gorm.DB, repo repository.Repository, ss *servi wsStreamer := ss.BotWS onlineCounter := ss.OnlineCounter ogpService := ss.OGP + oidcService := ss.OIDC viewerManager := ss.ViewerManager webrtcv3Manager := ss.WebRTCv3 processor := ss.Imaging @@ -62,6 +63,7 @@ func newRouter(hub2 *hub.Hub, db *gorm.DB, repo repository.Repository, ss *servi Logger: logger, OC: onlineCounter, OGP: ogpService, + OIDC: oidcService, VM: viewerManager, WebRTC: webrtcv3Manager, Imaging: processor, @@ -79,6 +81,7 @@ func newRouter(hub2 *hub.Hub, db *gorm.DB, repo repository.Repository, ss *servi Repo: repo, Logger: logger, SessStore: store, + OIDC: oidcService, Config: oauth2Config, } router := &Router{ diff --git a/service/oidc/userinfo.go b/service/oidc/userinfo.go new file mode 100644 index 000000000..9b2dbdddd --- /dev/null +++ b/service/oidc/userinfo.go @@ -0,0 +1,65 @@ +package oidc + +import ( + "github.com/gofrs/uuid" + + "github.com/traPtitech/traQ/model" + "github.com/traPtitech/traQ/repository" + "github.com/traPtitech/traQ/service/rbac" + "github.com/traPtitech/traQ/utils" +) + +type Service struct { + repo repository.Repository + origin string + rbac rbac.RBAC +} + +func NewOIDCService( + repo repository.Repository, + origin string, + rbac rbac.RBAC, +) *Service { + return &Service{ + repo: repo, + origin: origin, + rbac: rbac, + } +} + +func (s *Service) GetUserInfo(userID uuid.UUID) (map[string]any, error) { + user, err := s.repo.GetUser(userID, true) + if err != nil { + return nil, err + } + tags, err := s.repo.GetUserTagsByUserID(user.GetID()) + if err != nil { + return nil, err + } + groups, err := s.repo.GetUserBelongingGroupIDs(user.GetID()) + if err != nil { + return nil, err + } + + return map[string]any{ + // OIDC standard claims + "name": user.GetName(), + "preferred_username": user.GetName(), + "picture": s.origin + "/api/v3/public/icon/" + user.GetName(), + "updated_at": user.GetUpdatedAt(), + // traQ specific claims + "traq": map[string]any{ + "bio": user.GetBio(), + "groups": groups, + "tags": utils.Map(tags, func(tag model.UserTag) string { return tag.GetTag() }), + "last_online": user.GetLastOnline(), + "twitter_id": user.GetTwitterID(), + "display_name": user.GetResponseDisplayName(), + "icon_file_id": user.GetIconFileID(), + "bot": user.IsBot(), + "state": user.GetState().Int(), + "permissions": s.rbac.GetGrantedPermissions(user.GetRole()), + "home_channel": user.GetHomeChannel(), + }, + }, nil +} diff --git a/service/rbac/role/profile.go b/service/rbac/role/profile.go new file mode 100644 index 000000000..d1cda1eed --- /dev/null +++ b/service/rbac/role/profile.go @@ -0,0 +1,12 @@ +package role + +import ( + "github.com/traPtitech/traQ/service/rbac/permission" +) + +// Profile ユーザー情報読み取り専用ロール +const Profile = "profile" + +var profilePerms = []permission.Permission{ + permission.GetMe, +} diff --git a/service/rbac/role/role.go b/service/rbac/role/role.go index 3855e2f6d..a4b09e946 100644 --- a/service/rbac/role/role.go +++ b/service/rbac/role/role.go @@ -38,6 +38,11 @@ func GetSystemRoles() Roles { oauth2Scope: true, permissions: permission.PermissionsFromArray(manageBotPerms), }, + Profile: &systemRole{ + name: Profile, + oauth2Scope: true, + permissions: permission.PermissionsFromArray(profilePerms), + }, } } diff --git a/service/services.go b/service/services.go index edb0bfcb4..b24a37f09 100644 --- a/service/services.go +++ b/service/services.go @@ -12,6 +12,7 @@ import ( "github.com/traPtitech/traQ/service/message" "github.com/traPtitech/traQ/service/notification" "github.com/traPtitech/traQ/service/ogp" + "github.com/traPtitech/traQ/service/oidc" "github.com/traPtitech/traQ/service/rbac" "github.com/traPtitech/traQ/service/search" "github.com/traPtitech/traQ/service/viewer" @@ -33,6 +34,7 @@ type Services struct { MessageManager message.Manager Notification *notification.Service OGP ogp.Service + OIDC *oidc.Service RBAC rbac.RBAC Search search.Engine ViewerManager *viewer.Manager diff --git a/service/services_wire.go b/service/services_wire.go index 25ab608d5..796c726a4 100644 --- a/service/services_wire.go +++ b/service/services_wire.go @@ -21,6 +21,7 @@ var ProviderSet = wire.NewSet(wire.FieldsOf(new(*Services), "MessageManager", "Notification", "OGP", + "OIDC", "RBAC", "Search", "ViewerManager", diff --git a/utils/jwt/signer.go b/utils/jwt/signer.go index 3b0df0596..7869f9675 100644 --- a/utils/jwt/signer.go +++ b/utils/jwt/signer.go @@ -2,12 +2,22 @@ package jwt import ( "bytes" + "context" "crypto/ecdsa" + "encoding/json" + "github.com/MicahParks/jwkset" "github.com/golang-jwt/jwt/v5" ) -var priv *ecdsa.PrivateKey +var ( + priv *ecdsa.PrivateKey + jwks jwkset.JWKSet[any] +) + +func init() { + jwks = jwkset.NewMemory[any]() +} // SetupSigner JWTを発行・検証するためのSignerのセットアップ func SetupSigner(privRaw []byte) error { @@ -17,10 +27,20 @@ func SetupSigner(privRaw []byte) error { } priv = _priv - return nil + return jwks.Store.WriteKey(context.Background(), jwkset.NewKey[any](priv, "traq")) } // Sign JWTの発行を行う func Sign(claims jwt.Claims) (string, error) { return jwt.NewWithClaims(jwt.SigningMethodES256, claims).SignedString(priv) } + +// SupportedAlgorithms サポートする signing algorithm の一覧 +func SupportedAlgorithms() []string { + return []string{jwt.SigningMethodES256.Alg()} +} + +// JWKSet Public の JSON Web Key Set を取得する +func JWKSet(ctx context.Context) (json.RawMessage, error) { + return jwks.JSONPublic(ctx) +} diff --git a/utils/utils.go b/utils/utils.go index 295602e42..79fa56b9e 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -7,3 +7,14 @@ func Map[T, R any](s []T, mapper func(item T) R) []R { } return ret } + +func MergeMap[K comparable, V any](m1, m2 map[K]V) map[K]V { + ret := make(map[K]V, len(m1)+len(m2)) + for k, v := range m1 { + ret[k] = v + } + for k, v := range m2 { + ret[k] = v + } + return ret +} From 13c15f48b7b3c6d8b5ad794b3f79c853045893f8 Mon Sep 17 00:00:00 2001 From: motoki317 Date: Wed, 1 Nov 2023 14:52:46 +0900 Subject: [PATCH 02/14] handle well-known routes by server-side --- dev/Caddyfile | 6 +----- router/router.go | 12 +++++++++--- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/dev/Caddyfile b/dev/Caddyfile index c9fe8e939..d03f49052 100644 --- a/dev/Caddyfile +++ b/dev/Caddyfile @@ -10,12 +10,8 @@ root * /usr/share/caddy handle /api/* { reverse_proxy backend:3000 } - handle /.well-known/* { - respond 404 -} -handle /.well-known/change-password { - redir * /settings/session + reverse_proxy backend:3000 } handle { diff --git a/router/router.go b/router/router.go index 3037bd450..1610c2ace 100644 --- a/router/router.go +++ b/router/router.go @@ -34,9 +34,15 @@ type Router struct { func Setup(hub *hub.Hub, db *gorm.DB, repo repository.Repository, ss *service.Services, logger *zap.Logger, config *Config) *echo.Echo { r := newRouter(hub, db, repo, ss, logger.Named("router"), config) - r.e.GET("/.well-known/openid-configuration", func(c echo.Context) error { - return c.Redirect(http.StatusFound, "/api/v3/oauth2/oidc/discovery") - }) + wellKnown := r.e.Group("/.well-known") + { + wellKnown.GET("/reset-password", func(c echo.Context) error { + return c.Redirect(http.StatusFound, "/settings/session") + }) + wellKnown.GET("/openid-configuration", func(c echo.Context) error { + return c.Redirect(http.StatusFound, "/api/v3/oauth2/oidc/discovery") + }) + } api := r.e.Group("/api") api.GET("/metrics", echoprometheus.NewHandler()) From 4e5889db603b9e93770910f53e8299b2dbd6698e Mon Sep 17 00:00:00 2001 From: motoki317 Date: Wed, 1 Nov 2023 16:10:10 +0900 Subject: [PATCH 03/14] add email scope --- migration/v35.go | 37 +++++++++++++++++++++++++++--------- model/oauth2.go | 2 +- service/oidc/userinfo.go | 2 ++ service/rbac/role/email.go | 12 ++++++++++++ service/rbac/role/profile.go | 2 +- service/rbac/role/role.go | 5 +++++ 6 files changed, 49 insertions(+), 11 deletions(-) create mode 100644 service/rbac/role/email.go diff --git a/migration/v35.go b/migration/v35.go index 8a4a1b1ab..023821eae 100644 --- a/migration/v35.go +++ b/migration/v35.go @@ -10,18 +10,37 @@ func v35() *gormigrate.Migration { return &gormigrate.Migration{ ID: "35", Migrate: func(db *gorm.DB) error { - v := v35UserRole{ - Name: "profile", - Oauth2Scope: true, - System: true, - Permissions: []v35RolePermission{ - { - Role: "profile", - Permission: "get_me", + roles := []v35UserRole{ + { + Name: "profile", + Oauth2Scope: true, + System: true, + Permissions: []v35RolePermission{ + { + Role: "profile", + Permission: "get_me", + }, }, }, + { + Name: "email", + Oauth2Scope: true, + System: true, + Permissions: []v35RolePermission{ + { + Role: "profile", + Permission: "get_me", + }, + }, + }, + } + for _, role := range roles { + err := db.Create(&role).Error + if err != nil { + return err + } } - return db.Create(v).Error + return nil }, } } diff --git a/model/oauth2.go b/model/oauth2.go index 68d3cf6ad..b234e9bd9 100644 --- a/model/oauth2.go +++ b/model/oauth2.go @@ -31,7 +31,7 @@ type AccessScopes map[AccessScope]struct{} // SupportedAccessScopes 対応するスコープ一覧を返します func SupportedAccessScopes() []string { - return []string{"read", "write", "manage_bot", "openid", "profile"} + return []string{"read", "write", "manage_bot", "openid", "profile", "email"} } // Value database/sql/driver.Valuer 実装 diff --git a/service/oidc/userinfo.go b/service/oidc/userinfo.go index 9b2dbdddd..740a95aab 100644 --- a/service/oidc/userinfo.go +++ b/service/oidc/userinfo.go @@ -44,6 +44,8 @@ func (s *Service) GetUserInfo(userID uuid.UUID) (map[string]any, error) { return map[string]any{ // OIDC standard claims "name": user.GetName(), + "email": user.GetName() + "+dummy@example.com", + "email_verified": false, "preferred_username": user.GetName(), "picture": s.origin + "/api/v3/public/icon/" + user.GetName(), "updated_at": user.GetUpdatedAt(), diff --git a/service/rbac/role/email.go b/service/rbac/role/email.go new file mode 100644 index 000000000..ceecfab3b --- /dev/null +++ b/service/rbac/role/email.go @@ -0,0 +1,12 @@ +package role + +import ( + "github.com/traPtitech/traQ/service/rbac/permission" +) + +// Email ユーザー情報読み取り専用ロール (for OIDC) +const Email = "email" + +var emailPerms = []permission.Permission{ + permission.GetMe, +} diff --git a/service/rbac/role/profile.go b/service/rbac/role/profile.go index d1cda1eed..2a2b0c9ac 100644 --- a/service/rbac/role/profile.go +++ b/service/rbac/role/profile.go @@ -4,7 +4,7 @@ import ( "github.com/traPtitech/traQ/service/rbac/permission" ) -// Profile ユーザー情報読み取り専用ロール +// Profile ユーザー情報読み取り専用ロール (for OIDC) const Profile = "profile" var profilePerms = []permission.Permission{ diff --git a/service/rbac/role/role.go b/service/rbac/role/role.go index a4b09e946..00a95e16a 100644 --- a/service/rbac/role/role.go +++ b/service/rbac/role/role.go @@ -43,6 +43,11 @@ func GetSystemRoles() Roles { oauth2Scope: true, permissions: permission.PermissionsFromArray(profilePerms), }, + Email: &systemRole{ + name: Email, + oauth2Scope: true, + permissions: permission.PermissionsFromArray(emailPerms), + }, } } From 2abd53277b0d0327f503b4cc9a682751db0e008d Mon Sep 17 00:00:00 2001 From: motoki317 Date: Wed, 1 Nov 2023 17:03:00 +0900 Subject: [PATCH 04/14] add openid user role for access token --- migration/v35.go | 6 ++++++ service/rbac/role/openid.go | 10 ++++++++++ service/rbac/role/role.go | 5 +++++ 3 files changed, 21 insertions(+) create mode 100644 service/rbac/role/openid.go diff --git a/migration/v35.go b/migration/v35.go index 023821eae..2009e218f 100644 --- a/migration/v35.go +++ b/migration/v35.go @@ -11,6 +11,12 @@ func v35() *gormigrate.Migration { ID: "35", Migrate: func(db *gorm.DB) error { roles := []v35UserRole{ + { + Name: "openid", + Oauth2Scope: true, + System: true, + Permissions: []v35RolePermission{}, + }, { Name: "profile", Oauth2Scope: true, diff --git a/service/rbac/role/openid.go b/service/rbac/role/openid.go new file mode 100644 index 000000000..13614183d --- /dev/null +++ b/service/rbac/role/openid.go @@ -0,0 +1,10 @@ +package role + +import ( + "github.com/traPtitech/traQ/service/rbac/permission" +) + +// OpenID OIDC専用ロール +const OpenID = "openid" + +var openIDPerms []permission.Permission diff --git a/service/rbac/role/role.go b/service/rbac/role/role.go index 00a95e16a..e80518ee2 100644 --- a/service/rbac/role/role.go +++ b/service/rbac/role/role.go @@ -38,6 +38,11 @@ func GetSystemRoles() Roles { oauth2Scope: true, permissions: permission.PermissionsFromArray(manageBotPerms), }, + OpenID: &systemRole{ + name: OpenID, + oauth2Scope: true, + permissions: permission.PermissionsFromArray(openIDPerms), + }, Profile: &systemRole{ name: Profile, oauth2Scope: true, From 396afc1292e79c912d37c2dc828d96752c497b04 Mon Sep 17 00:00:00 2001 From: motoki317 Date: Mon, 13 Nov 2023 02:09:12 +0900 Subject: [PATCH 05/14] fix response type validation --- router/oauth2/authorization_endpoint.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/router/oauth2/authorization_endpoint.go b/router/oauth2/authorization_endpoint.go index 577eaf145..a7966c0f6 100644 --- a/router/oauth2/authorization_endpoint.go +++ b/router/oauth2/authorization_endpoint.go @@ -245,7 +245,7 @@ func (h *Handler) AuthorizationEndpointHandler(c echo.Context) error { } switch { - case types.Code && !types.Token: // "code" 現状はcodeしかサポートしない + case types.Code && !types.Token && !types.IDToken: // 現状は Authorization Code Flow しかサポートしない if se == nil { // 未ログインの場合はログインしてから再度叩かせる current := c.Request().URL @@ -353,7 +353,7 @@ func (h *Handler) AuthorizationDecideHandler(c echo.Context) error { } switch { - case reqAuth.Types.Code && !reqAuth.Types.Token: // "code" 現状はcodeしかサポートしない + case reqAuth.Types.Code && !reqAuth.Types.Token && !reqAuth.Types.IDToken: // 現状は Authorization Code Flow しかサポートしない data := &model.OAuth2Authorize{ Code: random.SecureAlphaNumeric(36), ClientID: reqAuth.ClientID, From 0c5ec680ff0cad1d5ec65158eed8b6b4dff93605 Mon Sep 17 00:00:00 2001 From: motoki317 Date: Tue, 14 Nov 2023 22:17:20 +0900 Subject: [PATCH 06/14] Do not issue refresh tokens to OIDC requests --- router/oauth2/token_endpoint.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/router/oauth2/token_endpoint.go b/router/oauth2/token_endpoint.go index 77fd0e6e8..de93880e8 100644 --- a/router/oauth2/token_endpoint.go +++ b/router/oauth2/token_endpoint.go @@ -68,8 +68,11 @@ func (h *Handler) issueIDToken(client *model.OAuth2Client, token *model.OAuth2To return jwt2.Sign(claims) } -func (h *Handler) issueToken(client *model.OAuth2Client, userID uuid.UUID, scopes, originalScopes model.AccessScopes, allowRefreshToken bool) (*tokenResponse, error) { - token, err := h.Repo.IssueToken(client, userID, client.RedirectURI, scopes, h.AccessTokenExp, h.IsRefreshEnabled) +func (h *Handler) issueToken(client *model.OAuth2Client, userID uuid.UUID, scopes, originalScopes model.AccessScopes, grantTypeRefreshAllowed bool) (*tokenResponse, error) { + isOIDC := scopes.Contains("openid") + // OIDCの場合は、Refresh TokenのScopeの管理(主にoffline_access周り)が面倒なので、一律で発行しないことにする + refresh := h.IsRefreshEnabled && grantTypeRefreshAllowed && !isOIDC + token, err := h.Repo.IssueToken(client, userID, client.RedirectURI, scopes, h.AccessTokenExp, refresh) if err != nil { return nil, err } @@ -81,7 +84,7 @@ func (h *Handler) issueToken(client *model.OAuth2Client, userID uuid.UUID, scope if len(originalScopes) != len(token.Scopes) { res.Scope = token.Scopes.String() } - if allowRefreshToken && token.IsRefreshEnabled() { + if token.IsRefreshEnabled() { res.RefreshToken = token.RefreshToken } if scopes.Contains("openid") { From 5aa7dd47ccc2ffc1773c05fbae6fc75a897c0a6b Mon Sep 17 00:00:00 2001 From: motoki317 Date: Thu, 16 Nov 2023 21:23:19 +0900 Subject: [PATCH 07/14] Update jwt gen comment --- cmd/serve.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/serve.go b/cmd/serve.go index ef1485b16..39372c365 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -81,7 +81,7 @@ func serveCommand() *cobra.Command { } logger.Info("repository was set up") - // JWT + // JWT for QRCode and OIDC if priv := c.JWT.Keys.Private; priv != "" { privRaw, err := os.ReadFile(priv) if err != nil { From d99dd361321f4a888142e7ad03f941398760bf10 Mon Sep 17 00:00:00 2001 From: motoki317 Date: Fri, 17 Nov 2023 15:04:03 +0900 Subject: [PATCH 08/14] Pass in scopes to narrow UserInfo returned claims --- router/oauth2/token_endpoint.go | 13 ++++++----- router/v3/users.go | 13 ++++++++++- service/oidc/userinfo.go | 39 ++++++++++++++++++++++----------- 3 files changed, 45 insertions(+), 20 deletions(-) diff --git a/router/oauth2/token_endpoint.go b/router/oauth2/token_endpoint.go index de93880e8..8b6eb80da 100644 --- a/router/oauth2/token_endpoint.go +++ b/router/oauth2/token_endpoint.go @@ -51,6 +51,7 @@ func (h *Handler) TokenEndpointHandler(c echo.Context) error { } func (h *Handler) issueIDToken(client *model.OAuth2Client, token *model.OAuth2Token, userID uuid.UUID) (string, error) { + // Base claims claims := jwt.MapClaims{ "iss": h.Origin, "sub": userID.String(), @@ -58,13 +59,13 @@ func (h *Handler) issueIDToken(client *model.OAuth2Client, token *model.OAuth2To "exp": token.Deadline().Unix(), "iat": token.CreatedAt.Unix(), } - if token.Scopes.Contains("profile") { - userInfo, err := h.OIDC.GetUserInfo(userID) - if err != nil { - return "", err - } - claims = utils.MergeMap(userInfo, claims) + // Extra claims according to scopes (profile, email) + userInfo, err := h.OIDC.GetUserInfo(userID, token.Scopes) + if err != nil { + return "", err } + claims = utils.MergeMap(userInfo, claims) + // Sign to JWT return jwt2.Sign(claims) } diff --git a/router/v3/users.go b/router/v3/users.go index 542f42f39..999e671b6 100644 --- a/router/v3/users.go +++ b/router/v3/users.go @@ -2,6 +2,7 @@ package v3 import ( "context" + "github.com/samber/lo" "net/http" "sort" "time" @@ -20,6 +21,7 @@ import ( "github.com/traPtitech/traQ/router/utils" "github.com/traPtitech/traQ/service/channel" "github.com/traPtitech/traQ/service/file" + "github.com/traPtitech/traQ/service/oidc" "github.com/traPtitech/traQ/service/rbac/role" jwt2 "github.com/traPtitech/traQ/utils/jwt" "github.com/traPtitech/traQ/utils/optional" @@ -117,9 +119,18 @@ func (h *Handlers) GetMe(c echo.Context) error { }) } +type userAccessScopes struct{} + +func (u userAccessScopes) Contains(_ model.AccessScope) bool { + return true +} + // GetMeOIDC GET /users/me/oidc func (h *Handlers) GetMeOIDC(c echo.Context) error { - userInfo, err := h.OIDC.GetUserInfo(getRequestUserID(c)) + tokenScopes, ok := c.Get(consts.KeyOAuth2AccessScopes).(model.AccessScopes) + scopes := lo.Ternary[oidc.ScopeChecker](ok, tokenScopes, userAccessScopes{}) + + userInfo, err := h.OIDC.GetUserInfo(getRequestUserID(c), scopes) if err != nil { return herror.InternalServerError(err) } diff --git a/service/oidc/userinfo.go b/service/oidc/userinfo.go index 740a95aab..42a030a55 100644 --- a/service/oidc/userinfo.go +++ b/service/oidc/userinfo.go @@ -27,7 +27,11 @@ func NewOIDCService( } } -func (s *Service) GetUserInfo(userID uuid.UUID) (map[string]any, error) { +type ScopeChecker interface { + Contains(scope model.AccessScope) bool +} + +func (s *Service) GetUserInfo(userID uuid.UUID, scopes ScopeChecker) (map[string]any, error) { user, err := s.repo.GetUser(userID, true) if err != nil { return nil, err @@ -41,16 +45,19 @@ func (s *Service) GetUserInfo(userID uuid.UUID) (map[string]any, error) { return nil, err } - return map[string]any{ - // OIDC standard claims - "name": user.GetName(), - "email": user.GetName() + "+dummy@example.com", - "email_verified": false, - "preferred_username": user.GetName(), - "picture": s.origin + "/api/v3/public/icon/" + user.GetName(), - "updated_at": user.GetUpdatedAt(), - // traQ specific claims - "traq": map[string]any{ + // Build claims + claims := make(map[string]any) + + // Required in UserInfo response + claims["sub"] = userID.String() + + // Scope specific claims + if scopes.Contains("profile") { + claims["name"] = user.GetName() + claims["preferred_username"] = user.GetName() + claims["picture"] = s.origin + "/api/v3/public/icon/" + user.GetName() + claims["updated_at"] = user.GetUpdatedAt().Unix() + claims["traq"] = map[string]any{ "bio": user.GetBio(), "groups": groups, "tags": utils.Map(tags, func(tag model.UserTag) string { return tag.GetTag() }), @@ -62,6 +69,12 @@ func (s *Service) GetUserInfo(userID uuid.UUID) (map[string]any, error) { "state": user.GetState().Int(), "permissions": s.rbac.GetGrantedPermissions(user.GetRole()), "home_channel": user.GetHomeChannel(), - }, - }, nil + } + } + if scopes.Contains("email") { + claims["email"] = user.GetName() + "+dummy@example.com" + claims["email_verified"] = false + } + + return claims, nil } From 17b4a5a7a3d1b8a7b1dfe72027ee322fe3ee0b8c Mon Sep 17 00:00:00 2001 From: motoki317 Date: Fri, 17 Nov 2023 15:14:30 +0900 Subject: [PATCH 09/14] Split get_me and get_oidc_userinfo permissions --- migration/current.go | 2 +- migration/v35.go | 31 ++++++++++++++++++++++++--- router/v3/router.go | 2 +- service/rbac/permission/permission.go | 1 + service/rbac/permission/user.go | 2 ++ service/rbac/role/bot.go | 1 + service/rbac/role/email.go | 2 +- service/rbac/role/manage_bot.go | 1 + service/rbac/role/profile.go | 2 +- service/rbac/role/read.go | 1 + 10 files changed, 38 insertions(+), 7 deletions(-) diff --git a/migration/current.go b/migration/current.go index c4508b7d4..879885763 100644 --- a/migration/current.go +++ b/migration/current.go @@ -45,7 +45,7 @@ func Migrations() []*gormigrate.Migration { v32(), // ユーザーの表示名上限を32文字に v33(), // 未読テーブルにチャンネルIDカラムを追加 / インデックス類の更新 / 不要なレコードの削除 v34(), // 未読テーブルのcreated_atカラムをメッセージテーブルを元に更新 / カラム名を変更 - v35(), // OIDC実装のためProfileロールを追加 + v35(), // OIDC実装のため、openid, profile, emailロール、get_oidc_userinfo権限を追加 } } diff --git a/migration/v35.go b/migration/v35.go index 2009e218f..216299b3a 100644 --- a/migration/v35.go +++ b/migration/v35.go @@ -5,7 +5,7 @@ import ( "gorm.io/gorm" ) -// v35 OIDC実装のためProfileロールを追加 +// v35 OIDC実装のため、openid, profile, emailロール、get_oidc_userinfo権限を追加 func v35() *gormigrate.Migration { return &gormigrate.Migration{ ID: "35", @@ -24,7 +24,7 @@ func v35() *gormigrate.Migration { Permissions: []v35RolePermission{ { Role: "profile", - Permission: "get_me", + Permission: "get_oidc_userinfo", }, }, }, @@ -35,7 +35,7 @@ func v35() *gormigrate.Migration { Permissions: []v35RolePermission{ { Role: "profile", - Permission: "get_me", + Permission: "get_oidc_userinfo", }, }, }, @@ -46,6 +46,31 @@ func v35() *gormigrate.Migration { return err } } + + rolePermissions := []v35RolePermission{ + { + Role: "read", + Permission: "get_oidc_userinfo", + }, + { + Role: "user", + Permission: "get_oidc_userinfo", + }, + { + Role: "bot", + Permission: "get_oidc_userinfo", + }, + { + Role: "manage_bot", + Permission: "get_oidc_userinfo", + }, + } + for _, rp := range rolePermissions { + err := db.Create(&rp).Error + if err != nil { + return err + } + } return nil }, } diff --git a/router/v3/router.go b/router/v3/router.go index ac1973d83..2820ebf01 100644 --- a/router/v3/router.go +++ b/router/v3/router.go @@ -115,7 +115,7 @@ func (h *Handlers) Setup(e *echo.Group) { { apiUsersMe.GET("", h.GetMe, requires(permission.GetMe)) apiUsersMe.PATCH("", h.EditMe, requires(permission.EditMe)) - apiUsersMe.GET("/oidc", h.GetMeOIDC, requires(permission.GetMe)) + apiUsersMe.GET("/oidc", h.GetMeOIDC, requires(permission.GetOIDCUserInfo)) apiUsersMe.GET("/stamp-history", h.GetMyStampHistory, requires(permission.GetMyStampHistory)) apiUsersMe.GET("/qr-code", h.GetMyQRCode, requires(permission.GetUserQRCode), blockBot) apiUsersMe.GET("/icon", h.GetMyIcon, requires(permission.DownloadFile)) diff --git a/service/rbac/permission/permission.go b/service/rbac/permission/permission.go index 62f683d15..82904f927 100644 --- a/service/rbac/permission/permission.go +++ b/service/rbac/permission/permission.go @@ -118,6 +118,7 @@ var List = []Permission{ GetUser, RegisterUser, GetMe, + GetOIDCUserInfo, EditMe, ChangeMyIcon, ChangeMyPassword, diff --git a/service/rbac/permission/user.go b/service/rbac/permission/user.go index 9f054de56..27f6e4db6 100644 --- a/service/rbac/permission/user.go +++ b/service/rbac/permission/user.go @@ -7,6 +7,8 @@ const ( RegisterUser = Permission("register_user") // GetMe 自ユーザー情報取得権限 GetMe = Permission("get_me") + // GetOIDCUserInfo 自ユーザー情報取得権限 (OIDC専用) + GetOIDCUserInfo = Permission("get_oidc_userinfo") // EditMe 自ユーザー情報変更権限 EditMe = Permission("edit_me") // ChangeMyIcon 自ユーザーアイコン変更権限 diff --git a/service/rbac/role/bot.go b/service/rbac/role/bot.go index f42ea29e2..c7bc96ff2 100644 --- a/service/rbac/role/bot.go +++ b/service/rbac/role/bot.go @@ -20,6 +20,7 @@ var botPerms = []permission.Permission{ permission.EditChannelSubscription, permission.GetUser, permission.GetMe, + permission.GetOIDCUserInfo, permission.EditMe, permission.GetMyStampHistory, permission.ChangeMyIcon, diff --git a/service/rbac/role/email.go b/service/rbac/role/email.go index ceecfab3b..3ef13da85 100644 --- a/service/rbac/role/email.go +++ b/service/rbac/role/email.go @@ -8,5 +8,5 @@ import ( const Email = "email" var emailPerms = []permission.Permission{ - permission.GetMe, + permission.GetOIDCUserInfo, } diff --git a/service/rbac/role/manage_bot.go b/service/rbac/role/manage_bot.go index 261cd0bc9..0f83f0f99 100644 --- a/service/rbac/role/manage_bot.go +++ b/service/rbac/role/manage_bot.go @@ -11,6 +11,7 @@ var manageBotPerms = []permission.Permission{ permission.GetChannel, permission.GetUser, permission.GetMe, + permission.GetOIDCUserInfo, permission.GetWebhook, permission.CreateWebhook, permission.EditWebhook, diff --git a/service/rbac/role/profile.go b/service/rbac/role/profile.go index 2a2b0c9ac..4be58de68 100644 --- a/service/rbac/role/profile.go +++ b/service/rbac/role/profile.go @@ -8,5 +8,5 @@ import ( const Profile = "profile" var profilePerms = []permission.Permission{ - permission.GetMe, + permission.GetOIDCUserInfo, } diff --git a/service/rbac/role/read.go b/service/rbac/role/read.go index 15f7eaf9b..b3b1080d0 100644 --- a/service/rbac/role/read.go +++ b/service/rbac/role/read.go @@ -14,6 +14,7 @@ var readPerms = []permission.Permission{ permission.ConnectNotificationStream, permission.GetUser, permission.GetMe, + permission.GetOIDCUserInfo, permission.GetChannelStar, permission.GetUnread, permission.GetUserTag, From db7223614f6a84eb3bd64a2ced8d0aca66fb769c Mon Sep 17 00:00:00 2001 From: motoki317 Date: Fri, 17 Nov 2023 15:34:32 +0900 Subject: [PATCH 10/14] add userinfo endpoint swagger doc --- docs/v3-api.yaml | 116 +++++++++++++++++++++++++++++++++++++++ service/oidc/userinfo.go | 2 +- 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/docs/v3-api.yaml b/docs/v3-api.yaml index 9de7435f0..1c314ad05 100644 --- a/docs/v3-api.yaml +++ b/docs/v3-api.yaml @@ -1230,6 +1230,22 @@ paths: schema: $ref: '#/components/schemas/PatchMeRequest' description: 自身のユーザー情報を変更します。 + '/users/me/oidc': + get: + summary: 自分のユーザー詳細を取得 (OIDC UserInfo) + tags: + - me + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/OIDCUserInfo' + operationId: getOIDCUserInfo + description: | + OIDCトークンを用いてユーザー詳細を取得します。 + OIDC UserInfo Endpointです。 '/users/{userId}/messages': parameters: - $ref: '#/components/parameters/userIdInPath' @@ -5247,6 +5263,106 @@ components: - state - permissions - homeChannel + OIDCUserInfo: + title: OIDCUserInfo + type: object + description: 自分のユーザー詳細情報 + properties: + sub: + type: string + description: ユーザーUUID + format: uuid + name: + type: string + pattern: '^[a-zA-Z0-9_-]{1,32}$' + description: ユーザー名 + preferred_username: + type: string + pattern: '^[a-zA-Z0-9_-]{1,32}$' + description: ユーザー名 + picture: + type: string + description: アイコン画像URL + updated_at: + type: integer + format: int64 + description: 更新日時 + traq: + $ref: '#/components/schemas/OIDCTraqUserInfo' + email: + type: string + description: メールアドレス (フェイク値) + email_verified: + type: boolean + description: メールアドレスの確認が取れているか (フェイク値) + required: + - sub + OIDCTraqUserInfo: + title: OIDCTraqUserInfo + type: object + description: traQ特有のユーザー詳細情報 + properties: + bio: + type: string + description: 自己紹介(biography) + maxLength: 1000 + groups: + type: array + description: 所属グループのUUIDの配列 + items: + type: string + format: uuid + tags: + type: array + description: タグリスト + items: + $ref: '#/components/schemas/UserTag' + last_online: + type: string + description: 最終オンライン日時 + format: date-time + nullable: true + twitter_id: + type: string + description: Twitter ID + pattern: '^[a-zA-Z0-9_]{1,15}$' + display_name: + type: string + description: ユーザー表示名 + minLength: 0 + maxLength: 32 + icon_file_id: + type: string + format: uuid + description: アイコンファイルUUID + bot: + type: boolean + description: BOTかどうか + state: + $ref: '#/components/schemas/UserAccountState' + permissions: + type: array + description: 所有している権限の配列 + items: + $ref: '#/components/schemas/UserPermission' + home_channel: + type: string + format: uuid + description: ホームチャンネル + nullable: true + required: + - bio + - groups + - tags + - updated_at + - last_online + - twitter_id + - display_name + - icon_file_id + - bot + - state + - permissions + - home_channel PatchChannelSubscribersRequest: title: PatchChannelSubscribersRequest type: object diff --git a/service/oidc/userinfo.go b/service/oidc/userinfo.go index 42a030a55..f6e744054 100644 --- a/service/oidc/userinfo.go +++ b/service/oidc/userinfo.go @@ -72,7 +72,7 @@ func (s *Service) GetUserInfo(userID uuid.UUID, scopes ScopeChecker) (map[string } } if scopes.Contains("email") { - claims["email"] = user.GetName() + "+dummy@example.com" + claims["email"] = user.GetName() + "@example.com" claims["email_verified"] = false } From 82fe73251d82a5213bd2ea226545f099412ede06 Mon Sep 17 00:00:00 2001 From: motoki317 Date: Sat, 13 Jan 2024 18:36:25 +0900 Subject: [PATCH 11/14] Remove fake email scope value --- docs/v3-api.yaml | 6 ------ migration/current.go | 2 +- migration/v35.go | 13 +------------ model/oauth2.go | 2 +- router/oauth2/token_endpoint.go | 2 +- service/oidc/userinfo.go | 4 ---- service/rbac/role/email.go | 12 ------------ service/rbac/role/role.go | 5 ----- 8 files changed, 4 insertions(+), 42 deletions(-) delete mode 100644 service/rbac/role/email.go diff --git a/docs/v3-api.yaml b/docs/v3-api.yaml index 1c314ad05..f13217325 100644 --- a/docs/v3-api.yaml +++ b/docs/v3-api.yaml @@ -5289,12 +5289,6 @@ components: description: 更新日時 traq: $ref: '#/components/schemas/OIDCTraqUserInfo' - email: - type: string - description: メールアドレス (フェイク値) - email_verified: - type: boolean - description: メールアドレスの確認が取れているか (フェイク値) required: - sub OIDCTraqUserInfo: diff --git a/migration/current.go b/migration/current.go index 879885763..0967937c0 100644 --- a/migration/current.go +++ b/migration/current.go @@ -45,7 +45,7 @@ func Migrations() []*gormigrate.Migration { v32(), // ユーザーの表示名上限を32文字に v33(), // 未読テーブルにチャンネルIDカラムを追加 / インデックス類の更新 / 不要なレコードの削除 v34(), // 未読テーブルのcreated_atカラムをメッセージテーブルを元に更新 / カラム名を変更 - v35(), // OIDC実装のため、openid, profile, emailロール、get_oidc_userinfo権限を追加 + v35(), // OIDC実装のため、openid, profileロール、get_oidc_userinfo権限を追加 } } diff --git a/migration/v35.go b/migration/v35.go index 216299b3a..eff2183f6 100644 --- a/migration/v35.go +++ b/migration/v35.go @@ -5,7 +5,7 @@ import ( "gorm.io/gorm" ) -// v35 OIDC実装のため、openid, profile, emailロール、get_oidc_userinfo権限を追加 +// v35 OIDC実装のため、openid, profileロール、get_oidc_userinfo権限を追加 func v35() *gormigrate.Migration { return &gormigrate.Migration{ ID: "35", @@ -28,17 +28,6 @@ func v35() *gormigrate.Migration { }, }, }, - { - Name: "email", - Oauth2Scope: true, - System: true, - Permissions: []v35RolePermission{ - { - Role: "profile", - Permission: "get_oidc_userinfo", - }, - }, - }, } for _, role := range roles { err := db.Create(&role).Error diff --git a/model/oauth2.go b/model/oauth2.go index b234e9bd9..68d3cf6ad 100644 --- a/model/oauth2.go +++ b/model/oauth2.go @@ -31,7 +31,7 @@ type AccessScopes map[AccessScope]struct{} // SupportedAccessScopes 対応するスコープ一覧を返します func SupportedAccessScopes() []string { - return []string{"read", "write", "manage_bot", "openid", "profile", "email"} + return []string{"read", "write", "manage_bot", "openid", "profile"} } // Value database/sql/driver.Valuer 実装 diff --git a/router/oauth2/token_endpoint.go b/router/oauth2/token_endpoint.go index 8b6eb80da..8ade70431 100644 --- a/router/oauth2/token_endpoint.go +++ b/router/oauth2/token_endpoint.go @@ -59,7 +59,7 @@ func (h *Handler) issueIDToken(client *model.OAuth2Client, token *model.OAuth2To "exp": token.Deadline().Unix(), "iat": token.CreatedAt.Unix(), } - // Extra claims according to scopes (profile, email) + // Extra claims according to scopes (profile) userInfo, err := h.OIDC.GetUserInfo(userID, token.Scopes) if err != nil { return "", err diff --git a/service/oidc/userinfo.go b/service/oidc/userinfo.go index f6e744054..c4b0ad172 100644 --- a/service/oidc/userinfo.go +++ b/service/oidc/userinfo.go @@ -71,10 +71,6 @@ func (s *Service) GetUserInfo(userID uuid.UUID, scopes ScopeChecker) (map[string "home_channel": user.GetHomeChannel(), } } - if scopes.Contains("email") { - claims["email"] = user.GetName() + "@example.com" - claims["email_verified"] = false - } return claims, nil } diff --git a/service/rbac/role/email.go b/service/rbac/role/email.go deleted file mode 100644 index 3ef13da85..000000000 --- a/service/rbac/role/email.go +++ /dev/null @@ -1,12 +0,0 @@ -package role - -import ( - "github.com/traPtitech/traQ/service/rbac/permission" -) - -// Email ユーザー情報読み取り専用ロール (for OIDC) -const Email = "email" - -var emailPerms = []permission.Permission{ - permission.GetOIDCUserInfo, -} diff --git a/service/rbac/role/role.go b/service/rbac/role/role.go index e80518ee2..77794c9dd 100644 --- a/service/rbac/role/role.go +++ b/service/rbac/role/role.go @@ -48,11 +48,6 @@ func GetSystemRoles() Roles { oauth2Scope: true, permissions: permission.PermissionsFromArray(profilePerms), }, - Email: &systemRole{ - name: Email, - oauth2Scope: true, - permissions: permission.PermissionsFromArray(emailPerms), - }, } } From 5e7f62ebe80ba46c552891aa7168e76fde7af635 Mon Sep 17 00:00:00 2001 From: motoki317 Date: Wed, 8 May 2024 13:05:47 +0900 Subject: [PATCH 12/14] Add .well-known backend route to document Caddyfile --- docs/deployment.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/deployment.md b/docs/deployment.md index 3a33f7c1c..1b17dc0fc 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -440,6 +440,9 @@ example.com { handle /api/* { reverse_proxy backend:3000 } + handle /.well-known/* { + reverse_proxy backend:3000 + } handle /widget { uri strip_prefix /widget reverse_proxy widget:80 From 0ab33b3f6c027a636a08dbbea6a3a3730442d17d Mon Sep 17 00:00:00 2001 From: motoki317 Date: Wed, 8 May 2024 13:37:02 +0900 Subject: [PATCH 13/14] Always supply traQ name information with openid scope --- service/oidc/userinfo.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/service/oidc/userinfo.go b/service/oidc/userinfo.go index c4b0ad172..1a14cebd2 100644 --- a/service/oidc/userinfo.go +++ b/service/oidc/userinfo.go @@ -51,12 +51,18 @@ func (s *Service) GetUserInfo(userID uuid.UUID, scopes ScopeChecker) (map[string // Required in UserInfo response claims["sub"] = userID.String() + // Also supply some basic traQ ID related information + // These claims usually belong to 'profile' scope according to OpenID spec, + // but in traQ, we will just supply traQ ID related information as well + claims["name"] = user.GetName() + claims["preferred_username"] = user.GetName() + claims["picture"] = s.origin + "/api/v3/public/icon/" + user.GetName() + // Scope specific claims if scopes.Contains("profile") { - claims["name"] = user.GetName() - claims["preferred_username"] = user.GetName() - claims["picture"] = s.origin + "/api/v3/public/icon/" + user.GetName() claims["updated_at"] = user.GetUpdatedAt().Unix() + // Putting non-standard traq-specific claims under the 'traq.' key + // for clarity and to avoid possible conflict with other standard claims claims["traq"] = map[string]any{ "bio": user.GetBio(), "groups": groups, From 9d0c19e97517aa2c444d64654d66fd39b9c0080b Mon Sep 17 00:00:00 2001 From: motoki317 Date: Wed, 8 May 2024 15:26:47 +0900 Subject: [PATCH 14/14] Add always present fields in openapi docs --- docs/v3-api.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/v3-api.yaml b/docs/v3-api.yaml index f13217325..ef40a11f6 100644 --- a/docs/v3-api.yaml +++ b/docs/v3-api.yaml @@ -5291,6 +5291,9 @@ components: $ref: '#/components/schemas/OIDCTraqUserInfo' required: - sub + - name + - preferred_username + - picture OIDCTraqUserInfo: title: OIDCTraqUserInfo type: object