mirror of
https://github.com/rclone/rclone.git
synced 2025-04-19 01:59:00 +08:00
initial namecrane backend
This commit is contained in:
parent
e6f82ef3f2
commit
e9da24a89c
@ -1,103 +1,235 @@
|
||||
package namecrane
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"github.com/rclone/rclone/fs"
|
||||
"github.com/rclone/rclone/fs/config/configmap"
|
||||
"github.com/rclone/rclone/fs/config/obscure"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoToken = errors.New("could not find access token")
|
||||
ErrExpiredRefreshToken = errors.New("refresh token expired")
|
||||
)
|
||||
|
||||
const (
|
||||
accessTokenKey = "access_token"
|
||||
accessTokenExpireKey = "access_token_expires"
|
||||
refreshTokenKey = "refresh_token"
|
||||
refreshTokenExpireKey = "refresh_token_expires"
|
||||
)
|
||||
|
||||
// AuthManager manages the authentication token.
|
||||
type AuthManager struct {
|
||||
mu sync.Mutex
|
||||
token string
|
||||
expiresAt time.Time
|
||||
client *http.Client
|
||||
apiURL string
|
||||
username string
|
||||
password string
|
||||
mu sync.Mutex
|
||||
cm configmap.Mapper
|
||||
expiresAt time.Time
|
||||
client *http.Client
|
||||
apiURL string
|
||||
lastResponse *authResponse
|
||||
}
|
||||
|
||||
// NewAuthManager initializes the AuthManager.
|
||||
func NewAuthManager(client *http.Client, apiURL, username, password string) *AuthManager {
|
||||
func NewAuthManager(client *http.Client, cm configmap.Mapper, apiURL string) *AuthManager {
|
||||
return &AuthManager{
|
||||
client: client,
|
||||
apiURL: apiURL,
|
||||
username: username,
|
||||
password: password,
|
||||
client: client,
|
||||
cm: cm,
|
||||
apiURL: apiURL,
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate obtains a new token.
|
||||
func (am *AuthManager) Authenticate(ctx context.Context) error {
|
||||
am.mu.Lock()
|
||||
defer am.mu.Unlock()
|
||||
type authRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
TwoFactorCode string `json:"twoFactorCode"`
|
||||
}
|
||||
|
||||
// If the token is still valid, skip re-authentication
|
||||
if time.Now().Before(am.expiresAt) {
|
||||
type authResponse struct {
|
||||
Username string `json:"username"`
|
||||
Token string `json:"accessToken"`
|
||||
TokenExpiration time.Time `json:"accessTokenExpiration"` // Token expiration datetime
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
RefreshTokenExpiration time.Time `json:"refreshTokenExpiration"`
|
||||
}
|
||||
|
||||
func (am *AuthManager) fillFromConfigMapper() error {
|
||||
fs.Debugf(am, "Filling last response value from config mapper")
|
||||
|
||||
var response authResponse
|
||||
var ok bool
|
||||
var err error
|
||||
|
||||
if response.Token, ok = am.cm.Get(accessTokenKey); !ok {
|
||||
fs.Debugf(am, "Token not found in config mapper")
|
||||
return nil
|
||||
} else {
|
||||
response.Token, err = obscure.Reveal(response.Token)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if tokenExpiration, ok := am.cm.Get(accessTokenExpireKey); ok {
|
||||
response.TokenExpiration, err = time.Parse(time.RFC3339, tokenExpiration)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
fs.Debugf(am, "Token expiration not found in config mapper")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Construct the API URL for authentication
|
||||
url := fmt.Sprintf("%s/api/v1/auth/authenticate-user", am.apiURL)
|
||||
if response.RefreshToken, ok = am.cm.Get(refreshTokenKey); !ok {
|
||||
fs.Debugf(am, "Refresh token not found in config mapper")
|
||||
return nil
|
||||
} else {
|
||||
response.RefreshToken, err = obscure.Reveal(response.RefreshToken)
|
||||
|
||||
// Prepare the request body
|
||||
requestBody := map[string]string{
|
||||
"username": am.username,
|
||||
"password": am.password,
|
||||
}
|
||||
jsonBody, err := json.Marshal(requestBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal authentication body: %w", err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Create the HTTP request
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create authentication request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if refreshTokenExpiration, ok := am.cm.Get(refreshTokenExpireKey); ok {
|
||||
var err error
|
||||
response.RefreshTokenExpiration, err = time.Parse(time.RFC3339, refreshTokenExpiration)
|
||||
|
||||
// Execute the request
|
||||
resp, err := am.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("authentication request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("authentication failed, status: %d, response: %s", resp.StatusCode, string(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
fs.Debugf(am, "Refresh token expiration not found in config mapper")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse the response
|
||||
var response struct {
|
||||
Token string `json:"accessToken"`
|
||||
ExpiresIn string `json:"accessTokenExpiration"` // Token expiration datetime
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
|
||||
return fmt.Errorf("failed to decode authentication response: %w", err)
|
||||
}
|
||||
fs.Debugf(am, "All information found and filled")
|
||||
|
||||
// Store the token and expiration time
|
||||
am.token = response.Token
|
||||
expiresAt, err := time.Parse(time.RFC3339, response.ExpiresIn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse token expiration time: %w", err)
|
||||
}
|
||||
am.expiresAt = expiresAt
|
||||
am.lastResponse = &response
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Authenticate obtains a new token.
|
||||
func (am *AuthManager) Authenticate(ctx context.Context, username, password, twoFactorCode string) error {
|
||||
fs.Debugf(am, "Trying to authenticate user")
|
||||
|
||||
am.mu.Lock()
|
||||
defer am.mu.Unlock()
|
||||
|
||||
// Construct the API URL for authentication
|
||||
url := fmt.Sprintf("%s/api/v1/auth/authenticate-user", am.apiURL)
|
||||
|
||||
res, err := doHttpRequest(ctx, am.client, http.MethodPost, url, authRequest{
|
||||
Username: username,
|
||||
Password: password,
|
||||
TwoFactorCode: twoFactorCode,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status code %d", res.StatusCode)
|
||||
}
|
||||
|
||||
// Parse the response
|
||||
var response authResponse
|
||||
|
||||
if err := res.Decode(&response); err != nil {
|
||||
return fmt.Errorf("failed to decode authenteication response: %w", err)
|
||||
}
|
||||
|
||||
// Store the token and expiration time
|
||||
am.lastResponse = &response
|
||||
|
||||
am.updateConfigMapper(response)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type refreshRequest struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func (am *AuthManager) RefreshToken(ctx context.Context) error {
|
||||
fs.Debugf(am, "Trying to refresh token")
|
||||
|
||||
am.mu.Lock()
|
||||
defer am.mu.Unlock()
|
||||
|
||||
url := fmt.Sprintf("%s/api/v1/auth/refresh-token", am.apiURL)
|
||||
|
||||
res, err := doHttpRequest(ctx, am.client, http.MethodPost, url, refreshRequest{
|
||||
Token: am.lastResponse.RefreshToken,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status code %d", res.StatusCode)
|
||||
}
|
||||
|
||||
var response authResponse
|
||||
|
||||
if err := res.Decode(&response); err != nil {
|
||||
return fmt.Errorf("failed to decode refresh response: %w", err)
|
||||
}
|
||||
|
||||
am.lastResponse = &response
|
||||
|
||||
am.updateConfigMapper(response)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateConfigMapper stores data into the config map
|
||||
func (am *AuthManager) updateConfigMapper(response authResponse) {
|
||||
fs.Debugf(am, "Updating config mapper values")
|
||||
|
||||
am.cm.Set(accessTokenKey, obscure.MustObscure(response.Token))
|
||||
am.cm.Set(accessTokenExpireKey, response.TokenExpiration.Format(time.RFC3339))
|
||||
am.cm.Set(refreshTokenKey, obscure.MustObscure(response.RefreshToken))
|
||||
am.cm.Set(refreshTokenExpireKey, response.RefreshTokenExpiration.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// GetToken ensures the token is valid and returns it.
|
||||
func (am *AuthManager) GetToken(ctx context.Context) (string, error) {
|
||||
if err := am.Authenticate(ctx); err != nil {
|
||||
return "", err
|
||||
if am.lastResponse == nil || am.lastResponse.Token == "" {
|
||||
fs.Debugf(am, "No token set in AuthManager")
|
||||
return "", ErrNoToken
|
||||
}
|
||||
return am.token, nil
|
||||
|
||||
// Handle if we can't use our refresh token
|
||||
if am.lastResponse.RefreshTokenExpiration.Before(time.Now()) {
|
||||
fs.Debugf(am, "Refresh token expired")
|
||||
return "", ErrExpiredRefreshToken
|
||||
}
|
||||
|
||||
// Give us a 5 minute grace period to prevent race conditions/issues
|
||||
if am.lastResponse.TokenExpiration.Before(time.Now().Add(5 * time.Minute)) {
|
||||
fs.Debugf(am, "Access token expires soon, need to refresh")
|
||||
|
||||
// Refresh token
|
||||
if err := am.RefreshToken(ctx); err != nil {
|
||||
return "", fmt.Errorf("failed to refresh token: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
fs.Debug(am, "Using existing token")
|
||||
|
||||
return am.lastResponse.Token, nil
|
||||
}
|
||||
|
||||
func (am *AuthManager) String() string {
|
||||
return "Namecrane Auth Manager"
|
||||
}
|
||||
|
@ -170,7 +170,7 @@ func (n *Namecrane) Upload(ctx context.Context, in io.Reader, filePath string, f
|
||||
fileName := path.Base(filePath)
|
||||
|
||||
// encode brackets, fixing bug within uploader
|
||||
// fileName = url.PathEscape(fileName)
|
||||
// fileName = url.PathEscape(fileName)
|
||||
|
||||
basePath := path.Dir(filePath)
|
||||
|
||||
@ -560,19 +560,7 @@ func WithHeader(key, value string) RequestOpt {
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Namecrane) doRequest(ctx context.Context, method, path string, body any, opts ...RequestOpt) (*Response, error) {
|
||||
token, err := n.authManager.GetToken(ctx)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve token: %w", err)
|
||||
}
|
||||
|
||||
apiUrl, err := n.apiUrl(path)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func doHttpRequest(ctx context.Context, client *http.Client, method, u string, body any, opts ...RequestOpt) (*Response, error) {
|
||||
var bodyReader io.Reader
|
||||
var jsonBody bool
|
||||
|
||||
@ -600,20 +588,18 @@ func (n *Namecrane) doRequest(ctx context.Context, method, path string, body any
|
||||
case http.MethodGet:
|
||||
switch v := body.(type) {
|
||||
case *url.Values:
|
||||
apiUrl += "?" + v.Encode()
|
||||
u += "?" + v.Encode()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the HTTP request
|
||||
req, err := http.NewRequestWithContext(ctx, method, apiUrl, bodyReader)
|
||||
req, err := http.NewRequestWithContext(ctx, method, u, bodyReader)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create rmdir request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||
|
||||
if jsonBody {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
@ -624,7 +610,7 @@ func (n *Namecrane) doRequest(ctx context.Context, method, path string, body any
|
||||
}
|
||||
|
||||
// Execute the HTTP request
|
||||
resp, err := n.client.Do(req)
|
||||
resp, err := client.Do(req)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to execute rmdir request: %w", err)
|
||||
@ -635,6 +621,26 @@ func (n *Namecrane) doRequest(ctx context.Context, method, path string, body any
|
||||
}, err
|
||||
}
|
||||
|
||||
func (n *Namecrane) doRequest(ctx context.Context, method, path string, body any, opts ...RequestOpt) (*Response, error) {
|
||||
ctx = context.WithValue(ctx, "httpClient", n.client)
|
||||
|
||||
token, err := n.authManager.GetToken(ctx)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve token: %w", err)
|
||||
}
|
||||
|
||||
opts = append(opts, WithHeader("Authorization", "Bearer "+token))
|
||||
|
||||
apiUrl, err := n.apiUrl(path)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return doHttpRequest(ctx, n.client, method, apiUrl, body, opts...)
|
||||
}
|
||||
|
||||
// parsePath parses the last segment off the specified path, representing either a file or directory
|
||||
func (n *Namecrane) parsePath(path string) (basePath, lastSegment string) {
|
||||
trimmedPath := strings.Trim(path, "/")
|
||||
|
@ -51,6 +51,7 @@ type Options struct {
|
||||
ApiURL string `config:"api_url"`
|
||||
Username string `config:"username"`
|
||||
Password string `config:"password"`
|
||||
TwoFA string `config:"2fa"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
@ -59,20 +60,56 @@ func init() {
|
||||
Description: "NameCrane Mail File Storage",
|
||||
NewFs: NewFs,
|
||||
Options: []fs.Option{{
|
||||
Name: "api_url",
|
||||
Help: `NameCrane API URL, like https://us1.workspace.org`,
|
||||
Default: "https://us1.workspace.org",
|
||||
Sensitive: true,
|
||||
Name: "api_url",
|
||||
Help: `NameCrane API URL, like https://us1.workspace.org`,
|
||||
Default: "https://us1.workspace.org",
|
||||
}, {
|
||||
Name: "username",
|
||||
Help: `NameCrane username`,
|
||||
Required: true,
|
||||
Sensitive: true,
|
||||
Name: "username",
|
||||
Help: `NameCrane username`,
|
||||
Required: true,
|
||||
}, {
|
||||
Name: "password",
|
||||
Help: `NameCrane password`,
|
||||
Name: "password",
|
||||
Help: `NameCrane password
|
||||
|
||||
Only required for the first auth, subsequent requests re-use the access/refresh token`,
|
||||
Required: true,
|
||||
IsPassword: true,
|
||||
}, {
|
||||
Name: "2fa",
|
||||
Help: `Two Factor Authentication Code
|
||||
|
||||
Can be supplied with --namecrane-2fa=CODE when using any command for the first auth`,
|
||||
Required: false,
|
||||
}, {
|
||||
Name: accessTokenKey,
|
||||
Help: "Access token (internal only)",
|
||||
Required: false,
|
||||
Advanced: true,
|
||||
Sensitive: true,
|
||||
IsPassword: true,
|
||||
Hide: fs.OptionHideBoth,
|
||||
}, {
|
||||
Name: accessTokenExpireKey,
|
||||
Help: "Access token expiration (internal only)",
|
||||
Required: false,
|
||||
Advanced: true,
|
||||
Sensitive: true,
|
||||
Hide: fs.OptionHideBoth,
|
||||
}, {
|
||||
Name: refreshTokenKey,
|
||||
Help: "Refresh token (internal only)",
|
||||
Required: false,
|
||||
Advanced: true,
|
||||
Sensitive: true,
|
||||
IsPassword: true,
|
||||
Hide: fs.OptionHideBoth,
|
||||
}, {
|
||||
Name: refreshTokenExpireKey,
|
||||
Help: "Refresh token expiration (internal only)",
|
||||
Required: false,
|
||||
Advanced: true,
|
||||
Sensitive: true,
|
||||
Hide: fs.OptionHideBoth,
|
||||
}},
|
||||
})
|
||||
}
|
||||
@ -96,9 +133,20 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
|
||||
root = "/"
|
||||
}
|
||||
|
||||
authManager := NewAuthManager(http.DefaultClient, opt.ApiURL, opt.Username, opt.Password)
|
||||
authManager := NewAuthManager(http.DefaultClient, m, opt.ApiURL)
|
||||
|
||||
if _, err := authManager.GetToken(ctx); err != nil {
|
||||
authManager.fillFromConfigMapper()
|
||||
|
||||
if _, err := authManager.GetToken(ctx); errors.Is(err, ErrNoToken) {
|
||||
if opt.Username != "" && opt.Password != "" {
|
||||
err = authManager.Authenticate(ctx, opt.Username, opt.Password, opt.TwoFA)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to authenticate: %w", err)
|
||||
}
|
||||
}
|
||||
} else if err != nil {
|
||||
// Other error occurred, potentially needing a re-login
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user