chore: 初始化 Cloudreve 源码仓库

This commit is contained in:
2026-09-13 11:28:59 +08:00
commit 2a7b03ab1d
489 changed files with 163867 additions and 0 deletions
+227
View File
@@ -0,0 +1,227 @@
package auth
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"sort"
"strings"
"time"
"github.com/cloudreve/Cloudreve/v4/application/constants"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
)
var (
ErrAuthFailed = serializer.NewError(serializer.CodeInvalidSign, "invalid sign", nil)
ErrAuthHeaderMissing = serializer.NewError(serializer.CodeNoPermissionErr, "authorization header is missing", nil)
ErrExpiresMissing = serializer.NewError(serializer.CodeNoPermissionErr, "expire timestamp is missing", nil)
ErrExpired = serializer.NewError(serializer.CodeSignExpired, "signature expired", nil)
)
const (
TokenHeaderPrefixCr = "Bearer Cr "
)
// General 通用的认证接口
// Deprecated
var General Auth
type (
// Auth 鉴权认证
Auth interface {
// 对给定Body进行签名,expires为0表示永不过期
Sign(body string, expires int64) string
// 对给定Body和Sign进行检查
Check(body string, sign string) error
}
)
// SignRequest 对PUT\POST等复杂HTTP请求签名,只会对URI部分、
// 请求正文、`X-Cr-`开头的header进行签名
func SignRequest(ctx context.Context, instance Auth, r *http.Request, expires *time.Time) *http.Request {
// 处理有效期
expireTime := int64(0)
if expires != nil {
expireTime = expires.Unix()
}
// 生成签名
sign := instance.Sign(getSignContent(ctx, r), expireTime)
// 将签名加到请求Header中
r.Header["Authorization"] = []string{TokenHeaderPrefixCr + sign}
return r
}
// SignRequestDeprecated 对PUT\POST等复杂HTTP请求签名,只会对URI部分、
// 请求正文、`X-Cr-`开头的header进行签名
func SignRequestDeprecated(instance Auth, r *http.Request, expires int64) *http.Request {
// 处理有效期
if expires > 0 {
expires += time.Now().Unix()
}
// 生成签名
sign := instance.Sign(getSignContent(context.Background(), r), expires)
// 将签名加到请求Header中
r.Header["Authorization"] = []string{TokenHeaderPrefixCr + sign}
return r
}
// CheckRequest 对复杂请求进行签名验证
func CheckRequest(ctx context.Context, instance Auth, r *http.Request) error {
var (
sign []string
ok bool
)
if sign, ok = r.Header["Authorization"]; !ok || len(sign) == 0 {
return ErrAuthHeaderMissing
}
sign[0] = strings.TrimPrefix(sign[0], TokenHeaderPrefixCr)
return instance.Check(getSignContent(ctx, r), sign[0])
}
func isUploadDataRequest(r *http.Request) bool {
return strings.Contains(r.URL.Path, constants.APIPrefix+"/slave/upload/") && r.Method != http.MethodPut
}
// getSignContent 签名请求 path、正文、以`X-`开头的 Header. 如果请求 path 为从机上传 API,
// 则不对正文签名。返回待签名/验证的字符串
func getSignContent(ctx context.Context, r *http.Request) (rawSignString string) {
// 读取所有body正文
var body = []byte{}
if !isUploadDataRequest(r) {
if r.Body != nil {
body, _ = io.ReadAll(r.Body)
_ = r.Body.Close()
r.Body = io.NopCloser(bytes.NewReader(body))
}
}
// 决定要签名的header
var signedHeader []string
for k, _ := range r.Header {
if strings.HasPrefix(k, constants.CrHeaderPrefix) && k != constants.CrHeaderPrefix+"Filename" {
signedHeader = append(signedHeader, fmt.Sprintf("%s=%s", k, r.Header.Get(k)))
}
}
sort.Strings(signedHeader)
// 读取所有待签名Header
rawSignString = serializer.NewRequestSignString(getUrlSignContent(ctx, r.URL), strings.Join(signedHeader, "&"), string(body))
return rawSignString
}
// SignURI 对URI进行签名
func SignURI(ctx context.Context, instance Auth, uri string, expires *time.Time) (*url.URL, error) {
// 处理有效期
expireTime := int64(0)
if expires != nil {
expireTime = expires.Unix()
}
base, err := url.Parse(uri)
if err != nil {
return nil, err
}
// 生成签名
sign := instance.Sign(getUrlSignContent(ctx, base), expireTime)
// 将签名加到URI中
queries := base.Query()
queries.Set("sign", sign)
base.RawQuery = queries.Encode()
return base, nil
}
// SignURIDeprecated 对URI进行签名,签名只针对Path部分,query部分不做验证
// Deprecated
func SignURIDeprecated(instance Auth, uri string, expires int64) (*url.URL, error) {
// 处理有效期
if expires != 0 {
expires += time.Now().Unix()
}
base, err := url.Parse(uri)
if err != nil {
return nil, err
}
// 生成签名
sign := instance.Sign(base.Path, expires)
// 将签名加到URI中
queries := base.Query()
queries.Set("sign", sign)
base.RawQuery = queries.Encode()
return base, nil
}
// CheckURI 对URI进行鉴权
func CheckURI(ctx context.Context, instance Auth, url *url.URL) error {
//获取待验证的签名正文
queries := url.Query()
sign := queries.Get("sign")
queries.Del("sign")
url.RawQuery = queries.Encode()
return instance.Check(getUrlSignContent(ctx, url), sign)
}
func RedactSensitiveValues(errorMessage string) string {
// Regular expression to match URLs
urlRegex := regexp.MustCompile(`https?://[^\s]+`)
// Find all URLs in the error message
urls := urlRegex.FindAllString(errorMessage, -1)
for _, urlStr := range urls {
// Parse the URL
parsedURL, err := url.Parse(urlStr)
if err != nil {
continue
}
// Get the query parameters
queryParams := parsedURL.Query()
// Redact the 'sign' parameter if it exists
if _, exists := queryParams["sign"]; exists {
queryParams.Set("sign", "REDACTED")
parsedURL.RawQuery = queryParams.Encode()
}
// Replace the original URL with the redacted one in the error message
errorMessage = strings.Replace(errorMessage, urlStr, parsedURL.String(), -1)
}
return errorMessage
}
func getUrlSignContent(ctx context.Context, url *url.URL) string {
// host := url.Host
// if host == "" {
// reqInfo := requestinfo.RequestInfoFromContext(ctx)
// if reqInfo != nil {
// host = reqInfo.Host
// }
// }
// host = strings.TrimSuffix(host, "/")
// // remove port if it exists
// host = strings.Split(host, ":")[0]
if url.Path == "" {
return "/"
}
return url.Path
}
+57
View File
@@ -0,0 +1,57 @@
package auth
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"io"
"strconv"
"strings"
"time"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
)
// HMACAuth HMAC算法鉴权
type HMACAuth struct {
SecretKey []byte
}
// Sign 对给定Body生成expires后失效的签名,expires为过期时间戳,
// 填写为0表示不限制有效期
func (auth HMACAuth) Sign(body string, expires int64) string {
h := hmac.New(sha256.New, auth.SecretKey)
expireTimeStamp := strconv.FormatInt(expires, 10)
_, err := io.WriteString(h, body+":"+expireTimeStamp)
if err != nil {
return ""
}
return base64.URLEncoding.EncodeToString(h.Sum(nil)) + ":" + expireTimeStamp
}
// Check 对给定Body和Sign进行鉴权,包括对expires的检查
func (auth HMACAuth) Check(body string, sign string) error {
signSlice := strings.Split(sign, ":")
// 如果未携带expires字段
if signSlice[len(signSlice)-1] == "" {
return ErrExpiresMissing
}
// 验证是否过期
expires, err := strconv.ParseInt(signSlice[len(signSlice)-1], 10, 64)
if err != nil {
return serializer.NewError(serializer.CodeInvalidSign, "sign expired", nil)
}
// 如果签名过期
if expires < time.Now().Unix() && expires != 0 {
return ErrExpired
}
// 验证签名
if subtle.ConstantTimeCompare([]byte(auth.Sign(body, expires)), []byte(sign)) != 1 {
return serializer.NewError(serializer.CodeInvalidSign, "invalid sign", nil)
}
return nil
}
+357
View File
@@ -0,0 +1,357 @@
package auth
import (
"bytes"
"context"
"crypto/sha256"
"errors"
"fmt"
"strings"
"time"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/pkg/cache"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gin-gonic/gin"
"github.com/gofrs/uuid"
"github.com/golang-jwt/jwt/v5"
)
type TokenAuth interface {
// Issue issues a new pair of credentials for the given user.
Issue(ctx context.Context, args *IssueTokenArgs) (*Token, error)
// VerifyAndRetrieveUser verifies the given token and inject the user into current context.
// Returns if upper caller should continue process other session provider.
VerifyAndRetrieveUser(c *gin.Context) (bool, error)
// Refresh refreshes the given refresh token and returns a new pair of credentials.
Refresh(ctx context.Context, refreshToken string) (*Token, error)
// Claims parses the given token string and returns the claims.
Claims(ctx context.Context, tokenStr string) (*Claims, error)
}
type IssueTokenArgs struct {
User *ent.User
RootTokenID *uuid.UUID
ClientID string
Scopes []string
RefreshTTLOverride time.Duration
}
// Token stores token pair for authentication
type Token struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
AccessExpires time.Time `json:"access_expires"`
RefreshExpires time.Time `json:"refresh_expires"`
UID int `json:"-"`
}
type (
TokenType string
TokenIDContextKey struct{}
ScopeContextKey struct{}
)
var (
TokenTypeAccess = TokenType("access")
TokenTypeRefresh = TokenType("refresh")
ErrInvalidRefreshToken = errors.New("invalid refresh token")
ErrUserNotFound = errors.New("user not found")
)
const (
AuthorizationHeader = "Authorization"
TokenHeaderPrefix = "Bearer "
RevokeTokenPrefix = "jwt_revoke_"
)
type Claims struct {
TokenType TokenType `json:"token_type"`
jwt.RegisteredClaims
StateHash []byte `json:"state_hash,omitempty"`
RootTokenID *uuid.UUID `json:"root_token_id,omitempty"`
Scopes []string `json:"scopes,omitempty"`
ClientID string `json:"client_id,omitempty"`
}
// NewTokenAuth creates a new token based auth provider.
func NewTokenAuth(idEncoder hashid.Encoder, s setting.Provider, secret []byte, userClient inventory.UserClient,
l logging.Logger, kv cache.Driver, oAuthClient inventory.OAuthClientClient) TokenAuth {
return &tokenAuth{
idEncoder: idEncoder,
s: s,
secret: secret,
userClient: userClient,
l: l,
kv: kv,
oAuthClient: oAuthClient,
}
}
type tokenAuth struct {
l logging.Logger
idEncoder hashid.Encoder
s setting.Provider
secret []byte
userClient inventory.UserClient
oAuthClient inventory.OAuthClientClient
kv cache.Driver
}
func (t *tokenAuth) Claims(ctx context.Context, tokenStr string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return t.secret, nil
})
if err != nil {
return nil, fmt.Errorf("invalid token: %w", err)
}
claims, ok := token.Claims.(*Claims)
if !ok {
return nil, fmt.Errorf("invalid token claims")
}
return claims, nil
}
func (t *tokenAuth) Refresh(ctx context.Context, refreshToken string) (*Token, error) {
token, err := jwt.ParseWithClaims(refreshToken, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return t.secret, nil
})
if err != nil {
return nil, fmt.Errorf("invalid refresh token: %w", err)
}
claims, ok := token.Claims.(*Claims)
if !ok || claims.TokenType != TokenTypeRefresh {
return nil, ErrInvalidRefreshToken
}
uid, err := t.idEncoder.Decode(claims.Subject, hashid.UserID)
if err != nil {
return nil, ErrUserNotFound
}
expectedUser, err := t.userClient.GetActiveByID(ctx, uid)
if err != nil {
return nil, ErrUserNotFound
}
// Check if user changed password or revoked session
expectedHash := t.hashUserState(ctx, expectedUser)
if !bytes.Equal(claims.StateHash, expectedHash[:]) {
return nil, ErrInvalidRefreshToken
}
// Check if root token is revoked
if claims.RootTokenID == nil {
return nil, ErrInvalidRefreshToken
}
_, ok = t.kv.Get(fmt.Sprintf("%s%s", RevokeTokenPrefix, claims.RootTokenID.String()))
if ok {
return nil, ErrInvalidRefreshToken
}
// If token issued for an OAuth client, check if the client is still valid
refreshTTLOverride := time.Duration(0)
if claims.ClientID != "" {
client, err := t.oAuthClient.GetByGUIDWithGrants(ctx, claims.ClientID, expectedUser.ID)
if err != nil || len(client.Edges.Grants) == 0 {
return nil, ErrInvalidRefreshToken
}
// Consented scopes must be a subset of the client's scopes
if !ValidateScopes(claims.Scopes, client.Edges.Grants[0].Scopes) {
return nil, ErrInvalidRefreshToken
}
// Update last used at for the grant
if err := t.oAuthClient.UpdateGrantLastUsedAt(ctx, expectedUser.ID, client.ID); err != nil {
return nil, ErrInvalidRefreshToken
}
if client.Props != nil {
refreshTTLOverride = time.Duration(client.Props.RefreshTokenTTL) * time.Second
}
}
return t.Issue(ctx, &IssueTokenArgs{
User: expectedUser,
RootTokenID: claims.RootTokenID,
Scopes: claims.Scopes,
ClientID: claims.ClientID,
RefreshTTLOverride: refreshTTLOverride,
})
}
func (t *tokenAuth) VerifyAndRetrieveUser(c *gin.Context) (bool, error) {
headerVal := c.GetHeader(AuthorizationHeader)
if strings.HasPrefix(headerVal, TokenHeaderPrefixCr) {
// This is an HMAC auth header, skip JWT verification
return false, nil
}
tokenString := strings.TrimPrefix(headerVal, TokenHeaderPrefix)
if tokenString == "" {
return true, nil
}
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return t.secret, nil
})
if err != nil {
t.l.Warning("Failed to parse jwt token: %s", err)
return false, nil
}
claims, ok := token.Claims.(*Claims)
if !ok || claims.TokenType != TokenTypeAccess {
return false, serializer.NewError(serializer.CodeCredentialInvalid, "Invalid token type", nil)
}
uid, err := t.idEncoder.Decode(claims.Subject, hashid.UserID)
if err != nil {
return false, serializer.NewError(serializer.CodeNotFound, "User not found", err)
}
util.WithValue(c, inventory.UserIDCtx{}, uid)
if claims.ClientID != "" {
util.WithValue(c, ScopeContextKey{}, claims.Scopes)
}
return false, nil
}
func (t *tokenAuth) Issue(ctx context.Context, args *IssueTokenArgs) (*Token, error) {
u := args.User
rootTokenID := args.RootTokenID
uidEncoded := hashid.EncodeUserID(t.idEncoder, u.ID)
tokenSettings := t.s.TokenAuth(ctx)
issueDate := time.Now()
accessTokenExpired := time.Now().Add(tokenSettings.AccessTokenTTL)
refreshTokenExpired := time.Now().Add(tokenSettings.RefreshTokenTTL)
if args.RefreshTTLOverride > 0 {
refreshTokenExpired = time.Now().Add(args.RefreshTTLOverride)
}
if rootTokenID == nil {
newRootTokenID := uuid.Must(uuid.NewV4())
rootTokenID = &newRootTokenID
}
accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{
TokenType: TokenTypeAccess,
RegisteredClaims: jwt.RegisteredClaims{
Subject: uidEncoded,
NotBefore: jwt.NewNumericDate(issueDate),
ExpiresAt: jwt.NewNumericDate(accessTokenExpired),
},
ClientID: args.ClientID,
Scopes: args.Scopes,
}).SignedString(t.secret)
if err != nil {
return nil, fmt.Errorf("faield to sign access token: %w", err)
}
userHash := t.hashUserState(ctx, u)
refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{
TokenType: TokenTypeRefresh,
RootTokenID: rootTokenID,
RegisteredClaims: jwt.RegisteredClaims{
Subject: uidEncoded,
NotBefore: jwt.NewNumericDate(issueDate),
ExpiresAt: jwt.NewNumericDate(refreshTokenExpired),
},
Scopes: args.Scopes,
ClientID: args.ClientID,
StateHash: userHash[:],
}).SignedString(t.secret)
if err != nil {
return nil, fmt.Errorf("faield to sign refresh token: %w", err)
}
return &Token{
AccessToken: accessToken,
RefreshToken: refreshToken,
AccessExpires: accessTokenExpired,
RefreshExpires: refreshTokenExpired,
UID: u.ID,
}, nil
}
// hashUserState returns a hash string for user state for critical fields, it is used
// to detect refresh token revocation after user changed password.
func (t *tokenAuth) hashUserState(ctx context.Context, u *ent.User) [32]byte {
return sha256.Sum256([]byte(fmt.Sprintf("%s/%s/%s", u.Email, u.Password, t.s.SiteBasic(ctx).ID)))
}
// ValidateScopes checks if all requested scopes are a subset of the allowed scopes.
// Returns true if all requested scopes are valid, false otherwise.
func ValidateScopes(requestedScopes, allowedScopes []string) bool {
allowed := make(map[string]struct{}, len(allowedScopes))
for _, scope := range allowedScopes {
allowed[scope] = struct{}{}
}
for _, scope := range requestedScopes {
if _, ok := allowed[scope]; !ok {
return false
}
}
return true
}
func GetScopesFromContext(ctx context.Context) (bool, []string) {
scopes, ok := ctx.Value(ScopeContextKey{}).([]string)
if !ok {
return false, nil
}
return true, scopes
}
func CheckScope(c *gin.Context, requiredScopes ...string) error {
hasScopes, tokenScopes := GetScopesFromContext(c)
if !hasScopes {
return nil
}
// Build a set of token scopes including implicit read permissions from write scopes
scopeSet := make(map[string]struct{}, len(tokenScopes)*2)
for _, scope := range tokenScopes {
scopeSet[scope] = struct{}{}
// If scope is "xxx.Write", also grant "xxx.Read"
if resource, ok := extractWriteResource(scope); ok {
scopeSet[resource+".Read"] = struct{}{}
}
}
// Check if all required scopes are present
for _, required := range requiredScopes {
if _, ok := scopeSet[required]; !ok {
return serializer.NewError(serializer.CodeInsufficientScope,
"Insufficient scope: "+required, nil)
}
}
return nil
}
// extractWriteResource extracts the resource name from a write scope.
// For example, "File.Write" returns ("File", true), "File.Read" returns ("", false).
func extractWriteResource(scope string) (string, bool) {
const writeSuffix = ".Write"
if len(scope) > len(writeSuffix) && scope[len(scope)-len(writeSuffix):] == writeSuffix {
return scope[:len(scope)-len(writeSuffix)], true
}
return "", false
}
+27
View File
@@ -0,0 +1,27 @@
package requestinfo
import (
"context"
)
// RequestInfoCtx context key for RequestInfo
type RequestInfoCtx struct{}
// RequestInfoFromContext retrieves RequestInfo from context
func RequestInfoFromContext(ctx context.Context) *RequestInfo {
v, ok := ctx.Value(RequestInfoCtx{}).(*RequestInfo)
if !ok {
return nil
}
return v
}
// RequestInfo store request info for audit
type RequestInfo struct {
Host string
IP string
UserAgent string
// ID of sync client
ClientID string
}