initial namecrane backend

This commit is contained in:
Tyler Stuyfzand 2025-04-06 15:54:27 -04:00
parent e6f82ef3f2
commit e9da24a89c
3 changed files with 282 additions and 96 deletions

View File

@ -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"
}

View File

@ -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, "/")

View File

@ -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
}