You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
cloudreve/service/oauth/oauth.go

310 lines
9.7 KiB

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"
)
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)
}
// 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
}