chore: 初始化 Cloudreve 源码仓库
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/application/dependency"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/auth"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/cluster/routes"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/util"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type (
|
||||
GetAppRegistrationParamCtx struct{}
|
||||
GetAppRegistrationService struct {
|
||||
AppID string `uri:"app_id" binding:"required"`
|
||||
}
|
||||
)
|
||||
|
||||
func (s *GetAppRegistrationService) Get(c *gin.Context) (*AppRegistration, error) {
|
||||
dep := dependency.FromContext(c)
|
||||
oAuthClient := dep.OAuthClientClient()
|
||||
|
||||
app, err := oAuthClient.GetByGUIDWithGrants(c, s.AppID, inventory.UserIDFromContext(c))
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeNotFound, "App not found", err)
|
||||
}
|
||||
|
||||
var grant *ent.OAuthGrant
|
||||
if len(app.Edges.Grants) == 1 {
|
||||
grant = app.Edges.Grants[0]
|
||||
}
|
||||
|
||||
return BuildAppRegistration(app, grant), nil
|
||||
}
|
||||
|
||||
type (
|
||||
GrantParamCtx struct{}
|
||||
GrantService struct {
|
||||
ClientID string `json:"client_id" binding:"required"`
|
||||
ResponseType string `json:"response_type" binding:"required,eq=code"`
|
||||
RedirectURI string `json:"redirect_uri" binding:"required"`
|
||||
State string `json:"state" 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"`
|
||||
}
|
||||
)
|
||||
|
||||
func (s *GrantService) Get(c *gin.Context) (*GrantResponse, error) {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
kv := dep.KV()
|
||||
oAuthClient := dep.OAuthClientClient()
|
||||
if s.CodeChallenge != "" && s.CodeChallengeMethod == "" {
|
||||
s.CodeChallengeMethod = "S256"
|
||||
}
|
||||
|
||||
// 1. Get app registration and grant
|
||||
app, err := oAuthClient.GetByGUIDWithGrants(c, s.ClientID, user.ID)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeNotFound, "App not found", err)
|
||||
}
|
||||
|
||||
// 2. Validate redirect URL: must match one of the registered redirect URIs
|
||||
redirectValid := false
|
||||
for _, uri := range app.RedirectUris {
|
||||
if uri == s.RedirectURI {
|
||||
redirectValid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !redirectValid {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid redirect URI", nil)
|
||||
}
|
||||
|
||||
// Parse requested scopes (space-separated per OAuth 2.0 spec)
|
||||
requestedScopes := strings.Split(s.Scope, " ")
|
||||
|
||||
// Validate requested scopes: must be a subset of registered app scopes
|
||||
if !auth.ValidateScopes(requestedScopes, app.Scopes) {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid scope requested", nil)
|
||||
}
|
||||
|
||||
// Must have openid scope
|
||||
if !lo.Contains(requestedScopes, types.ScopeOpenID) {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "openid scope required", nil)
|
||||
}
|
||||
|
||||
// 3. Create/update grant
|
||||
if err := oAuthClient.UpsertGrant(c, user.ID, app.ID, requestedScopes); err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeDBError, "Failed to create grant", err)
|
||||
}
|
||||
|
||||
// 4. Generate code and save required state into KV for future token exchange request.
|
||||
code := util.RandStringRunesCrypto(128)
|
||||
authCode := &AuthorizationCode{
|
||||
ClientID: s.ClientID,
|
||||
UserID: user.ID,
|
||||
Scopes: requestedScopes,
|
||||
RedirectURI: s.RedirectURI,
|
||||
CodeChallenge: s.CodeChallenge,
|
||||
}
|
||||
|
||||
// Store auth code in KV with 10 minute TTL
|
||||
if err := kv.Set(authCodeKey(code), authCode, 600); err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeCacheOperation, "Failed to store authorization code", err)
|
||||
}
|
||||
|
||||
return &GrantResponse{
|
||||
Code: code,
|
||||
State: s.State,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type (
|
||||
ExchangeTokenParamCtx struct{}
|
||||
ExchangeTokenService struct {
|
||||
ClientID string `form:"client_id" binding:"required"`
|
||||
ClientSecret string `form:"client_secret" binding:"required"`
|
||||
GrantType string `form:"grant_type" binding:"required,eq=authorization_code"`
|
||||
Code string `form:"code" binding:"required"`
|
||||
CodeVerifier string `form:"code_verifier"`
|
||||
}
|
||||
)
|
||||
|
||||
func (s *ExchangeTokenService) Exchange(c *gin.Context) (*TokenResponse, error) {
|
||||
dep := dependency.FromContext(c)
|
||||
kv := dep.KV()
|
||||
oAuthClient := dep.OAuthClientClient()
|
||||
userClient := dep.UserClient()
|
||||
tokenAuth := dep.TokenAuth()
|
||||
|
||||
// 1. Retrieve and validate authorization code from KV
|
||||
codeKey := authCodeKey(s.Code)
|
||||
authCodeRaw, ok := kv.Get(codeKey)
|
||||
if !ok {
|
||||
return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Invalid or expired authorization code", nil)
|
||||
}
|
||||
|
||||
authCode, ok := authCodeRaw.(*AuthorizationCode)
|
||||
if !ok {
|
||||
return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Invalid authorization code", nil)
|
||||
}
|
||||
|
||||
// Delete the code immediately to prevent replay attacks
|
||||
_ = kv.Delete("", codeKey)
|
||||
|
||||
// 2. Validate client_id matches the one in authorization code
|
||||
if authCode.ClientID != s.ClientID {
|
||||
return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Client ID mismatch", nil)
|
||||
}
|
||||
|
||||
// 3. Verify PKCE: SHA256(code_verifier) should match code_challenge
|
||||
if authCode.CodeChallenge != "" {
|
||||
verifierHash := sha256.Sum256([]byte(s.CodeVerifier))
|
||||
expectedChallenge := base64.RawURLEncoding.EncodeToString(verifierHash[:])
|
||||
if expectedChallenge != authCode.CodeChallenge {
|
||||
return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Invalid code verifier", nil)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Validate client secret
|
||||
app, err := oAuthClient.GetByGUID(c, s.ClientID)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeNotFound, "App not found", err)
|
||||
}
|
||||
|
||||
if app.Secret != s.ClientSecret {
|
||||
return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Invalid client secret", nil)
|
||||
}
|
||||
|
||||
// 5. Validate scopes are still valid for this app
|
||||
if !auth.ValidateScopes(authCode.Scopes, app.Scopes) {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid scope", nil)
|
||||
}
|
||||
|
||||
// 6. Get user
|
||||
user, err := userClient.GetActiveByID(c, authCode.UserID)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeUserNotFound, "User not found", err)
|
||||
}
|
||||
|
||||
// 7. Determine refresh token TTL override from app settings
|
||||
var refreshTTLOverride time.Duration
|
||||
if app.Props != nil && app.Props.RefreshTokenTTL > 0 {
|
||||
refreshTTLOverride = time.Duration(app.Props.RefreshTokenTTL) * time.Second
|
||||
}
|
||||
|
||||
// 8. Issue tokens
|
||||
token, err := tokenAuth.Issue(c, &auth.IssueTokenArgs{
|
||||
User: user,
|
||||
ClientID: s.ClientID,
|
||||
Scopes: authCode.Scopes,
|
||||
RefreshTTLOverride: refreshTTLOverride,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Failed to issue token", err)
|
||||
}
|
||||
|
||||
// 9. Update grant last used at
|
||||
if err := oAuthClient.UpdateGrantLastUsedAt(c, user.ID, app.ID); err != nil {
|
||||
dep.Logger().Warning("Failed to update grant last used at: %s", err)
|
||||
}
|
||||
|
||||
// 10.
|
||||
|
||||
// 11. Build response, only include refresh token if offline_access scope is present
|
||||
resp := &TokenResponse{
|
||||
AccessToken: token.AccessToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int64(time.Until(token.AccessExpires).Seconds()),
|
||||
RefreshTokenExpiresIn: int64(time.Until(token.RefreshExpires).Seconds()),
|
||||
Scope: strings.Join(authCode.Scopes, " "),
|
||||
}
|
||||
|
||||
for _, scope := range authCode.Scopes {
|
||||
if scope == types.ScopeOfflineAccess {
|
||||
resp.RefreshToken = token.RefreshToken
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
type (
|
||||
DeleteOAuthGrantParamCtx struct{}
|
||||
DeleteOAuthGrantService struct {
|
||||
AppID string `uri:"app_id" binding:"required"`
|
||||
}
|
||||
)
|
||||
|
||||
func (s *DeleteOAuthGrantService) Delete(c *gin.Context) error {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
oAuthClient := dep.OAuthClientClient()
|
||||
|
||||
// Delete the grant - the method validates that the grant belongs to the current user
|
||||
deleted, err := oAuthClient.DeleteGrantByUserAndClientGUID(c, user.ID, s.AppID)
|
||||
if err != nil {
|
||||
return serializer.NewError(serializer.CodeDBError, "Failed to delete OAuth grant", err)
|
||||
}
|
||||
|
||||
if !deleted {
|
||||
return serializer.NewError(serializer.CodeNotFound, "OAuth grant not found", nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type (
|
||||
UserInfoParamCtx struct{}
|
||||
UserInfoService struct{}
|
||||
)
|
||||
|
||||
// GetUserInfo returns OpenID Connect userinfo based on the access token's scopes.
|
||||
// The response fields are conditionally included based on granted scopes:
|
||||
// - openid: sub (always required)
|
||||
// - profile: name, preferred_username, picture, updated_at
|
||||
// - email: email, email_verified
|
||||
func (s *UserInfoService) GetUserInfo(c *gin.Context) (*UserInfoResponse, error) {
|
||||
dep := dependency.FromContext(c)
|
||||
u := inventory.UserFromContext(c)
|
||||
hashIDEncoder := dep.HashIDEncoder()
|
||||
|
||||
// 1. Get and parse the access token from Authorization header
|
||||
hasScopes, scopes := auth.GetScopesFromContext(c)
|
||||
if hasScopes {
|
||||
// 2. Verify openid scope is present (required for userinfo endpoint)
|
||||
hasOpenID := false
|
||||
for _, scope := range scopes {
|
||||
if scope == types.ScopeOpenID {
|
||||
hasOpenID = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasOpenID {
|
||||
return nil, serializer.NewError(serializer.CodeNoPermissionErr, "openid scope required", nil)
|
||||
}
|
||||
} else {
|
||||
scopes = []string{types.ScopeOpenID, types.ScopeProfile, types.ScopeEmail}
|
||||
}
|
||||
|
||||
// 4. Build response based on scopes
|
||||
resp := &UserInfoResponse{
|
||||
Sub: hashid.EncodeUserID(hashIDEncoder, u.ID),
|
||||
}
|
||||
|
||||
// Check scopes and populate fields accordingly
|
||||
for _, scope := range scopes {
|
||||
switch scope {
|
||||
case types.ScopeProfile:
|
||||
siteUrl := dep.SettingProvider().SiteURL(c)
|
||||
resp.Name = u.Nick
|
||||
resp.PreferredUsername = u.Nick
|
||||
resp.Picture = routes.MasterUserAvatarUrl(siteUrl, hashid.EncodeUserID(hashIDEncoder, u.ID)).String()
|
||||
resp.UpdatedAt = u.UpdatedAt.Unix()
|
||||
case types.ScopeEmail:
|
||||
resp.Email = u.Email
|
||||
resp.EmailVerified = true // Users in Cloudreve have verified emails
|
||||
}
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||
)
|
||||
|
||||
const (
|
||||
authCodeKeyPrefix = "oauth_code_"
|
||||
)
|
||||
|
||||
type AppRegistration struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
HomepageURL string `json:"homepage_url,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
ConstentedScopes []string `json:"consented_scopes,omitempty"`
|
||||
}
|
||||
|
||||
func BuildAppRegistration(app *ent.OAuthClient, grant *ent.OAuthGrant) *AppRegistration {
|
||||
res := &AppRegistration{
|
||||
ID: app.GUID,
|
||||
Name: app.Name,
|
||||
HomepageURL: app.HomepageURL,
|
||||
}
|
||||
|
||||
if app.Props != nil {
|
||||
res.Description = app.Props.Description
|
||||
res.Icon = app.Props.Icon
|
||||
}
|
||||
|
||||
if grant != nil {
|
||||
res.ConstentedScopes = grant.Scopes
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
type GrantResponse struct {
|
||||
Code string `json:"code"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// TokenResponse represents the OAuth token response.
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
RefreshTokenExpiresIn int64 `json:"refresh_token_expires_in"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
// UserInfoResponse represents the OpenID Connect userinfo response.
|
||||
// Fields are conditionally included based on granted scopes.
|
||||
type UserInfoResponse struct {
|
||||
// Always included (openid scope)
|
||||
Sub string `json:"sub"`
|
||||
|
||||
// Profile scope
|
||||
Name string `json:"name,omitempty"`
|
||||
PreferredUsername string `json:"preferred_username,omitempty"`
|
||||
Picture string `json:"picture,omitempty"`
|
||||
UpdatedAt int64 `json:"updated_at,omitempty"`
|
||||
|
||||
// Email scope
|
||||
Email string `json:"email,omitempty"`
|
||||
EmailVerified bool `json:"email_verified,omitempty"`
|
||||
}
|
||||
|
||||
// AuthorizationCode represents the data stored in KV for an OAuth authorization code.
|
||||
// Used for PKCE verification during token exchange.
|
||||
type AuthorizationCode struct {
|
||||
ClientID string `json:"client_id"`
|
||||
UserID int `json:"user_id"`
|
||||
Scopes []string `json:"scopes"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
CodeChallenge string `json:"code_challenge"`
|
||||
}
|
||||
|
||||
func authCodeKey(code string) string {
|
||||
return authCodeKeyPrefix + code
|
||||
}
|
||||
|
||||
func init() {
|
||||
gob.Register(&AuthorizationCode{})
|
||||
}
|
||||
Reference in New Issue
Block a user