diff --git a/inventory/migration.go b/inventory/migration.go index f581dbc3..993e71ef 100644 --- a/inventory/migration.go +++ b/inventory/migration.go @@ -27,6 +27,14 @@ import ( // needMigration exams if required schema version is satisfied. func needMigration(client *ent.Client, ctx context.Context, requiredDbVersion string) bool { c, _ := client.Setting.Query().Where(setting.NameEQ(DBVersionPrefix + requiredDbVersion)).Count(ctx) + if c == 0 { + return true + } + + c, _ = client.Setting.Query().Where( + setting.NameEQ(OIDCSigningPrivateKeySetting), + setting.ValueNEQ(""), + ).Count(ctx) return c == 0 } @@ -66,19 +74,22 @@ func migrateDefaultSettings(l logging.Logger, client *ent.Client, ctx context.Co } // List existing settings into a map - existingSettings := make(map[string]struct{}) + existingSettings := make(map[string]*ent.Setting) settings, err := client.Setting.Query().All(ctx) if err != nil { l.Warning("Failed to query existing settings: %s", err) } for _, s := range settings { - existingSettings[s.Name] = struct{}{} + existingSettings[s.Name] = s } l.Info("Insert default settings...") for k, v := range DefaultSettings { - if _, ok := existingSettings[k]; ok { + if existing, ok := existingSettings[k]; ok { + if k == OIDCSigningPrivateKeySetting && existing.Value == "" { + client.Setting.UpdateOne(existing).SetValue(v).SaveX(ctx) + } l.Debug("Skip inserting setting %s, already exists.", k) continue } diff --git a/inventory/setting.go b/inventory/setting.go index 78ce9de9..12d21805 100644 --- a/inventory/setting.go +++ b/inventory/setting.go @@ -3,8 +3,11 @@ package inventory import ( "context" "crypto/rand" + "crypto/rsa" + "crypto/x509" "encoding/base64" "encoding/json" + "encoding/pem" "fmt" "io" @@ -483,6 +486,20 @@ var mailTemplateContents = []MailTemplateContent{ }, } +const OIDCSigningPrivateKeySetting = "oidc_signing_private_key" + +func mustGenerateOIDCSigningPrivateKey() string { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic(fmt.Errorf("failed to generate OIDC signing key: %w", err)) + } + + return string(pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + })) +} + var DefaultSettings = map[string]string{ "siteURL": `http://localhost:5212`, "siteName": `Cloudreve`, @@ -524,6 +541,7 @@ var DefaultSettings = map[string]string{ "theme_options": `{"#1976d2":{"light":{"palette":{"primary":{"main":"#1976d2","light":"#42a5f5","dark":"#1565c0"},"secondary":{"main":"#9c27b0","light":"#ba68c8","dark":"#7b1fa2"}}},"dark":{"palette":{"primary":{"main":"#90caf9","light":"#e3f2fd","dark":"#42a5f5"},"secondary":{"main":"#ce93d8","light":"#f3e5f5","dark":"#ab47bc"}}}},"#3f51b5":{"light":{"palette":{"primary":{"main":"#3f51b5"},"secondary":{"main":"#f50057"}}},"dark":{"palette":{"primary":{"main":"#9fa8da"},"secondary":{"main":"#ff4081"}}}}}`, "max_parallel_transfer": `4`, "secret_key": util.RandStringRunesCrypto(256), + OIDCSigningPrivateKeySetting: mustGenerateOIDCSigningPrivateKey(), "temp_path": "temp", "avatar_path": "avatar", "avatar_size": "4194304", @@ -689,8 +707,9 @@ var DefaultSettings = map[string]string{ } var RedactedSettings = map[string]struct{}{ - "encrypt_master_key": {}, - "secret_key": {}, + "encrypt_master_key": {}, + "secret_key": {}, + "oidc_signing_private_key": {}, } func init() { diff --git a/pkg/auth/oidc.go b/pkg/auth/oidc.go new file mode 100644 index 00000000..8b935253 --- /dev/null +++ b/pkg/auth/oidc.go @@ -0,0 +1,89 @@ +package auth + +import ( + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "math/big" + + "github.com/golang-jwt/jwt/v5" +) + +type OIDCIDTokenClaims struct { + jwt.RegisteredClaims + Nonce string `json:"nonce,omitempty"` + Name string `json:"name,omitempty"` + PreferredUsername string `json:"preferred_username,omitempty"` + Picture string `json:"picture,omitempty"` + UpdatedAt int64 `json:"updated_at,omitempty"` + Email string `json:"email,omitempty"` + EmailVerified bool `json:"email_verified,omitempty"` +} + +type JWKSet struct { + Keys []JWK `json:"keys"` +} + +type JWK struct { + Kty string `json:"kty"` + Use string `json:"use"` + Alg string `json:"alg"` + Kid string `json:"kid"` + N string `json:"n"` + E string `json:"e"` +} + +func SignOIDCIDToken(privateKeyRaw string, claims *OIDCIDTokenClaims) (string, error) { + key, err := parseRSAPrivateKey(privateKeyRaw) + if err != nil { + return "", err + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header["kid"] = oidcSigningKeyID(&key.PublicKey) + return token.SignedString(key) +} + +func OIDCJWKSet(privateKeyRaw string) (*JWKSet, error) { + key, err := parseRSAPrivateKey(privateKeyRaw) + if err != nil { + return nil, err + } + + kid := oidcSigningKeyID(&key.PublicKey) + return &JWKSet{Keys: []JWK{buildJWK(&key.PublicKey, kid)}}, nil +} + +func parseRSAPrivateKey(privateKeyRaw string) (*rsa.PrivateKey, error) { + block, _ := pem.Decode([]byte(privateKeyRaw)) + if block == nil { + return nil, fmt.Errorf("invalid OIDC signing key PEM") + } + + key, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("invalid OIDC signing key: %w", err) + } + + return key, nil +} + +func oidcSigningKeyID(key *rsa.PublicKey) string { + der, _ := x509.MarshalPKIXPublicKey(key) + sum := sha256.Sum256(der) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +func buildJWK(key *rsa.PublicKey, kid string) JWK { + return JWK{ + Kty: "RSA", + Use: "sig", + Alg: "RS256", + Kid: kid, + N: base64.RawURLEncoding.EncodeToString(key.N.Bytes()), + E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes()), + } +} diff --git a/pkg/cluster/routes/routes.go b/pkg/cluster/routes/routes.go index bd51d215..79b18f1b 100644 --- a/pkg/cluster/routes/routes.go +++ b/pkg/cluster/routes/routes.go @@ -43,6 +43,11 @@ func MasterPingUrl(base *url.URL) *url.URL { return base.ResolveReference(masterPing) } +func MasterOIDCEndpointUrl(base *url.URL, endpoint string) string { + route, _ := url.Parse(endpoint) + return base.ResolveReference(route).String() +} + func MasterSlaveCallbackUrl(base *url.URL, driver, id, secret string) *url.URL { apiBaseURI, _ := url.Parse(path.Join(constants.APIPrefix+"/callback", driver, id, secret)) return base.ResolveReference(apiBaseURI) diff --git a/pkg/setting/provider.go b/pkg/setting/provider.go index e9816c45..9ebef198 100644 --- a/pkg/setting/provider.go +++ b/pkg/setting/provider.go @@ -52,6 +52,8 @@ type ( SiteURL(ctx context.Context) *url.URL // SecretKey returns the secret key for general signature. SecretKey(ctx context.Context) string + // OIDCSigningPrivateKey returns the private key used to sign OIDC ID tokens. + OIDCSigningPrivateKey(ctx context.Context) string // ActivationEmailTemplate returns the email template for activation. ActivationEmailTemplate(ctx context.Context) []EmailTemplate // ResetEmailTemplate returns the email template for reset password. @@ -740,6 +742,10 @@ func (s *settingProvider) SecretKey(ctx context.Context) string { return s.getString(ctx, "secret_key", "") } +func (s *settingProvider) OIDCSigningPrivateKey(ctx context.Context) string { + return s.getString(ctx, "oidc_signing_private_key", "") +} + func (s *settingProvider) AllSiteURLs(ctx context.Context) []*url.URL { rawUrls := s.getStringList(ctx, "siteURL", []string{"http://localhost"}) if len(rawUrls) == 0 { diff --git a/routers/controllers/oauth.go b/routers/controllers/oauth.go index 9c4472bf..bc196af2 100644 --- a/routers/controllers/oauth.go +++ b/routers/controllers/oauth.go @@ -6,6 +6,23 @@ import ( "github.com/gin-gonic/gin" ) +func OpenIDConfiguration(c *gin.Context) { + service := &oauth.DiscoveryService{} + c.JSON(200, service.Get(c)) +} + +func OpenIDJWKS(c *gin.Context) { + service := &oauth.JWKService{} + res, err := service.Get(c) + if err != nil { + c.JSON(500, serializer.Err(c, err)) + c.Abort() + return + } + + c.JSON(200, res) +} + func GetAppRegistration(c *gin.Context) { service := ParametersFromContext[*oauth.GetAppRegistrationService](c, oauth.GetAppRegistrationParamCtx{}) app, err := service.Get(c) diff --git a/routers/router.go b/routers/router.go index 846f81af..197e8b46 100644 --- a/routers/router.go +++ b/routers/router.go @@ -208,6 +208,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { */ r.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{"/api/"}))) r.Use(middleware.SharePreview(dep)) + r.GET(".well-known/openid-configuration", controllers.OpenIDConfiguration) r.Use(middleware.FrontendFileHandler(dep)) r.GET("manifest.json", controllers.Manifest) @@ -333,6 +334,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { controllers.FromForm[oauth.ExchangeTokenService](oauth.ExchangeTokenParamCtx{}), controllers.ExchangeToken, ) + oauthRouter.GET("jwks", controllers.OpenIDJWKS) oauthRouter.GET("userinfo", middleware.LoginRequired(), controllers.FromQuery[oauth.UserInfoService](oauth.UserInfoParamCtx{}), diff --git a/service/oauth/oauth.go b/service/oauth/oauth.go index a15940e1..064799ad 100644 --- a/service/oauth/oauth.go +++ b/service/oauth/oauth.go @@ -16,6 +16,7 @@ import ( "github.com/cloudreve/Cloudreve/v4/pkg/serializer" "github.com/cloudreve/Cloudreve/v4/pkg/util" "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" "github.com/samber/lo" ) @@ -50,6 +51,7 @@ type ( ResponseType string `json:"response_type" binding:"required,eq=code"` RedirectURI string `json:"redirect_uri" binding:"required"` State string `json:"state" binding:"max=4096"` + Nonce string `json:"nonce" binding:"max=4096"` Scope string `json:"scope" binding:"required"` CodeChallenge string `json:"code_challenge" binding:"max=255"` CodeChallengeMethod string `json:"code_challenge_method" binding:"omitempty,eq=S256"` @@ -84,7 +86,7 @@ func (s *GrantService) Get(c *gin.Context) (*GrantResponse, error) { } // Parse requested scopes (space-separated per OAuth 2.0 spec) - requestedScopes := strings.Split(s.Scope, " ") + requestedScopes := strings.Fields(s.Scope) // Validate requested scopes: must be a subset of registered app scopes if !auth.ValidateScopes(requestedScopes, app.Scopes) { @@ -108,6 +110,7 @@ func (s *GrantService) Get(c *gin.Context) (*GrantResponse, error) { UserID: user.ID, Scopes: requestedScopes, RedirectURI: s.RedirectURI, + Nonce: s.Nonce, CodeChallenge: s.CodeChallenge, } @@ -129,6 +132,7 @@ type ( ClientSecret string `form:"client_secret" binding:"required"` GrantType string `form:"grant_type" binding:"required,eq=authorization_code"` Code string `form:"code" binding:"required"` + RedirectURI string `form:"redirect_uri"` CodeVerifier string `form:"code_verifier"` } ) @@ -159,6 +163,11 @@ func (s *ExchangeTokenService) Exchange(c *gin.Context) (*TokenResponse, error) if authCode.ClientID != s.ClientID { return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Client ID mismatch", nil) } + if s.RedirectURI == "" { + dep.Logger().Warning("OAuth client %q did not provide redirect_uri in token request; it may become required in a future release", s.ClientID) + } else if authCode.RedirectURI != s.RedirectURI { + return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Redirect URI mismatch", nil) + } // 3. Verify PKCE: SHA256(code_verifier) should match code_challenge if authCode.CodeChallenge != "" { @@ -230,9 +239,47 @@ func (s *ExchangeTokenService) Exchange(c *gin.Context) (*TokenResponse, error) } } + if lo.Contains(authCode.Scopes, types.ScopeOpenID) { + idToken, err := buildIDToken(c, dep, user, s.ClientID, authCode.Scopes, token.AccessExpires, authCode.Nonce) + if err != nil { + return nil, serializer.NewError(serializer.CodeEncryptError, "Failed to issue ID token", err) + } + resp.IDToken = idToken + } + return resp, nil } +func buildIDToken(c *gin.Context, dep dependency.Dep, user *ent.User, clientID string, scopes []string, expires time.Time, nonce string) (string, error) { + sub := hashid.EncodeUserID(dep.HashIDEncoder(), user.ID) + claims := &auth.OIDCIDTokenClaims{ + Nonce: nonce, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: oidcIssuer(c).String(), + Subject: sub, + Audience: jwt.ClaimStrings{clientID}, + IssuedAt: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(expires), + }, + } + + for _, scope := range scopes { + switch scope { + case types.ScopeProfile: + siteUrl := dep.SettingProvider().SiteURL(c) + claims.Name = user.Nick + claims.PreferredUsername = user.Nick + claims.Picture = routes.MasterUserAvatarUrl(siteUrl, sub).String() + claims.UpdatedAt = user.UpdatedAt.Unix() + case types.ScopeEmail: + claims.Email = user.Email + claims.EmailVerified = true + } + } + + return auth.SignOIDCIDToken(dep.SettingProvider().OIDCSigningPrivateKey(c), claims) +} + type ( DeleteOAuthGrantParamCtx struct{} DeleteOAuthGrantService struct { diff --git a/service/oauth/oidc.go b/service/oauth/oidc.go new file mode 100644 index 00000000..c37861ac --- /dev/null +++ b/service/oauth/oidc.go @@ -0,0 +1,72 @@ +package oauth + +import ( + "net/url" + + "github.com/cloudreve/Cloudreve/v4/application/constants" + "github.com/cloudreve/Cloudreve/v4/application/dependency" + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/auth" + "github.com/cloudreve/Cloudreve/v4/pkg/cluster/routes" + "github.com/gin-gonic/gin" +) + +type DiscoveryService struct{} + +type JWKService struct{} + +func (s *DiscoveryService) Get(c *gin.Context) *DiscoveryResponse { + issuer := oidcIssuer(c) + return &DiscoveryResponse{ + Issuer: issuer.String(), + AuthorizationEndpoint: routes.MasterOIDCEndpointUrl(issuer, "/session/authorize"), + TokenEndpoint: routes.MasterOIDCEndpointUrl(issuer, constants.APIPrefix+"/session/oauth/token"), + UserInfoEndpoint: routes.MasterOIDCEndpointUrl(issuer, constants.APIPrefix+"/session/oauth/userinfo"), + JWKSURI: routes.MasterOIDCEndpointUrl(issuer, constants.APIPrefix+"/session/oauth/jwks"), + ResponseTypesSupported: []string{ + "code", + }, + GrantTypesSupported: []string{ + "authorization_code", + }, + SubjectTypesSupported: []string{ + "public", + }, + IDTokenSigningAlgValuesSupported: []string{ + "RS256", + }, + TokenEndpointAuthMethods: []string{ + "client_secret_post", + }, + CodeChallengeMethodsSupported: []string{ + "S256", + }, + ScopesSupported: []string{ + types.ScopeOpenID, + types.ScopeProfile, + types.ScopeEmail, + }, + ClaimsSupported: []string{ + "sub", + "name", + "preferred_username", + "picture", + "updated_at", + "email", + "email_verified", + }, + } +} + +func (s *JWKService) Get(c *gin.Context) (*auth.JWKSet, error) { + dep := dependency.FromContext(c) + return auth.OIDCJWKSet(dep.SettingProvider().OIDCSigningPrivateKey(c)) +} + +func oidcIssuer(c *gin.Context) *url.URL { + dep := dependency.FromContext(c) + issuer := *dep.SettingProvider().SiteURL(c) + issuer.RawQuery = "" + issuer.Fragment = "" + return &issuer +} diff --git a/service/oauth/response.go b/service/oauth/response.go index 49a99df1..88d591d1 100644 --- a/service/oauth/response.go +++ b/service/oauth/response.go @@ -19,6 +19,22 @@ type AppRegistration struct { ConstentedScopes []string `json:"consented_scopes,omitempty"` } +type DiscoveryResponse struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + UserInfoEndpoint string `json:"userinfo_endpoint"` + JWKSURI string `json:"jwks_uri"` + ResponseTypesSupported []string `json:"response_types_supported"` + GrantTypesSupported []string `json:"grant_types_supported"` + SubjectTypesSupported []string `json:"subject_types_supported"` + IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"` + TokenEndpointAuthMethods []string `json:"token_endpoint_auth_methods_supported"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` + ScopesSupported []string `json:"scopes_supported"` + ClaimsSupported []string `json:"claims_supported"` +} + func BuildAppRegistration(app *ent.OAuthClient, grant *ent.OAuthGrant) *AppRegistration { res := &AppRegistration{ ID: app.GUID, @@ -50,6 +66,7 @@ type TokenResponse struct { ExpiresIn int64 `json:"expires_in"` RefreshTokenExpiresIn int64 `json:"refresh_token_expires_in"` RefreshToken string `json:"refresh_token,omitempty"` + IDToken string `json:"id_token,omitempty"` Scope string `json:"scope"` } @@ -77,6 +94,7 @@ type AuthorizationCode struct { UserID int `json:"user_id"` Scopes []string `json:"scopes"` RedirectURI string `json:"redirect_uri"` + Nonce string `json:"nonce"` CodeChallenge string `json:"code_challenge"` }