pull/3472/merge
Ricky 6 days ago committed by GitHub
commit e2a09536ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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
}

@ -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() {

@ -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()),
}
}

@ -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)

@ -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 {

@ -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)

@ -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{}),

@ -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 {

@ -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
}

@ -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"`
}

Loading…
Cancel
Save