feat(oauth): OAuth for 3rd party apps

This commit is contained in:
Aaron Liu
2026-01-23 15:22:29 +08:00
parent a908ec462f
commit a84c5d8e97
51 changed files with 10542 additions and 39 deletions

View File

@@ -12,6 +12,7 @@ import (
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/ent/group"
"github.com/cloudreve/Cloudreve/v4/ent/node"
"github.com/cloudreve/Cloudreve/v4/ent/oauthclient"
"github.com/cloudreve/Cloudreve/v4/ent/setting"
"github.com/cloudreve/Cloudreve/v4/ent/storagepolicy"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
@@ -45,6 +46,10 @@ func migrate(l logging.Logger, client *ent.Client, ctx context.Context, kv cache
return fmt.Errorf("failed migrating default storage policy: %w", err)
}
if err := migrateOAuthClient(l, client, ctx); err != nil {
return fmt.Errorf("failed migrating OAuth client: %w", err)
}
if err := applyPatches(l, client, ctx, requiredDbVersion); err != nil {
return fmt.Errorf("failed applying schema patches: %w", err)
}
@@ -269,6 +274,70 @@ func migrateMasterNode(l logging.Logger, client *ent.Client, ctx context.Context
return nil
}
const (
OAuthClientDesktopGUID = "393a1839-f52e-498e-9972-e77cc2241eee"
OAuthClientDesktopSecret = "8GaQIu3lOSdqYoDHi9cR8IZ4pvuMH8ya"
OAuthClientDesktopName = "application:oauth.desktop"
OAuthClientDesktopRedirectURI = "/callback/desktop"
OAuthClientiOSGUID = "220db97a-44a3-44f7-99b6-d767262b4daa"
OAuthClientiOSSecret = "1kxOW4IyVOkPlsKCnTwzfHyP8XrbpfaF"
OAuthClientiOSName = "application:setting.iOSApp"
OAuthClientiOSRedirectURI = "/callback/ios"
)
func migrateOAuthClient(l logging.Logger, client *ent.Client, ctx context.Context) error {
if err := migrateOAuthClientDesktop(l, client, ctx); err != nil {
return err
}
if err := migrateOAuthClientiOS(l, client, ctx); err != nil {
return err
}
return nil
}
func migrateOAuthClientiOS(l logging.Logger, client *ent.Client, ctx context.Context) error {
if _, err := client.OAuthClient.Query().Where(oauthclient.GUID(OAuthClientiOSGUID)).First(ctx); err == nil {
l.Info("Default OAuth client (GUID=%s) already exists, skip migrating.", OAuthClientiOSGUID)
return nil
}
if _, err := client.OAuthClient.Create().
SetGUID(OAuthClientiOSGUID).
SetSecret(OAuthClientiOSSecret).
SetName(OAuthClientiOSName).
SetRedirectUris([]string{OAuthClientiOSRedirectURI}).
SetScopes([]string{"profile", "email", "openid", "offline_access", "UserInfo.Write", "UserSecurityInfo.Write", "Workflow.Write", "Files.Write", "Shares.Write", "Finance.Write", "DavAccount.Write"}).
SetProps(&types.OAuthClientProps{Icon: "/static/img/cloudreve_ios.svg", RefreshTokenTTL: 7776000}).
SetIsEnabled(true).
Save(ctx); err != nil {
return fmt.Errorf("failed to create default OAuth client: %w", err)
}
return nil
}
func migrateOAuthClientDesktop(l logging.Logger, client *ent.Client, ctx context.Context) error {
if _, err := client.OAuthClient.Query().Where(oauthclient.GUID(OAuthClientDesktopGUID)).First(ctx); err == nil {
l.Info("Default OAuth client (GUID=%s) already exists, skip migrating.", OAuthClientDesktopGUID)
return nil
}
if _, err := client.OAuthClient.Create().
SetGUID(OAuthClientDesktopGUID).
SetSecret(OAuthClientDesktopSecret).
SetName(OAuthClientDesktopName).
SetRedirectUris([]string{OAuthClientDesktopRedirectURI}).
SetScopes([]string{"profile", "email", "openid", "offline_access", "UserInfo.Write", "Workflow.Write", "Files.Write", "Shares.Write"}).
SetProps(&types.OAuthClientProps{Icon: "/static/img/cloudreve.svg", RefreshTokenTTL: 7776000}).
SetIsEnabled(true).
Save(ctx); err != nil {
return fmt.Errorf("failed to create default OAuth client: %w", err)
}
return nil
}
type (
PatchFunc func(l logging.Logger, client *ent.Client, ctx context.Context) error
Patch struct {
@@ -467,7 +536,7 @@ var patches = []Patch{
for i, t := range mailResetTemplate {
mailResetTemplate[i].Title = fmt.Sprintf("[{{ .CommonContext.SiteBasic.Name }}] %s", t.Title)
}
newMailResetTemplate, err := json.Marshal(mailResetTemplate)
if err != nil {
return fmt.Errorf("failed to marshal mail_reset_template setting: %w", err)

82
inventory/oauth_client.go Normal file
View File

@@ -0,0 +1,82 @@
package inventory
import (
"context"
"time"
"entgo.io/ent/dialect/sql"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/ent/oauthclient"
"github.com/cloudreve/Cloudreve/v4/ent/oauthgrant"
)
type (
OAuthClientClient interface {
TxOperator
// GetByGUID returns the OAuth client by its GUID (client_id).
GetByGUID(ctx context.Context, guid string) (*ent.OAuthClient, error)
// GetByGUIDWithGrants returns the OAuth client by its GUID (client_id) with the grants for the user.
GetByGUIDWithGrants(ctx context.Context, guid string, uid int) (*ent.OAuthClient, error)
// UpsertGrant creates or updates an OAuth grant for a user and client.
UpsertGrant(ctx context.Context, userID, clientID int, scopes []string) error
// UpdateGrantLastUsedAt updates the last used at for an OAuth grant for a user and client.
UpdateGrantLastUsedAt(ctx context.Context, userID, clientID int) error
}
)
func NewOAuthClientClient(client *ent.Client) OAuthClientClient {
return &oauthClientClient{
client: client,
}
}
type oauthClientClient struct {
client *ent.Client
}
func (c *oauthClientClient) SetClient(newClient *ent.Client) TxOperator {
return &oauthClientClient{client: newClient}
}
func (c *oauthClientClient) GetClient() *ent.Client {
return c.client
}
func (c *oauthClientClient) GetByGUID(ctx context.Context, guid string) (*ent.OAuthClient, error) {
return c.client.OAuthClient.Query().
Where(oauthclient.GUID(guid), oauthclient.IsEnabled(true)).
First(ctx)
}
func (c *oauthClientClient) GetByGUIDWithGrants(ctx context.Context, guid string, uid int) (*ent.OAuthClient, error) {
stm := c.client.OAuthClient.Query().
Where(oauthclient.GUID(guid), oauthclient.IsEnabled(true))
if uid > 0 {
stm.WithGrants(func(ogq *ent.OAuthGrantQuery) {
ogq.Where(oauthgrant.UserID(uid))
})
}
return stm.First(ctx)
}
func (c *oauthClientClient) UpsertGrant(ctx context.Context, userID, clientID int, scopes []string) error {
return c.client.OAuthGrant.Create().
SetUserID(userID).
SetClientID(clientID).
SetScopes(scopes).
SetLastUsedAt(time.Now()).
OnConflict(
sql.ConflictColumns(oauthgrant.FieldUserID, oauthgrant.FieldClientID),
).
UpdateScopes().
UpdateLastUsedAt().
Exec(ctx)
}
func (c *oauthClientClient) UpdateGrantLastUsedAt(ctx context.Context, userID, clientID int) error {
return c.client.OAuthGrant.Update().
Where(oauthgrant.UserID(userID), oauthgrant.ClientID(clientID)).
SetLastUsedAt(time.Now()).
Exec(ctx)
}

View File

@@ -207,6 +207,12 @@ type (
ShowReadMe bool `json:"show_read_me,omitempty"`
}
OAuthClientProps struct {
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
RefreshTokenTTL int64 `json:"refresh_token_ttl,omitempty"` // in seconds, 0 means default
}
FileTypeIconSetting struct {
Exts []string `json:"exts"`
Icon string `json:"icon,omitempty"`
@@ -363,3 +369,26 @@ const (
const (
CipherAES256CTR Cipher = "aes-256-ctr"
)
const (
ScopeProfile = "profile"
ScopeEmail = "email"
ScopeOpenID = "openid"
ScopeOfflineAccess = "offline_access"
ScopeUserInfoRead = "UserInfo.Read"
ScopeUserInfoWrite = "UserInfo.Write"
ScopeUserSecurityInfoRead = "UserSecurityInfo.Read"
ScopeUserSecurityInfoWrite = "UserSecurityInfo.Write"
ScopeWorkflowRead = "Workflow.Read"
ScopeWorkflowWrite = "Workflow.Write"
ScopeAdminRead = "Admin.Read"
ScopeAdminWrite = "Admin.Write"
ScopeFilesRead = "Files.Read"
ScopeFilesWrite = "Files.Write"
ScopeSharesRead = "Shares.Read"
ScopeSharesWrite = "Shares.Write"
ScopeFinanceRead = "Finance.Read"
ScopeFinanceWrite = "Finance.Write"
ScopeDavAccountRead = "DavAccount.Read"
ScopeDavAccountWrite = "DavAccount.Write"
)